@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.
@@ -0,0 +1,32 @@
1
+ /** Codex's percentage-based rate-limit window. */
2
+ export interface CodexRateLimitWindow {
3
+ readonly usedPercent: number;
4
+ readonly windowDurationMins: number;
5
+ /** Epoch milliseconds, converted from Codex's epoch-second `resetsAt`. */
6
+ readonly resetsAtMs: number;
7
+ }
8
+ export interface CodexRateLimit {
9
+ readonly ordinaryUsageAllowed: boolean | null;
10
+ readonly planType?: string;
11
+ readonly primary: CodexRateLimitWindow | null;
12
+ readonly secondary: CodexRateLimitWindow | null;
13
+ readonly credits?: {
14
+ readonly hasCredits: boolean;
15
+ readonly unlimited: boolean;
16
+ readonly balance: number | null;
17
+ };
18
+ readonly spendControlReached?: boolean | null;
19
+ readonly rateLimitReachedType?: string | null;
20
+ readonly availableResetCredits?: number;
21
+ }
22
+ export type CodexRateLimitRead = {
23
+ readonly ok: true;
24
+ readonly limit: CodexRateLimit;
25
+ } | {
26
+ readonly ok: false;
27
+ readonly reason: string;
28
+ };
29
+ /** Read the allow-listed rate-limit fields; identifiers and unknown fields never pass through. */
30
+ export declare function readCodexRateLimit(value: unknown): CodexRateLimitRead;
31
+ export declare function parseCodexRateLimit(value: unknown): CodexRateLimit | undefined;
32
+ export declare function codexRateLimitNotice(limit: CodexRateLimit): string;
@@ -0,0 +1,129 @@
1
+ function isObject(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+ function describe(value) {
5
+ if (value === null)
6
+ return 'null';
7
+ if (value === undefined)
8
+ return 'undefined';
9
+ if (typeof value === 'string')
10
+ return `string(${value.length})`;
11
+ if (typeof value === 'number' || typeof value === 'boolean')
12
+ return String(value);
13
+ if (Array.isArray(value))
14
+ return `array(${value.length})`;
15
+ return typeof value === 'object' ? 'object' : typeof value;
16
+ }
17
+ function refuse(path, expected, got) {
18
+ return { ok: false, reason: `codex_rate_limit: ${path} expected ${expected}, got ${describe(got)}` };
19
+ }
20
+ function finite(value, min) {
21
+ return typeof value === 'number' && Number.isFinite(value) && value >= min;
22
+ }
23
+ function readWindow(value, path) {
24
+ if (value === null)
25
+ return null;
26
+ if (!isObject(value))
27
+ return refuse(path, 'an object or null', value);
28
+ if (!finite(value.usedPercent, 0)) {
29
+ return refuse(`${path}.usedPercent`, 'finite number >= 0', value.usedPercent);
30
+ }
31
+ const windowDurationMins = value.windowDurationMins;
32
+ if (typeof windowDurationMins !== 'number' || !Number.isSafeInteger(windowDurationMins) || windowDurationMins < 1) {
33
+ return refuse(`${path}.windowDurationMins`, 'safe integer >= 1', value.windowDurationMins);
34
+ }
35
+ if (!finite(value.resetsAt, Number.MIN_VALUE)) {
36
+ return refuse(`${path}.resetsAt`, 'finite number > 0 (epoch seconds)', value.resetsAt);
37
+ }
38
+ const resetsAtMs = value.resetsAt * 1000;
39
+ if (!Number.isFinite(resetsAtMs) || resetsAtMs > 8.64e15) {
40
+ return refuse(`${path}.resetsAt`, 'valid epoch seconds representable as a JavaScript date', value.resetsAt);
41
+ }
42
+ return {
43
+ usedPercent: value.usedPercent,
44
+ windowDurationMins,
45
+ resetsAtMs,
46
+ };
47
+ }
48
+ /** Read the allow-listed rate-limit fields; identifiers and unknown fields never pass through. */
49
+ export function readCodexRateLimit(value) {
50
+ if (!isObject(value))
51
+ return refuse('payload', 'an object', value);
52
+ const outer = value;
53
+ const rates = isObject(value.rateLimits) ? value.rateLimits : value;
54
+ const ordinaryUsageAllowed = outer.ordinaryUsageAllowed ?? rates.ordinaryUsageAllowed ?? null;
55
+ if (ordinaryUsageAllowed !== null && typeof ordinaryUsageAllowed !== 'boolean') {
56
+ return refuse('ordinaryUsageAllowed', 'boolean or null', ordinaryUsageAllowed);
57
+ }
58
+ const primary = readWindow(rates.primary, 'rateLimits.primary');
59
+ if (primary !== null && 'ok' in primary && primary.ok === false)
60
+ return primary;
61
+ const secondary = readWindow(rates.secondary, 'rateLimits.secondary');
62
+ if (secondary !== null && 'ok' in secondary && secondary.ok === false)
63
+ return secondary;
64
+ let credits;
65
+ if (rates.credits !== undefined && rates.credits !== null) {
66
+ if (!isObject(rates.credits))
67
+ return refuse('rateLimits.credits', 'an object or null', rates.credits);
68
+ const { hasCredits, unlimited, balance } = rates.credits;
69
+ if (typeof hasCredits !== 'boolean') {
70
+ return refuse('rateLimits.credits.hasCredits', 'boolean', hasCredits);
71
+ }
72
+ if (typeof unlimited !== 'boolean') {
73
+ return refuse('rateLimits.credits.unlimited', 'boolean', unlimited);
74
+ }
75
+ if (balance !== undefined && balance !== null && !finite(balance, 0)) {
76
+ return refuse('rateLimits.credits.balance', 'finite number >= 0 or null', balance);
77
+ }
78
+ credits = { hasCredits, unlimited, balance: typeof balance === 'number' ? balance : null };
79
+ }
80
+ if (rates.planType !== undefined && typeof rates.planType !== 'string') {
81
+ return refuse('rateLimits.planType', 'string', rates.planType);
82
+ }
83
+ const planType = typeof rates.planType === 'string' ? rates.planType : undefined;
84
+ if (rates.spendControlReached !== undefined &&
85
+ rates.spendControlReached !== null &&
86
+ typeof rates.spendControlReached !== 'boolean') {
87
+ return refuse('rateLimits.spendControlReached', 'boolean or null', rates.spendControlReached);
88
+ }
89
+ const spendControlReached = typeof rates.spendControlReached === 'boolean' || rates.spendControlReached === null
90
+ ? rates.spendControlReached
91
+ : undefined;
92
+ if (rates.rateLimitReachedType !== undefined &&
93
+ rates.rateLimitReachedType !== null &&
94
+ typeof rates.rateLimitReachedType !== 'string') {
95
+ return refuse('rateLimits.rateLimitReachedType', 'string or null', rates.rateLimitReachedType);
96
+ }
97
+ const rateLimitReachedType = typeof rates.rateLimitReachedType === 'string' || rates.rateLimitReachedType === null
98
+ ? rates.rateLimitReachedType
99
+ : undefined;
100
+ const resetCredits = isObject(outer.rateLimitResetCredits)
101
+ ? outer.rateLimitResetCredits.availableCount
102
+ : undefined;
103
+ if (resetCredits !== undefined && (!Number.isSafeInteger(resetCredits) || resetCredits < 0)) {
104
+ return refuse('rateLimitResetCredits.availableCount', 'integer >= 0', resetCredits);
105
+ }
106
+ return {
107
+ ok: true,
108
+ limit: {
109
+ ordinaryUsageAllowed: ordinaryUsageAllowed,
110
+ ...(planType === undefined ? {} : { planType }),
111
+ primary: primary,
112
+ secondary: secondary,
113
+ ...(credits === undefined ? {} : { credits }),
114
+ ...(spendControlReached === undefined ? {} : { spendControlReached }),
115
+ ...(rateLimitReachedType === undefined ? {} : { rateLimitReachedType }),
116
+ ...(resetCredits === undefined ? {} : { availableResetCredits: resetCredits }),
117
+ },
118
+ };
119
+ }
120
+ export function parseCodexRateLimit(value) {
121
+ const result = readCodexRateLimit(value);
122
+ return result.ok ? result.limit : undefined;
123
+ }
124
+ export function codexRateLimitNotice(limit) {
125
+ const window = limit.primary;
126
+ if (window === null)
127
+ return 'Codex rate limit usage is unavailable.';
128
+ return `Codex rate limit: ${window.usedPercent}% used in ${window.windowDurationMins} minutes, resets ${new Date(window.resetsAtMs).toISOString()}.`;
129
+ }
@@ -0,0 +1,24 @@
1
+ export interface CodexTransportHandlers {
2
+ readonly onFrame: (frame: unknown) => void;
3
+ readonly onStderr: (line: string) => void;
4
+ readonly onProblem: (problem: string) => void;
5
+ readonly onExit: (code: number | null, signal: NodeJS.Signals | null) => void;
6
+ }
7
+ export interface CodexTransport {
8
+ start(): Promise<void>;
9
+ send(frame: unknown): void;
10
+ close(signal?: NodeJS.Signals): void;
11
+ }
12
+ export interface CodexTransportOptions {
13
+ readonly cwd: string;
14
+ readonly env: Readonly<Record<string, string>>;
15
+ readonly binary?: string;
16
+ }
17
+ /** Run App Server over its measured, newline-framed stdio JSON-RPC channel. */
18
+ export declare class StdioCodexTransport implements CodexTransport {
19
+ #private;
20
+ constructor(options: CodexTransportOptions, handlers: CodexTransportHandlers);
21
+ start(): Promise<void>;
22
+ send(frame: unknown): void;
23
+ close(signal?: NodeJS.Signals): void;
24
+ }
@@ -0,0 +1,90 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { NdjsonFramer, encodeLine } from '../ndjson.js';
3
+ /** Run App Server over its measured, newline-framed stdio JSON-RPC channel. */
4
+ export class StdioCodexTransport {
5
+ #options;
6
+ #handlers;
7
+ #framer = new NdjsonFramer();
8
+ #child = null;
9
+ #stderrBuffer = '';
10
+ #closed = false;
11
+ #stderrOverflowReported = false;
12
+ constructor(options, handlers) {
13
+ this.#options = options;
14
+ this.#handlers = handlers;
15
+ }
16
+ async start() {
17
+ if (this.#child !== null)
18
+ throw new Error('Codex transport already started');
19
+ const major = Number(process.versions.node.split('.')[0]);
20
+ if (!Number.isInteger(major) || major < 22) {
21
+ throw new Error('Codex app-server stdio transport requires Node.js 22 or newer');
22
+ }
23
+ const child = spawn(this.#options.binary ?? 'codex', ['app-server', '--listen', 'stdio://'], {
24
+ cwd: this.#options.cwd,
25
+ env: { ...this.#options.env },
26
+ shell: process.platform === 'win32',
27
+ stdio: ['pipe', 'pipe', 'pipe'],
28
+ });
29
+ this.#child = child;
30
+ child.stdout?.on('data', (chunk) => {
31
+ for (const frame of this.#framer.push(chunk)) {
32
+ if (frame.ok)
33
+ this.#handlers.onFrame(frame.value);
34
+ else
35
+ this.#handlers.onProblem(frame.error.message);
36
+ }
37
+ });
38
+ child.stderr?.setEncoding('utf8');
39
+ child.stderr?.on('data', (chunk) => this.#onStderr(chunk));
40
+ child.on('error', (cause) => this.#handlers.onProblem(`failed to spawn Codex (${cause.name})`));
41
+ child.on('close', (code, signal) => {
42
+ this.#closed = true;
43
+ for (const frame of this.#framer.end()) {
44
+ if (frame.ok)
45
+ this.#handlers.onFrame(frame.value);
46
+ else
47
+ this.#handlers.onProblem(frame.error.message);
48
+ }
49
+ this.#handlers.onExit(code, signal);
50
+ });
51
+ await new Promise((resolve, reject) => {
52
+ child.once('spawn', resolve);
53
+ child.once('error', (cause) => reject(new Error(`Codex app-server spawn failed (${cause.name})`)));
54
+ });
55
+ }
56
+ send(frame) {
57
+ const stdin = this.#child?.stdin;
58
+ if (stdin === null || stdin === undefined || stdin.destroyed || this.#closed) {
59
+ throw new Error('Codex app-server stdin is not open');
60
+ }
61
+ stdin.write(encodeLine(frame));
62
+ }
63
+ close(signal = 'SIGTERM') {
64
+ if (this.#closed)
65
+ return;
66
+ this.#closed = true;
67
+ this.#child?.stdin?.end();
68
+ this.#child?.kill(signal);
69
+ }
70
+ #onStderr(chunk) {
71
+ this.#stderrBuffer += chunk;
72
+ if (this.#stderrBuffer.length > 64_000) {
73
+ if (!this.#stderrOverflowReported) {
74
+ this.#stderrOverflowReported = true;
75
+ this.#handlers.onProblem('Codex stderr buffer exceeded 64000 characters');
76
+ }
77
+ this.#stderrBuffer = this.#stderrBuffer.slice(-64_000);
78
+ }
79
+ let newline;
80
+ while ((newline = this.#stderrBuffer.indexOf('\n')) !== -1) {
81
+ const line = this.#stderrBuffer.slice(0, newline).replace(/\r$/, '');
82
+ this.#stderrBuffer = this.#stderrBuffer.slice(newline + 1);
83
+ if (line.length > 0) {
84
+ // App Server stderr is not a public logging surface: provider output
85
+ // can contain account and rate-limit identifiers.
86
+ this.#handlers.onStderr(`Codex stderr line withheld (${line.length} characters)`);
87
+ }
88
+ }
89
+ }
90
+ }
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { BASE_ALLOW, OUTRANKING_CREDENTIALS, childEnv } from './env.js';
4
4
  export type { ChildEnvOptions, ChildEnvResult } from './env.js';
5
5
  export { JsonRpcPeer, RPC_INTERNAL_ERROR, RPC_INVALID_PARAMS, RPC_INVALID_REQUEST, RPC_METHOD_NOT_FOUND, RPC_PARSE_ERROR, RpcError, } from './jsonrpc.js';
6
6
  export type { JsonRpcPeerOptions, NotificationHandler, RequestHandler, RpcId, } from './jsonrpc.js';
7
- export { META_CLI_SESSION_ID, META_NS, META_RATE_LIMIT, META_THINKING_SIGNATURE, META_THINKING_TOKENS_ESTIMATE, META_UNMAPPED_FRAME, RESERVED_META_KEYS, metaKey, withMeta, } from './meta.js';
7
+ export { META_CLI_SESSION_ID, META_EXEC_POLICY_AMENDMENT, META_NS, META_RATE_LIMIT, META_THINKING_SIGNATURE, META_THINKING_TOKENS_ESTIMATE, META_UNMAPPED_FRAME, RESERVED_META_KEYS, metaKey, withMeta, } from './meta.js';
8
8
  export { ClaudeToAcp } from './claude/to-acp.js';
9
9
  export type { AcpUpdate, ToolStatus } from './claude/to-acp.js';
10
10
  export { parseRateLimit, rateLimitNotice, readRateLimit } from './claude/rate-limit.js';
@@ -12,10 +12,14 @@ export type { ClaudeRateLimit, ClaudeRateLimitWindow, RateLimitRead } from './cl
12
12
  export { ClaudeDriver, claudeArgs, promptLine, updatesFromFrames } from './claude/driver.js';
13
13
  export type { ClaudeDriverEvents, ClaudeDriverOptions, ClaudePermissionMode, } from './claude/driver.js';
14
14
  export { AcpAgent, PROTOCOL_VERSION } from './acp/agent.js';
15
- export type { AcpAgentOptions, AgentDriver, DriverEvents, DriverFactory, SessionProbe as AcpSessionProbe, } from './acp/agent.js';
15
+ export type { AcpAgentOptions, AgentDriver, DriverEvents, DriverFactory, PermissionOutcome, PermissionRequest, SessionProbe as AcpSessionProbe, } from './acp/agent.js';
16
16
  export { probeSessionStore } from './claude/session-store.js';
17
17
  export type { SessionExistence, SessionProbe, SessionStoreOptions } from './claude/session-store.js';
18
18
  export { serve } from './acp/stdio.js';
19
19
  export type { Served, ServeOptions } from './acp/stdio.js';
20
20
  export { cliSessionIdOf, turnOutcomeOf } from './claude/driver.js';
21
21
  export type { StopReason, TurnOutcome } from './claude/driver.js';
22
+ export { CodexDriver } from './codex/driver.js';
23
+ export type { CodexDriverOptions, CodexTransportFactory } from './codex/driver.js';
24
+ export { codexRateLimitNotice, parseCodexRateLimit, readCodexRateLimit } from './codex/rate-limit.js';
25
+ export type { CodexRateLimit, CodexRateLimitRead, CodexRateLimitWindow } from './codex/rate-limit.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { MAX_LINE_BYTES, NdjsonFramer, encodeLine, parseLine } from './ndjson.js';
2
2
  export { BASE_ALLOW, OUTRANKING_CREDENTIALS, childEnv } from './env.js';
3
3
  export { JsonRpcPeer, RPC_INTERNAL_ERROR, RPC_INVALID_PARAMS, RPC_INVALID_REQUEST, RPC_METHOD_NOT_FOUND, RPC_PARSE_ERROR, RpcError, } from './jsonrpc.js';
4
- export { META_CLI_SESSION_ID, META_NS, META_RATE_LIMIT, META_THINKING_SIGNATURE, META_THINKING_TOKENS_ESTIMATE, META_UNMAPPED_FRAME, RESERVED_META_KEYS, metaKey, withMeta, } from './meta.js';
4
+ export { META_CLI_SESSION_ID, META_EXEC_POLICY_AMENDMENT, META_NS, META_RATE_LIMIT, META_THINKING_SIGNATURE, META_THINKING_TOKENS_ESTIMATE, META_UNMAPPED_FRAME, RESERVED_META_KEYS, metaKey, withMeta, } from './meta.js';
5
5
  export { ClaudeToAcp } from './claude/to-acp.js';
6
6
  export { parseRateLimit, rateLimitNotice, readRateLimit } from './claude/rate-limit.js';
7
7
  export { ClaudeDriver, claudeArgs, promptLine, updatesFromFrames } from './claude/driver.js';
@@ -9,3 +9,5 @@ export { AcpAgent, PROTOCOL_VERSION } from './acp/agent.js';
9
9
  export { probeSessionStore } from './claude/session-store.js';
10
10
  export { serve } from './acp/stdio.js';
11
11
  export { cliSessionIdOf, turnOutcomeOf } from './claude/driver.js';
12
+ export { CodexDriver } from './codex/driver.js';
13
+ export { codexRateLimitNotice, parseCodexRateLimit, readCodexRateLimit } from './codex/rate-limit.js';
package/dist/meta.d.ts CHANGED
@@ -69,15 +69,18 @@ export declare const META_THINKING_SIGNATURE: string;
69
69
  export declare const META_THINKING_TOKENS_ESTIMATE: string;
70
70
  /** Structured rate-limit detail, alongside the human-readable notice. */
71
71
  export declare const META_RATE_LIMIT: string;
72
+ /** A command-line approval's persistent execpolicy amendment, tied to its ACP option. */
73
+ export declare const META_EXEC_POLICY_AMENDMENT: string;
72
74
  /** The CLI frame a mapping could not place, kept so nothing is lost unseen. */
73
75
  export declare const META_UNMAPPED_FRAME: string;
74
76
  /**
75
- * The CLI's OWN session id -- the only string `session/load` can resume with.
77
+ * The provider's OWN session id -- the identity `session/load` can resume with.
76
78
  *
77
79
  * This exists because its absence was a hole in the middle of resume. ACP's
78
- * `sessionId` is minted by this server; the CLI has a different id of its own,
79
- * a UUID, and `--resume` wants that one. The two were kept correctly separate
80
- * inside the driver and then **never handed to the client**, so a client that
80
+ * `sessionId` is minted by this server; providers have their own identities
81
+ * (Claude's CLI accepts a UUID or title, while Codex uses its App Server thread
82
+ * id). The two were kept correctly separate inside the driver and then
83
+ * **never handed to the client**, so a client that
81
84
  * stored the id `session/new` returned and passed it back to `session/load`
82
85
  * was passing a string the CLI cannot resume.
83
86
  *
package/dist/meta.js CHANGED
@@ -81,15 +81,18 @@ export const META_THINKING_SIGNATURE = metaKey('thinking_signature');
81
81
  export const META_THINKING_TOKENS_ESTIMATE = metaKey('thinking_tokens_estimate');
82
82
  /** Structured rate-limit detail, alongside the human-readable notice. */
83
83
  export const META_RATE_LIMIT = metaKey('rate_limit');
84
+ /** A command-line approval's persistent execpolicy amendment, tied to its ACP option. */
85
+ export const META_EXEC_POLICY_AMENDMENT = metaKey('execpolicy_amendment');
84
86
  /** The CLI frame a mapping could not place, kept so nothing is lost unseen. */
85
87
  export const META_UNMAPPED_FRAME = metaKey('unmapped_frame');
86
88
  /**
87
- * The CLI's OWN session id -- the only string `session/load` can resume with.
89
+ * The provider's OWN session id -- the identity `session/load` can resume with.
88
90
  *
89
91
  * This exists because its absence was a hole in the middle of resume. ACP's
90
- * `sessionId` is minted by this server; the CLI has a different id of its own,
91
- * a UUID, and `--resume` wants that one. The two were kept correctly separate
92
- * inside the driver and then **never handed to the client**, so a client that
92
+ * `sessionId` is minted by this server; providers have their own identities
93
+ * (Claude's CLI accepts a UUID or title, while Codex uses its App Server thread
94
+ * id). The two were kept correctly separate inside the driver and then
95
+ * **never handed to the client**, so a client that
93
96
  * stored the id `session/new` returned and passed it back to `session/load`
94
97
  * was passing a string the CLI cannot resume.
95
98
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/prism-acp",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Speak the Agent Client Protocol to a coding-agent CLI the user has already authenticated. No API key, no third-party adapter.",
5
5
  "license": "MIT",
6
6
  "repository": {