@particle-academy/prism-acp 0.4.1 → 0.5.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 +50 -15
- package/dist/acp/agent.d.ts +17 -4
- package/dist/acp/agent.js +23 -9
- package/dist/acp/stdio.js +15 -5
- package/dist/codex/driver.d.ts +29 -0
- package/dist/codex/driver.js +1083 -0
- package/dist/codex/rate-limit.d.ts +32 -0
- package/dist/codex/rate-limit.js +129 -0
- package/dist/codex/transport.d.ts +24 -0
- package/dist/codex/transport.js +90 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/meta.d.ts +7 -4
- package/dist/meta.js +7 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,12 +20,13 @@ API key to supply and no adapter program to install.
|
|
|
20
20
|
## Status
|
|
21
21
|
|
|
22
22
|
Early, but a client can talk to it. `initialize`, `session/new`,
|
|
23
|
-
`session/load`, `session/prompt` and `session/cancel` work over a pipe
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
`session/load`, `session/prompt` and `session/cancel` work over a pipe. Claude
|
|
24
|
+
has been proven end to end against an authenticated binary; Codex is driven
|
|
25
|
+
through its App Server and tested against a fake transport, with the wire
|
|
26
|
+
shapes measured against captured traffic.
|
|
26
27
|
|
|
27
|
-
Missing: `session/set_mode
|
|
28
|
-
agent can make back
|
|
28
|
+
Missing: `session/set_mode` and the client-side `fs/*` and `terminal/*` calls an
|
|
29
|
+
agent can make back. The surface will change.
|
|
29
30
|
|
|
30
31
|
| piece | state |
|
|
31
32
|
|---|---|
|
|
@@ -38,7 +39,7 @@ agent can make back, and the Codex driver. The surface will change.
|
|
|
38
39
|
| ACP server surface + stdio | built |
|
|
39
40
|
| `session/load` resume | built, and **proven** to remember the first turn |
|
|
40
41
|
| `session/set_mode`, `fs/*`, `terminal/*` | not yet |
|
|
41
|
-
| Codex driver
|
|
42
|
+
| Codex App Server driver | built; paged history and permission requests |
|
|
42
43
|
|
|
43
44
|
**It maps 7 of ACP's 19 `session/update` kinds**, and that number is asserted by
|
|
44
45
|
a test rather than described here, so raising it means moving it. The twelve it
|
|
@@ -91,6 +92,12 @@ in the direction that spends money silently. Names are compared
|
|
|
91
92
|
case-insensitively, because Windows environment names are case-insensitive and
|
|
92
93
|
`anthropic_api_key` reaches a child exactly as the uppercase spelling does.
|
|
93
94
|
|
|
95
|
+
The allow-list withholds every variable not named in it, credentials and
|
|
96
|
+
non-credentials alike. If the child needs an orchestrator session or terminal
|
|
97
|
+
id, a callback URL, or a feature flag, name it in `allowEnv` or it will not
|
|
98
|
+
arrive. This failure is silent: the child can start and appear healthy while
|
|
99
|
+
being unable to identify itself, rather than reporting a missing variable.
|
|
100
|
+
|
|
94
101
|
Nothing here mutates `process.env`. A workspace may hold an API key on purpose —
|
|
95
102
|
other consumers beside this one legitimately bill per token — so the child's
|
|
96
103
|
environment is constructed and the ambient one is left alone.
|
|
@@ -115,9 +122,10 @@ implementations of one protocol disagree without anyone noticing. So:
|
|
|
115
122
|
which id to pass.
|
|
116
123
|
|
|
117
124
|
**ACP's `sessionId` is not resumable.** `session/new` returns an id this server
|
|
118
|
-
minted;
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
minted; providers resume with their own session identity. Claude accepts its
|
|
126
|
+
CLI session id (a UUID or session title) through `claude --resume`; Codex uses
|
|
127
|
+
its App Server thread id. The provider's id is published on the **first**
|
|
128
|
+
`session/update` of every session:
|
|
121
129
|
|
|
122
130
|
```ts
|
|
123
131
|
import { META_CLI_SESSION_ID } from '@particle-academy/prism-acp';
|
|
@@ -142,9 +150,16 @@ but by then `session/load` has already returned `{}` and you believe you have a
|
|
|
142
150
|
resumed session.
|
|
143
151
|
|
|
144
152
|
A session whose agent has **exited** is loadable; one whose agent is **still
|
|
145
|
-
running** is refused, by either id.
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
running** is refused, by either id.
|
|
154
|
+
|
|
155
|
+
History on load depends on the provider. **Claude replays none**: its CLI emits
|
|
156
|
+
no transcript on resume, so returning `{}` with no `session/update` is the
|
|
157
|
+
honest report. **Codex does replay history**: the driver resumes with
|
|
158
|
+
`excludeTurns: true`, then fetches turns and items through the paged App Server
|
|
159
|
+
methods and sends them as ACP updates. This deliberately differs between the
|
|
160
|
+
drivers because their measured resume behavior differs. Codex thread ids are
|
|
161
|
+
the provider's own captured ids and are the same ids accepted by its resume
|
|
162
|
+
method; ACP-minted ids are refused.
|
|
148
163
|
|
|
149
164
|
### Refusing an unknown id at load, not a turn later
|
|
150
165
|
|
|
@@ -190,9 +205,19 @@ const probeSession = (sessionId: string) => probeSessionStore(sessionId, { env:
|
|
|
190
205
|
```
|
|
191
206
|
|
|
192
207
|
`probeSession` is yours to supply because the answer belongs to the agent being
|
|
193
|
-
driven, not to ACP: `probeSessionStore` reads
|
|
194
|
-
|
|
195
|
-
`
|
|
208
|
+
driven, not to ACP: `probeSessionStore` reads Claude's session store. Codex
|
|
209
|
+
checks its identity by resuming the captured id through `thread/resume`; omit
|
|
210
|
+
`probeSession` when using Codex.
|
|
211
|
+
|
|
212
|
+
## Codex permissions
|
|
213
|
+
|
|
214
|
+
Codex App Server approvals become ACP `session/request_permission` requests.
|
|
215
|
+
The driver offers the decisions Codex sent, including the persistent
|
|
216
|
+
execpolicy-amendment choice; its argv is kept on that option under
|
|
217
|
+
`particle.academy/execpolicy_amendment` so a client can preserve the distinction
|
|
218
|
+
between one-time approval and a remembered command. Human decisions have no
|
|
219
|
+
default timeout. Cancelling a session, disconnecting the ACP client, or shutting
|
|
220
|
+
down the driver answers any outstanding Codex approval with `cancel`.
|
|
196
221
|
|
|
197
222
|
## Rate limits are a gauge, not just a breach event
|
|
198
223
|
|
|
@@ -252,6 +277,16 @@ parse and not an interface: an interface over `unknown` is a cast, so a renamed
|
|
|
252
277
|
provider field would still read as `undefined`, and a gauge renders `undefined`
|
|
253
278
|
as empty. An empty headroom gauge is read by a human as plenty of headroom.
|
|
254
279
|
|
|
280
|
+
Codex has a separate `CodexRateLimit` parser because App Server windows report
|
|
281
|
+
`usedPercent` and `windowDurationMins`; Claude's
|
|
282
|
+
`ClaudeRateLimit` uses fractional `utilization` and named windows. Both convert
|
|
283
|
+
the provider's epoch-second reset timestamp to `resetsAtMs`. Do not feed one
|
|
284
|
+
provider's payload to the other's parser.
|
|
285
|
+
|
|
286
|
+
Both parsers accept usage beyond the allowance (`usedPercent` above 100 or
|
|
287
|
+
`utilization` above 1) without capping it. Overage is real state, and capping
|
|
288
|
+
would invent a reading; clamp only the rendered bar, not the reported value.
|
|
289
|
+
|
|
255
290
|
## Using it
|
|
256
291
|
|
|
257
292
|
```ts
|
package/dist/acp/agent.d.ts
CHANGED
|
@@ -37,16 +37,29 @@ export interface AgentDriver {
|
|
|
37
37
|
}
|
|
38
38
|
export interface DriverEvents {
|
|
39
39
|
readonly onUpdate?: (update: AcpUpdate) => void;
|
|
40
|
+
/** Ask the ACP client to decide a Codex permission request. */
|
|
41
|
+
readonly onRequestPermission?: (request: PermissionRequest) => Promise<PermissionOutcome>;
|
|
40
42
|
readonly onTurnEnd?: (outcome: TurnOutcome) => void;
|
|
41
43
|
readonly onProtocolError?: (problem: string) => void;
|
|
42
44
|
readonly onStderr?: (line: string) => void;
|
|
43
45
|
readonly onExit?: (code: number | null, signal: NodeJS.Signals | null) => void;
|
|
44
46
|
}
|
|
47
|
+
export interface PermissionRequest {
|
|
48
|
+
readonly toolCall: Record<string, unknown>;
|
|
49
|
+
readonly options: readonly Record<string, unknown>[];
|
|
50
|
+
readonly _meta?: Readonly<Record<string, unknown>>;
|
|
51
|
+
}
|
|
52
|
+
export type PermissionOutcome = {
|
|
53
|
+
readonly outcome: 'selected';
|
|
54
|
+
readonly optionId: string;
|
|
55
|
+
} | {
|
|
56
|
+
readonly outcome: 'cancelled';
|
|
57
|
+
};
|
|
45
58
|
/**
|
|
46
59
|
* Builds a driver for one session.
|
|
47
60
|
*
|
|
48
|
-
* Injected rather than hardcoded because this surface
|
|
49
|
-
*
|
|
61
|
+
* Injected rather than hardcoded because this surface fronts more than one
|
|
62
|
+
* CLI, including Codex's `app-server`, and because a server
|
|
50
63
|
* that could only be tested by spawning a real agent would have its session
|
|
51
64
|
* bookkeeping covered by nothing.
|
|
52
65
|
*/
|
|
@@ -60,8 +73,8 @@ export type DriverFactory = (options: {
|
|
|
60
73
|
* Injected, and for two reasons. Tests must not read the developer's real
|
|
61
74
|
* session store; and the answer is a property of the AGENT being driven, not of
|
|
62
75
|
* ACP -- a Codex driver resolves it through `thread/resume`, not through
|
|
63
|
-
* claude's `~/.claude/projects` layout.
|
|
64
|
-
*
|
|
76
|
+
* claude's `~/.claude/projects` layout. The store helper is Claude-specific;
|
|
77
|
+
* Codex users should omit it and let the driver check the App Server identity.
|
|
65
78
|
*/
|
|
66
79
|
export type SessionProbe = (sessionId: string) => {
|
|
67
80
|
readonly existence: 'present' | 'absent' | 'indeterminate';
|
package/dist/acp/agent.js
CHANGED
|
@@ -146,11 +146,11 @@ export class AcpAgent {
|
|
|
146
146
|
throw new RpcError(RPC_INVALID_PARAMS, `session ${sessionId} is already open as ${session.id} and its agent is still running`);
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
-
// REFUSE an id this server minted, because
|
|
150
|
-
// it: `session/new` returns an id of OUR making, the
|
|
151
|
-
//
|
|
152
|
-
// passed it back here was the obvious thing to do
|
|
153
|
-
// worked.
|
|
149
|
+
// REFUSE an id this server minted, because the provider's resume mechanism
|
|
150
|
+
// cannot take it: `session/new` returns an id of OUR making, while the
|
|
151
|
+
// driver resumes with the provider's own captured id. A client that stored
|
|
152
|
+
// the id it was handed and passed it back here was the obvious thing to do
|
|
153
|
+
// and could never have worked.
|
|
154
154
|
//
|
|
155
155
|
// Refused HERE rather than left to the CLI, even though the CLI does error
|
|
156
156
|
// on it (verified: "is not a UUID and does not match any session title",
|
|
@@ -172,7 +172,7 @@ export class AcpAgent {
|
|
|
172
172
|
// CLI session title by inspection. That residue is exactly why the CLI's
|
|
173
173
|
// own id is published in `_meta` rather than left to be guessed at.
|
|
174
174
|
if (MINTED_SESSION_ID.test(sessionId) || this.#minted.has(sessionId)) {
|
|
175
|
-
throw new RpcError(RPC_INVALID_PARAMS, `${sessionId} is an ACP session id minted by this server
|
|
175
|
+
throw new RpcError(RPC_INVALID_PARAMS, `${sessionId} is an ACP session id minted by this server and cannot be resumed by the provider. ` +
|
|
176
176
|
`Resume with the CLI's own session id, sent as '${META_CLI_SESSION_ID}' in the _meta of the first ` +
|
|
177
177
|
`session/update of the original session.`);
|
|
178
178
|
}
|
|
@@ -195,9 +195,9 @@ export class AcpAgent {
|
|
|
195
195
|
}
|
|
196
196
|
this.#open(sessionId, cwd, sessionId);
|
|
197
197
|
// The spec's result is an empty object; history arrives as session/update
|
|
198
|
-
// notifications.
|
|
199
|
-
//
|
|
200
|
-
//
|
|
198
|
+
// notifications. Whether the driver replays transcript history is
|
|
199
|
+
// provider-specific: Claude replays none; Codex replays through its paged
|
|
200
|
+
// App Server endpoints.
|
|
201
201
|
return {};
|
|
202
202
|
}
|
|
203
203
|
#open(id, cwd, resumeSessionId) {
|
|
@@ -212,6 +212,14 @@ export class AcpAgent {
|
|
|
212
212
|
onUpdate: (update) => {
|
|
213
213
|
this.#peer.notify('session/update', { sessionId: id, update });
|
|
214
214
|
},
|
|
215
|
+
onRequestPermission: async (request) => {
|
|
216
|
+
const result = await this.#peer.request('session/request_permission', {
|
|
217
|
+
sessionId: id,
|
|
218
|
+
...request,
|
|
219
|
+
});
|
|
220
|
+
const outcome = asObject(result)?.outcome;
|
|
221
|
+
return isPermissionOutcome(outcome) ? outcome : { outcome: 'cancelled' };
|
|
222
|
+
},
|
|
215
223
|
onTurnEnd: (outcome) => {
|
|
216
224
|
const turn = session.turn;
|
|
217
225
|
session.turn = null;
|
|
@@ -341,6 +349,12 @@ function asObject(value) {
|
|
|
341
349
|
function asString(value) {
|
|
342
350
|
return typeof value === 'string' ? value : undefined;
|
|
343
351
|
}
|
|
352
|
+
function isPermissionOutcome(value) {
|
|
353
|
+
const outcome = asObject(value);
|
|
354
|
+
if (outcome?.outcome === 'cancelled')
|
|
355
|
+
return true;
|
|
356
|
+
return outcome?.outcome === 'selected' && typeof outcome.optionId === 'string';
|
|
357
|
+
}
|
|
344
358
|
function messageOf(cause) {
|
|
345
359
|
return cause instanceof Error ? cause.message : String(cause);
|
|
346
360
|
}
|
package/dist/acp/stdio.js
CHANGED
|
@@ -11,23 +11,33 @@ export function serve(options) {
|
|
|
11
11
|
});
|
|
12
12
|
const agent = new AcpAgent(peer, options);
|
|
13
13
|
const closed = new Promise((resolve) => {
|
|
14
|
+
let finished = false;
|
|
14
15
|
options.input.on('data', (chunk) => {
|
|
15
16
|
for (const frame of framer.push(chunk))
|
|
16
17
|
deliver(frame);
|
|
17
18
|
});
|
|
18
|
-
|
|
19
|
+
const closeClient = (flush, reason) => {
|
|
20
|
+
if (finished)
|
|
21
|
+
return;
|
|
22
|
+
finished = true;
|
|
19
23
|
// Flush before closing: a client can send its last message without a
|
|
20
24
|
// trailing newline, and on this transport the last message is the one
|
|
21
25
|
// that matters.
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
if (flush)
|
|
27
|
+
for (const frame of framer.end())
|
|
28
|
+
deliver(frame);
|
|
24
29
|
// Every session's child process outlives this stream unless it is told
|
|
25
30
|
// otherwise. A server that exited without killing them would leave an
|
|
26
31
|
// agent running with nobody listening.
|
|
27
32
|
agent.closeAll();
|
|
28
|
-
peer.fail(new Error(
|
|
33
|
+
peer.fail(new Error(reason));
|
|
29
34
|
resolve();
|
|
30
|
-
}
|
|
35
|
+
};
|
|
36
|
+
options.input.on('end', () => closeClient(true, 'client disconnected'));
|
|
37
|
+
// Destroyed pipes may emit `close` without `end`. That is a disconnect too:
|
|
38
|
+
// pending provider approvals must be cancelled before their socket closes.
|
|
39
|
+
options.input.on('close', () => closeClient(false, 'client disconnected'));
|
|
40
|
+
options.input.on('error', () => closeClient(false, 'client input failed'));
|
|
31
41
|
});
|
|
32
42
|
function deliver(frame) {
|
|
33
43
|
if (!frame.ok) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AgentDriver, DriverEvents } from '../acp/agent.js';
|
|
2
|
+
import { type CodexTransport, type CodexTransportHandlers, type CodexTransportOptions } from './transport.js';
|
|
3
|
+
export interface CodexDriverOptions {
|
|
4
|
+
readonly cwd: string;
|
|
5
|
+
/** Trusted configuration; must not be built from untrusted input. */
|
|
6
|
+
readonly binary?: string;
|
|
7
|
+
readonly parentEnv?: Readonly<Record<string, string | undefined>>;
|
|
8
|
+
readonly allowEnv?: readonly string[];
|
|
9
|
+
readonly resumeSessionId?: string;
|
|
10
|
+
readonly turnInactivityTimeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export type CodexTransportFactory = (options: CodexTransportOptions, handlers: CodexTransportHandlers) => CodexTransport;
|
|
13
|
+
/**
|
|
14
|
+
* Codex App Server driver. The App Server owns the durable thread identity;
|
|
15
|
+
* this driver only captures it from thread/start or thread/resume.
|
|
16
|
+
*/
|
|
17
|
+
export declare class CodexDriver implements AgentDriver {
|
|
18
|
+
#private;
|
|
19
|
+
withheldCredentials: readonly string[];
|
|
20
|
+
cliSessionId: string | null;
|
|
21
|
+
constructor(options: CodexDriverOptions, events?: DriverEvents, transportFactory?: CodexTransportFactory);
|
|
22
|
+
/** Resolves once initialize and thread start/resume have completed. */
|
|
23
|
+
get ready(): Promise<void>;
|
|
24
|
+
start(): void;
|
|
25
|
+
prompt(text: string): void;
|
|
26
|
+
/** App Server has no stdin close operation; the thread remains promptable. */
|
|
27
|
+
endInput(): void;
|
|
28
|
+
kill(signal?: NodeJS.Signals): void;
|
|
29
|
+
}
|