Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acpx",
"version": "0.1.3",
"version": "0.1.0",
"description": "Headless CLI client for the Agent Client Protocol (ACP) — talk to coding agents from the command line",
"type": "module",
"files": [
Expand Down
145 changes: 145 additions & 0 deletions src/acp-error-shapes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { OutputErrorAcpPayload } from "./types.js";

const RESOURCE_NOT_FOUND_ACP_CODES = new Set([-32001, -32002]);

function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}

function toAcpErrorPayload(value: unknown): OutputErrorAcpPayload | undefined {
const record = asRecord(value);
if (!record) {
return undefined;
}

if (typeof record.code !== "number" || !Number.isFinite(record.code)) {
return undefined;
}
if (typeof record.message !== "string" || record.message.length === 0) {
return undefined;
}

return {
code: record.code,
message: record.message,
data: record.data,
};
}

function extractAcpErrorInternal(
value: unknown,
depth: number,
): OutputErrorAcpPayload | undefined {
if (depth > 5) {
return undefined;
}

const direct = toAcpErrorPayload(value);
if (direct) {
return direct;
}

const record = asRecord(value);
if (!record) {
return undefined;
}

if ("error" in record) {
const nested = extractAcpErrorInternal(record.error, depth + 1);
if (nested) {
return nested;
}
}

if ("cause" in record) {
const nested = extractAcpErrorInternal(record.cause, depth + 1);
if (nested) {
return nested;
}
}

return undefined;
}

function formatUnknownErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}

if (error && typeof error === "object") {
const maybeMessage = (error as { message?: unknown }).message;
if (typeof maybeMessage === "string" && maybeMessage.length > 0) {
return maybeMessage;
}

try {
return JSON.stringify(error);
} catch {
// fall through
}
}

return String(error);
}

function isSessionNotFoundText(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}

const normalized = value.toLowerCase();
return (
normalized.includes("resource_not_found") ||
normalized.includes("resource not found") ||
normalized.includes("session not found") ||
normalized.includes("unknown session")
);
}

function hasSessionNotFoundHint(value: unknown, depth = 0): boolean {
if (depth > 4) {
return false;
}

if (isSessionNotFoundText(value)) {
return true;
}

if (Array.isArray(value)) {
return value.some((entry) => hasSessionNotFoundHint(entry, depth + 1));
}

const record = asRecord(value);
if (!record) {
return false;
}

return Object.values(record).some((entry) =>
hasSessionNotFoundHint(entry, depth + 1),
);
}

export function extractAcpError(error: unknown): OutputErrorAcpPayload | undefined {
return extractAcpErrorInternal(error, 0);
}

