rubino-agent 0.5.1 → 0.5.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +15 -0
  3. data/CHANGELOG.md +127 -0
  4. data/Dockerfile +56 -0
  5. data/agent.md +112 -0
  6. data/docs/api/v1.md +2 -0
  7. data/docs/commands.md +3 -6
  8. data/docs/configuration.md +13 -6
  9. data/docs/design/bg-shell-pty-port.md +88 -0
  10. data/docs/design/bg-shell-review-refinements.md +65 -0
  11. data/docs/design/bg-shell-ux.md +130 -0
  12. data/docs/oauth-providers.md +21 -0
  13. data/docs/tools.md +3 -12
  14. data/lib/rubino/agent/iteration_budget.rb +13 -0
  15. data/lib/rubino/agent/loop.rb +43 -5
  16. data/lib/rubino/agent/prompts/build.txt +10 -5
  17. data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
  18. data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
  19. data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
  20. data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
  21. data/lib/rubino/agent/runner.rb +55 -12
  22. data/lib/rubino/agent/tool_executor.rb +1 -1
  23. data/lib/rubino/api/operations/tasks/stop_operation.rb +0 -3
  24. data/lib/rubino/attachments/classify.rb +0 -1
  25. data/lib/rubino/cli/chat/completion_builder.rb +0 -8
  26. data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
  27. data/lib/rubino/cli/chat_command.rb +324 -171
  28. data/lib/rubino/cli/commands.rb +5 -0
  29. data/lib/rubino/commands/built_ins.rb +0 -1
  30. data/lib/rubino/commands/executor.rb +1 -7
  31. data/lib/rubino/commands/handlers/agents.rb +55 -265
  32. data/lib/rubino/commands/handlers/status.rb +6 -3
  33. data/lib/rubino/compression/line_skeleton.rb +1 -1
  34. data/lib/rubino/compression/python_code_skeleton.rb +1 -1
  35. data/lib/rubino/compression/ruby_code_skeleton.rb +1 -1
  36. data/lib/rubino/compression/tree_sitter_code_skeleton.rb +1 -1
  37. data/lib/rubino/config/configuration.rb +47 -18
  38. data/lib/rubino/config/defaults.rb +57 -33
  39. data/lib/rubino/context/prompt_assembler.rb +89 -1
  40. data/lib/rubino/context/summary_builder.rb +0 -22
  41. data/lib/rubino/context/token_budget.rb +0 -5
  42. data/lib/rubino/errors.rb +2 -2
  43. data/lib/rubino/interaction/events.rb +2 -2
  44. data/lib/rubino/interaction/lifecycle.rb +54 -20
  45. data/lib/rubino/llm/anthropic_role_merge.rb +75 -0
  46. data/lib/rubino/llm/error_classifier.rb +34 -1
  47. data/lib/rubino/llm/fake_provider.rb +0 -4
  48. data/lib/rubino/llm/ruby_llm_adapter.rb +222 -59
  49. data/lib/rubino/llm/stream_tool_call_recovery.rb +91 -0
  50. data/lib/rubino/llm/tool_call_recovery.rb +177 -0
  51. data/lib/rubino/memory/sqlite_extraction_prompt.rb +0 -2
  52. data/lib/rubino/memory/store.rb +0 -19
  53. data/lib/rubino/security/pattern_matcher.rb +0 -2
  54. data/lib/rubino/security/redactor.rb +1 -1
  55. data/lib/rubino/security/secret_path.rb +16 -4
  56. data/lib/rubino/session/message.rb +12 -0
  57. data/lib/rubino/skills/registry.rb +16 -2
  58. data/lib/rubino/tools/background_tasks.rb +132 -228
  59. data/lib/rubino/tools/base.rb +1 -17
  60. data/lib/rubino/tools/grep_tool.rb +13 -1
  61. data/lib/rubino/tools/question_tool.rb +3 -4
  62. data/lib/rubino/tools/read_attachment_tool.rb +52 -54
  63. data/lib/rubino/tools/registry.rb +21 -72
  64. data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
  65. data/lib/rubino/tools/shell_input_tool.rb +1 -1
  66. data/lib/rubino/tools/shell_kill_tool.rb +4 -4
  67. data/lib/rubino/tools/shell_registry.rb +178 -38
  68. data/lib/rubino/tools/shell_tool.rb +45 -5
  69. data/lib/rubino/tools/steer_tool.rb +3 -4
  70. data/lib/rubino/tools/task_result_tool.rb +4 -1
  71. data/lib/rubino/tools/task_stop_tool.rb +5 -7
  72. data/lib/rubino/tools/task_tool.rb +81 -35
  73. data/lib/rubino/tools/vision_tool.rb +1 -1
  74. data/lib/rubino/tools/write_tool.rb +22 -2
  75. data/lib/rubino/ui/agent_menu.rb +8 -4
  76. data/lib/rubino/ui/api.rb +11 -0
  77. data/lib/rubino/ui/bottom_composer.rb +240 -374
  78. data/lib/rubino/ui/cli.rb +381 -155
  79. data/lib/rubino/ui/input_history.rb +0 -5
  80. data/lib/rubino/ui/live_region.rb +18 -1
  81. data/lib/rubino/ui/markdown_renderer.rb +51 -4
  82. data/lib/rubino/ui/markdown_repair.rb +114 -0
  83. data/lib/rubino/ui/notifier.rb +4 -10
  84. data/lib/rubino/ui/stdout_proxy.rb +25 -10
  85. data/lib/rubino/ui/streaming_markdown.rb +79 -12
  86. data/lib/rubino/ui/subagent_cards.rb +18 -44
  87. data/lib/rubino/ui/tool_args_stream.rb +143 -0
  88. data/lib/rubino/update_check.rb +10 -2
  89. data/lib/rubino/util/ignore_rules.rb +18 -2
  90. data/lib/rubino/util/secrets_mask.rb +0 -9
  91. data/lib/rubino/version.rb +1 -1
  92. data/lib/rubino.rb +33 -7
  93. data/rubino-agent.gemspec +1 -0
  94. metadata +31 -5
  95. data/AGENTS.md +0 -97
  96. data/docs/agents.md +0 -224
  97. data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
  98. data/lib/rubino/tools/summarize_file_tool.rb +0 -194
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1debe685b923c625e0dc4dcf95da3c9fc12fcd6c73bdff71e35164279e62b06
4
- data.tar.gz: 5451e122fc13bfdd4ffeba0e680cad9fb6b976dfabe8f9dcfd894e5215ac9688
3
+ metadata.gz: ea93727a0527a270cfbad507d459eb7676532dd4f715967c9d6924cc9aa81c82
4
+ data.tar.gz: c11bf3ca63ed02a7705447fd65cd58f197939631c1c408a0bb7415b59e333c97
5
5
  SHA512:
6
- metadata.gz: bf657914d128053ffa39d7911a5c2c12e491ff45b1907e8bc78a694b8f8d7540a1e8d1182ee5831670af17e5e64b16148202d987ebc9e2c75604a88f78148d36
7
- data.tar.gz: eefe6fbbcd977bff1cf8b7a189fdaf73daee9ca6b12ca55b82876a99c55ede529dc78ba3f5146f8b34a7e6e6b6b38dab454c9d6cdcf3baca8e754187f49c6829
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` and `/reply`
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 (or answer it if it's blocked on you); `←` on the
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` and `/reply <id>` forms redundant — they're
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`
@@ -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
- ### server / api
666
+ ### api
661
667
 
662
- ```yaml
663
- server:
664
- port: 4820
665
- auth: false
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).