@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 +15 -0
- package/dist/h2-bridge.mjs +153 -77
- package/dist/index.js +28 -25
- package/package.json +1 -1
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
|
package/dist/h2-bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
395
|
+
|
|
396
|
+
if (currentStream && !currentStream.closed && !currentStream.destroyed) {
|
|
318
397
|
resetTimeout();
|
|
319
|
-
if (!
|
|
398
|
+
if (!currentStream.write(msg)) {
|
|
320
399
|
try {
|
|
321
|
-
await once(
|
|
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
|
}
|