This guide covers how to execute commands, monitor output, and manage terminal lifecycle in both agents and clients.
Overview
The ACP terminal capability allows agents to:
- Execute shell commands in the client environment
- Monitor command output in real-time
- Wait for command completion
- Kill running commands
- Properly clean up resources
Terminal Request Types
The SDK provides these terminal-related types in lib/src/schema.dart:
CreateTerminalRequest - Start a new terminal with a command
TerminalOutputRequest - Get current output from a terminal
WaitForTerminalExitRequest - Wait for command completion
KillTerminalCommandRequest - Terminate a running command
ReleaseTerminalRequest - Release terminal resources
Agent Side
Agents use terminals through the client connection to execute commands in the client’s environment.
Creating a Terminal
Request terminal creation
Agents call createTerminal on the client connection:
import 'package:acp_dart/acp_dart.dart';
class MyAgent implements Agent {
final AgentSideConnection _connection;
Future<void> executeCommand(String sessionId) async {
final response = await _connection.createTerminal(
CreateTerminalRequest(
sessionId: sessionId,
command: 'npm',
args: ['install', '--verbose'],
cwd: '/path/to/project',
env: [
EnvVariable(name: 'NODE_ENV', value: 'production'),
],
),
);
final terminalId = response.terminalId;
print('Created terminal: $terminalId');
}
}
Get output without waiting for completion:
final outputResponse = await _connection.terminalOutput(
TerminalOutputRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
print('Current output: ${outputResponse.output}');
if (outputResponse.exitStatus != null) {
print('Command exited with code: ${outputResponse.exitStatus!.exitCode}');
}
Block until the command finishes:
final exitResponse = await _connection.waitForTerminalExit(
WaitForTerminalExitRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
if (exitResponse.exitCode == 0) {
print('Command completed successfully');
} else {
print('Command failed with code: ${exitResponse.exitCode}');
}
if (exitResponse.signal != null) {
print('Command terminated by signal: ${exitResponse.signal}');
}
Always release terminals when done:
await _connection.releaseTerminal(
ReleaseTerminalRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
Using TerminalHandle
The SDK provides a convenient TerminalHandle class that wraps terminal operations:
import 'package:acp_dart/acp_dart.dart';
// Create a handle after getting terminal ID
final handle = TerminalHandle(
terminalId,
sessionId,
_connection._connection, // Internal connection
);
// Use the handle for operations
final output = await handle.currentOutput();
final exitStatus = await handle.waitForExit();
await handle.kill();
await handle.release();
// Or use async disposal pattern
await handle.dispose(); // Automatically calls release()
Report terminal operations to the client:
// Report terminal creation
await _connection.sessionUpdate(
SessionNotification(
sessionId: sessionId,
update: ToolCallSessionUpdate(
toolCallId: 'terminal_1',
title: 'Running npm install',
kind: ToolKind.execute,
status: ToolCallStatus.inProgress,
content: [
TerminalToolCallContent(terminalId: terminalId),
],
rawInput: {
'command': 'npm',
'args': ['install', '--verbose'],
},
),
),
);
// Wait for completion
final exitStatus = await _connection.waitForTerminalExit(
WaitForTerminalExitRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
// Get final output
final output = await _connection.terminalOutput(
TerminalOutputRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
// Update tool call with completion
await _connection.sessionUpdate(
SessionNotification(
sessionId: sessionId,
update: ToolCallUpdateSessionUpdate(
toolCallId: 'terminal_1',
status: exitStatus.exitCode == 0
? ToolCallStatus.completed
: ToolCallStatus.failed,
content: [
TerminalToolCallContent(terminalId: terminalId),
],
rawOutput: {
'exitCode': exitStatus.exitCode,
'output': output.output,
},
),
),
);
// Clean up
await _connection.releaseTerminal(
ReleaseTerminalRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
Implementing Command Timeouts
Kill commands that run too long:
future command execution with timeout
try {
final exitStatus = await _connection.waitForTerminalExit(
WaitForTerminalExitRequest(
sessionId: sessionId,
terminalId: terminalId,
),
).timeout(
Duration(seconds: 30),
onTimeout: () async {
// Kill the command
await _connection.killTerminal(
KillTerminalCommandRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
// Get final output
final output = await _connection.terminalOutput(
TerminalOutputRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
print('Command timed out. Output: ${output.output}');
throw TimeoutException('Command exceeded 30 second timeout');
},
);
print('Command completed with exit code: ${exitStatus.exitCode}');
} finally {
// Always release the terminal
await _connection.releaseTerminal(
ReleaseTerminalRequest(
sessionId: sessionId,
terminalId: terminalId,
),
);
}
Client Side
Clients implement terminal operations to execute commands in their environment.
Implementing Terminal Creation
import 'dart:io';
import 'dart:convert';
import 'package:acp_dart/acp_dart.dart';
class MyClient implements Client {
final Map<String, Process> _terminals = {};
final Map<String, StringBuffer> _outputs = {};
@override
Future<CreateTerminalResponse> createTerminal(
CreateTerminalRequest params,
) async {
// Generate unique terminal ID
final terminalId = _generateTerminalId();
// Build environment variables
final environment = <String, String>{};
if (params.env != null) {
for (final envVar in params.env!) {
environment[envVar.name] = envVar.value;
}
}
// Start the process
final process = await Process.start(
params.command,
params.args ?? [],
workingDirectory: params.cwd,
environment: environment.isNotEmpty ? environment : null,
);
// Store process and capture output
_terminals[terminalId] = process;
_outputs[terminalId] = StringBuffer();
// Capture stdout and stderr
process.stdout.transform(utf8.decoder).listen((data) {
_outputs[terminalId]!.write(data);
});
process.stderr.transform(utf8.decoder).listen((data) {
_outputs[terminalId]!.write(data);
});
return CreateTerminalResponse(terminalId: terminalId);
}
String _generateTerminalId() {
return 'term_${DateTime.now().millisecondsSinceEpoch}';
}
}
Implementing Terminal Output
@override
Future<TerminalOutputResponse> terminalOutput(
TerminalOutputRequest params,
) async {
final process = _terminals[params.terminalId];
final output = _outputs[params.terminalId];
if (process == null || output == null) {
throw RequestError.resourceNotFound('Terminal ${params.terminalId}');
}
// Check if process has exited
TerminalExitStatus? exitStatus;
try {
final exitCode = await process.exitCode.timeout(
Duration.zero,
onTimeout: () => null,
);
if (exitCode != null) {
exitStatus = TerminalExitStatus(exitCode: exitCode);
}
} catch (e) {
// Process still running
}
return TerminalOutputResponse(
output: output.toString(),
exitStatus: exitStatus,
truncated: false,
);
}
Implementing Wait for Exit
@override
Future<WaitForTerminalExitResponse> waitForTerminalExit(
WaitForTerminalExitRequest params,
) async {
final process = _terminals[params.terminalId];
if (process == null) {
throw RequestError.resourceNotFound('Terminal ${params.terminalId}');
}
// Wait for process to complete
final exitCode = await process.exitCode;
return WaitForTerminalExitResponse(
exitCode: exitCode,
signal: null,
);
}
Implementing Kill Terminal
@override
Future<KillTerminalCommandResponse> killTerminal(
KillTerminalCommandRequest params,
) async {
final process = _terminals[params.terminalId];
if (process == null) {
throw RequestError.resourceNotFound('Terminal ${params.terminalId}');
}
// Kill the process
process.kill();
return KillTerminalCommandResponse();
}
Implementing Release Terminal
@override
Future<ReleaseTerminalResponse> releaseTerminal(
ReleaseTerminalRequest params,
) async {
final process = _terminals.remove(params.terminalId);
_outputs.remove(params.terminalId);
if (process != null) {
// Kill if still running
try {
process.kill();
} catch (e) {
// Process already exited
}
}
return ReleaseTerminalResponse();
}
Best Practices
Always release terminals when done to prevent resource leaks.
Do:
✅ Always release terminals in a finally block:
try {
final terminal = await createTerminal(...);
// Use terminal
} finally {
await releaseTerminal(...);
}
✅ Set appropriate timeouts for long-running commands:
await waitForExit(...).timeout(Duration(minutes: 5));
✅ Capture both stdout and stderr:
process.stdout.listen((data) => buffer.write(data));
process.stderr.listen((data) => buffer.write(data));
Don’t:
❌ Leave terminals unreleased after errors:
// Bad: terminal leaks if error occurs
final terminal = await createTerminal(...);
if (someCondition) throw Exception('Error');
await releaseTerminal(...);
❌ Execute dangerous commands without permission
❌ Block indefinitely waiting for command completion
Complete Example
See the complete client example:
dart run example/client.dart
Next Steps
File System Operations
Learn about file operations
Error Handling
Handle terminal errors gracefully