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
@@ -114,16 +114,28 @@ module Rubino
114
114
  @idle_cards ||= Chat::IdleCardHost.new
115
115
  end
116
116
 
117
- # Auto-opens the EXISTING approval / reply prompt for ONE pending subagent
118
- # request the human must act on, from the idle poll loop (#421). Delegates
119
- # to the SAME Handlers::Agents the /agents and /reply slash commands use, so
120
- # there is no second prompt or new verb — the affordance simply opens itself
121
- # at idle instead of waiting for the user to type a slash command. Returns
117
+ # Auto-opens the EXISTING approval prompt for ONE pending subagent request
118
+ # the human must act on, from the idle poll loop (#421). Delegates to the
119
+ # SAME Handlers::Agents the /agents slash command uses, so there is no
120
+ # second prompt or new verb — the affordance simply opens itself at idle
121
+ # instead of waiting for the user to type a slash command. Returns
122
122
  # true when it presented a request (the poll loop repaints + re-checks),
123
123
  # false when nothing was pending. Best-effort: a hiccup in the auto-open
124
124
  # must never break the idle prompt, so it falls back to "nothing pending"
125
125
  # and the manual slash paths still work.
126
126
  def auto_resolve_pending_subagent_request(_runner = nil)
127
+ # Defer while the subagent PICKER is open (#586): the picker and this
128
+ # blocking approval/budget modal compete for the same stdin. A child
129
+ # hammering tool calls re-hits its budget gate every few ticks, so
130
+ # auto-firing the `wants +budget` modal here would suspend the open picker
131
+ # and swallow the ↓/Enter the user meant to ATTACH with — worse, the
132
+ # picker's ↓+Enter gesture could land on the modal's destructive
133
+ # "Summarize now". The request stays pending and auto-presents on the very
134
+ # next idle tick once the picker closes: deferred a few seconds, never
135
+ # lost — it still appears like a permission, in arrival order. (@composer
136
+ # is nil on the piped/one-shot paths, which have no picker.)
137
+ return false if @composer&.agent_menu_open?
138
+
127
139
  agents_request_handler.auto_resolve_pending
128
140
  rescue StandardError => e
129
141
  # Resilience floor: a hiccup in the auto-open must never crash the idle
@@ -276,7 +288,7 @@ module Rubino
276
288
  text, image_paths = Chat::ImageInbox.resolve_oneshot(query, opt(:image))
277
289
  requested_session_id = session_resolver.resolve_session_id
278
290
  runner = build_runner(session_id: requested_session_id, ui: ui,
279
- announce_session: announce_session)
291
+ announce_session: announce_session, interactive: false)
280
292
  warn_if_resume_forked(requested_session_id, runner)
281
293
  note_if_resuming_compacted_parent(runner)
282
294
  recorder = Output::TurnRecorder.new.attach!
@@ -973,6 +985,31 @@ module Rubino
973
985
  # the loop commits as normal messages when their turn runs.
974
986
  @pending_queued = []
975
987
 
988
+ # ONE BottomComposer per interactive session (BUG 02). The REPL used to
989
+ # build a FRESH composer for EVERY idle read AND every turn and #start /
990
+ # #stop it each time; each new/start→stop cycle churned native-backed
991
+ # state (the raw-mode/IO.console buffers, the reader thread + self/wake
992
+ # pipes, the escape-reader buffers, the frame strings) that malloc freed
993
+ # but never returned to the OS — so RSS climbed superlinearly with the
994
+ # turn count. Construct it ONCE here, #start it once (the reader thread,
995
+ # pipes and raw-mode entry are now allocated a single time and stay live
996
+ # for the whole session), and swap $stdout for the proxy once. Each phase
997
+ # (idle read ↔ run_turn) only RECONFIGURES the per-phase hooks/echo/prompt
998
+ # (#reconfigure) and brackets a turn with #begin_turn / #end_turn — no more
999
+ # per-turn native alloc/free. Torn down ONCE in the ensure below. nil when
1000
+ # the composer can't run (piped / -q / non-TTY): the cooked fallback path
1001
+ # in #next_input is unchanged there.
1002
+ @composer = start_session_composer(input_queue, runner)
1003
+ @composer_stdout = nil
1004
+ if @composer
1005
+ @composer_stdout = $stdout
1006
+ # Force the lazily-built logger to bind to the REAL $stdout NOW, before
1007
+ # the swap — otherwise the first log call would build a Logger against
1008
+ # the proxy and route diagnostic lines into the chat.
1009
+ Rubino.logger
1010
+ $stdout = UI::StdoutProxy.new(@composer)
1011
+ end
1012
+
976
1013
  # Keep structured JSON log lines OUT of the raw-mode TUI (#125): for the
977
1014
  # whole interactive session the logger writes to a file in the logs dir
978
1015
  # instead of the terminal $stdout the renderer owns. A warn/info event
@@ -1020,8 +1057,8 @@ module Rubino
1020
1057
 
1021
1058
  # While ATTACHED to a subagent (the agent-view), the prompt is scoped
1022
1059
  # to it: the line NEVER runs a parent turn. A `/`-line is an
1023
- # agent-scoped command; anything else steers the child (or answers it
1024
- # when it is blocked on you). The `--attach` command that ENTERS this
1060
+ # agent-scoped command; anything else steers the child. The `--attach`
1061
+ # command that ENTERS this
1025
1062
  # mode (from the main prompt) arrives while @attached_id is still nil,
1026
1063
  # so it falls through to normal dispatch below.
1027
1064
  if attached_to_agent?
@@ -1184,9 +1221,9 @@ module Rubino
1184
1221
  ensure
