@particle-academy/prism-acp 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Particle Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,140 @@
1
- # Temporary Holding Version
1
+ # prism-acp (TypeScript)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Speak the [Agent Client Protocol](https://agentclientprotocol.com) to a
4
+ coding-agent CLI **the user has already authenticated**.
5
+
6
+ A client — an editor, a terminal UI, an orchestrator — gets structured state
7
+ from an agent instead of guessing it from bytes on a pseudo-terminal: session
8
+ state, streaming assistant output with thought content separable, tool calls
9
+ with status, a plan that changes in place, mid-turn permission requests it can
10
+ answer, token usage and cost.
11
+
12
+ ```sh
13
+ npm install @particle-academy/prism-acp
14
+ ```
15
+
16
+ **Zero runtime dependencies.** It drives the agent CLI already on the machine,
17
+ so the user's existing subscription login is the authentication — there is no
18
+ API key to supply and no adapter program to install.
19
+
20
+ ## Status
21
+
22
+ Early, but a client can talk to it. `initialize`, `session/new`,
23
+ `session/load`, `session/prompt` and `session/cancel` work over a pipe, driving
24
+ the Claude CLI, proven end to end against an authenticated binary. The mapping
25
+ is tested against **captured traffic** rather than a hand-written fixture.
26
+
27
+ Missing: `session/set_mode`, the client-side `fs/*` and `terminal/*` calls an
28
+ agent can make back, and the Codex driver. The surface will change.
29
+
30
+ | piece | state |
31
+ |---|---|
32
+ | NDJSON framing | built |
33
+ | child-environment construction | built |
34
+ | JSON-RPC peer | built |
35
+ | extension-field policy (`_meta`) | built |
36
+ | Claude `stream-json` → ACP mapping | built |
37
+ | Claude process driver | built |
38
+ | ACP server surface + stdio | built |
39
+ | `session/load` resume | built, and **proven** to remember the first turn |
40
+ | `session/set_mode`, `fs/*`, `terminal/*` | not yet |
41
+ | Codex driver (`app-server`) | not yet |
42
+
43
+ **It maps 7 of ACP's 19 `session/update` kinds**, and that number is asserted by
44
+ a test rather than described here, so raising it means moving it. The twelve it
45
+ does not map each carry a measured reason — most notably the three plan kinds:
46
+ the CLI emits **no plan frame at all**. A captured turn that built a three-item
47
+ plan produced it entirely as `TaskCreate` / `TaskUpdate` tool calls, so ACP's
48
+ plan kinds could only ever be *synthesised* here, and that is a decision to take
49
+ deliberately rather than a mapping to add casually. Until then a plan is not
50
+ lost — it is visible as the tool calls that built it.
51
+
52
+ ## Why drive a CLI rather than call an API
53
+
54
+ Because of the authentication, and it is the whole reason this package exists.
55
+
56
+ A provider **API** client needs an API key and bills per token. A user who pays
57
+ for a subscription already has working credentials — in the CLI's own store, not
58
+ in any protocol — and the way to use them is to run the CLI they belong to. So
59
+ this package spawns the agent binary and translates, rather than reimplementing
60
+ the agent against an HTTP API the user has no key for.
61
+
62
+ That also keeps the credential out of this package entirely. It never reads one,
63
+ never holds one, and never puts one on a wire.
64
+
65
+ ## The environment is built, not inherited
66
+
67
+ `childEnv` constructs the spawned CLI's environment **allow-list-first**, and
68
+ withholds credentials that would outrank the CLI's own login:
69
+
70
+ ```ts
71
+ import { childEnv } from '@particle-academy/prism-acp';
72
+
73
+ const { env, withheld } = childEnv(process.env);
74
+ // withheld: ['ANTHROPIC_API_KEY'] -- names only; values are never read
75
+ ```
76
+
77
+ This matters more than it looks. The SDK behind these CLIs resolves credentials
78
+ from an **ordered** list, and `ANTHROPIC_API_KEY` is first — so an inherited key
79
+ does not lose to the subscription, it **outranks** it:
80
+
81
+ - a **valid** inherited key bills per token while the interface reports a
82
+ subscription session;
83
+ - an **invalid** one makes every call fail `401 authentication_failed` and retry
84
+ to exhaustion. It does **not** fall back to the working login, because
85
+ precedence is resolved before validity is tested — so the 401 points at the
86
+ account, one layer below the actual cause.
87
+
88
+ An allow-list rather than a deny-list, deliberately: a deny-list naming today's
89
+ credential variables is one new provider variable away from being wrong again,
90
+ in the direction that spends money silently. Names are compared
91
+ case-insensitively, because Windows environment names are case-insensitive and
92
+ `anthropic_api_key` reaches a child exactly as the uppercase spelling does.
93
+
94
+ Nothing here mutates `process.env`. A workspace may hold an API key on purpose —
95
+ other consumers beside this one legitimately bill per token — so the child's
96
+ environment is constructed and the ambient one is left alone.
97
+
98
+ ## Framing is pinned, not inferred
99
+
100
+ This package exists in three languages, and framing is where three
101
+ implementations of one protocol disagree without anyone noticing. So:
102
+
103
+ - a trailing `\r` is **stripped**, not left to each language's JSON parser to
104
+ tolerate;
105
+ - `MAX_LINE_BYTES` is **1,000,000** in every port, because a cap that differs
106
+ per language means one implementation dies where another succeeds on the same
107
+ stream;
108
+ - an oversized or unparseable line reports its **size, never its content** — a
109
+ line here can carry a prompt, a file or a credential, and a framing error is
110
+ not a reason to copy it into a log.
111
+
112
+ ## Using it
113
+
114
+ ```ts
115
+ import { serve, ClaudeDriver } from '@particle-academy/prism-acp';
116
+
117
+ serve({
118
+ input: process.stdin,
119
+ output: process.stdout,
120
+ driverFactory: (options, events) =>
121
+ new ClaudeDriver({ cwd: options.cwd, ...options }, events),
122
+ });
123
+ ```
124
+
125
+ A client then speaks ACP on those pipes. Several sessions run at once, each with
126
+ its own agent process, its own in-flight turn and its own updates — tagged with
127
+ the session they belong to, because a room of agents shares one stream.
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ npm ci
133
+ npm run typecheck
134
+ npm test
135
+ npm run build
136
+ ```
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The ACP agent surface: `initialize`, `session/new`, `session/load`,
3
+ * `session/prompt`, `session/cancel`.
4
+ *
5
+ * This is the half a client talks to. Underneath, each ACP session owns one
6
+ * driver, which owns one agent-CLI process.
7
+ *
8
+ * ## Several sessions, not one
9
+ *
10
+ * Sessions live in a map from the start, and that is a requirement rather than
11
+ * tidiness. The first consumer of this package needs a room holding SEVERAL
12
+ * agents at once, each its own process with its own in-flight turn and pending
13
+ * approvals. A design that assumed one session per server works perfectly for
14
+ * the first agent and has to be taken apart for the second, so it is not worth
15
+ * writing even as a step.
16
+ *
17
+ * ## Capabilities are claims, so they are reported honestly
18
+ *
19
+ * `initialize` declares what this agent can do, and a client plans around the
20
+ * answer. `loadSession` is reported true only because `session/load` is
21
+ * implemented on top of the CLI's own `--resume`; anything not implemented is
22
+ * reported absent rather than optimistically.
23
+ */
24
+ import { JsonRpcPeer } from '../jsonrpc.js';
25
+ import type { AcpUpdate } from '../claude/to-acp.js';
26
+ import type { TurnOutcome } from '../claude/driver.js';
27
+ /** The protocol version this agent speaks. */
28
+ export declare const PROTOCOL_VERSION = 1;
29
+ /** What a driver must offer the agent surface, whichever CLI it drives. */
30
+ export interface AgentDriver {
31
+ start(): void;
32
+ prompt(text: string): void;
33
+ endInput(): void;
34
+ kill(signal?: NodeJS.Signals): void;
35
+ readonly cliSessionId: string | null;
36
+ readonly withheldCredentials: readonly string[];
37
+ }
38
+ export interface DriverEvents {
39
+ readonly onUpdate?: (update: AcpUpdate) => void;
40
+ readonly onTurnEnd?: (outcome: TurnOutcome) => void;
41
+ readonly onProtocolError?: (problem: string) => void;
42
+ readonly onStderr?: (line: string) => void;
43
+ readonly onExit?: (code: number | null, signal: NodeJS.Signals | null) => void;
44
+ }
45
+ /**
46
+ * Builds a driver for one session.
47
+ *
48
+ * Injected rather than hardcoded because this surface is meant to front more
49
+ * than one CLI -- Codex's `app-server` is the next one -- and because a server
50
+ * that could only be tested by spawning a real agent would have its session
51
+ * bookkeeping covered by nothing.
52
+ */
53
+ export type DriverFactory = (options: {
54
+ readonly cwd: string;
55
+ readonly resumeSessionId?: string;
56
+ }, events: DriverEvents) => AgentDriver;
57
+ export interface AcpAgentOptions {
58
+ readonly driverFactory: DriverFactory;
59
+ readonly agentInfo?: {
60
+ readonly name: string;
61
+ readonly title?: string;
62
+ readonly version: string;
63
+ };
64
+ /** Generate a session id. Injected so tests can assert on stable ids. */
65
+ readonly newSessionId?: () => string;
66
+ readonly onStderr?: (sessionId: string, line: string) => void;
67
+ readonly onProtocolError?: (sessionId: string, problem: string) => void;
68
+ }
69
+ export declare class AcpAgent {
70
+ #private;
71
+ constructor(peer: JsonRpcPeer, options: AcpAgentOptions);
72
+ /** Session ids currently open. */
73
+ get sessionIds(): readonly string[];
74
+ /** Stop every session's process. */
75
+ closeAll(): void;
76
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The ACP agent surface: `initialize`, `session/new`, `session/load`,
3
+ * `session/prompt`, `session/cancel`.
4
+ *
5
+ * This is the half a client talks to. Underneath, each ACP session owns one
6
+ * driver, which owns one agent-CLI process.
7
+ *
8
+ * ## Several sessions, not one
9
+ *
10
+ * Sessions live in a map from the start, and that is a requirement rather than
11
+ * tidiness. The first consumer of this package needs a room holding SEVERAL
12
+ * agents at once, each its own process with its own in-flight turn and pending
13
+ * approvals. A design that assumed one session per server works perfectly for
14
+ * the first agent and has to be taken apart for the second, so it is not worth
15
+ * writing even as a step.
16
+ *
17
+ * ## Capabilities are claims, so they are reported honestly
18
+ *
19
+ * `initialize` declares what this agent can do, and a client plans around the
20
+ * answer. `loadSession` is reported true only because `session/load` is
21
+ * implemented on top of the CLI's own `--resume`; anything not implemented is
22
+ * reported absent rather than optimistically.
23
+ */
24
+ import { RPC_INVALID_PARAMS, RPC_INTERNAL_ERROR, RpcError } from '../jsonrpc.js';
25
+ /** The protocol version this agent speaks. */
26
+ export const PROTOCOL_VERSION = 1;
27
+ export class AcpAgent {
28
+ #peer;
29
+ #options;
30
+ #sessions = new Map();
31
+ #counter = 0;
32
+ constructor(peer, options) {
33
+ this.#peer = peer;
34
+ this.#options = options;
35
+ peer
36
+ .handle('initialize', (params) => this.#initialize(params))
37
+ .handle('authenticate', () => this.#authenticate())
38
+ .handle('session/new', (params) => this.#sessionNew(params))
39
+ .handle('session/load', (params) => this.#sessionLoad(params))
40
+ .handle('session/prompt', async (params) => await this.#sessionPrompt(params));
41
+ peer.onNotify('session/cancel', (params) => this.#sessionCancel(params));
42
+ }
43
+ /** Session ids currently open. */
44
+ get sessionIds() {
45
+ return [...this.#sessions.keys()];
46
+ }
47
+ /** Stop every session's process. */
48
+ closeAll() {
49
+ for (const session of this.#sessions.values())
50
+ session.driver.kill();
51
+ this.#sessions.clear();
52
+ }
53
+ #initialize(params) {
54
+ const requested = asObject(params)?.protocolVersion;
55
+ // The spec negotiates: an agent answers with the version it will speak. We
56
+ // speak 1, and say so whatever was asked, rather than echoing a number we
57
+ // do not implement back at the client.
58
+ void requested;
59
+ return {
60
+ protocolVersion: PROTOCOL_VERSION,
61
+ agentInfo: this.#options.agentInfo ?? {
62
+ name: '@particle-academy/prism-acp',
63
+ title: 'Prism ACP',
64
+ version: '0.1.0',
65
+ },
66
+ // Empty because this agent needs no authentication STEP: the CLI it
67
+ // drives is already authenticated by the user, and no credential ever
68
+ // travels in this protocol. Empty is the honest answer, not a placeholder.
69
+ authMethods: [],
70
+ agentCapabilities: {
71
+ // True, and PROVEN rather than wired. A live test stores a number in
72
+ // one turn, resumes, and asks for it back -- an assertion a fresh
73
+ // conversation cannot satisfy. That test exists because a wrong resume
74
+ // does not error: it starts a new conversation while the caller
75
+ // believes it continued one, so the flag being set proves nothing on
76
+ // its own. A capability reported optimistically is worse than one
77
+ // reported absent, because a client plans around the answer.
78
+ loadSession: true,
79
+ promptCapabilities: {
80
+ // Text only, for now. Reported as false rather than omitted, because
81
+ // for these two the client needs to know we will not accept them.
82
+ image: false,
83
+ audio: false,
84
+ embeddedContext: false,
85
+ },
86
+ },
87
+ };
88
+ }
89
+ #authenticate() {
90
+ // Reachable only if a client ignores the empty authMethods above. Answering
91
+ // null rather than erroring keeps a confused client working, since there is
92
+ // genuinely nothing to authenticate.
93
+ return null;
94
+ }
95
+ #sessionNew(params) {
96
+ const cwd = requireAbsoluteCwd(params);
97
+ const id = this.#options.newSessionId?.() ?? `sess_${++this.#counter}_${Date.now()}`;
98
+ this.#open(id, cwd, undefined);
99
+ return { sessionId: id };
100
+ }
101
+ #sessionLoad(params) {
102
+ const object = asObject(params);
103
+ const cwd = requireAbsoluteCwd(params);
104
+ const sessionId = asString(object?.sessionId);
105
+ if (sessionId === undefined) {
106
+ throw new RpcError(RPC_INVALID_PARAMS, 'session/load requires a sessionId');
107
+ }
108
+ // Resuming an id this server already has open would leave two processes
109
+ // writing updates for one session, and the second would look like the
110
+ // first stuttering.
111
+ if (this.#sessions.has(sessionId)) {
112
+ throw new RpcError(RPC_INVALID_PARAMS, `session ${sessionId} is already open`);
113
+ }
114
+ this.#open(sessionId, cwd, sessionId);
115
+ // The spec's result is an empty object; history arrives as session/update
116
+ // notifications. We send none, because the CLI replays nothing on --resume
117
+ // -- claiming otherwise by returning early would be a silent lie about what
118
+ // a client is about to receive.
119
+ return {};
120
+ }
121
+ #open(id, cwd, resumeSessionId) {
122
+ const session = {
123
+ id,
124
+ cwd,
125
+ driver: undefined,
126
+ turn: null,
127
+ exited: false,
128
+ };
129
+ const driver = this.#options.driverFactory({ cwd, ...(resumeSessionId === undefined ? {} : { resumeSessionId }) }, {
130
+ onUpdate: (update) => {
131
+ this.#peer.notify('session/update', { sessionId: id, update });
132
+ },
133
+ onTurnEnd: (outcome) => {
134
+ const turn = session.turn;
135
+ session.turn = null;
136
+ turn?.settle(outcome);
137
+ },
138
+ onStderr: (line) => this.#options.onStderr?.(id, line),
139
+ onProtocolError: (problem) => this.#options.onProtocolError?.(id, problem),
140
+ onExit: (code) => {
141
+ session.exited = true;
142
+ // A turn still in flight when the process dies must be settled, or
143
+ // session/prompt never returns and the client waits forever on an
144
+ // agent that no longer exists.
145
+ const turn = session.turn;
146
+ session.turn = null;
147
+ turn?.settle({
148
+ stopReason: null,
149
+ isError: true,
150
+ raw: `agent exited with code ${String(code)}`,
151
+ });
152
+ },
153
+ });
154
+ session.driver = driver;
155
+ this.#sessions.set(id, session);
156
+ driver.start();
157
+ return session;
158
+ }
159
+ async #sessionPrompt(params) {
160
+ const object = asObject(params);
161
+ const sessionId = asString(object?.sessionId);
162
+ if (sessionId === undefined) {
163
+ throw new RpcError(RPC_INVALID_PARAMS, 'session/prompt requires a sessionId');
164
+ }
165
+ const session = this.#sessions.get(sessionId);
166
+ if (session === undefined) {
167
+ throw new RpcError(RPC_INVALID_PARAMS, `no such session: ${sessionId}`);
168
+ }
169
+ if (session.exited) {
170
+ throw new RpcError(RPC_INTERNAL_ERROR, `session ${sessionId} has exited`);
171
+ }
172
+ if (session.turn !== null) {
173
+ // One turn at a time per session. Interleaving two would mix their
174
+ // updates on one stream with nothing to tell them apart.
175
+ throw new RpcError(RPC_INVALID_PARAMS, `session ${sessionId} already has a turn in flight`);
176
+ }
177
+ const text = promptText(object?.prompt);
178
+ if (text === undefined) {
179
+ throw new RpcError(RPC_INVALID_PARAMS, 'session/prompt requires a prompt of content blocks');
180
+ }
181
+ const outcome = await new Promise((resolve) => {
182
+ session.turn = { settle: resolve, cancelled: false };
183
+ try {
184
+ session.driver.prompt(text);
185
+ }
186
+ catch (cause) {
187
+ session.turn = null;
188
+ resolve({ stopReason: null, isError: true, raw: messageOf(cause) });
189
+ }
190
+ });
191
+ // ACP's five stop reasons all describe a turn that FINISHED. None of them
192
+ // describes a crash, so a failed turn is reported as an ERROR rather than
193
+ // given the nearest-looking reason: `end_turn` would claim a clean finish
194
+ // and `refusal` would claim a decision the agent never made.
195
+ if (outcome.stopReason === null) {
196
+ throw new RpcError(RPC_INTERNAL_ERROR, `turn did not complete${outcome.raw === null ? '' : `: ${outcome.raw}`}`);
197
+ }
198
+ return { stopReason: outcome.stopReason };
199
+ }
200
+ #sessionCancel(params) {
201
+ const sessionId = asString(asObject(params)?.sessionId);
202
+ if (sessionId === undefined)
203
+ return;
204
+ const session = this.#sessions.get(sessionId);
205
+ if (session === undefined)
206
+ return;
207
+ if (session.turn !== null) {
208
+ session.turn.cancelled = true;
209
+ const turn = session.turn;
210
+ session.turn = null;
211
+ // `cancelled` is a real ACP stop reason, so a cancelled turn RESOLVES
212
+ // rather than erroring. The client asked for this outcome; it is not a
213
+ // failure.
214
+ turn.settle({ stopReason: 'cancelled', isError: false, raw: 'cancelled' });
215
+ }
216
+ session.driver.kill();
217
+ session.exited = true;
218
+ }
219
+ }
220
+ /** ACP requires an absolute cwd, and says so with a MUST. */
221
+ function requireAbsoluteCwd(params) {
222
+ const cwd = asString(asObject(params)?.cwd);
223
+ if (cwd === undefined) {
224
+ throw new RpcError(RPC_INVALID_PARAMS, 'cwd is required and MUST be an absolute path');
225
+ }
226
+ if (!isAbsolute(cwd)) {
227
+ // Enforced rather than resolved against our own cwd. The spec makes cwd the
228
+ // session's filesystem boundary, and silently anchoring a relative path to
229
+ // wherever this process happens to be running would put the agent
230
+ // somewhere the client never named.
231
+ throw new RpcError(RPC_INVALID_PARAMS, `cwd MUST be an absolute path: ${cwd}`);
232
+ }
233
+ return cwd;
234
+ }
235
+ /** Absolute on either platform, without importing node:path for one check. */
236
+ function isAbsolute(path) {
237
+ return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path);
238
+ }
239
+ /** Flatten ACP content blocks into the text a CLI prompt wants. */
240
+ function promptText(prompt) {
241
+ if (!Array.isArray(prompt))
242
+ return undefined;
243
+ const parts = [];
244
+ for (const block of prompt) {
245
+ const object = asObject(block);
246
+ if (object?.type === 'text' && typeof object.text === 'string')
247
+ parts.push(object.text);
248
+ }
249
+ // An empty array is not a prompt, and neither is an array of blocks we cannot
250
+ // render. Refusing is better than sending an empty turn the agent will answer
251
+ // with something unrelated.
252
+ return parts.length === 0 ? undefined : parts.join('\n');
253
+ }
254
+ function asObject(value) {
255
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
256
+ ? value
257
+ : undefined;
258
+ }
259
+ function asString(value) {
260
+ return typeof value === 'string' ? value : undefined;
261
+ }
262
+ function messageOf(cause) {
263
+ return cause instanceof Error ? cause.message : String(cause);
264
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Serve the ACP agent over a pipe.
3
+ *
4
+ * ACP runs over stdio: the client spawns the agent and they exchange NDJSON on
5
+ * its stdin and stdout. This is the fifteen lines that join {@link NdjsonFramer},
6
+ * {@link JsonRpcPeer} and {@link AcpAgent} to a pair of streams.
7
+ *
8
+ * Streams are parameters rather than `process.stdin`/`process.stdout` reached
9
+ * for directly, so the whole surface can be driven end to end in a test over a
10
+ * pair of in-memory streams -- no spawning, no pipes, no timing.
11
+ */
12
+ import type { Readable, Writable } from 'node:stream';
13
+ import { JsonRpcPeer } from '../jsonrpc.js';
14
+ import { AcpAgent, type AcpAgentOptions } from './agent.js';
15
+ export interface ServeOptions extends AcpAgentOptions {
16
+ readonly input: Readable;
17
+ readonly output: Writable;
18
+ /** A frame that arrived but was not usable. */
19
+ readonly onProtocolError?: (sessionId: string, problem: string) => void;
20
+ }
21
+ export interface Served {
22
+ readonly agent: AcpAgent;
23
+ readonly peer: JsonRpcPeer;
24
+ /** Resolves when the input stream ends. */
25
+ readonly closed: Promise<void>;
26
+ }
27
+ export declare function serve(options: ServeOptions): Served;
@@ -0,0 +1,40 @@
1
+ import { JsonRpcPeer } from '../jsonrpc.js';
2
+ import { NdjsonFramer, encodeLine } from '../ndjson.js';
3
+ import { AcpAgent } from './agent.js';
4
+ export function serve(options) {
5
+ const framer = new NdjsonFramer();
6
+ const peer = new JsonRpcPeer({
7
+ send: (message) => {
8
+ options.output.write(encodeLine(message));
9
+ },
10
+ onProtocolError: (problem) => options.onProtocolError?.('', problem),
11
+ });
12
+ const agent = new AcpAgent(peer, options);
13
+ const closed = new Promise((resolve) => {
14
+ options.input.on('data', (chunk) => {
15
+ for (const frame of framer.push(chunk))
16
+ deliver(frame);
17
+ });
18
+ options.input.on('end', () => {
19
+ // Flush before closing: a client can send its last message without a
20
+ // trailing newline, and on this transport the last message is the one
21
+ // that matters.
22
+ for (const frame of framer.end())
23
+ deliver(frame);
24
+ // Every session's child process outlives this stream unless it is told
25
+ // otherwise. A server that exited without killing them would leave an
26
+ // agent running with nobody listening.
27
+ agent.closeAll();
28
+ peer.fail(new Error('client disconnected'));
29
+ resolve();
30
+ });
31
+ });
32
+ function deliver(frame) {
33
+ if (!frame.ok) {
34
+ options.onProtocolError?.('', frame.error.message);
35
+ return;
36
+ }
37
+ void peer.receive(frame.value);
38
+ }
39
+ return { agent, peer, closed };
40
+ }