Skip to content

feat(tools): background bash + Monitor (notify on stdout / completion) like Claude Code #67

Description

@Fullstop000

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:

  1. bash(run_in_background=true) — spawn, return a shell handle, don't wait.
  2. bash_output(id) + bash_kill(id) — fetch buffered stdout/stderr since last read, or terminate.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions