@particle-academy/prism-acp 0.0.0-stage → 0.2.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 +196 -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/rate-limit.d.ts +141 -0
- package/dist/claude/rate-limit.js +163 -0
- package/dist/claude/to-acp.d.ts +19 -0
- package/dist/claude/to-acp.js +362 -0
- package/dist/env.d.ts +93 -0
- package/dist/env.js +153 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +10 -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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { NdjsonFramer } from '../ndjson.js';
|
|
2
|
+
import { type AcpUpdate, ClaudeToAcp } from './to-acp.js';
|
|
3
|
+
/** ACP permission modes map onto the CLI's own `--permission-mode` values. */
|
|
4
|
+
export type ClaudePermissionMode = 'acceptEdits' | 'auto' | 'bypassPermissions' | 'manual' | 'dontAsk' | 'plan';
|
|
5
|
+
export interface ClaudeDriverOptions {
|
|
6
|
+
/** Working directory for the agent. */
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
/** Binary to run. Overridable for tests and unusual installs. */
|
|
9
|
+
readonly binary?: string;
|
|
10
|
+
/** Parent environment to build the child's from. Defaults to `process.env`. */
|
|
11
|
+
readonly parentEnv?: Readonly<Record<string, string | undefined>>;
|
|
12
|
+
/** Extra environment names to pass through to the child. */
|
|
13
|
+
readonly allowEnv?: readonly string[];
|
|
14
|
+
/** Resume an existing CLI session instead of starting a new one. */
|
|
15
|
+
readonly resumeSessionId?: string;
|
|
16
|
+
readonly permissionMode?: ClaudePermissionMode;
|
|
17
|
+
/** Tool names the agent may use. Omitted means the CLI's own default. */
|
|
18
|
+
readonly allowedTools?: readonly string[];
|
|
19
|
+
readonly disallowedTools?: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export interface ClaudeDriverEvents {
|
|
22
|
+
/** One ACP `session/update` payload. */
|
|
23
|
+
readonly onUpdate?: (update: AcpUpdate) => void;
|
|
24
|
+
/**
|
|
25
|
+
* A line the CLI wrote to stderr.
|
|
26
|
+
*
|
|
27
|
+
* Surfaced rather than discarded, because the most valuable diagnostic this
|
|
28
|
+
* CLI produces arrives here and nowhere else:
|
|
29
|
+
*
|
|
30
|
+
* > claude.ai connectors are disabled because ANTHROPIC_API_KEY or another
|
|
31
|
+
* > auth source is set and takes precedence over your claude.ai login
|
|
32
|
+
*
|
|
33
|
+
* A driver that dropped stderr would turn the clearest possible explanation
|
|
34
|
+
* of a billing or auth problem into silence followed by a 401.
|
|
35
|
+
*/
|
|
36
|
+
readonly onStderr?: (line: string) => void;
|
|
37
|
+
/** A frame that arrived but could not be framed or parsed. */
|
|
38
|
+
readonly onProtocolError?: (problem: string) => void;
|
|
39
|
+
readonly onExit?: (code: number | null, signal: NodeJS.Signals | null) => void;
|
|
40
|
+
/**
|
|
41
|
+
* One turn finished.
|
|
42
|
+
*
|
|
43
|
+
* Separate from onExit because with `--input-format stream-json` the CLI
|
|
44
|
+
* stays alive ACROSS turns -- the process exiting and a turn ending are
|
|
45
|
+
* different events, and a server that conflated them would resolve every
|
|
46
|
+
* prompt only when the agent shut down.
|
|
47
|
+
*/
|
|
48
|
+
readonly onTurnEnd?: (outcome: TurnOutcome) => void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the CLI arguments.
|
|
52
|
+
*
|
|
53
|
+
* Every flag here is load-bearing and was verified against the binary rather
|
|
54
|
+
* than recalled:
|
|
55
|
+
*
|
|
56
|
+
* - `--print` with `--output-format stream-json` and `--input-format
|
|
57
|
+
* stream-json` gives a bidirectional structured stream, which is what makes
|
|
58
|
+
* this possible without a third-party adapter.
|
|
59
|
+
* - `--verbose` is REQUIRED for `stream-json` output; without it the CLI
|
|
60
|
+
* refuses the combination.
|
|
61
|
+
* - `--include-partial-messages` is what produces the deltas that become
|
|
62
|
+
* `agent_message_chunk` and `agent_thought_chunk`. Without it the reply
|
|
63
|
+
* arrives in one lump and the whole point of a streaming transport is lost.
|
|
64
|
+
*/
|
|
65
|
+
export declare function claudeArgs(options: ClaudeDriverOptions): string[];
|
|
66
|
+
/**
|
|
67
|
+
* Encode one user turn for `--input-format stream-json`.
|
|
68
|
+
*
|
|
69
|
+
* The CLI expects the same message envelope it emits, not a bare string.
|
|
70
|
+
*/
|
|
71
|
+
export declare function promptLine(text: string): string;
|
|
72
|
+
/** ACP's five stop reasons. No sixth, and no "unknown". */
|
|
73
|
+
export type StopReason = 'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled';
|
|
74
|
+
export interface TurnOutcome {
|
|
75
|
+
/** Null when the turn did not finish cleanly -- see {@link turnOutcomeOf}. */
|
|
76
|
+
readonly stopReason: StopReason | null;
|
|
77
|
+
readonly isError: boolean;
|
|
78
|
+
/** The CLI's own reason string, kept even when it maps to nothing. */
|
|
79
|
+
readonly raw: string | null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Read a turn's outcome from a `result` frame, or null if it is not one.
|
|
83
|
+
*
|
|
84
|
+
* `stopReason` comes back **null** when the CLI reported an error or a reason
|
|
85
|
+
* ACP has no literal for. That is deliberate and it is the whole reason this
|
|
86
|
+
* returns a structure rather than a string: ACP's five reasons all describe a
|
|
87
|
+
* turn that FINISHED, and none of them describes a crash. Answering `end_turn`
|
|
88
|
+
* for a failed turn would report a clean finish, and `refusal` would report a
|
|
89
|
+
* decision the agent never made. A caller that cannot name the stop reason
|
|
90
|
+
* should fail the request instead of inventing one.
|
|
91
|
+
*/
|
|
92
|
+
export declare function turnOutcomeOf(frame: unknown): TurnOutcome | null;
|
|
93
|
+
/**
|
|
94
|
+
* The CLI's own session id, from its `init` frame.
|
|
95
|
+
*
|
|
96
|
+
* Needed for `--resume`, which is how ACP's `session/load` is served. Captured
|
|
97
|
+
* from the stream rather than invented, because the two ids are not the same
|
|
98
|
+
* thing: ACP's sessionId is ours to choose, the CLI's is the CLI's.
|
|
99
|
+
*/
|
|
100
|
+
export declare function cliSessionIdOf(frame: unknown): string | null;
|
|
101
|
+
/**
|
|
102
|
+
* Turn framed output into ACP updates, separating what mapped from what failed
|
|
103
|
+
* to frame.
|
|
104
|
+
*
|
|
105
|
+
* Extracted from the driver so the wiring has a test that does not require
|
|
106
|
+
* spawning anything. The framer and the mapper are each covered thoroughly on
|
|
107
|
+
* their own, but "the framer's output reaches the mapper and both kinds of
|
|
108
|
+
* result reach the caller" is its own claim, and the only other place it could
|
|
109
|
+
* be checked is an opt-in live test that does not run in CI.
|
|
110
|
+
*/
|
|
111
|
+
export declare function updatesFromFrames(frames: readonly ReturnType<NdjsonFramer['push']>[number][], mapper: ClaudeToAcp): {
|
|
112
|
+
updates: AcpUpdate[];
|
|
113
|
+
problems: string[];
|
|
114
|
+
};
|
|
115
|
+
export declare class ClaudeDriver {
|
|
116
|
+
#private;
|
|
117
|
+
/** Names of credentials withheld from the child, available after start(). */
|
|
118
|
+
withheldCredentials: readonly string[];
|
|
119
|
+
/**
|
|
120
|
+
* The CLI's own session id, once it has announced one.
|
|
121
|
+
*
|
|
122
|
+
* Not the same thing as an ACP sessionId: that one is ours to choose, this
|
|
123
|
+
* one is the CLI's, and `--resume` wants the CLI's. Conflating them is how a
|
|
124
|
+
* resume silently starts a fresh conversation.
|
|
125
|
+
*/
|
|
126
|
+
cliSessionId: string | null;
|
|
127
|
+
constructor(options: ClaudeDriverOptions, events?: ClaudeDriverEvents);
|
|
128
|
+
/** Frames the mapper could not place. */
|
|
129
|
+
get unmapped(): readonly unknown[];
|
|
130
|
+
get running(): boolean;
|
|
131
|
+
start(): void;
|
|
132
|
+
/** Send a user turn. */
|
|
133
|
+
prompt(text: string): void;
|
|
134
|
+
/** Close stdin, which tells the CLI no more turns are coming. */
|
|
135
|
+
endInput(): void;
|
|
136
|
+
/** Stop the child. */
|
|
137
|
+
kill(signal?: NodeJS.Signals): void;
|
|
138
|
+
/** Everything the CLI has written to stderr so far. */
|
|
139
|
+
get stderr(): string;
|
|
140
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn the Claude CLI and turn its `stream-json` output into ACP updates.
|
|
3
|
+
*
|
|
4
|
+
* This is the piece that joins the others: {@link childEnv} builds the
|
|
5
|
+
* environment, {@link NdjsonFramer} splits the output, {@link ClaudeToAcp}
|
|
6
|
+
* translates it. Each of those is tested on its own, so what is left here is
|
|
7
|
+
* process handling -- which is the part that cannot be unit-tested, and
|
|
8
|
+
* therefore the part worth keeping small.
|
|
9
|
+
*
|
|
10
|
+
* The argv and the stdin encoding are exported as pure functions for exactly
|
|
11
|
+
* that reason. A driver whose flags could only be checked by spawning something
|
|
12
|
+
* would have its most mistake-prone surface covered by its least reliable test.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { childEnv } from '../env.js';
|
|
16
|
+
import { NdjsonFramer, encodeLine } from '../ndjson.js';
|
|
17
|
+
import { ClaudeToAcp } from './to-acp.js';
|
|
18
|
+
/**
|
|
19
|
+
* Build the CLI arguments.
|
|
20
|
+
*
|
|
21
|
+
* Every flag here is load-bearing and was verified against the binary rather
|
|
22
|
+
* than recalled:
|
|
23
|
+
*
|
|
24
|
+
* - `--print` with `--output-format stream-json` and `--input-format
|
|
25
|
+
* stream-json` gives a bidirectional structured stream, which is what makes
|
|
26
|
+
* this possible without a third-party adapter.
|
|
27
|
+
* - `--verbose` is REQUIRED for `stream-json` output; without it the CLI
|
|
28
|
+
* refuses the combination.
|
|
29
|
+
* - `--include-partial-messages` is what produces the deltas that become
|
|
30
|
+
* `agent_message_chunk` and `agent_thought_chunk`. Without it the reply
|
|
31
|
+
* arrives in one lump and the whole point of a streaming transport is lost.
|
|
32
|
+
*/
|
|
33
|
+
export function claudeArgs(options) {
|
|
34
|
+
const args = [
|
|
35
|
+
'--print',
|
|
36
|
+
'--output-format',
|
|
37
|
+
'stream-json',
|
|
38
|
+
'--input-format',
|
|
39
|
+
'stream-json',
|
|
40
|
+
'--verbose',
|
|
41
|
+
'--include-partial-messages',
|
|
42
|
+
];
|
|
43
|
+
if (options.resumeSessionId !== undefined)
|
|
44
|
+
args.push('--resume', options.resumeSessionId);
|
|
45
|
+
if (options.permissionMode !== undefined)
|
|
46
|
+
args.push('--permission-mode', options.permissionMode);
|
|
47
|
+
if (options.allowedTools !== undefined && options.allowedTools.length > 0) {
|
|
48
|
+
args.push('--allowed-tools', options.allowedTools.join(','));
|
|
49
|
+
}
|
|
50
|
+
if (options.disallowedTools !== undefined && options.disallowedTools.length > 0) {
|
|
51
|
+
args.push('--disallowed-tools', options.disallowedTools.join(','));
|
|
52
|
+
}
|
|
53
|
+
return args;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Encode one user turn for `--input-format stream-json`.
|
|
57
|
+
*
|
|
58
|
+
* The CLI expects the same message envelope it emits, not a bare string.
|
|
59
|
+
*/
|
|
60
|
+
export function promptLine(text) {
|
|
61
|
+
return encodeLine({
|
|
62
|
+
type: 'user',
|
|
63
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* ACP stop reasons the CLI reports under the same names.
|
|
68
|
+
*
|
|
69
|
+
* Shared vocabulary for the common cases, which is luck rather than design, so
|
|
70
|
+
* it is written as a map instead of passed through. A pass-through would import
|
|
71
|
+
* every future CLI value into a closed ACP enum silently.
|
|
72
|
+
*/
|
|
73
|
+
const STOP_REASONS = {
|
|
74
|
+
end_turn: 'end_turn',
|
|
75
|
+
max_tokens: 'max_tokens',
|
|
76
|
+
max_turn_requests: 'max_turn_requests',
|
|
77
|
+
refusal: 'refusal',
|
|
78
|
+
cancelled: 'cancelled',
|
|
79
|
+
canceled: 'cancelled',
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Read a turn's outcome from a `result` frame, or null if it is not one.
|
|
83
|
+
*
|
|
84
|
+
* `stopReason` comes back **null** when the CLI reported an error or a reason
|
|
85
|
+
* ACP has no literal for. That is deliberate and it is the whole reason this
|
|
86
|
+
* returns a structure rather than a string: ACP's five reasons all describe a
|
|
87
|
+
* turn that FINISHED, and none of them describes a crash. Answering `end_turn`
|
|
88
|
+
* for a failed turn would report a clean finish, and `refusal` would report a
|
|
89
|
+
* decision the agent never made. A caller that cannot name the stop reason
|
|
90
|
+
* should fail the request instead of inventing one.
|
|
91
|
+
*/
|
|
92
|
+
export function turnOutcomeOf(frame) {
|
|
93
|
+
if (!isObject(frame) || frame.type !== 'result')
|
|
94
|
+
return null;
|
|
95
|
+
const raw = typeof frame.stop_reason === 'string' ? frame.stop_reason : null;
|
|
96
|
+
const isError = frame.is_error === true || frame.subtype === 'error';
|
|
97
|
+
const mapped = raw === null ? undefined : STOP_REASONS[raw];
|
|
98
|
+
return { stopReason: isError || mapped === undefined ? null : mapped, isError, raw };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The CLI's own session id, from its `init` frame.
|
|
102
|
+
*
|
|
103
|
+
* Needed for `--resume`, which is how ACP's `session/load` is served. Captured
|
|
104
|
+
* from the stream rather than invented, because the two ids are not the same
|
|
105
|
+
* thing: ACP's sessionId is ours to choose, the CLI's is the CLI's.
|
|
106
|
+
*/
|
|
107
|
+
export function cliSessionIdOf(frame) {
|
|
108
|
+
if (!isObject(frame) || frame.type !== 'system' || frame.subtype !== 'init')
|
|
109
|
+
return null;
|
|
110
|
+
return typeof frame.session_id === 'string' ? frame.session_id : null;
|
|
111
|
+
}
|
|
112
|
+
function isObject(value) {
|
|
113
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Turn framed output into ACP updates, separating what mapped from what failed
|
|
117
|
+
* to frame.
|
|
118
|
+
*
|
|
119
|
+
* Extracted from the driver so the wiring has a test that does not require
|
|
120
|
+
* spawning anything. The framer and the mapper are each covered thoroughly on
|
|
121
|
+
* their own, but "the framer's output reaches the mapper and both kinds of
|
|
122
|
+
* result reach the caller" is its own claim, and the only other place it could
|
|
123
|
+
* be checked is an opt-in live test that does not run in CI.
|
|
124
|
+
*/
|
|
125
|
+
export function updatesFromFrames(frames, mapper) {
|
|
126
|
+
const updates = [];
|
|
127
|
+
const problems = [];
|
|
128
|
+
for (const frame of frames) {
|
|
129
|
+
if (!frame.ok) {
|
|
130
|
+
problems.push(frame.error.message);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
updates.push(...mapper.frame(frame.value));
|
|
134
|
+
}
|
|
135
|
+
return { updates, problems };
|
|
136
|
+
}
|
|
137
|
+
export class ClaudeDriver {
|
|
138
|
+
#options;
|
|
139
|
+
#events;
|
|
140
|
+
#framer = new NdjsonFramer();
|
|
141
|
+
#mapper = new ClaudeToAcp();
|
|
142
|
+
#child = null;
|
|
143
|
+
#stderr = '';
|
|
144
|
+
#pendingOutcome = null;
|
|
145
|
+
/** Names of credentials withheld from the child, available after start(). */
|
|
146
|
+
withheldCredentials = [];
|
|
147
|
+
/**
|
|
148
|
+
* The CLI's own session id, once it has announced one.
|
|
149
|
+
*
|
|
150
|
+
* Not the same thing as an ACP sessionId: that one is ours to choose, this
|
|
151
|
+
* one is the CLI's, and `--resume` wants the CLI's. Conflating them is how a
|
|
152
|
+
* resume silently starts a fresh conversation.
|
|
153
|
+
*/
|
|
154
|
+
cliSessionId = null;
|
|
155
|
+
constructor(options, events = {}) {
|
|
156
|
+
this.#options = options;
|
|
157
|
+
this.#events = events;
|
|
158
|
+
}
|
|
159
|
+
/** Frames the mapper could not place. */
|
|
160
|
+
get unmapped() {
|
|
161
|
+
return this.#mapper.unmapped;
|
|
162
|
+
}
|
|
163
|
+
get running() {
|
|
164
|
+
return this.#child !== null && this.#child.exitCode === null;
|
|
165
|
+
}
|
|
166
|
+
start() {
|
|
167
|
+
if (this.#child !== null)
|
|
168
|
+
throw new Error('driver already started');
|
|
169
|
+
const { env, withheld } = childEnv(this.#options.parentEnv ?? process.env, {
|
|
170
|
+
allow: [
|
|
171
|
+
// The CLI finds the user's own login through these, so they are allowed
|
|
172
|
+
// explicitly rather than inherited wholesale.
|
|
173
|
+
'CLAUDE_CONFIG_DIR',
|
|
174
|
+
'XDG_CONFIG_HOME',
|
|
175
|
+
...(this.#options.allowEnv ?? []),
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
this.withheldCredentials = withheld;
|
|
179
|
+
const child = spawn(this.#options.binary ?? 'claude', claudeArgs(this.#options), {
|
|
180
|
+
cwd: this.#options.cwd,
|
|
181
|
+
env,
|
|
182
|
+
// On Windows the binary is a `.cmd` shim, which cannot be executed
|
|
183
|
+
// directly. The shim re-entering whatever `node` is on PATH is a real
|
|
184
|
+
// hazard for a JS entry point; for the CLI itself the shim IS the
|
|
185
|
+
// documented entry, so the shell is the right way to reach it.
|
|
186
|
+
shell: process.platform === 'win32',
|
|
187
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
188
|
+
});
|
|
189
|
+
this.#child = child;
|
|
190
|
+
child.stdout?.on('data', (chunk) => this.#onStdout(chunk));
|
|
191
|
+
child.stderr?.setEncoding('utf8');
|
|
192
|
+
child.stderr?.on('data', (chunk) => this.#onStderr(chunk));
|
|
193
|
+
child.on('error', (cause) => {
|
|
194
|
+
// A spawn failure (ENOENT for a missing binary) arrives here and nowhere
|
|
195
|
+
// else. Reported as a protocol error so a caller cannot mistake "the CLI
|
|
196
|
+
// is not installed" for "the agent said nothing".
|
|
197
|
+
this.#events.onProtocolError?.(`failed to spawn: ${cause.message}`);
|
|
198
|
+
});
|
|
199
|
+
child.on('close', (code, signal) => {
|
|
200
|
+
// Flush first: a frame can be complete without its trailing newline, and
|
|
201
|
+
// the LAST frame of a turn is the `result` that carries the outcome.
|
|
202
|
+
this.#emit(this.#framer.end());
|
|
203
|
+
this.#events.onExit?.(code, signal);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** Send a user turn. */
|
|
207
|
+
prompt(text) {
|
|
208
|
+
const stdin = this.#child?.stdin;
|
|
209
|
+
if (stdin === null || stdin === undefined) {
|
|
210
|
+
throw new Error('driver is not started, or its stdin has closed');
|
|
211
|
+
}
|
|
212
|
+
stdin.write(promptLine(text));
|
|
213
|
+
}
|
|
214
|
+
/** Close stdin, which tells the CLI no more turns are coming. */
|
|
215
|
+
endInput() {
|
|
216
|
+
this.#child?.stdin?.end();
|
|
217
|
+
}
|
|
218
|
+
/** Stop the child. */
|
|
219
|
+
kill(signal = 'SIGTERM') {
|
|
220
|
+
this.#child?.kill(signal);
|
|
221
|
+
}
|
|
222
|
+
/** Everything the CLI has written to stderr so far. */
|
|
223
|
+
get stderr() {
|
|
224
|
+
return this.#stderr;
|
|
225
|
+
}
|
|
226
|
+
#onStdout(chunk) {
|
|
227
|
+
this.#emit(this.#framer.push(chunk));
|
|
228
|
+
}
|
|
229
|
+
#emit(frames) {
|
|
230
|
+
// Raw frames are inspected for turn boundaries and the CLI's session id
|
|
231
|
+
// BEFORE mapping, because neither survives translation: the `result` frame
|
|
232
|
+
// becomes a usage_update and the `init` frame maps to nothing at all.
|
|
233
|
+
for (const frame of frames) {
|
|
234
|
+
if (!frame.ok)
|
|
235
|
+
continue;
|
|
236
|
+
const cliSessionId = cliSessionIdOf(frame.value);
|
|
237
|
+
if (cliSessionId !== null)
|
|
238
|
+
this.cliSessionId = cliSessionId;
|
|
239
|
+
const outcome = turnOutcomeOf(frame.value);
|
|
240
|
+
if (outcome !== null)
|
|
241
|
+
this.#pendingOutcome = outcome;
|
|
242
|
+
}
|
|
243
|
+
const { updates, problems } = updatesFromFrames(frames, this.#mapper);
|
|
244
|
+
for (const problem of problems)
|
|
245
|
+
this.#events.onProtocolError?.(problem);
|
|
246
|
+
for (const update of updates)
|
|
247
|
+
this.#events.onUpdate?.(update);
|
|
248
|
+
// Fired AFTER the updates, so a caller resolving a turn on this signal has
|
|
249
|
+
// already received everything the turn produced -- including the
|
|
250
|
+
// usage_update that the same `result` frame generates. Firing first would
|
|
251
|
+
// resolve session/prompt before its own usage arrived.
|
|
252
|
+
if (this.#pendingOutcome !== null) {
|
|
253
|
+
const outcome = this.#pendingOutcome;
|
|
254
|
+
this.#pendingOutcome = null;
|
|
255
|
+
this.#events.onTurnEnd?.(outcome);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
#onStderr(chunk) {
|
|
259
|
+
this.#stderr += chunk;
|
|
260
|
+
// Line-wise, so a caller can match on a whole message rather than on
|
|
261
|
+
// whatever happened to land in one chunk.
|
|
262
|
+
const lines = chunk.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
263
|
+
for (const line of lines)
|
|
264
|
+
this.#events.onStderr?.(line);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rate-limit payload the Claude CLI reports, declared and narrowed.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a parse and not just an interface
|
|
5
|
+
*
|
|
6
|
+
* This shipped as `input.rate_limit_info ?? input` -- passed through verbatim,
|
|
7
|
+
* with nothing in the `.d.ts` naming a field. A consumer building a headroom
|
|
8
|
+
* gauge then has to guess the provider's field names off a captured frame, and
|
|
9
|
+
* the failure mode when the provider renames one is the worst available: the
|
|
10
|
+
* read yields `undefined`, the gauge renders empty, and a human reads an empty
|
|
11
|
+
* gauge as PLENTY OF HEADROOM. A wrong answer delivered confidently.
|
|
12
|
+
*
|
|
13
|
+
* An interface alone does not fix that. The payload crosses a pipe as JSON, so
|
|
14
|
+
* it arrives as `unknown`, and an interface over `unknown` is a cast: a rename
|
|
15
|
+
* still produces the same silent `undefined`, only now with a type annotation
|
|
16
|
+
* standing behind it. What makes a rename LOUD is {@link parseRateLimit}
|
|
17
|
+
* refusing the shape, so the mapper can emit no gauge at all and say what it
|
|
18
|
+
* actually received instead. Absent and explained beats zero and plausible.
|
|
19
|
+
*
|
|
20
|
+
* ## Where the shape came from
|
|
21
|
+
*
|
|
22
|
+
* Three independently captured turns in `test/fixtures`, which agree on every
|
|
23
|
+
* key. Nothing here is inferred from a schema, because no schema for this frame
|
|
24
|
+
* was available -- the same provenance rule as the rest of this mapper.
|
|
25
|
+
*
|
|
26
|
+
* ## What the captures do NOT tell us
|
|
27
|
+
*
|
|
28
|
+
* Every captured frame says `status: "allowed"`. **No breached frame has ever
|
|
29
|
+
* been captured**, so how a breach is spelled is genuinely unknown, and so are
|
|
30
|
+
* the `overageStatus` values beyond `"rejected"`. Those stay open string unions
|
|
31
|
+
* on purpose -- see {@link ClaudeRateLimit.status}.
|
|
32
|
+
*/
|
|
33
|
+
/** One rate-limit window, as the provider reports it. */
|
|
34
|
+
export interface ClaudeRateLimitWindow {
|
|
35
|
+
/**
|
|
36
|
+
* The fraction of this window consumed -- `0.12` is 12% used.
|
|
37
|
+
*
|
|
38
|
+
* **This can exceed 1.** The frame models overage (`isUsingOverage`,
|
|
39
|
+
* `overageStatus`), so a window consumed past its allowance is a real state
|
|
40
|
+
* and not a corrupt reading. {@link parseRateLimit} therefore accepts any
|
|
41
|
+
* finite value `>= 0` and does NOT cap it at 1: a capped figure would be a
|
|
42
|
+
* number this package made up. Clamp for a progress bar if you like, but
|
|
43
|
+
* clamp at the point of display, where a reader can also see
|
|
44
|
+
* `isUsingOverage`.
|
|
45
|
+
*/
|
|
46
|
+
readonly utilization: number;
|
|
47
|
+
/** When this window resets, in epoch MILLISECONDS. See {@link ClaudeRateLimit.resetsAtMs}. */
|
|
48
|
+
readonly resetsAtMs: number;
|
|
49
|
+
}
|
|
50
|
+
/** The structured rate-limit detail carried under `particle.academy/rate_limit`. */
|
|
51
|
+
export interface ClaudeRateLimit {
|
|
52
|
+
/**
|
|
53
|
+
* `"allowed"` in every frame captured so far.
|
|
54
|
+
*
|
|
55
|
+
* The union is OPEN (`'allowed' | (string & {})`) deliberately, which keeps
|
|
56
|
+
* the known literal in autocomplete while accepting any string. A closed
|
|
57
|
+
* union would be the same silent-failure class inverted: it would break a
|
|
58
|
+
* consumer's BUILD the first time a real breach arrived, which is worse than
|
|
59
|
+
* the problem it was guarding against. Test `status !== 'allowed'` for "not
|
|
60
|
+
* allowed"; never match a specific breach spelling, because nobody here has
|
|
61
|
+
* seen one.
|
|
62
|
+
*/
|
|
63
|
+
readonly status: 'allowed' | (string & {});
|
|
64
|
+
/**
|
|
65
|
+
* When the binding window resets, in epoch MILLISECONDS.
|
|
66
|
+
*
|
|
67
|
+
* **The provider sends SECONDS**, in a field named `resetsAt` that gives no
|
|
68
|
+
* hint of its unit. This field is named for its unit and converted exactly
|
|
69
|
+
* once, here, because the alternative is every consumer deciding
|
|
70
|
+
* independently and one of them rendering January 1970.
|
|
71
|
+
*
|
|
72
|
+
* No seconds-vs-milliseconds heuristic is applied. A range sniff would
|
|
73
|
+
* silently absorb a unit change by the provider; the conversion is
|
|
74
|
+
* unconditional so `test/rate-limit.test.ts` fails instead -- it pins the
|
|
75
|
+
* captured values to their real dates.
|
|
76
|
+
*/
|
|
77
|
+
readonly resetsAtMs: number;
|
|
78
|
+
/** Which window the provider currently treats as binding, e.g. `"five_hour"`. */
|
|
79
|
+
readonly rateLimitType: string;
|
|
80
|
+
/** Open union for the same reason as {@link ClaudeRateLimit.status}: only `"rejected"` has been seen. */
|
|
81
|
+
readonly overageStatus?: string;
|
|
82
|
+
readonly overageDisabledReason?: string;
|
|
83
|
+
readonly isUsingOverage?: boolean;
|
|
84
|
+
/** Every window the frame reported, keyed as the provider keys them (`five_hour`, `seven_day`). */
|
|
85
|
+
readonly windows: Readonly<Record<string, ClaudeRateLimitWindow>>;
|
|
86
|
+
/**
|
|
87
|
+
* The provider's object, verbatim.
|
|
88
|
+
*
|
|
89
|
+
* Kept ON the typed value rather than beside it, so one `_meta` key always
|
|
90
|
+
* carries both views. It covers the case a refusal cannot: a field the
|
|
91
|
+
* provider ADDS still parses, and would otherwise be dropped by a type that
|
|
92
|
+
* does not know about it yet. `src/meta.ts` is explicit that nothing is
|
|
93
|
+
* silently dropped, and a narrowing parse is exactly where that rule would
|
|
94
|
+
* otherwise be quietly broken.
|
|
95
|
+
*/
|
|
96
|
+
readonly raw: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The result of reading a payload: the value, or WHY it was refused.
|
|
100
|
+
*
|
|
101
|
+
* The reason exists because the refusal is total. Since one bad field rejects
|
|
102
|
+
* the whole payload, the explanation is the ONLY thing a human gets when the
|
|
103
|
+
* provider changes shape -- so "not recognised" would turn a bug report into
|
|
104
|
+
* somebody diffing a frame by hand. Raised by prism-acp's first external
|
|
105
|
+
* reviewer, against this exact design.
|
|
106
|
+
*/
|
|
107
|
+
export type RateLimitRead = {
|
|
108
|
+
readonly ok: true;
|
|
109
|
+
readonly limit: ClaudeRateLimit;
|
|
110
|
+
} | {
|
|
111
|
+
readonly ok: false;
|
|
112
|
+
readonly reason: string;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Narrow an unknown rate-limit payload, or refuse it.
|
|
116
|
+
*
|
|
117
|
+
* Returns `undefined` rather than a partial value. A partial is the thing worth
|
|
118
|
+
* refusing hardest: a gauge built from half a payload looks like a reading.
|
|
119
|
+
*
|
|
120
|
+
* **One malformed window refuses the WHOLE payload.** Dropping the bad window
|
|
121
|
+
* and keeping the rest would mean a consumer whose `five_hour` figure went
|
|
122
|
+
* malformed silently renders the `seven_day` one in its place -- a healthy
|
|
123
|
+
* number, off the wrong window, with nothing to indicate the substitution.
|
|
124
|
+
*/
|
|
125
|
+
export declare function parseRateLimit(value: unknown): ClaudeRateLimit | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* The same read, but saying WHY when it refuses.
|
|
128
|
+
*
|
|
129
|
+
* {@link parseRateLimit} is the convenience; this is what the mapper uses,
|
|
130
|
+
* because the mapper is what has to explain itself to a human.
|
|
131
|
+
*/
|
|
132
|
+
export declare function readRateLimit(value: unknown): RateLimitRead;
|
|
133
|
+
/**
|
|
134
|
+
* The sentence a human reads, built from the figures rather than from nothing.
|
|
135
|
+
*
|
|
136
|
+
* The notice this goes on used to say only "The provider reported a rate
|
|
137
|
+
* limit." -- true, and actionable by no one. The structured half is for a
|
|
138
|
+
* client; this half is for the person watching, and it should carry the two
|
|
139
|
+
* numbers they would otherwise have to open a debugger to see.
|
|
140
|
+
*/
|
|
141
|
+
export declare function rateLimitNotice(limit: ClaudeRateLimit): string;
|