Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` polls status (running/completed/failed/killed), exit code, and output — including partial output while still running — and `kill_background <id>` 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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -344,7 +345,24 @@ function jsonRepairNote(toolName: string, args: Record<string, unknown>): 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 `<background_commands>\nThese background commands finished since your last turn. Use check_background with the id if you need the output.\n${lines.join("\n")}\n</background_commands>`
}

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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
Expand Down
3 changes: 3 additions & 0 deletions src/prompts/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <background_commands> 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
Expand Down
245 changes: 245 additions & 0 deletions src/tools/executors/backgroundCommands.ts
Original file line number Diff line number Diff line change
@@ -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<string, BackgroundCommand>()
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()
}
52 changes: 52 additions & 0 deletions src/tools/executors/checkBackground.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, context: ToolContext): Promise<ToolResult> {
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 }
}
Loading
Loading