@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 +21 -0
- package/README.md +139 -2
- package/dist/acp/agent.d.ts +76 -0
- package/dist/acp/agent.js +264 -0
- package/dist/acp/stdio.d.ts +27 -0
- package/dist/acp/stdio.js +40 -0
- package/dist/claude/driver.d.ts +140 -0
- package/dist/claude/driver.js +266 -0
- package/dist/claude/to-acp.d.ts +19 -0
- package/dist/claude/to-acp.js +337 -0
- package/dist/env.d.ts +93 -0
- package/dist/env.js +153 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +9 -0
- package/dist/jsonrpc.d.ts +96 -0
- package/dist/jsonrpc.js +222 -0
- package/dist/meta.d.ts +85 -0
- package/dist/meta.js +98 -0
- package/dist/ndjson.d.ts +85 -0
- package/dist/ndjson.js +144 -0
- package/package.json +33 -4
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
|
-
#
|
|
1
|
+
# prism-acp (TypeScript)
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|