@automatalabs/acp-server 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,20 @@
1
1
  # `@automatalabs/acp-server`
2
2
 
3
3
  Connection-pinned ACP V1 proxy for the backends configured through
4
- `@automatalabs/acp-agents`. The package exposes the `agentprism-acp-server` stdio executable and a
5
- programmatic `serveAcpServer()` entry point.
4
+ `@automatalabs/acp-agents`. The `agentprism-acp-server` executable defaults to stdio and can also
5
+ listen for Streamable HTTP and WebSocket clients on one endpoint.
6
6
 
7
7
  ```bash
8
- npx @automatalabs/acp-server
8
+ npx @automatalabs/acp-server # stdio
9
+ npx @automatalabs/acp-server --http # http://127.0.0.1:7331/acp + ws://…
10
+ npx @automatalabs/acp-server --http --host 127.0.0.1 --port 8080 --path /acp
9
11
  agentprism-acp-server --version
10
12
  ```
11
13
 
14
+ `--host`, `--port`, and `--path` apply only with `--http`; their defaults are `127.0.0.1`, `7331`,
15
+ and `/acp`. Each accepted HTTP or WebSocket connection has an independent discovery/backend mode
16
+ and pinned downstream connection.
17
+
12
18
  The server accepts only clients that negotiate AgentPrism router extension version 1 under
13
19
  `clientCapabilities._meta["@automatalabs/agentprism"].acpRouter`.
14
20
 
@@ -72,17 +78,283 @@ proxy stores no session-routing table.
72
78
  Built-in backend IDs are `claude`, `codex`, `opencode`, and `pi`. Custom backends use the existing
73
79
  `AGENTPRISM_BACKENDS` registry.
74
80
 
