@particle-academy/prism-acp 0.2.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/README.md CHANGED
@@ -109,6 +109,43 @@ 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
+
112
149
  ## Rate limits are a gauge, not just a breach event
113
150
 
114
151
  ACP has no field for a rate limit, so the detail rides in `_meta` under
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
- if (this.#sessions.has(sessionId)) {
112
- throw new RpcError(RPC_INVALID_PARAMS, `session ${sessionId} is already open`);
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
@@ -21,7 +21,7 @@
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
25
  import { rateLimitNotice, readRateLimit } from './rate-limit.js';
26
26
  /**
27
27
  * ACP's ten tool kinds.
@@ -237,7 +237,24 @@ export class ClaudeToAcp {
237
237
  return [
238
238
  withMeta({ sessionUpdate: 'notice', notice: { level: 'debug', message: 'thinking' } }, { [META_THINKING_TOKENS_ESTIMATE]: input.estimated_tokens ?? null }),
239
239
  ];
240
- 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
+ }
241
258
  case 'status':
242
259
  case 'api_retry':
243
260
  // Not mapped, but RECORDED. api_retry especially: it is how an
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ 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
10
  export { parseRateLimit, rateLimitNotice, readRateLimit } from './claude/rate-limit.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
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
6
  export { parseRateLimit, rateLimitNotice, readRateLimit } from './claude/rate-limit.js';
7
7
  export { ClaudeDriver, claudeArgs, promptLine, updatesFromFrames } from './claude/driver.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.2.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": {