@fieldwangai/agentflow 0.1.165 → 0.1.166

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.
@@ -7,13 +7,18 @@ import { normalizeCursorModelForCli } from "./model-config.mjs";
7
7
  import { t } from "./i18n.mjs";
8
8
  import { readMergedEnvObject } from "./user-env.mjs";
9
9
  import {
10
+ classifyCursorApiKeyLimitError,
11
+ clearCursorApiKeyLaneCooldown,
10
12
  createCursorApiKeyAttempts,
11
13
  cursorApiKeyCooldownMinutes,
12
14
  cursorApiKeyEnv,
13
15
  cursorApiKeyLabel,
16
+ isCursorAutoFallbackEligible,
14
17
  isCursorQuotaError,
15
- markCursorApiKeyQuotaBlocked,
18
+ markCursorApiKeyLaneBlocked,
19
+ recordCursorApiKeyFallbackModel,
16
20
  } from "./cursor-api-key-pool.mjs";
21
+ import { discoverCursorModels } from "./cursor-model-catalog.mjs";
17
22
  import { outputNodeBasename } from "../pipeline/get-exec-id.mjs";
18
23
 
19
24
  function shouldPassCursorModelArg(model) {
@@ -53,6 +58,16 @@ function nextCursorAttemptOptions(options = {}, attempts = [], attemptIndex = 0)
53
58
  ...options,
54
59
  _agentflowCursorApiKeyAttempts: attempts,
55
60
  _agentflowCursorApiKeyAttemptIndex: attemptIndex + 1,
61
+ _agentflowCursorModelSelection: undefined,
62
+ };
63
+ }
64
+
65
+ function cursorModelAttemptOptions(options = {}, attempts = [], attemptIndex = 0, modelSelection) {
66
+ return {
67
+ ...options,
68
+ _agentflowCursorApiKeyAttempts: attempts,
69
+ _agentflowCursorApiKeyAttemptIndex: attemptIndex,
70
+ _agentflowCursorModelSelection: modelSelection,
56
71
  };
57
72
  }
58
73
 