1185
1222
  # Structured-concurrency teardown: the parent REPL is leaving (clean quit
1186
1223
  # OR the double-tap Ctrl+C break above), so cancel every live subagent
1187
- # before we return. Without this a child blocked on ask_parent(blocking)
1188
- # stays parked on its gate for the full ask_parent_timeout (~900s) — the
1189
- # parent that owed it an answer is gone, but nothing wakes its gate.
1224
+ # before we return. Without this a child parked on its approval gate
1225
+ # stays parked for the full approval timeout — the parent that owed it a
1226
+ # decision is gone, but nothing wakes its gate.
1190
1227
  # #shutdown! wakes each within one WAKE_TICK so it unwinds via its
1191
1228
  # `rescue Rubino::Interrupted` with the clean "cancelled" message. If a
1192
1229
  # child is stuck in a provider read and never observes the cancel token,
@@ -1195,6 +1232,11 @@ module Rubino
1195
1232
  Tools::BackgroundTasks.instance.shutdown!
1196
1233
  restore_signal_traps(prev_signal_traps)
1197
1234
  restore_logger(prev_log_io)
1235
+ # Tear the single session composer down ONCE (BUG 02): restore the real
1236
+ # $stdout (flushing any held partial through the still-live composer),
1237
+ # then stop the reader thread / leave raw mode / restore the traps. No-op
1238
+ # when no composer ran (piped / -q).
1239
+ stop_session_composer
1198
1240
  end
1199
1241
 
1200
1242
  # Mark the session ended on a clean teardown (#100) so it stops showing
@@ -1461,7 +1503,15 @@ module Rubino
1461
1503
  # Shift+Tab, and hosts
1462
1504
  # the background-subagent card region (F1) when children are live. The
1463
1505
  # plain cooked readline is the fallback for non-TTY / piped / -q input.
1464
- if UI::BottomComposer.active?
1506
+ #
1507
+ # BUG 02: gate on @composer, NOT a live UI::BottomComposer.active? check.
1508
+ # The session composer is built once when active? was true, and $stdout is
1509
+ # now the StdoutProxy (swapped at session setup) whose #tty? is false — so
1510
+ # a fresh active? here would read FALSE and wrongly fall to the cooked
1511
+ # branch (a blocking $stdin.gets that fights the live raw reader for stdin
1512
+ # — the "dead idle prompt" regression). @composer is non-nil exactly when
1513
+ # the composer is running, so it is the correct, proxy-immune gate.
1514
+ if @composer
1465
1515
  read_idle_line(input_queue, draft, runner)
1466
1516
  else
1467
1517
  cooked_input(build_prompt, draft)
@@ -1496,20 +1546,19 @@ module Rubino
1496
1546
  # to run the clear/two-tap-exit through #idle_interrupt — declared here so
1497
1547
  # the lambda captures it.
1498
1548
  int_pending = false
