Skip to main content

Overview

Terminal capabilities allow agents to execute shell commands in the client’s environment. Terminals are created, monitored, and eventually released through a series of client methods.

Advertising Terminal Support

Declare terminal support during initialization:
await connection.initialize({
  protocolVersion: acp.PROTOCOL_VERSION,
  clientCapabilities: {
    terminal: true,  // Enable terminal operations
  },
});
Terminal execution poses significant security risks. Implement careful validation and sandboxing.

Terminal Lifecycle

  1. Create: Agent calls createTerminal to start a command
  2. Monitor: Agent calls terminalOutput to get current output
  3. Wait: Agent calls waitForTerminalExit to wait for completion
  4. Kill (optional): Agent calls killTerminal to terminate the command
  5. Release: Agent calls releaseTerminal to free resources
Client                                Agent
  |                                     |
  |<-- createTerminal request ----------|
  |-- terminalId response ------------->|
  |                                     |
  |<-- terminalOutput request ----------|
  |-- output response ----------------->|
  |                                     |
  |<-- waitForTerminalExit request -----|
  |-- exitStatus response ------------->|
  |                                     |
  |<-- releaseTerminal request ---------|
  |-- (empty) response ---------------->|

createTerminal()

Creates a new terminal to execute a command.

Method Signature

async createTerminal(
  params: CreateTerminalRequest
): Promise<CreateTerminalResponse>
params.sessionId
string
The session creating the terminal.
params.command
string
The command to execute (e.g., "npm", "git").
params.args
string[]
Command arguments (e.g., ["install", "lodash"]).
params.cwd
string
Working directory for the command (absolute path).
params.env
Record<string, string>
Additional environment variables.
terminalId
string
required
Unique identifier for this terminal.

Basic Implementation

import { spawn, ChildProcess } from "node:child_process";
import * as acp from "@agentclientprotocol/acp";

class MyClient implements acp.Client {
  private terminals = new Map<string, {
    process: ChildProcess;
    output: string;
    exitStatus?: ExitStatus;
  }>();
  
