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
- Create: Agent calls
createTerminal to start a command
- Monitor: Agent calls
terminalOutput to get current output
- Wait: Agent calls
waitForTerminalExit to wait for completion
- Kill (optional): Agent calls
killTerminal to terminate the command
- 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>
The session creating the terminal.
The command to execute (e.g., "npm", "git").
Command arguments (e.g., ["install", "lodash"]).
Working directory for the command (absolute path).
Additional environment variables.
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>
The terminal output (combined stdout/stderr).
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>
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>
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>
The terminal ID to release.
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