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.
- checksums.yaml +4 -4
- data/.dockerignore +15 -0
- data/CHANGELOG.md +127 -0
- data/Dockerfile +56 -0
- data/agent.md +112 -0
- data/docs/api/v1.md +2 -0
- data/docs/commands.md +3 -6
- data/docs/configuration.md +13 -6
- data/docs/design/bg-shell-pty-port.md +88 -0
- data/docs/design/bg-shell-review-refinements.md +65 -0
- data/docs/design/bg-shell-ux.md +130 -0
- data/docs/oauth-providers.md +21 -0
- data/docs/tools.md +3 -12
- data/lib/rubino/agent/iteration_budget.rb +13 -0
- data/lib/rubino/agent/loop.rb +43 -5
- data/lib/rubino/agent/prompts/build.txt +10 -5
- data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
- data/lib/rubino/agent/runner.rb +55 -12
- data/lib/rubino/agent/tool_executor.rb +1 -1
- data/lib/rubino/api/operations/tasks/stop_operation.rb +0 -3
- data/lib/rubino/attachments/classify.rb +0 -1
- data/lib/rubino/cli/chat/completion_builder.rb +0 -8
- data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
- data/lib/rubino/cli/chat_command.rb +324 -171
- data/lib/rubino/cli/commands.rb +5 -0
- data/lib/rubino/commands/built_ins.rb +0 -1
- data/lib/rubino/commands/executor.rb +1 -7
- data/lib/rubino/commands/handlers/agents.rb +55 -265
- data/lib/rubino/commands/handlers/status.rb +6 -3
- data/lib/rubino/compression/line_skeleton.rb +1 -1
- data/lib/rubino/compression/python_code_skeleton.rb +1 -1
- data/lib/rubino/compression/ruby_code_skeleton.rb +1 -1
- data/lib/rubino/compression/tree_sitter_code_skeleton.rb +1 -1
- data/lib/rubino/config/configuration.rb +47 -18
- data/lib/rubino/config/defaults.rb +57 -33
- data/lib/rubino/context/prompt_assembler.rb +89 -1
- data/lib/rubino/context/summary_builder.rb +0 -22
- data/lib/rubino/context/token_budget.rb +0 -5
- data/lib/rubino/errors.rb +2 -2
- data/lib/rubino/interaction/events.rb +2 -2
- data/lib/rubino/interaction/lifecycle.rb +54 -20
- data/lib/rubino/llm/anthropic_role_merge.rb +75 -0
- data/lib/rubino/llm/error_classifier.rb +34 -1
- data/lib/rubino/llm/fake_provider.rb +0 -4
- data/lib/rubino/llm/ruby_llm_adapter.rb +222 -59
- data/lib/rubino/llm/stream_tool_call_recovery.rb +91 -0
- data/lib/rubino/llm/tool_call_recovery.rb +177 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +0 -2
- data/lib/rubino/memory/store.rb +0 -19
- data/lib/rubino/security/pattern_matcher.rb +0 -2
- data/lib/rubino/security/redactor.rb +1 -1
- data/lib/rubino/security/secret_path.rb +16 -4
- data/lib/rubino/session/message.rb +12 -0
- data/lib/rubino/skills/registry.rb +16 -2
- data/lib/rubino/tools/background_tasks.rb +132 -228
- data/lib/rubino/tools/base.rb +1 -17
- data/lib/rubino/tools/grep_tool.rb +13 -1
- data/lib/rubino/tools/question_tool.rb +3 -4
- data/lib/rubino/tools/read_attachment_tool.rb +52 -54
- data/lib/rubino/tools/registry.rb +21 -72
- data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
- data/lib/rubino/tools/shell_input_tool.rb +1 -1
- data/lib/rubino/tools/shell_kill_tool.rb +4 -4
- data/lib/rubino/tools/shell_registry.rb +178 -38
- data/lib/rubino/tools/shell_tool.rb +45 -5
- data/lib/rubino/tools/steer_tool.rb +3 -4
- data/lib/rubino/tools/task_result_tool.rb +4 -1
- data/lib/rubino/tools/task_stop_tool.rb +5 -7
- data/lib/rubino/tools/task_tool.rb +81 -35
- data/lib/rubino/tools/vision_tool.rb +1 -1
- data/lib/rubino/tools/write_tool.rb +22 -2
- data/lib/rubino/ui/agent_menu.rb +8 -4
- data/lib/rubino/ui/api.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +240 -374
- data/lib/rubino/ui/cli.rb +381 -155
- data/lib/rubino/ui/input_history.rb +0 -5
- data/lib/rubino/ui/live_region.rb +18 -1
- data/lib/rubino/ui/markdown_renderer.rb +51 -4
- data/lib/rubino/ui/markdown_repair.rb +114 -0
- data/lib/rubino/ui/notifier.rb +4 -10
- data/lib/rubino/ui/stdout_proxy.rb +25 -10
- data/lib/rubino/ui/streaming_markdown.rb +79 -12
- data/lib/rubino/ui/subagent_cards.rb +18 -44
- data/lib/rubino/ui/tool_args_stream.rb +143 -0
- data/lib/rubino/update_check.rb +10 -2
- data/lib/rubino/util/ignore_rules.rb +18 -2
- data/lib/rubino/util/secrets_mask.rb +0 -9
- data/lib/rubino/version.rb +1 -1
- data/lib/rubino.rb +33 -7
- data/rubino-agent.gemspec +1 -0
- metadata +31 -5
- data/AGENTS.md +0 -97
- data/docs/agents.md +0 -224
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
- 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
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
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
|
|
1024
|
-
#
|
|
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
|
|
1188
|
-
# stays parked
|
|
1189
|
-
#
|
|
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
|
-
|
|
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
|
|
1500
|
-
|
|
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)
|
|
1525
|
-
#
|
|
1526
|
-
#
|
|
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
|
|
1533
|
-
#
|
|
1534
|
-
#
|
|
1535
|
-
#
|
|
1536
|
-
#
|
|
1537
|
-
#
|
|
1538
|
-
#
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
|
1589
|
-
#
|
|
1590
|
-
#
|
|
1591
|
-
#
|
|
1592
|
-
#
|
|
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
|
-
#
|
|
1680
|
-
#
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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?
|
|
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
|
|
1950
|
-
#
|
|
1951
|
-
#
|
|
1952
|
-
#
|
|
1953
|
-
#
|
|
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:
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
2227
|
-
#
|
|
2228
|
-
#
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
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,
|
|
2426
|
-
# (<bash-input>/<bash-stdout> context glue
|
|
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
|
-
!
|
|
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).
|
|
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
|
|
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
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
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
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
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`, `/
|
|
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
|
|
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
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
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
|