@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 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
 
@@ -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.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": {