@rahularya01/pi-cursor 1.4.20 → 1.4.21

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.21] - 2026-08-18
4
+
5
+ ### Fixed
6
+
7
+ - **Session switch/fork/shutdown deleted the on-disk conversation journal.** Switching chats (or `/fork`, `/tree`, shutdown) killed the HTTP/2 bridge _and_ `unlink`d the journal that `/resume` hydrates from. The next turn in that session had no Cursor checkpoint and rebuilt without the compacted summary — it looked like the chat had forgotten the conversation. Those hooks now only tear down bridges; the journal stays until TTL eviction.
8
+ - **Trivial turns (`hi` / `ok`) blanked a system prompt that held folded session memory.** Greetings omit tools _and_ used to drop the system prompt to save tokens. After compaction that prompt is where the recovered `<session_state>` lives, so a short follow-up started from a blank slate. The prompt is kept when it contains provider-context / session-resume memory; tools are still omitted.
9
+ - **Compaction and resume summaries were framed as disposable infrastructure.** The same "latest user message is the only task; do not continue prior work" banner wrapped live context-mode injections _and_ recovered `<summary>` / `<session_resume>` blocks, so the model treated the compacted memory as noise. Resume/compaction side-channels now say they are active memory to continue from. Empty hierarchy+mode-only injections are still dropped; a real `<summary>` is kept even when short. Trailing user text after `</session_state>` is no longer capped at 500 characters.
10
+ - **A compacted Pi transcript kept the old Cursor `conversationId`.** When turn count or history fingerprint no longer matched the checkpoint, the checkpoint was discarded but the id stayed, so the next rebuild attached to a Cursor conversation whose history no longer existed. Those mismatches now rotate `conversationId`. A KV blob miss does the same: drop the checkpoint, rotate, persist — instead of answering the miss with an empty blob and leaving the hole in place.
11
+ - **Replayed history dropped thinking.** Pi thinking blocks never became Cursor `ThinkingMessage` steps, so a rebuild after checkpoint loss lost the reasoning that earlier turns had produced. Thinking is now carried on the OpenAI-shaped assistant message and encoded as a turn step.
12
+ - **Native Cursor execs (read / shell / …) stalled or listed the workspace to "recover" context.** Rejects now name the matching Pi MCP tool when one is advertised (`read` → `read`, `shellArgs` → `bash`, …). The system prompt also states the session is running inside Pi, not Cursor IDE.
13
+
14
+ ### Performance
15
+
16
+ - **The HTTP/2 bridge process is reused across user turns.** Each turn previously spawned `h2-bridge.mjs` and did a fresh TLS + HTTP/2 handshake. A completed stream now keeps the child and session; the next turn sends `{"cmd":"open"}` and a new Connect stream. Spawn + handshake remain only for the first turn, after an idle TTL, or when the session is switched away. Mid-tool pauses still hold the live stream as before.
17
+
3
18
  ## [1.4.20] - 2026-08-18
4
19
 
5
20
  ### Fixed
@@ -12,11 +12,17 @@
12
12
  * [4 bytes big-endian length][payload]
13
13
  *
14
14
  * First message on stdin is JSON config:
15
- * { "accessToken": "...", "url": "...", "path": "...", "unary": false }
15
+ * { "accessToken": "...", "url": "...", "path": "...", "unary": false, "persistent": true }
16
16
  *
17
17
  * When unary=true, the bridge uses application/proto (raw protobuf) instead
18
18
  * of application/connect+proto (Connect streaming). The single stdin message
19
19
  * is written as the request body and the stream is ended immediately.
20
+ *
21
+ * Streaming with persistent=true (the default for chat) keeps the HTTP/2
22
+ * session after a stream ends, writes a STREAM_DONE sentinel, and waits for
23
+ * an `{"cmd":"open"}` stdin message to open the next Connect stream — so later
24
+ * turns skip process spawn + TLS.
25
+ *
20
26
  * After config, subsequent stdin messages are raw bytes to write to the H2 stream.
21
27
  * H2 response data is written to stdout using the same length-prefixed framing.
22
28
  */
@@ -129,7 +135,9 @@ if (!config || typeof config !== "object") {
129
135
  process.stderr.write("[h2-bridge] config must be a JSON object\n");
130
136
  process.exit(1);
131
137
  }
132
- const { accessToken, url, path: rpcPath, unary } = config;
138
+ const { accessToken, url, path: rpcPath, unary, persistent: persistentFlag } = config;
139
+ const persistent = unary ? false : persistentFlag !== false;
140
+ const STREAM_DONE_MAGIC = Buffer.from("PI_CURSOR_STREAM_DONE");
133
141
  // Connect timeout still protects against a hung first handshake (default 30s).
134
142
  // Activity idle is off by default (0) so long agent turns are not killed;
135
143
  // set idleTimeoutMs / PI_CURSOR_H2_IDLE_TIMEOUT_MS to re-enable a safety net.
