@particle-academy/prism-acp 0.3.0 → 0.4.1
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 +49 -0
- package/dist/acp/agent.d.ts +14 -0
- package/dist/acp/agent.js +17 -0
- package/dist/claude/session-store.d.ts +34 -0
- package/dist/claude/session-store.js +132 -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,54 @@ 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
|
+
**It looks where the CLI looks, including `CLAUDE_CONFIG_DIR`.** The CLI resolves
|
|
176
|
+
its configuration home as that variable or, unset, `<home>/.claude`, and keeps
|
|
177
|
+
`projects` under whichever it picked — so the probe does the same. This is not a
|
|
178
|
+
detail: an installation that sets it does so because the stored subscription
|
|
179
|
+
credential lives there, which is exactly the installation whose resumes matter,
|
|
180
|
+
and reading the wrong store would report a real conversation `absent` and refuse
|
|
181
|
+
a resume that would have worked.
|
|
182
|
+
|
|
183
|
+
`childEnv` passes that variable through unchanged, so a driver spawned from this
|
|
184
|
+
process resolves the same store the probe read, by construction. Hand the driver
|
|
185
|
+
a `parentEnv` you built yourself and hand the same environment to the probe, or
|
|
186
|
+
name the store outright:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const probeSession = (sessionId: string) => probeSessionStore(sessionId, { env: parentEnv });
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`probeSession` is yours to supply because the answer belongs to the agent being
|
|
193
|
+
driven, not to ACP: `probeSessionStore` reads claude's session store, and a Codex
|
|
194
|
+
driver would resolve the same question through `thread/resume`. Omit it and
|
|
195
|
+
`session/load` behaves as it always did.
|
|
196
|
+
|
|
149
197
|
## Rate limits are a gauge, not just a breach event
|
|
150
198
|
|
|
151
199
|
ACP has no field for a rate limit, so the detail rides in `_meta` under
|
|
@@ -213,6 +261,7 @@ serve({
|
|
|
213
261
|
input: process.stdin,
|
|
214
262
|
output: process.stdout,
|
|
215
263
|
driverFactory: (options, events) => new ClaudeDriver(options, events),
|
|
264
|
+
probeSession,
|
|
216
265
|
});
|
|
217
266
|
```
|
|
218
267
|
|
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,34 @@
|
|
|
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
|
+
* The CLI's configuration home, naming the store directly. Outranks both
|
|
20
|
+
* {@link home} and the environment -- for a caller that knows where the store
|
|
21
|
+
* is, or that builds the driven CLI's environment itself.
|
|
22
|
+
*/
|
|
23
|
+
readonly configDir?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The environment the DRIVEN CLI will see, read for `CLAUDE_CONFIG_DIR`.
|
|
26
|
+
*
|
|
27
|
+
* Defaults to this process's own, which is correct by construction when the
|
|
28
|
+
* driver is spawned from here: `childEnv` passes `CLAUDE_CONFIG_DIR` through
|
|
29
|
+
* unchanged, so the probe and the child resolve one store. Hand the driver a
|
|
30
|
+
* `parentEnv` of your own and hand the same one here, or they will not.
|
|
31
|
+
*/
|
|
32
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
33
|
+
}
|
|
34
|
+
export declare function probeSessionStore(sessionId: string, options?: SessionStoreOptions): SessionProbe;
|
|
@@ -0,0 +1,132 @@
|
|
|
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 { isAbsolute, 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
|
+
/**
|
|
33
|
+
* WHERE THE STORE IS. The CLI resolves its configuration home as
|
|
34
|
+
* `CLAUDE_CONFIG_DIR` or, unset, `<home>/.claude`, and keeps `projects` under
|
|
35
|
+
* whichever it picked.
|
|
36
|
+
*
|
|
37
|
+
* Reading only the second was this probe's one serious bug (0.4.0). An
|
|
38
|
+
* installation that sets the variable -- Genie forwards it deliberately,
|
|
39
|
+
* because the stored subscription credential lives there and is what lets a
|
|
40
|
+
* child run with no API key at all -- had the probe read a store the CLI does
|
|
41
|
+
* not use, find nothing, and report `absent` for a conversation that exists.
|
|
42
|
+
* That refuses a resume that would have worked: the failure this whole
|
|
43
|
+
* three-state design exists to avoid, reintroduced by the check meant to
|
|
44
|
+
* prevent it.
|
|
45
|
+
*
|
|
46
|
+
* The variable names the configuration home ITSELF, so `projects` sits directly
|
|
47
|
+
* inside it and no `.claude` is appended. Resolving it as
|
|
48
|
+
* `home: dirname(CLAUDE_CONFIG_DIR)` instead would work only while the
|
|
49
|
+
* directory happens to be named `.claude`.
|
|
50
|
+
*/
|
|
51
|
+
function storeRoot(options) {
|
|
52
|
+
const configured = options.configDir ?? (options.env ?? process.env).CLAUDE_CONFIG_DIR;
|
|
53
|
+
const trimmed = configured?.trim();
|
|
54
|
+
// The CLI reads it with `||`, so an empty or blank value is no value. Taking
|
|
55
|
+
// it literally would resolve `projects` against this process's working
|
|
56
|
+
// directory.
|
|
57
|
+
if (trimmed === undefined || trimmed === '') {
|
|
58
|
+
return { root: join(options.home ?? homedir(), '.claude', 'projects') };
|
|
59
|
+
}
|
|
60
|
+
// The CLI refuses to run at all with a relative configuration home -- "the
|
|
61
|
+
// configuration home (CLAUDE_CONFIG_DIR) is not an absolute path" -- so there
|
|
62
|
+
// is no store to name, and resolving it against our own cwd would answer
|
|
63
|
+
// about a directory the CLI never looks in. It is also not a case for the
|
|
64
|
+
// `<home>/.claude` fallback: the CLI will not fall back either.
|
|
65
|
+
if (!isAbsolute(trimmed)) {
|
|
66
|
+
// The value itself stays out of the message. Every other `detail` here
|
|
67
|
+
// names the path it looked at, which is useful and harmless for a path we
|
|
68
|
+
// derived; this one is an environment value, and a detail string travels to
|
|
69
|
+
// the client.
|
|
70
|
+
return { reason: 'CLAUDE_CONFIG_DIR is not an absolute path, so the session store it names cannot be located' };
|
|
71
|
+
}
|
|
72
|
+
return { root: join(trimmed, 'projects') };
|
|
73
|
+
}
|
|
74
|
+
export function probeSessionStore(sessionId, options = {}) {
|
|
75
|
+
// A non-UUID is refused by the CLI outright -- verified against claude
|
|
76
|
+
// 2.1.292: "Provided value ... is not a UUID and does not match any session
|
|
77
|
+
// title." Reported as absent without touching the disk, because the store
|
|
78
|
+
// cannot contain it under any layout.
|
|
79
|
+
//
|
|
80
|
+
// This is narrower than it looks: the CLI also accepts a session TITLE, and a
|
|
81
|
+
// title is not a UUID. It is reported absent anyway, because this server
|
|
82
|
+
// publishes the CLI's UUID as the thing to resume with and a client passing a
|
|
83
|
+
// human-chosen title is working from something else.
|
|
84
|
+
if (!UUID.test(sessionId)) {
|
|
85
|
+
return {
|
|
86
|
+
existence: 'absent',
|
|
87
|
+
detail: `${sessionId} is not a session id the CLI can resume: it is not a UUID.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const resolved = storeRoot(options);
|
|
91
|
+
if ('reason' in resolved)
|
|
92
|
+
return { existence: 'indeterminate', detail: resolved.reason };
|
|
93
|
+
const { root } = resolved;
|
|
94
|
+
let projects;
|
|
95
|
+
try {
|
|
96
|
+
if (!statSync(root).isDirectory()) {
|
|
97
|
+
return { existence: 'indeterminate', detail: `${root} is not a directory` };
|
|
98
|
+
}
|
|
99
|
+
projects = readdirSync(root);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return { existence: 'indeterminate', detail: `${root} could not be read: ${error.message}` };
|
|
103
|
+
}
|
|
104
|
+
// An EMPTY store is indeterminate rather than absent. A store with no
|
|
105
|
+
// projects in it is far more likely to be the wrong store than a genuine
|
|
106
|
+
// record that this conversation never existed.
|
|
107
|
+
if (projects.length === 0) {
|
|
108
|
+
return { existence: 'indeterminate', detail: `${root} lists no projects` };
|
|
109
|
+
}
|
|
110
|
+
const wanted = `${sessionId}.jsonl`;
|
|
111
|
+
let readable = 0;
|
|
112
|
+
for (const project of projects) {
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = readdirSync(join(root, project));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue; // One unreadable project does not settle the question.
|
|
119
|
+
}
|
|
120
|
+
readable++;
|
|
121
|
+
if (entries.includes(wanted))
|
|
122
|
+
return { existence: 'present', detail: '' };
|
|
123
|
+
}
|
|
124
|
+
// Every project directory failed to open. Absence has not been established.
|
|
125
|
+
if (readable === 0) {
|
|
126
|
+
return { existence: 'indeterminate', detail: `no project directory under ${root} could be read` };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
existence: 'absent',
|
|
130
|
+
detail: `no conversation with session id ${sessionId} exists in this installation's session store.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
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.1",
|
|
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": {
|