Skip to content

Background Agents

A background agent is an agent that runs independently of the user’s current interaction. The user submits a task, the agent starts executing, and the UI remains responsive for other work. Progress is communicated asynchronously — via event streams, status channels, or polling — rather than blocking the primary session.

The design challenges are:

  1. Output routing — where does the agent’s output go if the user isn’t watching? How does the system collect and surface it later?
  2. Cancellation — how does the user stop a background agent that’s taking too long or going in the wrong direction?
  3. Progress visibility — the user needs to know the agent is alive and what it’s doing, without being overwhelmed by updates.
  4. Resource limits — background agents consume tokens, API quota, and compute. Without limits, a long-running background agent can exhaust the user’s budget.
  5. Process lifecycle — if the terminal closes or the machine sleeps, what happens to background agents?

Aider has no background agent support. Codex was designed from the start with background execution in mind via its ThreadId model. OpenCode added background agents via the task tool with async session execution.


Aider has no background agent support. Every operation is synchronous and tied to the user’s terminal session. If the user runs /ask or starts an edit, the terminal is blocked until the LLM responds and edits are applied. There is no mechanism to continue interacting while a slow LLM response streams in.

The closest approximation is using aider --no-interactive in a subprocess from a shell script, which gives you a background process. But there is no coordination mechanism — the output goes to stdout/stderr of that process, and there is no way to receive updates or inject new prompts from within Aider itself.


Codex’s architecture was designed for background execution from the beginning. Every agent runs as an independent thread (in the Codex sense — an async tokio task identified by ThreadId), and clients interact with threads via event subscription rather than direct blocking calls.

File: codex-rs/core/src/agent/control.rs

Each ThreadId represents an independent agent execution. The client creates a thread, subscribes to its output channel, and can disconnect at any time without killing the agent. This is what makes background execution natural in Codex.

// Spawn a background agent
let thread_id = control.spawn_agent(
config.clone(),
vec![UserInput::text("Refactor the authentication module")],
None,
).await?;
// Subscribe to events from this thread
let mut events = manager.subscribe(thread_id).await;
// Non-blocking: process events as they arrive, continue other work
tokio::spawn(async move {
while let Some(event) = events.recv().await {
match event {
Event::AgentReasoning(text) => print_background_thought(text),
Event::ToolCallOutput(result) => log_tool_result(result),
Event::TurnComplete(msg) => notify_user(msg),
Event::Error(e) => report_error(e),
_ => {}
}
}
});

File: codex-rs/core/src/agent/control.rs:147

pub async fn subscribe_status(
&self,
thread_id: ThreadId,
) -> CodexResult<tokio::sync::watch::Receiver<AgentStatus>>

The watch::Receiver pattern (tokio) gives the caller the latest status at any point in time without buffering every intermediate state. This is ideal for displaying progress indicators in the TUI — you poll the watch channel on each frame render and show the current status.

pub enum AgentStatus {
PendingInit,
Running,
Completed(String), // final output
Errored(String),
Shutdown,
NotFound,
}

The TUI renders a spinner for Running, a checkmark for Completed, and an error indicator for Errored. The Completed(String) variant carries the final message, allowing the TUI to display it without re-querying the thread.

File: codex-rs/mcp-server/src/main.rs

Codex’s MCP server exposes two tools:

  • codex — start a new session (returns thread_id immediately)
  • codex-reply — send a follow-up prompt to an existing session

This is the primary background execution model: a caller (e.g., Claude, an IDE extension) calls codex with a task, gets a thread_id, and can poll for completion by calling codex-reply or by subscribing to the SSE event stream. The MCP server process persists independently of any particular caller, so a disconnected IDE reconnect will find the agent still running.

Caller → codex("refactor auth") → thread_id: "t-001"
Caller disconnects (IDE restart, network blip)
...
Caller reconnects → codex-reply(thread_id: "t-001", "are you done?")
Agent: "Yes, completed. Here is what I changed: ..."

File: codex-rs/core/src/codex.rs

Each agent loop holds a CancellationToken. Calling control.cancel(thread_id) signals the token, which causes the current LLM stream to abort cleanly:

async fn run_turn(..., cancel_token: CancellationToken) {
tokio::select! {
result = stream_llm_response() => { /* process */ }
_ = cancel_token.cancelled() => {
emit_event(Event::TurnAborted(AbortedReason::Interrupted));
return;
}
}
}

Cancellation is cooperative — the agent finishes the current atomic operation (writing a file, completing a tool call) before stopping. It does not kill the process mid-write.

Every event the agent emits is appended to a JSONL rollout file at ~/.codex/sessions/{thread_id}.jsonl. This means background agents are fully recoverable: if the Codex process crashes, the rollout file contains all events up to the crash, and resume_agent_from_rollout() can pick up from that point.