@@ -402,7 +417,7 @@ function tryEmitOpenCodeLineAsNatural(line, emit) {
402
417
  export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {}) {
403
418
  const onStreamEvent = typeof options.onStreamEvent === "function" ? options.onStreamEvent : null;
404
419
  const ws = path.resolve(cliWorkspace);
405
- const model = normalizeCursorModelForCli(options.model ?? process.env.CURSOR_AGENT_MODEL ?? null);
420
+ const requestedModel = normalizeCursorModelForCli(options.model ?? process.env.CURSOR_AGENT_MODEL ?? null);
406
421
  const agentCmd = process.env.CURSOR_AGENT_CMD || "agent";
407
422
  const {
408
423
  baseEnv: cursorBaseEnv,
@@ -410,6 +425,13 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
410
425
  attemptIndex: cursorAttemptIndex,
411
426
  selection: cursorSelection,
412
427
  } = cursorAttemptOptions(options);
428
+ const hasExplicitModel = shouldPassCursorModelArg(requestedModel);
429
+ const cursorModelSelection = hasExplicitModel
430
+ ? { lane: "auto", modelId: requestedModel, modelName: requestedModel }
431
+ : options._agentflowCursorModelSelection
432
+ || cursorSelection?.modelSelection
433
+ || { lane: "auto", modelId: "auto", modelName: "Auto" };
434
+ const model = hasExplicitModel ? requestedModel : cursorModelSelection.modelId;
413
435
  // Web UI Composer 需要能无交互执行本机 curl 等命令来刷新画布。
414
436
  const args = ["--print", "--output-format", "stream-json", "--trust", "--sandbox", "disabled", "--workspace", ws];
415
437
  const approveMcps = process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "0" && process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "false";
@@ -448,6 +470,12 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
448
470
  line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} / ${cursorAttempts.length}`,
449
471
  });
450
472
  }
473
+ if (cursorModelSelection.lane === "fallback") {
474
+ emit({
475
+ type: "status",
476
+ line: `Cursor is using Composer fallback: ${cursorModelSelection.modelName}`,
477
+ });
478
+ }
451
479
 
452
480
  if (!useStderrInherit) {
453
481
  child.stderr.on("data", (chunk) => {
@@ -588,20 +616,83 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
588
616
  }
589
617
  const retryCursorQuota = (errorText) => {
590
618
  if (!cursorSelection) return false;
591
- if (cursorAttemptIndex >= cursorAttempts.length - 1) return false;
592
619
  if (hadToolActivity) return false;
593
620
  if (!isCursorQuotaError(errorText)) return false;
594
- markCursorApiKeyQuotaBlocked(cursorSelection, cursorApiKeyCooldownMinutes(cursorBaseEnv));
595
- emit({
596
- type: "status",
597
- line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} reached quota, retrying ${cursorAttemptIndex + 2}/${cursorAttempts.length}`,
598
- });
599
- const next = runCursorAgentWithPrompt(
600
- cliWorkspace,
601
- promptText,
602
- nextCursorAttemptOptions(options, cursorAttempts, cursorAttemptIndex),
621
+ const errorCategory = classifyCursorApiKeyLimitError(errorText);
622
+ const cooldownMinutes = cursorApiKeyCooldownMinutes(cursorBaseEnv);
623
+ markCursorApiKeyLaneBlocked(
624
+ cursorSelection,
625
+ cursorModelSelection.lane,
626
+ cooldownMinutes,
627
+ errorText,
603
628
  );
604
- next.finished.then(resolve).catch(reject);
629
+ const canTryComposer = !hasExplicitModel
630
+ && cursorModelSelection.lane === "auto"
631
+ && errorCategory === "explicit_limit"
632
+ && isCursorAutoFallbackEligible(errorText);
633
+ const hasNextKey = cursorAttemptIndex < cursorAttempts.length - 1;
634
+ if (!canTryComposer && !hasNextKey) return false;
635
+
636
+ const retry = async () => {
637
+ if (canTryComposer) {
638
+ emit({
639
+ type: "status",
640
+ line: `Cursor Auto on API Key ${cursorApiKeyLabel(cursorSelection)} is out of usage; discovering Composer fallback...`,
641
+ });
642
+ const catalog = await discoverCursorModels({
643
+ keyId: cursorSelection.id,
644
+ cwd: ws,
645
+ command: agentCmd,
646
+ env: childEnv(options, cursorApiKeyEnv(cursorSelection)),
647
+ });
648
+ if (catalog.fallbackModel) {
649
+ recordCursorApiKeyFallbackModel(cursorSelection, catalog.fallbackModel);
650
+ const fallbackSelection = {
651
+ lane: "fallback",
652
+ modelId: catalog.fallbackModel.id,
653
+ modelName: catalog.fallbackModel.displayName,
654
+ };
655
+ emit({
656
+ type: "status",
657
+ line: `Switching the same Cursor API Key to ${fallbackSelection.modelName}.`,
658
+ });
659
+ emit({
660
+ type: "raw",
661
+ source: "cursor",
662
+ stream: "runner",
663
+ eventType: "model_fallback",
664
+ text: `auto -> ${fallbackSelection.modelId}`,
665
+ });
666
+ const fallback = runCursorAgentWithPrompt(
667
+ cliWorkspace,
668
+ promptText,
669
+ cursorModelAttemptOptions(options, cursorAttempts, cursorAttemptIndex, fallbackSelection),
670
+ );
671
+ await fallback.finished;
672
+ return;
673
+ }
674
+ emit({
675
+ type: "status",
676
+ line: `Composer fallback is unavailable${catalog.error ? `: ${catalog.error}` : "."}`,
677
+ });
678
+ }
679
+
680
+ if (hasNextKey) {
681
+ emit({
682
+ type: "status",
683
+ line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} reached its limit; retrying ${cursorAttemptIndex + 2}/${cursorAttempts.length}.`,
684
+ });
685
+ const next = runCursorAgentWithPrompt(
686
+ cliWorkspace,
687
+ promptText,
688
+ nextCursorAttemptOptions(options, cursorAttempts, cursorAttemptIndex),
689
+ );
690
+ await next.finished;
691
+ return;
692
+ }
693
+ throw new Error(errorText || "Cursor API Key reached its limit.");
694
+ };
695
+ retry().then(resolve).catch(reject);
605
696
  return true;
606
697
  };
607
698
  if (code !== 0 && lastResult == null) {
@@ -622,6 +713,7 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
622
713
  reject(new Error(msg));
623
714
  return;
624
715
  }
716
+ if (cursorSelection) clearCursorApiKeyLaneCooldown(cursorSelection, cursorModelSelection.lane);
625
717
  resolve();
626
718
  });
627
719
  });
@@ -1,71 +1,180 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  let nextCursorApiKeyCursor = 0;
2
- const cursorApiKeyBlockedUntil = new Map();
4
+ const cursorApiKeyStates = new Map();
3
5
 
4
- export function parseCursorApiKeyPool(value = "") {
6
+ export function parseCursorApiKeyRecords(value = "") {
5
7
  const raw = String(value || "").trim();
6
8
  if (!raw) return [];
9
+ const normalizeRecord = (item, index) => {
10
+ if (typeof item === "string") {
11
+ const key = item.trim();
12
+ return key ? { id: legacyKeyId(key), name: `Key ${index + 1}`, key } : null;
13
+ }
14
+ if (!item || typeof item !== "object") return null;
15
+ const key = String(item.key || "").trim();
16
+ if (!key) return null;
17
+ return {
18
+ id: String(item.id || "").trim() || legacyKeyId(key),
19
+ name: String(item.name || "").trim() || `Key ${index + 1}`,
20
+ key,
21
+ };
22
+ };
7
23
  try {
8
24
  const parsed = JSON.parse(raw);
9
- if (Array.isArray(parsed)) {
10
- return parsed
11
- .map((item) => {
12
- if (typeof item === "string") return item.trim();
13
- if (item && typeof item === "object") return String(item.key || "").trim();
14
- return "";
15
- })
16
- .filter(Boolean);
17
- }
25
+ if (Array.isArray(parsed)) return parsed.map(normalizeRecord).filter(Boolean);
18
26
  } catch {
19
27
  // Legacy comma-separated format.
20
28
  }
21
- return raw.split(",").map((key) => key.trim()).filter(Boolean);
29
+ return raw.split(",").map(normalizeRecord).filter(Boolean);
22
30
  }
23
31
 
24
- export function createCursorApiKeyAttempts(env = {}) {
25
- const keys = parseCursorApiKeyPool(env.CURSOR_API_KEYS);
26
- if (keys.length === 0) return [undefined];
32
+ export function parseCursorApiKeyPool(value = "") {
33
+ return parseCursorApiKeyRecords(value).map((record) => record.key);
34
+ }
27
35
 
28
- const now = Date.now();
29
- const candidates = keys
30
- .map((key, index) => ({ key, index, total: keys.length }))
31
- .filter((selection) => (cursorApiKeyBlockedUntil.get(selection.index) || 0) <= now);
32
- const effective = candidates.length > 0 ? candidates : keys.map((key, index) => ({ key, index, total: keys.length }));
36
+ export function createCursorApiKeyAttempts(env = {}, now = Date.now()) {
37
+ const records = parseCursorApiKeyRecords(env.CURSOR_API_KEYS);
38
+ if (records.length === 0) return [undefined];
39
+
40
+ const selections = records.map((record, index) => ({
41
+ ...record,
42
+ index,
43
+ total: records.length,
44
+ modelSelection: getCursorApiKeyModelSelection(record.id, now),
45
+ }));
46
+ const candidates = selections.filter((selection) => Boolean(selection.modelSelection));
47
+ // Preserve the old pool's last-resort behavior if every key is cooling down.
48
+ // Normal operation always uses candidates and therefore respects lane cooldowns.
49
+ const effective = candidates.length > 0
50
+ ? candidates
51
+ : selections.map((selection) => ({
52
+ ...selection,
53
+ modelSelection: { lane: "auto", modelId: "auto", modelName: "Auto" },
54
+ }));
33
55
  const start = nextCursorApiKeyCursor % effective.length;
34
56
  nextCursorApiKeyCursor += 1;
35
57
  return [...effective.slice(start), ...effective.slice(0, start)];
36
58
  }
37
59
 
38
- export function cursorApiKeyEnv(selection) {
39
- return selection && selection.key ? { CURSOR_API_KEY: selection.key } : {};
60
+ export function getCursorApiKeyModelSelection(keyOrSelection, now = Date.now()) {
61
+ const keyId = selectionId(keyOrSelection);
62
+ const keyState = cursorApiKeyStates.get(keyId);
63
+ const autoBlocked = (keyState?.auto?.blockedUntil || 0) > now;
64
+ if (!autoBlocked) return { lane: "auto", modelId: "auto", modelName: "Auto" };
65
+
66
+ if (keyState?.auto?.errorCategory !== "explicit_limit" || keyState.auto.fallbackEligible !== true) {
67
+ return undefined;
68
+ }
69
+ const fallbackModel = keyState.fallbackModel;
70
+ if (!fallbackModel || (keyState.fallback?.blockedUntil || 0) > now) return undefined;
71
+ return {
72
+ lane: "fallback",
73
+ modelId: fallbackModel.id,
74
+ modelName: fallbackModel.displayName,
75
+ };
76
+ }
77
+
78
+ export function recordCursorApiKeyFallbackModel(keyOrSelection, model) {
79
+ if (!model?.id) return;
80
+ const keyId = selectionId(keyOrSelection);
81
+ const keyState = cursorApiKeyStates.get(keyId) || {};
82
+ keyState.fallbackModel = { ...model };
83
+ cursorApiKeyStates.set(keyId, keyState);
40
84
  }
41
85
 
42
- export function markCursorApiKeyQuotaBlocked(selection, cooldownMinutes = 30) {
43
- if (!selection || !Number.isFinite(selection.index)) return;
86
+ export function markCursorApiKeyLaneBlocked(
87
+ keyOrSelection,
88
+ lane,
89
+ cooldownMinutes = 30,
90
+ errorText = "",
91
+ now = Date.now(),
92
+ ) {
93
+ const keyId = selectionId(keyOrSelection);
94
+ if (!keyId || !["auto", "fallback"].includes(lane)) return 0;
44
95
  const minutes = Math.max(1, Number(cooldownMinutes) || 30);
45
- cursorApiKeyBlockedUntil.set(selection.index, Date.now() + minutes * 60 * 1000);
96
+ const keyState = cursorApiKeyStates.get(keyId) || {};
97
+ const currentLane = keyState[lane] || {};
98
+ const blockedUntil = Math.max(currentLane.blockedUntil || 0, now + minutes * 60 * 1000);
99
+ const errorCategory = classifyCursorApiKeyLimitError(errorText);
100
+ keyState[lane] = {
101
+ ...currentLane,
102
+ blockedUntil,
103
+ ...(errorCategory ? { errorCategory } : {}),
104
+ ...(lane === "auto" ? { fallbackEligible: isCursorAutoFallbackEligible(errorText) } : {}),
105
+ };
106
+ cursorApiKeyStates.set(keyId, keyState);
107
+ return blockedUntil;
108
+ }
109
+
110
+ export function clearCursorApiKeyLaneCooldown(keyOrSelection, lane) {
111
+ const keyState = cursorApiKeyStates.get(selectionId(keyOrSelection));
112
+ if (!keyState?.[lane]) return false;
113
+ keyState[lane].blockedUntil = 0;
114
+ delete keyState[lane].errorCategory;
115
+ delete keyState[lane].fallbackEligible;
116
+ return true;
117
+ }
118
+
119
+ export function cursorApiKeyEnv(selection) {
120
+ return selection?.key ? { CURSOR_API_KEY: selection.key } : {};
121
+ }
122
+
123
+ export function markCursorApiKeyQuotaBlocked(selection, cooldownMinutes = 30, errorText = "") {
124
+ return markCursorApiKeyLaneBlocked(selection, "auto", cooldownMinutes, errorText);
46
125
  }
47
126
 
48
127
  export function cursorApiKeyLabel(selection) {
49
128
  if (!selection) return "default";
50
- return `${selection.index + 1}/${selection.total}`;
129
+ return selection.name ? `${selection.index + 1}/${selection.total} (${selection.name})` : `${selection.index + 1}/${selection.total}`;
51
130
  }
52
131
 
53
- export function isCursorQuotaError(error = "") {
132
+ export function isCursorAutoFallbackEligible(error = "") {
54
133
  const text = String(error || "");
55
- if (!text) return false;
56
- return [
134
+ return [/\bout\s+of\s+usage\b/i, /\busage\s+limit\b/i].some((pattern) => pattern.test(text));
135
+ }
136
+
137
+ export function classifyCursorApiKeyLimitError(error = "") {
138
+ const text = String(error || "");
139
+ if (!text) return undefined;
140
+ const isExplicitLimit = [
57
141
  /\b429\b/i,
58
142
  /rate[_\s-]*limit/i,
59
143
  /too many requests/i,
60
144
  /quota/i,
61
145
  /usage\s+limit/i,
146
+ /out\s+of\s+usage/i,
62
147
  /limit\s+(?:exceeded|reached)/i,
63
148
  /exceeded\s+(?:your\s+)?limit/i,
64
- /resource[_\s-]*exhausted/i,
65
- /ActionRequiredError/i,
149
+ /insufficient[_\s-]*quota/i,
150
+ /credit[s]?\s+(?:exhausted|limit)/i,
151
+ /request\s+limit/i,
66
152
  ].some((pattern) => pattern.test(text));
153
+ if (isExplicitLimit) return "explicit_limit";
154
+ if (/resource[_\s-]*exhausted/i.test(text)) return "resource_exhausted";
155
+ return undefined;
156
+ }
157
+
158
+ export function isCursorQuotaError(error = "") {
159
+ return classifyCursorApiKeyLimitError(error) !== undefined;
67
160
  }
68
161
 
69
162
  export function cursorApiKeyCooldownMinutes(env = {}) {
70
163
  return Math.max(1, Number(env.AGENTFLOW_CURSOR_API_KEY_COOLDOWN_MINUTES || env.CURSOR_API_KEY_COOLDOWN_MINUTES || 30) || 30);
71
164
  }
165
+
166
+ export function resetCursorApiKeyPoolForTests() {
167
+ nextCursorApiKeyCursor = 0;
168
+ cursorApiKeyStates.clear();
169
+ }
170
+
171
+ function selectionId(keyOrSelection) {
172
+ if (typeof keyOrSelection === "string") return keyOrSelection;
173
+ if (keyOrSelection?.id) return String(keyOrSelection.id);
174
+ if (keyOrSelection?.key) return legacyKeyId(String(keyOrSelection.key));
175
+ return "default";
176
+ }
177
+
178
+ function legacyKeyId(key) {
179
+ return `legacy_${createHash("sha256").update(String(key || "")).digest("hex").slice(0, 16)}`;
180
+ }
@@ -0,0 +1,152 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const MODEL_CATALOG_TIMEOUT_MS = 15_000;
4
+ const MODEL_CATALOG_CACHE_MS = 15 * 60 * 1000;
5
+ const MAX_CATALOG_OUTPUT_LENGTH = 64 * 1024;
6
+ const ANSI_ESCAPE_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
7
+
8
+ const modelCatalogCache = new Map();
9
+
10
+ export async function discoverCursorModels({
11
+ keyId,
12
+ cwd,
13
+ env,
14
+ command = "agent",
15
+ forceRefresh = false,
16
+ } = {}) {
17
+ const now = Date.now();
18
+ const cacheKey = String(keyId || "default");
19
+ const cached = modelCatalogCache.get(cacheKey);
20
+ if (!forceRefresh && cached && cached.expiresAt > now) {
21
+ return buildCatalogResult(cached.models);
22
+ }
23
+
24
+ const commandResult = await runModelsCommand({ cwd, env, command });
25
+ if (!commandResult.success) return { models: [], error: commandResult.error };
26
+
27
+ const models = parseCursorModelsOutput(commandResult.output);
28
+ if (models.length === 0) {
29
+ return { models: [], error: "Cursor CLI did not return any available models." };
30
+ }
31
+ modelCatalogCache.set(cacheKey, {
32
+ expiresAt: now + MODEL_CATALOG_CACHE_MS,
33
+ models,
34
+ });
35
+ return buildCatalogResult(models);
36
+ }
37
+
38
+ export function parseCursorModelsOutput(output = "") {
39
+ const lines = stripAnsi(String(output || ""))
40
+ .split(/\r?\n/)
41
+ .map((line) => line.trim())
42
+ .filter(Boolean);
43
+ const models = [];
44
+ let insideModelList = false;
45
+ for (const line of lines) {
46
+ if (/^available models$/i.test(line)) {
47
+ insideModelList = true;
48
+ continue;
49
+ }
50
+ if (/^tip:/i.test(line)) break;
51
+ if (!insideModelList || /^no models available/i.test(line)) continue;
52
+
53
+ const flagsMatch = line.match(/\s+\(((?:current|default)(?:,\s*(?:current|default))*)\)$/i);
54
+ const flags = flagsMatch?.[1]?.toLowerCase() || "";
55
+ const value = flagsMatch ? line.slice(0, flagsMatch.index).trim() : line;
56
+ const separatorIndex = value.indexOf(" - ");
57
+ const id = (separatorIndex >= 0 ? value.slice(0, separatorIndex) : value).trim();
58
+ const displayName = (separatorIndex >= 0 ? value.slice(separatorIndex + 3) : id).trim();
59
+ if (!id || (separatorIndex < 0 && /\s/.test(id))) continue;
60
+ models.push({
61
+ id,
62
+ displayName: displayName || id,
63
+ isDefault: flags.split(/,\s*/).includes("default"),
64
+ isCurrent: flags.split(/,\s*/).includes("current"),
65
+ });
66
+ }
67
+ return dedupeModels(models);
68
+ }
69
+
70
+ export function selectComposerFallbackModel(models = []) {
71
+ return models.find((model) => [model?.id, model?.displayName].some((value) =>
72
+ /(^|[^a-z0-9])composer(?=$|[^a-z0-9])/i.test(String(value || ""))
73
+ ));
74
+ }
75
+
76
+ export function clearCursorModelCatalogCache() {
77
+ modelCatalogCache.clear();
78
+ }
79
+
80
+ function buildCatalogResult(models) {
81
+ const fallback = selectComposerFallbackModel(models);
82
+ return {
83
+ models,
84
+ ...(fallback ? {
85
+ fallbackModel: {
86
+ id: fallback.id,
87
+ displayName: formatComposerModelName(fallback),
88
+ discoveredAt: new Date().toISOString(),
89
+ },
90
+ } : {}),
91
+ };
92
+ }
93
+
94
+ function runModelsCommand({ cwd, env, command }) {
95
+ return new Promise((resolve) => {
96
+ let stdout = "";
97
+ let stderr = "";
98
+ let settled = false;
99
+ const child = spawn(command || "agent", ["models"], {
100
+ cwd,
101
+ shell: false,
102
+ stdio: ["ignore", "pipe", "pipe"],
103
+ env,
104
+ });
105
+ const finish = (result) => {
106
+ if (settled) return;
107
+ settled = true;
108
+ clearTimeout(timeoutId);
109
+ resolve(result);
110
+ };
111
+ const timeoutId = setTimeout(() => {
112
+ child.kill("SIGTERM");
113
+ finish({ success: false, error: "Cursor CLI model discovery timed out." });
114
+ }, MODEL_CATALOG_TIMEOUT_MS);
115
+ child.stdout?.on("data", (chunk) => {
116
+ if (stdout.length < MAX_CATALOG_OUTPUT_LENGTH) stdout += chunk.toString();
117
+ });
118
+ child.stderr?.on("data", (chunk) => {
119
+ if (stderr.length < MAX_CATALOG_OUTPUT_LENGTH) stderr += chunk.toString();
120
+ });
121
+ child.on("error", (error) => finish({ success: false, error: error.message }));
122
+ child.on("exit", (code) => {
123
+ if (code === 0) {
124
+ finish({ success: true, output: stdout.slice(0, MAX_CATALOG_OUTPUT_LENGTH) });
125
+ return;
126
+ }
127
+ finish({
128
+ success: false,
129
+ error: stripAnsi(stderr || stdout || `Cursor CLI models exited with code ${code}`).trim().slice(0, 1000),
130
+ });
131
+ });
132
+ });
133
+ }
134
+
135
+ function stripAnsi(value) {
136
+ return String(value || "").replace(ANSI_ESCAPE_PATTERN, "");
137
+ }
138
+
139
+ function formatComposerModelName(model) {
140
+ if (/(^|[^a-z0-9])composer(?=$|[^a-z0-9])/i.test(model.displayName)) return model.displayName;
141
+ if (model.displayName && model.displayName !== model.id) return `${model.id} · ${model.displayName}`;
142
+ return model.id;
143
+ }
144
+
145
+ function dedupeModels(models) {
146
+ const seen = new Set();
147
+ return models.filter((model) => {
148
+ if (seen.has(model.id)) return false;
149
+ seen.add(model.id);
150
+ return true;
151
+ });
152
+ }