@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.
@@ -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;