  async createTerminal(
    params: acp.CreateTerminalRequest
  ): Promise<acp.CreateTerminalResponse> {
    const terminalId = `term-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    
    // Spawn process
    const proc = spawn(params.command, params.args, {
      cwd: params.cwd || process.cwd(),
      env: { ...process.env, ...params.env },
      shell: false,
    });
    
    // Capture output
    let output = "";
    
    proc.stdout?.on("data", (data) => {
      output += data.toString();
    });
    
    proc.stderr?.on("data", (data) => {
      output += data.toString();
    });
    
    // Store terminal state
    const terminal = { process: proc, output: "" };
    this.terminals.set(terminalId, terminal);
    
    // Update output reference
    const updateOutput = () => {
      terminal.output = output;
    };
    proc.stdout?.on("data", updateOutput);
    proc.stderr?.on("data", updateOutput);
    
    // Handle exit
    proc.on("exit", (code, signal) => {
      updateOutput();
      if (signal) {
        terminal.exitStatus = { type: "signal", signal };
      } else {
        terminal.exitStatus = { type: "exit_code", code: code ?? -1 };
      }
    });
    
    return { terminalId };
  }
}

terminalOutput()

Gets the current output and exit status of a terminal.

Method Signature

async terminalOutput(
  params: TerminalOutputRequest
): Promise<TerminalOutputResponse>
params.sessionId
string
The session ID.
params.terminalId
string
The terminal ID.
output
string
required
The terminal output (combined stdout/stderr).
exitStatus
ExitStatus
Exit status if the command has completed:
  • { type: "exit_code", code: number }
  • { type: "signal", signal: string }

Implementation

class MyClient implements acp.Client {
  async terminalOutput(
    params: acp.TerminalOutputRequest
  ): Promise<acp.TerminalOutputResponse> {
    const terminal = this.terminals.get(params.terminalId);
    
    if (!terminal) {
      throw new Error(`Terminal not found: ${params.terminalId}`);
    }
    
    return {
      output: terminal.output,
      exitStatus: terminal.exitStatus,
    };
  }
}

waitForTerminalExit()

Waits for a terminal command to exit and returns its exit status.

Method Signature

async waitForTerminalExit(
  params: WaitForTerminalExitRequest
): Promise<WaitForTerminalExitResponse>
params.sessionId
string
The session ID.
params.terminalId
string
The terminal ID.
exitStatus
ExitStatus
required
The exit status:
  • { type: "exit_code", code: number }
  • { type: "signal", signal: string }

Implementation

class MyClient implements acp.Client {
  async waitForTerminalExit(
    params: acp.WaitForTerminalExitRequest
  ): Promise<acp.WaitForTerminalExitResponse> {
    const terminal = this.terminals.get(params.terminalId);
    
    if (!terminal) {
      throw new Error(`Terminal not found: ${params.terminalId}`);
    }
    
    // If already exited, return immediately
    if (terminal.exitStatus) {
      return { exitStatus: terminal.exitStatus };
    }
    
    // Wait for exit
    return new Promise((resolve) => {
      terminal.process.on("exit", (code, signal) => {
        const exitStatus = signal
          ? { type: "signal" as const, signal }
          : { type: "exit_code" as const, code: code ?? -1 };
        
        terminal.exitStatus = exitStatus;
        resolve({ exitStatus });
      });
    });
  }
}

killTerminal()

Kills a terminal command without releasing the terminal.

Method Signature

async killTerminal(
  params: KillTerminalRequest
): Promise<KillTerminalResponse | void>
params.sessionId
string
The session ID.
params.terminalId
string
The terminal ID.
(empty)
object
Returns an empty object {} or void on success.

Implementation

class MyClient implements acp.Client {
  async killTerminal(
    params: acp.KillTerminalRequest
  ): Promise<void> {
    const terminal = this.terminals.get(params.terminalId);
    
    if (!terminal) {
      throw new Error(`Terminal not found: ${params.terminalId}`);
    }
    
    // Kill the process
    if (!terminal.exitStatus) {
      terminal.process.kill();
    }
  }
}
The terminal remains valid after killing, allowing you to get final output with terminalOutput(). Call releaseTerminal() when done.

releaseTerminal()

Releases a terminal and frees all associated resources.

Method Signature

async releaseTerminal(
  params: ReleaseTerminalRequest
): Promise<ReleaseTerminalResponse | void>
params.sessionId
string
The session ID.
params.terminalId
string
The terminal ID to release.
(empty)
object
Returns an empty object {} or void on success.

Implementation

class MyClient implements acp.Client {
  async releaseTerminal(
    params: acp.ReleaseTerminalRequest
  ): Promise<void> {
    const terminal = this.terminals.get(params.terminalId);
    
    if (!terminal) {
      // Already released, silently succeed
      return;
    }
    
    // Kill if still running
    if (!terminal.exitStatus) {
      terminal.process.kill();
    }
    
    // Remove from map
    this.terminals.delete(params.terminalId);
  }
}
Always call releaseTerminal() when done to prevent resource leaks.

Complete Implementation

Here’s a complete, production-ready terminal implementation:
import { spawn, ChildProcess } from "node:child_process";
import { resolve } from "node:path";
import * as acp from "@agentclientprotocol/acp";

interface Terminal {
  process: ChildProcess;
  output: string;
  exitStatus?: acp.ExitStatus;
}

class ProductionClient implements acp.Client {
  private terminals = new Map<string, Terminal>();
  private allowedCwd: string;
  
  constructor(allowedCwd: string) {
    this.allowedCwd = resolve(allowedCwd);
  }
  
  async createTerminal(
    params: acp.CreateTerminalRequest
  ): Promise<acp.CreateTerminalResponse> {
    // Validate working directory
    const cwd = resolve(params.cwd || this.allowedCwd);
    if (!cwd.startsWith(this.allowedCwd)) {
      throw new Error("Access denied: working directory outside allowed path");
    }
    
    // Validate command
    this.validateCommand(params.command, params.args);
    
    // Generate terminal ID
    const terminalId = `term-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    
    // Spawn process
    const proc = spawn(params.command, params.args, {
      cwd,
      env: { ...process.env, ...params.env },
      shell: false,
    });
    
    // Setup output capture
    let output = "";
    const terminal: Terminal = { process: proc, output: "", exitStatus: undefined };
    
    const appendOutput = (data: Buffer) => {
      const text = data.toString();
      output += text;
      terminal.output = output;
    };
    
    proc.stdout?.on("data", appendOutput);
    proc.stderr?.on("data", appendOutput);
    
    // Handle exit
    proc.on("exit", (code, signal) => {
      if (signal) {
        terminal.exitStatus = { type: "signal", signal };
      } else {
        terminal.exitStatus = { type: "exit_code", code: code ?? -1 };
      }
    });
    
    // Handle errors
    proc.on("error", (error) => {
      output += `\nError: ${error.message}\n`;
      terminal.output = output;
    });
    
    // Store terminal
    this.terminals.set(terminalId, terminal);
    
    return { terminalId };
  }
  
