| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { spawn, ChildProcess } from "child_process";
|
| import { EventEmitter } from "events";
|
|
|
| export interface AcpSession {
|
|
|
| id: string;
|
|
|
| agentId: string;
|
|
|
| process: ChildProcess;
|
|
|
| alive: boolean;
|
|
|
| stdoutBuffer: string;
|
|
|
| stderrBuffer: string;
|
|
|
| createdAt: Date;
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| export class AcpManager extends EventEmitter {
|
| private sessions: Map<string, AcpSession> = new Map();
|
|
|
| |
| |
|
|
| spawn(
|
| agentId: string,
|
| binary: string,
|
| args: string[] = [],
|
| env: Record<string, string> = {}
|
| ): AcpSession {
|
| const ALLOWED_AGENTS = ["claude", "codex", "gemini", "qwen"];
|
| if (!ALLOWED_AGENTS.includes(agentId)) {
|
| throw new Error(`Unknown agent: ${agentId}`);
|
| }
|
|
|
| const sessionId = `acp-${agentId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
|
|
| const child = spawn(binary, args, {
|
| stdio: ["pipe", "pipe", "pipe"],
|
| env: { ...process.env, ...env },
|
| shell: false,
|
| });
|
|
|
| const session: AcpSession = {
|
| id: sessionId,
|
| agentId,
|
| process: child,
|
| alive: true,
|
| stdoutBuffer: "",
|
| stderrBuffer: "",
|
| createdAt: new Date(),
|
| };
|
|
|
| child.stdout?.on("data", (chunk: Buffer) => {
|
| session.stdoutBuffer += chunk.toString();
|
| this.emit("stdout", { sessionId, data: chunk.toString() });
|
| });
|
|
|
| child.stderr?.on("data", (chunk: Buffer) => {
|
| session.stderrBuffer += chunk.toString();
|
| this.emit("stderr", { sessionId, data: chunk.toString() });
|
| });
|
|
|
| child.on("exit", (code, signal) => {
|
| session.alive = false;
|
| this.emit("exit", { sessionId, code, signal });
|
| });
|
|
|
| child.on("error", (err) => {
|
| session.alive = false;
|
| this.emit("error", { sessionId, error: err });
|
| });
|
|
|
| this.sessions.set(sessionId, session);
|
| return session;
|
| }
|
|
|
| |
| |
|
|
| sendInput(sessionId: string, input: string): boolean {
|
| const session = this.sessions.get(sessionId);
|
| if (!session?.alive || !session.process.stdin?.writable) return false;
|
|
|
| session.process.stdin.write(input);
|
| return true;
|
| }
|
|
|
| |
| |
| |
|
|
| async sendPrompt(sessionId: string, prompt: string, timeoutMs: number = 120000): Promise<string> {
|
| const session = this.sessions.get(sessionId);
|
| if (!session?.alive) throw new Error(`Session ${sessionId} is not alive`);
|
|
|
|
|
| session.stdoutBuffer = "";
|
|
|
|
|
| this.sendInput(sessionId, prompt + "\n");
|
|
|
|
|
| return new Promise((resolve, reject) => {
|
| const timer = setTimeout(() => {
|
| reject(new Error(`ACP timeout after ${timeoutMs}ms`));
|
| }, timeoutMs);
|
|
|
| let idleTimer: ReturnType<typeof setTimeout>;
|
|
|
| const onData = ({ sessionId: sid }: { sessionId: string }) => {
|
| if (sid !== sessionId) return;
|
|
|
| clearTimeout(idleTimer);
|
| idleTimer = setTimeout(() => {
|
| clearTimeout(timer);
|
| this.removeListener("stdout", onData);
|
| this.removeListener("exit", onExit);
|
| resolve(session.stdoutBuffer);
|
| }, 2000);
|
| };
|
|
|
| const onExit = ({ sessionId: sid }: { sessionId: string }) => {
|
| if (sid !== sessionId) return;
|
| clearTimeout(timer);
|
| clearTimeout(idleTimer);
|
| this.removeListener("stdout", onData);
|
| this.removeListener("exit", onExit);
|
| resolve(session.stdoutBuffer);
|
| };
|
|
|
| this.on("stdout", onData);
|
| this.on("exit", onExit);
|
| });
|
| }
|
|
|
| |
| |
|
|
| kill(sessionId: string): boolean {
|
| const session = this.sessions.get(sessionId);
|
| if (!session) return false;
|
|
|
| if (session.alive) {
|
| session.process.kill("SIGTERM");
|
|
|
| setTimeout(() => {
|
| if (session.alive) {
|
| session.process.kill("SIGKILL");
|
| }
|
| }, 5000);
|
| }
|
|
|
| this.sessions.delete(sessionId);
|
| return true;
|
| }
|
|
|
| |
| |
|
|
| getActiveSessions(): AcpSession[] {
|
| return Array.from(this.sessions.values()).filter((s) => s.alive);
|
| }
|
|
|
| |
| |
|
|
| getSession(sessionId: string): AcpSession | undefined {
|
| return this.sessions.get(sessionId);
|
| }
|
|
|
| |
| |
|
|
| killAll(): void {
|
| for (const [id] of this.sessions) {
|
| this.kill(id);
|
| }
|
| }
|
| }
|
|
|
|
|
| export const acpManager = new AcpManager();
|
|
|