- Added `background` option to `bash` tool to allow long-lived commands to run without blocking the turn. - Introduced `bash_status` tool to check the status and output of background commands. - Added `bash_stop` tool to explicitly stop running background commands. - Updated command parsing to handle `/bash` commands for listing, stopping individual, and stopping all background commands. - Enhanced session management to reap stale background commands on agent shutdown. - Updated UI to reflect background command status and allow user interruption via ctrl-c. - Added tests for background command functionality and command parsing. - Documented new features and usage in background-bash.md.
7.5 KiB
Background bash — run long-lived commands without blocking the turn
Status: spec (implemented) Date: 2026-09-11
Problem
Running a dev server (or any long-lived command) via bash blocks the tool
until the process exits or the 120 s default timeout fires. A dev server never
exits, so the model burns the turn waiting, then gets a killed-by-timeout error
in which the server may or may not still be running. Dev workflows inside the
agent are effectively impossible.
Design
Add an optional background: true mode to bash. Background commands:
- spawn detached (new process group / session) so the agent process can exit without taking them down, and so ctrl-c in the agent never kills them.
- return immediately with a
handle(small integer), astartedmarker, and the first few lines of output (when available). - keep streaming output into a per-handle ring buffer (capped) in memory;
bash_statusreturns recent output and the current running/finished state. - are reaped on agent shutdown — the module saves a
~/.shiro-neko/backgrounds.jsonjournal and kills live children on exit (killBun.spawnprocess). - can be killed explicitly via
bash_stop(the tool), or ctrl-c while focused, or/bash(the command).
Why a handle + tools, not a long-lived "bash" result
Background processes are by definition not one-shot, so a single tool result
cannot represent them. Separating into bash (start/one-shot) + bash_status
(poll) + bash_stop (kill) keeps each tool's contract small and lets the agent
poll while continuing to work. The model is instructed to poll and stop when
done; otherwise the process lingers until shutdown reaps it.
Why detached
Bun.spawn(..., {detached: true}) (a.k.a. setsid) is required so that:
- killing the agent does not SIGKILL the dev server (kill process-group on exit is deliberate, see below);
- ctrl-c in the agent (which kills the agent's own process group) does not signal the dev server;
interruptBash()keeps working for foreground commands only.
On Windows, process groups work differently (detached behaves differently in
Bun; the kill is best-effort). Document that background is primarily for
Unix-like dev servers.
Tool changes
bash — add background?: boolean and name?: string
backgrounddefault false (foreground = existing behavior, backward compat).nameoptional label used for /bash listing.- When background:
- spawn
bash -lc '<cmd>'withdetached: true(orcmd /c+ best-effort on win32), pipes captured for streaming, no tool timeout (the process decides its own lifetime). - register in the module-level
backgroundsmap keyed by an incrementing handle. - return
running <handle>: <cmd> (background pid N)— the model learns the handle and can poll.
- spawn
- The
runningmap (foreground,interruptBash) is untouched: foreground commands still behave exactly as today.
bash_status — new nav/core read tool
- Input:
handle: number. - Output: one of:
running:status: running (pid N)\n<recent output, tail capped>finished:status: finished, exit: <code>\n<tail of captured output>not found:status: no such handle
- Implementation reuses
bashListenerstreaming (sessions get live progress while a background command runs) and keeps a tail buffer per handle (MAX_OUTPUT-capped, so the model never burns context).
bash_stop — new mutating tool
- Input:
handle: number. - Returns which command was killed (
killed <handle>: <cmd>), orno such handlewhen absent. ReuseskillTree(process-group aware) on the background process, awaited so the process really is gone.
Tool registrations
bash_status:withMeta({ set: 'core', mutating: false }), read tool, no approval needed (likeread_file).bash_stop:withMeta({ set: 'core', mutating: true })— mutating requires aDEFAULT_PERMISSIONSentry +subjectOfcase insrc/permission.ts(falls back toaskon*otherwise, bypassing command gating).bashremainsmutating: true;bash_stopandbashshare the bash permission subject (bashsubjectOf:bash_stopcommand =bash <cmd>), so an approvedbashrule can also coverbash_stop(subject-derived).MUTATING_TOOLS/TOOL_SETSderive automatically via_meta.
Default permissions
bash_stopadded to the mutating loop list (src/permission.ts:223).subjectOf(src/permission.ts:306) mapsbash_stop→'bash'so existing bash rules apply (e.g. a blanket allow onbashcovers stop).
Session / UI
src/session.tsstreams background command output through the existingonBashOutputlistener (live output panel in interactive mode, same as a foreground command's streaming).src/ui/App.tsx: render the[bg N]prefix from thebashtool result and make ctrl-c while no foreground command is running stop the most recently started background command (mirror ofinterruptBash). Keeps esc semantics: with a foreground command running, esc still kills it first.src/commands.ts:/bashcommand —list(default) showshandle: cmd (running|exit N),stop <id>kills,stop allkills all. Parser case incommands.ts, UI switch inApp.tsx.src/cli.tsxshutdown: beforeprocess.exit, callshutdownBackgrounds()(async kill live children + write journal). Best-effort — must not throw or delay exit. Journal written to~/.shiro-neko/backgrounds.json(SHIRO_HOME-aware viastore.tspatterns).
Prompt guidance
src/prompt.tsbash tool descriptions: note that long-lived commands (dev servers, watchers, tests that run forever,bun dev) should usebackground: trueand then be polled withbash_statusand stopped withbash_stopwhen done. Instruct the model to always stop what it starts.
Files touched
src/tools.ts— bash background branch,bash_status,bash_stop, registry entries,bgHandlecounter,backgroundsmap,killBackground.src/tool-utils.ts— no change (meta derives).src/permission.ts— mutating list +subjectOf+ DEFAULT_PERMISSIONS.src/prompt.ts— tool descriptions / guidance (bash description + status/stop).src/session.ts— listener wiring (streaming bg output) if not already covered byonBashOutput; nothing else needed.src/commands.ts—/bashcommand definition + parser case.src/ui/App.tsx—/bashswitch case + ctrl-c background fallback.src/cli.tsx— shutdown reaping + journal.test/tools.test.ts— bg tests.test/permission.test.ts— auto (bash_stop mutating coverage).test/commands.test.ts—/bashparse + list/stop.
Verification
bun test(665+ tests, new ones included:sleep 30background returns immediately; status shows running then finished afterexit 0; stop kills; journal + reap on shutdown;/bash list/stopparse).bun run typecheck.bun run build(must pass--production;dist/shiro --version).- Manual:
SHIRO_HOME=$(mktemp -d) bun run src/cli.tsx -p "run a dev server in the background and check it is up, then stop it" --yolo --json.
Risks / notes
- Old journal entries (from crashed sessions) are reaped on next boot: at startup, kill stale PIDs or ignore missing ones. Do not leak orphan dev servers across sessions.
bash_statuspoll output is capped (context safety).- Windows: background is best-effort (no process group / setsid semantics); foreground behavior unchanged.
- Background processes are not snapshot / undo targets; killing on shutdown is deliberate to avoid orphan servers the user cannot see.