export function isAcpResourceNotFoundError(error: unknown): boolean {
const acp = extractAcpError(error);
if (acp && RESOURCE_NOT_FOUND_ACP_CODES.has(acp.code)) {
return true;
}

if (acp) {
if (isSessionNotFoundText(acp.message)) {
return true;
}
if (hasSessionNotFoundHint(acp.data)) {
return true;
}
}

return isSessionNotFoundText(formatUnknownErrorMessage(error));
}
2 changes: 1 addition & 1 deletion src/agent-registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const AGENT_REGISTRY: Record<string, string> = {
codex: "npx @zed-industries/codex-acp",
claude: "npx @zed-industries/claude-agent-acp",
claude: "npx -y @zed-industries/claude-agent-acp",
gemini: "gemini",
opencode: "npx opencode-ai",
pi: "npx pi-acp",
Expand Down
98 changes: 96 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ type CommandParts = {
const REPLAY_IDLE_MS = 80;
const REPLAY_DRAIN_TIMEOUT_MS = 5_000;
const DRAIN_POLL_INTERVAL_MS = 20;
const AGENT_CLOSE_AFTER_STDIN_END_MS = 100;
const AGENT_CLOSE_TERM_GRACE_MS = 1_500;
const AGENT_CLOSE_KILL_GRACE_MS = 1_000;

type LoadSessionOptions = {
suppressReplayUpdates?: boolean;
Expand Down Expand Up @@ -132,6 +135,47 @@ function waitForSpawn(child: ChildProcess): Promise<void> {
});
}

function isChildProcessRunning(child: ChildProcess): boolean {
return child.exitCode == null && child.signalCode == null;
}

function waitForChildExit(
child: ChildProcessByStdio<Writable, Readable, Readable>,
timeoutMs: number,
): Promise<boolean> {
if (!isChildProcessRunning(child)) {
return Promise.resolve(true);
}

return new Promise<boolean>((resolve) => {
let settled = false;
const timer = setTimeout(
() => {
finish(false);
},
Math.max(0, timeoutMs),
);

const finish = (value: boolean) => {
if (settled) {
return;
}
settled = true;
child.off("close", onExitLike);
child.off("exit", onExitLike);
clearTimeout(timer);
resolve(value);
};

const onExitLike = () => {
finish(true);
};

child.once("close", onExitLike);
child.once("exit", onExitLike);
});
}

function splitCommandLine(value: string): CommandParts {
const parts: string[] = [];
let current = "";
Expand Down Expand Up @@ -665,8 +709,8 @@ export class AcpClient {

await this.terminalManager.shutdown();

if (this.agent && !this.agent.killed) {
this.agent.kill();
if (this.agent) {
await this.terminateAgentProcess(this.agent);
}

this.sessionUpdateChain = Promise.resolve();
Expand All @@ -680,6 +724,56 @@ export class AcpClient {
this.agent = undefined;
}

private async terminateAgentProcess(
child: ChildProcessByStdio<Writable, Readable, Readable>,
): Promise<void> {
// Closing stdin is the most graceful shutdown signal for stdio-based ACP agents.
if (!child.stdin.destroyed) {
try {
child.stdin.end();
} catch {
// best effort
}
}

let exited = await waitForChildExit(child, AGENT_CLOSE_AFTER_STDIN_END_MS);
if (!exited && isChildProcessRunning(child)) {
try {
child.kill("SIGTERM");
} catch {
// best effort
}
exited = await waitForChildExit(child, AGENT_CLOSE_TERM_GRACE_MS);
}

if (!exited && isChildProcessRunning(child)) {
this.log(
`agent did not exit after ${AGENT_CLOSE_TERM_GRACE_MS}ms; forcing SIGKILL`,
);
try {
child.kill("SIGKILL");
} catch {
// best effort
}
exited = await waitForChildExit(child, AGENT_CLOSE_KILL_GRACE_MS);
}

// Ensure stdio handles don't keep this process alive after close() returns.
if (!child.stdin.destroyed) {
child.stdin.destroy();
}
if (!child.stdout.destroyed) {
child.stdout.destroy();
}
if (!child.stderr.destroyed) {
child.stderr.destroy();
}

if (!exited) {
child.unref();
}
}

private getConnection(): ClientSideConnection {
if (!this.connection) {
throw new Error("ACP client not started");
Expand Down
58 changes: 3 additions & 55 deletions src/error-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
PermissionDeniedError,
PermissionPromptUnavailableError,
} from "./errors.js";
import { extractAcpError, isAcpResourceNotFoundError } from "./acp-error-shapes.js";
import {
EXIT_CODES,
OUTPUT_ERROR_CODES,
Expand All @@ -13,9 +14,10 @@ import {
type OutputErrorOrigin,
} from "./types.js";

const RESOURCE_NOT_FOUND_ACP_CODES = new Set([-32001, -32002]);
const AUTH_REQUIRED_ACP_CODES = new Set([-32000]);

export { extractAcpError, isAcpResourceNotFoundError } from "./acp-error-shapes.js";

type ErrorMeta = {
outputCode?: OutputErrorCode;
detailCode?: string;
Expand Down Expand Up @@ -157,41 +159,6 @@ function toAcpErrorPayload(value: unknown): OutputErrorAcpPayload | undefined {
};
}

function extractAcpErrorInternal(
value: unknown,
depth: number,
): OutputErrorAcpPayload | undefined {
if (depth > 5) {
return undefined;
}

const direct = toAcpErrorPayload(value);
if (direct) {
return direct;
}

const record = asRecord(value);
if (!record) {
return undefined;
}

if ("error" in record) {
const nested = extractAcpErrorInternal(record.error, depth + 1);
if (nested) {
return nested;
}
}

if ("cause" in record) {
const nested = extractAcpErrorInternal(record.cause, depth + 1);
if (nested) {
return nested;
}
}

return undefined;
}

function isTimeoutLike(error: unknown): boolean {
return error instanceof Error && error.name === "TimeoutError";
}
Expand Down Expand Up @@ -232,25 +199,6 @@ export function formatErrorMessage(error: unknown): string {
return String(error);
}

export function extractAcpError(error: unknown): OutputErrorAcpPayload | undefined {
return extractAcpErrorInternal(error, 0);
}

export function isAcpResourceNotFoundError(error: unknown): boolean {
const acp = extractAcpError(error);
if (acp && RESOURCE_NOT_FOUND_ACP_CODES.has(acp.code)) {
return true;
}

const message = formatErrorMessage(error).toLowerCase();
return (
message.includes("resource_not_found") ||
message.includes("resource not found") ||
message.includes("session not found") ||
message.includes("unknown session")
);
}

function mapErrorCode(error: unknown): OutputErrorCode | undefined {
if (error instanceof PermissionPromptUnavailableError) {
return "PERMISSION_PROMPT_UNAVAILABLE";
Expand Down
Loading