Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
287c382
feat: proxy primitive and public JSON-RPC peer layer
chazcb Jul 14, 2026
17a9b01
refactor: dedupe cross-close wiring and simplify connect plumbing
chazcb Jul 14, 2026
9af3b68
docs: proxy example and README, align option names with the Rust Prox…
chazcb Jul 14, 2026
1d883fb
fix: serialize proxy dispatch to match the Rust SDK's ordering guarantee
chazcb Jul 15, 2026
c44180e
feat: typed per-method proxy registration via side builders
chazcb Jul 15, 2026
55b145b
refactor: expose proxy() only — retract the raw peer layer from the p…
chazcb Jul 15, 2026
c927842
docs: spell out the request vs notification handler contract
chazcb Jul 15, 2026
79bfe2a
feat: document and pin live append-only registration after connect
chazcb Jul 15, 2026
fae559e
refactor: snapshot registrations at connect, matching the fluent buil…
chazcb Jul 15, 2026
22b690a
refactor: collapse proxy dispatch to one handler with synchronous fas…
chazcb Jul 15, 2026
a7b6b8d
docs: describe proxy guarantees on their own terms
chazcb Jul 15, 2026
c1168e1
refactor: flatten registration to direction-named methods
chazcb Jul 15, 2026
39e9d3b
refactor: shrink the footprint on existing files
chazcb Jul 15, 2026
90bdc8b
refactor: keep the in-memory stream pair private
chazcb Jul 15, 2026
71b2ef7
test: cover the proxy's error, cancellation, ordering, and compositio…
chazcb Jul 15, 2026
e0a0da5
test: EOF-driven teardown over a real byte transport
chazcb Jul 15, 2026
239cf44
test: assert close outcomes explicitly
chazcb Jul 15, 2026
b6d2ded
feat: expose signal on proxy sides, assert close reasons in tests
chazcb Jul 15, 2026
15db0d4
test: cover agent-side registration wiring and object-form parsers
chazcb Jul 15, 2026
811a8e3
fix: reference the documented v1 Stream type from ProxyStreams
chazcb Aug 20, 2026
5cfc1c3
feat: scope proxy() to stable v1 by rejecting batch wire messages
chazcb Aug 20, 2026
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ If you're building an [Agent](https://agentclientprotocol.com/protocol/overview#

If you're building a [Client](https://agentclientprotocol.com/protocol/overview#client), start with `client({ name })`, register client-side handlers such as `requestPermission(...)` and `sessionUpdate(...)`, then run your agent workflow with `connectWith(stream, async (ctx) => ...)`.

If you're building something that sits between the two — a logger, an authorization gate, a message transformer — start with `proxy()`. Register typed handlers with `onRequestFromClient(...)` / `onNotificationFromAgent(...)` (the method names say which direction they intercept) just like the agent/client builders, then call `connect({ client, agent })`. Anything you don't claim is forwarded untouched in both directions; handlers can rewrite, answer, or drop messages before they cross.

### Study a Production Implementation

For a complete, production-ready implementation, check out the [Gemini CLI Agent](https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/zed-integration/zedIntegration.ts).
Expand Down
20 changes: 16 additions & 4 deletions src/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ export function ndJsonStream(
return createJsonStream(output, input);
}

