Skip to main content
The process runtime plugin manages agent execution as Node.js child processes, providing lightweight, headless execution with full output capture.

Overview

The process runtime spawns agents as direct child processes with piped stdio, offering:
  • Minimal overhead (no tmux server required)
  • Full stdout/stderr capture in a rolling buffer
  • Automatic cleanup when the orchestrator stops
  • Process group management for proper child termination
Process sessions do not persist if the orchestrator crashes. For long-running agents that need to survive orchestrator restarts, use the tmux runtime.

Configuration

Configure the process runtime in agent-orchestrator.yaml:
plugins:
  runtime: process

Requirements

No external dependencies. The plugin uses Node.js built-in child_process module.

Runtime API

create(config)

Spawns a new child process with the specified launch command.
config.sessionId
string
required
Session identifier (must match [a-zA-Z0-9_-]+)
config.workspacePath
string
required
Working directory for the process (cwd)
config.launchCommand
string
required
Shell command to execute (runs with shell: true)
config.environment
Record<string, string>
Environment variables merged with process.env
The plugin uses shell: true intentionally, as launch commands may contain pipes, redirects, or other shell syntax. Ensure launch commands come from trusted sources (YAML config, not user input).

sendMessage(handle, message)

Writes a message to the process’s stdin, followed by a newline.
If stdin is not writable (closed or process exited), this throws an error. Always check isAlive() before sending messages.

getOutput(handle, lines)

Returns the last N lines from the rolling output buffer.
lines
number
default:"50"
Number of lines to retrieve from the buffer (max 1000)
The plugin maintains a rolling buffer of the last 1000 lines. Older lines are automatically discarded to prevent unbounded memory growth.

destroy(handle)

Terminates the process gracefully (SIGTERM), then forcefully (SIGKILL) after 5 seconds.
  1. Send SIGTERM to the entire process group (via negative PID)
  2. Wait up to 5 seconds for graceful exit
  3. Send SIGKILL to force termination if still running
  4. Remove session from internal map
Process group termination ensures child commands spawned by the shell are also killed, not orphaned.

isAlive(handle)

Checks if the process has exited (exitCode === null).

Usage Examples

Basic Agent Launch

import { create } from '@composio/ao-plugin-runtime-process';

const runtime = create();

const handle = await runtime.create({
  sessionId: 'agent-1',
  workspacePath: '/path/to/workspace',
  launchCommand: 'claude --dangerously-skip-permissions',
  environment: {
    AO_SESSION_ID: 'agent-1',
    AO_PROJECT_ID: 'my-project',
  },
});

console.log('Process PID:', handle.data.pid);

Send Messages to Agent

// Check if agent is still running
if (await runtime.isAlive(handle)) {
  await runtime.sendMessage(handle, 'Fix the type errors in src/index.ts');
}

Monitor Output

// Get last 100 lines of output
const output = await runtime.getOutput(handle, 100);
console.log(output);

// Check for specific patterns
if (output.includes('ERROR')) {
  console.error('Agent encountered an error');
}

Graceful Shutdown

// Cleanup when done
await runtime.destroy(handle);

// Verify termination
const alive = await runtime.isAlive(handle);
console.log('Process still running:', alive); // false

Advanced Features

Rolling Output Buffer

The plugin captures stdout and stderr into a single rolling buffer with:
  • Max capacity: 1000 lines
  • Automatic trimming when limit exceeded
  • Separate partial-line buffers for stdout/stderr to prevent interleaved corruption
// Implementation detail (informational)
const MAX_OUTPUT_LINES = 1000;

// Buffer auto-trims when it grows beyond max
if (outputBuffer.length > MAX_OUTPUT_LINES) {
  outputBuffer.splice(0, outputBuffer.length - MAX_OUTPUT_LINES);
}

Process Group Management

The plugin uses detached: true to spawn the child in its own process group, then kills the entire group (negative PID) on destroy:
# Kills the shell AND any commands it spawned
process.kill(-pid, 'SIGTERM');
This prevents orphaned child processes when agents spawn subcommands.

Exit Handling

The plugin attaches exit handlers immediately (before any await) to ensure fast-exiting processes don’t slip through:
child.once('exit', () => {
  entry.outputBuffer.push(`[process exited with code ${child.exitCode}]`);
  processes.delete(handleId);
});

Partial Line Buffering

Each stream (stdout, stderr) maintains its own partial-line buffer to handle split writes:
function makeAppendOutput(): (data: Buffer) => void {
  let partial = "";
  return (data: Buffer) => {
    const text = partial + data.toString("utf-8");
    const lines = text.split("\n");
    partial = lines.pop()!; // Save incomplete line
    outputBuffer.push(...lines);
  };
}
This ensures interleaved stdout/stderr writes don’t corrupt each other.

Troubleshooting

Cause: Launch command failed or invalidSolution:
  1. Check launchCommand syntax (test in a shell first)
  2. Verify required binaries are in PATH
  3. Check workspacePath exists and is accessible
  4. Inspect exit code in output buffer: [process exited with code X]
Cause: Process has exited or stdin was closedSolution:
// Always check isAlive before sending
if (await runtime.isAlive(handle)) {
  await runtime.sendMessage(handle, 'message');
} else {
  console.error('Process has exited');
}
Cause: Trying to create two sessions with the same IDSolution: Each session must have a unique ID. Call destroy() before re-creating:
// Clean up first
await runtime.destroy(handle);

// Now safe to recreate
const newHandle = await runtime.create({ sessionId: 'agent-1', ... });
Cause: Process group kill failed (rare)Solution: Manually kill orphaned processes:
# Find orphaned agents
ps aux | grep claude

# Kill by PID
kill -9 <pid>
Cause: Buffer only stores last 1000 linesSolution:
  • For full output capture, redirect to a file in your launch command:
    launchCommand: "claude 2>&1 | tee session.log"
    
  • Increase MAX_OUTPUT_LINES in plugin source if needed (requires rebuild)

Comparison with Tmux Runtime

FeatureProcessTmux
OverheadMinimal (direct child)Higher (tmux server)
PersistenceTied to orchestratorSurvives orchestrator crashes
AttachmentNo interactive shellFull terminal via tmux attach
Output CaptureRolling buffer (1000 lines)Full scrollback via tmux
CleanupAutomatic on exitManual (sessions persist)
Use CaseCI/CD, headless automationInteractive debugging
Use process runtime for production and automated workflows. Use tmux for development when you need to attach and debug agents interactively.

Security Considerations

The plugin uses shell: true, which enables shell syntax (pipes, redirects) but also introduces command injection risk if launch commands include untrusted input.
Safe usage:
// ✅ GOOD: Static commands from config
launchCommand: 'claude --dangerously-skip-permissions'

// ✅ GOOD: Dynamic args with proper escaping
import { shellEscape } from '@composio/ao-core';
launchCommand: `claude --model ${shellEscape(userModel)}`

// ❌ BAD: Unescaped user input (shell injection risk)
launchCommand: `claude --message "${userPrompt}"`
Design note: shell: true is intentional for the launchCommand field, which comes from trusted YAML config and may contain pipes or redirects. Never interpolate untrusted user input into launch commands.

Build docs developers (and LLMs) love