@particle-academy/prism-acp 0.1.0 → 0.3.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 -21
- package/README.md +96 -2
- package/dist/acp/agent.js +67 -2
- package/dist/claude/rate-limit.d.ts +141 -0
- package/dist/claude/rate-limit.js +163 -0
- package/dist/claude/to-acp.js +46 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/meta.d.ts +19 -0
- package/dist/meta.js +19 -0
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Particle Academy
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Particle Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -109,6 +109,101 @@ implementations of one protocol disagree without anyone noticing. So:
|
|
|
109
109
|
line here can carry a prompt, a file or a credential, and a framing error is
|
|
110
110
|
not a reason to copy it into a log.
|
|
111
111
|
|
|
112
|
+
## Resuming a session: use the CLI's id, not ACP's
|
|
113
|
+
|
|
114
|
+
`session/load` works, and `initialize` reports `loadSession: true`. The trap is
|
|
115
|
+
which id to pass.
|
|
116
|
+
|
|
117
|
+
**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:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { META_CLI_SESSION_ID } from '@particle-academy/prism-acp';
|
|
124
|
+
|
|
125
|
+
// on the first session/update of a session
|
|
126
|
+
const cliSessionId = update._meta?.[META_CLI_SESSION_ID];
|
|
127
|
+
// store this against your own record -- it is what survives a restart
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Then after a crash, a kill, or a restart:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
session/load { sessionId: <the cli session id>, cwd: <absolute> }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Pass the ACP id instead and you get an error naming the key above, rather than a
|
|
137
|
+
success followed by a dead first prompt. That refusal exists because the CLI's
|
|
138
|
+
own complaint arrives one turn too late: it does error on an id it cannot
|
|
139
|
+
resume -- verified against claude 2.1.292 for both a non-UUID and a well-formed
|
|
140
|
+
UUID that does not exist, and it never silently starts a fresh conversation --
|
|
141
|
+
but by then `session/load` has already returned `{}` and you believe you have a
|
|
142
|
+
resumed session.
|
|
143
|
+
|
|
144
|
+
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.
|
|
148
|
+
|
|
149
|
+
## Rate limits are a gauge, not just a breach event
|
|
150
|
+
|
|
151
|
+
ACP has no field for a rate limit, so the detail rides in `_meta` under
|
|
152
|
+
`particle.academy/rate_limit` alongside a human-readable notice. It is a
|
|
153
|
+
**declared type**, not a passthrough:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { parseRateLimit, type ClaudeRateLimit } from '@particle-academy/prism-acp';
|
|
157
|
+
|
|
158
|
+
const limit: ClaudeRateLimit | undefined = parseRateLimit(payload);
|
|
159
|
+
const fiveHour = limit?.windows.five_hour;
|
|
160
|
+
|
|
161
|
+
if (fiveHour !== undefined) {
|
|
162
|
+
const remaining = Math.max(0, 1 - fiveHour.utilization);
|
|
163
|
+
console.log(`${Math.round(remaining * 100)}% left, resets ${new Date(fiveHour.resetsAtMs)}`);
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The frame arrives **mid-turn with `status: "allowed"`**, not only once you are
|
|
168
|
+
limited, and `utilization` moves as work is done — so remaining headroom is a
|
|
169
|
+
real reading rather than a feature invented to fill a panel.
|
|
170
|
+
|
|
171
|
+
Three things a consumer needs and cannot infer:
|
|
172
|
+
|
|
173
|
+
- **`resetsAt` is epoch SECONDS on the wire.** `resetsAtMs` is this package's,
|
|
174
|
+
converted once. Read the provider's field as milliseconds and every reset
|
|
175
|
+
time lands in January 1970.
|
|
176
|
+
- **`utilization` can exceed 1.** The frame models overage, so a window past
|
|
177
|
+
its allowance is a real state; the parse does not cap it, because a capped
|
|
178
|
+
figure would be one this package made up. Clamp where you draw the bar, next
|
|
179
|
+
to `isUsingOverage`.
|
|
180
|
+
- **`status` and `overageStatus` are open string unions.** Every frame captured
|
|
181
|
+
says `"allowed"`; no breached frame has ever been captured, so the breached
|
|
182
|
+
spelling is unknown. Test `status !== 'allowed'`, and never match a specific
|
|
183
|
+
breach value.
|
|
184
|
+
|
|
185
|
+
`parseRateLimit` returns `undefined` rather than a partial, and a payload it
|
|
186
|
+
does not recognise gets **no `rate_limit` key at all** — the frame goes to
|
|
187
|
+
`particle.academy/unmapped_frame` instead, **with the field that failed named**:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
rate_limit: unifiedWindows.five_hour.utilization expected finite number >= 0, got null
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
That reason is load-bearing precisely because the refusal is total: one bad
|
|
194
|
+
field rejects the whole payload, so this string is the only thing a human gets.
|
|
195
|
+
Generic would mean a bug report of "the gauge vanished" rather than "they
|
|
196
|
+
renamed `utilization`". `readRateLimit()` returns it to you directly
|
|
197
|
+
(`{ ok: true, limit } | { ok: false, reason }`) if you would rather handle the
|
|
198
|
+
refusal than check for `undefined`.
|
|
199
|
+
|
|
200
|
+
A string value is described as `string(10)`, never quoted. This mapper sits on
|
|
201
|
+
the same stream as prompts, file contents and credentials, and the type tells
|
|
202
|
+
you a number became a string just as well as the digits would. That is the whole reason it is a
|
|
203
|
+
parse and not an interface: an interface over `unknown` is a cast, so a renamed
|
|
204
|
+
provider field would still read as `undefined`, and a gauge renders `undefined`
|
|
205
|
+
as empty. An empty headroom gauge is read by a human as plenty of headroom.
|
|
206
|
+
|
|
112
207
|
## Using it
|
|
113
208
|
|
|
114
209
|
```ts
|
|
@@ -117,8 +212,7 @@ import { serve, ClaudeDriver } from '@particle-academy/prism-acp';
|
|
|
117
212
|
serve({
|
|
118
213
|
input: process.stdin,
|
|
119
214
|
output: process.stdout,
|
|
120
|
-
driverFactory: (options, events) =>
|
|
121
|
-
new ClaudeDriver({ cwd: options.cwd, ...options }, events),
|
|
215
|
+
driverFactory: (options, events) => new ClaudeDriver(options, events),
|
|
122
216
|
});
|
|
123
217
|
```
|
|
124
218
|
|
package/dist/acp/agent.js
CHANGED
|
@@ -22,13 +22,25 @@
|
|
|
22
22
|
* reported absent rather than optimistically.
|
|
23
23
|
*/
|
|
24
24
|
import { RPC_INVALID_PARAMS, RPC_INTERNAL_ERROR, RpcError } from '../jsonrpc.js';
|
|
25
|
+
import { META_CLI_SESSION_ID } from '../meta.js';
|
|
25
26
|
/** The protocol version this agent speaks. */
|
|
26
27
|
export const PROTOCOL_VERSION = 1;
|
|
28
|
+
/**
|
|
29
|
+
* The shape `#sessionNew` mints: `sess_<counter>_<epoch ms>`.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately narrow. A broader "does not look like a UUID" test would refuse
|
|
32
|
+
* a session TITLE, which the CLI accepts alongside a UUID -- so this refuses
|
|
33
|
+
* only the ids this server is known to have handed out and which provably
|
|
34
|
+
* cannot resume.
|
|
35
|
+
*/
|
|
36
|
+
const MINTED_SESSION_ID = /^sess_\d+_\d+$/;
|
|
27
37
|
export class AcpAgent {
|
|
28
38
|
#peer;
|
|
29
39
|
#options;
|
|
30
40
|
#sessions = new Map();
|
|
31
41
|
#counter = 0;
|
|
42
|
+
/** Ids this process handed out via session/new. See the refusal in #sessionLoad. */
|
|
43
|
+
#minted = new Set();
|
|
32
44
|
constructor(peer, options) {
|
|
33
45
|
this.#peer = peer;
|
|
34
46
|
this.#options = options;
|
|
@@ -95,6 +107,7 @@ export class AcpAgent {
|
|
|
95
107
|
#sessionNew(params) {
|
|
96
108
|
const cwd = requireAbsoluteCwd(params);
|
|
97
109
|
const id = this.#options.newSessionId?.() ?? `sess_${++this.#counter}_${Date.now()}`;
|
|
110
|
+
this.#minted.add(id);
|
|
98
111
|
this.#open(id, cwd, undefined);
|
|
99
112
|
return { sessionId: id };
|
|
100
113
|
}
|
|
@@ -108,8 +121,60 @@ export class AcpAgent {
|
|
|
108
121
|
// Resuming an id this server already has open would leave two processes
|
|
109
122
|
// writing updates for one session, and the second would look like the
|
|
110
123
|
// first stuttering.
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
// Refused only while the session is genuinely LIVE. A session whose agent
|
|
125
|
+
// process has exited is the main thing anyone resumes -- a crashed or
|
|
126
|
+
// killed agent, or one lost to a restart -- and refusing that as "already
|
|
127
|
+
// open" described the map rather than reality: an exited session is never
|
|
128
|
+
// removed from it, only flagged. The old check made the one case resume
|
|
129
|
+
// exists for the one case it rejected.
|
|
130
|
+
const existing = this.#sessions.get(sessionId);
|
|
131
|
+
if (existing !== undefined && !existing.exited) {
|
|
132
|
+
throw new RpcError(RPC_INVALID_PARAMS, `session ${sessionId} is already open and its agent is still running`);
|
|
133
|
+
}
|
|
134
|
+
// The same refusal, by the CLI's id rather than ACP's.
|
|
135
|
+
//
|
|
136
|
+
// The sessions map is keyed by the ACP session id, so a lookup for a CLI id
|
|
137
|
+
// never matched a session opened by `session/new` -- meaning a client
|
|
138
|
+
// resuming a conversation whose agent was STILL RUNNING got a second agent
|
|
139
|
+
// on the same conversation, with no collision reported. That was
|
|
140
|
+
// unreachable while nothing could obtain a CLI id to resume with, and
|
|
141
|
+
// publishing the id is exactly what makes it reachable. Fixing the one
|
|
142
|
+
// without the other would have traded a dead end for two processes writing
|
|
143
|
+
// updates for one conversation.
|
|
144
|
+
for (const session of this.#sessions.values()) {
|
|
145
|
+
if (!session.exited && session.driver?.cliSessionId === sessionId) {
|
|
146
|
+
throw new RpcError(RPC_INVALID_PARAMS, `session ${sessionId} is already open as ${session.id} and its agent is still running`);
|
|
147
|
+
}
|
|
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.
|
|
154
|
+
//
|
|
155
|
+
// Refused HERE rather than left to the CLI, even though the CLI does error
|
|
156
|
+
// on it (verified: "is not a UUID and does not match any session title",
|
|
157
|
+
// and "No conversation found with session ID" for a well-formed one that
|
|
158
|
+
// does not exist -- it never silently starts a fresh conversation). That
|
|
159
|
+
// error arrives when the FIRST PROMPT runs, by which point `session/load`
|
|
160
|
+
// has already returned success and the client believes it has a resumed
|
|
161
|
+
// session. Moving the refusal to the load makes the failure land where the
|
|
162
|
+
// mistake was made, and the message can say what to pass instead -- which
|
|
163
|
+
// the CLI's cannot, because the CLI has never heard of ACP.
|
|
164
|
+
// Two tests, because neither alone is enough. The PATTERN catches the
|
|
165
|
+
// default mint and survives a restart, which is the case that matters most
|
|
166
|
+
// -- but an embedder supplying its own `newSessionId` is not covered by it.
|
|
167
|
+
// The SET catches any id this process actually handed out, whatever its
|
|
168
|
+
// shape, and does not survive a restart.
|
|
169
|
+
//
|
|
170
|
+
// Nothing covers "an id minted by a previous process using an injected
|
|
171
|
+
// generator", and nothing can: this server cannot tell such a string from a
|
|
172
|
+
// CLI session title by inspection. That residue is exactly why the CLI's
|
|
173
|
+
// own id is published in `_meta` rather than left to be guessed at.
|
|
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. ` +
|
|
176
|
+
`Resume with the CLI's own session id, sent as '${META_CLI_SESSION_ID}' in the _meta of the first ` +
|
|
177
|
+
`session/update of the original session.`);
|
|
113
178
|
}
|
|
114
179
|
this.#open(sessionId, cwd, sessionId);
|
|
115
180
|
// The spec's result is an empty object; history arrives as session/update
|
|
@@ -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;
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
function isObject(value) {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
/** A finite number at or above `minimum`, or `undefined`. */
|
|
37
|
+
function finite(value, minimum) {
|
|
38
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= minimum
|
|
39
|
+
? value
|
|
40
|
+
: undefined;
|
|
41
|
+
}
|
|
42
|
+
function optionalString(value) {
|
|
43
|
+
return typeof value === 'string' ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Describe what arrived, WITHOUT quoting it.
|
|
47
|
+
*
|
|
48
|
+
* A number, boolean, null or undefined is reported as itself -- those are the
|
|
49
|
+
* cases that identify a shape change and none of them can carry content. A
|
|
50
|
+
* STRING is reported as its type and length only, because this module sits on
|
|
51
|
+
* the same stream as prompts, file contents and credentials, and this package
|
|
52
|
+
* already refuses to put a framing error's content in a log (see
|
|
53
|
+
* `MAX_LINE_BYTES` in the README). "expected finite number, got string(10)"
|
|
54
|
+
* identifies a provider switching a number to a string just as well as the
|
|
55
|
+
* digits would, and cannot leak anything if a future frame puts something else
|
|
56
|
+
* in that field.
|
|
57
|
+
*/
|
|
58
|
+
function describe(value) {
|
|
59
|
+
if (value === null)
|
|
60
|
+
return 'null';
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
return 'undefined';
|
|
63
|
+
if (typeof value === 'string')
|
|
64
|
+
return `string(${value.length})`;
|
|
65
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
66
|
+
return String(value);
|
|
67
|
+
if (Array.isArray(value))
|
|
68
|
+
return `array(${value.length})`;
|
|
69
|
+
if (typeof value === 'object')
|
|
70
|
+
return 'object';
|
|
71
|
+
return typeof value;
|
|
72
|
+
}
|
|
73
|
+
function refuse(path, expected, got) {
|
|
74
|
+
return { ok: false, reason: `rate_limit: ${path} expected ${expected}, got ${describe(got)}` };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Narrow an unknown rate-limit payload, or refuse it.
|
|
78
|
+
*
|
|
79
|
+
* Returns `undefined` rather than a partial value. A partial is the thing worth
|
|
80
|
+
* refusing hardest: a gauge built from half a payload looks like a reading.
|
|
81
|
+
*
|
|
82
|
+
* **One malformed window refuses the WHOLE payload.** Dropping the bad window
|
|
83
|
+
* and keeping the rest would mean a consumer whose `five_hour` figure went
|
|
84
|
+
* malformed silently renders the `seven_day` one in its place -- a healthy
|
|
85
|
+
* number, off the wrong window, with nothing to indicate the substitution.
|
|
86
|
+
*/
|
|
87
|
+
export function parseRateLimit(value) {
|
|
88
|
+
const read = readRateLimit(value);
|
|
89
|
+
return read.ok ? read.limit : undefined;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The same read, but saying WHY when it refuses.
|
|
93
|
+
*
|
|
94
|
+
* {@link parseRateLimit} is the convenience; this is what the mapper uses,
|
|
95
|
+
* because the mapper is what has to explain itself to a human.
|
|
96
|
+
*/
|
|
97
|
+
export function readRateLimit(value) {
|
|
98
|
+
if (!isObject(value))
|
|
99
|
+
return refuse('payload', 'an object', value);
|
|
100
|
+
const status = optionalString(value.status);
|
|
101
|
+
if (status === undefined)
|
|
102
|
+
return refuse('status', 'string', value.status);
|
|
103
|
+
const rateLimitType = optionalString(value.rateLimitType);
|
|
104
|
+
if (rateLimitType === undefined)
|
|
105
|
+
return refuse('rateLimitType', 'string', value.rateLimitType);
|
|
106
|
+
const resetsAtSeconds = finite(value.resetsAt, Number.MIN_VALUE);
|
|
107
|
+
if (resetsAtSeconds === undefined) {
|
|
108
|
+
return refuse('resetsAt', 'finite number > 0 (epoch seconds)', value.resetsAt);
|
|
109
|
+
}
|
|
110
|
+
// An absent `unifiedWindows` is refused, not treated as "no windows": it is
|
|
111
|
+
// the only part of this payload that answers "how much is left", so a
|
|
112
|
+
// consumer receiving a typed value without it would have a reset time and no
|
|
113
|
+
// gauge, which is the shape this type exists to stop being ambiguous.
|
|
114
|
+
if (!isObject(value.unifiedWindows)) {
|
|
115
|
+
return refuse('unifiedWindows', 'an object', value.unifiedWindows);
|
|
116
|
+
}
|
|
117
|
+
const windows = {};
|
|
118
|
+
for (const [name, window] of Object.entries(value.unifiedWindows)) {
|
|
119
|
+
if (!isObject(window))
|
|
120
|
+
return refuse(`unifiedWindows.${name}`, 'an object', window);
|
|
121
|
+
const utilization = finite(window.utilization, 0);
|
|
122
|
+
if (utilization === undefined) {
|
|
123
|
+
return refuse(`unifiedWindows.${name}.utilization`, 'finite number >= 0', window.utilization);
|
|
124
|
+
}
|
|
125
|
+
const windowResetsAtSeconds = finite(window.resetsAt, Number.MIN_VALUE);
|
|
126
|
+
if (windowResetsAtSeconds === undefined) {
|
|
127
|
+
return refuse(`unifiedWindows.${name}.resetsAt`, 'finite number > 0 (epoch seconds)', window.resetsAt);
|
|
128
|
+
}
|
|
129
|
+
windows[name] = { utilization, resetsAtMs: windowResetsAtSeconds * 1000 };
|
|
130
|
+
}
|
|
131
|
+
const isUsingOverage = typeof value.isUsingOverage === 'boolean' ? value.isUsingOverage : undefined;
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
limit: {
|
|
135
|
+
status,
|
|
136
|
+
resetsAtMs: resetsAtSeconds * 1000,
|
|
137
|
+
rateLimitType,
|
|
138
|
+
...(optionalString(value.overageStatus) === undefined
|
|
139
|
+
? {}
|
|
140
|
+
: { overageStatus: value.overageStatus }),
|
|
141
|
+
...(optionalString(value.overageDisabledReason) === undefined
|
|
142
|
+
? {}
|
|
143
|
+
: { overageDisabledReason: value.overageDisabledReason }),
|
|
144
|
+
...(isUsingOverage === undefined ? {} : { isUsingOverage }),
|
|
145
|
+
windows,
|
|
146
|
+
raw: value,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The sentence a human reads, built from the figures rather than from nothing.
|
|
152
|
+
*
|
|
153
|
+
* The notice this goes on used to say only "The provider reported a rate
|
|
154
|
+
* limit." -- true, and actionable by no one. The structured half is for a
|
|
155
|
+
* client; this half is for the person watching, and it should carry the two
|
|
156
|
+
* numbers they would otherwise have to open a debugger to see.
|
|
157
|
+
*/
|
|
158
|
+
export function rateLimitNotice(limit) {
|
|
159
|
+
const binding = limit.windows[limit.rateLimitType];
|
|
160
|
+
const resetsAtMs = binding?.resetsAtMs ?? limit.resetsAtMs;
|
|
161
|
+
const used = binding === undefined ? '' : ` at ${Math.round(binding.utilization * 100)}% used`;
|
|
162
|
+
return `Rate limit: ${limit.rateLimitType} window${used}, resets ${new Date(resetsAtMs).toISOString()}.`;
|
|
163
|
+
}
|
package/dist/claude/to-acp.js
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
* `{"index": 0, "delta": {...}}` and have no idea whether that is prose,
|
|
22
22
|
* reasoning, or the arguments to a command about to run.
|
|
23
23
|
*/
|
|
24
|
-
import { META_RATE_LIMIT, META_THINKING_SIGNATURE, META_THINKING_TOKENS_ESTIMATE, META_UNMAPPED_FRAME, withMeta, } from '../meta.js';
|
|
24
|
+
import { META_CLI_SESSION_ID, META_RATE_LIMIT, META_THINKING_SIGNATURE, META_THINKING_TOKENS_ESTIMATE, META_UNMAPPED_FRAME, withMeta, } from '../meta.js';
|
|
25
|
+
import { rateLimitNotice, readRateLimit } from './rate-limit.js';
|
|
25
26
|
/**
|
|
26
27
|
* ACP's ten tool kinds.
|
|
27
28
|
*
|
|
@@ -236,7 +237,24 @@ export class ClaudeToAcp {
|
|
|
236
237
|
return [
|
|
237
238
|
withMeta({ sessionUpdate: 'notice', notice: { level: 'debug', message: 'thinking' } }, { [META_THINKING_TOKENS_ESTIMATE]: input.estimated_tokens ?? null }),
|
|
238
239
|
];
|
|
239
|
-
case 'init':
|
|
240
|
+
case 'init': {
|
|
241
|
+
// The init frame carries the CLI's OWN session id, and that is the only
|
|
242
|
+
// string `--resume` accepts -- so it is the only string `session/load`
|
|
243
|
+
// can work with. It used to be recorded as unmapped, which meant the
|
|
244
|
+
// driver captured it, kept it correctly separate from ACP's sessionId,
|
|
245
|
+
// and then nothing handed it to the client. Resume was a method the
|
|
246
|
+
// client could not supply an argument for.
|
|
247
|
+
//
|
|
248
|
+
// Emitted on the FIRST frame of a session rather than at the end of a
|
|
249
|
+
// turn: a session that fails early still needs to be resumable, and a
|
|
250
|
+
// client cannot store what it was never sent.
|
|
251
|
+
const cliSessionId = asString(input.session_id);
|
|
252
|
+
if (cliSessionId === undefined)
|
|
253
|
+
return this.#unknown(input);
|
|
254
|
+
return [
|
|
255
|
+
withMeta({ sessionUpdate: 'notice', notice: { level: 'debug', message: 'session started' } }, { [META_CLI_SESSION_ID]: cliSessionId }),
|
|
256
|
+
];
|
|
257
|
+
}
|
|
240
258
|
case 'status':
|
|
241
259
|
case 'api_retry':
|
|
242
260
|
// Not mapped, but RECORDED. api_retry especially: it is how an
|
|
@@ -273,11 +291,35 @@ export class ClaudeToAcp {
|
|
|
273
291
|
#rateLimit(input) {
|
|
274
292
|
// A notice because it is genuinely user-facing, AND _meta so a client can
|
|
275
293
|
// act on the reset times rather than parse a sentence.
|
|
294
|
+
//
|
|
295
|
+
// The payload is NARROWED rather than passed through. A rename by the
|
|
296
|
+
// provider used to reach the consumer as a field it could not read, which
|
|
297
|
+
// a gauge renders as empty -- and an empty headroom gauge reads as plenty
|
|
298
|
+
// of headroom. So an unrecognised payload now emits no rate-limit value at
|
|
299
|
+
// all and keeps the frame under `unmapped_frame` instead: absent and
|
|
300
|
+
// explained, rather than zero and plausible.
|
|
301
|
+
//
|
|
302
|
+
// The refusal NAMES THE FIELD, because the refusal is total: one bad field
|
|
303
|
+
// rejects the whole payload, so this reason is the only thing a human gets.
|
|
304
|
+
// A generic "not recognised" would turn a bug report from "they renamed
|
|
305
|
+
// utilization" into "the gauge vanished", and someone diffing a frame by
|
|
306
|
+
// hand to tell the difference.
|
|
307
|
+
const read = readRateLimit(input.rate_limit_info ?? input);
|
|
308
|
+
if (!read.ok) {
|
|
309
|
+
return [
|
|
310
|
+
withMeta({
|
|
311
|
+
sessionUpdate: 'notice',
|
|
312
|
+
notice: { level: 'warning', message: 'The provider reported a rate limit.' },
|
|
313
|
+
}, {
|
|
314
|
+
[META_UNMAPPED_FRAME]: { reason: read.reason, frame: input },
|
|
315
|
+
}),
|
|
316
|
+
];
|
|
317
|
+
}
|
|
276
318
|
return [
|
|
277
319
|
withMeta({
|
|
278
320
|
sessionUpdate: 'notice',
|
|
279
|
-
notice: { level: 'warning', message:
|
|
280
|
-
}, { [META_RATE_LIMIT]:
|
|
321
|
+
notice: { level: 'warning', message: rateLimitNotice(read.limit) },
|
|
322
|
+
}, { [META_RATE_LIMIT]: read.limit }),
|
|
281
323
|
];
|
|
282
324
|
}
|
|
283
325
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -4,9 +4,11 @@ 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_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_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
|
+
export { parseRateLimit, rateLimitNotice, readRateLimit } from './claude/rate-limit.js';
|
|
11
|
+
export type { ClaudeRateLimit, ClaudeRateLimitWindow, RateLimitRead } from './claude/rate-limit.js';
|
|
10
12
|
export { ClaudeDriver, claudeArgs, promptLine, updatesFromFrames } from './claude/driver.js';
|
|
11
13
|
export type { ClaudeDriverEvents, ClaudeDriverOptions, ClaudePermissionMode, } from './claude/driver.js';
|
|
12
14
|
export { AcpAgent, PROTOCOL_VERSION } from './acp/agent.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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_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_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
|
+
export { parseRateLimit, rateLimitNotice, readRateLimit } from './claude/rate-limit.js';
|
|
6
7
|
export { ClaudeDriver, claudeArgs, promptLine, updatesFromFrames } from './claude/driver.js';
|
|
7
8
|
export { AcpAgent, PROTOCOL_VERSION } from './acp/agent.js';
|
|
8
9
|
export { serve } from './acp/stdio.js';
|
package/dist/meta.d.ts
CHANGED
|
@@ -71,6 +71,25 @@ export declare const META_THINKING_TOKENS_ESTIMATE: string;
|
|
|
71
71
|
export declare const META_RATE_LIMIT: string;
|
|
72
72
|
/** The CLI frame a mapping could not place, kept so nothing is lost unseen. */
|
|
73
73
|
export declare const META_UNMAPPED_FRAME: string;
|
|
74
|
+
/**
|
|
75
|
+
* The CLI's OWN session id -- the only string `session/load` can resume with.
|
|
76
|
+
*
|
|
77
|
+
* 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
|
|
81
|
+
* stored the id `session/new` returned and passed it back to `session/load`
|
|
82
|
+
* was passing a string the CLI cannot resume.
|
|
83
|
+
*
|
|
84
|
+
* ACP has no field for a provider's internal session id, which is precisely
|
|
85
|
+
* what `_meta` is for. It rides on the `init` frame's notice, so it is the
|
|
86
|
+
* first thing a client learns about a session -- a client that only reads it
|
|
87
|
+
* after the first turn would miss it on a session that fails early.
|
|
88
|
+
*
|
|
89
|
+
* Store it against your own record of the session. It is what you pass to
|
|
90
|
+
* `session/load` after a restart, and ACP's own `sessionId` is not.
|
|
91
|
+
*/
|
|
92
|
+
export declare const META_CLI_SESSION_ID: string;
|
|
74
93
|
/**
|
|
75
94
|
* Attach `_meta` entries to an ACP object without disturbing its own fields.
|
|
76
95
|
*
|
package/dist/meta.js
CHANGED
|
@@ -83,6 +83,25 @@ export const META_THINKING_TOKENS_ESTIMATE = metaKey('thinking_tokens_estimate')
|
|
|
83
83
|
export const META_RATE_LIMIT = metaKey('rate_limit');
|
|
84
84
|
/** The CLI frame a mapping could not place, kept so nothing is lost unseen. */
|
|
85
85
|
export const META_UNMAPPED_FRAME = metaKey('unmapped_frame');
|
|
86
|
+
/**
|
|
87
|
+
* The CLI's OWN session id -- the only string `session/load` can resume with.
|
|
88
|
+
*
|
|
89
|
+
* 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
|
|
93
|
+
* stored the id `session/new` returned and passed it back to `session/load`
|
|
94
|
+
* was passing a string the CLI cannot resume.
|
|
95
|
+
*
|
|
96
|
+
* ACP has no field for a provider's internal session id, which is precisely
|
|
97
|
+
* what `_meta` is for. It rides on the `init` frame's notice, so it is the
|
|
98
|
+
* first thing a client learns about a session -- a client that only reads it
|
|
99
|
+
* after the first turn would miss it on a session that fails early.
|
|
100
|
+
*
|
|
101
|
+
* Store it against your own record of the session. It is what you pass to
|
|
102
|
+
* `session/load` after a restart, and ACP's own `sessionId` is not.
|
|
103
|
+
*/
|
|
104
|
+
export const META_CLI_SESSION_ID = metaKey('cli_session_id');
|
|
86
105
|
/**
|
|
87
106
|
* Attach `_meta` entries to an ACP object without disturbing its own fields.
|
|
88
107
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@particle-academy/prism-acp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"engines": {
|
|
22
|
-
"node": ">=
|
|
22
|
+
"node": ">=22"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build": "tsc -p tsconfig.json",
|