@particle-academy/prism-acp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/acp/agent.d.ts +14 -0
- package/dist/acp/agent.js +17 -0
- package/dist/claude/session-store.d.ts +19 -0
- package/dist/claude/session-store.js +87 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -146,6 +146,37 @@ running** is refused, by either id. No history is replayed on load, because the
|
|
|
146
146
|
CLI replays none -- `session/load` returning `{}` with no `session/update`
|
|
147
147
|
notifications is the honest report of that, not an omission.
|
|
148
148
|
|
|
149
|
+
### Refusing an unknown id at load, not a turn later
|
|
150
|
+
|
|
151
|
+
Pass `probeSession` and an id naming **no** conversation is refused by
|
|
152
|
+
`session/load` itself, instead of starting an agent that fails on its first
|
|
153
|
+
prompt:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { probeSessionStore } from '@particle-academy/prism-acp';
|
|
157
|
+
|
|
158
|
+
const probeSession = (sessionId: string) => probeSessionStore(sessionId);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Pass that to `serve` beside `driverFactory` — see [Using it](#using-it).
|
|
162
|
+
|
|
163
|
+
It costs a directory listing, not a turn. The obvious probe -- resuming with a
|
|
164
|
+
throwaway prompt -- refuses a bad id for free but **answers** a good one, so it
|
|
165
|
+
would spend a real turn on every successful load.
|
|
166
|
+
|
|
167
|
+
**It can decline to answer, and that matters more than the refusal.** The result
|
|
168
|
+
is `present`, `absent` or `indeterminate`, and only `absent` refuses. The store's
|
|
169
|
+
layout is undocumented, so a store the probe cannot read -- a relocated home, a
|
|
170
|
+
permissions problem, a future CLI version -- reports `indeterminate` and the load
|
|
171
|
+
proceeds exactly as it did before. Calling a real session absent would refuse a
|
|
172
|
+
resume that would have worked, which is worse than the late error this replaces;
|
|
173
|
+
that error is still there as the backstop.
|
|
174
|
+
|
|
175
|
+
`probeSession` is yours to supply because the answer belongs to the agent being
|
|
176
|
+
driven, not to ACP: `probeSessionStore` reads claude's session store, and a Codex
|
|
177
|
+
driver would resolve the same question through `thread/resume`. Omit it and
|
|
178
|
+
`session/load` behaves as it always did.
|
|
179
|
+
|
|
149
180
|
## Rate limits are a gauge, not just a breach event
|
|
150
181
|
|
|
151
182
|
ACP has no field for a rate limit, so the detail rides in `_meta` under
|
|
@@ -213,6 +244,7 @@ serve({
|
|
|
213
244
|
input: process.stdin,
|
|
214
245
|
output: process.stdout,
|
|
215
246
|
driverFactory: (options, events) => new ClaudeDriver(options, events),
|
|
247
|
+
probeSession,
|
|
216
248
|
});
|
|
217
249
|
```
|
|
218
250
|
|
package/dist/acp/agent.d.ts
CHANGED
|
@@ -54,8 +54,22 @@ export type DriverFactory = (options: {
|
|
|
54
54
|
readonly cwd: string;
|
|
55
55
|
readonly resumeSessionId?: string;
|
|
56
56
|
}, events: DriverEvents) => AgentDriver;
|
|
57
|
+
/**
|
|
58
|
+
* Whether a session id names a conversation that can be resumed.
|
|
59
|
+
*
|
|
60
|
+
* Injected, and for two reasons. Tests must not read the developer's real
|
|
61
|
+
* session store; and the answer is a property of the AGENT being driven, not of
|
|
62
|
+
* ACP -- a Codex driver resolves it through `thread/resume`, not through
|
|
63
|
+
* claude's `~/.claude/projects` layout. Omit it and `session/load` behaves as it
|
|
64
|
+
* did before: it accepts the id and the agent reports the problem later.
|
|
65
|
+
*/
|
|
66
|
+
export type SessionProbe = (sessionId: string) => {
|
|
67
|
+
readonly existence: 'present' | 'absent' | 'indeterminate';
|
|
68
|
+
readonly detail: string;
|
|
69
|
+
};
|
|
57
70
|
export interface AcpAgentOptions {
|
|
58
71
|
readonly driverFactory: DriverFactory;
|
|
72
|
+
readonly probeSession?: SessionProbe;
|
|
59
73
|
readonly agentInfo?: {
|
|
60
74
|
readonly name: string;
|
|
61
75
|
readonly title?: string;
|
package/dist/acp/agent.js
CHANGED
|
@@ -176,6 +176,23 @@ export class AcpAgent {
|
|
|
176
176
|
`Resume with the CLI's own session id, sent as '${META_CLI_SESSION_ID}' in the _meta of the first ` +
|
|
177
177
|
`session/update of the original session.`);
|
|
178
178
|
}
|
|
179
|
+
// REFUSE an id that names no conversation, here rather than a turn later.
|
|
180
|
+
//
|
|
181
|
+
// Without this the agent starts, `session/load` returns success, and the
|
|
182
|
+
// CLI's "No conversation found with session ID" arrives when the first
|
|
183
|
+
// prompt runs -- a real error, but one a client cannot tell from any other
|
|
184
|
+
// late failure, and one that lands after it has been told it holds a
|
|
185
|
+
// resumed session. A consumer reported this as the only thing standing
|
|
186
|
+
// between a bad id and a lost conversation.
|
|
187
|
+
//
|
|
188
|
+
// `indeterminate` deliberately PROCEEDS. The probe reads a store whose
|
|
189
|
+
// layout is undocumented, so a store it cannot read must not be allowed to
|
|
190
|
+
// refuse a resume that would have worked; the late error is still there as
|
|
191
|
+
// the backstop it always was. Only a positive `absent` refuses.
|
|
192
|
+
const probe = this.#options.probeSession?.(sessionId);
|
|
193
|
+
if (probe?.existence === 'absent') {
|
|
194
|
+
throw new RpcError(RPC_INVALID_PARAMS, `cannot resume ${sessionId}: ${probe.detail}`);
|
|
195
|
+
}
|
|
179
196
|
this.#open(sessionId, cwd, sessionId);
|
|
180
197
|
// The spec's result is an empty object; history arrives as session/update
|
|
181
198
|
// notifications. We send none, because the CLI replays nothing on --resume
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three states, and the third is the point.
|
|
3
|
+
*
|
|
4
|
+
* `indeterminate` means the store could not be read -- a different CLI version,
|
|
5
|
+
* a relocated home, a permissions problem, a layout change. It must never be
|
|
6
|
+
* treated as `absent`: this check exists to turn a late failure into an early
|
|
7
|
+
* one, and it is not worth refusing a resume that would have worked.
|
|
8
|
+
*/
|
|
9
|
+
export type SessionExistence = 'present' | 'absent' | 'indeterminate';
|
|
10
|
+
export interface SessionProbe {
|
|
11
|
+
readonly existence: SessionExistence;
|
|
12
|
+
/** Why, in terms a client can act on. Empty for `present`. */
|
|
13
|
+
readonly detail: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SessionStoreOptions {
|
|
16
|
+
/** Overridable so tests never read the developer's real sessions. */
|
|
17
|
+
readonly home?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function probeSessionStore(sessionId: string, options?: SessionStoreOptions): SessionProbe;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Does a CLI session id name a conversation that exists?
|
|
2
|
+
//
|
|
3
|
+
// `session/load` used to answer this by trying. It spawned the agent with
|
|
4
|
+
// `--resume <id>` and the CLI's refusal -- `No conversation found with session
|
|
5
|
+
// ID: <id>` -- arrived when the FIRST PROMPT ran, by which point `session/load`
|
|
6
|
+
// had already returned success and the client believed it held a resumed
|
|
7
|
+
// session. The failure was real but indistinguishable from any other late
|
|
8
|
+
// error, and a consumer reported it as the only thing standing between a bad id
|
|
9
|
+
// and a lost conversation.
|
|
10
|
+
//
|
|
11
|
+
// WHY NOT PROBE WITH THE CLI. The obvious probe is
|
|
12
|
+
// `claude --resume <id> -p x`, which does refuse a bad id for free. On a GOOD
|
|
13
|
+
// id it resumes the conversation and answers "x" -- a real turn, real tokens,
|
|
14
|
+
// on every successful load. A probe that costs a turn on the happy path is
|
|
15
|
+
// worse than the problem it solves.
|
|
16
|
+
//
|
|
17
|
+
// So this reads the session store instead, which costs a directory listing.
|
|
18
|
+
//
|
|
19
|
+
// WHY SCAN RATHER THAN DERIVE THE PATH. Sessions live at
|
|
20
|
+
// `~/.claude/projects/<slug>/<uuid>.jsonl`, where `<slug>` is the project's cwd
|
|
21
|
+
// with its separators and punctuation replaced by `-`. Deriving that slug means
|
|
22
|
+
// reimplementing an undocumented rule, and getting it wrong would mean looking
|
|
23
|
+
// in a directory that does not exist and calling a VALID session absent --
|
|
24
|
+
// refusing a resume that would have worked, which is a worse failure than the
|
|
25
|
+
// late error this replaces. A session id is a UUID and therefore unique across
|
|
26
|
+
// every project, so looking for the FILE is enough and needs no slug at all.
|
|
27
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
/** The CLI accepts a UUID, or a session TITLE, and nothing else. */
|
|
31
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
32
|
+
export function probeSessionStore(sessionId, options = {}) {
|
|
33
|
+
// A non-UUID is refused by the CLI outright -- verified against claude
|
|
34
|
+
// 2.1.292: "Provided value ... is not a UUID and does not match any session
|
|
35
|
+
// title." Reported as absent without touching the disk, because the store
|
|
36
|
+
// cannot contain it under any layout.
|
|
37
|
+
//
|
|
38
|
+
// This is narrower than it looks: the CLI also accepts a session TITLE, and a
|
|
39
|
+
// title is not a UUID. It is reported absent anyway, because this server
|
|
40
|
+
// publishes the CLI's UUID as the thing to resume with and a client passing a
|
|
41
|
+
// human-chosen title is working from something else.
|
|
42
|
+
if (!UUID.test(sessionId)) {
|
|
43
|
+
return {
|
|
44
|
+
existence: 'absent',
|
|
45
|
+
detail: `${sessionId} is not a session id the CLI can resume: it is not a UUID.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const root = join(options.home ?? homedir(), '.claude', 'projects');
|
|
49
|
+
let projects;
|
|
50
|
+
try {
|
|
51
|
+
if (!statSync(root).isDirectory()) {
|
|
52
|
+
return { existence: 'indeterminate', detail: `${root} is not a directory` };
|
|
53
|
+
}
|
|
54
|
+
projects = readdirSync(root);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return { existence: 'indeterminate', detail: `${root} could not be read: ${error.message}` };
|
|
58
|
+
}
|
|
59
|
+
// An EMPTY store is indeterminate rather than absent. A store with no
|
|
60
|
+
// projects in it is far more likely to be the wrong store than a genuine
|
|
61
|
+
// record that this conversation never existed.
|
|
62
|
+
if (projects.length === 0) {
|
|
63
|
+
return { existence: 'indeterminate', detail: `${root} lists no projects` };
|
|
64
|
+
}
|
|
65
|
+
const wanted = `${sessionId}.jsonl`;
|
|
66
|
+
let readable = 0;
|
|
67
|
+
for (const project of projects) {
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = readdirSync(join(root, project));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue; // One unreadable project does not settle the question.
|
|
74
|
+
}
|
|
75
|
+
readable++;
|
|
76
|
+
if (entries.includes(wanted))
|
|
77
|
+
return { existence: 'present', detail: '' };
|
|
78
|
+
}
|
|
79
|
+
// Every project directory failed to open. Absence has not been established.
|
|
80
|
+
if (readable === 0) {
|
|
81
|
+
return { existence: 'indeterminate', detail: `no project directory under ${root} could be read` };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
existence: 'absent',
|
|
85
|
+
detail: `no conversation with session id ${sessionId} exists in this installation's session store.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,9 @@ export type { ClaudeRateLimit, ClaudeRateLimitWindow, RateLimitRead } from './cl
|
|
|
12
12
|
export { ClaudeDriver, claudeArgs, promptLine, updatesFromFrames } from './claude/driver.js';
|
|
13
13
|
export type { ClaudeDriverEvents, ClaudeDriverOptions, ClaudePermissionMode, } from './claude/driver.js';
|
|
14
14
|
export { AcpAgent, PROTOCOL_VERSION } from './acp/agent.js';
|
|
15
|
-
export type { AcpAgentOptions, AgentDriver, DriverEvents, DriverFactory, } from './acp/agent.js';
|
|
15
|
+
export type { AcpAgentOptions, AgentDriver, DriverEvents, DriverFactory, SessionProbe as AcpSessionProbe, } from './acp/agent.js';
|
|
16
|
+
export { probeSessionStore } from './claude/session-store.js';
|
|
17
|
+
export type { SessionExistence, SessionProbe, SessionStoreOptions } from './claude/session-store.js';
|
|
16
18
|
export { serve } from './acp/stdio.js';
|
|
17
19
|
export type { Served, ServeOptions } from './acp/stdio.js';
|
|
18
20
|
export { cliSessionIdOf, turnOutcomeOf } from './claude/driver.js';
|
package/dist/index.js
CHANGED
|
@@ -6,5 +6,6 @@ export { ClaudeToAcp } from './claude/to-acp.js';
|
|
|
6
6
|
export { parseRateLimit, rateLimitNotice, readRateLimit } from './claude/rate-limit.js';
|
|
7
7
|
export { ClaudeDriver, claudeArgs, promptLine, updatesFromFrames } from './claude/driver.js';
|
|
8
8
|
export { AcpAgent, PROTOCOL_VERSION } from './acp/agent.js';
|
|
9
|
+
export { probeSessionStore } from './claude/session-store.js';
|
|
9
10
|
export { serve } from './acp/stdio.js';
|
|
10
11
|
export { cliSessionIdOf, turnOutcomeOf } from './claude/driver.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@particle-academy/prism-acp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|