@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.
Files changed (80) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +270 -0
  3. package/dist/cli/discover.js +527 -0
  4. package/dist/cli/mcptool.js +10339 -0
  5. package/dist/cli/opencode-cursor.js +2989 -0
  6. package/dist/index.js +20588 -0
  7. package/dist/plugin-entry.js +19848 -0
  8. package/package.json +82 -0
  9. package/scripts/cursor-agent-runner.mjs +272 -0
  10. package/scripts/sdk-runner.mjs +412 -0
  11. package/src/acp/metrics.ts +83 -0
  12. package/src/acp/sessions.ts +107 -0
  13. package/src/acp/tools.ts +209 -0
  14. package/src/auth.ts +175 -0
  15. package/src/cli/discover.ts +53 -0
  16. package/src/cli/mcptool.ts +133 -0
  17. package/src/cli/model-discovery.ts +71 -0
  18. package/src/cli/opencode-cursor.ts +1195 -0
  19. package/src/client/cursor-agent-child.ts +459 -0
  20. package/src/client/sdk-child.ts +550 -0
  21. package/src/client/simple.ts +293 -0
  22. package/src/commands/status.ts +39 -0
  23. package/src/index.ts +39 -0
  24. package/src/mcp/client-manager.ts +166 -0
  25. package/src/mcp/config.ts +169 -0
  26. package/src/mcp/tool-bridge.ts +133 -0
  27. package/src/models/config.ts +64 -0
  28. package/src/models/discovery.ts +105 -0
  29. package/src/models/index.ts +3 -0
  30. package/src/models/pricing.ts +196 -0
  31. package/src/models/sync.ts +247 -0
  32. package/src/models/types.ts +11 -0
  33. package/src/models/variants.ts +446 -0
  34. package/src/plugin-entry.ts +28 -0
  35. package/src/plugin-toggle.ts +81 -0
  36. package/src/plugin.ts +2802 -0
  37. package/src/provider/backend.ts +71 -0
  38. package/src/provider/boundary.ts +168 -0
  39. package/src/provider/passthrough-tracker.ts +38 -0
  40. package/src/provider/runtime-interception.ts +818 -0
  41. package/src/provider/tool-loop-guard.ts +644 -0
  42. package/src/provider/tool-schema-compat.ts +800 -0
  43. package/src/provider.ts +268 -0
  44. package/src/proxy/formatter.ts +60 -0
  45. package/src/proxy/handler.ts +29 -0
  46. package/src/proxy/incremental-prompt.ts +74 -0
  47. package/src/proxy/prompt-builder.ts +204 -0
  48. package/src/proxy/server.ts +207 -0
  49. package/src/proxy/session-resume.ts +312 -0
  50. package/src/proxy/tool-loop.ts +359 -0
  51. package/src/proxy/types.ts +13 -0
  52. package/src/services/toast-service.ts +81 -0
  53. package/src/streaming/ai-sdk-parts.ts +109 -0
  54. package/src/streaming/delta-tracker.ts +89 -0
  55. package/src/streaming/line-buffer.ts +44 -0
  56. package/src/streaming/openai-sse.ts +118 -0
  57. package/src/streaming/parser.ts +22 -0
  58. package/src/streaming/types.ts +158 -0
  59. package/src/tools/core/executor.ts +25 -0
  60. package/src/tools/core/registry.ts +27 -0
  61. package/src/tools/core/types.ts +31 -0
  62. package/src/tools/defaults.ts +954 -0
  63. package/src/tools/discovery.ts +140 -0
  64. package/src/tools/executors/cli.ts +59 -0
  65. package/src/tools/executors/local.ts +25 -0
  66. package/src/tools/executors/mcp.ts +39 -0
  67. package/src/tools/executors/sdk.ts +39 -0
  68. package/src/tools/index.ts +8 -0
  69. package/src/tools/registry.ts +34 -0
  70. package/src/tools/router.ts +123 -0
  71. package/src/tools/schema.ts +58 -0
  72. package/src/tools/skills/loader.ts +61 -0
  73. package/src/tools/skills/resolver.ts +21 -0
  74. package/src/tools/types.ts +29 -0
  75. package/src/types.ts +8 -0
  76. package/src/usage.ts +112 -0
  77. package/src/utils/binary.ts +71 -0
  78. package/src/utils/errors.ts +224 -0
  79. package/src/utils/logger.ts +191 -0
  80. package/src/utils/perf.ts +76 -0
