pi-provider-cursor-ask 0.1.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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/README.zh-CN.md +87 -0
- package/UPSTREAM_CHANGELOG.md +368 -0
- package/UPSTREAM_SOURCE.md +23 -0
- package/dist/index.js +54 -0
- package/package.json +97 -0
- package/src/auth/cli-credentials.ts +275 -0
- package/src/auth/consent.ts +25 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +282 -0
- package/src/auth/refresh-guard.ts +93 -0
- package/src/client/bridge.ts +673 -0
- package/src/client/cursor-wire.ts +213 -0
- package/src/client/h2-unary.ts +142 -0
- package/src/client/index.ts +18 -0
- package/src/config/index.ts +69 -0
- package/src/diagnostics/diagnostics.ts +116 -0
- package/src/diagnostics/index.ts +1 -0
- package/src/extension/auth.ts +99 -0
- package/src/extension/commands.ts +163 -0
- package/src/extension/compaction-guard.ts +86 -0
- package/src/extension/debug-hooks.ts +359 -0
- package/src/extension/index.ts +8 -0
- package/src/extension/provider.ts +277 -0
- package/src/extension/quota-adapter.ts +175 -0
- package/src/extension/report-dashboard.ts +133 -0
- package/src/identity.ts +16 -0
- package/src/index.ts +186 -0
- package/src/models/ask-catalog.ts +384 -0
- package/src/models/catalog.json +1163 -0
- package/src/models/cost.ts +126 -0
- package/src/models/index.ts +6 -0
- package/src/models/limits.ts +36 -0
- package/src/models/parameterized.ts +416 -0
- package/src/models/processing.ts +313 -0
- package/src/proto/agent_pb.ts +14577 -0
- package/src/stream/bridge-session.ts +215 -0
- package/src/stream/client-transcript.ts +51 -0
- package/src/stream/config.ts +5 -0
- package/src/stream/context-normalize.ts +308 -0
- package/src/stream/context-usage.ts +168 -0
- package/src/stream/debug-log.ts +316 -0
- package/src/stream/drift.ts +122 -0
- package/src/stream/images.ts +201 -0
- package/src/stream/index.ts +68 -0
- package/src/stream/interaction-query.ts +369 -0
- package/src/stream/message-parsing.ts +402 -0
- package/src/stream/model-cache.ts +100 -0
- package/src/stream/model-discovery.ts +242 -0
- package/src/stream/model-routing.ts +100 -0
- package/src/stream/native-core.ts +2121 -0
- package/src/stream/pi-adapter.ts +414 -0
- package/src/stream/protocol.ts +63 -0
- package/src/stream/recovery.ts +494 -0
- package/src/stream/request-build.ts +668 -0
- package/src/stream/root-prompt.ts +184 -0
- package/src/stream/run-journal.ts +474 -0
- package/src/stream/run-usage.ts +107 -0
- package/src/stream/server-messages.ts +777 -0
- package/src/stream/session-state.ts +499 -0
- package/src/stream/stream-writer.ts +211 -0
- package/src/stream/thinking-filter.ts +63 -0
- package/src/stream/tool-schema.ts +185 -0
- package/src/stream/transport-errors.ts +150 -0
- package/src/stream/tuning.ts +250 -0
- package/src/stream/types.ts +330 -0
- package/src/types/enums.ts +103 -0
- package/src/types/index.ts +4 -0
- package/src/usage.ts +262 -0
- package/src/utils/cache-dir.ts +39 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/security.ts +68 -0
- package/src/utils/util.ts +43 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips inline thinking tags out of Cursor's text stream.
|
|
3
|
+
*
|
|
4
|
+
* Some models wrap reasoning in `<think>`/`<thinking>`/... tags inside the
|
|
5
|
+
* ordinary text channel. Pi has a separate thinking channel, so the tags have to
|
|
6
|
+
* come out of the visible text without waiting for the full response — the
|
|
7
|
+
* filter is incremental and holds back only a partial tag's worth of characters
|
|
8
|
+
* at a chunk boundary.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const THINKING_TAG_NAMES = ["think", "thinking", "reasoning", "thought", "think_intent"];
|
|
12
|
+
|
|
13
|
+
const MAX_THINKING_TAG_LEN = 16;
|
|
14
|
+
|
|
15
|
+
// Hoisted to module scope so it is compiled once rather than rebuilt on every
|
|
16
|
+
// streamed chunk. `lastIndex` is reset at the start of each process() call.
|
|
17
|
+
const THINKING_TAG_RE = new RegExp(`<(/?)(?:${THINKING_TAG_NAMES.join("|")})\\s*>`, "gi");
|
|
18
|
+
|
|
19
|
+
export function createThinkingTagFilter() {
|
|
20
|
+
let buffer = "";
|
|
21
|
+
let inThinking = false;
|
|
22
|
+
return {
|
|
23
|
+
process(text: string) {
|
|
24
|
+
const input = buffer + text;
|
|
25
|
+
buffer = "";
|
|
26
|
+
let content = "";
|
|
27
|
+
let reasoning = "";
|
|
28
|
+
let lastIdx = 0;
|
|
29
|
+
const re = THINKING_TAG_RE;
|
|
30
|
+
re.lastIndex = 0;
|
|
31
|
+
let match: RegExpExecArray | null;
|
|
32
|
+
while ((match = re.exec(input)) !== null) {
|
|
33
|
+
const before = input.slice(lastIdx, match.index);
|
|
34
|
+
if (inThinking) reasoning += before;
|
|
35
|
+
else content += before;
|
|
36
|
+
inThinking = match[1] !== "/";
|
|
37
|
+
lastIdx = re.lastIndex;
|
|
38
|
+
}
|
|
39
|
+
const rest = input.slice(lastIdx);
|
|
40
|
+
const ltPos = rest.lastIndexOf("<");
|
|
41
|
+
if (
|
|
42
|
+
ltPos >= 0 &&
|
|
43
|
+
rest.length - ltPos < MAX_THINKING_TAG_LEN &&
|
|
44
|
+
/^<\/?[a-z_]*$/i.test(rest.slice(ltPos))
|
|
45
|
+
) {
|
|
46
|
+
buffer = rest.slice(ltPos);
|
|
47
|
+
const before = rest.slice(0, ltPos);
|
|
48
|
+
if (inThinking) reasoning += before;
|
|
49
|
+
else content += before;
|
|
50
|
+
} else {
|
|
51
|
+
if (inThinking) reasoning += rest;
|
|
52
|
+
else content += rest;
|
|
53
|
+
}
|
|
54
|
+
return { content, reasoning };
|
|
55
|
+
},
|
|
56
|
+
flush() {
|
|
57
|
+
const b = buffer;
|
|
58
|
+
buffer = "";
|
|
59
|
+
if (!b) return { content: "", reasoning: "" };
|
|
60
|
+
return inThinking ? { content: "", reasoning: b } : { content: b, reasoning: "" };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor MCP tool-schema encoding.
|
|
3
|
+
*
|
|
4
|
+
* Pi tools use JSON Schema, while Cursor carries each schema as a protobuf
|
|
5
|
+
* Value. The optional slimming pass removes model-facing prose without changing
|
|
6
|
+
* property names or the executable schema contract.
|
|
7
|
+
*/
|
|
8
|
+
import { create, fromJson, toBinary, type JsonValue } from "@bufbuild/protobuf";
|
|
9
|
+
import { ValueSchema } from "@bufbuild/protobuf/wkt";
|
|
10
|
+
|
|
11
|
+
import { McpToolDefinitionSchema, type McpToolDefinition } from "../proto/agent_pb.js";
|
|
12
|
+
import type { OpenAIToolDef } from "./types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether to truncate verbose tool descriptions/parameter docs before sending
|
|
16
|
+
* them to Cursor. Default ON — full Pi/MCP prose often costs tens of thousands
|
|
17
|
+
* of tokens per turn without improving tool selection. Set
|
|
18
|
+
* PI_CURSOR_SLIM_TOOLS=0 to keep the original tool definitions.
|
|
19
|
+
*/
|
|
20
|
+
export function isSlimToolsEnabled(envValue = process.env.PI_CURSOR_SLIM_TOOLS): boolean {
|
|
21
|
+
const raw = envValue?.trim().toLowerCase();
|
|
22
|
+
if (!raw) return true;
|
|
23
|
+
return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SCHEMA_ANNOTATION_KEYS = new Set([
|
|
27
|
+
"description",
|
|
28
|
+
"title",
|
|
29
|
+
"examples",
|
|
30
|
+
"default",
|
|
31
|
+
"$comment",
|
|
32
|
+
"$schema",
|
|
33
|
+
"$id",
|
|
34
|
+
"deprecated",
|
|
35
|
+
"readOnly",
|
|
36
|
+
"writeOnly",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** Keywords whose values are maps from user-defined names to child schemas. */
|
|
40
|
+
const NAMED_SCHEMA_MAP_KEYS = new Set([
|
|
41
|
+
"properties",
|
|
42
|
+
"patternProperties",
|
|
43
|
+
"$defs",
|
|
44
|
+
"definitions",
|
|
45
|
+
"dependentSchemas",
|
|
46
|
+
// Draft-07 dependencies values may be either child schemas or string arrays.
|
|
47
|
+
"dependencies",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** Keywords whose value is one child schema or an array of child schemas. */
|
|
51
|
+
const CHILD_SCHEMA_KEYS = new Set([
|
|
52
|
+
"additionalProperties",
|
|
53
|
+
"unevaluatedProperties",
|
|
54
|
+
"propertyNames",
|
|
55
|
+
"contains",
|
|
56
|
+
"items",
|
|
57
|
+
"additionalItems",
|
|
58
|
+
"unevaluatedItems",
|
|
59
|
+
"if",
|
|
60
|
+
"then",
|
|
61
|
+
"else",
|
|
62
|
+
"not",
|
|
63
|
+
"contentSchema",
|
|
64
|
+
"allOf",
|
|
65
|
+
"anyOf",
|
|
66
|
+
"oneOf",
|
|
67
|
+
"prefixItems",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function slimChildSchema(value: unknown, depth: number): unknown {
|
|
71
|
+
if (Array.isArray(value)) return value.map((item) => slimJsonSchema(item, depth));
|
|
72
|
+
return slimJsonSchema(value, depth);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Slim a map such as `properties` without interpreting its user-defined keys as
|
|
77
|
+
* JSON Schema keywords. A parameter may legally be named `description`,
|
|
78
|
+
* `default`, or any other annotation keyword.
|
|
79
|
+
*/
|
|
80
|
+
function slimNamedSchemaMap(value: unknown, depth: number): unknown {
|
|
81
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
82
|
+
return Object.fromEntries(
|
|
83
|
+
Object.entries(value as Record<string, unknown>).map(([name, childSchema]) => [
|
|
84
|
+
name,
|
|
85
|
+
slimChildSchema(childSchema, depth),
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Remove prose-only annotations from actual JSON Schema nodes while preserving
|
|
92
|
+
* the executable contract. Traversal is keyword-aware: blindly recursing into
|
|
93
|
+
* `properties` treats a parameter named `description` as an annotation and
|
|
94
|
+
* leaves an invalid dangling entry in `required`.
|
|
95
|
+
*/
|
|
96
|
+
function slimJsonSchema(value: unknown, depth = 0): unknown {
|
|
97
|
+
if (value == null || depth > 12 || typeof value !== "object") return value;
|
|
98
|
+
if (Array.isArray(value)) return value.map((item) => slimJsonSchema(item, depth + 1));
|
|
99
|
+
|
|
100
|
+
const input = value as Record<string, unknown>;
|
|
101
|
+
const out: Record<string, unknown> = {};
|
|
102
|
+
for (const [key, child] of Object.entries(input)) {
|
|
103
|
+
if (SCHEMA_ANNOTATION_KEYS.has(key) || child === undefined) continue;
|
|
104
|
+
if (key === "additionalProperties" && child === true) continue;
|
|
105
|
+
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
|
|
106
|
+
if (NAMED_SCHEMA_MAP_KEYS.has(key)) {
|
|
107
|
+
out[key] = slimNamedSchemaMap(child, depth + 1);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (CHILD_SCHEMA_KEYS.has(key)) {
|
|
111
|
+
out[key] = slimChildSchema(child, depth + 1);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Constraints and literal values (for example enum/const) are executable
|
|
115
|
+
// data, not schema containers. Keep them byte-for-byte equivalent.
|
|
116
|
+
out[key] = child;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function conciseToolDescription(description: string): string {
|
|
122
|
+
const normalized = description.replace(/\s+/g, " ").trim();
|
|
123
|
+
if (normalized.length <= 120) return normalized;
|
|
124
|
+
const firstSentence = normalized.match(/^.{24,117}?[.!?](?:\s|$)/)?.[0]?.trim();
|
|
125
|
+
return firstSentence || `${normalized.slice(0, 117)}...`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Compact tool prose/schemas for the Cursor MCP tool surface. */
|
|
129
|
+
export function slimOpenAIToolsForCursor(tools: OpenAIToolDef[]): OpenAIToolDef[] {
|
|
130
|
+
if (!isSlimToolsEnabled()) return tools;
|
|
131
|
+
return tools.map((tool) => {
|
|
132
|
+
const fn = tool.function;
|
|
133
|
+
const parameters =
|
|
134
|
+
fn.parameters && typeof fn.parameters === "object"
|
|
135
|
+
? (slimJsonSchema(fn.parameters) as Record<string, unknown>)
|
|
136
|
+
: fn.parameters;
|
|
137
|
+
return {
|
|
138
|
+
...tool,
|
|
139
|
+
function: {
|
|
140
|
+
...fn,
|
|
141
|
+
description: conciseToolDescription(fn.description || ""),
|
|
142
|
+
...(parameters ? { parameters } : {}),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Pi typically hands the provider the same `tools` array reference turn after
|
|
149
|
+
// turn. Cache the pure schema preparation + protobuf encoding by that identity
|
|
150
|
+
// and the env-controlled slimming mode.
|
|
151
|
+
const mcpToolDefinitionsCache = new WeakMap<OpenAIToolDef[], Map<boolean, McpToolDefinition[]>>();
|
|
152
|
+
|
|
153
|
+
export function buildMcpToolDefinitions(tools: OpenAIToolDef[]): McpToolDefinition[] {
|
|
154
|
+
const slimEnabled = isSlimToolsEnabled();
|
|
155
|
+
const byMode = mcpToolDefinitionsCache.get(tools);
|
|
156
|
+
const cached = byMode?.get(slimEnabled);
|
|
157
|
+
if (cached) return cached;
|
|
158
|
+
|
|
159
|
+
const prepared = slimOpenAIToolsForCursor(tools);
|
|
160
|
+
const result = prepared.map((tool) => {
|
|
161
|
+
const fn = tool.function;
|
|
162
|
+
const jsonSchema: JsonValue =
|
|
163
|
+
fn.parameters && typeof fn.parameters === "object"
|
|
164
|
+
? (fn.parameters as JsonValue)
|
|
165
|
+
: { type: "object", properties: {}, required: [] };
|
|
166
|
+
// Cursor CLI's current schema uses google.protobuf.Value for
|
|
167
|
+
// McpToolDefinition.input_schema. The committed generated schema still
|
|
168
|
+
// exposes that field as bytes, but the outer wire encoding is identical
|
|
169
|
+
// for bytes and message fields (length-delimited field #3), so place the
|
|
170
|
+
// serialized Value bytes here.
|
|
171
|
+
const inputSchema = toBinary(ValueSchema, fromJson(ValueSchema, jsonSchema));
|
|
172
|
+
return create(McpToolDefinitionSchema, {
|
|
173
|
+
name: fn.name,
|
|
174
|
+
description: fn.description || "",
|
|
175
|
+
providerIdentifier: "pi",
|
|
176
|
+
toolName: fn.name,
|
|
177
|
+
inputSchema,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const modes = byMode ?? new Map<boolean, McpToolDefinition[]>();
|
|
182
|
+
modes.set(slimEnabled, result);
|
|
183
|
+
mcpToolDefinitionsCache.set(tools, modes);
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured classification for Cursor transport failures.
|
|
3
|
+
*
|
|
4
|
+
* Callers use this to decide whether a transport close / Connect error is safe to
|
|
5
|
+
* recover from (new generation + checkpoint/history) versus permanent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TransportFailureKind } from "../types/enums.js";
|
|
9
|
+
|
|
10
|
+
export { TransportFailureKind };
|
|
11
|
+
|
|
12
|
+
export interface TransportFailure {
|
|
13
|
+
kind: TransportFailureKind;
|
|
14
|
+
/** Whether a new stream generation may be attempted. */
|
|
15
|
+
retryable: boolean;
|
|
16
|
+
/** Prefer force-refreshing credentials before the next attempt. */
|
|
17
|
+
refreshAuth: boolean;
|
|
18
|
+
/** Human-readable summary (secrets must already be redacted by caller). */
|
|
19
|
+
message: string;
|
|
20
|
+
/** Optional normalized transport close code. */
|
|
21
|
+
exitCode?: number;
|
|
22
|
+
/** Optional in-process transport diagnostics tail. */
|
|
23
|
+
stderr?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const AUTH_RE =
|
|
27
|
+
/\b(401|403|unauthenticated|unauthorized|forbidden|invalid[_ ]token|token (?:expired|revoked)|auth(?:entication|orization) (?:failed|error))\b/i;
|
|
28
|
+
const RATE_RE = /\b(429|resource_exhausted|rate[_ ]?limit|too many requests)\b/i;
|
|
29
|
+
const GOAWAY_RE = /\bGOAWAY\b/i;
|
|
30
|
+
const RESET_RE = /\b(ECONNRESET|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|connection reset)\b/i;
|
|
31
|
+
const TIMEOUT_RE = /\b(ETIMEDOUT|timed? ?out|timeout)\b/i;
|
|
32
|
+
const PROTOCOL_RE = /\b(protocol|protobuf|wire drift|invalid argument|failed_precondition)\b/i;
|
|
33
|
+
|
|
34
|
+
export function classifyBridgeExit(input: {
|
|
35
|
+
exitCode: number;
|
|
36
|
+
stderr?: string;
|
|
37
|
+
endErrorMessage?: string;
|
|
38
|
+
}): TransportFailure {
|
|
39
|
+
const stderr = (input.stderr || "").trim();
|
|
40
|
+
const endError = (input.endErrorMessage || "").trim();
|
|
41
|
+
const combined = [endError, stderr].filter(Boolean).join(" | ");
|
|
42
|
+
|
|
43
|
+
if (input.exitCode === 2 || GOAWAY_RE.test(combined)) {
|
|
44
|
+
return {
|
|
45
|
+
kind: TransportFailureKind.Goaway,
|
|
46
|
+
retryable: true,
|
|
47
|
+
refreshAuth: false,
|
|
48
|
+
message: combined
|
|
49
|
+
? `Cursor upstream closed the connection (GOAWAY): ${combined}`
|
|
50
|
+
: "Cursor upstream closed the connection (GOAWAY).",
|
|
51
|
+
exitCode: input.exitCode,
|
|
52
|
+
stderr: stderr || undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (AUTH_RE.test(combined)) {
|
|
57
|
+
return {
|
|
58
|
+
kind: TransportFailureKind.Authentication,
|
|
59
|
+
retryable: true,
|
|
60
|
+
refreshAuth: true,
|
|
61
|
+
message: combined || "Cursor authentication failed.",
|
|
62
|
+
exitCode: input.exitCode,
|
|
63
|
+
stderr: stderr || undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (RATE_RE.test(combined)) {
|
|
68
|
+
return {
|
|
69
|
+
kind: TransportFailureKind.RateLimit,
|
|
70
|
+
retryable: true,
|
|
71
|
+
refreshAuth: false,
|
|
72
|
+
message: combined || "Cursor rate limited the request.",
|
|
73
|
+
exitCode: input.exitCode,
|
|
74
|
+
stderr: stderr || undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (TIMEOUT_RE.test(combined)) {
|
|
79
|
+
return {
|
|
80
|
+
kind: TransportFailureKind.SocketTimeout,
|
|
81
|
+
retryable: true,
|
|
82
|
+
refreshAuth: false,
|
|
83
|
+
message: combined || "Cursor transport timed out.",
|
|
84
|
+
exitCode: input.exitCode,
|
|
85
|
+
stderr: stderr || undefined,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (RESET_RE.test(combined)) {
|
|
90
|
+
return {
|
|
91
|
+
kind: TransportFailureKind.ConnectionReset,
|
|
92
|
+
retryable: true,
|
|
93
|
+
refreshAuth: false,
|
|
94
|
+
message: combined || "Cursor connection was reset.",
|
|
95
|
+
exitCode: input.exitCode,
|
|
96
|
+
stderr: stderr || undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (PROTOCOL_RE.test(combined)) {
|
|
101
|
+
return {
|
|
102
|
+
kind: TransportFailureKind.ProtocolDrift,
|
|
103
|
+
retryable: false,
|
|
104
|
+
refreshAuth: false,
|
|
105
|
+
message: combined || "Cursor protocol mismatch.",
|
|
106
|
+
exitCode: input.exitCode,
|
|
107
|
+
stderr: stderr || undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (input.exitCode !== 0) {
|
|
112
|
+
return {
|
|
113
|
+
kind: TransportFailureKind.BridgeCrash,
|
|
114
|
+
retryable: true,
|
|
115
|
+
refreshAuth: false,
|
|
116
|
+
message: combined
|
|
117
|
+
? `Cursor transport connection lost (code ${input.exitCode}): ${combined}`
|
|
118
|
+
: `Cursor transport connection lost (code ${input.exitCode}).`,
|
|
119
|
+
exitCode: input.exitCode,
|
|
120
|
+
stderr: stderr || undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
kind: TransportFailureKind.Unknown,
|
|
126
|
+
retryable: false,
|
|
127
|
+
refreshAuth: false,
|
|
128
|
+
message: combined || "Cursor transport closed cleanly.",
|
|
129
|
+
exitCode: input.exitCode,
|
|
130
|
+
stderr: stderr || undefined,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function formatTransportFailure(failure: TransportFailure): string {
|
|
135
|
+
const hints: string[] = [];
|
|
136
|
+
if (failure.retryable) {
|
|
137
|
+
hints.push("A checkpoint/history recovery may continue the turn.");
|
|
138
|
+
}
|
|
139
|
+
if (failure.refreshAuth) {
|
|
140
|
+
hints.push("Token may need refresh — run /login cursor or check /cursor.doctor tokenSource.");
|
|
141
|
+
}
|
|
142
|
+
if (failure.kind === TransportFailureKind.ProtocolDrift) {
|
|
143
|
+
hints.push("Pin PI_CURSOR_CLIENT_VERSION or regenerate proto if wire drift persists.");
|
|
144
|
+
}
|
|
145
|
+
return hints.length ? `${failure.message} ${hints.join(" ")}` : failure.message;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Synthetic user text used when resuming a run after transport loss with a checkpoint. */
|
|
149
|
+
export const CHECKPOINT_CONTINUATION_PROMPT =
|
|
150
|
+
"[pi-cursor] The previous upstream stream was interrupted. Continue exactly where you left off. Do not repeat content already produced.";
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuning knobs for the stream runtime: timeouts, retry budgets, and the
|
|
3
|
+
* silence watchdog that backs them.
|
|
4
|
+
*
|
|
5
|
+
* Every `resolve*` function takes the raw env string so the parsing rules
|
|
6
|
+
* (blank = default, 0 = disabled, floors on the rest) are unit-testable without
|
|
7
|
+
* mutating `process.env`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CONVERSATION_TTL_MS = 30 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ACTIVE_BRIDGE_TTL_MS = 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
// Safety net against permanent hangs: if the upstream stream produces no progress
|
|
15
|
+
// for this long, the watchdog recovers/retries or ends the turn with a clear error
|
|
16
|
+
// instead of parking forever. Real work (textDelta, thinkingDelta, tokenDelta,
|
|
17
|
+
// tool-call events, answered interaction/exec) always resets it, and it is paused
|
|
18
|
+
// during tool execution — so long reasoning turns and slow tools are unaffected.
|
|
19
|
+
// Set the env vars to 0 to disable. 3 minutes: long pure-thinking stretches without
|
|
20
|
+
// tokenDelta still need headroom, while a true silent park should not hang forever.
|
|
21
|
+
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 180_000;
|
|
22
|
+
|
|
23
|
+
// A stream parked on an exec we could not answer is not slow, it is finished: Cursor
|
|
24
|
+
// waits for a reply that will never arrive while its heartbeats keep the connection
|
|
25
|
+
// alive. Liveness stops counting as progress once that happens (see StreamProgress),
|
|
26
|
+
// and this shorter deadline gives the generic ExecClientThrow answer time to unpark
|
|
27
|
+
// the run before the turn is failed.
|
|
28
|
+
export const DEFAULT_STREAM_PARK_TIMEOUT_MS = 45_000;
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_RESUME_IDLE_TIMEOUT_MS = 180_000;
|
|
31
|
+
|
|
32
|
+
// More generations for multi-hour agent sessions; each attempt can force-refresh auth.
|
|
33
|
+
export const DEFAULT_STREAM_IDLE_MAX_RETRIES = 5;
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_MIDPAUSE_REBUILD_MAX_AGE_MS = 15 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
/** Soft cap on retained blob bytes per conversation (images + turn blobs). */
|
|
38
|
+
export const MAX_CONVERSATION_BLOB_BYTES = 128 * 1024 * 1024;
|
|
39
|
+
export const MAX_ACTIVE_BLOB_BYTES = MAX_CONVERSATION_BLOB_BYTES;
|
|
40
|
+
// Entry bound, evicted oldest-first alongside the byte bound. Turn blobs run a
|
|
41
|
+
// couple of KB, so this is reached long before the byte cap on any conversation
|
|
42
|
+
// that lives for a few hundred tool calls; it bounds Map overhead, and matches
|
|
43
|
+
// the number of blobs the run journal is willing to persist.
|
|
44
|
+
export const MAX_ACTIVE_BLOB_ENTRIES = 512;
|
|
45
|
+
export const MAX_INDIVIDUAL_BLOB_BYTES = 32 * 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hard cap on a stored upstream checkpoint's byte size. Unlike blobs and tool
|
|
49
|
+
* results, the checkpoint Cursor hands back is opaque and unbounded — over a
|
|
50
|
+
* long-running conversation it can grow past the transport's 64 MiB Connect
|
|
51
|
+
* frame limit, at which point the checkpoint would fail every future turn
|
|
52
|
+
* (frameConnectMessage throws before anything is sent). Discarding it above
|
|
53
|
+
* this cap forces a rebuild from the (bounded) blob store instead, leaving
|
|
54
|
+
* plenty of headroom under the transport limit for mcpTools/model metadata.
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_CHECKPOINT_BYTES = 48 * 1024 * 1024;
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_H2_CONNECT_TIMEOUT_MS = 30_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Activity idle kill after first I/O. Default 0 (disabled): parent heartbeats
|
|
62
|
+
* already keep the bridge alive, and a hard idle kill during long tool pauses
|
|
63
|
+
* was a common source of "Bridge connection lost" mid-session. Set
|
|
64
|
+
* PI_CURSOR_H2_IDLE_TIMEOUT_MS to re-enable a safety net.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_H2_IDLE_TIMEOUT_MS = 0;
|
|
67
|
+
|
|
68
|
+
export function resolveActiveBridgeTtlMs(envValue?: string): number {
|
|
69
|
+
if (envValue === undefined || envValue === "") return DEFAULT_ACTIVE_BRIDGE_TTL_MS;
|
|
70
|
+
const parsed = Number(envValue);
|
|
71
|
+
if (!Number.isFinite(parsed)) return DEFAULT_ACTIVE_BRIDGE_TTL_MS;
|
|
72
|
+
return Math.max(1_000, parsed);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveStreamIdleTimeoutMs(envValue?: string): number {
|
|
76
|
+
const normalized = envValue?.trim();
|
|
77
|
+
if (normalized === undefined || normalized === "") return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
78
|
+
const parsed = Number(normalized);
|
|
79
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
80
|
+
if (parsed === 0) return 0;
|
|
81
|
+
return Math.max(1_000, Math.floor(parsed));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveStreamIdleMaxRetries(envValue?: string): number {
|
|
85
|
+
const normalized = envValue?.trim();
|
|
86
|
+
if (normalized === undefined || normalized === "") return DEFAULT_STREAM_IDLE_MAX_RETRIES;
|
|
87
|
+
const parsed = Number(normalized);
|
|
88
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_STREAM_IDLE_MAX_RETRIES;
|
|
89
|
+
if (parsed === 0) return 0;
|
|
90
|
+
return Math.min(10, Math.max(1, Math.floor(parsed)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resolveResumeIdleTimeoutMs(envValue?: string): number {
|
|
94
|
+
const normalized = envValue?.trim();
|
|
95
|
+
if (normalized === undefined || normalized === "") return DEFAULT_RESUME_IDLE_TIMEOUT_MS;
|
|
96
|
+
const parsed = Number(normalized);
|
|
97
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_RESUME_IDLE_TIMEOUT_MS;
|
|
98
|
+
if (parsed === 0) return 0;
|
|
99
|
+
return Math.max(1_000, Math.floor(parsed));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveH2ConnectTimeoutMs(envValue?: string): number {
|
|
103
|
+
const normalized = envValue?.trim();
|
|
104
|
+
if (normalized === undefined || normalized === "") return DEFAULT_H2_CONNECT_TIMEOUT_MS;
|
|
105
|
+
const parsed = Number(normalized);
|
|
106
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_H2_CONNECT_TIMEOUT_MS;
|
|
107
|
+
if (parsed === 0) return 0;
|
|
108
|
+
return Math.max(1_000, Math.floor(parsed));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resolveH2IdleTimeoutMs(envValue?: string): number {
|
|
112
|
+
const normalized = envValue?.trim();
|
|
113
|
+
if (normalized === undefined || normalized === "") return DEFAULT_H2_IDLE_TIMEOUT_MS;
|
|
114
|
+
const parsed = Number(normalized);
|
|
115
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_H2_IDLE_TIMEOUT_MS;
|
|
116
|
+
if (parsed === 0) return 0;
|
|
117
|
+
return Math.max(5_000, Math.floor(parsed));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What a server message says about the stream.
|
|
122
|
+
*
|
|
123
|
+
* `work` is the run moving: tokens, tool calls, answered execs, checkpoints.
|
|
124
|
+
* `liveness` is the connection breathing while the run itself may be stuck —
|
|
125
|
+
* a heartbeat proves the socket, not the turn. `none` is noise.
|
|
126
|
+
*
|
|
127
|
+
* The distinction exists because a park is not silent: Cursor keeps heartbeating
|
|
128
|
+
* a run that is waiting for an exec reply we never sent, which made a
|
|
129
|
+
* silence-only watchdog unable to ever fire (a Grok session sat parked for 90
|
|
130
|
+
* minutes on an unknown exec case in 2026-08).
|
|
131
|
+
*/
|
|
132
|
+
export type StreamProgress = "none" | "liveness" | "work";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* How an interaction-update case should be treated by the stream idle watchdog.
|
|
136
|
+
* tokenDelta is work: long reasoning turns emit it without text for minutes at a
|
|
137
|
+
* time, and it only flows while the model is actually generating.
|
|
138
|
+
*/
|
|
139
|
+
export function interactionUpdateProgress(
|
|
140
|
+
updateCase: string | undefined,
|
|
141
|
+
hasNonEmptyText = false,
|
|
142
|
+
): StreamProgress {
|
|
143
|
+
if (updateCase === "textDelta" || updateCase === "thinkingDelta")
|
|
144
|
+
return hasNonEmptyText ? "work" : "none";
|
|
145
|
+
if (updateCase === "heartbeat") return "liveness";
|
|
146
|
+
if (updateCase === "tokenDelta") return "work";
|
|
147
|
+
if (updateCase === "toolCallCompleted") return "work";
|
|
148
|
+
if (updateCase === "toolCallStarted") return "work";
|
|
149
|
+
if (updateCase === "partialToolCall") return "work";
|
|
150
|
+
if (updateCase === "toolCallDelta") return "work";
|
|
151
|
+
if (updateCase === "thinkingCompleted") return "work";
|
|
152
|
+
if (
|
|
153
|
+
updateCase === "summary" ||
|
|
154
|
+
updateCase === "summaryStarted" ||
|
|
155
|
+
updateCase === "summaryCompleted"
|
|
156
|
+
)
|
|
157
|
+
return "work";
|
|
158
|
+
return "none";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether a blind full-request restart is safe given already-streamed content. */
|
|
162
|
+
export function canBlindIdleRestart(emittedUserVisibleContent: boolean): boolean {
|
|
163
|
+
return !emittedUserVisibleContent;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether recovery is allowed after transport loss.
|
|
168
|
+
* Blind restart only when nothing was streamed; checkpoint continuation is safe
|
|
169
|
+
* even after partial text because Cursor resumes server-side state and emits
|
|
170
|
+
* only new tokens (Pi appends them to the existing writer).
|
|
171
|
+
*/
|
|
172
|
+
export function canRecoverAfterTransportLoss(input: {
|
|
173
|
+
emittedUserVisibleContent: boolean;
|
|
174
|
+
hasCheckpoint: boolean;
|
|
175
|
+
}): boolean {
|
|
176
|
+
if (!input.emittedUserVisibleContent) return true;
|
|
177
|
+
return input.hasCheckpoint;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resolveMidPauseRebuildMaxAgeMs(envValue?: string): number {
|
|
181
|
+
const normalized = envValue?.trim();
|
|
182
|
+
if (normalized === undefined || normalized === "") return DEFAULT_MIDPAUSE_REBUILD_MAX_AGE_MS;
|
|
183
|
+
const parsed = Number(normalized);
|
|
184
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_MIDPAUSE_REBUILD_MAX_AGE_MS;
|
|
185
|
+
// Zero should keep the replay trust window bounded; negative values are treated as invalid.
|
|
186
|
+
return Math.max(1_000, Math.floor(parsed));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createStreamIdleWatchdog(options: { timeoutMs: number; onTimeout: () => void }): {
|
|
190
|
+
start(): void;
|
|
191
|
+
reset(): void;
|
|
192
|
+
pause(): void;
|
|
193
|
+
resume(): void;
|
|
194
|
+
clear(): void;
|
|
195
|
+
setTimeoutMs(timeoutMs: number): void;
|
|
196
|
+
} {
|
|
197
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
198
|
+
let started = false;
|
|
199
|
+
let paused = false;
|
|
200
|
+
let fired = false;
|
|
201
|
+
let timeoutMs = options.timeoutMs;
|
|
202
|
+
|
|
203
|
+
const clear = () => {
|
|
204
|
+
if (timer) clearTimeout(timer);
|
|
205
|
+
timer = undefined;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const arm = () => {
|
|
209
|
+
clear();
|
|
210
|
+
if (timeoutMs <= 0 || paused || fired) return;
|
|
211
|
+
timer = setTimeout(() => {
|
|
212
|
+
timer = undefined;
|
|
213
|
+
fired = true;
|
|
214
|
+
options.onTimeout();
|
|
215
|
+
}, timeoutMs);
|
|
216
|
+
(timer as { unref?: () => void }).unref?.();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
start() {
|
|
221
|
+
if (started) return;
|
|
222
|
+
started = true;
|
|
223
|
+
paused = false;
|
|
224
|
+
arm();
|
|
225
|
+
},
|
|
226
|
+
reset() {
|
|
227
|
+
if (paused || fired) return;
|
|
228
|
+
arm();
|
|
229
|
+
},
|
|
230
|
+
pause() {
|
|
231
|
+
paused = true;
|
|
232
|
+
clear();
|
|
233
|
+
},
|
|
234
|
+
resume() {
|
|
235
|
+
if (fired) return;
|
|
236
|
+
paused = false;
|
|
237
|
+
arm();
|
|
238
|
+
},
|
|
239
|
+
setTimeoutMs(next: number) {
|
|
240
|
+
if (next === timeoutMs) return;
|
|
241
|
+
timeoutMs = next;
|
|
242
|
+
if (started) arm();
|
|
243
|
+
},
|
|
244
|
+
clear,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const ACTIVE_BRIDGE_TTL_MS = resolveActiveBridgeTtlMs(
|
|
249
|
+
process.env.PI_CURSOR_ACTIVE_BRIDGE_TTL_MS,
|
|
250
|
+
);
|