@@ -225,109 +233,177 @@ client.on("goaway", (errorCode, _lastStreamId, opaqueData) => {
225
233
  setTimeout(() => process.exit(2), 100);
226
234
  });
227
235
 
228
- const headers = {
229
- ":method": "POST",
230
- ":path": rpcPath || "/agent.v1.AgentService/Run",
231
- "content-type": unary ? "application/proto" : "application/connect+proto",
232
- "connect-protocol-version": "1",
233
- te: "trailers",
234
- authorization: `Bearer ${accessToken}`,
235
- "x-ghost-mode": "true",
236
- "x-cursor-client-version": CURSOR_CLIENT_VERSION,
237
- "x-cursor-client-type": "cli",
238
- "x-request-id": crypto.randomUUID(),
239
- };
240
- const h2Stream = client.request(headers);
241
- let responseStatus = 0;
242
- let responseStatusText = "";
243
- const errorChunks = [];
244
- let errorBodyBytes = 0;
245
- const isErrorStatus = () => responseStatus !== 0 && (responseStatus < 200 || responseStatus >= 300);
246
-
247
- h2Stream.on("response", (responseHeaders) => {
248
- resetTimeout();
249
- responseStatus = Number(responseHeaders[":status"] || 0);
250
- responseStatusText =
251
- responseHeaders["grpc-message"] || responseHeaders["connect-error-message"] || "";
252
- });
236
+ function requestHeaders(token) {
237
+ return {
238
+ ":method": "POST",
239
+ ":path": rpcPath || "/agent.v1.AgentService/Run",
240
+ "content-type": unary ? "application/proto" : "application/connect+proto",
241
+ "connect-protocol-version": "1",
242
+ te: "trailers",
243
+ authorization: `Bearer ${token}`,
244
+ "x-ghost-mode": "true",
245
+ "x-cursor-client-version": CURSOR_CLIENT_VERSION,
246
+ "x-cursor-client-type": "cli",
247
+ "x-request-id": crypto.randomUUID(),
248
+ };
249
+ }
253
250
 