81
+ ## Using the official TypeScript client SDK
82
+
83
+ Install the router, the official ACP TypeScript SDK, and a TypeScript runner:
84
+
85
+ ```bash
86
+ pnpm add @automatalabs/acp-server @agentclientprotocol/sdk ws
87
+ pnpm add --save-dev tsx @types/node @types/ws
88
+ ```
89
+
90
+ The following examples are consecutive sections of one `router-client.ts` file. They follow the
91
+ official SDK's [current client example](https://github.com/agentclientprotocol/typescript-sdk/blob/main/src/examples/client.ts):
92
+ `client(...).onRequest(...).connectWith(...)`, `ctx.request(...)`, and the
93
+ `buildSession(...).withSession(...)` active-session API. The stdio helper starts a fresh
94
+ `agentprism-acp-server` process for each connection. The example permission handler rejects
95
+ safely; replace it with the application's actual user-confirmation UI.
96
+
97
+ ### Shared client and stdio setup
98
+
99
+ ```ts
100
+ import { spawn } from "node:child_process";
101
+ import { Readable, Writable } from "node:stream";
102
+ import * as acp from "@agentclientprotocol/sdk";
103
+ import { createHttpStream } from "@agentclientprotocol/sdk/experimental/http-client";
104
+ import { createWebSocketStream } from "@agentclientprotocol/sdk/experimental/ws-client";
105
+ import { WebSocket } from "ws";
106
+
107
+ const ROUTER_NAMESPACE = "@automatalabs/agentprism";
108
+ const PROBE_METHOD = "_automatalabs/agentprism/backends/probe";
109
+
110
+ type ProbeBackendsParams = {
111
+ cwd: string;
112
+ additionalDirectories?: string[];
113
+ mcpServers: acp.McpServer[];
114
+ _meta?: Record<string, unknown> | null;
115
+ };
116
+
117
+ type BackendProbe =
118
+ | {
119
+ id: string;
120
+ name: string;
121
+ available: true;
122
+ agentInfo?: acp.Implementation | null;
123
+ agentCapabilities?: acp.AgentCapabilities;
124
+ modes?: acp.SessionModeState | null;
125
+ configOptions?: acp.SessionConfigOption[] | null;
126
+ initializeMeta?: Record<string, unknown> | null;
127
+ sessionMeta?: Record<string, unknown> | null;
128
+ }
129
+ | {
130
+ id: string;
131
+ name: string;
132
+ available: false;
133
+ stage: "initialize" | "session/new";
134
+ error: string;
135
+ };
136
+
137
+ type ProbeBackendsResult = { backends: BackendProbe[] };
138
+
139
+ class RouterClient implements acp.Client {
140
+ async requestPermission(
141
+ params: acp.RequestPermissionRequest,
142
+ ): Promise<acp.RequestPermissionResponse> {
143
+ const reject = params.options.find(
144
+ (option) => option.kind === "reject_once" || option.kind === "reject_always",
145
+ );
146
+ return reject
147
+ ? { outcome: { outcome: "selected", optionId: reject.optionId } }
148
+ : { outcome: { outcome: "cancelled" } };
149
+ }
150
+
151
+ async sessionUpdate(params: acp.SessionNotification): Promise<void> {
152
+ const update = params.update;
153
+ if (
154
+ update.sessionUpdate === "agent_message_chunk" &&
155
+ update.content.type === "text"
156
+ ) {
157
+ process.stdout.write(update.content.text);
158
+ } else {
159
+ console.error(`[${update.sessionUpdate}]`);
160
+ }
161
+ }
162
+ }
163
+
164
+ const client = new RouterClient();
165
+
166
+ function initializeRequest(
167
+ mode: "discovery" | "backend",
168
+ backend?: string,
169
+ ): acp.InitializeRequest {
170
+ return {
171
+ protocolVersion: acp.PROTOCOL_VERSION,
172
+ clientInfo: { name: "agentprism-router-example", version: "1.0.0" },
173
+ clientCapabilities: {
174
+ _meta: {
175
+ [ROUTER_NAMESPACE]: {
176
+ acpRouter: { versions: [1] },
177
+ },
178
+ },
179
+ },
180
+ _meta: {
181
+ [ROUTER_NAMESPACE]: {
182
+ acpRouter:
183
+ mode === "discovery"
184
+ ? { version: 1, mode }
185
+ : { version: 1, mode, backend },
186
+ },
187
+ },
188
+ };
189
+ }
190
+
191
+ async function withRouter<T>(
192
+ operation: (ctx: acp.ClientContext) => Promise<T>,
193
+ ): Promise<T> {
194
+ const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
195
+ const child = spawn(pnpm, ["exec", "agentprism-acp-server"], {
196
+ stdio: ["pipe", "pipe", "inherit"],
197
+ });
198
+ if (!child.stdin || !child.stdout) throw new Error("router stdio unavailable");
199
+
200
+ const stream = acp.ndJsonStream(
201
+ Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
202
+ Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
203
+ );
204
+
205
+ try {
206
+ return await connectRouter(stream, operation);
207
+ } finally {
208
+ if (child.exitCode === null && child.signalCode === null) child.kill();
209
+ }
210
+ }
211
+
212
+ function connectRouter<T>(
213
+ stream: acp.Stream,
214
+ operation: (ctx: acp.ClientContext) => Promise<T>,
215
+ ): Promise<T> {
216
+ return acp
217
+ .client({ name: "agentprism-router-example" })
218
+ .onRequest(acp.methods.client.session.requestPermission, (ctx) =>
219
+ client.requestPermission(ctx.params),
220
+ )
221
+ .connectWith(stream, operation);
222
+ }
223
+
224
+ async function withNetworkRouter<T>(
225
+ transport: "http" | "websocket",
226
+ operation: (ctx: acp.ClientContext) => Promise<T>,
227
+ ): Promise<T> {
228
+ const stream =
229
+ transport === "http"
230
+ ? createHttpStream("http://127.0.0.1:7331/acp")
231
+ : createWebSocketStream("ws://127.0.0.1:7331/acp", { WebSocket });
232
+ try {
233
+ return await connectRouter(stream, operation);
234
+ } finally {
235
+ await stream.writable.close().catch(() => undefined);
236
+ }
237
+ }
238
+
239
+ function withConfiguredRouter<T>(
240
+ operation: (ctx: acp.ClientContext) => Promise<T>,
241
+ ): Promise<T> {
242
+ const transport = process.env.ACP_TRANSPORT ?? "stdio";
243
+ if (transport === "stdio") return withRouter(operation);
244
+ if (transport === "http" || transport === "websocket") {
245
+ return withNetworkRouter(transport, operation);
246
+ }
247
+ throw new Error(`ACP_TRANSPORT must be stdio, http, or websocket; got ${transport}`);
248
+ }
249
+ ```
250
+
251
+ The network adapters are the same official experimental transport APIs used by the SDK's current
252
+ HTTP server, HTTP client, and WebSocket client examples; the messages negotiated through them
253
+ remain ACP V1.
254
+
255
+ ### Discover configured backends
256
+
257
+ Discovery uses one initialized connection and the router-owned probe extension. The generic
258
+ `request<Response, Params>()` overload is the official SDK path for custom methods:
259
+
260
+ ```ts
261
+ const cwd = process.cwd();
262
+
263
+ const { backends } = await withConfiguredRouter(async (ctx) => {
264
+ await ctx.request(
265
+ acp.methods.agent.initialize,
266
+ initializeRequest("discovery"),
267
+ );
268
+
269
+ return ctx.request<ProbeBackendsResult, ProbeBackendsParams>(PROBE_METHOD, {
270
+ cwd,
271
+ mcpServers: [],
272
+ });
273
+ });
274
+
275
+ for (const backend of backends) {
276
+ if (backend.available) {
277
+ console.log(`${backend.id}: available (${backend.agentInfo?.name ?? backend.name})`);
278
+ } else {
279
+ console.log(`${backend.id}: unavailable at ${backend.stage}: ${backend.error}`);
280
+ }
281
+ }
282
+ ```
283
+
284
+ ### Pin a backend, create a session, and prompt
285
+
286
+ Backend operation uses a second initialized connection. The selection is repeated in
287
+ `session/new`; the SDK's active-session helper then sends ordinary ACP traffic without additional
288
+ router metadata. `session.sessionId` is the selected backend's native session ID.
289
+
290
+ ```ts
291
+ const selected = process.argv[2] ?? backends.find((backend) => backend.available)?.id;
292
+ if (!selected) throw new Error("No configured backend is available");
293
+
294
+ await withConfiguredRouter(async (ctx) => {
295
+ const initialized = await ctx.request(
296
+ acp.methods.agent.initialize,
297
+ initializeRequest("backend", selected),
298
+ );
299
+ console.log(`Connected to ${selected} using ACP v${initialized.protocolVersion}`);
300
+
301
+ await ctx
302
+ .buildSession({
303
+ cwd,
304
+ mcpServers: [],
305
+ _meta: {
306
+ [ROUTER_NAMESPACE]: {
307
+ acpRouter: { version: 1, backend: selected },
308
+ },
309
+ },
310
+ })
311
+ .withSession(async (session) => {
312
+ console.log(`Native backend session: ${session.sessionId}`);
313
+ session.prompt("Summarize this repository in three bullets.");
314
+
315
+ for (;;) {
316
+ const message = await session.nextUpdate();
317
+ if (message.kind === "stop") {
318
+ console.log(`\nStop reason: ${message.stopReason}`);
319
+ return message.response;
320
+ }
321
+
322
+ await client.sessionUpdate(message.notification);
323
+ }
324
+ });
325
+ });
326
+ ```
327
+
328
+ Run the file over stdio, or start `agentprism-acp-server --http` and select a network transport:
329
+
330
+ ```bash
331
+ pnpm exec tsx router-client.ts codex
332
+ ACP_TRANSPORT=http pnpm exec tsx router-client.ts codex
333
+ ACP_TRANSPORT=websocket pnpm exec tsx router-client.ts codex
334
+ ```
335
+
75
336
  ## Library API
