Add ACP-backed role sessions

This commit is contained in:
zenord
2026-08-16 10:08:03 +08:00
parent d59c4a654f
commit a80fe68d57
34 changed files with 1683 additions and 887 deletions
+125
View File
@@ -0,0 +1,125 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } from "@agentclientprotocol/sdk";
import type { RolePolicy } from "../config.js";
export interface AcpClientOptions {
initializeTimeoutMs: number;
policy: RolePolicy;
}
export class AcpClient {
private readonly connection: acp.ClientConnection;
private capabilities: AgentCapabilities = {};
private activeSessionId?: string;
private collecting = false;
private chunks: string[] = [];
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpClientOptions) {
const app = acp.client({ name: "gori-agent" })
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => decidePermission(params, options.policy))
.onNotification(acp.methods.client.session.update, ({ params }) => this.handleUpdate(params));
const stream = acp.ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>
);
this.connection = app.connect(stream);
}
async initialize(): Promise<InitializeResponse> {
const response = await withTimeout(this.connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: { name: "gori-agent", version: "0.1.0" }
}), this.options.initializeTimeoutMs, "ACP initialize timed out");
if (response.protocolVersion !== acp.PROTOCOL_VERSION) throw new Error(`Unsupported ACP protocol version ${response.protocolVersion}`);
this.capabilities = response.agentCapabilities || {};
return response;
}
async newSession(cwd: string): Promise<string> {
const response = await this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] });
this.activeSessionId = response.sessionId;
return response.sessionId;
}
async resumeSession(sessionId: string, cwd: string): Promise<void> {
this.collecting = false;
this.chunks = [];
if (this.capabilities.sessionCapabilities?.resume) {
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] });
} else if (this.capabilities.loadSession) {
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
} else {
throw new Error("ACP backend cannot resume or load sessions");
}
this.activeSessionId = sessionId;
this.chunks = [];
}
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> {
if (!this.activeSessionId) throw new Error("ACP session is not active");
this.chunks = [];
this.collecting = true;
try {
await this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId,
prompt: [{ type: "text", text }]
}, cancellationSignal ? { cancellationSignal } : undefined);
return this.chunks.join("").trim();
} finally {
this.collecting = false;
}
}
async cancel(): Promise<void> {
if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId });
}
async closeSession(): Promise<void> {
if (this.activeSessionId && this.capabilities.sessionCapabilities?.close) {
await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.activeSessionId }).catch(() => undefined);
}
}
close(error?: unknown): void { this.connection.close(error); }
private handleUpdate(notification: SessionNotification): void {
if (!this.collecting || notification.sessionId !== this.activeSessionId) return;
const update = notification.update;
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text);
}
}
export function decidePermission(request: RequestPermissionRequest, policy: RolePolicy): RequestPermissionResponse {
if (policy.permissionMode === "deny") return reject(request);
const allowOption = request.options.find((option) => option.kind === "allow_once") || request.options.find((option) => option.kind === "allow_always");
if (!allowOption) return reject(request);
if (policy.permissionMode === "auto") return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
const name = String(request.toolCall.name || request.toolCall.kind || "").toLowerCase();
const title = String(request.toolCall.title || "").toLowerCase();
const allowedTool = policy.allowedTools.some((tool) => name === tool.toLowerCase() || title.startsWith(tool.toLowerCase()));
if (!allowedTool) return reject(request);
if (name === "bash" || name === "terminal" || title.startsWith("bash") || title.startsWith("terminal")) {
if (request.toolCall.rawInput === undefined || policy.allowedCommandPatterns.length === 0) return reject(request);
const input = typeof request.toolCall.rawInput === "string" ? request.toolCall.rawInput : JSON.stringify(request.toolCall.rawInput);
if (!policy.allowedCommandPatterns.some((pattern) => new RegExp(pattern).test(input))) return reject(request);
}
return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
}
function reject(request: RequestPermissionRequest): RequestPermissionResponse {
const option = request.options.find((item) => item.kind === "reject_once") || request.options.find((item) => item.kind === "reject_always");
return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([promise, new Promise<T>((_resolve, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); })]);
} finally {
if (timer) clearTimeout(timer);
}
}