package/src/usage.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { StreamJsonResultEvent } from "./streaming/types.js";
2
+
3
+ export type CursorUsageMetrics = {
4
+ inputTokens: number;
5
+ outputTokens: number;
6
+ reasoningTokens: number;
7
+ cacheReadTokens: number;
8
+ cacheWriteTokens: number;
9
+ cost?: number;
10
+ };
11
+
12
+ export type OpenAiUsage = {
13
+ prompt_tokens: number;
14
+ completion_tokens: number;
15
+ total_tokens: number;
16
+ prompt_tokens_details: {
17
+ cached_tokens: number;
18
+ cache_write_tokens: number;
19
+ };
20
+ completion_tokens_details: {
21
+ reasoning_tokens: number;
22
+ };
23
+ cost?: number;
24
+ };
25
+
26
+ function readTokenCount(value: unknown): number {
27
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
28
+ return 0;
29
+ }
30
+ return Math.floor(value);
31
+ }
32
+
33
+ function readOptionalCost(value: unknown): number | undefined {
34
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
35
+ return undefined;
36
+ }
37
+ return value;
38
+ }
39
+
40
+ export function normalizeCursorUsage(value: unknown): CursorUsageMetrics | undefined {
41
+ if (!value || typeof value !== "object") {
42
+ return undefined;
43
+ }
44
+
45
+ const usage = value as Record<string, unknown>;
46
+ const metrics: CursorUsageMetrics = {
47
+ inputTokens: readTokenCount(usage.inputTokens ?? usage.input_tokens ?? usage.prompt_tokens),
48
+ outputTokens: readTokenCount(usage.outputTokens ?? usage.output_tokens ?? usage.completion_tokens),
49
+ reasoningTokens: readTokenCount(usage.reasoningTokens ?? usage.reasoning_tokens),
50
+ cacheReadTokens: readTokenCount(usage.cacheReadTokens ?? usage.cache_read_tokens),
51
+ cacheWriteTokens: readTokenCount(usage.cacheWriteTokens ?? usage.cache_write_tokens),
52
+ };
53
+
54
+ const cost = readOptionalCost(usage.cost ?? usage.totalCost ?? usage.total_cost);
55
+ if (cost !== undefined) {
56
+ metrics.cost = cost;
57
+ }
58
+
59
+ const hasUsage =
60
+ metrics.inputTokens > 0
61
+ || metrics.outputTokens > 0
62
+ || metrics.reasoningTokens > 0
63
+ || metrics.cacheReadTokens > 0
64
+ || metrics.cacheWriteTokens > 0
65
+ || cost !== undefined;
66
+
67
+ return hasUsage ? metrics : undefined;
68
+ }
69
+
70
+ export function createOpenAiUsage(metrics: CursorUsageMetrics): OpenAiUsage {
71
+ const promptTokens = metrics.inputTokens + metrics.cacheReadTokens + metrics.cacheWriteTokens;
72
+ const totalTokens = promptTokens + metrics.outputTokens + metrics.reasoningTokens;
73
+ const usage: OpenAiUsage = {
74
+ prompt_tokens: promptTokens,
75
+ completion_tokens: metrics.outputTokens,
76
+ total_tokens: totalTokens,
77
+ prompt_tokens_details: {
78
+ cached_tokens: metrics.cacheReadTokens,
79
+ cache_write_tokens: metrics.cacheWriteTokens,
80
+ },
81
+ completion_tokens_details: {
82
+ reasoning_tokens: metrics.reasoningTokens,
83
+ },
84
+ };
85
+
86
+ if (metrics.cost !== undefined) {
87
+ usage.cost = metrics.cost;
88
+ }
89
+
90
+ return usage;
91
+ }
92
+
93
+ export function extractOpenAiUsageFromResult(event: StreamJsonResultEvent): OpenAiUsage | undefined {
94
+ const metrics = normalizeCursorUsage(event.usage);
95
+ return metrics ? createOpenAiUsage(metrics) : undefined;
96
+ }
97
+
98
+ export function createChatCompletionUsageChunk(
99
+ id: string,
100
+ created: number,
101
+ model: string,
102
+ usage: OpenAiUsage,
103
+ ) {
104
+ return {
105
+ id,
106
+ object: "chat.completion.chunk",
107
+ created,
108
+ model,
109
+ choices: [],
110
+ usage,
111
+ };
112
+ }
@@ -0,0 +1,71 @@
1
+ // src/utils/binary.ts
2
+ //
3
+ // Resolves the cursor-agent executable path. On Windows the binary is a `.cmd`
4
+ // shim, which Node's spawn cannot execute directly without `shell: true` —
5
+ // callers therefore pair this resolver with `shell: process.platform === "win32"`
6
+ // and `formatShellCommandForPlatform()` at every Node spawn site. That re-enables
7
+ // shell metacharacter interpretation, so any user-controlled string passed as an
8
+ // argument on Windows must be treated as untrusted; never concatenate user input
9
+ // into argv on win32.
10
+ import { existsSync as fsExistsSync } from "fs";
11
+ import * as pathModule from "path";
12
+ import { homedir as osHomedir } from "os";
13
+ import { createLogger } from "./logger.js";
14
+
15
+ const log = createLogger("binary");
16
+
17
+ export type BinaryDeps = {
18
+ platform?: NodeJS.Platform;
19
+ env?: Record<string, string | undefined>;
20
+ existsSync?: (path: string) => boolean;
21
+ homedir?: () => string;
22
+ };
23
+
24
+ export function resolveCursorAgentBinary(deps: BinaryDeps = {}): string {
25
+ const platform = deps.platform ?? process.platform;
26
+ const env = deps.env ?? process.env;
27
+ const checkExists = deps.existsSync ?? fsExistsSync;
28
+ const home = (deps.homedir ?? osHomedir)();
29
+
30
+ const envOverride = env.CURSOR_AGENT_EXECUTABLE;
31
+ if (envOverride && envOverride.length > 0) {
32
+ return envOverride;
33
+ }
34
+
35
+ if (platform === "win32") {
36
+ const pathJoin = pathModule.win32.join;
37
+ const localAppData = env.LOCALAPPDATA ?? pathJoin(home, "AppData", "Local");
38
+ const knownPath = pathJoin(localAppData, "cursor-agent", "cursor-agent.cmd");
39
+ if (checkExists(knownPath)) {
40
+ return knownPath;
41
+ }
42
+ log.warn("cursor-agent not found at known Windows path, falling back to PATH", { checkedPath: knownPath });
43
+ return "cursor-agent.cmd";
44
+ }
45
+
46
+ const knownPaths = [
47
+ pathModule.join(home, ".cursor-agent", "cursor-agent"),
48
+ "/usr/local/bin/cursor-agent",
49
+ ];
50
+ for (const p of knownPaths) {
51
+ if (checkExists(p)) {
52
+ return p;
53
+ }
54
+ }
55
+
56
+ log.warn("cursor-agent not found at known paths, falling back to PATH", { checkedPaths: knownPaths });
57
+ return "cursor-agent";
58
+ }
59
+
60
+ export function formatShellCommandForPlatform(
61
+ command: string,
62
+ platform: NodeJS.Platform = process.platform,
63
+ ): string {
64
+ if (platform !== "win32") {
65
+ return command;
66
+ }
67
+ if (command.startsWith("\"") && command.endsWith("\"")) {
68
+ return command;
69
+ }
70
+ return `"${command}"`;
71
+ }
@@ -0,0 +1,224 @@
1
+ // src/utils/errors.ts
2
+
3
+ export type ErrorType = "quota" | "auth" | "network" | "model" | "unknown";
4
+
5
+ export interface ParsedError {
6
+ type: ErrorType;
7
+ recoverable: boolean;
8
+ message: string;
9
+ userMessage: string;
10
+ details: Record<string, string>;
11
+ suggestion?: string;
12
+ }
13
+
14
+ /**
15
+ * Strip ANSI escape codes from string
16
+ */
17
+ export function stripAnsi(str: string): string {
18
+ if (typeof str !== "string") return String(str ?? "");
19
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
20
+ }
21
+
22
+ /**
23
+ * Parse cursor-agent error output into structured format
24
+ */
25
+ export function parseAgentError(stderr: string | unknown): ParsedError {
26
+ const input = typeof stderr === "string" ? stderr : String(stderr ?? "");
27
+ const clean = stripAnsi(input).trim();
28
+
29
+ // Quota/usage limit error
30
+ if (clean.includes("usage limit") || clean.includes("hit your usage limit")) {
31
+ const savingsMatch = clean.match(/saved \$(\d+(?:\.\d+)?)/i);
32
+ const resetMatch = clean.match(/reset[^0-9]*(\d{1,2}\/\d{1,2}\/\d{4})/i);
33
+ const modelMatch = clean.match(/continue with (\w+)/i);
34
+
35
+ const details: Record<string, string> = {};
36
+ if (savingsMatch) details.savings = `$${savingsMatch[1]}`;
37
+ if (resetMatch) details.resetDate = resetMatch[1];
38
+ if (modelMatch) details.affectedModel = modelMatch[1];
39
+
40
+ return {
41
+ type: "quota",
42
+ recoverable: false,
43
+ message: clean,
44
+ userMessage: "You've hit your Cursor usage limit",
45
+ details,
46
+ suggestion: "Switch to a different model or set a Spend Limit in Cursor settings",
47
+ };
48
+ }
49
+
50
+ // Authentication error
51
+ if (clean.includes("not logged in") || clean.includes("auth") || clean.includes("unauthorized")) {
52
+ return {
53
+ type: "auth",
54
+ recoverable: false,
55
+ message: clean,
56
+ userMessage: "Not authenticated with Cursor",
57
+ details: {},
58
+ suggestion: "Run: opencode auth login → Other → cursor-acp, or: cursor-agent login",
59
+ };
60
+ }
61
+
62
+ // Network error
63
+ if (clean.includes("ECONNREFUSED") || clean.includes("network") || clean.includes("fetch failed")) {
64
+ return {
65
+ type: "network",
66
+ recoverable: true,
67
+ message: clean,
68
+ userMessage: "Connection to Cursor failed",
69
+ details: {},
70
+ suggestion: "Check your internet connection and try again",
71
+ };
72
+ }
73
+
74
+ // Model not found / not available
75
+ if (clean.includes("model not found") || clean.includes("invalid model") || clean.includes("Cannot use this model")) {
76
+ // Extract model name and available models from error
77
+ const modelMatch = clean.match(/Cannot use this model: ([^.]+)/);
78
+ const availableMatch = clean.match(/Available models: (.+)/);
79
+
80
+ const details: Record<string, string> = {};
81
+ if (modelMatch) details.requested = modelMatch[1];
82
+ if (availableMatch) details.available = availableMatch[1].split(", ").slice(0, 5).join(", ") + "...";
83
+
84
+ return {
85
+ type: "model",
86
+ recoverable: false,
87
+ message: clean,
88
+ userMessage: modelMatch ? `Model '${modelMatch[1]}' not available` : "Requested model not available",
89
+ details,
90
+ suggestion: "Use cursor-acp/auto or check available models with: cursor-agent models",
91
+ };
92
+ }
93
+
94
+ // Unknown error
95
+ const recoverable = clean.includes("timeout") || clean.includes("ETIMEDOUT");
96
+ return {
97
+ type: "unknown",
98
+ recoverable,
99
+ message: clean,
100
+ userMessage: clean.substring(0, 200) || "An error occurred",
101
+ details: {},
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Check if an error is recoverable (worth retrying).
107
+ */
108
+ export function isRecoverableError(error: ParsedError): boolean {
109
+ return error.recoverable;
110
+ }
111
+
112
+ /** Resume-specific failure signatures from cursor-agent stderr/stdout. */
113
+ const RESUME_FAILURE_PATTERNS = [
114
+ /\bsession\s+(?:has\s+(?:been\s+)?|is\s+|was\s+)?(?:not\s+found|expired|deleted|missing|no\s+longer\s+exists)/i,
115
+ /\bchat\s+(?:has\s+(?:been\s+)?|is\s+|was\s+)?(?:not\s+found|expired|deleted|missing|no\s+longer\s+exists)/i,
116
+ /\bconversation\s+(?:has\s+(?:been\s+)?|is\s+|was\s+)?(?:not\s+found|expired|deleted|missing|no\s+longer\s+exists)/i,
117
+ /\bthread\s+(?:has\s+(?:been\s+)?|is\s+|was\s+)?(?:not\s+found|expired|deleted|missing|no\s+longer\s+exists)/i,
118
+ /\bresume\s+(?:failed|error|invalid|aborted)(?:\s+(?:session|chat|conversation|thread))?/i,
119
+ /\bfailed\s+to\s+resume(?:\s+(?:session|chat|conversation|thread))?/i,
120
+ /\bcould\s+not\s+resume(?:\s+(?:session|chat|conversation|thread))?/i,
121
+ /\bno\s+active\s+session/i,
122
+ /\bno\s+such\s+session/i,
123
+ /\bno\s+such\s+chat/i,
124
+ /\binvalid\s+(?:session|chat|conversation|thread)(?:\s+id)?/i,
125
+ /\b(?:session|chat|conversation|thread)\s+invalid(?:\s+id)?/i,
126
+ /\b(?:session|chat|conversation|thread)\s+id\s+(?:is\s+)?(?:invalid|not\s+found|expired|missing)/i,
127
+ /\b(?:session|chat|conversation|thread)\s+(?:isn['’]t|wasn['’]t)\s+found/i,
128
+ /\b(?:session|chat|conversation|thread)\s+(?:can(?:not|\s+not)|could\s+not)\s+(?:be\s+)?resumed/i,
129
+ /\bunable\s+to\s+resume\b/i,
130
+ /\bcan(?:not|\s+not)\s+resume\b/i,
131
+ ];
132
+
133
+ /** Continuations that turn a session-gone phrase into an auth/validation/network error. */
134
+ const TRANSIENT_CONTINUATION_PATTERN = /^\s*[:;]?\s*(?:token|credential|credentials|auth|secret|password|format|network|quota|usage|limit|api|key|request(?:_|-|\s+)?id?|due\s+to\s+(?:network|auth|quota)|because\s+of\s+(?:network|auth|quota)|caused\s+by\s+(?:network|auth|quota))/i;
135
+
136
+ /** Words in a continuation clause that indicate a transient infrastructure failure. */
137
+ const TRANSIENT_CAUSE_WORDS =
138
+ /\b(?:auth(?:enticat(?:e|ion|ed))?|re-auth(?:enticate)?|token(?:\s+rotation)?|credential|password|secret|network|connection|internet|offline|quota|usage(?:\s+limit)?|api[\s-]?key|fetch\s+failed|econnrefused|timeout|timed\s+out)\b/i;
139
+
140
+ /** Session-specific causes that should still count as resume failures. */
141
+ const SESSION_SPECIFIC_CAUSE_WORDS =
142
+ /\b(?:inactiv(?:ity|e)|idle|policy|retention|archiv(?:e|ed)|purged|deleted|removed|expired)\b/i;
143
+
144
+ /**
145
+ * Decide whether a cursor-agent failure is specific to the resumed session.
146
+ * Transient errors (network, auth, OOM, signal kills) should not evict a
147
+ * valid chat ID; only failures that indicate the session itself is gone.
148
+ */
149
+ function isTransientContinuation(tail: string): boolean {
150
+ const trimmed = tail.trim();
151
+ if (!trimmed) return false;
152
+
153
+ const stripped = trimmed.replace(/^[\s:;,.]+/, "");
154
+
155
+ const causalMatch = stripped.match(/^(?:because of|due to)\s+(.+)/i);
156
+ if (causalMatch) {
157
+ const cause = causalMatch[1];
158
+ // Session-specific causes (inactivity, retention, purged, etc.) take
159
+ // precedence over transient words that may appear in the same clause
160
+ // (e.g. "inactivity timeout"). If the cause is clearly about the
161
+ // session being gone, treat it as a resume-specific failure.
162
+ if (SESSION_SPECIFIC_CAUSE_WORDS.test(cause)) {
163
+ return false;
164
+ }
165
+ if (TRANSIENT_CAUSE_WORDS.test(cause)) {
166
+ return true;
167
+ }
168
+ }
169
+
170
+ const firstSegment = stripped.split(/[,;]/)[0]?.trim() ?? "";
171
+ if (firstSegment) {
172
+ if (SESSION_SPECIFIC_CAUSE_WORDS.test(firstSegment)) {
173
+ return false;
174
+ }
175
+ if (TRANSIENT_CAUSE_WORDS.test(firstSegment)) {
176
+ return true;
177
+ }
178
+ }
179
+
180
+ if (SESSION_SPECIFIC_CAUSE_WORDS.test(stripped)) {
181
+ return false;
182
+ }
183
+
184
+ if (TRANSIENT_CAUSE_WORDS.test(stripped)) {
185
+ return true;
186
+ }
187
+
188
+ return TRANSIENT_CONTINUATION_PATTERN.test(tail);
189
+ }
190
+
191
+ export function isResumeSpecificFailure(stderr: unknown): boolean {
192
+ const text = typeof stderr === "string" ? stderr : String(stderr ?? "");
193
+ const clean = stripAnsi(text);
194
+ for (const pattern of RESUME_FAILURE_PATTERNS) {
195
+ const match = clean.match(pattern);
196
+ if (!match) continue;
197
+ const tail = clean.slice(match.index! + match[0].length);
198
+ if (!isTransientContinuation(tail)) {
199
+ return true;
200
+ }
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /**
206
+ * Format parsed error for user display
207
+ */
208
+ export function formatErrorForUser(error: ParsedError): string {
209
+ let output = `cursor-acp error: ${error.userMessage || error.message || "Unknown error"}`;
210
+
211
+ const details = error.details || {};
212
+ if (Object.keys(details).length > 0) {
213
+ const detailParts = Object.entries(details)
214
+ .map(([k, v]) => `${k}: ${v}`)
215
+ .join(" | ");
216
+ output += `\n ${detailParts}`;
217
+ }
218
+
219
+ if (error.suggestion) {
220
+ output += `\n Suggestion: ${error.suggestion}`;
221
+ }
222
+
223
+ return output;
224
+ }
@@ -0,0 +1,191 @@
1
+ // src/utils/logger.ts
2
+
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+
7
+ type LogLevel = "debug" | "info" | "warn" | "error";
8
+
9
+ const MAX_LOG_SIZE = 5 * 1024 * 1024;
10
+
11
+ const LEVEL_PRIORITY: Record<LogLevel, number> = {
12
+ debug: 0,
13
+ info: 1,
14
+ warn: 2,
15
+ error: 3,
16
+ };
17
+
18
+ function getConfiguredLevel(): LogLevel {
19
+ const env = process.env.CURSOR_ACP_LOG_LEVEL?.toLowerCase();
20
+ if (env && env in LEVEL_PRIORITY) {
21
+ return env as LogLevel;
22
+ }
23
+ return "info";
24
+ }
25
+
26
+ function isSilent(): boolean {
27
+ return process.env.CURSOR_ACP_LOG_SILENT === "1" ||
28
+ process.env.CURSOR_ACP_LOG_SILENT === "true";
29
+ }
30
+
31
+ function shouldLog(level: LogLevel): boolean {
32
+ if (isSilent()) return false;
33
+ const configured = getConfiguredLevel();
34
+ return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[configured];
35
+ }
36
+
37
+ function formatMessage(level: LogLevel, component: string, message: string, data?: unknown): string {
38
+ const prefix = `[cursor-acp:${component}]`;
39
+ const levelTag = level.toUpperCase().padEnd(5);
40
+
41
+ let formatted = `${prefix} ${levelTag} ${message}`;
42
+
43
+ if (data !== undefined) {
44
+ if (typeof data === "object") {
45
+ formatted += ` ${JSON.stringify(data)}`;
46
+ } else {
47
+ formatted += ` ${data}`;
48
+ }
49
+ }
50
+
51
+ return formatted;
52
+ }
53
+
54
+ function isConsoleEnabled(): boolean {
55
+ const consoleEnv = process.env.CURSOR_ACP_LOG_CONSOLE;
56
+ return consoleEnv === "1" || consoleEnv === "true";
57
+ }
58
+
59
+ let logDirEnsured = false;
60
+ let logFileError = false;
61
+ let logStream: fs.WriteStream | null = null;
62
+ let logBytesWritten = 0;
63
+
64
+ function getLogDir(): string {
65
+ const override = process.env.CURSOR_ACP_LOG_DIR?.trim();
66
+ return override || path.join(os.homedir(), ".opencode-cursor");
67
+ }
68
+
69
+ function getLogFile(): string {
70
+ return path.join(getLogDir(), "plugin.log");
71
+ }
72
+
73
+ /** Reset internal state (for testing only) */
74
+ export function _resetLoggerState(): void {
75
+ logDirEnsured = false;
76
+ logFileError = false;
77
+ if (logStream) {
78
+ logStream.end();
79
+ logStream = null;
80
+ }
81
+ logBytesWritten = 0;
82
+ }
83
+
84
+ function ensureLogDir(): void {
85
+ if (logDirEnsured) return;
86
+ try {
87
+ const logDir = getLogDir();
88
+ if (!fs.existsSync(logDir)) {
89
+ fs.mkdirSync(logDir, { recursive: true });
90
+ }
91
+ logDirEnsured = true;
92
+ } catch {
93
+ logFileError = true;
94
+ }
95
+ }
96
+
97
+ function openLogStream(): void {
98
+ if (logStream || logFileError) return;
99
+ ensureLogDir();
100
+ if (logFileError) return;
101
+
102
+ try {
103
+ // Seed byte counter from existing file size
104
+ try {
105
+ logBytesWritten = fs.statSync(getLogFile()).size;
106
+ } catch {
107
+ logBytesWritten = 0;
108
+ }
109
+ logStream = fs.createWriteStream(getLogFile(), { flags: "a" });
110
+ logStream.on("error", () => {
111
+ if (!logFileError) {
112
+ logFileError = true;
113
+ console.error(`[cursor-acp] Failed to write logs. Using: ${getLogFile()}`);
114
+ }
115
+ logStream = null;
116
+ });
117
+ } catch {
118
+ logFileError = true;
119
+ }
120
+ }
121
+
122
+ function rotateIfNeeded(): void {
123
+ if (logBytesWritten < MAX_LOG_SIZE) return;
124
+ try {
125
+ if (logStream) {
126
+ logStream.end();
127
+ logStream = null;
128
+ }
129
+ const logFile = getLogFile();
130
+ fs.renameSync(logFile, logFile + ".1");
131
+ logBytesWritten = 0;
132
+ openLogStream();
133
+ } catch {
134
+ if (!logFileError && !logStream) {
135
+ openLogStream();
136
+ }
137
+ }
138
+ }
139
+
140
+ function writeToFile(message: string): void {
141
+ if (logFileError) return;
142
+
143
+ if (!logStream) openLogStream();
144
+ if (logFileError || !logStream) return;
145
+
146
+ rotateIfNeeded();
147
+ if (logFileError || !logStream) return;
148
+
149
+ const timestamp = new Date().toISOString();
150
+ const line = `${timestamp} ${message}\n`;
151
+ logStream.write(line);
152
+ logBytesWritten += Buffer.byteLength(line);
153
+ }
154
+
155
+ export interface Logger {
156
+ debug: (message: string, data?: unknown) => void;
157
+ info: (message: string, data?: unknown) => void;
158
+ warn: (message: string, data?: unknown) => void;
159
+ error: (message: string, data?: unknown) => void;
160
+ isDebugEnabled: () => boolean;
161
+ }
162
+
163
+ export function createLogger(component: string): Logger {
164
+ return {
165
+ isDebugEnabled: () => shouldLog("debug"),
166
+ debug: (message: string, data?: unknown) => {
167
+ if (!shouldLog("debug")) return;
168
+ const formatted = formatMessage("debug", component, message, data);
169
+ writeToFile(formatted);
170
+ if (isConsoleEnabled()) console.error(formatted);
171
+ },
172
+ info: (message: string, data?: unknown) => {
173
+ if (!shouldLog("info")) return;
174
+ const formatted = formatMessage("info", component, message, data);
175
+ writeToFile(formatted);
176
+ if (isConsoleEnabled()) console.error(formatted);
177
+ },
178
+ warn: (message: string, data?: unknown) => {
179
+ if (!shouldLog("warn")) return;
180
+ const formatted = formatMessage("warn", component, message, data);
181
+ writeToFile(formatted);
182
+ if (isConsoleEnabled()) console.error(formatted);
183
+ },
184
+ error: (message: string, data?: unknown) => {
185
+ if (!shouldLog("error")) return;
186
+ const formatted = formatMessage("error", component, message, data);
187
+ writeToFile(formatted);
188
+ if (isConsoleEnabled()) console.error(formatted);
189
+ },
190
+ };
191
+ }
@@ -0,0 +1,76 @@
1
+ import { createLogger } from "./logger.js";
2
+
3
+ const log = createLogger("perf");
4
+
5
+ export interface PerfMarker {
6
+ name: string;
7
+ ts: number;
8
+ }
9
+
10
+ export interface PerfPhase {
11
+ name: string;
12
+ deltaMs: number;
13
+ atMs: number;
14
+ }
15
+
16
+ export interface PerfSummary {
17
+ requestId: string;
18
+ total: number;
19
+ phaseTotals: Record<string, number>;
20
+ timeline: PerfPhase[];
21
+ }
22
+
23
+ function isTimingLogEnabled(): boolean {
24
+ const value = process.env.CURSOR_ACP_TIMING?.toLowerCase();
25
+ return value === "1" || value === "true" || value === "on" || value === "yes";
26
+ }
27
+
28
+ export class RequestPerf {
29
+ private markers: PerfMarker[] = [];
30
+ private readonly requestId: string;
31
+
32
+ constructor(requestId: string) {
33
+ this.requestId = requestId;
34
+ this.mark("request:start");
35
+ }
36
+
37
+ mark(name: string): void {
38
+ this.markers.push({ name, ts: Date.now() });
39
+ }
40
+
41
+ /** Log timing summary. Call once at request end. */
42
+ summarize(): PerfSummary | undefined {
43
+ if (this.markers.length < 2) return undefined;
44
+ const start = this.markers[0].ts;
45
+ const phaseTotals: Record<string, number> = {};
46
+ const timeline: PerfPhase[] = [];
47
+ for (let i = 1; i < this.markers.length; i++) {
48
+ const marker = this.markers[i];
49
+ const deltaMs = marker.ts - this.markers[i - 1].ts;
50
+ phaseTotals[marker.name] = (phaseTotals[marker.name] ?? 0) + deltaMs;
51
+ timeline.push({
52
+ name: marker.name,
53
+ deltaMs,
54
+ atMs: marker.ts - start,
55
+ });
56
+ }
57
+ const total = this.markers[this.markers.length - 1].ts - start;
58
+ const summary: PerfSummary = { requestId: this.requestId, total, phaseTotals, timeline };
59
+ if (isTimingLogEnabled()) {
60
+ log.info("Request timing", summary);
61
+ } else {
62
+ log.debug("Request timing", summary);
63
+ }
64
+ return summary;
65
+ }
66
+
67
+ /** Get elapsed ms since construction. */
68
+ elapsed(): number {
69
+ return this.markers.length > 0 ? Date.now() - this.markers[0].ts : 0;
70
+ }
71
+
72
+ /** Get all markers (for testing). */
73
+ getMarkers(): ReadonlyArray<PerfMarker> {
74
+ return this.markers;
75
+ }
76
+ }