76
337
 
77
338
  ```ts
78
- import { serveAcpServer } from "@automatalabs/acp-server";
339
+ import {
340
+ listenAcpHttpServer,
341
+ serveAcpServer,
342
+ } from "@automatalabs/acp-server";
79
343
 
80
- await serveAcpServer();
344
+ await serveAcpServer(); // one stdio connection
345
+
346
+ const server = await listenAcpHttpServer({
347
+ host: "127.0.0.1",
348
+ port: 7331,
349
+ path: "/acp",
350
+ });
351
+ console.log(server.url, server.webSocketUrl);
352
+ await server.close();
81
353
  ```
82
354
 
83
355
  Embedding hosts can pass a custom ACP `stream`, custom backend registrations, exact `targets`, a
84
- version string, and an abort signal through `ServeAcpServerOptions`. The package also exports the
85
- router constants, request parsers, response helpers, probe types, and backend-target resolver.
86
-
87
- Authentication, authorization, workspace policy, HTTP, WebSocket, and ACP V2 are outside this
88
- package.
356
+ version string, and an abort signal through `ServeAcpServerOptions`. `ListenAcpHttpServerOptions`
357
+ adds `host`, `port`, `path`, and `maxRequestBodyBytes`; the returned handle exposes the bound URLs,
358
+ a `closed` promise, and idempotent `close()`. The package also exports the default network listener
359
+ constants, router constants, request parsers, response helpers, probe types, and backend-target
360
+ resolver.
package/dist/cli.js CHANGED
@@ -5,6 +5,17 @@ if (process.argv.includes("--version") || process.argv.includes("-v")) {
5
5
  process.stdout.write(`${manifest.version}\n`);
6
6
  process.exit(0);
7
7
  }