OpenCode’s background agent system is built on top of its task tool and the session hierarchy. A background agent is simply a subagent session that the parent session does not block waiting for.

The task tool in OpenCode’s general agent runs subagents synchronously — the parent blocks until the child completes. But the design allows the parent to spawn multiple tasks and Promise.all() them, giving effective parallelism:

// Parallel subagent execution (within the agent loop)
const [result1, result2] = await Promise.all([
task({ description: "Analyze auth", prompt: "...", subagent_type: "explore" }),
task({ description: "Analyze tests", prompt: "...", subagent_type: "explore" }),
]);

Each task() call creates a child session and runs SessionPrompt.prompt() asynchronously. From the LLM’s perspective these are sequential tool calls, but the OpenCode runtime can execute them concurrently.

File: opencode/packages/opencode/src/session/index.ts

The TUI shows active background sessions in a sidebar panel. The session state is tracked via the time.compacting field (repurposed as “active background indicator”) and time.archived. Sessions that are children (parentID set) and not yet archived are shown as “in progress.”

Each child session emits BusEvent events (part delta, tool call, message complete) that the parent TUI subscribes to for live progress display.

When a long-running background task completes, OpenCode emits a session.completed bus event. The TUI renders a notification banner: “Background task finished: [session title]”.

If the parent session is compacted before the child completes, the child’s output is written to the parent session as a tool result message, which appears in the parent’s history when the user next reviews it.

File: opencode/packages/opencode/src/tool/task.ts

// Resume an in-progress background agent
task({
description: "Continue analysis",
prompt: "You were analyzing the auth module. Continue from where you left off.",
subagent_type: "general",
task_id: "ses_01abc123", // ID from previous task call
})

The child session retains its full history, so a resumption prompt has access to everything the agent did in previous runs. This enables long-running work split across multiple user interactions.


Background Agents Hold Context Windows Open

Section titled “Background Agents Hold Context Windows Open”

A background agent running a long task will accumulate tokens in its context window. If the session is not compacted, it can hit the context limit and stall mid-task. OpenCode’s compaction system does handle this, but the compaction itself consumes API calls and time. For very long background tasks (thousands of tool calls), budget for compaction overhead.

In Codex, if no subscriber is attached to a thread’s event channel when an event fires, the event is buffered until a subscriber attaches. But the buffer is bounded. If the background agent completes thousands of tool calls while the TUI is closed, older events may be dropped before the user reconnects.

The rollout file (~/.codex/sessions/{id}.jsonl) is the canonical record — but reading it requires replaying the full JSONL log, which can be slow for long sessions.

In both Codex and OpenCode, cancelling a background agent stops future actions but does not undo the actions already taken. If the agent wrote 5 files and is cancelled while writing the 6th, the 5 committed files remain. Users must manually inspect and revert, or use the undo mechanism (ghost commits in Codex, snapshots in OpenCode).

Always pair background agent execution with undo snapshots. See Ghost Commits & Snapshots and Undo/Redo.

Codex MCP server agents survive client disconnects. OpenCode session-based agents also survive if the server process stays alive. But neither system handles the case where the server process itself dies (OOM, SIGKILL, machine restart).

Codex’s rollout file is the recovery mechanism. OpenCode’s SQLite database survives process death but the in-memory agent state does not — the agent would need to be explicitly resumed via the task tool with the old task_id.

Background agents have no visibility into the parent’s remaining token budget. A parent that has 80k tokens left spawns a background agent that consumes 60k before returning. The parent is now at 20k — too little for a meaningful follow-up turn. There is no back-pressure or budget-sharing mechanism.

Mitigation: Use the steps field to hard-cap background agents, and size the cap based on the parent’s remaining budget before spawning.

In Codex’s thread model, all threads compete equally for the LLM’s API quota. A low-priority background indexing task can consume rate-limit quota that a high-priority foreground task needs. There is no thread priority or quota partitioning.


Background agents in OpenOxide are tokio tasks that write events to a bounded channel. The TUI subscribes to this channel for live progress display. When the TUI is not watching (e.g., different screen focused), events are buffered up to BACKGROUND_EVENT_BUFFER_SIZE. Events beyond the buffer size trigger a compaction: older events are summarized and the buffer is cleared.