254
- // Forward H2 response data → stdout (length-prefixed)
255
- h2Stream.on("data", (chunk) => {
256
- resetTimeout();
257
- if (isErrorStatus()) {
258
- const remaining = MAX_ERROR_BODY_BYTES - errorBodyBytes;
259
- if (remaining > 0) {
260
- const kept = Buffer.from(chunk).subarray(0, remaining);
261
- errorChunks.push(kept);
262
- errorBodyBytes += kept.byteLength;
263
- }
264
- } else {
265
- if (!writeMessage(chunk)) {
251
+ function parseOpenCommand(msg) {
252
+ if (!msg || msg.length === 0 || msg[0] !== 0x7b) return undefined;
253
+ try {
254
+ const parsed = JSON.parse(msg.toString("utf8"));
255
+ if (parsed && parsed.cmd === "open") return parsed;
256
+ } catch {
257
+ // Binary Connect frames that happen to start with `{` are not open commands.
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ function attachStream(h2Stream) {
263
+ let responseStatus = 0;
264
+ let responseStatusText = "";
265
+ const errorChunks = [];
266
+ let errorBodyBytes = 0;
267
+ const isErrorStatus = () => responseStatus !== 0 && (responseStatus < 200 || responseStatus >= 300);
268
+
269
+ h2Stream.on("response", (responseHeaders) => {
270
+ resetTimeout();
271
+ responseStatus = Number(responseHeaders[":status"] || 0);
272
+ responseStatusText =
273
+ responseHeaders["grpc-message"] || responseHeaders["connect-error-message"] || "";
274
+ });
275
+
276
+ h2Stream.on("data", (chunk) => {
277
+ resetTimeout();
278
+ if (isErrorStatus()) {
279
+ const remaining = MAX_ERROR_BODY_BYTES - errorBodyBytes;
280
+ if (remaining > 0) {
281
+ const kept = Buffer.from(chunk).subarray(0, remaining);
282
+ errorChunks.push(kept);
283
+ errorBodyBytes += kept.byteLength;
284
+ }
285
+ } else if (!writeMessage(chunk)) {
266
286
  h2Stream.pause();
267
287
  process.stdout.once("drain", () => h2Stream.resume());
268
288
  }
269
- }
270
- });
289
+ });
271
290
 
272
- h2Stream.on("end", () => {
273
- clearBridgeTimeout();
274
- if (pingTimer) clearInterval(pingTimer);
275
- client.close();
276
- if (isErrorStatus()) {
277
- const body = Buffer.concat(errorChunks).toString("utf8").trim();
278
- const detail = responseStatusText || body || "HTTP/2 upstream request failed";
279
- writeMessage(
280
- connectEndStreamError(`http_${responseStatus}`, `Cursor HTTP ${responseStatus}: ${detail}`),
281
- );
282
- setTimeout(() => process.exit(1), 100);
283
- return;
284
- }
285
- // Give stdout time to flush
286
- setTimeout(() => process.exit(0), 100);
287
- });
291
+ return new Promise((resolve) => {
292
+ const finish = (result) => {
293
+ resolve(result);
294
+ };
295
+
296
+ h2Stream.on("end", () => {
297
+ if (isErrorStatus()) {
298
+ const body = Buffer.concat(errorChunks).toString("utf8").trim();
299
+ const detail = responseStatusText || body || "HTTP/2 upstream request failed";
300
+ writeMessage(
301
+ connectEndStreamError(`http_${responseStatus}`, `Cursor HTTP ${responseStatus}: ${detail}`),
302
+ );
303
+ finish({ ok: false, fatal: true });
304
+ return;
305
+ }
306
+ finish({ ok: true, fatal: false });
307
+ });
288
308
 
289
- h2Stream.on("error", (err) => {
309
+ h2Stream.on("error", (err) => {
310
+ process.stderr.write(
311
+ `[h2-bridge] stream error: ${err instanceof Error ? err.message : String(err)}\n`,
312
+ );
313
+ finish({ ok: false, fatal: true });
314
+ });
315
+ });
316
+ }
317
+
318
+ function shutdownClient(code) {
290
319
  clearBridgeTimeout();
291
320
  if (pingTimer) clearInterval(pingTimer);
292
- process.stderr.write(
293
- `[h2-bridge] stream error: ${err instanceof Error ? err.message : String(err)}\n`,
294
- );
295
- client.close();
296
- process.exit(1);
297
- });
321
+ try {
322
+ client.close();
323
+ } catch {
324
+ // Already closed.
325
+ }
326
+ setTimeout(() => process.exit(code), 100);
327
+ }
298
328
 
299
- // Forward stdin → H2 stream (after config message)
300
329
  if (unary) {
301
- // Unary mode: read a single body message, write it, and end the stream.
330
+ const h2Stream = client.request(requestHeaders(accessToken));
331
+ const ended = attachStream(h2Stream);
302
332
  const body = await readMessage();
303
333
  if (body && body.length > 0 && !h2Stream.closed && !h2Stream.destroyed) {
304
334
  h2Stream.end(body);
305
335
  } else {
306
336
  h2Stream.end();
307
337
  }
338
+ const result = await ended;
339
+ shutdownClient(result.ok ? 0 : 1);
308
340
  } else {
309
- // Streaming mode: forward all stdin messages as Connect frames.
341
+ let currentStream = client.request(requestHeaders(accessToken));
342
+ let currentEnded = attachStream(currentStream);
343
+
344
+ currentEnded.then((result) => {
345
+ if (!result.ok) {
346
+ shutdownClient(1);
347
+ return;
348
+ }
349
+ if (!persistent) {
350
+ shutdownClient(0);
351
+ return;
352
+ }
353
+ writeMessage(STREAM_DONE_MAGIC);
354
+ currentStream = null;
355
+ });
356
+
310
357
  (async () => {
311
358
  while (true) {
312
359
  const msg = await readMessage();
313
- if (!msg || msg.length === 0) {
314
- // EOF or zero-length = done writing
315
- break;
360
+ if (!msg) {
361
+ shutdownClient(0);
362
+ return;
363
+ }
364
+ if (msg.length === 0) {
365
+ if (currentStream && !currentStream.closed && !currentStream.destroyed) {
366
+ currentStream.end();
367
+ }
368
+ if (!persistent) {
369
+ shutdownClient(0);
370
+ return;
371
+ }
372
+ continue;
373
+ }
374
+
375
+ const open = parseOpenCommand(msg);
376
+ if (open) {
377
+ if (client.destroyed || client.closed) {
378
+ process.stderr.write("[h2-bridge] cannot open stream: client closed\n");
379
+ shutdownClient(1);
380
+ return;
381
+ }
382
+ const token = typeof open.accessToken === "string" && open.accessToken ? open.accessToken : accessToken;
383
+ currentStream = client.request(requestHeaders(token));
384
+ currentEnded = attachStream(currentStream);
385
+ currentEnded.then((result) => {
386
+ if (!result.ok) {
387
+ shutdownClient(1);
388
+ return;
389
+ }
390
+ writeMessage(STREAM_DONE_MAGIC);
391
+ currentStream = null;
392
+ });
393
+ continue;
316
394
  }
317
- if (!h2Stream.closed && !h2Stream.destroyed) {
395
+
396
+ if (currentStream && !currentStream.closed && !currentStream.destroyed) {
318
397
  resetTimeout();
319
- if (!h2Stream.write(msg)) {
398
+ if (!currentStream.write(msg)) {
320
399
  try {
321
- await once(h2Stream, "drain");
400
+ await once(currentStream, "drain");
322
401
  } catch {
323
402
  break;
324
403
  }
325
404
  }
326
405
  }
327
- }
328
-
329
- if (!h2Stream.closed && !h2Stream.destroyed) {
330
- h2Stream.end();
406
+ // Idle leftover heartbeats (after STREAM_DONE, before the next open) are ignored.
331
407
  }
332
408
  })();
333
409
  }