Summary
Today the bash tool runs strictly in the foreground: spawn → wait_with_output(timeout) → return. Anything long-running has to be wedged into a single blocking call with a generous timeout, and the agent has no way to stream progress, react when a job exits, or kill a hung process.
Claude Code's model (used by the agent that filed this issue) is much better for any non-trivial shell work. Three small additions cover ~95% of the value:
bash(run_in_background=true) — spawn, return a shell handle, don't wait.
bash_output(id) + bash_kill(id) — fetch buffered stdout/stderr since last read, or terminate.
Monitor — register an event source (typically a background shell). Each new stdout line is delivered as an event to the agent loop, so the model can poll-until-condition without blocking the turn on a long sleep.
Item (1) alone is a big quality-of-life win and is the smallest unit worth shipping. Items (2) and (3) follow naturally.
Today
ignis/src/tools/bash.rs:60-118 — single-shot path:
let child = tokio::process::Command::new(\"bash\").arg(\"-c\").arg(command)…spawn();
let result = tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output()).await;
No shell registry, no output buffer, no kill handle. grep -rn \"run_in_background\\|BashOutput\\|KillShell\\|task_id\" src/ returns nothing.
Proposed surface (v1, minimal)
bash
Add an optional run_in_background: bool (default false, preserves current behavior).
false → unchanged.
true → spawn, append to a BackgroundShells registry keyed by a short ID (e.g. sh_a1b2c3), return { \"shell_id\": \"sh_a1b2c3\" } immediately. The process inherits cwd / permission posture from the parent.
bash_output
- Args:
{ shell_id: string, filter?: string }
- Returns: stdout + stderr buffered since the last
bash_output call for this id, plus a status field (running / exited:<code> / killed).
- Buffer is bounded (mirror existing
BASH_OUTPUT_LIMIT = 50 KiB); on overflow, keep the tail and emit a [truncated N bytes] marker.
bash_kill
- Args:
{ shell_id: string }
- Sends SIGTERM (then SIGKILL after a short grace period) and removes the entry. Idempotent.
Monitor (stretch, but the real win)
- Args:
{ shell_id: string, until?: string } (until = a regex or substring; when matched on a stdout line, the monitor auto-stops and returns the matching line as the tool result).
- Behavior: while running, each stdout line is delivered as an
AgentEvent so the live TUI can show it and the agent loop can decide what to do next.
- Without
until: emits everything until the shell exits, then resolves.
- Replaces the awful
bash 'until <check>; do sleep 2; done' pattern with a first-class primitive.
Why this matters
Concrete things the model can do once this lands:
npm test --watch, cargo watch -x check, tail -f log — start once, poll output as new lines arrive, don't waste an entire 60s tool budget per iteration.
- CI / deploy / long migrations — kick off, get a shell id, work on something else, come back via
bash_output.
- Polling external state (a queue, a deploy URL) without blocking the whole turn on
sleep.
Today every one of these requires either choosing the timeout pessimistically (and burning tokens waiting) or fragile chains of foreground bash calls.
Implementation sketch
- New module
ignis/src/tools/background.rs (or extend bash.rs) with a BackgroundShells: Arc<Mutex<HashMap<String, ShellEntry>>> registered in Tools alongside BashTool.
ShellEntry { child: tokio::process::Child, stdout_buf: BytesMut, stderr_buf: BytesMut, status: ShellStatus }. Spawn drains stdout/stderr into the buffers via two tokio::task::spawn readers; capped at BASH_OUTPUT_LIMIT per stream with a circular tail.
bash_output snapshots the buffers, drains the read cursor, returns string + status. No new dep needed.
- For
Monitor, the existing AgentEvent channel in the runner is the natural delivery path — every reader-task line that satisfies the filter forwards an event.
- All sandbox / permission posture should be inherited from the existing
BashTool path: backgrounding doesn't bypass approvals (treat it as a single tool call gated up-front, same as foreground).
- Cleanup: on session exit, send SIGTERM to all live entries (
Drop on Tools / explicit hook in runner.rs).
Scope notes (YAGNI)
- In: background spawn, output drain, kill, optional Monitor with line-regex.
- Out for v1: stdin to background shells, stream-on-write hooks, persistent shells across sessions, structured exit-on-pattern beyond a simple regex. Add only if real usage demands them.
Environment
- Branch:
feat/minimax-provider at 7777124
- Reference: Claude Code's
Bash(run_in_background) + BashOutput + KillShell + Monitor tools.
Summary
Today the
bashtool runs strictly in the foreground: spawn →wait_with_output(timeout)→ return. Anything long-running has to be wedged into a single blocking call with a generous timeout, and the agent has no way to stream progress, react when a job exits, or kill a hung process.Claude Code's model (used by the agent that filed this issue) is much better for any non-trivial shell work. Three small additions cover ~95% of the value:
bash(run_in_background=true)— spawn, return a shell handle, don't wait.bash_output(id)+bash_kill(id)— fetch buffered stdout/stderr since last read, or terminate.Monitor— register an event source (typically a background shell). Each new stdout line is delivered as an event to the agent loop, so the model can poll-until-condition without blocking the turn on a longsleep.Item (1) alone is a big quality-of-life win and is the smallest unit worth shipping. Items (2) and (3) follow naturally.
Today
ignis/src/tools/bash.rs:60-118— single-shot path:No shell registry, no output buffer, no kill handle.
grep -rn \"run_in_background\\|BashOutput\\|KillShell\\|task_id\" src/returns nothing.Proposed surface (v1, minimal)
bashAdd an optional
run_in_background: bool(defaultfalse, preserves current behavior).false→ unchanged.true→ spawn, append to aBackgroundShellsregistry keyed by a short ID (e.g.sh_a1b2c3), return{ \"shell_id\": \"sh_a1b2c3\" }immediately. The process inherits cwd / permission posture from the parent.bash_output{ shell_id: string, filter?: string }bash_outputcall for this id, plus astatusfield (running/exited:<code>/killed).BASH_OUTPUT_LIMIT = 50 KiB); on overflow, keep the tail and emit a[truncated N bytes]marker.bash_kill{ shell_id: string }Monitor(stretch, but the real win){ shell_id: string, until?: string }(until = a regex or substring; when matched on a stdout line, the monitor auto-stops and returns the matching line as the tool result).AgentEventso the live TUI can show it and the agent loop can decide what to do next.until: emits everything until the shell exits, then resolves.bash 'until <check>; do sleep 2; done'pattern with a first-class primitive.Why this matters
Concrete things the model can do once this lands:
npm test --watch,cargo watch -x check,tail -f log— start once, poll output as new lines arrive, don't waste an entire 60s tool budget per iteration.bash_output.sleep.Today every one of these requires either choosing the timeout pessimistically (and burning tokens waiting) or fragile chains of foreground
bashcalls.Implementation sketch
ignis/src/tools/background.rs(or extendbash.rs) with aBackgroundShells: Arc<Mutex<HashMap<String, ShellEntry>>>registered inToolsalongsideBashTool.ShellEntry { child: tokio::process::Child, stdout_buf: BytesMut, stderr_buf: BytesMut, status: ShellStatus }. Spawn drains stdout/stderr into the buffers via twotokio::task::spawnreaders; capped atBASH_OUTPUT_LIMITper stream with a circular tail.bash_outputsnapshots the buffers, drains the read cursor, returns string + status. No new dep needed.Monitor, the existingAgentEventchannel in the runner is the natural delivery path — every reader-task line that satisfies the filter forwards an event.BashToolpath: backgrounding doesn't bypass approvals (treat it as a single tool call gated up-front, same as foreground).DroponTools/ explicit hook inrunner.rs).Scope notes (YAGNI)
Environment
feat/minimax-providerat7777124Bash(run_in_background)+BashOutput+KillShell+Monitortools.