  async terminalOutput(
    params: acp.TerminalOutputRequest
  ): Promise<acp.TerminalOutputResponse> {
    const terminal = this.terminals.get(params.terminalId);
    if (!terminal) {
      throw new Error(`Terminal not found: ${params.terminalId}`);
    }
    
    return {
      output: terminal.output,
      exitStatus: terminal.exitStatus,
    };
  }
  
  async waitForTerminalExit(
    params: acp.WaitForTerminalExitRequest
  ): Promise<acp.WaitForTerminalExitResponse> {
    const terminal = this.terminals.get(params.terminalId);
    if (!terminal) {
      throw new Error(`Terminal not found: ${params.terminalId}`);
    }
    
    if (terminal.exitStatus) {
      return { exitStatus: terminal.exitStatus };
    }
    
    return new Promise((resolve) => {
      terminal.process.on("exit", (code, signal) => {
        const exitStatus = signal
          ? { type: "signal" as const, signal }
          : { type: "exit_code" as const, code: code ?? -1 };
        resolve({ exitStatus });
      });
    });
  }
  
  async killTerminal(params: acp.KillTerminalRequest): Promise<void> {
    const terminal = this.terminals.get(params.terminalId);
    if (!terminal) return;
    
    if (!terminal.exitStatus) {
      terminal.process.kill("SIGTERM");
      
      // Force kill after timeout
      setTimeout(() => {
        if (!terminal.exitStatus) {
          terminal.process.kill("SIGKILL");
        }
      }, 5000);
    }
  }
  
  async releaseTerminal(params: acp.ReleaseTerminalRequest): Promise<void> {
    const terminal = this.terminals.get(params.terminalId);
    if (!terminal) return;
    
    if (!terminal.exitStatus) {
      terminal.process.kill();
    }
    
    this.terminals.delete(params.terminalId);
  }
  
  private validateCommand(command: string, args: string[]) {
    const BLOCKED_COMMANDS = ["rm -rf /", "mkfs", "dd if="];
    const fullCommand = `${command} ${args.join(" ")}`;
    
    for (const blocked of BLOCKED_COMMANDS) {
      if (fullCommand.includes(blocked)) {
        throw new Error(`Blocked dangerous command: ${blocked}`);
      }
    }
  }
}

Security Considerations

Command Validation

const ALLOWED_COMMANDS = new Set([
  "npm", "npx", "node",
  "git",
  "tsc", "eslint", "prettier",
]);

function validateCommand(command: string) {
  if (!ALLOWED_COMMANDS.has(command)) {
    throw new Error(`Command not allowed: ${command}`);
  }
}

Argument Sanitization

function sanitizeArgs(args: string[]): string[] {
  return args.map(arg => {
    // Remove shell metacharacters
    if (/[;&|`$()]/.test(arg)) {
      throw new Error(`Unsafe argument: ${arg}`);
    }
    return arg;
  });
}

Resource Limits

import { spawn } from "node:child_process";

function createTerminalWithLimits(command: string, args: string[]) {
  const proc = spawn(command, args, {
    shell: false,
    timeout: 5 * 60 * 1000, // 5 minute timeout
  });
  
  // Limit output size
  let outputSize = 0;
  const MAX_OUTPUT = 10 * 1024 * 1024; // 10MB
  
  proc.stdout?.on("data", (data: Buffer) => {
    outputSize += data.length;
    if (outputSize > MAX_OUTPUT) {
      proc.kill();
      throw new Error("Output size limit exceeded");
    }
  });
  
  return proc;
}

See Also

Build docs developers (and LLMs) love