@evanovation/open-cursor 2.4.15
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/LICENSE +28 -0
- package/README.md +270 -0
- package/dist/cli/discover.js +527 -0
- package/dist/cli/mcptool.js +10339 -0
- package/dist/cli/opencode-cursor.js +2989 -0
- package/dist/index.js +20588 -0
- package/dist/plugin-entry.js +19848 -0
- package/package.json +82 -0
- package/scripts/cursor-agent-runner.mjs +272 -0
- package/scripts/sdk-runner.mjs +412 -0
- package/src/acp/metrics.ts +83 -0
- package/src/acp/sessions.ts +107 -0
- package/src/acp/tools.ts +209 -0
- package/src/auth.ts +175 -0
- package/src/cli/discover.ts +53 -0
- package/src/cli/mcptool.ts +133 -0
- package/src/cli/model-discovery.ts +71 -0
- package/src/cli/opencode-cursor.ts +1195 -0
- package/src/client/cursor-agent-child.ts +459 -0
- package/src/client/sdk-child.ts +550 -0
- package/src/client/simple.ts +293 -0
- package/src/commands/status.ts +39 -0
- package/src/index.ts +39 -0
- package/src/mcp/client-manager.ts +166 -0
- package/src/mcp/config.ts +169 -0
- package/src/mcp/tool-bridge.ts +133 -0
- package/src/models/config.ts +64 -0
- package/src/models/discovery.ts +105 -0
- package/src/models/index.ts +3 -0
- package/src/models/pricing.ts +196 -0
- package/src/models/sync.ts +247 -0
- package/src/models/types.ts +11 -0
- package/src/models/variants.ts +446 -0
- package/src/plugin-entry.ts +28 -0
- package/src/plugin-toggle.ts +81 -0
- package/src/plugin.ts +2802 -0
- package/src/provider/backend.ts +71 -0
- package/src/provider/boundary.ts +168 -0
- package/src/provider/passthrough-tracker.ts +38 -0
- package/src/provider/runtime-interception.ts +818 -0
- package/src/provider/tool-loop-guard.ts +644 -0
- package/src/provider/tool-schema-compat.ts +800 -0
- package/src/provider.ts +268 -0
- package/src/proxy/formatter.ts +60 -0
- package/src/proxy/handler.ts +29 -0
- package/src/proxy/incremental-prompt.ts +74 -0
- package/src/proxy/prompt-builder.ts +204 -0
- package/src/proxy/server.ts +207 -0
- package/src/proxy/session-resume.ts +312 -0
- package/src/proxy/tool-loop.ts +359 -0
- package/src/proxy/types.ts +13 -0
- package/src/services/toast-service.ts +81 -0
- package/src/streaming/ai-sdk-parts.ts +109 -0
- package/src/streaming/delta-tracker.ts +89 -0
- package/src/streaming/line-buffer.ts +44 -0
- package/src/streaming/openai-sse.ts +118 -0
- package/src/streaming/parser.ts +22 -0
- package/src/streaming/types.ts +158 -0
- package/src/tools/core/executor.ts +25 -0
- package/src/tools/core/registry.ts +27 -0
- package/src/tools/core/types.ts +31 -0
- package/src/tools/defaults.ts +954 -0
- package/src/tools/discovery.ts +140 -0
- package/src/tools/executors/cli.ts +59 -0
- package/src/tools/executors/local.ts +25 -0
- package/src/tools/executors/mcp.ts +39 -0
- package/src/tools/executors/sdk.ts +39 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/router.ts +123 -0
- package/src/tools/schema.ts +58 -0
- package/src/tools/skills/loader.ts +61 -0
- package/src/tools/skills/resolver.ts +21 -0
- package/src/tools/types.ts +29 -0
- package/src/types.ts +8 -0
- package/src/usage.ts +112 -0
- package/src/utils/binary.ts +71 -0
- package/src/utils/errors.ts +224 -0
- package/src/utils/logger.ts +191 -0
- package/src/utils/perf.ts +76 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
import type { ProxyConfig, ProxyServer } from "./types.js";
|
|
5
|
+
import { createLogger } from "../utils/logger.js";
|
|
6
|
+
|
|
7
|
+
const log = createLogger("proxy-server");
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PORT = 32124;
|
|
10
|
+
const PORT_RANGE_SIZE = 256;
|
|
11
|
+
|
|
12
|
+
async function isPortAvailable(port: number, host: string): Promise<boolean> {
|
|
13
|
+
return await new Promise<boolean>((resolve) => {
|
|
14
|
+
const server = createServer();
|
|
15
|
+
server.unref();
|
|
16
|
+
|
|
17
|
+
server.once("error", () => {
|
|
18
|
+
resolve(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
server.listen({ port, host }, () => {
|
|
22
|
+
server.close(() => {
|
|
23
|
+
resolve(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the set of ports in [minPort, maxPort) that are currently in use (listening).
|
|
31
|
+
* Uses platform-specific commands:
|
|
32
|
+
* - Linux: `ss -tlnH`
|
|
33
|
+
* - macOS: `lsof -iTCP -sTCP:LISTEN -nP`
|
|
34
|
+
* Falls back to empty set if command is unavailable (e.g., Windows).
|
|
35
|
+
*/
|
|
36
|
+
function getUsedPortsInRange(minPort: number, maxPort: number): Set<number> {
|
|
37
|
+
const used = new Set<number>();
|
|
38
|
+
const os = platform();
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
let out: string;
|
|
42
|
+
if (os === "linux") {
|
|
43
|
+
out = execSync("ss -tlnH", { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
|
|
44
|
+
// ss output format: State Recv-Q Send-Q Local-Address:Port Peer-Address:Port
|
|
45
|
+
for (const line of out.split("\n")) {
|
|
46
|
+
const cols = line.trim().split(/\s+/);
|
|
47
|
+
const local = cols[3]; // e.g. "127.0.0.1:32124" or "*:22"
|
|
48
|
+
if (!local) continue;
|
|
49
|
+
const portStr = local.includes(":") ? local.slice(local.lastIndexOf(":") + 1) : local;
|
|
50
|
+
const port = parseInt(portStr, 10);
|
|
51
|
+
if (!Number.isNaN(port) && port >= minPort && port < maxPort) used.add(port);
|
|
52
|
+
}
|
|
53
|
+
} else if (os === "darwin") {
|
|
54
|
+
out = execSync("lsof -iTCP -sTCP:LISTEN -nP", { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
|
|
55
|
+
// lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
56
|
+
// NAME column contains: *:PORT or 127.0.0.1:PORT
|
|
57
|
+
for (const line of out.split("\n")) {
|
|
58
|
+
const match = line.match(/:(\d+)\s*(?:\(LISTEN\))?$/);
|
|
59
|
+
if (match) {
|
|
60
|
+
const port = parseInt(match[1], 10);
|
|
61
|
+
if (!Number.isNaN(port) && port >= minPort && port < maxPort) used.add(port);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// Windows and other platforms: no port detection available
|
|
66
|
+
// Will fall back to probe-based discovery via tryStart failures
|
|
67
|
+
log.debug(`Port detection not supported on ${os}. Using probe-based discovery.`);
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
71
|
+
log.debug(`Port detection failed: ${msg}. Using probe-based discovery.`);
|
|
72
|
+
}
|
|
73
|
+
return used;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Finds an available port in [DEFAULT_PORT, DEFAULT_PORT + PORT_RANGE_SIZE).
|
|
78
|
+
* Uses platform-specific tools (ss on Linux, lsof on macOS) to check used ports.
|
|
79
|
+
* On unsupported platforms, returns DEFAULT_PORT and relies on tryStart fallback.
|
|
80
|
+
*/
|
|
81
|
+
export async function findAvailablePort(host = "127.0.0.1"): Promise<number> {
|
|
82
|
+
const minPort = DEFAULT_PORT;
|
|
83
|
+
const maxPort = DEFAULT_PORT + PORT_RANGE_SIZE;
|
|
84
|
+
const used = getUsedPortsInRange(minPort, maxPort);
|
|
85
|
+
for (let p = minPort; p < maxPort; p++) {
|
|
86
|
+
if (used.has(p)) continue;
|
|
87
|
+
if (await isPortAvailable(p, host)) {
|
|
88
|
+
return p;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Port listing can be incomplete in sandboxed environments; fall back to probing
|
|
93
|
+
// ports we believe are "used" as well.
|
|
94
|
+
for (let p = minPort; p < maxPort; p++) {
|
|
95
|
+
if (await isPortAvailable(p, host)) {
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`No available port in range ${minPort}-${maxPort - 1}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createProxyServer(config: ProxyConfig): ProxyServer {
|
|
103
|
+
const requestedPort = config.port ?? 0;
|
|
104
|
+
const host = config.host ?? "127.0.0.1";
|
|
105
|
+
const healthCheckPath = config.healthCheckPath ?? "/health";
|
|
106
|
+
|
|
107
|
+
let server: any = null;
|
|
108
|
+
let baseURL = requestedPort > 0 ? `http://${host}:${requestedPort}/v1` : "";
|
|
109
|
+
|
|
110
|
+
const bunAny = (globalThis as any).Bun;
|
|
111
|
+
|
|
112
|
+
// Check Bun runtime availability
|
|
113
|
+
if (!bunAny || typeof bunAny.serve !== "function") {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Proxy server requires Bun runtime. Current runtime: ${typeof process !== "undefined" ? "Node.js" : "unknown"}. ` +
|
|
116
|
+
`Please run with Bun.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const tryStart = (port: number): { success: boolean; error?: Error } => {
|
|
121
|
+
try {
|
|
122
|
+
server = bunAny.serve({
|
|
123
|
+
port,
|
|
124
|
+
hostname: host,
|
|
125
|
+
fetch(request: Request): Response | Promise<Response> {
|
|
126
|
+
const url = new URL(request.url);
|
|
127
|
+
const path = url.pathname;
|
|
128
|
+
|
|
129
|
+
if (path === healthCheckPath && request.method === "GET") {
|
|
130
|
+
return Response.json({ ok: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return new Response("Not Found", { status: 404 });
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
return { success: true };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
139
|
+
// Log unexpected errors (not port-in-use)
|
|
140
|
+
const isPortInUse = err.message.includes("EADDRINUSE") ||
|
|
141
|
+
err.message.includes("address already in use") ||
|
|
142
|
+
err.message.includes("port is already in use");
|
|
143
|
+
if (!isPortInUse) {
|
|
144
|
+
log.debug(`Unexpected error starting on port ${port}: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
return { success: false, error: err };
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
async start(): Promise<string> {
|
|
152
|
+
if (server) {
|
|
153
|
+
return baseURL;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let port: number;
|
|
157
|
+
if (requestedPort > 0) {
|
|
158
|
+
const result = tryStart(requestedPort);
|
|
159
|
+
if (result.success) {
|
|
160
|
+
port = requestedPort;
|
|
161
|
+
} else {
|
|
162
|
+
log.debug(
|
|
163
|
+
`Requested port ${requestedPort} unavailable: ${result.error?.message ?? "unknown"}. Falling back to automatic port selection.`
|
|
164
|
+
);
|
|
165
|
+
port = await findAvailablePort(host);
|
|
166
|
+
const fallbackResult = tryStart(port);
|
|
167
|
+
if (!fallbackResult.success) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Failed to start server on port ${requestedPort} (${result.error?.message ?? "unknown"}) ` +
|
|
170
|
+
`and fallback port ${port} (${fallbackResult.error?.message ?? "unknown"})`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
log.debug(`Server started on fallback port ${port} instead of requested port ${requestedPort}`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
port = await findAvailablePort(host);
|
|
177
|
+
const result = tryStart(port);
|
|
178
|
+
if (!result.success) {
|
|
179
|
+
throw new Error(`Failed to start server on port ${port}: ${result.error?.message ?? "unknown"}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const actualPort = server.port ?? port ?? DEFAULT_PORT;
|
|
184
|
+
baseURL = `http://${host}:${actualPort}/v1`;
|
|
185
|
+
return baseURL;
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
stop(): Promise<void> {
|
|
189
|
+
if (!server) {
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
server.stop(true);
|
|
194
|
+
server = null;
|
|
195
|
+
baseURL = "";
|
|
196
|
+
return Promise.resolve();
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
getBaseURL(): string {
|
|
200
|
+
return baseURL;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
getPort(): number | null {
|
|
204
|
+
return server?.port ?? null;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps OpenCode conversation anchors to cursor-agent chat IDs for --resume.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode does not pass its session ID through the HTTP proxy, so we derive a
|
|
5
|
+
* stable key from workspace + model + first real user message in the request.
|
|
6
|
+
*
|
|
7
|
+
* Limitations:
|
|
8
|
+
* - In-memory, non-persistent cache. Restarting the plugin loses all resume
|
|
9
|
+
* state and the next turn falls back to a full prompt.
|
|
10
|
+
* - Entries expire after 1 hour (DEFAULT_TTL_MS).
|
|
11
|
+
* - Cache is capped at 64 entries (DEFAULT_MAX_ENTRIES); least-recently-used
|
|
12
|
+
* entry is evicted when the cap is exceeded.
|
|
13
|
+
* - Anchor is derived from the first non-meta user message using a heuristic
|
|
14
|
+
* filter for OpenCode's title-generation prompts. If OpenCode rewords those
|
|
15
|
+
* prompts, the filter may need updating.
|
|
16
|
+
* - Session resume is keyed per workspace + model + first-message hash. Changing
|
|
17
|
+
* any of those starts a fresh chat.
|
|
18
|
+
* - Session resume is only supported for the cursor-agent backend.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { createLogger } from "../utils/logger.js";
|
|
23
|
+
import { extractTextContent, type ProxyMessage } from "./incremental-prompt.js";
|
|
24
|
+
|
|
25
|
+
const log = createLogger("session-resume");
|
|
26
|
+
|
|
27
|
+
/** Safe resume chat ID pattern: alphanumeric, hyphen, underscore; no spaces or shell metacharacters. */
|
|
28
|
+
export const RESUME_CHAT_ID_SAFE_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
29
|
+
|
|
30
|
+
interface SessionResumeEntry {
|
|
31
|
+
chatId: string;
|
|
32
|
+
/** First-message content prefix used as a collision safety check on lookup. */
|
|
33
|
+
contentPrefix: string;
|
|
34
|
+
/** Fingerprint of the tool schema active when the session was created. */
|
|
35
|
+
toolFingerprint?: string;
|
|
36
|
+
/** Fingerprint of the subagent list active when the session was created. */
|
|
37
|
+
subagentFingerprint?: string;
|
|
38
|
+
updatedAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
42
|
+
const DEFAULT_MAX_ENTRIES = 64;
|
|
43
|
+
|
|
44
|
+
const cache = new Map<string, SessionResumeEntry>();
|
|
45
|
+
|
|
46
|
+
/** 128-bit SHA-256 prefix. SHA-256 cost is independent of digest length. */
|
|
47
|
+
function simpleHash(input: string): string {
|
|
48
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 32);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Skip OpenCode meta-requests that share the proxy but aren't the main chat.
|
|
52
|
+
*
|
|
53
|
+
* These substrings are observed heuristics, not a stable contract. If OpenCode
|
|
54
|
+
* rewords its title-generation prompt, update this filter.
|
|
55
|
+
*/
|
|
56
|
+
function isMetaUserMessage(content: string): boolean {
|
|
57
|
+
const lower = content.toLowerCase();
|
|
58
|
+
return (
|
|
59
|
+
lower.includes("title generator") ||
|
|
60
|
+
lower.includes("thread title") ||
|
|
61
|
+
lower.includes("generate a brief title")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stable anchor for a conversation: SHA-256 hash of the first non-meta user
|
|
67
|
+
* message content, plus an original-content prefix for collision detection.
|
|
68
|
+
* Assumes `opencode run -c` preserves the opening user message so the anchor
|
|
69
|
+
* remains stable across turns.
|
|
70
|
+
* Returns undefined when no usable user message exists, which tells callers
|
|
71
|
+
* to skip session resume entirely and avoid the collision-prone "default" key.
|
|
72
|
+
*/
|
|
73
|
+
export function deriveConversationAnchor(
|
|
74
|
+
messages: Array<ProxyMessage>,
|
|
75
|
+
): { anchor: string; contentPrefix: string } | undefined {
|
|
76
|
+
for (const message of messages) {
|
|
77
|
+
if (message?.role !== "user") continue;
|
|
78
|
+
const text = extractTextContent(message.content).trim();
|
|
79
|
+
if (!text || isMetaUserMessage(text)) continue;
|
|
80
|
+
const canonical = canonicalizeContentForAnchor(message.content);
|
|
81
|
+
return { anchor: simpleHash(canonical), contentPrefix: text.slice(0, 500) };
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Prefixes used to validate a resume cache entry as a conversation advances.
|
|
88
|
+
*
|
|
89
|
+
* The session key intentionally remains based on the first real user message so
|
|
90
|
+
* turn 2 can find the chat ID recorded after turn 1. The content prefix is the
|
|
91
|
+
* safety check: lookup uses the prior user-message sequence, while recording
|
|
92
|
+
* stores the current user-message sequence for the next request. If two chats
|
|
93
|
+
* share the same opener but diverge later, the mismatch drops resume instead of
|
|
94
|
+
* attaching to the wrong cursor-agent chat.
|
|
95
|
+
*/
|
|
96
|
+
export function deriveConversationResumePrefixes(
|
|
97
|
+
messages: Array<ProxyMessage>,
|
|
98
|
+
): { lookupContentPrefix: string; recordContentPrefix: string } | undefined {
|
|
99
|
+
const users: Array<{ canonical: string; prefix: string; index: number }> = [];
|
|
100
|
+
for (let index = 0; index < messages.length; index++) {
|
|
101
|
+
const message = messages[index];
|
|
102
|
+
if (message?.role !== "user") continue;
|
|
103
|
+
const text = extractTextContent(message.content).trim();
|
|
104
|
+
if (!text || isMetaUserMessage(text)) continue;
|
|
105
|
+
users.push({
|
|
106
|
+
canonical: canonicalizeContentForAnchor(message.content),
|
|
107
|
+
prefix: text.slice(0, 500),
|
|
108
|
+
index,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (users.length === 0) return undefined;
|
|
112
|
+
|
|
113
|
+
const lastUserIsLatestMessage = users[users.length - 1]?.index === messages.length - 1;
|
|
114
|
+
const lookupUsers = lastUserIsLatestMessage && users.length > 1
|
|
115
|
+
? users.slice(0, -1)
|
|
116
|
+
: users;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
lookupContentPrefix: buildUserSequencePrefix(lookupUsers),
|
|
120
|
+
recordContentPrefix: buildUserSequencePrefix(users),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildUserSequencePrefix(users: Array<{ canonical: string; prefix: string }>): string {
|
|
125
|
+
if (users.length === 1) return users[0].prefix;
|
|
126
|
+
return `users:${users.length}:${simpleHash(users.map((user) => user.canonical).join("\n\0\n"))}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Canonical serialization of message content for anchor hashing.
|
|
130
|
+
* Includes text and non-text parts so identical text with different images
|
|
131
|
+
* do not collide. A pure-text array produces the same canonical form as a
|
|
132
|
+
* plain string so the anchor is stable across OpenCode's content formats.
|
|
133
|
+
*/
|
|
134
|
+
function canonicalizeContentForAnchor(content: unknown): string {
|
|
135
|
+
if (typeof content === "string") return content;
|
|
136
|
+
if (!Array.isArray(content)) return "";
|
|
137
|
+
const hasNonText = content.some((part: any) => part?.type !== "text" || typeof part.text !== "string");
|
|
138
|
+
if (!hasNonText) {
|
|
139
|
+
return content.map((part: any) => part.text).join("\n");
|
|
140
|
+
}
|
|
141
|
+
return content
|
|
142
|
+
.map((part: any) => {
|
|
143
|
+
if (part?.type === "text" && typeof part.text === "string") {
|
|
144
|
+
return `text:${part.text}`;
|
|
145
|
+
}
|
|
146
|
+
if (part?.type === "image_url") {
|
|
147
|
+
return `image_url:${typeof part.image_url?.url === "string" ? part.image_url.url : ""}`;
|
|
148
|
+
}
|
|
149
|
+
return `part:${part?.type ?? ""}`;
|
|
150
|
+
})
|
|
151
|
+
.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Build a unique session key from workspace, model, and conversation anchor. */
|
|
155
|
+
export function buildSessionKey(workspace: string, model: string, anchor: string): string {
|
|
156
|
+
return `${workspace}\0${model}\0${anchor}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Return whether `CURSOR_ACP_SESSION_RESUME` is enabled (1/true/on/yes). */
|
|
160
|
+
export function isSessionResumeEnabled(): boolean {
|
|
161
|
+
const value = process.env.CURSOR_ACP_SESSION_RESUME?.toLowerCase();
|
|
162
|
+
return value === "1" || value === "true" || value === "on" || value === "yes";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Look up a cached cursor-agent chat ID for the given session key.
|
|
167
|
+
*
|
|
168
|
+
* Validates TTL, content prefix, and tool/subagent fingerprints. A request that
|
|
169
|
+
* supplies a non-empty fingerprint will evict a cached entry that was recorded
|
|
170
|
+
* without that fingerprint (or with a different one), preventing a stale chat
|
|
171
|
+
* from being resumed with an incompatible tool/subagent schema. Returns
|
|
172
|
+
* undefined and evicts stale entries on any mismatch.
|
|
173
|
+
*/
|
|
174
|
+
export function getResumeChatId(
|
|
175
|
+
sessionKey: string,
|
|
176
|
+
expectedPrefix?: string,
|
|
177
|
+
toolFingerprint?: string,
|
|
178
|
+
subagentFingerprint?: string,
|
|
179
|
+
): string | undefined {
|
|
180
|
+
const entry = cache.get(sessionKey);
|
|
181
|
+
if (!entry) return undefined;
|
|
182
|
+
if (Date.now() - entry.updatedAt > DEFAULT_TTL_MS) {
|
|
183
|
+
evictEntry(sessionKey, "ttlExpired", {
|
|
184
|
+
ageMs: Date.now() - entry.updatedAt,
|
|
185
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
186
|
+
});
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
if (expectedPrefix != null && entry.contentPrefix !== expectedPrefix) {
|
|
190
|
+
log.warn("Skipping session resume entry due to content prefix mismatch", {
|
|
191
|
+
sessionKeyHash: sanitizeSessionKey(sessionKey),
|
|
192
|
+
storedPrefixLength: entry.contentPrefix.length,
|
|
193
|
+
expectedPrefixLength: expectedPrefix.length,
|
|
194
|
+
});
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if ((toolFingerprint || entry.toolFingerprint) && entry.toolFingerprint !== toolFingerprint) {
|
|
198
|
+
evictEntry(sessionKey, "toolFingerprintMismatch", {}, "warn");
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
if ((subagentFingerprint || entry.subagentFingerprint) && entry.subagentFingerprint !== subagentFingerprint) {
|
|
202
|
+
evictEntry(sessionKey, "subagentFingerprintMismatch", {}, "warn");
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
// Refresh LRU order on a successful read.
|
|
206
|
+
cache.delete(sessionKey);
|
|
207
|
+
cache.set(sessionKey, entry);
|
|
208
|
+
return entry.chatId;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check whether a resume chat ID exists without evicting stale entries or
|
|
213
|
+
* refreshing LRU order. Use for observability-only checks (e.g. post-response
|
|
214
|
+
* warnings) where side effects would be incorrect.
|
|
215
|
+
*/
|
|
216
|
+
export function hasResumeChatId(
|
|
217
|
+
sessionKey: string,
|
|
218
|
+
expectedPrefix?: string,
|
|
219
|
+
toolFingerprint?: string,
|
|
220
|
+
subagentFingerprint?: string,
|
|
221
|
+
): boolean {
|
|
222
|
+
const entry = cache.get(sessionKey);
|
|
223
|
+
if (!entry) return false;
|
|
224
|
+
if (Date.now() - entry.updatedAt > DEFAULT_TTL_MS) return false;
|
|
225
|
+
if (expectedPrefix != null && entry.contentPrefix !== expectedPrefix) return false;
|
|
226
|
+
if ((toolFingerprint || entry.toolFingerprint) && entry.toolFingerprint !== toolFingerprint) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if ((subagentFingerprint || entry.subagentFingerprint) && entry.subagentFingerprint !== subagentFingerprint) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
return !!entry.chatId;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Store or refresh a chat ID for the given session key.
|
|
237
|
+
*
|
|
238
|
+
* Refreshes LRU insertion order, ignores empty chat IDs, and evicts the
|
|
239
|
+
* least-recently-used entry when the cache exceeds DEFAULT_MAX_ENTRIES.
|
|
240
|
+
*/
|
|
241
|
+
export function recordResumeChatId(
|
|
242
|
+
sessionKey: string,
|
|
243
|
+
chatId: string,
|
|
244
|
+
contentPrefix: string,
|
|
245
|
+
toolFingerprint?: string,
|
|
246
|
+
subagentFingerprint?: string,
|
|
247
|
+
): void {
|
|
248
|
+
if (!chatId) return;
|
|
249
|
+
const trimmed = chatId.trim();
|
|
250
|
+
if (!RESUME_CHAT_ID_SAFE_RE.test(trimmed)) {
|
|
251
|
+
log.warn("Refusing to cache unsafe resume chat ID", {
|
|
252
|
+
sessionKeyHash: sanitizeSessionKey(sessionKey),
|
|
253
|
+
chatIdHash: hashForLog(trimmed),
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Delete first so a re-set moves the key to the end (LRU insertion order).
|
|
258
|
+
cache.delete(sessionKey);
|
|
259
|
+
cache.set(sessionKey, {
|
|
260
|
+
chatId: trimmed,
|
|
261
|
+
contentPrefix,
|
|
262
|
+
toolFingerprint,
|
|
263
|
+
subagentFingerprint,
|
|
264
|
+
updatedAt: Date.now(),
|
|
265
|
+
});
|
|
266
|
+
while (cache.size > DEFAULT_MAX_ENTRIES) {
|
|
267
|
+
const oldest = cache.keys().next().value;
|
|
268
|
+
if (oldest === undefined) break;
|
|
269
|
+
evictEntry(oldest, "maxEntries", { maxEntries: DEFAULT_MAX_ENTRIES });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Sanitize a session key for logging by hashing the full key (128-bit prefix). */
|
|
274
|
+
export function sanitizeSessionKey(sessionKey: string): string {
|
|
275
|
+
return createHash("sha256").update(sessionKey).digest("hex").slice(0, 32);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Generic helper for hashing arbitrary text before logging. */
|
|
279
|
+
export function hashForLog(input: unknown): string {
|
|
280
|
+
return sanitizeSessionKey(typeof input === "string" ? input : String(input ?? ""));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Evict a stale cache entry with a single, consistent log line. */
|
|
284
|
+
function evictEntry(
|
|
285
|
+
sessionKey: string,
|
|
286
|
+
reason: string,
|
|
287
|
+
extra: Record<string, unknown> = {},
|
|
288
|
+
logLevel: "info" | "warn" = "info",
|
|
289
|
+
): void {
|
|
290
|
+
const payload = {
|
|
291
|
+
sessionKeyHash: sanitizeSessionKey(sessionKey),
|
|
292
|
+
reason,
|
|
293
|
+
...extra,
|
|
294
|
+
};
|
|
295
|
+
if (logLevel === "warn") {
|
|
296
|
+
log.warn("Evicting session resume entry", payload);
|
|
297
|
+
} else {
|
|
298
|
+
log.info("Evicting session resume entry", payload);
|
|
299
|
+
}
|
|
300
|
+
cache.delete(sessionKey);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Remove a cached chat ID, e.g. after a resume-specific cursor-agent failure. */
|
|
304
|
+
export function clearResumeChatId(sessionKey: string): void {
|
|
305
|
+
cache.delete(sessionKey);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** @internal Testing only. Gated on NODE_ENV to prevent accidental production wipe. */
|
|
309
|
+
export function _resetSessionResumeCache(): void {
|
|
310
|
+
if (process.env.NODE_ENV !== "test") return;
|
|
311
|
+
cache.clear();
|
|
312
|
+
}
|