@rama_nigg/open-cursor 2.2.0 → 2.3.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.
Files changed (58) hide show
  1. package/dist/cli/opencode-cursor.js +27 -8
  2. package/dist/index.js +44 -15
  3. package/dist/plugin-entry.js +44 -15
  4. package/package.json +9 -3
  5. package/src/acp/metrics.ts +83 -0
  6. package/src/acp/sessions.ts +107 -0
  7. package/src/acp/tools.ts +209 -0
  8. package/src/auth.ts +269 -0
  9. package/src/cli/discover.ts +53 -0
  10. package/src/cli/model-discovery.ts +50 -0
  11. package/src/cli/opencode-cursor.ts +620 -0
  12. package/src/client/simple.ts +277 -0
  13. package/src/commands/status.ts +39 -0
  14. package/src/index.ts +40 -0
  15. package/src/models/config.ts +64 -0
  16. package/src/models/discovery.ts +132 -0
  17. package/src/models/index.ts +3 -0
  18. package/src/models/types.ts +11 -0
  19. package/src/plugin-entry.ts +28 -0
  20. package/src/plugin-toggle.ts +67 -0
  21. package/src/plugin.ts +1918 -0
  22. package/src/provider/boundary.ts +161 -0
  23. package/src/provider/runtime-interception.ts +721 -0
  24. package/src/provider/tool-loop-guard.ts +644 -0
  25. package/src/provider/tool-schema-compat.ts +516 -0
  26. package/src/provider.ts +268 -0
  27. package/src/proxy/formatter.ts +42 -0
  28. package/src/proxy/handler.ts +29 -0
  29. package/src/proxy/prompt-builder.ts +171 -0
  30. package/src/proxy/server.ts +207 -0
  31. package/src/proxy/tool-loop.ts +317 -0
  32. package/src/proxy/types.ts +13 -0
  33. package/src/streaming/ai-sdk-parts.ts +105 -0
  34. package/src/streaming/delta-tracker.ts +33 -0
  35. package/src/streaming/line-buffer.ts +44 -0
  36. package/src/streaming/openai-sse.ts +114 -0
  37. package/src/streaming/parser.ts +22 -0
  38. package/src/streaming/types.ts +152 -0
  39. package/src/tools/core/executor.ts +25 -0
  40. package/src/tools/core/registry.ts +27 -0
  41. package/src/tools/core/types.ts +31 -0
  42. package/src/tools/defaults.ts +673 -0
  43. package/src/tools/discovery.ts +140 -0
  44. package/src/tools/executors/cli.ts +58 -0
  45. package/src/tools/executors/local.ts +25 -0
  46. package/src/tools/executors/mcp.ts +39 -0
  47. package/src/tools/executors/sdk.ts +39 -0
  48. package/src/tools/index.ts +8 -0
  49. package/src/tools/registry.ts +34 -0
  50. package/src/tools/router.ts +123 -0
  51. package/src/tools/schema.ts +58 -0
  52. package/src/tools/skills/loader.ts +61 -0
  53. package/src/tools/skills/resolver.ts +21 -0
  54. package/src/tools/types.ts +29 -0
  55. package/src/types.ts +8 -0
  56. package/src/utils/errors.ts +131 -0
  57. package/src/utils/logger.ts +146 -0
  58. package/src/utils/perf.ts +44 -0
@@ -412,11 +412,22 @@ function resolvePluginSource() {
412
412
  }
413
413
  throw new Error("Unable to locate plugin-entry.js next to CLI distribution files");
414
414
  }
