ACP connections have a well-defined lifecycle from establishment to closure. Understanding this lifecycle is essential for proper resource management and error handling.
Connection Phases
An ACP connection goes through these phases:
- Creation - Connection object is instantiated with a stream
- Active - Messages can be sent and received
- Closing - Stream is ending (readable stream closed)
- Closed - Connection is terminated, no more messages possible
Creating a Connection
Connections are created by passing a stream and handler function:
import * as acp from '@agentclientprotocol/acp';
import { Readable, Writable } from 'node:stream';
// Create stream from stdio
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(input, output);
// Create agent-side connection
const connection = new acp.AgentSideConnection(
(conn) => new MyAgent(conn),
stream
);
// Connection is now active and ready to receive messages
The handler function (toAgent or toClient) receives the connection object and should return the Agent or Client implementation.
Monitoring Connection State
The SDK provides two mechanisms for monitoring connection state:
Using signal (AbortSignal)
The signal property provides an AbortSignal that aborts when the connection closes:
const connection = new acp.ClientSideConnection(
(agent) => new MyClient(),
stream
);
// Check if connection is closed
if (connection.signal.aborted) {
console.log('Connection is closed');
}
// Listen for connection closure
connection.signal.addEventListener('abort', () => {
console.log('Connection closed');
performCleanup();
});
// Pass signal to other APIs for automatic cancellation
fetch('https://api.example.com/data', {
signal: connection.signal,
});
setTimeout(() => {
console.log('This will be cancelled if connection closes');
}, 10000, connection.signal);
AbortSignal advantages:
- Synchronous status check with
.aborted
- Can be passed to other APIs (fetch, setTimeout)
- Standard Web API for cancellation
Using closed (Promise)
The closed property provides a Promise that resolves when the connection closes:
const connection = new acp.ClientSideConnection(
(agent) => new MyClient(),
stream
);
// Async/await style
await connection.closed;
console.log('Connection has closed');
performCleanup();
// Promise style
connection.closed.then(() => {
console.log('Connection has closed');
performCleanup();
});
Promise advantages:
- Natural async/await syntax
- Can be combined with
Promise.race() for timeouts
Connection Closure
Connections close when the underlying readable stream ends. This can happen:
- Normally - The other side closes their output stream
- Due to error - A stream error or write failure occurs
- Process termination - The subprocess exits
Detecting Closure
Both sides should monitor for connection closure:
class MyAgent implements acp.Agent {
private connection: acp.AgentSideConnection;
constructor(connection: acp.AgentSideConnection) {
this.connection = connection;
// Monitor connection closure
this.connection.closed.then(() => {
console.log('Client disconnected');
this.cleanup();
});
}
private cleanup() {
// Clean up resources
this.sessions.forEach(session => session.cancel());
this.sessions.clear();
}
// ... rest of implementation
}
Resource Management
Proper resource management is critical for long-running connections.
Cleaning Up on Close
Always clean up resources when the connection closes:
import * as acp from '@agentclientprotocol/acp';
class MyAgent implements acp.Agent {
private sessions = new Map<string, Session>();
private timers = new Set<NodeJS.Timeout>();
private connection: acp.AgentSideConnection;
constructor(connection: acp.AgentSideConnection) {
this.connection = connection;
// Set up cleanup handler
connection.closed.then(() => this.cleanup());
}
private cleanup() {
console.log('Cleaning up resources...');
// Cancel all active sessions
for (const session of this.sessions.values()) {
session.abort();
}
this.sessions.clear();
// Clear all timers
for (const timer of this.timers) {
clearTimeout(timer);
}
this.timers.clear();
// Close any open file handles, database connections, etc.
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
// Pass connection signal to abort long operations
const result = await this.runLLM(
params.prompt,
this.connection.signal
);
return { stopReason: 'end_turn' };
}
}
Using Signals for Cancellation
Pass the connection’s signal to operations that should be cancelled on disconnect:
class MyAgent implements acp.Agent {
private connection: acp.AgentSideConnection;
constructor(connection: acp.AgentSideConnection) {
this.connection = connection;
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
// Create a combined signal: cancel on either disconnect OR explicit cancel
const sessionController = new AbortController();
const combinedSignal = AbortSignal.any([
this.connection.signal,
sessionController.signal,
]);
try {
// Pass signal to operations
const response = await fetch('https://api.openai.com/v1/chat/completions', {
signal: combinedSignal,
method: 'POST',
body: JSON.stringify({ /* ... */ }),
});
return { stopReason: 'end_turn' };
} catch (err) {
if (combinedSignal.aborted) {
return { stopReason: 'cancelled' };
}
throw err;
}
}
async cancel(params: acp.CancelNotification): Promise<void> {
// Cancel specific session
const session = this.sessions.get(params.sessionId);
session?.abort();
}
}
Graceful Shutdown
For clean shutdown, ensure all pending operations complete:
class MyClient implements acp.Client {
private pendingRequests = new Set<Promise<unknown>>();
async requestPermission(
params: acp.RequestPermissionRequest
): Promise<acp.RequestPermissionResponse> {
const promise = this.showPermissionDialog(params);
// Track pending requests
this.pendingRequests.add(promise);
try {
return await promise;
} finally {
this.pendingRequests.delete(promise);
}
}
async sessionUpdate(params: acp.SessionNotification): Promise<void> {
// Handle update...
}
async shutdown(): Promise<void> {
console.log('Waiting for pending requests to complete...');
// Wait for all pending operations
await Promise.allSettled(this.pendingRequests);
console.log('Shutdown complete');
}
}
// Usage
const client = new MyClient();
const connection = new acp.ClientSideConnection(
(agent) => client,
stream
);
// Monitor for closure
connection.closed.then(() => client.shutdown());
Handling Stream Errors
Stream errors trigger connection closure:
const connection = new acp.ClientSideConnection(
(agent) => new MyClient(),
stream
);
connection.signal.addEventListener('abort', () => {
if (connection.signal.reason) {
console.error('Connection closed due to error:', connection.signal.reason);
} else {
console.log('Connection closed normally');
}
performCleanup();
});
Example: Complete Lifecycle Management
Here’s a complete example showing proper lifecycle management:
import * as acp from '@agentclientprotocol/acp';
import { spawn } from 'node:child_process';
import { Readable, Writable } from 'node:stream';
class LifecycleAwareClient implements acp.Client {
private isShuttingDown = false;
private pendingOperations = new Set<Promise<unknown>>();
async requestPermission(
params: acp.RequestPermissionRequest
): Promise<acp.RequestPermissionResponse> {
if (this.isShuttingDown) {
return { outcome: { outcome: 'cancelled' } };
}
const operation = this.showDialog(params);
this.pendingOperations.add(operation);
try {
return await operation;
} finally {
this.pendingOperations.delete(operation);
}
}
async sessionUpdate(params: acp.SessionNotification): Promise<void> {
if (!this.isShuttingDown) {
this.handleUpdate(params);
}
}
async shutdown(): Promise<void> {
this.isShuttingDown = true;
console.log('Shutting down client...');
// Wait for pending operations with timeout
const timeout = new Promise((resolve) => setTimeout(resolve, 5000));
await Promise.race([
Promise.allSettled(this.pendingOperations),
timeout,
]);
console.log('Client shutdown complete');
}
private async showDialog(
params: acp.RequestPermissionRequest
): Promise<acp.RequestPermissionResponse> {
// Show dialog implementation...
return { outcome: { outcome: 'selected', optionId: 'allow' } };
}
private handleUpdate(params: acp.SessionNotification): void {
// Handle update implementation...
}
}
async function main() {
// Spawn agent
const agentProcess = spawn('npx', ['tsx', 'agent.ts'], {
stdio: ['pipe', 'pipe', 'inherit'],
});
// Create connection
const input = Writable.toWeb(agentProcess.stdin!);
const output = Readable.toWeb(
agentProcess.stdout!
) as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(input, output);
const client = new LifecycleAwareClient();
const connection = new acp.ClientSideConnection(
(agent) => client,
stream
);
// Set up lifecycle handlers
connection.signal.addEventListener('abort', async () => {
console.log('Connection closed');
await client.shutdown();
process.exit(0);
});
// Handle process signals
process.on('SIGINT', async () => {
console.log('Received SIGINT, shutting down...');
agentProcess.kill();
await connection.closed;
process.exit(0);
});
try {
// Initialize and use connection
await connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {},
});
// Do work...
} catch (err) {
console.error('Error:', err);
} finally {
// Ensure cleanup
agentProcess.kill();
await connection.closed;
}
}
main().catch(console.error);
Best Practices
Do:
- Monitor
connection.closed or connection.signal for disconnection
- Clean up resources (timers, file handles) when connection closes
- Pass
connection.signal to operations that should be cancelled
- Handle both normal and error-based closure
- Wait for pending operations during shutdown
Don’t:
- Attempt to send messages after connection closes
- Leak resources by not cleaning up on close
- Ignore connection closure errors
- Block shutdown waiting indefinitely for operations