8
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
9
+ process.stdout.write(`Usage:
10
+ agentprism-acp-server
11
+ agentprism-acp-server --http [--host <host>] [--port <port>] [--path <path>]
12
+
13
+ With no transport flag, the server speaks ACP over stdio. --http starts both
14
+ Streamable HTTP and WebSocket transports on one endpoint (default:
15
+ http://127.0.0.1:7331/acp and ws://127.0.0.1:7331/acp).
16
+ `);
17
+ process.exit(0);
18
+ }
8
19
  console.log = console.error;
9
20
  console.info = console.error;
10
21
  console.warn = console.error;
@@ -16,8 +27,23 @@ const abortController = new AbortController();
16
27
  process.once("SIGTERM", () => abortController.abort(new Error("SIGTERM")));
17
28
  process.once("SIGINT", () => abortController.abort(new Error("SIGINT")));
18
29
  try {
19
- const { serveAcpServer } = await import("./server.js");
20
- await serveAcpServer({ signal: abortController.signal });
30
+ const options = parseCliOptions(process.argv.slice(2));
31
+ if (options.mode === "stdio") {
32
+ const { serveAcpServer } = await import("./server.js");
33
+ await serveAcpServer({ signal: abortController.signal });
34
+ }
35
+ else {
36
+ const { listenAcpHttpServer } = await import("./http-server.js");
37
+ const server = await listenAcpHttpServer({
38
+ host: options.host,
39
+ port: options.port,
40
+ path: options.path,
41
+ signal: abortController.signal,
42
+ });
43
+ console.error(`ACP Streamable HTTP endpoint listening at ${server.url}`);
44
+ console.error(`ACP WebSocket endpoint listening at ${server.webSocketUrl}`);
45
+ await server.closed;
46
+ }
21
47
  }
22
48
  catch (error) {
23
49
  if (!abortController.signal.aborted) {
@@ -25,4 +51,64 @@ catch (error) {
25
51
  process.exitCode = 1;
26
52
  }
27
53
  }
54
+ function parseCliOptions(args) {
55
+ let http = false;
56
+ let host = "127.0.0.1";
57
+ let port = 7331;
58
+ let path = "/acp";
59
+ let networkOption = false;
60
+ for (let index = 0; index < args.length; index += 1) {
61
+ const argument = args[index];
62
+ if (argument === "--http") {
63
+ http = true;
64
+ continue;
65
+ }
66
+ if (argument === "--host" || argument === "--port" || argument === "--path") {
67
+ const value = args[index + 1];
68
+ if (value === undefined || value.startsWith("--")) {
69
+ throw new Error(`${argument} requires a value`);
70
+ }
71
+ networkOption = true;
72
+ index += 1;
73
+ if (argument === "--host")
74
+ host = value;
75
+ if (argument === "--path")
76
+ path = value;
77
+ if (argument === "--port")
78
+ port = parsePort(value);
79
+ continue;
80
+ }
81
+ if (argument.startsWith("--host=")) {
82
+ host = argument.slice("--host=".length);
83
+ networkOption = true;
84
+ continue;
85
+ }
86
+ if (argument.startsWith("--port=")) {
87
+ port = parsePort(argument.slice("--port=".length));
88
+ networkOption = true;
89
+ continue;
90
+ }
91
+ if (argument.startsWith("--path=")) {
92
+ path = argument.slice("--path=".length);
93
+ networkOption = true;
94
+ continue;
95
+ }
96
+ throw new Error(`unknown argument ${JSON.stringify(argument)}; run with --help for usage`);
97
+ }
98
+ if (!http) {
99
+ if (networkOption)
100
+ throw new Error("--host, --port, and --path require --http");
101
+ return { mode: "stdio" };
102
+ }
103
+ return { mode: "http", host, port, path };
104
+ }
105
+ function parsePort(value) {
106
+ if (!/^\d+$/.test(value))
107
+ throw new Error("--port must be an integer from 0 through 65535");
108
+ const port = Number(value);
109
+ if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) {
110
+ throw new Error("--port must be an integer from 0 through 65535");
111
+ }
112
+ return port;
113
+ }
28
114
  export {};
