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
2026-09-11 18:52:50 +07:00
parent deeb138cd9
commit 76043aeb38
12 changed files with 724 additions and 18 deletions
+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.
+48 -6
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 |
@@ -357,8 +357,10 @@ reported as `Invalid regex: <reason>` rather than returning an empty result set.
### `bash`
```
command shell command
timeout ms, default 120000, max 600000
command shell command
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,
+45 -4
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();
push({ kind: 'info', text: `interrupted: ${killed.join(', ')}` });
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');
});
+6 -1
View File
@@ -55,7 +55,12 @@ export function testHooks(over: Partial<AppHooks> = {}): AppHooks {
remove: async (name) => `removed ${name}`,
},
initPrompt: 'write AGENTS.md',
scaffoldWorkflow: () => [],
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);