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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea93727a0527a270cfbad507d459eb7676532dd4f715967c9d6924cc9aa81c82
|
|
4
|
+
data.tar.gz: c11bf3ca63ed02a7705447fd65cd58f197939631c1c408a0bb7415b59e333c97
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a0dfb145e9f590745b3cb178768581734109e0ac1d9fb5ec3b10552303e977323b394f96f20af66603d3f921aa180c32c593d77587756755c02195fcce6773e6
|
|
7
|
+
data.tar.gz: ccaf380590fe7c71c0a3d65f45e949b92ecd2c4f8ee8f00631e46da3b3188910fe49df333431746fc431553d212b6e7c006e4846df22ea9f8c7197f5bf9476c5
|
data/.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Build context trimming — keep the image small and the build fast. The gem is
|
|
2
|
+
# run from source (lib/ + exe/ + Gemfile), so none of the below is needed at
|
|
3
|
+
# build or run time. .git is the big one (~66M) and the gemspec's `git ls-files`
|
|
4
|
+
# simply yields [] without it, which is harmless (we run via exe/rubino, not the
|
|
5
|
+
# packaged file list).
|
|
6
|
+
.git
|
|
7
|
+
.github
|
|
8
|
+
coverage
|
|
9
|
+
tmp
|
|
10
|
+
pkg
|
|
11
|
+
*.gem
|
|
12
|
+
*.log
|
|
13
|
+
.DS_Store
|
|
14
|
+
node_modules
|
|
15
|
+
spec/tmp
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,132 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.5.2.2] - 2026-07-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Background shells get the same dev UX as background subagents.** A shell
|
|
10
|
+
started with `run_in_background: true` now appears in the `↓` picker and the
|
|
11
|
+
live cards alongside subagents, can be FOCUSED (Enter attaches to a cleared
|
|
12
|
+
view that live-tails its output and lets you type straight to its stdin), and
|
|
13
|
+
STOPPED with `/stop`. Interactive shells run on a real PTY, so `y/N` prompts,
|
|
14
|
+
sudo passwords, and tty-aware programs work where a plain pipe couldn't.
|
|
15
|
+
`probe` (an instant output snapshot — no LLM call), `steer` (→ stdin), the
|
|
16
|
+
`/agents` list and the `/status` count all treat shells consistently with
|
|
17
|
+
subagents. Stopping a subagent **cascade-kills the background shells it
|
|
18
|
+
opened** (shells the user/main agent opened are left running). Ported from
|
|
19
|
+
Hermes' `ptyprocess`/`process_registry` model.
|
|
20
|
+
- **H1/H2 headings get breathing room** — a blank line above and below big
|
|
21
|
+
headings so they break the surrounding prose instead of sitting glued to it;
|
|
22
|
+
H3+ stay compact.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **The per-turn wall clock is disabled by default.** `agent.max_turn_seconds`
|
|
27
|
+
(was 600s) guillotined legitimate multi-file work on slow local models — a real
|
|
28
|
+
docs-vs-code audit runs dozens of tool calls over 10+ minutes and was
|
|
29
|
+
force-summarized into a confused non-answer. The default is now `nil`
|
|
30
|
+
(disabled); the tool-iteration budget (`max_tool_iterations`, 90) is the
|
|
31
|
+
runaway guard, and per-tool timeouts bound a hung tool. Hermes parity (its
|
|
32
|
+
IterationBudget has no clock). Set a positive number to re-arm the clock as a
|
|
33
|
+
backstop.
|
|
34
|
+
- The background picker header reads "background" (not "subagents") now that it
|
|
35
|
+
lists background shells alongside subagents.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Truncated subagents are reported as PARTIAL, not "completed".** A background
|
|
40
|
+
subagent force-summarized at its budget/time cap used to return its partial
|
|
41
|
+
progress recap as a normal completion, so the parent — and you — got a false
|
|
42
|
+
success with no real deliverable. The turn's terminal stop reason now flows out
|
|
43
|
+
of the loop, and the completion notice, the main-timeline marker, and
|
|
44
|
+
`task_result` all mark a cut-off child **PARTIAL** with a banner telling the
|
|
45
|
+
parent the delegated work is unfinished — so it recovers (re-delegates or
|
|
46
|
+
finishes the work itself) instead of trusting a false completion.
|
|
47
|
+
- **A subagent's own `max_turns` budget is honored again.** `explore`'s per-agent
|
|
48
|
+
cap (20) was silently dropped — the runner passed `nil` for subagents, so the
|
|
49
|
+
cap never applied. A subagent now honors its cap and, on reaching it, surfaces
|
|
50
|
+
the budget-extension request (#574) instead of silently force-summarizing.
|
|
51
|
+
- **`rubino update` now reports the new version correctly.** After `gem update`
|
|
52
|
+
pulled a newer gem, the command read the version via
|
|
53
|
+
`Gem::Specification.find_by_name`, which returns the spec ACTIVATED in the
|
|
54
|
+
running process — so it still saw the old version and wrongly printed "rubino is
|
|
55
|
+
already up to date" even though the update had installed. It now `Gem.refresh`es
|
|
56
|
+
and reads the HIGHEST installed version (`find_all_by_name(...).max`), so the
|
|
57
|
+
post-update message reflects what was actually installed.
|
|
58
|
+
|
|
59
|
+
## [0.5.2.1] - 2026-06-26
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- **Symlinked workspace roots broke three path checks.** Several modules compared
|
|
64
|
+
a symlink-resolved path against a NON-resolved root, so a workspace reached
|
|
65
|
+
through a symlink (macOS `/etc` → `/private/etc`, `/var` → `/private/var`, or
|
|
66
|
+
any symlinked checkout) defeated the match:
|
|
67
|
+
- **SecretPath** — `secret?("/etc/sudoers")` returned `false` and the
|
|
68
|
+
`~/.ssh`/`~/.aws`/… credential read-gate classified nothing, silently
|
|
69
|
+
no-op'ing the write-approval gate and read-block for those paths.
|
|
70
|
+
- **IgnoreRules** — `git rev-parse --show-toplevel` returns the realpath, so
|
|
71
|
+
the allowed-set rebase dropped *every* file and the whole tree read as
|
|
72
|
+
git-ignored; `grep`/`glob` then returned nothing under a symlinked checkout.
|
|
73
|
+
- **Skills::Registry** — an untrusted repo's project-local `.rubino/skills` was
|
|
74
|
+
not recognised as project-local, so the trust gate failed to drop it (hostile
|
|
75
|
+
project skills could load in an untrusted directory).
|
|
76
|
+
|
|
77
|
+
All three now resolve both sides of the comparison through `realpath` /
|
|
78
|
+
`canonical_path`. Defense-in-depth — not security boundaries.
|
|
79
|
+
- **`grep` Ruby fallback now matches dotfiles.** Without ripgrep on PATH, the
|
|
80
|
+
fallback globbed `**/<include>` without `FNM_DOTMATCH`, so an include like
|
|
81
|
+
`*.env` never matched `.env`/`.envrc` — exactly the secret-bearing files. The
|
|
82
|
+
include glob now matches dotfiles, mirroring `rg --glob`.
|
|
83
|
+
|
|
84
|
+
## [0.5.2] - 2026-06-26
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
- **Live formatted-markdown streaming.** The in-flight model stream now renders
|
|
89
|
+
as formatted markdown while it arrives (Stage 1), painted as atomic frames via
|
|
90
|
+
DEC-2026 synchronized output so a fast stream never tears mid-update (Stage 2),
|
|
91
|
+
with committed code blocks syntax-highlighted through Rouge (Stage 3). (#592,
|
|
92
|
+
#593, #594)
|
|
93
|
+
- **Leaked tool-call recovery.** Models that emit a tool call as plain text or
|
|
94
|
+
garbled XML/JSON markup instead of a structured call (MiniMax-M3 and other
|
|
95
|
+
tool-loop models) now have those calls re-parsed into real `tool_calls` at the
|
|
96
|
+
transport layer so they actually execute, including a garbled `<invoke">`
|
|
97
|
+
variant.
|
|
98
|
+
- **`write` content preview.** The `write` tool box now shows a preview of the
|
|
99
|
+
content being written.
|
|
100
|
+
|
|
101
|
+
### Changed
|
|
102
|
+
|
|
103
|
+
- Raise the default `max_tool_iterations` from 25 to 90 (Hermes-aligned), so long
|
|
104
|
+
tool-driven turns no longer hit the ceiling mid-task.
|
|
105
|
+
- Teach the agent (via the build prompt) to read the compressed tool-output
|
|
106
|
+
markers introduced in 0.5.1.
|
|
107
|
+
|
|
108
|
+
### Fixed
|
|
109
|
+
|
|
110
|
+
- Render any unterminated code fence as a code box, matching CommonMark's
|
|
111
|
+
end-of-file fence auto-close, instead of leaking the raw backticks. (#595)
|
|
112
|
+
- Merge consecutive same-role messages on the Anthropic-family wire so the
|
|
113
|
+
request shape stays valid. (#597)
|
|
114
|
+
- Give MiniMax its full output ceiling so a long thinking block no longer starves
|
|
115
|
+
the visible output (a root cause of heavy-turn "invalid params" death).
|
|
116
|
+
- Keep a 5xx-wrapped "invalid params" response on the retryable path.
|
|
117
|
+
- Multi-line `ask()` prompts no longer erase terminal scrollback.
|
|
118
|
+
- Exclude synthetic `[harness control]` injections from the rewind picker.
|
|
119
|
+
- Fix an installed-gem launch crash (`uninitialized constant Rubino::TAGLINE`).
|
|
120
|
+
|
|
121
|
+
### Removed
|
|
122
|
+
|
|
123
|
+
- Drop the dead `server.*` config section, the orphaned `ask_parent` takeover and
|
|
124
|
+
ask/reply substrate, and dead code surfaced by the post-removal audit.
|
|
125
|
+
|
|
126
|
+
### Docs
|
|
127
|
+
|
|
128
|
+
- Mark native OAuth as not wired end-to-end (WIP).
|
|
129
|
+
|
|
3
130
|
## [0.5.1] - 2026-06-25
|
|
4
131
|
|
|
5
132
|
### Added
|
data/Dockerfile
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Ubuntu-based image that runs rubino-agent FROM SOURCE, for manual testing.
|
|
2
|
+
#
|
|
3
|
+
# Build: docker build -t rubino:latest .
|
|
4
|
+
# Run: docker run --rm -it \
|
|
5
|
+
# -v "$PWD":/work \ # the dir the agent works on
|
|
6
|
+
# -v "$HOME/.rubino":/root/.rubino \ # reuse your host config + keys
|
|
7
|
+
# rubino:latest rubino
|
|
8
|
+
#
|
|
9
|
+
# The agent is launched via the `rubino` wrapper from any cwd; mount your project
|
|
10
|
+
# at /work. Secrets are NEVER baked in — provide them by mounting ~/.rubino or
|
|
11
|
+
# passing *_API_KEY env vars (-e RUBINO_API_KEY=...).
|
|
12
|
+
FROM ubuntu:24.04
|
|
13
|
+
|
|
14
|
+
ENV DEBIAN_FRONTEND=noninteractive \
|
|
15
|
+
TERM=xterm-256color \
|
|
16
|
+
LANG=C.UTF-8 \
|
|
17
|
+
LC_ALL=C.UTF-8
|
|
18
|
+
|
|
19
|
+
# Two groups: (1) runtime tools the agent shells out to — git, ripgrep, sqlite3,
|
|
20
|
+
# tmux, curl, less, procps; (2) the toolchain ruby-build (via mise) needs to
|
|
21
|
+
# COMPILE Ruby 3.3.3 and the native gems (nokogiri / ffi / sqlite3).
|
|
22
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
23
|
+
ca-certificates curl git less procps tmux ripgrep sqlite3 \
|
|
24
|
+
build-essential autoconf bison \
|
|
25
|
+
libssl-dev libyaml-dev libreadline-dev zlib1g-dev \
|
|
26
|
+
libncurses-dev libffi-dev libgdbm-dev libsqlite3-dev \
|
|
27
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
28
|
+
|
|
29
|
+
# Ruby 3.3.3 via mise — Ubuntu's apt ships only 3.2, but the repo pins 3.3.3
|
|
30
|
+
# (.ruby-version), so we compile the exact version. Put the install's bin dir
|
|
31
|
+
# straight on PATH (no shims) so ruby/gem/bundler resolve deterministically.
|
|
32
|
+
ENV MISE_DATA_DIR=/opt/mise \
|
|
33
|
+
PATH=/opt/mise/installs/ruby/3.3.3/bin:/usr/local/bin:$PATH
|
|
34
|
+
RUN curl -fsSL https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \
|
|
35
|
+
&& mise install ruby@3.3.3 \
|
|
36
|
+
&& gem install bundler -v 4.0.12
|
|
37
|
+
|
|
38
|
+
WORKDIR /app
|
|
39
|
+
COPY . /app
|
|
40
|
+
# The lockfile is resolved on macOS (arm64-darwin); add the Linux platforms so
|
|
41
|
+
# `bundle install` stays in lockstep with the pinned versions instead of
|
|
42
|
+
# re-resolving, then install.
|
|
43
|
+
RUN bundle lock --add-platform x86_64-linux aarch64-linux \
|
|
44
|
+
&& bundle install
|
|
45
|
+
|
|
46
|
+
# Run rubino from the source checkout WITHOUT changing the caller's cwd, so the
|
|
47
|
+
# agent operates on the mounted /work dir, not /app. (A source checkout has no
|
|
48
|
+
# working binstub; this wrapper replaces it.)
|
|
49
|
+
RUN printf '#!/usr/bin/env bash\nexport BUNDLE_GEMFILE=/app/Gemfile\nexec bundle exec /app/exe/rubino "$@"\n' \
|
|
50
|
+
> /usr/local/bin/rubino \
|
|
51
|
+
&& chmod +x /usr/local/bin/rubino
|
|
52
|
+
|
|
53
|
+
ENV RUBINO_HOME=/root/.rubino
|
|
54
|
+
RUN mkdir -p /root/.rubino /work
|
|
55
|
+
WORKDIR /work
|
|
56
|
+
CMD ["bash"]
|
data/agent.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# agent.md — session state & decisions (handoff)
|
|
2
|
+
|
|
3
|
+
> Working handoff for the next agent. NOT the project guide — that's `AGENTS.md`.
|
|
4
|
+
> Branch: `test/pre-release-gate`.
|
|
5
|
+
|
|
6
|
+
## How this is run (no Docker)
|
|
7
|
+
|
|
8
|
+
- rubino runs from THIS checkout via `~/.local/bin/rubino-dev` (forces ruby
|
|
9
|
+
3.4.7, `BUNDLE_GEMFILE` = this repo, does NOT change cwd → operates on the dir
|
|
10
|
+
you invoke it from). Works anywhere on the machine; whatever is checked out
|
|
11
|
+
here is what runs — no reinstall. The installed gem `0.4.0` is the OLD fallback
|
|
12
|
+
WITHOUT our fixes — always verify with `rubino-dev`.
|
|
13
|
+
- LLM backend: local OpenAI-compatible server on `127.0.0.1:8000` = **`ds4-serve`**
|
|
14
|
+
(DeepSeek: `deepseek-v4-flash`, `deepseek-v4-pro`). It **STREAMS tool-call
|
|
15
|
+
argument deltas** (a file write is on the wire as it generates) AND round-trips
|
|
16
|
+
`reasoning_content`. Config: `~/.rubino/config.yml` → `model.default:
|
|
17
|
+
deepseek-v4-flash`, provider `gateway` (openai_compatible, base_url
|
|
18
|
+
`127.0.0.1:8000/v1`).
|
|
19
|
+
- ⚠️ **ds4-server is a SINGLE KV slot and has no auto-restart; it CRASHES under
|
|
20
|
+
large generations.** If a "freeze" reappears, FIRST check it's still up:
|
|
21
|
+
`curl -s 127.0.0.1:8000/v1/models`. Its log is `/tmp/ds4-server.log` — the
|
|
22
|
+
single best diagnostic (see below).
|
|
23
|
+
- Verify TUI behavior in a REAL terminal (offline PTY/pyte capture misses
|
|
24
|
+
raw-mode defects). The fastest objective probe is a PTY driver that timestamps
|
|
25
|
+
stdout (scratchpad/pty_*.rb in past sessions) + tailing `/tmp/ds4-server.log`.
|
|
26
|
+
|
|
27
|
+
## The local-performance work — DONE + validated live (this branch)
|
|
28
|
+
|
|
29
|
+
The user's "freeze on the local config" was THREE distinct bugs. All fixed and
|
|
30
|
+
verified live against ds4 (see commits on this branch). Read
|
|
31
|
+
`~/.claude/.../memory/reference_rubino_kv_cache_bust_rootcause.md` +
|
|
32
|
+
`project_rubino_kv_cache_fix.md` for the full diagnosis.
|
|
33
|
+
|
|
34
|
+
1. **Cross-turn freeze = KV prefix-cache busting (#608b/#608c).** ds4 only reuses
|
|
35
|
+
cache on a PURE prefix-extension (`common==live`); any divergence → full
|
|
36
|
+
`ctx=0..N` re-prefill (grows with context = "freeze after N turns").
|
|
37
|
+
- **Reasoning replay:** rubino dropped the assistant's `reasoning_content`, so
|
|
38
|
+
the replay diverged from the server's KV where reasoning was generated.
|
|
39
|
+
Now persisted (`metadata[:reasoning]`) and replayed as wire
|
|
40
|
+
`reasoning_content` (Hermes conversation_loop.py:940 parity). Bug found:
|
|
41
|
+
`extract_thinking` read `response.reasoning` (nonexistent) not `.thinking`.
|
|
42
|
+
Files: loop.rb, ruby_llm_adapter.rb (normalize_intermediate/rebuild_thinking/
|
|
43
|
+
load_history), session/message.rb. Effect: turn 2+ 21s → 0.6s.
|
|
44
|
+
- **Aux off-slot gate:** post-turn memory-extraction/distill ran a divergent
|
|
45
|
+
no-tools prompt on the SAME slot every turn → evicted the main KV. Now
|
|
46
|
+
SKIPPED on the interactive REPL when the aux task resolves to the main
|
|
47
|
+
endpoint (`Configuration#auxiliary_on_main_endpoint?`); extraction happens at
|
|
48
|
+
session-end flush + compaction (no recall lost — the per-session memory
|
|
49
|
+
snapshot is frozen anyway). `interactive` flag threaded build_runner(default
|
|
50
|
+
true)→setup_oneshot(false)→Runner→Lifecycle. A DISTINCT aux endpoint keeps
|
|
51
|
+
the inter-turn cadence. Files: configuration.rb, lifecycle.rb, runner.rb,
|
|
52
|
+
chat_command.rb.
|
|
53
|
+
|
|
54
|
+
2. **Large-write freeze = dead UI past the preview cap (#608d).** ds4 streams a
|
|
55
|
+
big `write`'s args for minutes; after the 30-line preview cap `tool_chunk`
|
|
56
|
+
stopped emitting and the facet was hidden → ~38s of dead screen. Fix (cli.rb):
|
|
57
|
+
`tool_params_feed`/`tool_chunk` stream the params IN FULL (`full: true`, no
|
|
58
|
+
cap — the user watches the file land; the cap stays for tool OUTPUT) + an
|
|
59
|
+
animated facet during arg streaming. Verified: full content shown, UI silence
|
|
60
|
+
38s → 6.3s. NB the deltas are INCREMENTAL (not cumulative — no O(N²)); the
|
|
61
|
+
model is just genuinely slow (~17-22 t/s, degrading) for big files.
|
|
62
|
+
|
|
63
|
+
3. **`ctx` gauge frozen during a run (#608e).** The bar repainted only at turn
|
|
64
|
+
boundaries and read persisted messages. Fix: `chat_command#live_status_meter`
|
|
65
|
+
captures the base once → cheap no-DB lambda on `ui.live_status_provider`; the
|
|
66
|
+
cli ticker (`refresh_live_ctx_bar`, ~1/s) feeds it `@turn_tok_chars/4` and
|
|
67
|
+
repaints. `build_status_line` refactored to share `render_status_bar`.
|
|
68
|
+
Verified: ctx climbs 0k→1.2k during a write.
|
|
69
|
+
|
|
70
|
+
### Diagnostic playbook (reuse this)
|
|
71
|
+
- `/tmp/ds4-server.log`: `live kv cache miss … common=N reason=token-mismatch`
|
|
72
|
+
then `chat ctx=0..N:N prompt done <s>` = a full re-prefill. `common==live` +
|
|
73
|
+
`ctx=K..N:small done 0.6s` = a cache HIT (what you want). Within-turn tool
|
|
74
|
+
iterations already hit; the bug is at turn boundaries.
|
|
75
|
+
- Repro multi-turn: `rubino-dev -q "…" --yolo` then `-c -q "…"` (one-shot
|
|
76
|
+
continues a session) OR a PTY driver for true interactive multi-turn (the gate
|
|
77
|
+
is REPL-only, so one-shot won't show the aux-eviction fix). `RUBINO_HOME=<tmp>`
|
|
78
|
+
+ a sed'd config isolates variables. A logging TCP proxy (:8999→:8000) captures
|
|
79
|
+
request/response bodies to confirm delta-vs-cumulative and reasoning replay.
|
|
80
|
+
|
|
81
|
+
## Other uncommitted history folded into this branch's tip
|
|
82
|
+
- **Streaming tool-call params UX (#608):** `lib/rubino/ui/tool_args_stream.rb`
|
|
83
|
+
(single-pass JSON streaming decoder, surfaces string VALUES), adapter
|
|
84
|
+
`announce_tool_stream` (emits `:tool_preparing` + `:tool_args`), `api.rb`/`cli.rb`
|
|
85
|
+
sinks, byte-batching in `stdout_proxy.rb`/`cli.rb`. The token/speed footer plan
|
|
86
|
+
is now partly realized by the live `ctx` gauge (#608e); a tok/s readout is still
|
|
87
|
+
open.
|
|
88
|
+
- **Per-turn SummarizeSessionJob removed:** the running summary is produced ONLY
|
|
89
|
+
by threshold-gated compaction (Hermes/Claude-Code parity), never a background
|
|
90
|
+
job every turn. Handler deleted; `auto_summarize` config + `memory_auto_summarize?`
|
|
91
|
+
gone. (This ALSO removed one of the per-turn aux-LLM calls — aligned with #608c.)
|
|
92
|
+
|
|
93
|
+
## Backlog / NOT done
|
|
94
|
+
- **Fix C (prompt normalization + volatile-to-tail):** NOT needed (cache hits
|
|
95
|
+
already land without it; the frozen snapshot keeps volatile_tail stable).
|
|
96
|
+
Offered as optional strict-Hermes parity hardening only.
|
|
97
|
+
- **Large files are genuinely slow on ds4** (model throughput, not rubino). The
|
|
98
|
+
UI now shows progress; making it FASTER is behavioral (steer toward edits /
|
|
99
|
+
smaller writes) — not yet done.
|
|
100
|
+
- **tok/s readout** in the footer (the other half of the #608 token-footer plan).
|
|
101
|
+
- 5 PRE-EXISTING host rspec failures (ruby_tool load-path, fresh_home_db schema)
|
|
102
|
+
— NOT regressions; see `reference_rubino_host_rspec_env_failures`.
|
|
103
|
+
|
|
104
|
+
## Constraints / protocol
|
|
105
|
+
- Clean code / DRY; refactor when it keeps things clean.
|
|
106
|
+
- Repo/commit/PR content in English. NO co-author / "Generated with" trailers.
|
|
107
|
+
- Verify with `rubino-dev` against live ds4 before claiming a TUI fix works
|
|
108
|
+
(offline render misses raw-mode defects).
|
|
109
|
+
|
|
110
|
+
## Test status
|
|
111
|
+
Full suite green except the 5 pre-existing host failures above
|
|
112
|
+
(`6376 examples, 5 failures, 8 pending`). Rubocop clean on all touched files.
|
data/docs/api/v1.md
CHANGED
|
@@ -350,6 +350,8 @@ Cooperative cancel of a running task (descendant ask-gates are cancelled too). R
|
|
|
350
350
|
|
|
351
351
|
## OAuth
|
|
352
352
|
|
|
353
|
+
> **WIP — not wired end-to-end.** These endpoints work and store encrypted tokens, but **no tool consumes a connection's token yet** and there is no CLI surface. See the status banner in [`docs/oauth-providers.md`](../oauth-providers.md) (and issue #590: native vs MCP-delegated).
|
|
354
|
+
|
|
353
355
|
See [`docs/oauth-providers.md`](../oauth-providers.md) for the full PKCE flow, encryption key requirements, and per-provider setup. The HTTP surface:
|
|
354
356
|
|
|
355
357
|
### `GET /v1/oauth/providers` → 200
|
data/docs/commands.md
CHANGED
|
@@ -179,7 +179,6 @@ Type these inside `rubino chat`. Generated from `BuiltIns::DESCRIPTIONS` (drift-
|
|
|
179
179
|
| `/agent` | Switch the primary agent (/agent <name>; a bare /<name> or Tab cycles) |
|
|
180
180
|
| `/agents` | List background subagents; ↓+Enter to attach & steer one live, or steer/probe/view by id |
|
|
181
181
|
| `/tasks` | Alias for /agents |
|
|
182
|
-
| `/reply` | Answer a subagent that is blocked waiting on you (e.g. an approval) |
|
|
183
182
|
| `/stop` | Stop a running subagent (/stop <id>; alias for /agents <id> --stop) |
|
|
184
183
|
| `/jobs` | List the background job queue (status counts); /jobs <id> for detail |
|
|
185
184
|
| `/skills` | List skills; activate one ('none' clears), or enable/disable NAME |
|
|
@@ -317,7 +316,7 @@ Read (and set) configuration without leaving the REPL, over the same **effective
|
|
|
317
316
|
|
|
318
317
|
Gets resolve default-valued keys (not just what's in the file), and secret-named keys (`api_key`, tokens, …) render masked — exactly like `rubino config show`. A set writes through `Config::Writer` (the same persist path `/reasoning` and `/think` use) **and** updates the live configuration, so it survives the session and applies from the next turn; consumers that memoize their config (e.g. the memory backend) still need a restart. Typing `/config ` opens a dropdown with the verbs plus the known config keys flattened from the defaults tree; after `get`/`set` the keys complete again.
|
|
319
318
|
|
|
320
|
-
### Background subagents: `/agents`
|
|
319
|
+
### Background subagents: `/agents`
|
|
321
320
|
|
|
322
321
|
The agent spawns background subagents with its `task` tool; these commands are the human surface over them (full model in [agents.md](agents.md)):
|
|
323
322
|
|
|
@@ -327,8 +326,6 @@ The agent spawns background subagents with its `task` tool; these commands are t
|
|
|
327
326
|
/agents <id> --stop # cancel a running subagent (blocked descendants unwind too)
|
|
328
327
|
/agents <id> steer "note" # park a note folded into the child's context at its next turn
|
|
329
328
|
/agents <id> probe "question" # ephemeral read-only peek — nothing is saved to the child
|
|
330
|
-
/reply <id> <answer> # answer a subagent blocked on you (e.g. an approval)
|
|
331
|
-
/reply # bare: list the subagents currently blocked on you
|
|
332
329
|
```
|
|
333
330
|
|
|
334
331
|
`/tasks` is an alias for `/agents`.
|
|
@@ -337,9 +334,9 @@ The agent spawns background subagents with its `task` tool; these commands are t
|
|
|
337
334
|
idle prompt to open the subagent picker, arrow to one, and `Enter` to **attach**:
|
|
338
335
|
the screen switches to that agent's own full timeline (its tool calls and what it
|
|
339
336
|
said, replayed) and the prompt becomes scoped — `sa_xxxx ❯`. While attached, just
|
|
340
|
-
type to steer the running child
|
|
337
|
+
type to steer the running child; `←` on the
|
|
341
338
|
empty prompt (or `/detach`) returns to the main timeline. The scoped prompt makes
|
|
342
|
-
the global `/agents <id> steer/probe`
|
|
339
|
+
the global `/agents <id> steer/probe` forms redundant — they're
|
|
343
340
|
the same operations, by id, from anywhere.
|
|
344
341
|
|
|
345
342
|
### Workspace roots: `/add-dir` and `/dirs`
|
data/docs/configuration.md
CHANGED
|
@@ -227,6 +227,9 @@ display:
|
|
|
227
227
|
statusbar: true # the model + context bar under the chat input
|
|
228
228
|
tool_output_preview_lines: 3 # head lines of tool output shown in the transcript (0 = full dump)
|
|
229
229
|
input_max_rows: 8 # chat input grows up to this many rows, then scrolls
|
|
230
|
+
live_markdown: true # format the in-flight streamed block live (false = raw live tail)
|
|
231
|
+
synchronized_output: true # atomic frames via DEC-2026 BSU/ESU (false = legacy per-write frames)
|
|
232
|
+
code_highlight: true # syntax-highlight committed code blocks (Rouge); false = plain
|
|
230
233
|
|
|
231
234
|
paste:
|
|
232
235
|
collapse_lines: 5 # pastes longer than this collapse to a placeholder
|
|
@@ -246,6 +249,10 @@ context:
|
|
|
246
249
|
- `display.statusbar` (default `true`) pins a dim one-line bar UNDER the chat input — the session mode first (plus the branch/skill tokens when set), then the resolved model id and context saturation, e.g. `default · MiniMax-M3 · ctx ~8.4k/64k (13%)` (the percentage is omitted below 1%). The mode token is the live mode indicator (the prompt itself is a constant `▍❯ `): dim `default`, yellow `plan`, red `yolo`. Saturation uses the REAL usage the provider reported for the last response when available (the full assembled prompt, recorded by the agent loop), else the same chars/4 estimate compaction runs on (`Context::TokenBudget`); the window comes from `model.context_length` / `context.max_tokens`. It refreshes at turn boundaries (after each turn footer, and on session resume), never per stream delta. The percentage turns yellow at 70% and red at 90%; with no usable window only the token count shows. The bar is omitted off a TTY or on terminals narrower than 40 columns.
|
|
247
250
|
- `display.tool_output_preview_lines` (default `3`) caps how many head lines of each tool's output the transcript shows before a dim `… +N lines (full output → context)` marker. DISPLAY-ONLY: the model always receives the full output (subject to the `tool_output` truncation caps) — only the scrollback rendering collapses. Set `0` to restore the old full dump.
|
|
248
251
|
- `display.input_max_rows` (default `8`) caps how many visual rows the chat input grows to as a long or multi-line prompt wraps; past the cap the input scrolls vertically, keeping the caret row in view.
|
|
252
|
+
- `display.live_markdown` (default `true`) renders the still-streaming (in-flight) block as FORMATTED markdown in the live region — bold, headings, lists and code style as the tokens arrive, with syntax left open by the partial stream repaired (an open code fence shows as a code block, a dangling `**`/`` ` `` span is closed) so no raw marker leaks. Set `false` for the legacy raw rolling-tail that only snaps to styled when the block commits. Display-only; the committed scrollback render is identical either way.
|
|
253
|
+
- `display.synchronized_output` (default `true`) wraps each live-region frame in DEC private mode 2026 (BSU/ESU synchronized output) so a supporting terminal (kitty, WezTerm, tmux ≥3.4, recent xterm.js) buffers the whole clear→commit→redraw sequence and swaps it in one atomic update — no flicker or tearing on multi-step repaints. Terminals without support silently ignore the mode (it degrades cleanly); the escapes are emitted only to a real TTY. Set `false` for the legacy per-write frames.
|
|
254
|
+
- `display.code_highlight` (default `true`) syntax-highlights fenced code blocks by language (via Rouge) in the COMMITTED render — the live tail stays unstyled, so highlighting never blocks the stream (code shows instantly, colours arrive a beat later when the block commits, like Claude Code). Unknown languages, language-less fences, and any failure fall back to the plain code body. Set `false` for plain (uncoloured) code blocks.
|
|
255
|
+
- An **unterminated** code fence at end-of-stream — a fence the model never closed, or closed with a too-short bare run of backticks (e.g. MiniMax-M3 emitting `` against a ``` opener) — is rendered as a code box, matching CommonMark's end-of-document auto-close (§4.5) that every other renderer relies on. The CLI synthesises the close at the opener length (never relaxing the "close ≥ opener" rule), because kramdown does not auto-close an open fence.
|
|
249
256
|
- `paste.collapse_lines` (default `5`) — the file-backed paste pipeline's first tier. Pasting MORE than this many lines into the chat input inserts a single cyan `[Pasted text #N +M lines]` placeholder instead of flooding the composer; the placeholder is one editable token (backspace deletes it whole, you can type around it, it survives ↑ draft recall and Alt+Enter queueing) and expands to the full pasted body when the message is sent — the model sees everything, while the transcript echo keeps the compact placeholder. Pastes at or under the threshold inline as real rows, exactly as before.
|
|
250
257
|
- `paste.file_threshold_tokens` (default `8000`) — the second tier. A paste estimated above this many tokens (chars/4, the same rule compaction uses) is written to `<RUBINO_HOME>/sessions/<session-id>/paste_N.txt` instead of being held inline, and the sent message carries `[Pasted text #N saved to <path> — too large to inline; read it with the read tool]` so the model reads just the parts it needs. The files persist for the session; `/clear-images` does not touch them (it only drops staged image attachments).
|
|
251
258
|
|
|
@@ -303,7 +310,6 @@ tasks:
|
|
|
303
310
|
max_children_per_node: 3 # max LIVE direct children per node
|
|
304
311
|
max_concurrent_total: 8 # hard ceiling on total LIVE subagents across the tree
|
|
305
312
|
max_live_probes_per_child: 5 # per-child budget for billed live probes (probe(live: true))
|
|
306
|
-
ask_parent_timeout: 900 # vestigial: governed the removed child→parent ask channel; no effect now
|
|
307
313
|
```
|
|
308
314
|
|
|
309
315
|
### tools
|
|
@@ -657,13 +663,14 @@ agents:
|
|
|
657
663
|
mcp_servers: []
|
|
658
664
|
```
|
|
659
665
|
|
|
660
|
-
###
|
|
666
|
+
### api
|
|
661
667
|
|
|
662
|
-
|
|
663
|
-
server
|
|
664
|
-
|
|
665
|
-
|
|
668
|
+
The API server's listen port and bind host come from the CLI, not config:
|
|
669
|
+
`rubino server --port <n>` (or `RUBINO_API_PORT`, default `4820`) and `--host`
|
|
670
|
+
(or `RUBINO_API_HOST`). The bearer token is `RUBINO_API_KEY`. The `api` block
|
|
671
|
+
configures payload caps, rate limiting, and the public-bind gate:
|
|
666
672
|
|
|
673
|
+
```yaml
|
|
667
674
|
api:
|
|
668
675
|
max_body_bytes: 5242880 # 5 MB cap on JSON request bodies (413 past this)
|
|
669
676
|
max_upload_bytes: 52428800 # 50 MB cap on multipart uploads
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Porting Hermes' interactive PTY shell to rubino
|
|
2
|
+
|
|
3
|
+
Status: DESIGN (deep-study of Hermes, no impl yet) · Branch: `feat/bg-shell-ux`
|
|
4
|
+
Source studied: `hermes-agent/tools/process_registry.py`, `hermes-agent/tools/terminal_tool.py`,
|
|
5
|
+
`hermes-agent/hermes_cli/pty_bridge.py`.
|
|
6
|
+
|
|
7
|
+
## Why PTY (the corrected conclusion)
|
|
8
|
+
|
|
9
|
+
A pipe-backed background shell has `stdin=DEVNULL` and can't answer `y/N`, sudo passwords,
|
|
10
|
+
or run TTY-aware/curses programs. Hermes (and Codex `unified_exec`, and the open Claude
|
|
11
|
+
Code FR) all converge on a **PTY**: the process believes it's on a real terminal, and the
|
|
12
|
+
user's keystrokes/answers are written to the PTY master. We follow Hermes.
|
|
13
|
+
|
|
14
|
+
## Hermes' model (the algorithm we port, with refs)
|
|
15
|
+
|
|
16
|
+
1. **Spawn.** `ProcessRegistry.spawn_local(use_pty=True)` (`process_registry.py:515`) →
|
|
17
|
+
`ptyprocess.PtyProcess.spawn(cmd, env, ...)` (`:553`); the handle is stored on
|
|
18
|
+
`ProcessSession._pty` (`:134`). Pipe fallback when ptyprocess is absent. Pipe mode is
|
|
19
|
+
`stdin=DEVNULL` (`:605`) — deliberately non-interactive.
|
|
20
|
+
2. **Output reader.** `_pty_reader_loop` (`:814`) `pty.read(4096)` until `pty.isalive()` is
|
|
21
|
+
false; captures `exitstatus`. Feeds `_check_watch_patterns` on each chunk (`:748/784/828`).
|
|
22
|
+
3. **Input primitives.** `write_stdin(id, data)` (`:1184`) → `_pty.write(bytes)`;
|
|
23
|
+
`submit_stdin(id, data="")` = `write_stdin(data + "\n")` (press Enter, `:1209`);
|
|
24
|
+
`close_stdin(id)` = EOF without kill (`:1213`).
|
|
25
|
+
4. **Interactive prompt routing.** Thread-local UI callbacks: `set_sudo_password_callback`
|
|
26
|
+
/ `set_approval_callback` (`terminal_tool.py:189-205`). When unset, fall back to
|
|
27
|
+
`/dev/tty` / `input()`. The CLI registers them so prompts run through the TUI event loop.
|
|
28
|
+
5. **Sudo password.** Detect `sudo` (`_rewrite_real_sudo_invocations`, `:501`), prompt the
|
|
29
|
+
user with HIDDEN input ("input is hidden", `:404`), cache per scope
|
|
30
|
+
(`_sudo_password_cache`, scope = session-key / callback-owner / thread, `:205-240`), feed
|
|
31
|
+
it to the process. Cache cleared on teardown (`_reset_cached_sudo_passwords`).
|
|
32
|
+
6. **Watch patterns.** Regexes scan new output to detect notable lines/prompts; after
|
|
33
|
+
`WATCH_STRIKE_LIMIT` (3) misses, disable + promote to `notify_on_complete` (`:191-288`).
|
|
34
|
+
|
|
35
|
+
## rubino mapping (DRY, faithful, clean-code)
|
|
36
|
+
|
|
37
|
+
rubino already has the skeleton: `Tools::ShellRegistry` (pgid tracking + kill),
|
|
38
|
+
`shell_tool` (background spawn), `shell_input`/`shell_output`/`shell_tail`/`shell_kill`.
|
|
39
|
+
Today it is **pipe-only**. The port adds a PTY mode alongside.
|
|
40
|
+
|
|
41
|
+
| Hermes | rubino target |
|
|
42
|
+
|--------|---------------|
|
|
43
|
+
| `ptyprocess.PtyProcess.spawn` | Ruby stdlib **`PTY.spawn`** (`require "pty"`) — returns `[reader_io, writer_io, pid]` |
|
|
44
|
+
| `ProcessSession._pty` | a `pty_master`/`pty_pid` field on `ShellRegistry::Entry` (`shell_registry.rb:31`) |
|
|
45
|
+
| `_pty_reader_loop` | the existing `drain_into` reader, reading the PTY master instead of the pipe |
|
|
46
|
+
| `write_stdin/submit_stdin/close_stdin` | extend `ShellRegistry.write_input` + add `submit_input` (`+"\n"`) and `close_input` |
|
|
47
|
+
| sudo/approval UI callbacks | **reuse rubino's existing prompt UI** (`UI::CLI#confirm` / the `question` tool / approval menu) — register a thread/fiber-local "shell input needed" callback that surfaces a masked prompt |
|
|
48
|
+
| `_sudo_password_cache` (per scope) | a per-session masked-secret cache (scope = session id), cleared on teardown; mask in scrollback via the existing `SecretsMask` |
|
|
49
|
+
| watch_patterns | OPTIONAL (slice 3) — a prompt detector (`password:`, `[y/N]`) to auto-surface input without the user attaching |
|
|
50
|
+
|
|
51
|
+
### How the USER provides the `y` / password (the goal)
|
|
52
|
+
|
|
53
|
+
Two complementary paths, both writing to the same `write_input` PTY primitive:
|
|
54
|
+
|
|
55
|
+
- **Attach-and-type (the focus view).** When attached to the shell (the bg-shell-as-
|
|
56
|
+
`BackgroundTasks`-entry from `bg-shell-ux.md`), your keystrokes/lines route to
|
|
57
|
+
`ShellRegistry.write_input(id, ...)` → the PTY. You see `[y/N]`, type `y`, it goes in.
|
|
58
|
+
- **Detect-and-prompt (Hermes sudo path, no attach needed).** A registered callback +
|
|
59
|
+
a prompt detector surface a masked/normal prompt inline ("the shell wants input:
|
|
60
|
+
`Password:`"); your answer is written to the PTY. Reuses the `question`/approval UI.
|
|
61
|
+
|
|
62
|
+
## Stages (each clean-code, spec'd, tmux-verified before the next)
|
|
63
|
+
|
|
64
|
+
- **Slice 0 — PTY foundation.** `ShellRegistry` gains a PTY mode (`PTY.spawn`), the reader
|
|
65
|
+
reads the master, `write_input`/`submit_input`/`close_input` work over the PTY. The model
|
|
66
|
+
tools (`shell_input`) already call `write_input`, so the agent can drive an interactive
|
|
67
|
+
bg process. Verify in tmux: `python3 -c "print(input('name? '))"` in bg, `shell_input`
|
|
68
|
+
"x\n", output shows it. (No user-facing UI yet.)
|
|
69
|
+
- **Slice 1 — SEE + STOP + FOCUS** (from `bg-shell-ux.md`): shell as a `BackgroundTasks`
|
|
70
|
+
`kind: :shell` entry → card + picker + `/stop` + attach view (clear + live PTY tail).
|
|
71
|
+
- **Slice 2 — USER types in focus.** Attached keystrokes/lines → `write_input` (the PTY).
|
|
72
|
+
Now you answer `y` yourself in the focus view.
|
|
73
|
+
- **Slice 3 — detect-and-prompt + sudo masked.** Prompt detector + masked password +
|
|
74
|
+
per-session cache, reusing the `question`/approval UI. The Hermes sudo flow.
|
|
75
|
+
|
|
76
|
+
## Gotchas (from the source)
|
|
77
|
+
|
|
78
|
+
- `PTY.spawn` makes the child a session leader (PID == PGID) — matches rubino's existing
|
|
79
|
+
pgid hard-kill, good. But PTY EOF/`Errno::EIO` on child exit must be caught in the reader
|
|
80
|
+
(Ruby's `PTY` raises `PTY::ChildExited`/`Errno::EIO`).
|
|
81
|
+
- Terminal size: a PTY needs a winsize (`TIOCSWINSZ`); set a sane default (e.g. 120x40) or
|
|
82
|
+
the attached terminal's size; resize on attach.
|
|
83
|
+
- Masking: the sudo/secret path MUST run answers through `SecretsMask` so the password
|
|
84
|
+
never lands in scrollback or the output buffer (Hermes hides it; rubino has `SecretsMask`).
|
|
85
|
+
- Don't break the pipe path: keep pipe mode as the default for non-interactive bg work;
|
|
86
|
+
PTY mode is opt-in (a `pty: true`/`interactive: true` arg, or auto when a prompt is likely).
|
|
87
|
+
- Output: a PTY echoes input back + emits control sequences; the output buffer/tail must
|
|
88
|
+
strip/normalize (rubino has `ansi_strip`-equivalent? confirm) so the model/user see clean text.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Review-driven refinements (Slice 1 design lock-in)
|
|
2
|
+
|
|
3
|
+
An adversarial clean-code review of Slice 0 + the bridge plan produced these changes.
|
|
4
|
+
They supersede the "thin adapters" framing in `bg-shell-ux.md` where they conflict.
|
|
5
|
+
|
|
6
|
+
## Slice 0 fixes already applied (from review)
|
|
7
|
+
|
|
8
|
+
- **close_stdin crash-on-retire (BLOCKER):** EOT only a LIVE child; a dead PTY master
|
|
9
|
+
raises `Errno::EIO`, so close the fd instead (also reclaiming a leaked `master_w`).
|
|
10
|
+
Rescue widened to `IOError, Errno::EIO, Errno::EBADF`.
|
|
11
|
+
- **spawn_pty cwd fragility:** `cd … || exit 127\n<cmd>` (own line) — not `cd && (<cmd>)`,
|
|
12
|
+
which a trailing `#`-comment broke.
|
|
13
|
+
- **winsize:** default `40x120` (a fresh PTY is 0x0).
|
|
14
|
+
- Honest comments (EOT is canonical-mode-only; dropped the dead `PTY::ChildExited` catch).
|
|
15
|
+
|
|
16
|
+
## Still-open Slice 0/2 prerequisites (do BEFORE wiring write_input/attach)
|
|
17
|
+
|
|
18
|
+
- **PTY echo:** a cooked PTY echoes typed input back into the buffer → doubled text, and a
|
|
19
|
+
typed password would land in the ring buffer in cleartext. Before the user/agent writes
|
|
20
|
+
to a PTY: turn `ECHO` off via `io/console` for the secret path, and/or strip the echoed
|
|
21
|
+
line at the capture seam. Mask through `SecretsMask`.
|
|
22
|
+
- **Control sequences:** a PTY emits `\r\n` + CSI/OSC. `drain_into` only `scrub_utf8`s.
|
|
23
|
+
For PTY mode, normalize at the capture seam (strip CR, strip non-SGR CSI/OSC) so the
|
|
24
|
+
model isn't fed escapes and the attach view doesn't paint raw escapes (route the attach
|
|
25
|
+
renderer through the same `sanitize_terminal_keep_sgr` the cards use — CWE-150).
|
|
26
|
+
|
|
27
|
+
## DRY: the `kind:` discriminator is a DATA TAG, not a control switch
|
|
28
|
+
|
|
29
|
+
Review verdict: a `case kind` would spray across ≥7 sites (stop_entry, attach view,
|
|
30
|
+
attached-input, cards, menu, watch, completion) — a smell. Instead:
|
|
31
|
+
|
|
32
|
+
1. Give the shell's `BackgroundTasks` entry the **same flat fields** the renderers already
|
|
33
|
+
read (`prompt`=command, `started_at`, synced `status`, `subagent`="shell"). Then
|
|
34
|
+
`SubagentCards`, `AgentMenu`, `render_agent_watch` need **zero** branches. Replace the
|
|
35
|
+
literal `"subagent"` strings with one `entry_kind_label(entry)` helper.
|
|
36
|
+
2. Push the genuinely-divergent behavior behind **~4 polymorphic methods on the entry**
|
|
37
|
+
(or two small duck-typed adapter objects): `#stop`, `#attach_render(ui)`,
|
|
38
|
+
`#feed_input(text)`, `#live?`. Then `stop_entry` → `entry.stop`, `attach_agent_view` →
|
|
39
|
+
`entry.attach_render`, `handle_attached_input` → `entry.feed_input`. **No `case kind`
|
|
40
|
+
in any UI file.** `kind` survives only as the label.
|
|
41
|
+
|
|
42
|
+
## Three gaps to handle when registering a shell entry
|
|
43
|
+
|
|
44
|
+
`BackgroundTasks#reserve` carries subagent semantics a shell must NOT inherit:
|
|
45
|
+
|
|
46
|
+
1. **Concurrency cap:** `reserve` counts against `max_concurrent_total`/depth/per-owner. A
|
|
47
|
+
shell is not an LLM run — it must register WITHOUT consuming the subagent budget
|
|
48
|
+
(separate register path, or exempt `kind: :shell` from `running_count`/`refusal_reason`).
|
|
49
|
+
2. **Double completion notice:** `ShellRegistry#notify_completion` ALREADY pushes
|
|
50
|
+
`[background-shell] finished`. If the BG entry's `complete` also fires a notice, the user
|
|
51
|
+
gets two. Pick ONE owner (keep ShellRegistry's; the BG entry only syncs status).
|
|
52
|
+
3. **Dead steer_queue:** `reserve` allocates a `steer_queue`; a shell can't steer/probe.
|
|
53
|
+
Disable steer/probe for `kind: :shell` (route attached input to `feed_input` → stdin).
|
|
54
|
+
|
|
55
|
+
## Status sync note
|
|
56
|
+
|
|
57
|
+
Two status sources (ShellRegistry `wait_thr`-derived vs BG stored): sync the BG entry to
|
|
58
|
+
terminal only at `notify_completion`. There's a small window where a just-killed shell still
|
|
59
|
+
reads live until the reader thread fires — acceptable, documented.
|
|
60
|
+
|
|
61
|
+
## Sandbox/pgid: confirmed intact under PTY.spawn
|
|
62
|
+
|
|
63
|
+
`PTY.spawn` setsid's the child → `pgid == pid` (pgroup:true redundant); the sandbox launcher
|
|
64
|
+
still `exec`s bash in place, so the write-jail + pgid-kill are identical to the pipe path.
|
|
65
|
+
Only cwd handling diverged (fixed above).
|