pub struct BackgroundAgent {
pub session_id: SessionId,
pub agent_name: String,
pub status: AgentStatus,
pub event_tx: mpsc::Sender<AgentEvent>,
pub cancel: CancellationToken,
pub started_at: Instant,
pub token_usage: TokenUsage,
}
pub struct BackgroundAgentManager {
agents: HashMap<SessionId, BackgroundAgent>,
max_concurrent: usize, // default: 4
}
impl BackgroundAgentManager {
pub async fn spawn(
&mut self,
session_id: SessionId,
prompt: String,
agent_def: AgentDef,
event_tx: mpsc::Sender<AgentEvent>,
) -> Result<(), BackgroundError> {
if self.agents.len() >= self.max_concurrent {
return Err(BackgroundError::ConcurrencyLimitReached {
limit: self.max_concurrent,
});
}
let cancel = CancellationToken::new();
let cancel_child = cancel.clone();
tokio::spawn(async move {
run_agent_loop(session_id, prompt, agent_def, event_tx, cancel_child).await;
});
self.agents.insert(session_id, BackgroundAgent {
session_id,
agent_name: agent_def.name.clone(),
status: AgentStatus::Running,
event_tx,
cancel,
started_at: Instant::now(),
token_usage: TokenUsage::default(),
});
Ok(())
}
pub fn cancel(&self, session_id: SessionId) {
if let Some(agent) = self.agents.get(&session_id) {
agent.cancel.cancel();
}
}
}
pub enum AgentEvent {
/// Reasoning text delta (streaming)
TextDelta { text: String },
/// Tool call started
ToolCallStarted { tool: String, params: serde_json::Value },
/// Tool call result
ToolCallResult { tool: String, output: String },
/// Agent completed successfully
Completed { final_message: String, tokens: TokenUsage },
/// Agent hit step limit
StepLimitReached { steps_used: u32 },
/// Agent cancelled by user
Cancelled,
/// Agent errored
Error { message: String },
}

The TUI maintains a background task panel showing:

  • Active background agents (spinner + task description + elapsed time)
  • Completed agents (checkmark + task description + token count)
  • Failed agents (X + error summary)
fn render_background_panel(frame: &mut Frame, agents: &[BackgroundAgent]) {
for agent in agents {
let status_icon = match agent.status {
AgentStatus::Running => Span::styled("", style::running()),
AgentStatus::Completed => Span::styled("", style::success()),
AgentStatus::Errored => Span::styled("", style::error()),
_ => Span::raw(" "),
};
let elapsed = agent.started_at.elapsed();
frame.render_widget(
Line::from(vec![status_icon, Span::raw(format!(" {} ({}s)", agent.agent_name, elapsed.as_secs()))]),
area,
);
}
}

Every AgentEvent is appended to the session’s JSONL file at ~/{xdg_data}/openoxide/sessions/{session_id}.jsonl. This mirrors Codex’s approach. On resume, the JSONL is replayed to restore session state.

pub struct RolloutWriter {
path: PathBuf,
writer: BufWriter<File>,
}
impl RolloutWriter {
pub fn append(&mut self, event: &AgentEvent) -> io::Result<()> {
let line = serde_json::to_string(event)? + "\n";
self.writer.write_all(line.as_bytes())?;
self.writer.flush()
}
}

Cancellation is cooperative via CancellationToken:

async fn run_agent_loop(
session_id: SessionId,
prompt: String,
agent_def: AgentDef,
event_tx: mpsc::Sender<AgentEvent>,
cancel: CancellationToken,
) {
loop {
tokio::select! {
_ = cancel.cancelled() => {
let _ = event_tx.send(AgentEvent::Cancelled).await;
break;
}
result = execute_turn(&mut state) => {
match result {
TurnResult::Continue => continue,
TurnResult::Done(msg) => {
let _ = event_tx.send(AgentEvent::Completed {
final_message: msg,
tokens: state.token_usage.clone(),
}).await;
break;
}
TurnResult::Error(e) => {
let _ = event_tx.send(AgentEvent::Error { message: e.to_string() }).await;
break;
}
}
}
}
}
}
  • tokiospawn, CancellationToken, mpsc::channel
  • openoxide-agentBackgroundAgent, BackgroundAgentManager, AgentEvent
  • openoxide-sessionRolloutWriter, session persistence
  • ratatui — background panel widget
  1. Bounded concurrency — default 4 concurrent background agents. Configurable. Reject new spawns when limit is hit, rather than queuing; the LLM deciding to spawn N background agents shouldn’t silently queue N-1 of them.
  2. Event buffering, not dropping — buffer up to BACKGROUND_EVENT_BUFFER_SIZE events when the TUI isn’t consuming them. On buffer full, emit a synthetic BufferOverflow event that summarizes the dropped events. Never silently drop progress data.
  3. Cancel is cooperative, undo is explicit — cancellation stops future work. It does not roll back committed changes. Rollback requires calling the undo system explicitly after cancel.
  4. Rollout file is the source of truth — in-memory state is derived from the rollout file. Process crashes are recoverable.
  5. Token budget enforcement — each background agent tracks TokenUsage. When usage exceeds agent_def.token_budget (if set), emit StepLimitReached and halt gracefully before the context window overflows.