export { proxy } from "./proxy.js";
// ProxyBuilder is type-only on purpose: proxy() is the sole factory, so its
// constructor is not part of the public contract.
export type {
ProxyBuilder,
ProxyHandle,
ProxyNotificationContext,
ProxyNotificationHandler,
ProxyRequestContext,
ProxyRequestHandler,
ProxySideConnection,
ProxyStreams,
} from "./proxy.js";
export { RequestError } from "./jsonrpc.js";
export type {
AnyMessage,
Expand All @@ -68,7 +81,6 @@ export type {
import type { WireStream } from "./stream.js";
import { Connection, Handled, HandlerRegistration } from "./jsonrpc.js";
import type {
AnyWireMessage,
ConnectionBuilder,
ConnectionContext,
ConnectionOptions,
Expand All @@ -93,9 +105,9 @@ function isStream(value: unknown): value is WireStream {
);
}

function memoryStreamPair(): [WireStream, WireStream] {
const leftToRight = new TransformStream<AnyWireMessage>();
const rightToLeft = new TransformStream<AnyWireMessage>();
function memoryStreamPair(): [Stream, Stream] {
const leftToRight = new TransformStream<AnyMessage>();
const rightToLeft = new TransformStream<AnyMessage>();
return [
{
readable: rightToLeft.readable,
Expand Down
1 change: 1 addition & 0 deletions src/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This directory contains examples using the [ACP](https://agentclientprotocol.com

- [`agent.ts`](./agent.ts) - A minimal agent implementation that simulates LLM interaction
- [`client.ts`](./client.ts) - A minimal client implementation that spawns the [`agent.ts`](./agent.ts) as a subprocess
- [`proxy.ts`](./proxy.ts) - A pass-through proxy that wraps any agent command and logs the messages crossing it in both directions
- [`http-server.ts`](./http-server.ts) - A minimal ACP Streamable HTTP server with WebSocket upgrade support
- [`http-client.ts`](./http-client.ts) - A minimal client using `createHttpStream`
- [`ws-client.ts`](./ws-client.ts) - A minimal client using `createWebSocketStream`
Expand Down
70 changes: 70 additions & 0 deletions src/examples/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env node

import { spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { Readable, Writable } from "node:stream";
import { fileURLToPath } from "node:url";

import * as acp from "../acp.js";

// A minimal ACP proxy: it presents as an agent on its own stdio, spawns the
// real agent as a subprocess, and forwards every message between the two
// while logging traffic to stderr. Point an ACP client (like Zed) at this
// script exactly as it would run the agent directly — neither side needs to
// know the proxy is there.
//
// Usage: proxy.ts [command args...]
// Runs the example agent from this directory when no command is given.

function logAndForward(
direction: string,
): acp.ProxyRequestHandler<unknown, unknown> {
return ({ method, params, forward }) => {
console.error(`[proxy] ${direction} request: ${method}`);
return forward(params);
};
}

function logAndForwardNotification(
direction: string,
): acp.ProxyNotificationHandler<unknown> {
return async ({ method, params, forward }) => {
console.error(`[proxy] ${direction} notification: ${method}`);
await forward(params);
};
}

// Spawn the wrapped agent: the command from argv, or the example agent.
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
const exampleAgent = join(dirname(fileURLToPath(import.meta.url)), "agent.ts");
const [command, ...args] =
process.argv.length > 2
? process.argv.slice(2)
: [npxCmd, "tsx", exampleAgent];
const agentProcess = spawn(command, args, {
stdio: ["pipe", "pipe", "inherit"],
});

// "*" catches anything without an exact registration; typed interception is
// also available, e.g.
// .onRequestFromClient("session/prompt", async ({ params, forward }) => ...).
const handle = acp
.proxy()
.onRequestFromClient("*", logAndForward("client → agent"))
.onNotificationFromClient("*", logAndForwardNotification("client → agent"))
.onRequestFromAgent("*", logAndForward("agent → client"))
.onNotificationFromAgent("*", logAndForwardNotification("agent → client"))
.connect({
client: acp.ndJsonStream(
Writable.toWeb(process.stdout),
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
),
agent: acp.ndJsonStream(
Writable.toWeb(agentProcess.stdin!),
Readable.toWeb(agentProcess.stdout!) as ReadableStream<Uint8Array>,
),
});

agentProcess.once("exit", () => handle.close());
await handle.closed;
agentProcess.kill();
7 changes: 6 additions & 1 deletion src/jsonrpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,12 @@ function requestCancelledError(reason?: unknown): RequestError {
return RequestError.requestCancelled(reason);
}

function errorToRequestResult<T>(
/**
* Maps an error thrown while handling a request to a JSON-RPC result:
* `RequestError` keeps its code/message/data, an abort after cancellation
* maps to request-cancelled, anything else becomes a generic internal error.
*/
export function errorToRequestResult<T>(
error: unknown,
signal: AbortSignal,
): Result<T> {
Expand Down
Loading