@rahularya01/pi-cursor 1.4.20 → 1.4.22
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 +27 -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,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.22] - 2026-08-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Greetings and "who are you" were answered in Cursor's voice, not Pi's.** Trivial conversational turns drop tools and blank the system prompt so a bare "hi" does not spend tens of thousands of input tokens on the agent prompt. The allowlist also held identity and capability questions — `who are you`, `what can you do for me`, `tell me about yourself` — whose answer _is_ the system prompt, so exactly those turns reached Cursor carrying no prompt at all and came back describing Cursor's IDE assistant. The allowlist is now split: pleasantries still drop the prompt, identity and capability turns keep it, and both still omit tools. A dropped prompt is no longer empty either — it carries a one-line "You are running inside Pi, not the Cursor IDE", so a greeting cannot be answered in Cursor's voice either. `tools_omitted` records whether the prompt was dropped.
|
|
8
|
+
- **The bundled fallback catalog under-reported the context window of every 1M Claude model by 5x.** Ten rows — `claude-4.6-opus-high`, `claude-4.6-opus-max`, `claude-4.6-sonnet-medium`, `claude-4.5-sonnet`, `claude-4-sonnet-1m` and their `-thinking` variants — carried `contextWindow: 200000` while their own display names read "1M". Live discovery infers the window from the id and name and had these right; only the offline snapshot was wrong, so the bad number surfaced precisely when discovery was unavailable. The rows are corrected, and `FALLBACK_MODELS` now derives `contextWindow` the same way it already derived `reasoning`, so the file cannot drift back out of agreement.
|
|
9
|
+
- **Every model advertised a 64K output ceiling.** `maxTokens` was a hardcoded `64_000` at all three places a model row is built, so Claude 4.6 and the GPT-5 family were reported at half their real limit. Cursor's `ModelDetails` carries no output ceiling, so it is now inferred from the id and display name alongside the context window: Claude 4.6+ and GPT-5 report 128K, and everything else — Claude 4.5 and older, Haiku 4.5, Composer, Gemini, Grok, Kimi, and `Auto` — keeps the 64K floor. This is Pi-side budgeting metadata only; the Cursor run request has no output-token field.
|
|
10
|
+
|
|
11
|
+
### Internal
|
|
12
|
+
|
|
13
|
+
- Context-window and output-token inference moved to a dependency-free `src/models/limits.ts`, re-exported from `src/stream/model-discovery.ts`. Importing them directly would have made the startup-path model catalog pull in the bridge and HTTP/2 transport modules at runtime, where it previously had only an erased `import type`.
|
|
14
|
+
|
|
15
|
+
## [1.4.21] - 2026-08-18
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
|
|
26
|
+
### Performance
|
|
27
|
+
|
|
28
|
+
- **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.
|
|
29
|
+
|
|
3
30
|
## [1.4.20] - 2026-08-18
|
|
4
31
|
|
|
5
32
|
### 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
|
}
|