From 76043aeb380fa221c647823a240bf9305452b321 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 11 Sep 2026 17:11:15 +0700 Subject: [PATCH] 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. --- .hermes/plans/background-bash.md | 162 +++++++++++++++++++++ docs/tools.md | 54 ++++++- src/cli.tsx | 28 ++++ src/commands.ts | 14 ++ src/permission.ts | 6 +- src/session.ts | 6 +- src/tools.ts | 241 ++++++++++++++++++++++++++++++- src/ui/App.tsx | 49 ++++++- test/commands.test.ts | 19 +++ test/helpers.ts | 7 +- test/session.test.ts | 59 +++++++- test/tools.test.ts | 97 +++++++++++++ 12 files changed, 724 insertions(+), 18 deletions(-) create mode 100644 .hermes/plans/background-bash.md diff --git a/.hermes/plans/background-bash.md b/.hermes/plans/background-bash.md new file mode 100644 index 0000000..2bd547c --- /dev/null +++ b/.hermes/plans/background-bash.md @@ -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 ''` 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 : (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` + - `finished`: `status: finished, exit: \n` + - `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 : `), 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 `), + 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 ` 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. \ No newline at end of file diff --git a/docs/tools.md b/docs/tools.md index 9fd054c..81b57b1 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -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: ` 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 ` 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 diff --git a/src/cli.tsx b/src/cli.tsx index 84fdfd9..2ee8b15 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -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 { 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, diff --git a/src/commands.ts b/src/commands.ts index 60f2798..d589572 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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 |stop all]', summary: 'list or stop background commands started with bash background: true' }, { name: 'search', arg: '', 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 or /bash stop all' }; + } + if (verb && verb !== 'list') return { type: 'info', text: 'usage: /bash [list|stop |stop all]' }; + return { type: 'bash', action: 'list' }; + } case 'search': return arg ? { type: 'search', query: arg } : { type: 'info', text: 'usage: /search ' }; case 'fork': diff --git a/src/permission.ts b/src/permission.ts index 5b8a24c..4b0a03f 100644 --- a/src/permission.ts +++ b/src/permission.ts @@ -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)[name] = 'ask'; } } diff --git a/src/session.ts b/src/session.ts index dcd83a8..ce47061 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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; diff --git a/src/tools.ts b/src/tools.ts index 8b430a0..b9e6407 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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(); +/** 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; + /** 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(); +const BY_NAME = new Map(); + +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 | 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); + void pump(proc.stderr as ReadableStream); + 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 { + 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 { + 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, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 01b0de3..32e5a1d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -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; + stopAll: () => Promise; + }; 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); diff --git a/test/commands.test.ts b/test/commands.test.ts index 0987b17..b3b4173 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -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'); +}); diff --git a/test/helpers.ts b/test/helpers.ts index 6fed378..674079c 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -55,7 +55,12 @@ export function testHooks(over: Partial = {}): 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, diff --git a/test/session.test.ts b/test/session.test.ts index 14346ab..92bdb91 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -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); diff --git a/test/tools.test.ts b/test/tools.test.ts index 8fa63a9..9c2e85e 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -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);