Implement background command support for bash tool

- 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.
This commit is contained in:
asepharyana committed 2026-09-11 18:52:50 +07:00
1 parent deeb138cd9
commit 76043aeb38
12 files changed
+721 -15

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
# 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), a `started` marker, and
the first few lines of output (when available).
- keep streaming output into a per-handle ring buffer (capped) in memory;
`bash_status` returns recent output and the current running/finished state.
- are reaped on agent shutdown — the module saves a `~/.shiro-neko/backgrounds.json`
journal and kills live children on exit (kill `Bun.spawn` process).
- 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`
- `background` default false (foreground = existing behavior, backward compat).
- `name` optional label used for /bash listing.
- When background:
- spawn `bash -lc '<cmd>'` with `detached: true` (or `cmd /c` + best-effort on
win32), pipes captured for streaming, no tool timeout (the process decides
its own lifetime).
- register in the module-level `backgrounds` map keyed by an incrementing
handle.
- return `running <handle>: <cmd> (background pid N)` — the model learns the
handle and can poll.
- The `running` map (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 `bashListener` streaming (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>`), or
`no such handle` when absent. Reuses `killTree` (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 (like `read_file`).
- `bash_stop`: `withMeta({ set: 'core', mutating: true })` — mutating requires
a `DEFAULT_PERMISSIONS` entry + `subjectOf` case in `src/permission.ts`
(falls back to `ask` on `*` otherwise, bypassing command gating).
- `bash` remains `mutating: true`; `bash_stop` and `bash` share the bash
permission subject (`bash` subjectOf: `bash_stop` command = `bash <cmd>`),
so an approved `bash` rule can also cover `bash_stop` (subject-derived).
- `MUTATING_TOOLS`/`TOOL_SETS` derive automatically via `_meta`.
### Default permissions
- `bash_stop` added to the mutating loop list (`src/permission.ts:223`).
- `subjectOf` (`src/permission.ts:306`) maps `bash_stop` → `'bash'` so existing
bash rules apply (e.g. a blanket allow on `bash` covers stop).
## Session / UI
- `src/session.ts` streams background command output through the existing
`onBashOutput` listener (live output panel in interactive mode, same as a
foreground command's streaming).
- `src/ui/App.tsx`: render the `[bg N]` prefix from the `bash` tool result and
make ctrl-c while no foreground command is running stop the most recently
started background command (mirror of `interruptBash`). Keeps esc semantics:
with a foreground command running, esc still kills it first.
- `src/commands.ts`: `/bash` command — `list` (default) shows
`handle: cmd (running|exit N)`, `stop <id>` kills, `stop all` kills all.
Parser case in `commands.ts`, UI switch in `App.tsx`.
- `src/cli.tsx` shutdown: before `process.exit`, call
`shutdownBackgrounds()` (async kill live children + write journal).
Best-effort — must not throw or delay exit. Journal written to
`~/.shiro-neko/backgrounds.json` (SHIRO_HOME-aware via `store.ts` patterns).
### Prompt guidance
- `src/prompt.ts` bash tool descriptions: note that long-lived commands
(dev servers, watchers, tests that run forever, `bun dev`) should use
`background: true` and then be polled with `bash_status` and stopped with
`bash_stop` when done. Instruct the model to always stop what it starts.
## Files touched
- `src/tools.ts` — bash background branch, `bash_status`, `bash_stop`,
registry entries, `bgHandle` counter, `backgrounds` map, `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 by `onBashOutput`; nothing else needed.
- `src/commands.ts` — `/bash` command definition + parser case.
- `src/ui/App.tsx` — `/bash` switch 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` — `/bash` parse + list/stop.
## Verification
- `bun test` (665+ tests, new ones included: `sleep 30` background returns
immediately; status shows running then finished after `exit 0`; stop kills;
journal + reap on shutdown; `/bash list/stop` parse).
- `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_status` poll 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.
+47 -5
View File
@@ -16,7 +16,7 @@ reaches the context is on the wire and in the session file, and there is no taki
`*.env.example` is allowed.
**Asked by default.** `write_file`, `edit_file`, `multi_edit`, `apply_patch`, `move_file`,
`delete_file`, `bash`, `web_fetch`, and every `mcp__*` tool.
`delete_file`, `bash`, `bash_stop`, `web_fetch`, and every `mcp__*` tool.
```
bash wants to run
@@ -66,7 +66,7 @@ Sets let you switch off what a project does not need:
| Set | Tools | Cost |
|---|---|---|
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` | ~2,993 B |
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` `bash_status` `bash_stop` | ~3,200 B |
| `edit-plus` | `multi_edit` `list_dir` `read_many_files` `apply_patch` `move_file` `delete_file` | patch and file ops |
| `nav` | `find_symbol` `json_query` | navigation and structured reads |
| `extra` | 20 tools: line edits, fs inspect, git extensions, code/env reads | on by default |
@@ -358,7 +358,9 @@ reported as `Invalid regex: <reason>` rather than returning an empty result set.
```
command shell command
timeout ms, default 120000, max 600000
timeout ms, default 120000, max 600000 (ignored when background is true)
background detach the command and return immediately with a handle
name label for a background command, shown in /bash and bash_status
```
Runs in the workspace root through `bash -lc` or `cmd /c`. Output streams live to the panel
@@ -380,7 +382,47 @@ stdout:
```
The turn continues from there. `esc` still aborts everything, and `ctrl-c` with nothing
running quits as usual.
running quits as usual. When a background command is running and nothing foreground is in
flight, `ctrl-c` stops the most recently started one instead of quitting — an accidental quit
must not kill a dev server the user still wants.
### `bash` background mode (dev servers, watchers)
A command that does not exit — `bun dev`, a watcher, a test suite that never returns — blocks
the tool until its timeout, which looks like the agent is stuck. Set `background: true` instead:
```
command: bun dev
background: true
name: dev server
```
The tool returns immediately with a handle:
```
background 1: running (pid 4821) — poll with bash_status handle=1, stop with bash_stop handle=1
command: bun dev
```
The command runs **detached** (its own process group), so it keeps running while the agent
works, ctrl-c in the agent does not signal it, and the model can poll it and keep going:
- **`bash_status`** `handle` — whether it is still running, its exit code when finished, and
any output produced since the last status. Poll this while working.
- **`bash_stop`** `handle` — kill it, awaited so the process really is gone.
```
$ bash_status handle: 1
handle 1: dev server
status: running
new output:
VITE ready in 312 ms
```
Background commands are reaped when the agent exits (killed and removed), and anything left
over from a crashed session is killed at the next boot, so a dev server an agent started cannot
linger unnoticed. `/bash` lists what is running, `/bash stop <id>` and `/bash stop all` stop
them. Like any `bash`, they never go through the file-snapshot/undo system.
## `web_fetch`
@@ -578,7 +620,7 @@ Any single tool result is truncated at 30,000 characters with a note saying how
| `list_dir` | 300 entries |
| `read_many_files` | 20 files |
| `read_file` | 2,000 lines by default |
| `bash` | 120 s default timeout, 600 s max |
| `bash` | 120 s default timeout, 600 s max (background mode has no timeout) |
Without caps one `grep` for `function` can end a session. The caps are per call, so a model
that needs more can narrow and ask again — which is cheaper than one call that fills the
+28
View File
@@ -23,6 +23,7 @@ import * as registry from './registry';
import { Session } from './session';
import { loadCustomCommands } from './custom-commands';
import { loadSkills } from './skills';
import { reapStaleBackgrounds, shutdownBackgrounds, backgroundSummary, stopBackground } from './tools';
import * as store from './store';
import { createTaskTool, type SubagentApproval } from './subagent';
import { VERSION, versionLine } from './version';
@@ -327,6 +328,10 @@ const subagentModel =
// wired after construction.
let recordSubagent: (usage: { inputTokens: number; outputTokens: number }) => void = () => {};
// Orphaned background commands from a crashed session: kill them if they are
// still alive, so a dev server a dead agent started does not linger.
reapStaleBackgrounds();
const session = new Session({
model: languageModel ?? unconfiguredModel,
modelId: cfg.model,
@@ -398,6 +403,13 @@ async function shutdown(code: number): Promise<never> {
clearTimeout(saveTimer);
if (session.messages.length > 0) await persist(session.messages);
await mcp?.close();
// Dev servers and watchers started in the background must not outlive the
// agent silently; reap them (best-effort, never blocks exit).
try {
await shutdownBackgrounds();
} catch {
// best-effort
}
process.exit(code);
}
const printArg = flag('-p', '--print');
@@ -562,6 +574,22 @@ const hooks: AppHooks = {
return `removed mcp server ${name}\nsaved to ${path}\nrestart shiro to disconnect it`;
},
},
backgroundCommands: {
list: () => {
const rows = backgroundSummary();
if (rows.length === 0) return 'no background commands';
return rows
.map((r) => `${r.handle}: ${r.name} — ${r.running ? 'running' : `exit ${r.exit}`}\n ${r.command}`)
.join('\n');
},
stop: async (handle) => stopBackground(handle),
stopAll: async () => {
const rows = backgroundSummary();
if (rows.length === 0) return 'no background commands';
const results = await Promise.all(rows.map((r) => stopBackground(r.handle)));
return results.join('\n');
},
},
initPrompt: INIT_PROMPT,
scaffoldWorkflow: () => scaffoldWorkflowFiles(),
history: promptHistory,
+14
View File
@@ -29,6 +29,7 @@ export type CommandAction =
| { type: 'undo' }
| { type: 'redo' }
| { type: 'changes' }
| { type: 'bash'; action: 'list' | 'stop' | 'stop-all'; arg?: string }
| { type: 'search'; query: string }
| { type: 'fork' }
| { type: 'workflow' }
@@ -70,6 +71,7 @@ export const COMMANDS: CommandSpec[] = [
{ name: 'undo', summary: 'undo the last turn — restores files and conversation (bash effects are not snapshotted)' },
{ name: 'redo', summary: 'redo the last undone turn' },
{ name: 'changes', summary: 'show what the last turn changed on disk' },
{ name: 'bash', arg: '[list|stop <id>|stop all]', summary: 'list or stop background commands started with bash background: true' },
{ name: 'search', arg: '<query>', summary: 'search saved sessions for a phrase' },
{ name: 'fork', summary: 'fork the session at the last turn boundary (keeps the original)' },
{ name: 'workflow', summary: 'show project workflow state: TODO/ROADMAP tracking, docs, nudges' },
@@ -243,6 +245,18 @@ export function parseCommand(raw: string, custom: readonly CustomCommand[] = [])
return { type: 'redo' };
case 'changes':
return { type: 'changes' };
case 'bash': {
const [verb = '', ...rest] = arg.split(/\s+/);
if (verb === 'stop') {
if (rest.join(' ').trim().toLowerCase() === 'all') return { type: 'bash', action: 'stop-all' };
const id = Number(rest[0]);
return Number.isInteger(id) && id > 0
? { type: 'bash', action: 'stop', arg: String(id) }
: { type: 'info', text: 'usage: /bash stop <id> or /bash stop all' };
}
if (verb && verb !== 'list') return { type: 'info', text: 'usage: /bash [list|stop <id>|stop all]' };
return { type: 'bash', action: 'list' };
}
case 'search':
return arg ? { type: 'search', query: arg } : { type: 'info', text: 'usage: /search <query>' };
case 'fork':
+5 -1
View File
@@ -61,6 +61,10 @@ export function subjectOf(tool: string, input: unknown): string | undefined {
switch (tool) {
case 'bash':
return str('command');
case 'bash_stop':
// Stopping a background command is gated like the command that started it,
// so an approved `bash` rule also covers stopping what it started.
return str('handle');
case 'mcp_call': {
const server = str('server');
const toolName = str('tool');
@@ -220,7 +224,7 @@ function buildDefaults(): PermissionConfig {
}
} catch {
// tests that import permission in isolation still get BASE + known mutating fallback
for (const name of ['write_file','edit_file','multi_edit','apply_patch','move_file','delete_file','insert_lines','delete_lines','replace_lines','append_file','prepend_file','bash','mcp_call'] as const) {
for (const name of ['write_file','edit_file','multi_edit','apply_patch','move_file','delete_file','insert_lines','delete_lines','replace_lines','append_file','prepend_file','bash','bash_stop','mcp_call'] as const) {
if (!(name in out)) (out as Record<string, PermissionEntry>)[name] = 'ask';
}
}
+5 -1
View File
@@ -1004,7 +1004,11 @@ export class Session {
// Files written this turn are now on disk; re-walk so the next prompt's
// workspace list shows them without a restart.
try { await this.refreshWorkspaceFiles(); } catch {}
onBashOutput(undefined);
// Keep the listener alive while a background process is running so the UI
// panel shows its output between turns. statusBackground always has the
// data either way (bg.tail); this just keeps the live panel up.
const { backgroundHandles } = await import('./tools');
if (backgroundHandles().length === 0) onBashOutput(undefined);
await (this.pluginHost ?? this.opts.plugins)?.afterTurn();
if (!this.opts.disableAutoLearn && this.messages.length >= 6) {
this.learnTurns += 1;
+237 -4
View File
@@ -1,6 +1,6 @@
import { tool } from 'ai';
import { stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { join, resolve, dirname } from 'node:path';
import { z } from 'zod';
import { jail, posix, walk } from './ignore';
import { recordBeforeWrite } from './snapshot';
@@ -585,6 +585,206 @@ type Running = { command: string; proc: Bun.Subprocess; interrupted: boolean; ki
const running = new Map<string, Running>();
/** Best-effort path for the background-process journal, resolved per call (SHIRO_HOME may move). */
function bgJournalPath(): string {
const home = process.env['SHIRO_HOME'] ?? join(process.env['HOME'] ?? '', '.shiro-neko');
return join(home, 'backgrounds.json');
}
/**
* A detached background command (dev server, watcher, long test).
*
* Unlike `running` (foreground, killed by ctrl-c), a background command is
* setsid'd so the agent process can exit without taking it down, and ctrl-c in
* the agent does not signal it. It lives until `bash_stop`, or until the agent
* exits and reaps it (see `shutdownBackgrounds`) — a stray dev server from a
* crashed session must not linger, so the journal exists for exactly that case.
*/
type Background = {
handle: number;
command: string;
name: string;
proc: Bun.Subprocess;
exited: Promise<number | null>;
/** Fresh output since the last `bash_status`. Append-only, capped by MAX_OUTPUT. */
tail: string;
/** How much of `tail` the last status read already showed; the rest is "new". */
shown: number;
};
let nextHandle = 1;
const backgrounds = new Map<number, Background>();
const BY_NAME = new Map<string, number>();
export function backgroundHandles(): number[] {
return [...backgrounds.keys()].sort((a, b) => a - b);
}
export function backgroundSummary(): { handle: number; name: string; command: string; running: boolean; exit: number | null }[] {
return [...backgrounds.values()]
.sort((a, b) => a.handle - b.handle)
.map((b) => ({
handle: b.handle,
name: b.name,
command: b.command,
running: !b.proc.exited,
exit: b.proc.exitCode,
}));
}
/**
* Starts a detached background command and returns its handle.
*
* Output streams into the per-handle tail buffer (and through the bash listener
* for live UI progress). The process is adopted by a cleanup crawler at boot:
* anything the journal lists that is still alive is killed (see below).
*/
export function startBackground(command: string, name: string): number {
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
let proc: Bun.Subprocess;
try {
proc = Bun.spawn(shell, {
cwd: process.cwd(),
stdout: 'pipe',
stderr: 'pipe',
detached: true,
});
} catch (e) {
throw new Error(`could not start background command: ${e instanceof Error ? e.message : String(e)}`);
}
const handle = nextHandle++;
const bg: Background = {
handle,
command,
name: name || command.split(/\s+/)[0] || 'command',
proc,
exited: proc.exited,
tail: '',
shown: 0,
};
backgrounds.set(handle, bg);
if (name) BY_NAME.set(name, handle);
const pump = async (stream: ReadableStream<Uint8Array> | undefined) => {
if (!stream) return;
const decoder = new TextDecoder();
for await (const chunk of stream) {
const text = decoder.decode(chunk, { stream: true });
if (!text) continue;
bg.tail = (bg.tail + text).slice(-MAX_OUTPUT * 2);
bashListener?.({ toolCallId: `bg${handle}`, chunk: text });
}
};
void pump(proc.stdout as ReadableStream<Uint8Array>);
void pump(proc.stderr as ReadableStream<Uint8Array>);
void proc.exited.then(() => {
try {
writeJournal();
} catch {
// journal is best-effort
}
});
writeJournal();
return handle;
}
function writeJournal(): void {
try {
const dir = dirname(bgJournalPath());
const { mkdirSync, writeFileSync } = require('node:fs') as typeof import('node:fs');
mkdirSync(dir, { recursive: true });
const rows = [...backgrounds.values()].map((b) => ({
handle: b.handle,
pid: b.proc.pid,
command: b.command,
name: b.name,
alive: b.proc.exitCode === null,
}));
writeFileSync(bgJournalPath(), JSON.stringify(rows, null, 2));
} catch {
// best-effort; a failed journal write must not break a command start
}
}
/** Stale entries from a crashed session: if still alive, kill them (best-effort). */
export function reapStaleBackgrounds(): void {
try {
const { readFileSync, existsSync } = require('node:fs') as typeof import('node:fs');
const p = bgJournalPath();
if (!existsSync(p)) return;
const rows = JSON.parse(readFileSync(p, 'utf8')) as { pid?: number }[];
for (const row of rows) {
if (typeof row.pid !== 'number' || row.pid <= 0) continue;
try {
process.kill(row.pid, 0); // throws if the pid is not ours / gone
process.kill(row.pid, 'SIGTERM');
} catch {
// already gone or not ours; nothing to reap
}
}
try {
const { unlinkSync } = require('node:fs') as typeof import('node:fs');
unlinkSync(p);
} catch {
// fine
}
} catch {
// best-effort
}
}
/**
* Kills every live background process and clears the journal. Called on agent
* shutdown so a dev server started by a session does not outlive it silently.
*/
export async function shutdownBackgrounds(): Promise<void> {
const live = [...backgrounds.values()].filter((b) => b.proc.exitCode === null);
const kills = live.map((b) => {
b.proc.kill();
return b.proc.exited;
});
await Promise.allSettled(kills);
backgrounds.clear();
BY_NAME.clear();
try {
const { unlinkSync } = require('node:fs') as typeof import('node:fs');
unlinkSync(bgJournalPath());
} catch {
// file already gone
}
}
/** Stops one background command by handle. Returns a human-readable result. */
export async function stopBackground(handle: number): Promise<string> {
const bg = backgrounds.get(handle);
if (!bg) return `no such handle: ${handle}`;
bg.proc.kill();
await bg.proc.exited;
backgrounds.delete(handle);
if (bg.name) BY_NAME.delete(bg.name);
writeJournal();
return `killed ${handle}: ${bg.command}`;
}
/**
* The summary a `bash_status` call returns: the process state, recent output,
* and whether there is output newer than the last status the model read (so a
* poll sees progress without re-reading the whole buffer).
*/
export function statusBackground(handle: number): string {
const bg = backgrounds.get(handle);
if (!bg) return `status: no such handle ${handle}`;
const lines = bg.tail.split('\n');
const shown = bg.shown;
const freshLines = lines.slice(Math.max(0, lines.length - shown)).join('\n').trim();
bg.shown = lines.length;
const state = bg.proc.exitCode === null ? 'running' : `finished (exit ${bg.proc.exitCode})`;
const fresh = freshLines.length > 0 ? `\nnew output:\n${freshLines}` : '\n(new output: none)';
return `handle ${handle}: ${bg.name}\nstatus: ${state}${bg.command ? `\ncommand: ${bg.command}` : ''}${fresh}`;
}
/**
* Kills the shell and everything it started.
*
@@ -633,12 +833,22 @@ export function interruptBash(): string[] {
export const bashTool = withMeta({ set: 'core', mutating: true }, tool({
description:
'Run a shell command in the workspace root. Use for builds, tests, git, and package managers. ' +
'Output streams live and the user can interrupt a command with ctrl-c without ending the turn.',
'Output streams live and the user can interrupt a command with ctrl-c without ending the turn. ' +
'For a command that does not exit — a dev server, a watcher, a long-running test — set background: true ' +
'so the tool returns immediately with a handle; poll it with bash_status and stop it with bash_stop when done. ' +
'Always stop what you start.',
inputSchema: z.object({
command: z.string(),
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000'),
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000; ignored when background is true'),
background: z.boolean().optional().describe('Detach the command and return immediately with a handle. Use for dev servers and watchers that never exit'),
name: z.string().optional().describe('Label for a background command, shown in /bash and bash_status. Ignored unless background is true'),
}),
execute: async ({ command, timeout = 120_000 }, { toolCallId, abortSignal }) => {
execute: async ({ command, timeout = 120_000, background, name }, { toolCallId, abortSignal }) => {
if (background) {
const handle = startBackground(command, name ?? '');
const pid = [...backgrounds.values()].find((b) => b.handle === handle)?.proc.pid;
return `background ${handle}: running (pid ${pid ?? '?'}) — poll with bash_status handle=${handle}, stop with bash_stop handle=${handle}\ncommand: ${command}`;
}
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
const proc = Bun.spawn(shell, {
cwd: process.cwd(),
@@ -694,6 +904,27 @@ export const bashTool = withMeta({ set: 'core', mutating: true }, tool({
},
}));
export const bashStatusTool = withMeta({ set: 'core', mutating: false }, tool({
description:
'Check a background command started with bash background: true. Pass the handle from that call. ' +
'Returns whether it is still running or finished (with its exit code) and any output produced since the last status. ' +
'Poll this while working; stop the command with bash_stop when it has served its purpose.',
inputSchema: z.object({
handle: z.number().int().positive().describe('Handle returned by a background bash call'),
}),
execute: async ({ handle }) => statusBackground(handle),
}));
export const bashStopTool = withMeta({ set: 'core', mutating: true }, tool({
description:
'Stop a background command started with bash background: true. Pass the handle from that call. ' +
'Use this when the command has done its job (a dev server you no longer need, a watcher, a long test).',
inputSchema: z.object({
handle: z.number().int().positive().describe('Handle returned by a background bash call'),
}),
execute: async ({ handle }) => stopBackground(handle),
}));
export const moveFileTool = withMeta({ set: 'edit-plus', mutating: true }, tool({
description:
'Move or rename one file. Creates the target directory. Refuses if the source is missing or the target ' +
@@ -869,6 +1100,8 @@ export const tools = {
find_symbol: findSymbolTool,
json_query: jsonQueryTool,
bash: bashTool,
bash_status: bashStatusTool,
bash_stop: bashStopTool,
...gitTools,
...netTools,
...extraTools,
+44 -3
View File
@@ -93,6 +93,12 @@ export type AppHooks = {
initPrompt: string;
/** Directly scaffold TODO.md / ROADMAP.md / docs/ when they are missing; returns what was written. */
scaffoldWorkflow: () => string[];
/** Background commands started with bash background: true — list, stop one, stop all. */
backgroundCommands: {
list: () => string;
stop: (handle: number) => Promise<string>;
stopAll: () => Promise<string>;
};
history: string[];
recordPrompt: (text: string) => void;
};
@@ -278,12 +284,28 @@ export function App({
// ctrl-c kills only the command in flight, leaving the turn alive so the model
// gets a tool error and can decide what to do. With nothing running it keeps its
// usual meaning and quits, which is why Ink's own ctrl-c handling is turned off
// in cli.tsx rather than left to race with this.
useInput((input, key) => {
// in cli.tsx rather than left to race with this. When a background command is
// running (started by the model with bash background: true) and nothing
// foreground is in flight, ctrl-c stops the most recently started one instead
// of quitting — an accidental quit killing a dev server the user still wants.
useInput(async (input, key) => {
if (!key.ctrl || input !== 'c') return;
const killed = interruptBash();
if (killed.length === 0) return exit();
if (killed.length > 0) {
push({ kind: 'info', text: `interrupted: ${killed.join(', ')}` });
return;
}
const handles = hooks.backgroundCommands.list();
if (handles.trim() !== '' && handles.trim() !== 'no background commands') {
const lines = handles.split('\n').map((l) => l.trim()).filter(Boolean);
const first = lines[0]?.match(/^(\d+):/)?.[1];
if (first) {
const msg = await hooks.backgroundCommands.stop(Number(first));
push({ kind: 'info', text: `ctrl-c: no foreground command; ${msg}` });
return;
}
}
return exit();
});
useInput(
@@ -749,6 +771,25 @@ export function App({
setPanel(workflowPanel(session));
return;
}
case 'bash': {
push({ kind: 'user', text: chosen.trim() });
if (action.action === 'list') {
push({ kind: 'info', text: hooks.backgroundCommands.list() });
return;
}
setWorking(true);
try {
const text =
action.action === 'stop-all'
? await hooks.backgroundCommands.stopAll()
: await hooks.backgroundCommands.stop(Number(action.arg));
push({ kind: 'info', text });
} catch (e) {
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
}
setWorking(false);
return;
}
case 'provider':
push({ kind: 'user', text: chosen.trim() });
setOnboarding(true);
+19
View File
@@ -158,3 +158,22 @@ test('/registry add with no name returns usage rather than fetching anything', (
test('a bare word after /registry is treated as a search', () => {
expect(parseCommand('/registry migration')).toEqual({ type: 'registry', action: 'search', arg: 'migration' });
});
test('/bash with no verb lists background commands', () => {
expect(parseCommand('/bash')).toEqual({ type: 'bash', action: 'list' });
expect(parseCommand('/bash list')).toEqual({ type: 'bash', action: 'list' });
});
test('/bash stop carries the handle', () => {
expect(parseCommand('/bash stop 3')).toEqual({ type: 'bash', action: 'stop', arg: '3' });
});
test('/bash stop all stops everything', () => {
expect(parseCommand('/bash stop all')).toEqual({ type: 'bash', action: 'stop-all' });
});
test('/bash with an unknown verb returns usage', () => {
const out = parseCommand('/bash frobnicate');
expect(out.type).toBe('info');
expect((out as { text: string }).text).toContain('usage: /bash');
});
+5
View File
@@ -56,6 +56,11 @@ export function testHooks(over: Partial<AppHooks> = {}): AppHooks {
},
initPrompt: 'write AGENTS.md',
scaffoldWorkflow: () => [],
backgroundCommands: {
list: () => 'no background commands',
stop: async (handle) => `stopped ${handle}`,
stopAll: async () => 'stopped all',
},
history: [],
recordPrompt: () => {},
...over,
+58 -1
View File
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Session } from '../src/session';
import { interruptBash } from '../src/tools';
import { backgroundHandles, interruptBash } from '../src/tools';
const usage = usageOf(10, 5);
@@ -373,3 +373,60 @@ test('an interrupted command becomes a tool error and the turn carries on', asyn
expect(kinds.at(-1)).toBe('done');
expect(call).toBe(2);
}), 30_000);
test('a background command runs, streams via the listener, and can be stopped', async () =>
inTempDir(async () => {
const script = process.platform === 'win32' ? 'ping -n 3 127.0.0.1 > nul' : 'sleep 0.2; echo bg-line';
let call = 0;
let modelCalls = 0;
const session = new Session({
yolo: true,
model: new MockLanguageModelV4({
doStream: async () => {
const n = call++;
modelCalls += 1;
if (n === 0) return stream(toolCall('c1', 'bash', { command: script, background: true, name: 'bgtest' }));
// The model polls whatever handle the start actually produced — the
// counter is module-global, so it is not necessarily 1 when other
// test files ran in the same process first.
const handles = backgroundHandles().sort((a, b) => a - b);
const handle = handles.at(-1) ?? 1;
if (n === 1) return stream(toolCall('c2', 'bash_status', { handle }));
// Give the script time to finish, then poll once more and stop it.
await Bun.sleep(1500);
if (n === 2) return stream(toolCall('c3', 'bash_status', { handle }));
if (n === 3) return stream(toolCall('c4', 'bash_stop', { handle }));
return stream(text('all done'));
},
}),
askApproval: async (req) => {
// In this session-scoped test the only gated call is the bash background
// start itself; approve it once so the model's own calls never see the prompt.
return req.toolName === 'bash' ? 'once' : 'always';
},
});
const results: string[] = [];
const streamed: string[] = [];
for await (const ev of session.send('run the dev server in the background and check it')) {
if (ev.type === 'tool-result') results.push(String(ev.output));
if (ev.type === 'tool-output') streamed.push(String(ev.chunk));
}
// The start returned a handle; the first poll saw running; the final poll saw it finish.
// Don't hardcode the handle number — read it out of the first tool result,
// since the counter is shared module-global state and the process may have
// been stopped by the final bash_stop by the time these assertions run.
const actualHandle = Number(results[0]?.match(/background (\d+):/)?.[1]);
expect(actualHandle).toBeGreaterThan(0);
expect(results[0]).toContain(`background ${actualHandle}:`);
expect(results[1]).toContain('running');
expect(results[2]).toContain('finished');
expect(results[3]).toContain(`killed ${actualHandle}`);
// Output streamed through the session's tool-output events (the live panel).
expect(streamed.join('')).toContain('bg-line');
expect(modelCalls).toBeGreaterThanOrEqual(4);
// Nothing left running after the turn's tool calls.
const { shutdownBackgrounds } = await import('../src/tools');
await shutdownBackgrounds();
}), 30_000);
+97
View File
@@ -5,6 +5,8 @@ import { join } from 'node:path';
import {
applyPatchTool,
bashTool,
bashStatusTool,
bashStopTool,
deleteFileTool,
editFileTool,
globTool,
@@ -22,6 +24,10 @@ import {
tools,
toolSetOf,
writeFileTool,
shutdownBackgrounds,
startBackground,
stopBackground,
statusBackground,
} from '../src/tools';
let dir: string;
@@ -581,3 +587,94 @@ test('a command that finished is no longer interruptible', async () => {
await run(bashTool, { command: 'echo done' });
expect(interruptBash()).toEqual([]);
}, 20_000);
// --- background commands (dev servers, watchers) ---
const bgScript = process.platform === 'win32' ? 'ping -n 3 127.0.0.1 > nul' : 'sleep 0.6; echo bg-done';
test('bash background returns immediately with a handle', async () => {
const started = Date.now();
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'sleeper' });
// The counter is module-global, so the number depends on what ran before in
// the same process — extract it rather than assuming it is 1.
const handle = Number(out.match(/background (\d+):/)?.[1]);
expect(handle).toBeGreaterThan(0);
expect(out).toContain('running');
expect(out).toContain('bash_status');
// Returns before the 30s command finishes.
expect(Date.now() - started).toBeLessThan(5_000);
await shutdownBackgrounds();
}, 20_000);
test('bash_status reports running then finished with the exit code', async () => {
const out = await run(bashTool, { command: bgScript, background: true, name: 'bgtest' });
const handle = Number(out.match(/background (\d+)/)?.[1]);
expect(handle).toBeGreaterThan(0);
const early = statusBackground(handle);
expect(early).toContain('running');
// Wait for the script to finish.
let status = '';
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
await Bun.sleep(200);
status = statusBackground(handle);
if (status.includes('finished')) break;
}
expect(status).toContain('finished');
expect(status).toContain('exit 0');
await shutdownBackgrounds();
}, 25_000);
test('bash_status unknown handle reports no such handle', async () => {
expect(statusBackground(999)).toContain('no such handle');
});
test('bash_stop kills a running background command', async () => {
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'killer' });
const handle = Number(out.match(/background (\d+)/)?.[1]);
const started = Date.now();
const msg = await run(bashStopTool, { handle });
expect(msg).toContain(`killed ${handle}`);
expect(msg).toContain('sleep 30');
// Killed, not waited out.
expect(Date.now() - started).toBeLessThan(10_000);
await shutdownBackgrounds();
}, 25_000);
test('bash_stop unknown handle is reported without throwing', async () => {
const out = await run(bashStopTool, { handle: 4242 });
expect(out).toContain('no such handle');
});
test('bash_status streams output into its buffer for a later read', async () => {
const out = await run(bashTool, { command: bgScript, background: true, name: 'stream' });
const handle = Number(out.match(/background (\d+)/)?.[1]);
// Give the script time to print its line.
await Bun.sleep(1200);
const status = statusBackground(handle);
expect(status.toLowerCase()).toContain('bg-done');
await shutdownBackgrounds();
}, 20_000);
test('background commands are not interrupted by interruptBash (foreground)', async () => {
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'immune' });
await Bun.sleep(200);
// Foreground interrupt must not touch background commands.
expect(interruptBash()).toEqual([]);
const handle = Number(out.match(/background (\d+)/)?.[1]);
expect(statusBackground(handle)).toContain('running');
await shutdownBackgrounds();
}, 20_000);
test('shutdownBackgrounds kills everything live and can be called twice', async () => {
await run(bashTool, { command: 'sleep 30', background: true, name: 'cleanup' });
const before = Date.now();
await shutdownBackgrounds();
// Killed, not waited out.
expect(Date.now() - before).toBeLessThan(10_000);
// Second call is a no-op.
await shutdownBackgrounds();
}, 20_000);