diff --git a/CHANGELOG.md b/CHANGELOG.md index 734e940..aba69a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.9.8] - 2026-10-09 + +### Added + +- **Background commands.** `Bash` gains a `background` parameter: long-running commands (downloads, builds, tests, installs) start detached and return a command ID immediately instead of blocking the turn for up to the 120s timeout. Output is captured to a temp file. Two new tools manage them: `check_background ` polls status (running/completed/failed/killed), exit code, and output — including partial output while still running — and `kill_background ` sends SIGTERM to the command's process group (SIGKILL after 3s). The status bar shows `● N shells running`; click it or press `ctrl+b` to expand a panel listing each shell's command, status, elapsed time, pid, cwd and latest output line, with `[stop]` and `[collapse]` actions. Only running shells are listed; finished ones drop out and the agent is told about them on the next turn instead of asking you to check an ID. Shells are scoped to the task that started them: they don't appear in other tasks and are stopped when you start a new task, resume another session, or exit OrbCode. Finished commands are pruned from the registry (and their temp files deleted) after one hour. Covered by `test/background-commands.test.ts` (`npm run test:background`). +- **Remove queued messages.** The queue panel now shows a clickable `[remove]` action beside each queued message, letting you drop a message you no longer want to send without waiting for it to be drained. + ## [6.9.7] - 2026-10-06 ### Added diff --git a/package.json b/package.json index 8b6f57a..13e9ddd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.9.7", + "version": "6.9.8", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { @@ -39,6 +39,7 @@ "test:metrics": "node --import tsx --test test/metrics.test.ts", "test:json-repair": "node --import tsx --test test/json-repair.test.ts", "test:readonly": "node --import tsx --test test/read-only-command.test.ts", + "test:background": "node --import tsx --test test/background-commands.test.ts", "test:search": "node --import tsx --test test/search-files.test.ts", "test:rewind": "node --import tsx --test test/rewind.test.ts", "test:headless": "node --import tsx --test test/headless.test.ts", diff --git a/src/core/agent.ts b/src/core/agent.ts index 8e4508d..88c12aa 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -50,6 +50,7 @@ import { renderLinkedReposSection } from "../config/links.js" import { unifiedDiff } from "../utils/diff.js" import { parseToolCallArguments } from "../utils/jsonRepair.js" import { normalizeTodoList } from "../utils/todos.js" +import { takeFinishedBackgroundCommands } from "../tools/executors/backgroundCommands.js" import { countDiffLines, detectGitRepo, @@ -344,7 +345,24 @@ function jsonRepairNote(toolName: string, args: Record): string return `[OrbCode] The ${toolName} arguments were malformed JSON and were auto-repaired before execution. Interpreted arguments: ${preview}. Emit strictly valid JSON in future tool calls — every key and string value must be double-quoted.` } +/** Completion notes for background commands that finished since the last turn. */ +function backgroundCommandsNote(taskId: string): string { + const finished = takeFinishedBackgroundCommands(taskId) + if (finished.length === 0) return "" + const lines = finished.map((cmd) => { + const outcome = + cmd.status === "killed" + ? "was stopped" + : `${cmd.status} (exit ${cmd.exitCode === null ? "unknown" : cmd.exitCode})` + return `- ${cmd.id}: \`${cmd.command}\` ${outcome}` + }) + return `\nThese background commands finished since your last turn. Use check_background with the id if you need the output.\n${lines.join("\n")}\n` +} + function formatResultPreview(toolName: string, text: string): string { + if ((toolName === "Bash" || toolName === "execute_command") && text.startsWith("Background command started")) { + return "Running in background · ctrl+b to view" + } const visibleText = toolName === "search_files" ? stripSearchPageMetadataForDisplay(text) : text if (!visibleText) return "" const lines = visibleText.split("\n") @@ -1021,6 +1039,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` cwd: this.options.cwd, token: this.options.token, signal: this.abortController?.signal, + taskId: this.taskId, beforeWrite: (filePath) => { const checkpoint = this.checkpoints[this.checkpoints.length - 1] if (checkpoint) snapshotFile(this.taskId, checkpoint, filePath) @@ -1104,6 +1123,8 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` if (promptContext) { userContent = `${userContent}\n\n${wrapHookContext("UserPromptSubmit", promptContext)}` } + const backgroundNote = backgroundCommandsNote(this.taskId) + if (backgroundNote) userContent = `${userContent}\n\n${backgroundNote}` const supportedImages = getModel(this.options.modelId).supportsImages ? imageAttachments : [] if (supportedImages.length !== imageAttachments.length) { onEvent({ type: "system", message: "The current model does not support image attachments.", isError: true }) diff --git a/src/prompts/system.ts b/src/prompts/system.ts index 3ae904a..5783a63 100644 --- a/src/prompts/system.ts +++ b/src/prompts/system.ts @@ -185,9 +185,12 @@ The \`Bash\` tool runs bash commands on the user's system. It is your primary to - \`cwd\` (required, string or null): Absolute working directory, or null for the workspace directory. You already have the Current Workspace Directory in the Environment Details section. - \`message\` (required): One-line description shown to the user. - \`isDangerous\` (required): true only for destructive or irreversible commands. +- \`background\` (optional): Set to true to run the command in the background (non-blocking). The command runs asynchronously and you can check its status later with the check_background tool. Use for long-running commands like downloads, builds, or tests. CRITICAL: If the command is a very long running process, prefer to let the user know so they can run it manually in their terminal. If the user specifically requests to run a long running command, you may proceed. +**Background commands:** For long-running commands (downloads, builds, tests, installs), set \`background: true\` to run them asynchronously. The command starts immediately and returns a command ID. The user sees running commands in the status bar and can view or stop them there, so never ask the user to copy the ID or check on the command, and don't repeat the ID in your reply. You are told when a background command finishes (in a note on the next user message); use \`check_background\` yourself when you need its output, and \`kill_background\` to stop a command that is no longer needed. + Command validity rules: a command is never empty, never just \`:\`, never a bare single word with no arguments (except \`ls\` or \`pwd\`), and never contains tool-call markup tokens or angle-bracket tags of any kind. ## Exploring with the shell diff --git a/src/tools/executors/backgroundCommands.ts b/src/tools/executors/backgroundCommands.ts new file mode 100644 index 0000000..36e78a2 --- /dev/null +++ b/src/tools/executors/backgroundCommands.ts @@ -0,0 +1,245 @@ +import { spawn } from "node:child_process" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +export interface BackgroundCommand { + id: string + /** Task that started the command; scopes listing, notices and cleanup. */ + owner: string | undefined + command: string + cwd: string + pid: number | null + status: "running" | "completed" | "failed" | "killed" + exitCode: number | null + output: string + outputFile: string + startedAt: number + endedAt: number | null + /** Set once the agent has been told this command finished. */ + reported: boolean +} + +/** Completed commands are pruned (and their temp files deleted) after this long. */ +const RETENTION_MS = 60 * 60 * 1000 + +/** Grace period between SIGTERM and SIGKILL when stopping a command. */ +const KILL_GRACE_MS = 3000 + +const commands = new Map() +const listeners = new Set<() => void>() +let exitHookInstalled = false + +/** Subscribe to start/finish/kill events. Returns an unsubscribe function. */ +export function subscribeBackgroundCommands(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function notify() { + for (const listener of listeners) { + try { + listener() + } catch { + // a broken listener must not affect the registry + } + } +} + +function signalCommand(cmd: BackgroundCommand, signal: NodeJS.Signals): boolean { + if (!cmd.pid) return false + try { + // Negative pid targets the whole process group (detached spawn). + process.kill(process.platform !== "win32" ? -cmd.pid : cmd.pid, signal) + return true + } catch { + return false + } +} + +/** Detached children outlive us by default; take them down when we exit. */ +function installExitHook() { + if (exitHookInstalled) return + exitHookInstalled = true + process.once("exit", () => { + for (const cmd of commands.values()) { + if (cmd.status === "running") signalCommand(cmd, "SIGTERM") + } + }) +} + +function readOutput(cmd: BackgroundCommand): string { + try { + return fs.readFileSync(cmd.outputFile, "utf-8") + } catch { + return cmd.output + } +} + +function deleteOutputFile(cmd: BackgroundCommand) { + try { + fs.rmSync(cmd.outputFile, { force: true }) + } catch { + // best-effort cleanup + } +} + +/** Drop finished commands older than the retention window, along with their temp files. */ +function pruneExpired() { + const now = Date.now() + for (const [id, cmd] of commands) { + if (cmd.status !== "running" && cmd.endedAt !== null && now - cmd.endedAt > RETENTION_MS) { + deleteOutputFile(cmd) + commands.delete(id) + } + } +} + +export function startBackgroundCommand( + command: string, + cwd: string, + shell: string, + shellArgs: string[], + owner?: string, +): BackgroundCommand { + pruneExpired() + installExitHook() + + const id = `bg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + const outputFile = path.join(os.tmpdir(), `orbcode-bg-${id}.log`) + + const bgCommand: BackgroundCommand = { + id, + owner, + command, + cwd, + pid: null, + status: "running", + exitCode: null, + output: "", + outputFile, + startedAt: Date.now(), + endedAt: null, + reported: false, + } + commands.set(id, bgCommand) + + const child = spawn(shell, shellArgs, { + cwd, + env: { ...process.env, TERM: "dumb" }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + + bgCommand.pid = child.pid ?? null + + // Output is captured to a temp file so it survives partial reads while running. + const outStream = fs.createWriteStream(outputFile) + child.stdout.on("data", (data) => outStream.write(data)) + child.stderr.on("data", (data) => outStream.write(data)) + + // Finalize status only after the stream has flushed, so a concurrent + // getBackgroundCommand never sees a finished status with a stale file. + // A spawn failure emits both "error" and "close"; the first one wins. + let finalized = false + const finalize = (status: BackgroundCommand["status"], exitCode: number | null) => { + if (finalized) return + finalized = true + outStream.end(() => { + bgCommand.exitCode = exitCode + bgCommand.status = status + bgCommand.endedAt = Date.now() + notify() + }) + } + + child.on("close", (code, signal) => { + finalize(signal ? "killed" : code === 0 ? "completed" : "failed", code) + }) + + child.on("error", (error) => { + outStream.write(`Error: ${error.message}\n`) + finalize("failed", null) + }) + + notify() + return bgCommand +} + +export function getBackgroundCommand(id: string): BackgroundCommand | null { + const cmd = commands.get(id) + if (!cmd) return null + + // Refresh output from the temp file: partial while running, final once done. + // Status itself is owned by the child's close/error events. + cmd.output = readOutput(cmd) + return cmd +} + +/** Tracked commands, optionally only those started by `owner`. */ +export function listBackgroundCommands(owner?: string): BackgroundCommand[] { + pruneExpired() + const all = Array.from(commands.values()) + return owner === undefined ? all : all.filter((cmd) => cmd.owner === owner) +} + +/** Stop every running command started by `owner` (e.g. when its task is left). */ +export function killBackgroundCommandsFor(owner: string): void { + for (const cmd of commands.values()) { + if (cmd.owner === owner && cmd.status === "running") killBackgroundCommand(cmd.id) + } +} + +/** Last `maxBytes` of a command's output, without reading the whole log. */ +export function readBackgroundOutputTail(id: string, maxBytes = 4096): string { + const cmd = commands.get(id) + if (!cmd) return "" + let fd: number | undefined + try { + fd = fs.openSync(cmd.outputFile, "r") + const size = fs.fstatSync(fd).size + const length = Math.min(size, maxBytes) + const buffer = Buffer.alloc(length) + fs.readSync(fd, buffer, 0, length, size - length) + return buffer.toString("utf-8") + } catch { + return cmd.output.slice(-maxBytes) + } finally { + if (fd !== undefined) fs.closeSync(fd) + } +} + +export function killBackgroundCommand(id: string): boolean { + const cmd = commands.get(id) + if (!cmd || cmd.status !== "running" || !cmd.pid) return false + if (!signalCommand(cmd, "SIGTERM")) return false + // Escalate if the process ignores SIGTERM. + const escalate = setTimeout(() => { + if (cmd.status === "running") signalCommand(cmd, "SIGKILL") + }, KILL_GRACE_MS) + escalate.unref?.() + return true +} + +/** + * Commands that finished since the agent was last told, marked as reported. + * Lets the agent learn about completions without polling. + */ +export function takeFinishedBackgroundCommands(owner?: string): BackgroundCommand[] { + const finished: BackgroundCommand[] = [] + for (const cmd of commands.values()) { + if (owner !== undefined && cmd.owner !== owner) continue + if (cmd.status !== "running" && !cmd.reported) { + cmd.reported = true + finished.push(cmd) + } + } + return finished +} + +/** Test-only: drop all tracked commands and their temp files. */ +export function resetBackgroundCommands() { + for (const cmd of commands.values()) deleteOutputFile(cmd) + commands.clear() + notify() +} diff --git a/src/tools/executors/checkBackground.ts b/src/tools/executors/checkBackground.ts new file mode 100644 index 0000000..67de080 --- /dev/null +++ b/src/tools/executors/checkBackground.ts @@ -0,0 +1,52 @@ +import { getBackgroundCommand } from "./backgroundCommands.js" +import { type ToolContext, type ToolResult } from "../types.js" + +const MAX_OUTPUT_CHARS = 30_000 + +function formatOutput(output: string): string { + const trimmed = output.trim() + if (!trimmed) return " Output: (empty)" + if (trimmed.length > MAX_OUTPUT_CHARS) { + return ` Output (truncated):\n${trimmed.slice(0, MAX_OUTPUT_CHARS)}\n... (output truncated)` + } + return ` Output:\n${trimmed}` +} + +export async function checkBackground(args: Record, context: ToolContext): Promise { + const id = String(args.id ?? "").trim() + if (!id) { + return { text: "FAILED: id is required", isError: true } + } + + const cmd = getBackgroundCommand(id) + if (!cmd) { + return { text: `FAILED: No background command found with id "${id}"`, isError: true } + } + + const elapsed = Math.round((Date.now() - cmd.startedAt) / 1000) + const duration = cmd.endedAt ? Math.round((cmd.endedAt - cmd.startedAt) / 1000) : null + + let text = `Background command status:\n` + text += ` ID: ${cmd.id}\n` + text += ` Command: ${cmd.command}\n` + text += ` Status: ${cmd.status}\n` + text += ` Elapsed: ${elapsed}s` + if (duration !== null) { + text += ` (duration: ${duration}s)` + } + text += `\n` + + if (cmd.status !== "running") { + // The agent has now seen the result; skip the next-turn completion notice. + cmd.reported = true + text += ` Exit code: ${cmd.exitCode === null ? "unknown" : cmd.exitCode}\n` + text += formatOutput(cmd.output) + } else { + text += ` Still running...` + if (cmd.output.trim()) { + text += `\n Partial output:\n${cmd.output.trim()}` + } + } + + return { text } +} diff --git a/src/tools/executors/executeCommand.ts b/src/tools/executors/executeCommand.ts index 7885b5e..3e50e35 100644 --- a/src/tools/executors/executeCommand.ts +++ b/src/tools/executors/executeCommand.ts @@ -1,20 +1,51 @@ import { spawn } from "node:child_process" +import * as fs from "node:fs" import { getShell, getShellRunArgs, isCmdShell } from "../../utils/shell.js" import { type ToolContext, type ToolResult, resolveWorkspacePath } from "../types.js" +import { startBackgroundCommand } from "./backgroundCommands.js" const COMMAND_TIMEOUT_MS = 120_000 const MAX_OUTPUT_CHARS = 30_000 +/** Some models stringify a null `cwd`; treat those spellings as "workspace". */ +const NULLISH_CWD = new Set(["", "null", "undefined", "none"]) + +function resolveCwd(raw: unknown, workspace: string): string { + if (raw === null || raw === undefined) return workspace + const value = String(raw).trim() + if (NULLISH_CWD.has(value.toLowerCase())) return workspace + return resolveWorkspacePath(workspace, value) +} + export async function executeCommand(args: Record, context: ToolContext): Promise { const command = String(args.command ?? "").trim() if (!command) { return { text: "FAILED: command is empty", isError: true } } - const cwd = args.cwd ? resolveWorkspacePath(context.cwd, String(args.cwd)) : context.cwd + const cwd = resolveCwd(args.cwd, context.cwd) + const background = Boolean(args.background) if (context.signal?.aborted) return { text: "Command not run: the turn was interrupted.", isError: true } + // A missing cwd makes spawn fail with a misleading "ENOENT ... /bin/bash". + if (!fs.statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) { + return { text: `FAILED: working directory does not exist: ${cwd}`, isError: true } + } + + // Background mode: start detached and return immediately + if (background) { + const bgCmd = startBackgroundCommand(command, cwd, getShell(), getShellRunArgs(command), context.taskId) + return { + text: + `Background command started (id: ${bgCmd.id}, pid: ${bgCmd.pid}). ` + + `The user sees it in the status bar and can view or stop it there. ` + + `You will be told when it finishes; call check_background yourself if you need its output sooner. ` + + `Do not ask the user to check on it or mention its id.`, + } + } + + // Foreground mode: wait for completion (existing behavior) return new Promise((resolve) => { const isWindows = process.platform === "win32" const child = spawn(getShell(), getShellRunArgs(command), { diff --git a/src/tools/executors/killBackground.ts b/src/tools/executors/killBackground.ts new file mode 100644 index 0000000..fd720d6 --- /dev/null +++ b/src/tools/executors/killBackground.ts @@ -0,0 +1,23 @@ +import { getBackgroundCommand, killBackgroundCommand } from "./backgroundCommands.js" +import { type ToolContext, type ToolResult } from "../types.js" + +export async function killBackground(args: Record, context: ToolContext): Promise { + const id = String(args.id ?? "").trim() + if (!id) { + return { text: "FAILED: id is required", isError: true } + } + + const cmd = getBackgroundCommand(id) + if (!cmd) { + return { text: `FAILED: No background command found with id "${id}"`, isError: true } + } + if (cmd.status !== "running") { + return { text: `Background command ${id} is not running (status: ${cmd.status}).` } + } + + const killed = killBackgroundCommand(id) + if (!killed) { + return { text: `FAILED: could not kill background command ${id} (process may have already exited).`, isError: true } + } + return { text: `Sent SIGTERM to background command ${id} (pid ${cmd.pid}). It is force-killed if still running after 3s.` } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 7095524..e99fac2 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,8 @@ import { fileEdit, fileWrite, multiFileEdit, readFile } from "./executors/files. import { listFiles } from "./executors/listFiles.js" import { disposeSearchFiles, normalizeSearchFilePattern, searchFiles } from "./executors/searchFiles.js" import { executeCommand } from "./executors/executeCommand.js" +import { checkBackground } from "./executors/checkBackground.js" +import { killBackground } from "./executors/killBackground.js" import { useSkill } from "./executors/skills.js" import { webFetch, webSearch } from "./executors/web.js" import { figmaFetch } from "./executors/figma.js" @@ -59,6 +61,10 @@ export function describeToolCall(toolName: string, args: Record case "Bash": case "execute_command": return String(args.command ?? "") + case "check_background": + return `checking background command ${String(args.id ?? "")}` + case "kill_background": + return `killing background command ${String(args.id ?? "")}` case "list_files": return `${args.path ?? "."}${args.recursive ? " (recursive)" : ""}` case "search_files": { @@ -98,6 +104,8 @@ const executors: Record, context: ToolCon search_files: searchFiles, Bash: executeCommand, execute_command: executeCommand, + check_background: checkBackground, + kill_background: killBackground, web_search: webSearch, web_fetch: webFetch, figma_fetch: figmaFetch, diff --git a/src/tools/schemas/bash.ts b/src/tools/schemas/bash.ts index 218e33e..625cb6b 100644 --- a/src/tools/schemas/bash.ts +++ b/src/tools/schemas/bash.ts @@ -5,7 +5,7 @@ export default { function: { name: "Bash", description: - "Run one bash command. Provide a short user-facing message and explicitly classify whether it may modify or delete data. Prefer commands scoped to the workspace.", + "Run one bash command. Provide a short user-facing message and explicitly classify whether it may modify or delete data. Prefer commands scoped to the workspace. For long-running commands (downloads, builds, tests), set background to true to run them asynchronously and check status later with check_background.", strict: true, parameters: { type: "object", @@ -27,6 +27,11 @@ export default { description: "Set true when the command is potentially destructive or irreversible — e.g. deletes/overwrites files (rm, mv over existing paths), force-pushes or resets git history, drops/migrates databases, changes system/network/permission state, installs globally, or sends data to external services. Set false for safe read-only or routine commands (ls, cat, build, test, install local deps). The user's selected approval mode may auto-approve only commands marked false.", }, + background: { + type: "boolean", + description: + "Set true to run the command in the background (non-blocking). The command runs asynchronously and you can check its status later with the check_background tool. Use for long-running commands like downloads, builds, or tests. The command's output is captured to a temp file and available when you check status.", + }, }, required: ["command", "cwd", "message", "isDangerous"], additionalProperties: false, diff --git a/src/tools/schemas/check_background.ts b/src/tools/schemas/check_background.ts new file mode 100644 index 0000000..cbd129e --- /dev/null +++ b/src/tools/schemas/check_background.ts @@ -0,0 +1,22 @@ +import type OpenAI from "openai" + +export default { + type: "function", + function: { + name: "check_background", + description: + "Check the status of a background command started with Bash(background=true). Returns the command's status (running/completed/failed/killed), exit code, and output. Use this to poll long-running background commands.", + strict: true, + parameters: { + type: "object", + properties: { + id: { + type: "string", + description: "The background command ID returned when the command was started", + }, + }, + required: ["id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool \ No newline at end of file diff --git a/src/tools/schemas/index.ts b/src/tools/schemas/index.ts index 454dd3e..44b2880 100644 --- a/src/tools/schemas/index.ts +++ b/src/tools/schemas/index.ts @@ -12,6 +12,8 @@ import useSkill from "./use_skill.js" import figmaFetch from "./figma_fetch.js" import webFetch from "./web_fetch.js" import webSearch from "./web_search.js" +import checkBackground from "./check_background.js" +import killBackground from "./kill_background.js" // Native tool schemas ported from the Orbital extension. File discovery and // content search (list_files, search_files) are deliberately not exposed: the @@ -33,4 +35,6 @@ export const nativeTools = [ figmaFetch, webFetch, webSearch, + checkBackground, + killBackground, ] satisfies OpenAI.Chat.ChatCompletionTool[] diff --git a/src/tools/schemas/kill_background.ts b/src/tools/schemas/kill_background.ts new file mode 100644 index 0000000..e02af34 --- /dev/null +++ b/src/tools/schemas/kill_background.ts @@ -0,0 +1,22 @@ +import type OpenAI from "openai" + +export default { + type: "function", + function: { + name: "kill_background", + description: + "Stop a background command started with Bash(background=true) by sending SIGTERM to its process group. Use this when a background command is no longer needed or is misbehaving. Confirm with check_background afterwards.", + strict: true, + parameters: { + type: "object", + properties: { + id: { + type: "string", + description: "The background command ID returned when the command was started", + }, + }, + required: ["id"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/tools/types.ts b/src/tools/types.ts index 54835d8..db51e4e 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -10,6 +10,8 @@ export interface ToolContext { signal?: AbortSignal /** called with the absolute path of a file right before a tool writes it, so it can be backed up for /rewind */ beforeWrite?: (filePath: string) => void + /** task that owns background commands started from this context */ + taskId?: string } export interface ToolResult { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 9dac682..ee970e6 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -129,6 +129,18 @@ import { } from "./components/TranscriptViewport.js"; import { ScrollToBottomChip } from "./components/ScrollToBottomChip.js"; import { QueuedMessages } from "./components/QueuedMessages.js"; +import { + BackgroundShells, + backgroundShellsHeight, + backgroundShellsLabel, +} from "./components/BackgroundShells.js"; +import { + killBackgroundCommand, + killBackgroundCommandsFor, + listBackgroundCommands, + subscribeBackgroundCommands, + type BackgroundCommand, +} from "../tools/executors/backgroundCommands.js"; import { Toast } from "./components/Toast.js"; import { copyToClipboard } from "../utils/clipboard.js"; import { @@ -506,6 +518,11 @@ export function App({ // Drained one-per-turn on each `turn-end` event so multi-step work can // keep flowing without making the user wait for the previous response. const [queuedMessages, setQueuedMessages] = useState([]); + // Running background shells (Bash with background=true) of the current + // task. Finished shells drop out; the agent is told about them instead. + const [bgShells, setBgShells] = useState([]); + const [bgExpanded, setBgExpanded] = useState(false); + const [, setBgTick] = useState(0); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [themePickerOpen, setThemePickerOpen] = useState(false); const [effortPickerOpen, setEffortPickerOpen] = useState(false); @@ -637,6 +654,47 @@ export function App({ setQueuedMessages([]); }, []); + const removeQueued = useCallback((index: number) => { + const queue = queueRef.current; + const clamped = Math.min(Math.max(index, 0), queue.length - 1); + queueRef.current = [ + ...queue.slice(0, clamped), + ...queue.slice(clamped + 1), + ]; + setQueuedMessages(queueRef.current); + }, []); + + const refreshBgShells = useCallback(() => { + const taskId = agentRef.current?.taskId; + // Copy each entry so React sees status changes on the mutable records. + setBgShells( + taskId + ? listBackgroundCommands(taskId) + .filter((cmd) => cmd.status === "running") + .map((cmd) => ({ ...cmd })) + .reverse() + : [], + ); + }, []); + useEffect(() => { + refreshBgShells(); + return subscribeBackgroundCommands(refreshBgShells); + }, [refreshBgShells]); + + const bgPanelOpen = bgExpanded && bgShells.length > 0; + // Collapse once the last shell finishes, so the next one starts collapsed. + useEffect(() => { + if (bgShells.length === 0) setBgExpanded(false); + }, [bgShells.length]); + + // Tick once a second while the panel is open so elapsed time and the + // output tail stay live. + useEffect(() => { + if (!bgPanelOpen) return; + const timer = setInterval(() => setBgTick((n) => n + 1), 1000); + return () => clearInterval(timer); + }, [bgPanelOpen]); + const scrollTranscriptBy = useCallback((lines: number) => { setScrollOffset((current) => Math.max(0, current + lines)); }, []); @@ -939,12 +997,15 @@ export function App({ const agent = agentRef.current; agentRef.current = null; agent?.abort(); + // Background shells belong to the task being left. + if (agent) killBackgroundCommandsFor(agent.taskId); + refreshBgShells(); textBufferRef.current = ""; reasoningBufferRef.current = ""; setStreamingText(""); setStreamingReasoning(""); setBusy(false); - }, []); + }, [refreshBgShells]); // Force-send: jump a queued message to the front of the queue, then skip // the wait for the in-flight turn. Aborting makes the agent's `finally` @@ -2170,6 +2231,11 @@ export function App({ ); // The terminal adapter replaces the retained screen rows in place. } + // Ctrl+B expands/collapses the background shells panel. + if (key.ctrl && input === "b" && view === "chat") { + if (bgShells.length > 0) setBgExpanded((prev) => !prev); + return; + } // Ctrl+S force-sends the next queued message without waiting for the // in-flight turn (the queue panel advertises this next to each message). if ( @@ -2272,6 +2338,10 @@ export function App({ (queuedMessages.length > 5 ? 1 : 0); } + if (bgPanelOpen) { + bottomControlsHeight += backgroundShellsHeight(bgShells.length); + } + const contentHeight = Math.max(1, termRows - bottomControlsHeight); const wrapWidth = Math.max(20, termCols - 4); const approvalMaxDiffLines = Math.max( @@ -2623,6 +2693,17 @@ export function App({ messages={queuedMessages} width={wrapWidth} onForceSend={forceSendQueued} + onRemove={removeQueued} + /> + )} + {bgPanelOpen && ( + { + killBackgroundCommand(id); + }} + onCollapse={() => setBgExpanded(false)} /> )} 0 + ? backgroundShellsLabel(bgShells.length) + : undefined + } + shellsExpanded={bgPanelOpen} + onShellsClick={() => setBgExpanded((prev) => !prev)} /> diff --git a/src/ui/components/BackgroundShells.tsx b/src/ui/components/BackgroundShells.tsx new file mode 100644 index 0000000..2d50379 --- /dev/null +++ b/src/ui/components/BackgroundShells.tsx @@ -0,0 +1,158 @@ +import React, { useState } from "react"; +import * as os from "node:os"; +import { Box, Text } from "../primitives.js"; +import { COLORS } from "../../branding.js"; +import { + readBackgroundOutputTail, + type BackgroundCommand, +} from "../../tools/executors/backgroundCommands.js"; + +/** Shells shown before the panel collapses into a "… N more" line. */ +const MAX_VISIBLE = 4; +/** Each shell renders a title row, a metadata row and an output-tail row. */ +const LINES_PER_SHELL = 3; +const STOP_TAG = "[stop]"; +const COLLAPSE_TAG = "[collapse]"; + +function fit(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + if (maxWidth <= 1) return text.slice(0, Math.max(0, maxWidth)); + return text.slice(0, maxWidth - 1) + "…"; +} + +function formatElapsed(ms: number): string { + const sec = Math.max(0, Math.floor(ms / 1000)); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ${sec % 60}s`; + return `${Math.floor(min / 60)}h ${min % 60}m`; +} + +function shortenPath(p: string): string { + const home = os.homedir(); + return home && p.startsWith(home) ? "~" + p.slice(home.length) : p; +} + +function lastOutputLine(id: string): string { + const lines = readBackgroundOutputTail(id, 2048) + // Progress bars redraw with \r; keep only the latest frame. + .split(/\r?\n/) + .map((line) => line.split("\r").pop() ?? "") + .map((line) => line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trimEnd()) + .filter(Boolean); + return lines[lines.length - 1] ?? ""; +} + +/** Status-bar label for the running shells, e.g. "2 shells running". */ +export function backgroundShellsLabel(count: number): string { + return `${count} shell${count === 1 ? "" : "s"} running`; +} + +/** Rows the expanded panel occupies, for the bottom-stack height budget. */ +export function backgroundShellsHeight(count: number): number { + if (count === 0) return 0; + const visible = Math.min(MAX_VISIBLE, count); + // header + shells + overflow line + bottom margin + return 1 + visible * LINES_PER_SHELL + (count > MAX_VISIBLE ? 1 : 0) + 1; +} + +export interface BackgroundShellsProps { + shells: BackgroundCommand[]; + width: number; + onStop: (id: string) => void; + onCollapse: () => void; +} + +/** Expanded view of the running background shells started by the agent, + * each with a stop action. Collapsed, they show as a count in the status + * bar; finished shells drop out (the agent is told about them instead). */ +export function BackgroundShells({ + shells, + width, + onStop, + onCollapse, +}: BackgroundShellsProps) { + const [hovered, setHovered] = useState(null); + const now = Date.now(); + const header = `Background shells (${shells.length}) · ctrl+b to collapse`; + + return ( + + + + {fit(header, Math.max(8, width - COLLAPSE_TAG.length - 2))} + + { + event.stopPropagation?.(); + onCollapse(); + }} + onMouseMove={(event) => { + event.stopPropagation?.(); + if (hovered !== "__collapse") setHovered("__collapse"); + }} + > + {` ${COLLAPSE_TAG}`} + + + {shells.slice(0, MAX_VISIBLE).map((shell) => { + const isHovered = hovered === shell.id; + const prefix = "● "; + const suffix = ` · ${formatElapsed(now - shell.startedAt)}`; + const commandWidth = Math.max( + 8, + width - 4 - prefix.length - suffix.length - STOP_TAG.length, + ); + const meta = [ + shell.pid ? `pid ${shell.pid}` : null, + shortenPath(shell.cwd), + shell.id, + ] + .filter(Boolean) + .join(" · "); + const output = lastOutputLine(shell.id); + + return ( + + + {prefix} + + {fit(shell.command.replace(/\s*\n\s*/g, " ↵ "), commandWidth)} + + {suffix} + { + event.stopPropagation?.(); + onStop(shell.id); + }} + onMouseMove={(event) => { + event.stopPropagation?.(); + if (hovered !== shell.id) setHovered(shell.id); + }} + > + {` ${STOP_TAG}`} + + + + {fit(` ${meta}`, width - 2)} + + + {fit(` ${output ? `› ${output}` : "(no output yet)"}`, width - 2)} + + + ); + })} + {shells.length > MAX_VISIBLE && ( + + {` … ${shells.length - MAX_VISIBLE} more`} + + )} + + ); +} diff --git a/src/ui/components/QueuedMessages.tsx b/src/ui/components/QueuedMessages.tsx index ba81018..ea020bd 100644 --- a/src/ui/components/QueuedMessages.tsx +++ b/src/ui/components/QueuedMessages.tsx @@ -6,7 +6,8 @@ import type { SubmittedPrompt } from "../../attachments.js"; /** Messages shown before the queue collapses into a "… N more" line. */ const MAX_VISIBLE = 5; const QUEUE_PREVIEW_LIMIT = 80; -const ACTION_TAG = "[send now]"; +const SEND_TAG = "[send now]"; +const REMOVE_TAG = "[remove]"; function fit(text: string, maxWidth: number): string { if (text.length <= maxWidth) return text; @@ -30,6 +31,8 @@ export interface QueuedMessagesProps { width: number; /** Force-send the message at `index` (0 = next in line) without waiting. */ onForceSend: (index: number) => void; + /** Remove the message at `index` from the queue. */ + onRemove: (index: number) => void; } /** Messages typed while the agent is streaming, each with an action to @@ -38,13 +41,14 @@ export function QueuedMessages({ messages, width, onForceSend, + onRemove, }: QueuedMessagesProps) { const [hovered, setHovered] = useState(null); const header = fit( `Queue (${messages.length}) · ${width < 56 ? "ctrl+s" : "ctrl+s sends the next one now"}`, width, ); - const textWidth = Math.max(8, width - 6 - ACTION_TAG.length); + const textWidth = Math.max(8, width - 6 - SEND_TAG.length - REMOVE_TAG.length - 1); return ( @@ -71,7 +75,22 @@ export function QueuedMessages({ if (hovered !== index) setHovered(index); }} > - {` ${ACTION_TAG}`} + {` ${SEND_TAG}`} + + { + event.stopPropagation?.(); + onRemove(index); + }} + onMouseMove={(event) => { + event.stopPropagation?.(); + if (hovered !== index) setHovered(index); + }} + > + {` ${REMOVE_TAG}`} ); diff --git a/src/ui/components/StatusBar.tsx b/src/ui/components/StatusBar.tsx index b2395bd..dffb312 100644 --- a/src/ui/components/StatusBar.tsx +++ b/src/ui/components/StatusBar.tsx @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { Box, Text } from "../primitives.js"; import { COLORS } from "../../branding.js"; @@ -54,6 +54,10 @@ interface StatusBarProps { plan?: string; usagePercentage?: number; tieredUsage?: AxonCodeTieredUsage; + /** Background shells summary, e.g. "1 shell running"; hidden when unset. */ + shellsLabel?: string; + shellsExpanded?: boolean; + onShellsClick?: () => void; } function truncate(text: string, max: number): string { @@ -117,7 +121,11 @@ export function StatusBar({ plan: _plan, usagePercentage: _usagePercentage, tieredUsage, + shellsLabel, + shellsExpanded, + onShellsClick, }: StatusBarProps) { + const [shellsHovered, setShellsHovered] = useState(false); const model = getModel(modelId); // Refresh after each agent turn, which also catches branch switches made by // commands during that turn without spawning git for every streaming frame. @@ -131,6 +139,24 @@ export function StatusBar({ + {shellsLabel && !exitConfirmationActive && ( + + { + event.stopPropagation?.(); + onShellsClick?.(); + }} + onMouseMove={() => setShellsHovered(true)} + onMouseOut={() => setShellsHovered(false)} + > + {`● ${shellsLabel} ${shellsExpanded ? "▾" : "▸"}`} + + {" · "} + + )} {exitConfirmationActive ? ( Press Ctrl+D again to exit diff --git a/test/background-commands.test.ts b/test/background-commands.test.ts new file mode 100644 index 0000000..5d37bf6 --- /dev/null +++ b/test/background-commands.test.ts @@ -0,0 +1,275 @@ +import assert from "node:assert/strict" +import test from "node:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { executeCommand } from "../src/tools/executors/executeCommand.js" +import { checkBackground } from "../src/tools/executors/checkBackground.js" +import { killBackground } from "../src/tools/executors/killBackground.js" +import { + getBackgroundCommand, + killBackgroundCommandsFor, + listBackgroundCommands, + readBackgroundOutputTail, + resetBackgroundCommands, + subscribeBackgroundCommands, + takeFinishedBackgroundCommands, +} from "../src/tools/executors/backgroundCommands.js" + +const context = () => ({ cwd: os.tmpdir(), token: "", getTodos: () => "", setTodos: () => {}, signal: undefined }) + +/** Extract the bg command id from the tool result text. */ +function extractId(text: string): string { + const match = /id: (bg_[\w.]+)/.exec(text) + assert.ok(match, `no background id in: ${text}`) + return match[1] +} + +/** Wait until the background command leaves the running state (or timeout). */ +async function waitForCompletion(id: string, timeoutMs = 10_000): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const cmd = getBackgroundCommand(id) + assert.ok(cmd, "command disappeared from registry") + if (cmd.status !== "running") return + await new Promise((r) => setTimeout(r, 100)) + } + assert.fail(`background command ${id} did not finish within ${timeoutMs}ms`) +} + +test("background command returns an id and pid immediately", async () => { + try { + const res = await executeCommand({ command: "echo hi", background: true }, context()) + assert.equal(res.isError, undefined) + assert.match(res.text, /Background command started/) + assert.match(res.text, /id: bg_/) + assert.match(res.text, /pid: \d+/) + } finally { + resetBackgroundCommands() + } +}) + +test("completed background command reports exit code and output", async () => { + try { + const start = await executeCommand({ command: "echo hello-bg", background: true }, context()) + const id = extractId(start.text) + await waitForCompletion(id) + + const res = await checkBackground({ id }, context()) + assert.match(res.text, /Status: completed/) + assert.match(res.text, /Exit code: 0/) + assert.match(res.text, /hello-bg/) + } finally { + resetBackgroundCommands() + } +}) + +test("failed background command reports non-zero exit code", async () => { + try { + const start = await executeCommand({ command: "exit 3", background: true }, context()) + const id = extractId(start.text) + await waitForCompletion(id) + + const res = await checkBackground({ id }, context()) + assert.match(res.text, /Status: failed/) + assert.match(res.text, /Exit code: 3/) + } finally { + resetBackgroundCommands() + } +}) + +test("running background command shows partial output", { skip: process.platform === "win32" }, async () => { + try { + const start = await executeCommand({ command: "echo early; sleep 5; echo late", background: true }, context()) + const id = extractId(start.text) + + // Wait for the early line to be flushed to the output file. + let sawPartial = false + for (let i = 0; i < 50 && !sawPartial; i++) { + await new Promise((r) => setTimeout(r, 100)) + const res = await checkBackground({ id }, context()) + if (/Status: running/.test(res.text) && /early/.test(res.text)) sawPartial = true + } + assert.ok(sawPartial, "partial output was never visible while running") + + const killed = await killBackground({ id }, context()) + assert.notEqual(killed.isError, true) + await waitForCompletion(id) + const res = await checkBackground({ id }, context()) + assert.match(res.text, /Status: killed/) + } finally { + resetBackgroundCommands() + } +}) + +test("check_background with unknown id fails", async () => { + const res = await checkBackground({ id: "bg_nonexistent" }, context()) + assert.equal(res.isError, true) + assert.match(res.text, /No background command found/) +}) + +test("check_background without id fails", async () => { + const res = await checkBackground({}, context()) + assert.equal(res.isError, true) + assert.match(res.text, /id is required/) +}) + +test("kill_background stops a running command", { skip: process.platform === "win32" }, async () => { + try { + const start = await executeCommand({ command: "sleep 30", background: true }, context()) + const id = extractId(start.text) + + const killRes = await killBackground({ id }, context()) + assert.notEqual(killRes.isError, true) + await waitForCompletion(id) + + const res = await checkBackground({ id }, context()) + assert.match(res.text, /Status: killed/) + } finally { + resetBackgroundCommands() + } +}) + +test("kill_background on a finished command is a no-op, not an error", async () => { + try { + const start = await executeCommand({ command: "true", background: true }, context()) + const id = extractId(start.text) + await waitForCompletion(id) + + const res = await killBackground({ id }, context()) + assert.notEqual(res.isError, true) + assert.match(res.text, /not running/) + } finally { + resetBackgroundCommands() + } +}) + +test("reset removes temp output files", async () => { + const start = await executeCommand({ command: "echo cleanup-test", background: true }, context()) + const id = extractId(start.text) + await waitForCompletion(id) + + const cmd = getBackgroundCommand(id) + assert.ok(cmd) + const outputFile = cmd.outputFile + assert.ok(fs.existsSync(outputFile), "output file should exist before reset") + + resetBackgroundCommands() + assert.equal(getBackgroundCommand(id), null) + assert.ok(!fs.existsSync(outputFile), "output file should be deleted after reset") + assert.ok(path.dirname(outputFile) === os.tmpdir()) +}) + +test("foreground mode still works (regression)", async () => { + const res = await executeCommand({ command: "echo fg-still-works" }, context()) + assert.equal(res.isError, false) + assert.match(res.text, /fg-still-works/) + assert.match(res.text, /Exit code: 0/) +}) + +test("subscribers are notified on start and finish", async () => { + let calls = 0 + const unsubscribe = subscribeBackgroundCommands(() => calls++) + try { + const start = await executeCommand({ command: "echo notify", background: true }, context()) + assert.equal(calls, 1, "notified on start") + await waitForCompletion(extractId(start.text)) + assert.equal(calls, 2, "notified on finish") + } finally { + unsubscribe() + resetBackgroundCommands() + } +}) + +test("finished commands are handed to the agent once", async () => { + try { + const start = await executeCommand({ command: "echo once", background: true }, context()) + const id = extractId(start.text) + await waitForCompletion(id) + const first = takeFinishedBackgroundCommands() + assert.deepEqual(first.map((cmd) => cmd.id), [id]) + assert.deepEqual(takeFinishedBackgroundCommands(), []) + } finally { + resetBackgroundCommands() + } +}) + +test("check_background on a finished command suppresses the completion notice", async () => { + try { + const start = await executeCommand({ command: "echo seen", background: true }, context()) + const id = extractId(start.text) + await waitForCompletion(id) + await checkBackground({ id }, context()) + assert.deepEqual(takeFinishedBackgroundCommands(), []) + } finally { + resetBackgroundCommands() + } +}) + +test("output tail returns only the end of the log", async () => { + try { + const start = await executeCommand({ command: "echo aaaa; echo bbbb", background: true }, context()) + const id = extractId(start.text) + await waitForCompletion(id) + assert.equal(readBackgroundOutputTail(id, 5), "bbbb\n") + assert.equal(readBackgroundOutputTail("bg_missing"), "") + } finally { + resetBackgroundCommands() + } +}) + +test("a stringified null cwd runs in the workspace", async () => { + try { + const start = await executeCommand({ command: "pwd", cwd: "null", background: true }, context()) + const id = extractId(start.text) + await waitForCompletion(id) + const cmd = getBackgroundCommand(id) + assert.equal(cmd?.status, "completed") + assert.equal(fs.realpathSync(cmd!.output.trim()), fs.realpathSync(os.tmpdir())) + } finally { + resetBackgroundCommands() + } +}) + +test("a missing cwd fails clearly instead of blaming the shell", async () => { + const missing = path.join(os.tmpdir(), "orbcode-no-such-dir") + for (const background of [true, false]) { + const res = await executeCommand({ command: "echo hi", cwd: missing, background }, context()) + assert.equal(res.isError, true) + assert.match(res.text, /working directory does not exist/) + } +}) + +test("commands are scoped to the task that started them", async () => { + try { + const a = await executeCommand({ command: "echo a", background: true }, { ...context(), taskId: "task-a" }) + const b = await executeCommand({ command: "echo b", background: true }, { ...context(), taskId: "task-b" }) + const idA = extractId(a.text) + const idB = extractId(b.text) + assert.deepEqual(listBackgroundCommands("task-a").map((cmd) => cmd.id), [idA]) + await waitForCompletion(idA) + await waitForCompletion(idB) + // Task B's completion must not leak into task A's notices. + assert.deepEqual(takeFinishedBackgroundCommands("task-a").map((cmd) => cmd.id), [idA]) + assert.deepEqual(takeFinishedBackgroundCommands("task-b").map((cmd) => cmd.id), [idB]) + } finally { + resetBackgroundCommands() + } +}) + +test("leaving a task stops only its running commands", { skip: process.platform === "win32" }, async () => { + try { + const a = await executeCommand({ command: "sleep 30", background: true }, { ...context(), taskId: "task-a" }) + const b = await executeCommand({ command: "sleep 30", background: true }, { ...context(), taskId: "task-b" }) + const idA = extractId(a.text) + const idB = extractId(b.text) + killBackgroundCommandsFor("task-a") + await waitForCompletion(idA) + assert.equal(getBackgroundCommand(idA)?.status, "killed") + assert.equal(getBackgroundCommand(idB)?.status, "running") + } finally { + killBackgroundCommandsFor("task-b") + resetBackgroundCommands() + } +})