415
+ function isErrnoException(error) {
416
+ return typeof error === "object" && error !== null && "code" in error;
417
+ }
415
418
  function readConfig(configPath) {
416
419
  if (!existsSync(configPath)) {
417
420
  return { plugin: [], provider: {} };
418
421
  }
419
- const raw = readFileSync(configPath, "utf8");
422
+ let raw;
423
+ try {
424
+ raw = readFileSync(configPath, "utf8");
425
+ } catch (error) {
426
+ if (isErrnoException(error) && error.code === "ENOENT") {
427
+ return { plugin: [], provider: {} };
428
+ }
429
+ throw error;
430
+ }
420
431
  try {
421
432
  return JSON.parse(raw);
422
433
  } catch (error) {
@@ -536,14 +547,22 @@ function getStatusResult(configPath, pluginPath) {
536
547
  let pluginType = "missing";
537
548
  let pluginTarget;
538
549
  if (existsSync(pluginPath)) {
539
- const stat = lstatSync(pluginPath);
540
- pluginType = stat.isSymbolicLink() ? "symlink" : "file";
541
- if (pluginType === "symlink") {
542
- try {
543
- pluginTarget = readFileSync(pluginPath, "utf8");
544
- } catch {
545
- pluginTarget = undefined;
550
+ try {
551
+ const stat = lstatSync(pluginPath);
552
+ pluginType = stat.isSymbolicLink() ? "symlink" : "file";
553
+ if (pluginType === "symlink") {
554
+ try {
555
+ pluginTarget = readFileSync(pluginPath, "utf8");
556
+ } catch {
557
+ pluginTarget = undefined;
558
+ }
559
+ }
560
+ } catch (error) {
561
+ if (!isErrnoException(error) || error.code !== "ENOENT") {
562
+ throw error;
546
563
  }
564
+ pluginType = "missing";
565
+ pluginTarget = undefined;
547
566
  }
548
567
  }
549
568
  let providerEnabled = false;
package/dist/index.js CHANGED
@@ -17754,6 +17754,8 @@ var exports_plugin = {};
17754
17754
  __export(exports_plugin, {
17755
17755
  shouldProcessModel: () => shouldProcessModel,
17756
17756
  resolveChatParamTools: () => resolveChatParamTools,
17757
+ normalizeWorkspaceForCompare: () => normalizeWorkspaceForCompare,
17758
+ isReusableProxyHealthPayload: () => isReusableProxyHealthPayload,
17757
17759
  ensurePluginDirectory: () => ensurePluginDirectory,
17758
17760
  default: () => plugin_default,
17759
17761
  CursorPlugin: () => CursorPlugin
@@ -17855,6 +17857,18 @@ function resolveWorkspaceDirectory(worktree, directory) {
17855
17857
  }
17856
17858
  return dirCandidate || cwd || configPrefix;
17857
17859
  }
17860
+ function normalizeWorkspaceForCompare(pathValue) {
17861
+ return resolve(pathValue);
17862
+ }
17863
+ function isReusableProxyHealthPayload(payload, workspaceDirectory) {
17864
+ if (!payload || payload.ok !== true) {
17865
+ return false;
17866
+ }
17867
+ if (typeof payload.workspaceDirectory !== "string" || payload.workspaceDirectory.length === 0) {
17868
+ return false;
17869
+ }
17870
+ return normalizeWorkspaceForCompare(payload.workspaceDirectory) === normalizeWorkspaceForCompare(workspaceDirectory);
17871
+ }
17858
17872
  function parseToolLoopMode(value) {
17859
17873
  const normalized = (value ?? "opencode").trim().toLowerCase();
17860
17874
  if (normalized === "opencode" || normalized === "proxy-exec" || normalized === "off") {
@@ -18055,16 +18069,20 @@ async function findFirstAllowedToolCallInOutput(output, options) {
18055
18069
  async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18056
18070
  const key = getGlobalKey();
18057
18071
  const g = globalThis;
18058
- const existingBaseURL = g[key]?.baseURL;
18072
+ const normalizedWorkspace = normalizeWorkspaceForCompare(workspaceDirectory);
18073
+ const state = g[key] ?? { baseURL: "", baseURLByWorkspace: {} };
18074
+ state.baseURLByWorkspace = state.baseURLByWorkspace ?? {};
18075
+ g[key] = state;
18076
+ const existingBaseURL = state.baseURLByWorkspace[normalizedWorkspace] ?? state.baseURL;
18059
18077
  if (typeof existingBaseURL === "string" && existingBaseURL.length > 0) {
18060
18078
  return existingBaseURL;
18061
18079
  }
18062
- g[key] = { baseURL: "" };
18080
+ state.baseURL = "";
18063
18081
  const handler = async (req) => {
18064
18082
  try {
18065
18083
  const url2 = new URL(req.url);
18066
18084
  if (url2.pathname === "/health") {
18067
- return new Response(JSON.stringify({ ok: true }), {
18085
+ return new Response(JSON.stringify({ ok: true, workspaceDirectory }), {
18068
18086
  status: 200,
18069
18087
  headers: { "Content-Type": "application/json" }
18070
18088
  });
@@ -18182,7 +18200,7 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18182
18200
  ]);
18183
18201
  const stdout = (stdoutText || "").trim();
18184
18202
  const stderr = (stderrText || "").trim();
18185
- const exitCode = child.exitCode;
18203
+ const exitCode = await child.exited;
18186
18204
  log14.debug("cursor-agent completed (bun non-stream)", {
18187
18205
  exitCode,
18188
18206
  stdoutChars: stdout.length,
@@ -18431,14 +18449,15 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18431
18449
  if (streamTerminated) {
18432
18450
  return;
18433
18451
  }
18434
- if (child.exitCode !== 0) {
18452
+ const exitCode = await child.exited;
18453
+ if (exitCode !== 0) {
18435
18454
  const stderrText = await new Response(child.stderr).text();
18436
- const errSource = (stderrText || "").trim() || `cursor-agent exited with code ${String(child.exitCode ?? "unknown")} and no output`;
18455
+ const errSource = (stderrText || "").trim() || `cursor-agent exited with code ${String(exitCode ?? "unknown")} and no output`;
18437
18456
  const parsed = parseAgentError(errSource);
18438
18457
  const msg = formatErrorForUser(parsed);
18439
18458
  log14.error("cursor-cli streaming failed", {
18440
18459
  type: parsed.type,
18441
- code: child.exitCode
18460
+ code: exitCode
18442
18461
  });
18443
18462
  const errChunk = createChatCompletionChunk(id, created, model, msg, true);
18444
18463
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(errChunk)}
@@ -18448,7 +18467,7 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18448
18467
  return;
18449
18468
  }
18450
18469
  log14.debug("cursor-agent completed (bun stream)", {
18451
- exitCode: child.exitCode
18470
+ exitCode
18452
18471
  });
18453
18472
  const doneChunk = createChatCompletionChunk(id, created, model, "", true);
18454
18473
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(doneChunk)}
@@ -18482,8 +18501,12 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18482
18501
  try {
18483
18502
  const res = await fetch(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`).catch(() => null);
18484
18503
  if (res && res.ok) {
18485
- g[key].baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
18486
- return CURSOR_PROXY_DEFAULT_BASE_URL;
18504
+ const payload = await res.json().catch(() => null);
18505
+ if (isReusableProxyHealthPayload(payload, workspaceDirectory)) {
18506
+ state.baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
18507
+ state.baseURLByWorkspace[normalizedWorkspace] = CURSOR_PROXY_DEFAULT_BASE_URL;
18508
+ return CURSOR_PROXY_DEFAULT_BASE_URL;
18509
+ }
18487
18510
  }
18488
18511
  } catch {}
18489
18512
  }
@@ -18494,7 +18517,7 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18494
18517
  const url2 = new URL(req.url || "/", `http://${req.headers.host}`);
18495
18518
  if (url2.pathname === "/health") {
18496
18519
  res.writeHead(200, { "Content-Type": "application/json" });
18497
- res.end(JSON.stringify({ ok: true }));
18520
+ res.end(JSON.stringify({ ok: true, workspaceDirectory }));
18498
18521
  return;
18499
18522
  }
18500
18523
  if (url2.pathname === "/v1/models" || url2.pathname === "/models") {
@@ -18916,7 +18939,8 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18916
18939
  server2.once("error", reject);
18917
18940
  });
18918
18941
  const baseURL = `http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/v1`;
18919
- g[key].baseURL = baseURL;
18942
+ state.baseURL = baseURL;
18943
+ state.baseURLByWorkspace[normalizedWorkspace] = baseURL;
18920
18944
  return baseURL;
18921
18945
  } catch (error45) {
18922
18946
  if (error45?.code !== "EADDRINUSE") {
@@ -18926,8 +18950,12 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18926
18950
  try {
18927
18951
  const res = await fetch(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`).catch(() => null);
18928
18952
  if (res && res.ok) {
18929
- g[key].baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
18930
- return CURSOR_PROXY_DEFAULT_BASE_URL;
18953
+ const payload = await res.json().catch(() => null);
18954
+ if (isReusableProxyHealthPayload(payload, workspaceDirectory)) {
18955
+ state.baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
18956
+ state.baseURLByWorkspace[normalizedWorkspace] = CURSOR_PROXY_DEFAULT_BASE_URL;
18957
+ return CURSOR_PROXY_DEFAULT_BASE_URL;
18958
+ }
18931
18959
  }
18932
18960
  } catch {}
18933
18961
  }
@@ -18938,7 +18966,8 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18938
18966
  });
18939
18967
  const addr = server2.address();
18940
18968
  const baseURL = `http://${CURSOR_PROXY_HOST}:${addr.port}/v1`;
18941
- g[key].baseURL = baseURL;
18969
+ state.baseURL = baseURL;
18970
+ state.baseURLByWorkspace[normalizedWorkspace] = baseURL;
18942
18971
  return baseURL;
18943
18972
  }
18944
18973
  }
@@ -17754,6 +17754,8 @@ var exports_plugin = {};
17754
17754
  __export(exports_plugin, {
17755
17755
  shouldProcessModel: () => shouldProcessModel,
17756
17756
  resolveChatParamTools: () => resolveChatParamTools,
17757
+ normalizeWorkspaceForCompare: () => normalizeWorkspaceForCompare,
17758
+ isReusableProxyHealthPayload: () => isReusableProxyHealthPayload,
17757
17759
  ensurePluginDirectory: () => ensurePluginDirectory,
17758
17760
  default: () => plugin_default,
17759
17761
  CursorPlugin: () => CursorPlugin
@@ -17855,6 +17857,18 @@ function resolveWorkspaceDirectory(worktree, directory) {
17855
17857
  }
17856
17858
  return dirCandidate || cwd || configPrefix;
17857
17859
  }
17860
+ function normalizeWorkspaceForCompare(pathValue) {
17861
+ return resolve2(pathValue);
17862
+ }
17863
+ function isReusableProxyHealthPayload(payload, workspaceDirectory) {
17864
+ if (!payload || payload.ok !== true) {
17865
+ return false;
17866
+ }
17867
+ if (typeof payload.workspaceDirectory !== "string" || payload.workspaceDirectory.length === 0) {
17868
+ return false;
17869
+ }
17870
+ return normalizeWorkspaceForCompare(payload.workspaceDirectory) === normalizeWorkspaceForCompare(workspaceDirectory);
17871
+ }
17858
17872
  function parseToolLoopMode(value) {
17859
17873
  const normalized = (value ?? "opencode").trim().toLowerCase();
17860
17874
  if (normalized === "opencode" || normalized === "proxy-exec" || normalized === "off") {
@@ -18055,16 +18069,20 @@ async function findFirstAllowedToolCallInOutput(output, options) {
18055
18069
  async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18056
18070
  const key = getGlobalKey();
18057
18071
  const g = globalThis;
18058
- const existingBaseURL = g[key]?.baseURL;
18072
+ const normalizedWorkspace = normalizeWorkspaceForCompare(workspaceDirectory);
18073
+ const state = g[key] ?? { baseURL: "", baseURLByWorkspace: {} };
18074
+ state.baseURLByWorkspace = state.baseURLByWorkspace ?? {};
18075
+ g[key] = state;
18076
+ const existingBaseURL = state.baseURLByWorkspace[normalizedWorkspace] ?? state.baseURL;
18059
18077
  if (typeof existingBaseURL === "string" && existingBaseURL.length > 0) {
18060
18078
  return existingBaseURL;
18061
18079
  }
18062
- g[key] = { baseURL: "" };
18080
+ state.baseURL = "";
18063
18081
  const handler = async (req) => {
18064
18082
  try {
18065
18083
  const url2 = new URL(req.url);
18066
18084
  if (url2.pathname === "/health") {
18067
- return new Response(JSON.stringify({ ok: true }), {
18085
+ return new Response(JSON.stringify({ ok: true, workspaceDirectory }), {
18068
18086
  status: 200,
18069
18087
  headers: { "Content-Type": "application/json" }
18070
18088
  });
@@ -18182,7 +18200,7 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18182
18200
  ]);
18183
18201
  const stdout = (stdoutText || "").trim();
18184
18202
  const stderr = (stderrText || "").trim();
18185
- const exitCode = child.exitCode;
18203
+ const exitCode = await child.exited;
18186
18204
  log14.debug("cursor-agent completed (bun non-stream)", {
18187
18205
  exitCode,
18188
18206
  stdoutChars: stdout.length,
@@ -18431,14 +18449,15 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18431
18449
  if (streamTerminated) {
18432
18450
  return;
18433
18451
  }
18434
- if (child.exitCode !== 0) {
18452
+ const exitCode = await child.exited;
18453
+ if (exitCode !== 0) {
18435
18454
  const stderrText = await new Response(child.stderr).text();
18436
- const errSource = (stderrText || "").trim() || `cursor-agent exited with code ${String(child.exitCode ?? "unknown")} and no output`;
18455
+ const errSource = (stderrText || "").trim() || `cursor-agent exited with code ${String(exitCode ?? "unknown")} and no output`;
18437
18456
  const parsed = parseAgentError(errSource);
18438
18457
  const msg = formatErrorForUser(parsed);
18439
18458
  log14.error("cursor-cli streaming failed", {
18440
18459
  type: parsed.type,
18441
- code: child.exitCode
18460
+ code: exitCode
18442
18461
  });
18443
18462
  const errChunk = createChatCompletionChunk(id, created, model, msg, true);
18444
18463
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(errChunk)}
@@ -18448,7 +18467,7 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18448
18467
  return;
18449
18468
  }
18450
18469
  log14.debug("cursor-agent completed (bun stream)", {
18451
- exitCode: child.exitCode
18470
+ exitCode
18452
18471
  });
18453
18472
  const doneChunk = createChatCompletionChunk(id, created, model, "", true);
18454
18473
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(doneChunk)}
@@ -18482,8 +18501,12 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18482
18501
  try {
18483
18502
  const res = await fetch(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`).catch(() => null);
18484
18503
  if (res && res.ok) {
18485
- g[key].baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
18486
- return CURSOR_PROXY_DEFAULT_BASE_URL;
18504
+ const payload = await res.json().catch(() => null);
18505
+ if (isReusableProxyHealthPayload(payload, workspaceDirectory)) {
18506
+ state.baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
18507
+ state.baseURLByWorkspace[normalizedWorkspace] = CURSOR_PROXY_DEFAULT_BASE_URL;
18508
+ return CURSOR_PROXY_DEFAULT_BASE_URL;
18509
+ }
18487
18510
  }
18488
18511
  } catch {}
18489
18512
  }
@@ -18494,7 +18517,7 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18494
18517
  const url2 = new URL(req.url || "/", `http://${req.headers.host}`);
18495
18518
  if (url2.pathname === "/health") {
18496
18519
  res.writeHead(200, { "Content-Type": "application/json" });
18497
- res.end(JSON.stringify({ ok: true }));
18520
+ res.end(JSON.stringify({ ok: true, workspaceDirectory }));
18498
18521
  return;
18499
18522
  }
18500
18523
  if (url2.pathname === "/v1/models" || url2.pathname === "/models") {
@@ -18916,7 +18939,8 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18916
18939
  server2.once("error", reject);
18917
18940
  });
18918
18941
  const baseURL = `http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/v1`;
18919
- g[key].baseURL = baseURL;
18942
+ state.baseURL = baseURL;
18943
+ state.baseURLByWorkspace[normalizedWorkspace] = baseURL;
18920
18944
  return baseURL;
18921
18945
  } catch (error45) {
18922
18946
  if (error45?.code !== "EADDRINUSE") {
@@ -18926,8 +18950,12 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18926
18950
  try {
18927
18951
  const res = await fetch(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`).catch(() => null);
18928
18952
  if (res && res.ok) {
18929
- g[key].baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
18930
- return CURSOR_PROXY_DEFAULT_BASE_URL;
18953
+ const payload = await res.json().catch(() => null);
18954
+ if (isReusableProxyHealthPayload(payload, workspaceDirectory)) {
18955
+ state.baseURL = CURSOR_PROXY_DEFAULT_BASE_URL;
18956
+ state.baseURLByWorkspace[normalizedWorkspace] = CURSOR_PROXY_DEFAULT_BASE_URL;
18957
+ return CURSOR_PROXY_DEFAULT_BASE_URL;
18958
+ }
18931
18959
  }
18932
18960
  } catch {}
18933
18961
  }
@@ -18938,7 +18966,8 @@ async function ensureCursorProxyServer(workspaceDirectory, toolRouter) {
18938
18966
  });
18939
18967
  const addr = server2.address();
18940
18968
  const baseURL = `http://${CURSOR_PROXY_HOST}:${addr.port}/v1`;
18941
- g[key].baseURL = baseURL;
18969
+ state.baseURL = baseURL;
18970
+ state.baseURLByWorkspace[normalizedWorkspace] = baseURL;
18942
18971
  return baseURL;
18943
18972
  }
18944
18973
  }
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@rama_nigg/open-cursor",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "No prompt limits. No broken streams. Full thinking + tool support. Your Cursor subscription, properly integrated.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
+ "module": "src/plugin-entry.ts",
7
8
  "scripts": {
8
9
  "build": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node",
9
10
  "dev": "bun build ./src/index.ts ./src/plugin-entry.ts ./src/cli/discover.ts ./src/cli/opencode-cursor.ts --outdir ./dist --target node --watch",
@@ -20,10 +21,15 @@
20
21
  "cursor-discover": "dist/cli/discover.js"
21
22
  },
22
23
  "exports": {
23
- ".": "./dist/index.js"
24
+ ".": {
25
+ "bun": "./src/plugin-entry.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ }
24
29
  },
25
30
  "files": [
26
- "dist"
31
+ "dist",
32
+ "src"
27
33
  ],
28
34
  "dependencies": {
29
35
  "ai": "^6.0.55",
@@ -0,0 +1,83 @@
1
+ export interface SessionMetrics {
2
+ sessionId: string;
3
+ model: string;
4
+ promptTokens: number;
5
+ toolCalls: number;
6
+ duration: number;
7
+ timestamp: number;
8
+ }
9
+
10
+ export interface AggregateMetrics {
11
+ totalPrompts: number;
12
+ totalToolCalls: number;
13
+ totalDuration: number;
14
+ avgDuration: number;
15
+ }
16
+
17
+ export class MetricsTracker {
18
+ private sessions: Map<string, SessionMetrics> = new Map();
19
+
20
+ recordPrompt(sessionId: string, model: string, tokens: number): void {
21
+ const existing = this.sessions.get(sessionId);
22
+ if (existing) {
23
+ existing.promptTokens = tokens;
24
+ existing.model = model;
25
+ } else {
26
+ this.sessions.set(sessionId, {
27
+ sessionId,
28
+ model,
29
+ promptTokens: tokens,
30
+ toolCalls: 0,
31
+ duration: 0,
32
+ timestamp: Date.now()
33
+ });
34
+ }
35
+ }
36
+
37
+ recordToolCall(sessionId: string, toolName: string, duration: number): void {
38
+ const existing = this.sessions.get(sessionId);
39
+ if (existing) {
40
+ existing.toolCalls++;
41
+ existing.duration += duration;
42
+ }
43
+ // If no session exists, silently ignore (matches test expectations)
44
+ }
45
+
46
+ getSessionMetrics(sessionId: string): SessionMetrics | undefined {
47
+ return this.sessions.get(sessionId);
48
+ }
49
+
50
+ getAggregateMetrics(hours: number): AggregateMetrics {
51
+ const cutoff = Date.now() - (hours * 60 * 60 * 1000);
52
+ let totalPrompts = 0;
53
+ let totalToolCalls = 0;
54
+ let totalDuration = 0;
55
+
56
+ for (const metrics of this.sessions.values()) {
57
+ if (metrics.timestamp >= cutoff) {
58
+ totalPrompts++;
59
+ totalToolCalls += metrics.toolCalls;
60
+ totalDuration += metrics.duration;
61
+ }
62
+ }
63
+
64
+ return {
65
+ totalPrompts,
66
+ totalToolCalls,
67
+ totalDuration,
68
+ avgDuration: totalPrompts > 0 ? Math.round(totalDuration / totalPrompts) : 0
69
+ };
70
+ }
71
+
72
+ clearMetrics(sessionId?: string): void {
73
+ if (sessionId) {
74
+ this.sessions.delete(sessionId);
75
+ } else {
76
+ this.sessions.clear();
77
+ }
78
+ }
79
+
80
+ clearAll(): void {
81
+ this.sessions.clear();
82
+ }
83
+ }
@@ -0,0 +1,107 @@
1
+ import { mkdir, readFile, writeFile } from "fs/promises";
2
+ import { dirname, join } from "path";
3
+
4
+ export interface Session {
5
+ id: string;
6
+ cwd: string;
7
+ modeId?: string;
8
+ cancelled?: boolean;
9
+ resumeId?: string;
10
+ createdAt: number;
11
+ updatedAt: number;
12
+ }
13
+
14
+ export interface SessionCreateOptions {
15
+ cwd?: string;
16
+ modeId?: string;
17
+ }
18
+
19
+ export class SessionManager {
20
+ private sessions: Map<string, Session> = new Map();
21
+ private storagePath: string;
22
+
23
+ constructor(storagePath?: string) {
24
+ this.storagePath = storagePath || join(process.cwd(), ".opencode", "sessions.json");
25
+ }
26
+
27
+ async initialize(): Promise<void> {
28
+ // Load sessions from disk if storage file exists
29
+ try {
30
+ const data = await readFile(this.storagePath, "utf-8");
31
+ const sessions = JSON.parse(data) as Record<string, Session>;
32
+ this.sessions = new Map(Object.entries(sessions));
33
+ } catch {
34
+ // File doesn't exist or is invalid, start fresh
35
+ this.sessions.clear();
36
+ }
37
+ }
38
+
39
+ private async persist(): Promise<void> {
40
+ // Save sessions to disk
41
+ const dir = dirname(this.storagePath);
42
+ await mkdir(dir, { recursive: true });
43
+ const data = JSON.stringify(Object.fromEntries(this.sessions), null, 2);
44
+ await writeFile(this.storagePath, data, "utf-8");
45
+ }
46
+
47
+ async createSession(options: SessionCreateOptions): Promise<Session> {
48
+ const id = `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
49
+ const session: Session = {
50
+ id,
51
+ cwd: options.cwd || process.cwd(),
52
+ modeId: options.modeId,
53
+ cancelled: false,
54
+ createdAt: Date.now(),
55
+ updatedAt: Date.now()
56
+ };
57
+
58
+ this.sessions.set(id, session);
59
+ await this.persist();
60
+ return session;
61
+ }
62
+
63
+ async getSession(id: string): Promise<Session | null> {
64
+ return this.sessions.get(id) || null;
65
+ }
66
+
67
+ async updateSession(id: string, updates: Partial<Session>): Promise<void> {
68
+ const session = this.sessions.get(id);
69
+ if (session) {
70
+ Object.assign(session, updates, { updatedAt: Date.now() });
71
+ await this.persist();
72
+ }
73
+ }
74
+
75
+ async deleteSession(id: string): Promise<void> {
76
+ this.sessions.delete(id);
77
+ await this.persist();
78
+ }
79
+
80
+ isCancelled(id: string): boolean {
81
+ const session = this.sessions.get(id);
82
+ return session?.cancelled || false;
83
+ }
84
+
85
+ markCancelled(id: string): void {
86
+ const session = this.sessions.get(id);
87
+ if (session) {
88
+ session.cancelled = true;
89
+ session.updatedAt = Date.now();
90
+ this.persist().catch(() => {});
91
+ }
92
+ }
93
+
94
+ canResume(id: string): boolean {
95
+ const session = this.sessions.get(id);
96
+ return !!session?.resumeId;
97
+ }
98
+
99
+ setResumeId(id: string, resumeId: string): void {
100
+ const session = this.sessions.get(id);
101
+ if (session) {
102
+ session.resumeId = resumeId;
103
+ session.updatedAt = Date.now();
104
+ this.persist().catch(() => {});
105
+ }
106
+ }
107
+ }