Files
OmniRoute/src/lib/acp/manager.ts
anhtahaylove 20abd89d7c fix(acp): bound session output buffers and reset stderr per prompt (#13100)
Keeping the tail is the right direction — `sendPrompt` resolves with the stdout collected since the prompt was written and stderr is read for diagnostics after a failure, so the newest output is what callers actually use. The `[...output truncated...]` marker keeps it from being silent. Resetting `stderrBuffer` alongside `stdoutBuffer` fixes the subtler half: diagnostics for one prompt were carrying stale output from every earlier one.

Verified on the tree that actually ships — your branch merged onto the current tip, which already carries #13096: `appendCapped()` and `settle()` coexist cleanly and all 7 assertions across both ACP test files pass together.

I reformatted the changelog fragment to the `changelog.d` convention (`- **fix(scope):** …`) before merging — `check:changelog-integrity` rejects a fragment that does not start with a markdown bullet, which is the same gate your #13158 was about. Wording is yours, unchanged in substance.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:32:51 -03:00

250 lines
7.5 KiB
TypeScript

/**
* ACP (Agent Client Protocol) — Process Spawner & Manager
*
* Spawns CLI agents as child processes and manages their lifecycle.
* Communication happens via stdin/stdout (JSON-RPC style) or piped HTTP.
*
* This module provides a "CLI-as-backend" transport: instead of intercepting
* HTTP API calls, OmniRoute spawns the CLI directly and feeds prompts through
* its native interface.
*/
import { spawn, ChildProcess } from "child_process";
import { EventEmitter } from "events";
import { hasRegisteredAgent } from "./registry";
export interface AcpSession {
/** Unique session ID */
id: string;
/** Agent ID (e.g., "codex", "claude") */
agentId: string;
/** Child process handle */
process: ChildProcess;
/** Whether the process is alive */
alive: boolean;
/** Accumulated stdout buffer */
stdoutBuffer: string;
/** Accumulated stderr buffer */
stderrBuffer: string;
/** Created timestamp */
createdAt: Date;
}
/**
* Upper bound for each per-session output buffer.
*
* Both buffers grow on every chunk a CLI agent writes and are only reset when
* the next prompt starts, so a chatty or looping agent can grow them without
* limit while the session stays alive. 1 MiB is far above a realistic agent
* response while keeping a stuck session's footprint bounded.
*/
const MAX_BUFFER_CHARS = 1_048_576;
const TRUNCATION_NOTICE = "\n[...output truncated...]\n";
/**
* Append to a buffer, keeping the most recent output when the cap is exceeded.
*
* The tail is what callers care about: `sendPrompt` resolves with the stdout
* collected since the prompt was written, and stderr is read for diagnostics
* after a failure. Dropping from the front keeps both useful.
*/
function appendCapped(buffer: string, chunk: string): string {
const combined = buffer + chunk;
if (combined.length <= MAX_BUFFER_CHARS) return combined;
const keep = MAX_BUFFER_CHARS - TRUNCATION_NOTICE.length;
if (keep <= 0) return combined.slice(-MAX_BUFFER_CHARS);
return TRUNCATION_NOTICE + combined.slice(-keep);
}
/**
* ACP Session Manager
*
* Manages the lifecycle of CLI agent processes.
* Each session represents one running CLI agent instance.
*/
export class AcpManager extends EventEmitter {
private sessions: Map<string, AcpSession> = new Map();
/**
* Spawn a new CLI agent process.
*/
spawn(
agentId: string,
binary: string,
args: string[] = [],
env: Record<string, string> = {}
): AcpSession {
const normalizedAgentId = String(agentId || "")
.trim()
.toLowerCase();
if (!hasRegisteredAgent(normalizedAgentId)) {
throw new Error(`Unknown agent: ${agentId}`);
}
// Keep session ids and telemetry stable when a caller uses a registry
// alias/custom spelling. The registry remains the source of truth for
// which ACP-capable IDs may be spawned.
agentId = normalizedAgentId;
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 = appendCapped(session.stdoutBuffer, chunk.toString());
this.emit("stdout", { sessionId, data: chunk.toString() });
});
child.stderr?.on("data", (chunk: Buffer) => {
session.stderrBuffer = appendCapped(session.stderrBuffer, chunk.toString());
this.emit("stderr", { sessionId, data: chunk.toString() });
});
child.on("exit", (code, signal) => {
session.alive = false;
// Only kill() used to remove entries, so any agent that exited on its own
// stayed in the map forever. getActiveSessions() filters on `alive`, which
// hid the growth from callers.
this.sessions.delete(sessionId);
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;
}
/**
* Send input to a running session's stdin.
*/
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;
}
/**
* Send a prompt to a CLI agent and collect the response.
* This is a higher-level method that handles the send/receive cycle.
*/
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`);
// Clear buffers before sending. stderr is reset too: it was previously only
// ever appended to, so diagnostics for one prompt carried stale output from
// every earlier prompt in the session.
session.stdoutBuffer = "";
session.stderrBuffer = "";
// Send prompt
this.sendInput(sessionId, prompt + "\n");
// Wait for response (collect until process goes idle or timeout)
return new Promise((resolve, reject) => {
let idleTimer: ReturnType<typeof setTimeout> | undefined;
// Every outcome -- idle, exit, or timeout -- has to release the same
// resources. `acpManager` is a module-level singleton, so a branch that
// skips this leaks a listener per call for the lifetime of the process.
const settle = (finish: () => void) => {
clearTimeout(timer);
clearTimeout(idleTimer);
this.removeListener("stdout", onData);
this.removeListener("exit", onExit);
finish();
};
const timer = setTimeout(() => {
settle(() => reject(new Error(`ACP timeout after ${timeoutMs}ms`)));
}, timeoutMs);
const onData = ({ sessionId: sid }: { sessionId: string }) => {
if (sid !== sessionId) return;
// Reset idle timer on new data
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
settle(() => resolve(session.stdoutBuffer));
}, 2000); // 2s idle = response complete
};
const onExit = ({ sessionId: sid }: { sessionId: string }) => {
if (sid !== sessionId) return;
settle(() => resolve(session.stdoutBuffer));
};
this.on("stdout", onData);
this.on("exit", onExit);
});
}
/**
* Kill a session and clean up.
*/
kill(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
if (session.alive) {
session.process.kill("SIGTERM");
// Force kill after 5s
setTimeout(() => {
if (session.alive) {
session.process.kill("SIGKILL");
}
}, 5000);
}
this.sessions.delete(sessionId);
return true;
}
/**
* Get all active sessions.
*/
getActiveSessions(): AcpSession[] {
return Array.from(this.sessions.values()).filter((s) => s.alive);
}
/**
* Get a specific session.
*/
getSession(sessionId: string): AcpSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Kill all sessions.
*/
killAll(): void {
for (const [id] of this.sessions) {
this.kill(id);
}
}
}
// Singleton manager instance
export const acpManager = new AcpManager();