1499
- composer = UI::BottomComposer.new(
1500
- input_queue: input_queue,
1549
+ # RECONFIGURE the single session composer for the IDLE prompt (BUG 02):
1550
+ # it was built + #started ONCE at session setup and $stdout already routes
1551
+ # through its proxy, so the idle read no longer allocates a fresh composer
1552
+ # or re-swaps $stdout — it only re-points the per-phase hooks/echo/prompt.
1553
+ # nil ⇒ no composer (this method isn't reached on the cooked fallback).
1554
+ composer = @composer
1555
+ composer.reconfigure(
1501
1556
  prompt: build_prompt,
1502
- rail: composer_rail,
1503
1557
  on_ctrl_o: ctrl_o_handler,
1504
1558
  on_mode_cycle: mode_cycle_handler(runner),
1505
1559
  on_agent_cycle: agent_cycle_handler(runner),
1506
- completion_source: @completion_source,
1507
- history: @input_history,
1508
1560
  echo: :prompt,
1509
- pending_queued: pending_queued,
1510
1561
  status_line: build_status_line(runner),
1511
- max_input_rows: Rubino.configuration.display_input_max_rows,
1512
- paste_store: paste_store,
1513
1562
  on_double_esc: runner ? -> { rewind_pending = true } : nil,
1514
1563
  on_idle_interrupt: -> { int_pending = true },
1515
1564
  # ONE Esc cancels the detached post-turn polishing (#319): only when
@@ -1521,27 +1570,25 @@ module Rubino
1521
1570
  # the same). Routed through the input queue so the idle loop runs it the
1522
1571
  # same way a typed /detach would. nil when not attached.
1523
1572
  on_back: (attached_to_agent? ? -> { input_queue.push("/detach") } : nil),
1524
- # Seed the focus-gate from the PERSISTENT attach-state (#82): this
1525
- # composer is rebuilt every idle pass, so a flag set on the previous
1526
- # one at attach time is gone the moment the loop recreates it.
1527
- # Reconcile it here so the parent cards stay suppressed and the
1528
- # focused sub's live tail owns the screen. The id (not just a bool)
1529
- # so the while-attached switcher marks the focused sub (#87).
1573
+ # Seed the focus-gate from the PERSISTENT attach-state (#82) so the
1574
+ # parent cards stay suppressed and the focused sub's live tail owns the
1575
+ # screen. The id (not just a bool) marks the focused sub (#87).
1530
1576
  attached: @attached_id
1531
1577
  )
1532
- composer.start
1533
- # Route $stdout through the composer for the whole idle read the SAME
1534
- # StdoutProxy swap a turn gets so anything printed while the idle
1535
- # prompt is pinned (a background subagent's completion note, a late
1536
- # status line) commits ABOVE the input under the composer's render
1537
- # mutex instead of raw-painting over the prompt row (#169). The logger
1538
- # is forced to bind to the real IO first, exactly as in #start_composer.
1539
- real_stdout = $stdout
1540
- Rubino.logger
1541
- $stdout = UI::StdoutProxy.new(composer)
1578
+ # The composer's reader is already live and $stdout is on the proxy for the
1579
+ # whole session, so anything printed while the idle prompt is pinned (a
1580
+ # background subagent's completion note, a late status line) commits ABOVE
1581
+ # the input under the render mutex (#169) no per-read swap needed.
1582
+ #
1583
+ # BUG 02: the composer is REUSED, so an unsubmitted draft from the prior
1584
+ # turn may still sit in the buffer. The chat loop carries that draft
1585
+ # explicitly via @pending_draft → +draft+ here, so clear the buffer first
1586
+ # and re-seed it — otherwise the carried draft would DOUBLE. On the old
1587
+ # per-turn composer the fresh instance was already empty.
1588
+ composer.reset_input
1542
1589
  seed_draft(composer, draft)
1543
1590
  idle_cards.paint
1544
- ticker = idle_cards.children_live? ? idle_cards.start_ticker(composer) : nil
1591
+ ticker = idle_cards.children_live? ? idle_cards.start_ticker(composer) { tail_attached_shell(composer) } : nil
1545
1592
 
1546
1593
  # SIGINT trap as a FALLBACK only (BH-2 / #551): the dependable idle Ctrl+C
1547
1594
  # path is now the in-band \x03 byte (on_idle_interrupt above), because
@@ -1585,11 +1632,11 @@ module Rubino
1585
1632
  break
1586
1633
  end
1587
1634
 
1588
- # Auto-open the EXISTING approval / reply prompt for a pending subagent
1589
- # request (#421): a parked child needs a human decision, so the
1590
- # affordance presents ITSELF here at idle instead of leaving a passive
1591
- # card the user must answer by guessing `/agents <id>` / `/reply <id>`.
1592
- # This is the SAME prompt those slash commands open — just auto-opened.
1635
+ # Auto-open the EXISTING approval prompt for a pending subagent request
1636
+ # (#421): a parked child needs a human decision, so the affordance
1637
+ # presents ITSELF here at idle instead of leaving a passive card the
1638
+ # user must answer by guessing `/agents <id>`. This is the SAME prompt
1639
+ # that slash command opens — just auto-opened.
1593
1640
  # Because the REPL re-enters this idle loop at EVERY turn boundary
1594
1641
  # (including after an interrupted/aborted turn), a request that arrived
1595
1642
  # mid-turn or survived an abort is re-detected here and never lost. The
@@ -1597,6 +1644,7 @@ module Rubino
1597
1644
  # suspend THIS composer and restore it after), so it does not race the
1598
1645
  # reader. Resolves one request, then `next` so the cards repaint and the
1599
1646
  # loop re-checks for the next pending request before reading input.
1647
+ # (It defers itself while the picker is open — see the hook, #586.)
1600
1648
  if auto_resolve_pending_subagent_request(runner)
1601
1649
  idle_cards.paint
1602
1650
  next
@@ -1676,18 +1724,15 @@ module Rubino
1676
1724
  restore_idle_int(prev_int)
1677
1725
  ticker&.kill
1678
1726
  ticker&.join
1679
- # Mirror #stop_composer: restore the real $stdout, then flush any held
1680
- # partial line through the still-live composer before tearing it down.
1681
- if real_stdout
1682
- proxy = $stdout
1683
- $stdout = real_stdout
1684
- proxy.finish if proxy.respond_to?(:finish)
1685
- end
1727
+ # The session composer is NOT stopped here (BUG 02) — its reader/raw mode
1728
+ # live for the whole session and $stdout stays on the proxy; the single
1729
+ # teardown is #stop_session_composer on REPL exit. Just preserve an
1730
+ # un-submitted draft so the NEXT prompt pre-fills with it (a submitted
1731
+ # line already cleared the buffer).
1686
1732
  if composer
1687
1733
  pending = composer.buffer.to_s
1688
1734
  @pending_draft = pending unless pending.strip.empty?
1689
1735
  end
1690
- composer&.stop
1691
1736
  end
1692
1737
 
1693
1738
  # The idle composer's single-Esc hook (#319): cancel the detached post-turn
@@ -1908,7 +1953,11 @@ module Rubino
1908
1953
  # cards stay visible and their elapsed time advances until the turn ends.
1909
1954
  # Killed in the ensure below.
1910
1955
  idle_cards.paint
1911
- card_ticker = idle_cards.children_live? ? idle_cards.start_ticker(composer) : nil
1956
+ card_ticker = if idle_cards.children_live?
1957
+ idle_cards.start_ticker(composer) do
1958
+ tail_attached_shell(composer)
1959
+ end
1960
+ end
1912
1961
 
1913
1962
  # If this turn's prompt came off the input queue (interrupt-by-default
1914
1963
  # Enter, Alt+Enter, or "/queued" during the previous turn), commit it now
@@ -1935,6 +1984,11 @@ module Rubino
1935
1984
  # Only thread the paste expansions when a placeholder was actually
1936
1985
  # collected, so a normal turn's runner.run signature is unchanged.
1937
1986
  run_kwargs[:paste_expansions] = paste_expansions unless paste_expansions.empty?
1987
+ # Drive the live `ctx ~Xk/…` gauge during THIS turn (#608e): hand the UI a
1988
+ # cheap render lambda (base ctx captured once + the in-flight token
1989
+ # estimate) so its ticker repaints the bar ~1/s as the model generates,
1990
+ # instead of the bar sitting frozen until the turn ends. Cleared in ensure.
1991
+ ui.live_status_provider = live_status_meter(runner) if ui.respond_to?(:live_status_provider=)
1938
1992
  oneshot = one_shot_agent_definition(agent_name)
1939
1993
  if oneshot && runner.respond_to?(:run_with_agent)
1940
1994
  runner.run_with_agent(oneshot, prompt, **run_kwargs)
@@ -1946,11 +2000,11 @@ module Rubino
1946
2000
  # escaped the cooperative path. Cancel and re-raise so run_interactive's
1947
2001
  # loop breaks and the session ends cleanly.
1948
2002
  runner.cancel!
1949
- # This Ctrl-C-aborted turn may have orphaned a subagent blocked on
1950
- # ask_parent(blocking:true): the parent turn that owed it an answer is
1951
- # gone, so without this the child stays parked on its gate for the full
1952
- # ask_parent_timeout (~900s). Cancel every live child so each unwinds NOW
1953
- # via its `rescue Rubino::Interrupted` (clean "cancelled" message). The
2003
+ # This Ctrl-C-aborted turn may have orphaned a subagent parked on its
2004
+ # approval gate: the parent turn that owed it a decision is gone, so
2005
+ # without this the child stays parked for the full approval timeout.
2006
+ # Cancel every live child so each unwinds NOW via its
2007
+ # `rescue Rubino::Interrupted` (clean "cancelled" message). The
1954
2008
  # re-raise also reaches run_interactive's teardown #cancel_all, but doing
1955
2009
  # it here keeps the unwind local to the edge that orphaned the child and
1956
2010
  # is idempotent, so the second call is a no-op.
@@ -1969,6 +2023,8 @@ module Rubino
1969
2023
  # time the runner returns, so the facet has already landed in the
1970
2024
  # footer and the engine thread must not outlive the turn.
1971
2025
  ui.turn_finished if ui.respond_to?(:turn_finished)
2026
+ # Stop driving the live ctx gauge; the reconcile below sets the exact bar.
2027
+ ui.live_status_provider = nil if ui.respond_to?(:live_status_provider=)
1972
2028
  # Stop the during-turn panel ticker before tearing the composer down, so
1973
2029
  # it can't repaint over the next idle prompt (the idle read starts its
1974
2030
  # own ticker). Idempotent if it already exited on its own (no live child).
@@ -2001,17 +2057,42 @@ module Rubino
2001
2057
  session = runner.session
2002
2058
  budget = Context::TokenBudget.new(model_id: session[:model], config: Rubino.configuration)
2003
2059
  messages = ::Rubino::Session::Store.new.for_session(session[:id])
2060
+ render_status_bar(session, budget, context_tokens(messages, budget))
2061
+ rescue StandardError
2062
+ nil
2063
+ end
2064
+
2065
+ # A cheap, DB-free render lambda for the LIVE ctx gauge (#608e): captures the
2066
+ # base (persisted) token count ONCE here on the main thread, then maps an
2067
+ # in-flight token estimate → a bar line with NO further DB reads, so the UI
2068
+ # ticker can call it ~1/s from its thread without re-querying the session.
2069
+ # The base omits this turn's not-yet-persisted generation; the +extra+
2070
+ # estimate covers it, and #ensure reconciles to the exact bar at turn end.
2071
+ # nil (no live gauge) when the bar is disabled or on any failure.
2072
+ def live_status_meter(runner)
2073
+ return nil unless runner && Rubino.configuration.display_statusbar?
2074
+
2075
+ session = runner.session
2076
+ budget = Context::TokenBudget.new(model_id: session[:model], config: Rubino.configuration)
2077
+ base = context_tokens(::Rubino::Session::Store.new.for_session(session[:id]), budget)
2078
+ ->(extra) { render_status_bar(session, budget, base + extra.to_i) }
2079
+ rescue StandardError
2080
+ nil
2081
+ end
2082
+
2083
+ # Renders the model + context-saturation bar for +tokens+ against the
2084
+ # session's window. Shared by the turn-boundary bar (#build_status_line) and
2085
+ # the live gauge (#live_status_meter) so both read one format (#608e).
2086
+ def render_status_bar(session, budget, tokens)
2004
2087
  UI::StatusBar.render(
2005
2088
  chips: { mode: Rubino::Modes.current, agent: status_agent_chip,
2006
2089
  branch: @branch_short_id,
2007
2090
  skill: Rubino::ActiveSkill.current },
2008
2091
  model: session[:model] || model_name,
2009
- tokens: context_tokens(messages, budget),
2092
+ tokens: tokens,
2010
2093
  window: budget.available_tokens,
2011
2094
  pastel: pastel
2012
2095
  )
2013
- rescue StandardError
2014
- nil
2015
2096
  end
2016
2097
 
2017
2098
  # The status-bar agent chip (#320): the active primary agent name, but
@@ -2102,60 +2183,101 @@ module Rubino
2102
2183
  # the runner or the agent loop, so it cannot race the turn own work — the
2103
2184
  # parked text is consumed by the loop at a safe iteration boundary (atomic
2104
2185
  # #drain), or by #next_input between turns for anything typed in the gap.
2105
- def start_composer(input_queue, runner)
2106
- return [nil, nil] unless input_queue && UI::BottomComposer.active?
2107
-
2108
- # The mode/branch/skill context rides the STATUS BAR (build_status_line);
2109
- # the prompt itself is the constant clean "❯ " behind the red rail.
2110
- # `runner` is threaded in (not captured from an enclosing scope) so the
2111
- # interrupt lambda resolves it it is a parameter of #run_turn, not in
2112
- # scope here, and there is no @runner ivar, so capturing it implicitly
2113
- # raised NameError the instant an Enter-during-turn fired (BH-1).
2114
- # Same completion + history wiring as the idle composer: the prompt is
2115
- # pinned and editable for the WHOLE turn — including the post-turn
2116
- # window where inline jobs (memory auto-extract, skill distill) spend
2117
- # aux-LLM seconds after the `↳ turn` footer — so `/` and `@` dropdowns
2118
- # and ↑↓ history work whenever the prompt is visible (#169).
2119
- busy = busy_command_handler(runner)
2120
- composer = UI::BottomComposer.new(input_queue: input_queue, prompt: build_prompt,
2121
- rail: composer_rail,
2122
- on_ctrl_o: ctrl_o_handler,
2123
- on_mode_cycle: mode_cycle_handler(runner),
2124
- on_interrupt: interrupt_handler(runner),
2125
- completion_source: @completion_source,
2126
- history: @input_history,
2127
- pending_queued: pending_queued,
2128
- status_line: build_status_line(runner),
2129
- max_input_rows: Rubino.configuration.display_input_max_rows,
2130
- paste_store: paste_store,
2131
- # ← on an empty prompt backs out of an attached subagent view to the
2132
- # main timeline MID-TURN too (slice 3): routed through the SAME busy
2133
- # handler typed lines use so the detach happens IMMEDIATELY on the
2134
- # reader thread (not queued behind the still-running turn). Guarded by
2135
- # attached_to_agent? so it's a no-op cursor key when not attached.
2136
- on_back: -> { busy.call("/back") if attached_to_agent? },
2137
- # Seed the focus-gate from the persistent attach-state (#82):
2138
- # if a turn's composer is built while already attached to a
2139
- # sub (attach happened mid-turn, the parent kept running), it
2140
- # starts suppressed so the parent's stream/cards stay off the
2141
- # focused view. The id (not just a bool) so the while-attached
2142
- # switcher marks the focused sub (#87).
2143
- attached: @attached_id,
2144
- on_busy_command: busy)
2186
+ # Build + #start the SINGLE long-lived composer for this interactive
2187
+ # session (BUG 02). Only the SESSION-STABLE collaborators are wired here —
2188
+ # the input queue, the shared completion source + history, the explicit-
2189
+ # queue stack, the paste store, the rail, the row cap — plus the reader
2190
+ # thread / self+wake pipes / raw-mode entry the #start allocates ONCE. The
2191
+ # PER-PHASE config (prompt, echo, key hooks, status line, focus) is left at
2192
+ # its construction default and re-pointed by #reconfigure at each idle read
2193
+ # / run_turn boundary, so the same instance serves both phases without a
2194
+ # fresh native alloc. Returns the composer, or nil when it can't run (no
2195
+ # queue / non-TTY) the cooked fallback then runs exactly as before.
2196
+ def start_session_composer(input_queue, runner)
2197
+ return nil unless input_queue && UI::BottomComposer.active?
2198
+
2199
+ composer = UI::BottomComposer.new(
2200
+ input_queue: input_queue,
2201
+ prompt: build_prompt,
2202
+ rail: composer_rail,
2203
+ completion_source: @completion_source,
2204
+ history: @input_history,
2205
+ pending_queued: pending_queued,
2206
+ status_line: build_status_line(runner),
2207
+ max_input_rows: Rubino.configuration.display_input_max_rows,
2208
+ paste_store: paste_store,
2209
+ attached: @attached_id
2210
+ )
2145
2211
  composer.start
2146
- real_stdout = $stdout
2147
- # Force the lazily-built logger to bind to the REAL $stdout NOW, before
2148
- # the swap — otherwise the first log call during the turn would build a
2149
- # Logger against the proxy and route diagnostic lines into the chat (and,
2150
- # after the turn, into a dead proxy). The logger stays on the real IO.
2151
- Rubino.logger
2152
- $stdout = UI::StdoutProxy.new(composer)
2153
- [composer, real_stdout]
2212
+ composer
2154
2213
  rescue StandardError
2155
- # Setup failed — fall back to the plain path so the turn still runs
2156
- # (no raw, no proxy).
2214
+ # Setup failed — fall back to the plain path so the session still runs
2215
+ # (no raw, no proxy), exactly as the old per-turn #start_composer did.
2157
2216
  composer&.stop
2158
- $stdout = real_stdout if real_stdout
2217
+ nil
2218
+ end
2219
+
2220
+ # Tear the single session composer down ONCE on REPL exit (BUG 02): restore
2221
+ # the real $stdout (flushing any held partial line through the still-live
2222
+ # composer first), then #stop it — stopping the reader thread, leaving raw
2223
+ # mode and restoring the WINCH/CONT traps. Mirrors the old #stop_composer
2224
+ # teardown, run once per session instead of once per turn. Safe on nil.
2225
+ def stop_session_composer
2226
+ composer = @composer
2227
+ @composer = nil
2228
+ if @composer_stdout
2229
+ proxy = $stdout
2230
+ $stdout = @composer_stdout
2231
+ @composer_stdout = nil
2232
+ proxy.finish if proxy.respond_to?(:finish)
2233
+ end
2234
+ composer&.stop
2235
+ rescue IOError, Errno::ENOTTY, Errno::EIO
2236
+ nil
2237
+ end
2238
+
2239
+ # RECONFIGURE the single session composer for a turn (BUG 02). The composer
2240
+ # was built + #started ONCE at session setup and $stdout already routes
2241
+ # through its proxy, so a turn no longer allocates a fresh one or re-swaps
2242
+ # $stdout — it only re-points the per-phase hooks/echo/prompt onto the
2243
+ # shared instance. Returns [composer, nil]; the nil second slot keeps the
2244
+ # `composer, real_stdout = start_composer(...)` call site and #stop_composer
2245
+ # signature unchanged (the stdout restore is now the session-level teardown,
2246
+ # not per-turn). Returns [nil, nil] when no composer is running (piped / -q /
2247
+ # non-TTY), so the plain path runs exactly as before.
2248
+ #
2249
+ # `runner` is threaded in (not captured) so the interrupt lambda resolves it
2250
+ # — it is a parameter of #run_turn, not in scope on the helpers (BH-1). The
2251
+ # in-turn config differs from the idle prompt only in these hooks/echo: Esc
2252
+ # interrupts the turn (on_interrupt), a typed control command runs NOW
2253
+ # (on_busy_command), ← detaches mid-turn through the same busy handler, and
2254
+ # the idle-only chords (on_double_esc / on_idle_interrupt / on_escape) are
2255
+ # cleared so they can't fire during a turn.
2256
+ def start_composer(_input_queue, runner)
2257
+ return [nil, nil] unless @composer
2258
+
2259
+ busy = busy_command_handler(runner)
2260
+ @composer.reconfigure(
2261
+ prompt: build_prompt,
2262
+ echo: :queued,
2263
+ on_ctrl_o: ctrl_o_handler,
2264
+ on_mode_cycle: mode_cycle_handler(runner),
2265
+ on_interrupt: interrupt_handler(runner),
2266
+ # ← on an empty prompt backs out of an attached subagent view to the main
2267
+ # timeline MID-TURN (slice 3): routed through the SAME busy handler typed
2268
+ # lines use so the detach happens IMMEDIATELY on the reader thread (not
2269
+ # queued behind the still-running turn). A no-op cursor key when not attached.
2270
+ on_back: -> { busy.call("/back") if attached_to_agent? },
2271
+ on_busy_command: busy,
2272
+ status_line: build_status_line(runner),
2273
+ # Seed the focus-gate from the persistent attach-state (#82): a turn that
2274
+ # opens while attached to a sub starts suppressed so the parent's stream/
2275
+ # cards stay off the focused view. The id (not a bool) marks the sub (#87).
2276
+ attached: @attached_id
2277
+ )
2278
+ [@composer, nil]
2279
+ rescue StandardError
2280
+ # Reconfig failed — fall back to the plain path so the turn still runs.
2159
2281
  [nil, nil]
2160
2282
  end
2161
2283
 
@@ -2223,23 +2345,21 @@ module Rubino
2223
2345
  end
2224
2346
  end
2225
2347
 
2226
- # Tears down the composer: restores the real $stdout, flushes any held
2227
- # partial line into scrollback, stops the reader and restores cooked mode.
2228
- # Safe to call with nils (no composer was started).
2229
- def stop_composer(composer, real_stdout)
2230
- proxy = $stdout
2231
- $stdout = real_stdout if real_stdout
2232
- proxy.finish if proxy.respond_to?(:finish)
2233
- # Preserve an un-submitted draft (text typed during the turn with no
2234
- # Enter) before tearing the composer down; #next_input pre-fills the next
2235
- # prompt with it. A submitted line clears the buffer, so this only ever
2236
- # carries genuinely-pending input. An empty buffer leaves any prior draft
2237
- # untouched so it survives queued steering turns in between.
2238
- if composer
2239
- draft = composer.buffer.to_s
2240
- @pending_draft = draft unless draft.strip.empty?
2241
- end
2242
- composer&.stop
2348
+ # End-of-turn handoff for the SINGLE session composer (BUG 02). The
2349
+ # composer is NO LONGER stopped or torn down per turn (its reader thread,
2350
+ # pipes and raw mode live for the whole session, and $stdout stays on the
2351
+ # proxy) — #stop_session_composer does that ONCE on REPL exit. This now only
2352
+ # preserves an un-submitted draft (text typed during the turn with no Enter)
2353
+ # so #next_input pre-fills the next prompt with it. A submitted line cleared
2354
+ # the buffer, so this only ever carries genuinely-pending input; an empty
2355
+ # buffer leaves any prior draft untouched so it survives queued steering
2356
+ # turns in between. +real_stdout+ is retained (nil from #start_composer now)
2357
+ # so the `stop_composer(composer, real_stdout)` call site is unchanged.
2358
+ def stop_composer(composer, _real_stdout = nil)
2359
+ return unless composer
2360
+
2361
+ draft = composer.buffer.to_s
2362
+ @pending_draft = draft unless draft.strip.empty?
2243
2363
  rescue IOError, Errno::ENOTTY, Errno::EIO
2244
2364
  nil
2245
2365
  end
@@ -2422,11 +2542,18 @@ module Rubino
2422
2542
  end
2423
2543
 
2424
2544
  # A row the rewind picker offers: a REAL typed user message — not a tool
2425
- # result riding the user role, and not the `!` bang-shell injections
2426
- # (<bash-input>/<bash-stdout> context glue is not something to resend).
2545
+ # result riding the user role, not the `!` bang-shell injections
2546
+ # (<bash-input>/<bash-stdout> context glue), and not a SYNTHETIC harness
2547
+ # injection (the `[harness control]` iteration-cap continuation / blocked-
2548
+ # tool / resume nudges, #75) — those are runtime control text the agent
2549
+ # wrote on the user's behalf, never something to rewind-to-and-resend. Same
2550
+ # principle as Codex's is_user_turn_boundary, which excludes injected /
2551
+ # contextual messages so only genuine user turns are restore points.
2427
2552
  def rewindable_message?(msg)
2553
+ content = msg.content.to_s
2428
2554
  msg.role == "user" && msg.tool_call_id.nil? &&
2429
- !msg.content.to_s.start_with?("<bash-")
2555
+ !content.start_with?("<bash-") &&
2556
+ !content.start_with?(Agent::Loop::HARNESS_CONTROL_MARKER)
2430
2557
  end
2431
2558
 
2432
2559
  # One picker row: `N ago · <first 60 chars>` — recency + a flattened
@@ -2544,32 +2671,11 @@ module Rubino
2544
2671
  ui.success("agent: #{previous} → #{Rubino::ActiveAgent.current}")
2545
2672
  # A /agent switch is NON-destructive: the REPL, the session, and the
2546
2673
  # subagent registry all stay alive, so we must NOT cancel running
2547
- # children (that would kill useful in-flight work). But a child blocked on
2548
- # ask_parent is now waiting on a parent the human just re-pinned, which is
2549
- # easy to forget — so SURFACE any blocked child (the safe behavior here)
2550
- # rather than leave it stuck invisibly. The human can still /reply it.
2551
- warn_blocked_children_after_switch(ui)
2674
+ # children (that would kill useful in-flight work).
2552
2675
  rescue ArgumentError => e
2553
2676
  ui.error(e.message)
2554
2677
  end
2555
2678
 
2556
- # After a /agent switch, remind the human of any subagent still blocked on
2557
- # an ask_parent question (waiting on the human OR on its agent-parent) so
2558
- # the switch never silently strands a parked child at the idle prompt. Pure
2559
- # surfacing — nothing is cancelled; the children keep running and stay
2560
- # answerable via /reply <id>. Best-effort and quiet when nothing is blocked.
2561
- def warn_blocked_children_after_switch(ui)
2562
- blocked = Tools::BackgroundTasks.instance.running.select do |e|
2563
- %i[blocked_on_human blocked_on_parent].include?(e.status)
2564
- end
2565
- return if blocked.empty?
2566
-
2567
- ui.warning("#{blocked.size} subagent(s) still waiting on an answer — /reply <id> to answer:")
2568
- blocked.each { |e| ui.info(" #{e.id} · #{e.subagent}") }
2569
- rescue StandardError
2570
- nil
2571
- end
2572
-
2573
2679
  # Resolves a one-shot `/<agent> <message>` route to its Definition, or nil
2574
2680
  # when no agent was named (the plain-turn path). An unknown name degrades
2575
2681
  # to nil (the turn runs under the sticky agent) rather than crashing.
@@ -2909,7 +3015,7 @@ module Rubino
2909
3015
  # Builds an Agent::Runner with this invocation's shared flag overrides —
2910
3016
  # only the session and UI vary per call site (one-shot, interactive boot,
2911
3017
  # /sessions resume, /new).
2912
- def build_runner(session_id:, ui:, announce_session: true)
3018
+ def build_runner(session_id:, ui:, announce_session: true, interactive: true)
2913
3019
  Agent::Runner.new(
2914
3020
  session_id: session_id,
2915
3021
  model_override: model_name,
@@ -2917,7 +3023,11 @@ module Rubino
2917
3023
  max_turns: max_turns_override,
2918
3024
  ignore_rules: opt(:ignore_rules) || false,
2919
3025
  ui: ui,
2920
- announce_session: announce_session
3026
+ announce_session: announce_session,
3027
+ # build_runner is the interactive-REPL builder; only setup_oneshot
3028
+ # overrides this to false (a headless one-shot exits after one turn).
3029
+ # Drives Lifecycle's single-slot KV-cache gate (#608c).
3030
+ interactive: interactive
2921
3031
  )
2922
3032
  end
2923
3033
 
@@ -2951,7 +3061,7 @@ module Rubino
2951
3061
  # and what it said.
2952
3062
  def attach_agent_view(id, ui)
2953
3063
  entry = Tools::BackgroundTasks.instance.find(id)
2954
- return ui.error("no background subagent with id #{id}") unless entry
3064
+ return ui.error("no background task with id #{id}") unless entry
2955
3065
 
2956
3066
  @attached_id = id
2957
3067
  # Focus the composer on this sub (tmux-style unified render): only frames
@@ -2973,13 +3083,57 @@ module Rubino
2973
3083
  # the focus gate) hands the bottom region to the sub; detach refocuses
2974
3084
  # main and the cards return.
2975
3085
  composer&.set_cards([])
2976
- ui.info(pastel.cyan("▶ attached to #{id} · #{entry.subagent}") +
2977
- pastel.dim(" type to steer · to switch subagents · ← to go back"))
2978
- session_resolver.replay_messages(ui, snapshot)
3086
+ # The attach BODY differs by kind — the one polymorphic seam: a subagent
3087
+ # replays its session transcript (and its per-sub CLI keeps painting live
3088
+ # through the focus gate); a shell has no transcript, so it shows its
3089
+ # captured OUTPUT and the user types straight to its stdin.
3090
+ if entry.shell?
3091
+ ui.info(pastel.cyan("▶ attached to #{id} · shell") +
3092
+ pastel.dim(" — type to send input · ↓ to switch · ← to go back"))
3093
+ paint_shell_tail(composer, entry, full: true)
3094
+ else
3095
+ ui.info(pastel.cyan("▶ attached to #{id} · #{entry.subagent}") +
3096
+ pastel.dim(" — type to steer · ↓ to switch subagents · ← to go back"))
3097
+ session_resolver.replay_messages(ui, snapshot)
3098
+ end
3099
+ end
3100
+ # No watcher: a subagent's OWN per-sub CLI paints its ongoing activity live
3101
+ # through the focus gate. A shell has none, so its NEW output is rendered
3102
+ # after each input (see #handle_attached_input) — continuous auto-tailing
3103
+ # is a later refinement.
3104
+ end
3105
+
3106
+ # Paint a focused shell's NEW output into the attached view, through the SAME
3107
+ # focus-gated, render-mutex-safe seam subagent live frames use
3108
+ # (composer#print_above with the shell's origin) — so it is safe to call both
3109
+ # from the keystroke handler AND the 1 Hz idle ticker thread. A private,
3110
+ # mutex-guarded cursor tracks bytes already shown so it NEVER advances the
3111
+ # shared read_offset the model's shell_output reads. full: ⇒ from the start
3112
+ # (on attach); otherwise only bytes added since the last paint.
3113
+ def paint_shell_tail(composer, entry, full: false)
3114
+ return unless composer && entry
3115
+
3116
+ @attached_shell_mutex ||= Mutex.new
3117
+ text = @attached_shell_mutex.synchronize do
3118
+ buf = entry.output_all.to_s
3119
+ @attached_shell_cursor = 0 if full || @attached_shell_cursor.nil?
3120
+ slice = buf.byteslice(@attached_shell_cursor..) || ""
3121
+ @attached_shell_cursor = buf.bytesize
3122
+ slice
2979
3123
  end
2980
- # No watcher: the sub's OWN per-sub CLI now paints its ongoing activity
2981
- # live through the focus gate (it commits with this sub's origin), so the
2982
- # attached view stays live without a polling ticker.
3124
+ return if text.strip.empty?
3125
+
3126
+ composer.print_above(text.chomp, origin: @attached_id)
3127
+ end
3128
+
3129
+ # The idle ticker's per-tick hook (#start_ticker): live-tail the focused
3130
+ # shell's output, if one is attached. A no-op while on a subagent (its own
3131
+ # per-sub CLI streams) or the main view.
3132
+ def tail_attached_shell(composer)
3133
+ return unless @attached_id
3134
+
3135
+ entry = Tools::BackgroundTasks.instance.find(@attached_id)
3136
+ paint_shell_tail(composer, entry) if entry&.shell?
2983
3137
  end
2984
3138
 
2985
3139
  # Leave the agent-view and return to the main session: clear the screen,
@@ -3024,7 +3178,7 @@ module Rubino
3024
3178
 
3025
3179
  # Route a line typed while attached. `/back`/`/detach` (or the child being
3026
3180
  # gone) return to the main view; a `/`-line is a COMMAND — the agent-scoped
3027
- # raw-text forms (bare `/stop`, `/reply`, `/probe`, `--attach`) are handled
3181
+ # raw-text forms (bare `/stop`, `/probe`, `--attach`) are handled
3028
3182
  # in place, and EVERY other `/`-command (`/stop <id>`, `/agents`, `/status`,
3029
3183
  # …) routes through the SAME executor the main prompt uses (R3); only plain
3030
3184
  # text answers a blocked child or steers a running one. Reuses the existing
@@ -3054,7 +3208,7 @@ module Rubino
3054
3208
  # Switching to another live subagent still works; anything else gets a calm
3055
3209
  # notice — ← / /back returns to main. (Live = the same set BackgroundTasks#
3056
3210
  # live_status? / AgentMenu#live? use; inlined since it's the only use here.)
3057
- unless %i[running needs_approval blocked_on_human blocked_on_parent stopping].include?(entry.status)
3211
+ unless %i[running needs_approval stopping].include?(entry.status)
3058
3212
  return attach_agent_view(Regexp.last_match(1), ui) if input =~ %r{\A/agents\s+(\S+)\s+--attach\z}
3059
3213
 
3060
3214
  # A `/`-command still EXECUTES even when the sub you're parked on has
@@ -3082,8 +3236,6 @@ module Rubino
3082
3236
  # The picker is a switcher while attached: selecting another subagent
3083
3237
  # SWITCHES the view to it (re-clear + replay) rather than steering.
3084
3238
  attach_agent_view(Regexp.last_match(1), ui)
3085
- when %r{\A/(?:reply|answer)\s+(.+)\z}m
3086
- agents_request_handler.deliver_reply(entry, Regexp.last_match(1))
3087
3239
  when %r{\A/probe\s+(.+)\z}m
3088
3240
  agents_request_handler.probe_agent(id, Regexp.last_match(1))
3089
3241
  when %r{\A/}
@@ -3099,12 +3251,13 @@ module Rubino
3099
3251
  result = cmd_executor.try_execute(input)
3100
3252
  attach_agent_view(result[:attach_agent], ui) if result.is_a?(Hash) && result[:attach_agent]
3101
3253
  else
3102
- if %i[needs_approval blocked_on_human].include?(entry.status)
3103
- # The child is blocked on YOU the line is the answer.
3104
- agents_request_handler.deliver_reply(entry, input)
3105
- else
3106
- # The child is running → the line is a steer note folded at its next turn.
3107
- agents_request_handler.steer_agent(id, input)
3254
+ # Plain text → the worker's input: a subagent folds it as a steer note at
3255
+ # its next turn boundary; a shell writes it to stdin. For a shell, surface
3256
+ # the output that input produced so the attached view stays useful.
3257
+ agents_request_handler.steer_agent(id, input)
3258
+ if entry.shell?
3259
+ sleep 0.2 # let the shell consume the line + emit its response
3260
+ paint_shell_tail(UI::BottomComposer.current, entry)
3108
3261
  end
3109
3262
  end
3110
3263
  end