@particle-academy/prism-acp 0.4.1 → 0.5.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/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, 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.
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`, the client-side `fs/*` and `terminal/*` calls an
28
- agent can make back, and the Codex driver. The surface will change.
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 (`app-server`) | not yet |
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
@@ -115,9 +116,10 @@ implementations of one protocol disagree without anyone noticing. So:
115
116
  which id to pass.
116
117
 
117
118
  **ACP's `sessionId` is not resumable.** `session/new` returns an id this server
118
- minted; the CLI has its own session id, a UUID, and `claude --resume` accepts
119
- only that one (or a session title). The two are deliberately separate, and the
120
- CLI's is published on the **first** `session/update` of every session:
119
+ minted; providers resume with their own session identity. Claude accepts its
120
+ CLI session id (a UUID or session title) through `claude --resume`; Codex uses
121
+ its App Server thread id. The provider's id is published on the **first**
122
+ `session/update` of every session:
121
123
 
122
124
  ```ts
123
125
  import { META_CLI_SESSION_ID } from '@particle-academy/prism-acp';
@@ -142,9 +144,16 @@ but by then `session/load` has already returned `{}` and you believe you have a
142
144
  resumed session.
143
145
 
144
146
  A session whose agent has **exited** is loadable; one whose agent is **still
145
- running** is refused, by either id. No history is replayed on load, because the
146
- CLI replays none -- `session/load` returning `{}` with no `session/update`
147
- notifications is the honest report of that, not an omission.
147
+ running** is refused, by either id.
148
+
149
+ History on load depends on the provider. **Claude replays none**: its CLI emits
150
+ no transcript on resume, so returning `{}` with no `session/update` is the
151
+ honest report. **Codex does replay history**: the driver resumes with
152
+ `excludeTurns: true`, then fetches turns and items through the paged App Server
153
+ methods and sends them as ACP updates. This deliberately differs between the
154
+ drivers because their measured resume behavior differs. Codex thread ids are
155
+ the provider's own captured ids and are the same ids accepted by its resume
156
+ method; ACP-minted ids are refused.
148
157
 
149
158
  ### Refusing an unknown id at load, not a turn later
150
159
 
@@ -190,9 +199,19 @@ const probeSession = (sessionId: string) => probeSessionStore(sessionId, { env:
190
199
  ```
191
200
 
192
201
  `probeSession` is yours to supply because the answer belongs to the agent being
193
- driven, not to ACP: `probeSessionStore` reads claude's session store, and a Codex
194
- driver would resolve the same question through `thread/resume`. Omit it and
195
- `session/load` behaves as it always did.
202
+ driven, not to ACP: `probeSessionStore` reads Claude's session store. Codex
203
+ checks its identity by resuming the captured id through `thread/resume`; omit
204
+ `probeSession` when using Codex.
205
+
206
+ ## Codex permissions
207
+
208
+ Codex App Server approvals become ACP `session/request_permission` requests.
209
+ The driver offers the decisions Codex sent, including the persistent
210
+ execpolicy-amendment choice; its argv is kept on that option under
211
+ `particle.academy/execpolicy_amendment` so a client can preserve the distinction
212
+ between one-time approval and a remembered command. Human decisions have no
213
+ default timeout. Cancelling a session, disconnecting the ACP client, or shutting
214
+ down the driver answers any outstanding Codex approval with `cancel`.
196
215
 
197
216
  ## Rate limits are a gauge, not just a breach event
198
217
 
@@ -252,6 +271,16 @@ parse and not an interface: an interface over `unknown` is a cast, so a renamed
252
271
  provider field would still read as `undefined`, and a gauge renders `undefined`
253
272
  as empty. An empty headroom gauge is read by a human as plenty of headroom.
254
273
 
274
+ Codex has a separate `CodexRateLimit` parser because App Server windows report
275
+ `usedPercent` and `windowDurationMins`; Claude's
276
+ `ClaudeRateLimit` uses fractional `utilization` and named windows. Both convert
277
+ the provider's epoch-second reset timestamp to `resetsAtMs`. Do not feed one
278
+ provider's payload to the other's parser.
279
+
280
+ Both parsers accept usage beyond the allowance (`usedPercent` above 100 or
281
+ `utilization` above 1) without capping it. Overage is real state, and capping
282
+ would invent a reading; clamp only the rendered bar, not the reported value.
283
+
255
284
  ## Using it
256
285
 
257
286
  ```ts
@@ -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 is meant to front more
49
- * than one CLI -- Codex's `app-server` is the next one -- and because a server
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. Omit it and `session/load` behaves as it
64
- * did before: it accepts the id and the agent reports the problem later.
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 `--resume` provably cannot take
150
- // it: `session/new` returns an id of OUR making, the CLI has its own UUID,
151
- // and only the CLI's works. A client that stored the id it was handed and
152
- // passed it back here was the obvious thing to do and could never have
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, which 'claude --resume' cannot accept. ` +
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. We send none, because the CLI replays nothing on --resume
199
- // -- claiming otherwise by returning early would be a silent lie about what
200
- // a client is about to receive.
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
- options.input.on('end', () => {
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
- for (const frame of framer.end())
23
- deliver(frame);
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('client disconnected'));
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
+ }