@@ -0,0 +1,41 @@
1
+ import type { CustomBackendConfig } from "@automatalabs/acp-agents";
2
+ import { type BackendTarget } from "./backends.js";
3
+ export declare const DEFAULT_ACP_HTTP_HOST: "127.0.0.1";
4
+ export declare const DEFAULT_ACP_HTTP_PORT: 7331;
5
+ export declare const DEFAULT_ACP_HTTP_PATH: "/acp";
6
+ export interface ListenAcpHttpServerOptions {
7
+ /** Interface to bind. Defaults to loopback. */
8
+ host?: string;
9
+ /** TCP port to bind. Pass zero to allocate an ephemeral port. */
10
+ port?: number;
11
+ /** Exact HTTP and WebSocket endpoint path. Defaults to /acp. */
12
+ path?: string;
13
+ /** Maximum JSON request body accepted by the Streamable HTTP adapter. */
14
+ maxRequestBodyBytes?: number;
15
+ /** Programmatic custom backends merged over AGENTPRISM_BACKENDS. */
16
+ backends?: Record<string, CustomBackendConfig>;
17
+ /** Exact backend targets, primarily for embedding and deterministic tests. */
18
+ targets?: readonly BackendTarget[];
19
+ /** Package version advertised on discovery connections. */
20
+ version?: string;
21
+ /** Stops the listener and every active transport connection when aborted. */
22
+ signal?: AbortSignal;
23
+ }
24
+ export interface AcpHttpServerHandle {
25
+ readonly host: string;
26
+ readonly port: number;
27
+ readonly path: string;
28
+ readonly url: string;
29
+ readonly webSocketUrl: string;
30
+ readonly closed: Promise<void>;
31
+ close(): Promise<void>;
32
+ }
33
+ /**
34
+ * Listen for ACP V1 Streamable HTTP and WebSocket connections on one endpoint.
35
+ *
36
+ * Each accepted transport connection receives an independent connection-pinned router. The
37
+ * experimental SDK surface used here is the official ACP HTTP/WebSocket transport implementation;
38
+ * the router protocol carried over it remains ACP V1.
39
+ */
40
+ export declare function listenAcpHttpServer(options?: ListenAcpHttpServerOptions): Promise<AcpHttpServerHandle>;
41
+ //# sourceMappingURL=http-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAEpE,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAG1E,eAAO,MAAM,qBAAqB,EAAG,WAAoB,CAAC;AAC1D,eAAO,MAAM,qBAAqB,EAAG,IAAa,CAAC;AACnD,eAAO,MAAM,qBAAqB,EAAG,MAAe,CAAC;AAErD,MAAM,WAAW,0BAA0B;IACzC,+CAA+C;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IAC/C,8EAA8E;IAC9E,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IACnC,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,GAAE,0BAA+B,GACvC,OAAO,CAAC,mBAAmB,CAAC,CA8H9B"}
@@ -0,0 +1,200 @@
1
+ import { createServer } from "node:http";
2
+ import { AcpServer } from "@agentclientprotocol/sdk/experimental/server";
3
+ import { createNodeHttpHandler, createNodeWebSocketUpgradeHandler, } from "@agentclientprotocol/sdk/experimental/node";
4
+ import { WebSocketServer } from "ws";
5
+ import { resolveBackendTargets } from "./backends.js";
6
+ import { serveAcpServer } from "./server.js";
7
+ export const DEFAULT_ACP_HTTP_HOST = "127.0.0.1";
8
+ export const DEFAULT_ACP_HTTP_PORT = 7331;
9
+ export const DEFAULT_ACP_HTTP_PATH = "/acp";
10
+ /**
11
+ * Listen for ACP V1 Streamable HTTP and WebSocket connections on one endpoint.
12
+ *
13
+ * Each accepted transport connection receives an independent connection-pinned router. The
14
+ * experimental SDK surface used here is the official ACP HTTP/WebSocket transport implementation;
15
+ * the router protocol carried over it remains ACP V1.
16
+ */
17
+ export async function listenAcpHttpServer(options = {}) {
18
+ options.signal?.throwIfAborted();
19
+ const host = requireHost(options.host ?? DEFAULT_ACP_HTTP_HOST);
20
+ const port = requirePort(options.port ?? DEFAULT_ACP_HTTP_PORT);
21
+ const path = requirePath(options.path ?? DEFAULT_ACP_HTTP_PATH);
22
+ const targets = options.targets
23
+ ? [...options.targets]
24
+ : resolveBackendTargets({ backends: options.backends });
25
+ requireUniqueTargets(targets);
26
+ const transportServer = new AcpServer({
27
+ createAgent: () => ({
28
+ connect(stream) {
29
+ // AcpServer exposes a batch-capable transport stream so it can also host draft ACP V2.
30
+ // This router rejects non-V1 initialize requests, after which the SDK guarantees that V1
31
+ // connections contain individual messages only.
32
+ const closed = serveAcpServer({
33
+ stream: stream,
34
+ targets,
35
+ ...(options.version === undefined ? {} : { version: options.version }),
36
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
37
+ }).catch((error) => {
38
+ // One failed client connection must not become a process-level unhandled rejection or
39
+ // stop the shared listener. AcpServer observes this lifecycle promise to tear down only
40
+ // the affected transport connection.
41
+ console.error("ACP network connection failed:", error);
42
+ });
43
+ return { closed };
44
+ },
45
+ }),
46
+ });
47
+ const httpHandler = createNodeHttpHandler(transportServer, {
48
+ ...(options.maxRequestBodyBytes === undefined
49
+ ? {}
50
+ : { maxRequestBodyBytes: options.maxRequestBodyBytes }),
51
+ });
52
+ const webSocketServer = new WebSocketServer({ noServer: true });
53
+ const upgradeHandler = createNodeWebSocketUpgradeHandler(transportServer, webSocketServer);
54
+ const httpServer = createServer((request, response) => {
55
+ if (!isAcpPath(request, path)) {
56
+ notFound(response);
57
+ return;
58
+ }
59
+ httpHandler(request, response);
60
+ });
61
+ httpServer.on("upgrade", (request, socket, head) => {
62
+ if (!isAcpPath(request, path)) {
63
+ socket.destroy();
64
+ return;
65
+ }
66
+ upgradeHandler(request, socket, head);
67
+ });
68
+ await new Promise((resolve, reject) => {
69
+ const onError = (error) => {
70
+ httpServer.off("listening", onListening);
71
+ reject(error);
72
+ };
73
+ const onListening = () => {
74
+ httpServer.off("error", onError);
75
+ resolve();
76
+ };
77
+ httpServer.once("error", onError);
78
+ httpServer.once("listening", onListening);
79
+ httpServer.listen(port, host);
80
+ });
81
+ const address = httpServer.address();
82
+ if (!isAddressInfo(address)) {
83
+ await closeNodeServer(httpServer).catch(() => undefined);
84
+ await transportServer.close();
85
+ throw new Error("ACP HTTP server did not bind to a TCP address");
86
+ }
87
+ let resolveClosed;
88
+ let rejectClosed;
89
+ const closed = new Promise((resolve, reject) => {
90
+ resolveClosed = resolve;
91
+ rejectClosed = reject;
92
+ });
93
+ closed.catch(() => { });
94
+ let closePromise;
95
+ let runtimeError;
96
+ const close = () => {
97
+ closePromise ??= (async () => {
98
+ options.signal?.removeEventListener("abort", onAbort);
99
+ const stopListening = closeNodeServer(httpServer);
100
+ const results = await Promise.allSettled([
101
+ transportServer.close(),
102
+ stopListening,
103
+ ]);
104
+ for (const client of webSocketServer.clients)
105
+ client.terminate();
106
+ await closeWebSocketServer(webSocketServer).catch((error) => {
107
+ results.push({ status: "rejected", reason: error });
108
+ });
109
+ const errors = results
110
+ .filter((result) => result.status === "rejected")
111
+ .map((result) => result.reason);
112
+ if (runtimeError !== undefined)
113
+ errors.unshift(runtimeError);
114
+ if (errors.length > 0)
115
+ throw new AggregateError(errors, "Failed to close ACP HTTP server");
116
+ })();
117
+ closePromise.then(resolveClosed, rejectClosed);
118
+ return closePromise;
119
+ };
120
+ const onAbort = () => {
121
+ void close();
122
+ };
123
+ options.signal?.addEventListener("abort", onAbort, { once: true });
124
+ httpServer.on("error", (error) => {
125
+ runtimeError = error;
126
+ void close();
127
+ });
128
+ if (options.signal?.aborted)
129
+ void close();
130
+ const endpointHost = formatUrlHost(host);
131
+ return {
132
+ host,
133
+ port: address.port,
134
+ path,
135
+ url: `http://${endpointHost}:${address.port}${path}`,
136
+ webSocketUrl: `ws://${endpointHost}:${address.port}${path}`,
137
+ closed,
138
+ close,
139
+ };
140
+ }
141
+ function requireHost(value) {
142
+ const host = value.trim();
143
+ if (host.length === 0)
144
+ throw new TypeError("ACP HTTP host must not be empty");
145
+ return host;
146
+ }
147
+ function requirePort(value) {
148
+ if (!Number.isSafeInteger(value) || value < 0 || value > 65_535) {
149
+ throw new RangeError("ACP HTTP port must be an integer from 0 through 65535");
150
+ }
151
+ return value;
152
+ }
153
+ function requirePath(value) {
154
+ if (!value.startsWith("/") || value.includes("?") || value.includes("#")) {
155
+ throw new TypeError("ACP HTTP path must be an absolute path without a query or fragment");
156
+ }
157
+ const parsed = new URL(value, "http://localhost");
158
+ if (parsed.pathname !== value) {
159
+ throw new TypeError("ACP HTTP path must be a canonical URL path");
160
+ }
161
+ return value;
162
+ }
163
+ function requireUniqueTargets(targets) {
164
+ const ids = new Set(targets.map((target) => target.id));
165
+ if (ids.size !== targets.length)
166
+ throw new Error("ACP backend target ids must be unique");
167
+ }
168
+ function isAcpPath(request, path) {
169
+ return new URL(request.url ?? "/", "http://localhost").pathname === path;
170
+ }
171
+ function notFound(response) {
172
+ response.writeHead(404, { "Content-Type": "text/plain" });
173
+ response.end("Not Found");
174
+ }
175
+ function isAddressInfo(value) {
176
+ return value !== null && typeof value === "object";
177
+ }
178
+ function formatUrlHost(host) {
179
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
180
+ }
181
+ function closeNodeServer(server) {
182
+ return new Promise((resolve, reject) => {
183
+ server.close((error) => {
184
+ if (error)
185
+ reject(error);
186
+ else
187
+ resolve();
188
+ });
189
+ });
190
+ }
191
+ function closeWebSocketServer(server) {
192
+ return new Promise((resolve, reject) => {
193
+ server.close((error) => {
194
+ if (error)
195
+ reject(error);
196
+ else
197
+ resolve();
198
+ });
199
+ });
200
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { serveAcpServer } from "./server.js";
2
2
  export type { ServeAcpServerOptions } from "./server.js";
3
+ export { DEFAULT_ACP_HTTP_HOST, DEFAULT_ACP_HTTP_PATH, DEFAULT_ACP_HTTP_PORT, listenAcpHttpServer, } from "./http-server.js";
4
+ export type { AcpHttpServerHandle, ListenAcpHttpServerOptions } from "./http-server.js";
3
5
  export { resolveBackendTargets } from "./backends.js";
4
6
  export type { BackendTarget, ResolveBackendTargetsOptions } from "./backends.js";
5
7
  export { ACP_BACKENDS_PROBE_METHOD, ACP_ROUTER_META_NAMESPACE, ACP_ROUTER_VERSION, assertSessionBackend, discoveryInitializeResponse, mergeBackendInitializeResponse, parseProbeBackendsParams, parseRouterInitialize, } from "./protocol.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,YAAY,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzD,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AACtD,YAAY,EAAE,aAAa,EAAE,4BAA4B,EAAE,MAAM,eAAe,CAAC;AAEjF,OAAO,EACL,yBAAyB,EACzB,yBAAyB,EACzB,kBAAkB,EAClB,oBAAoB,EACpB,2BAA2B,EAC3B,8BAA8B,EAC9B,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,YAAY,EACZ,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,eAAe,GAChB,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,YAAY,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,kBAAkB,CAAC;AAExF,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AACtD,YAAY,EAAE,aAAa,EAAE,4BAA4B,EAAE,MAAM,eAAe,CAAC;AAEjF,OAAO,EACL,yBAAyB,EACzB,yBAAyB,EACzB,kBAAkB,EAClB,oBAAoB,EACpB,2BAA2B,EAC3B,8BAA8B,EAC9B,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,YAAY,EACZ,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,eAAe,GAChB,MAAM,eAAe,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { serveAcpServer } from "./server.js";
2
+ export { DEFAULT_ACP_HTTP_HOST, DEFAULT_ACP_HTTP_PATH, DEFAULT_ACP_HTTP_PORT, listenAcpHttpServer, } from "./http-server.js";
2
3
  export { resolveBackendTargets } from "./backends.js";
3
4
  export { ACP_BACKENDS_PROBE_METHOD, ACP_ROUTER_META_NAMESPACE, ACP_ROUTER_VERSION, assertSessionBackend, discoveryInitializeResponse, mergeBackendInitializeResponse, parseProbeBackendsParams, parseRouterInitialize, } from "./protocol.js";
package/dist/server.d.ts CHANGED
@@ -13,6 +13,6 @@ export interface ServeAcpServerOptions {
13
13
  /** Closes the active outer and downstream connections when aborted. */
14
14
  signal?: AbortSignal;
15
15
  }
16
- /** Serve one connection-pinned AgentPrism ACP stdio connection until either side closes. */
16
+ /** Serve one connection-pinned AgentPrism ACP V1 stream until either side closes. */
17
17
  export declare function serveAcpServer(options?: ServeAcpServerOptions): Promise<void>;
18
18
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AACA,OAAO,EAYL,KAAK,MAAM,EACZ,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,mBAAmB,EAAwB,MAAM,0BAA0B,CAAC;AAC1F,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAgB1E,MAAM,WAAW,qBAAqB;IACpC,0DAA0D;IAC1D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IAC/C,8EAA8E;IAC9E,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IACnC,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,4FAA4F;AAC5F,wBAAsB,cAAc,CAAC,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAiEvF"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AACA,OAAO,EAYL,KAAK,MAAM,EACZ,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,mBAAmB,EAAwB,MAAM,0BAA0B,CAAC;AAC1F,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAgB1E,MAAM,WAAW,qBAAqB;IACpC,0DAA0D;IAC1D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IAC/C,8EAA8E;IAC9E,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IACnC,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,qFAAqF;AACrF,wBAAsB,cAAc,CAAC,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAiEvF"}
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@ import { Readable, Writable } from "node:stream";
4
4
  import { resolveBackendTargets } from "./backends.js";
5
5
  import { ACP_BACKENDS_PROBE_METHOD, assertSessionBackend, discoveryInitializeResponse, isRecord, mergeBackendInitializeResponse, parseProbeBackendsParams, parseRouterInitialize, } from "./protocol.js";
6
6
  import { RawRpcPeer, errorResponse } from "./raw-rpc.js";
7
- /** Serve one connection-pinned AgentPrism ACP stdio connection until either side closes. */
7
+ /** Serve one connection-pinned AgentPrism ACP V1 stream until either side closes. */
8
8
  export async function serveAcpServer(options = {}) {
9
9
  const stream = options.stream ?? stdioStream();
10
10
  const targets = options.targets ? [...options.targets] : resolveBackendTargets({ backends: options.backends });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automatalabs/acp-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "license": "Apache-2.0",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -31,7 +31,11 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@agentclientprotocol/sdk": "^1.4.0",
34
- "@automatalabs/acp-agents": "1.1.0"
34
+ "ws": "^8.21.3",
35
+ "@automatalabs/acp-agents": "1.1.1"
36
+ },
37
+ "devDependencies": {
38
+ "@types/ws": "^8.18.1"
35
39
  },
36
40
  "scripts": {
37
41
  "build": "tsc -b",