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