@fieldwangai/agentflow 0.1.166 → 0.1.167

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.
@@ -17,6 +17,7 @@ import {
17
17
  isCursorQuotaError,
18
18
  markCursorApiKeyLaneBlocked,
19
19
  recordCursorApiKeyFallbackModel,
20
+ recordCursorApiKeyUsage,
20
21
  } from "./cursor-api-key-pool.mjs";
21
22
  import { discoverCursorModels } from "./cursor-model-catalog.mjs";
22
23
  import { outputNodeBasename } from "../pipeline/get-exec-id.mjs";
@@ -123,7 +124,7 @@ function shouldSkipCodexGitCheck(workspace) {
123
124
  return !hasGitMetadataAncestor(workspace);
124
125
  }
125
126
 
126
- function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessagePath, promptText, configArgs = [] }) {
127
+ function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessagePath, promptText, configArgs = [], sandboxMode = "", allowDanger = true }) {
127
128
  const args = [];
128
129
  for (const cfg of Array.isArray(configArgs) ? configArgs : []) {
129
130
  const value = String(cfg || "").trim();
@@ -137,10 +138,10 @@ function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessageP
137
138
  const abs = path.resolve(dir);
138
139
  if (abs && abs !== workspace) args.push("--add-dir", abs);
139
140
  }
140
- if (envFlag("AGENTFLOW_CODEX_DANGER", false)) {
141
+ if (allowDanger && envFlag("AGENTFLOW_CODEX_DANGER", false)) {
141
142
  args.push("--dangerously-bypass-approvals-and-sandbox");
142
143
  } else {
143
- args.push("--sandbox", String(process.env.AGENTFLOW_CODEX_SANDBOX || "workspace-write").trim() || "workspace-write");
144
+ args.push("--sandbox", String(sandboxMode || process.env.AGENTFLOW_CODEX_SANDBOX || "workspace-write").trim() || "workspace-write");
144
145
  }
145
146
  if (shouldSkipCodexGitCheck(workspace)) args.push("--skip-git-repo-check");
146
147
  if (envFlag("AGENTFLOW_CODEX_EPHEMERAL", false)) args.push("--ephemeral");
@@ -432,11 +433,16 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
432
433
  || cursorSelection?.modelSelection
433
434
  || { lane: "auto", modelId: "auto", modelName: "Auto" };
434
435
  const model = hasExplicitModel ? requestedModel : cursorModelSelection.modelId;
436
+ if (cursorSelection) recordCursorApiKeyUsage(cursorSelection, cursorModelSelection);
435
437
  // Web UI Composer 需要能无交互执行本机 curl 等命令来刷新画布。
436
- const args = ["--print", "--output-format", "stream-json", "--trust", "--sandbox", "disabled", "--workspace", ws];
437
- const approveMcps = process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "0" && process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "false";
438
+ const args = ["--print", "--output-format", "stream-json"];
439
+ if (options.mode) args.push("--mode", String(options.mode));
440
+ args.push("--trust");
441
+ if (options.sandboxDisabled !== false) args.push("--sandbox", "disabled");
442
+ args.push("--workspace", ws);
443
+ const approveMcps = options.approveMcps ?? (process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "0" && process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "false");
438
444
  if (approveMcps) args.push("--approve-mcps");
439
- args.push("--force");
445
+ if (options.force !== false) args.push("--force");
440
446
  if (shouldPassCursorModelArg(model)) args.push("--model", model);
441
447
  args.push(promptText);
442
448
 
@@ -551,14 +557,18 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
551
557
  if (options.onToolCall) options.onToolCall("thinking", "");
552
558
  } else if (event.type === "result") {
553
559
  lastResult = event;
554
- const resultNl = extractCursorResultNl(event);
560
+ const resultNl = options.includeJsonResult && typeof event.result === "string"
561
+ ? normalizeStreamTextChunk(event.result)
562
+ : extractCursorResultNl(event);
555
563
  if (resultNl) emit({ type: "natural", kind: "result", text: resultNl });
556
564
  if (event.subtype === "success" && !event.is_error) {
557
565
  hadError = false;
558
566
  emit({ type: "status", line: t("runner.completed") });
559
567
  } else {
560
568
  hadError = true;
561
- const errNl = extractCursorResultNl(event);
569
+ const errNl = options.includeJsonResult && typeof event.result === "string"
570
+ ? normalizeStreamTextChunk(event.result)
571
+ : extractCursorResultNl(event);
562
572
  if (errNl) emit({ type: "natural", kind: "error", text: errNl });
563
573
  emit({
564
574
  type: "status",
@@ -619,12 +629,17 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
619
629
  if (hadToolActivity) return false;
620
630
  if (!isCursorQuotaError(errorText)) return false;
621
631
  const errorCategory = classifyCursorApiKeyLimitError(errorText);
622
- const cooldownMinutes = cursorApiKeyCooldownMinutes(cursorBaseEnv);
632
+ const cooldownMinutes = cursorApiKeyCooldownMinutes(cursorBaseEnv, errorText);
623
633
  markCursorApiKeyLaneBlocked(
624
634
  cursorSelection,
625
635
  cursorModelSelection.lane,
626
636
  cooldownMinutes,
627
637
  errorText,
638
+ Date.now(),
639
+ {
640
+ modelId: cursorModelSelection.modelId,
641
+ modelName: cursorModelSelection.modelName,
642
+ },
628
643
  );
629
644
  const canTryComposer = !hasExplicitModel
630
645
  && cursorModelSelection.lane === "auto"
@@ -840,6 +855,7 @@ export function runClaudeCodeAgentWithPrompt(cliWorkspace, promptText, options =
840
855
  const model = options.model && String(options.model).trim();
841
856
  const claudeCmd = process.env.CLAUDE_CODE_CMD || "claude";
842
857
  const bypassPermissions =
858
+ options.allowDanger !== false &&
843
859
  process.env.AGENTFLOW_CLAUDE_CODE_BYPASS_PERMISSIONS !== "0" &&
844
860
  process.env.AGENTFLOW_CLAUDE_CODE_BYPASS_PERMISSIONS !== "false";
845
861
  const args = ["-p", "--output-format", "stream-json", "--verbose", "--add-dir", ws];
@@ -1033,6 +1049,8 @@ export function runCodexAgentWithPrompt(cliWorkspace, promptText, options = {})
1033
1049
  outputLastMessagePath,
1034
1050
  promptText,
1035
1051
  configArgs: options.codexConfigArgs,
1052
+ sandboxMode: options.sandboxMode,
1053
+ allowDanger: options.allowDanger !== false,
1036
1054
  });
1037
1055
 
1038
1056
  const useStderrInherit =
@@ -0,0 +1,293 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const EXPLORATION_DIR = path.join(".workspace", "agentflow", "explorations");
6
+ const SESSION_ID_RE = /^exp_[a-z0-9_-]{8,80}$/i;
7
+ const EVENT_TYPES = new Set(["run", "turn", "decision", "agent", "tool", "command", "file", "artifact", "status"]);
8
+ const EVENT_STATUSES = new Set(["planned", "running", "success", "error", "blocked", "skipped"]);
9
+ const EVENT_PHASES = new Set(["planned", "simulated", "observed", "materialized"]);
10
+ const SIDE_EFFECTS = new Set(["none", "read", "write", "external"]);
11
+
12
+ function explorationRoot(workspaceRoot) {
13
+ return path.join(path.resolve(workspaceRoot), EXPLORATION_DIR);
14
+ }
15
+
16
+ function sessionDir(workspaceRoot, sessionId) {
17
+ const id = normalizeSessionId(sessionId);
18
+ return path.join(explorationRoot(workspaceRoot), id);
19
+ }
20
+
21
+ function sessionMetadataPath(workspaceRoot, sessionId) {
22
+ return path.join(sessionDir(workspaceRoot, sessionId), "session.json");
23
+ }
24
+
25
+ function sessionEventsPath(workspaceRoot, sessionId) {
26
+ return path.join(sessionDir(workspaceRoot, sessionId), "trace.jsonl");
27
+ }
28
+
29
+ function normalizeSessionId(value) {
30
+ const id = String(value || "").trim();
31
+ if (!SESSION_ID_RE.test(id)) throw new Error("Invalid exploration session id");
32
+ return id;
33
+ }
34
+
35
+ function clip(value, max = 2000) {
36
+ return redactSecrets(String(value ?? "")).trim().slice(0, max);
37
+ }
38
+
39
+ function redactSecrets(value) {
40
+ return String(value || "")
41
+ .replace(/\b(?:sk|key|token|secret)[-_][A-Za-z0-9_.-]{8,}\b/gi, "[redacted]")
42
+ .replace(/(authorization\s*[:=]\s*bearer\s+)[^\s,;]+/gi, "$1[redacted]")
43
+ .replace(/((?:api[_-]?key|access[_-]?token|password|secret)\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]");
44
+ }
45
+
46
+ function writeJsonAtomic(filePath, value) {
47
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
49
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
50
+ fs.renameSync(tempPath, filePath);
51
+ }
52
+
53
+ function readJson(filePath) {
54
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
55
+ }
56
+
57
+ function sessionSummary(raw = {}) {
58
+ return {
59
+ version: 1,
60
+ id: normalizeSessionId(raw.id),
61
+ title: clip(raw.title || "AI 探索运行", 160),
62
+ goal: clip(raw.goal || "", 4000),
63
+ summary: clip(raw.summary || "", 2000),
64
+ mode: EVENT_PHASES.has(String(raw.mode || "")) ? String(raw.mode) : "planned",
65
+ status: ["draft", "planning", "ready", "running", "completed", "failed"].includes(String(raw.status || ""))
66
+ ? String(raw.status)
67
+ : "draft",
68
+ source: {
69
+ provider: clip(raw.source?.provider || "agentflow", 80),
70
+ agent: clip(raw.source?.agent || "workspace", 120),
71
+ },
72
+ eventCount: Math.max(0, Number(raw.eventCount || 0) || 0),
73
+ createdAt: String(raw.createdAt || new Date().toISOString()),
74
+ updatedAt: String(raw.updatedAt || raw.createdAt || new Date().toISOString()),
75
+ ...(raw.materializedAt ? { materializedAt: String(raw.materializedAt) } : {}),
76
+ };
77
+ }
78
+
79
+ export function createAiExplorationSession(workspaceRoot, input = {}) {
80
+ const now = new Date().toISOString();
81
+ const id = `exp_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
82
+ const session = sessionSummary({
83
+ id,
84
+ title: input.title,
85
+ goal: input.goal,
86
+ mode: input.mode,
87
+ status: input.status || "draft",
88
+ source: input.source,
89
+ createdAt: now,
90
+ updatedAt: now,
91
+ });
92
+ const dir = sessionDir(workspaceRoot, id);
93
+ fs.mkdirSync(path.join(dir, "artifacts"), { recursive: true, mode: 0o700 });
94
+ writeJsonAtomic(sessionMetadataPath(workspaceRoot, id), session);
95
+ fs.writeFileSync(sessionEventsPath(workspaceRoot, id), "", { encoding: "utf-8", mode: 0o600 });
96
+ return session;
97
+ }
98
+
99
+ export function listAiExplorationSessions(workspaceRoot, limit = 50) {
100
+ const root = explorationRoot(workspaceRoot);
101
+ if (!fs.existsSync(root)) return [];
102
+ const sessions = [];
103
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
104
+ if (!entry.isDirectory() || !SESSION_ID_RE.test(entry.name)) continue;
105
+ try {
106
+ sessions.push(sessionSummary(readJson(sessionMetadataPath(workspaceRoot, entry.name))));
107
+ } catch {
108
+ // A corrupt exploration is omitted instead of breaking the Workspace.
109
+ }
110
+ }
111
+ return sessions
112
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id))
113
+ .slice(0, Math.max(1, Math.min(200, Number(limit) || 50)));
114
+ }
115
+
116
+ export function readAiExplorationSession(workspaceRoot, sessionId) {
117
+ const session = sessionSummary(readJson(sessionMetadataPath(workspaceRoot, sessionId)));
118
+ const events = [];
119
+ const eventPath = sessionEventsPath(workspaceRoot, session.id);
120
+ if (fs.existsSync(eventPath)) {
121
+ for (const line of fs.readFileSync(eventPath, "utf-8").split("\n")) {
122
+ if (!line.trim()) continue;
123
+ try { events.push(JSON.parse(line)); } catch { /* retain readable events */ }
124
+ }
125
+ }
126
+ return { ...session, events };
127
+ }
128
+
129
+ export function updateAiExplorationSession(workspaceRoot, sessionId, patch = {}) {
130
+ const current = readAiExplorationSession(workspaceRoot, sessionId);
131
+ const next = sessionSummary({
132
+ ...current,
133
+ ...patch,
134
+ source: patch.source ? { ...current.source, ...patch.source } : current.source,
135
+ updatedAt: new Date().toISOString(),
136
+ });
137
+ writeJsonAtomic(sessionMetadataPath(workspaceRoot, next.id), next);
138
+ return next;
139
+ }
140
+
141
+ function normalizeArtifacts(raw) {
142
+ return (Array.isArray(raw) ? raw : []).slice(0, 20).map((artifact) => ({
143
+ kind: clip(artifact?.kind || "file", 40),
144
+ path: clip(artifact?.path || "", 500),
145
+ label: clip(artifact?.label || artifact?.path || "artifact", 160),
146
+ ...(artifact?.sha256 ? { sha256: clip(artifact.sha256, 80) } : {}),
147
+ })).filter((artifact) => artifact.path || artifact.label);
148
+ }
149
+
150
+ export function normalizeAiTraceEvent(raw = {}, defaults = {}) {
151
+ const now = new Date().toISOString();
152
+ const phase = EVENT_PHASES.has(String(raw.phase || defaults.phase || "")) ? String(raw.phase || defaults.phase) : "observed";
153
+ const type = EVENT_TYPES.has(String(raw.type || "")) ? String(raw.type) : "status";
154
+ const status = EVENT_STATUSES.has(String(raw.status || "")) ? String(raw.status) : (phase === "planned" ? "planned" : "running");
155
+ const sideEffect = SIDE_EFFECTS.has(String(raw.sideEffect || "")) ? String(raw.sideEffect) : "none";
156
+ return {
157
+ id: clip(raw.id || `evt_${crypto.randomUUID().replace(/-/g, "").slice(0, 18)}`, 100),
158
+ traceId: clip(raw.traceId || defaults.traceId || "", 100),
159
+ spanId: clip(raw.spanId || raw.id || `span_${crypto.randomUUID().replace(/-/g, "").slice(0, 18)}`, 100),
160
+ parentSpanId: clip(raw.parentSpanId || "", 100),
161
+ sequence: Math.max(1, Number(raw.sequence || defaults.sequence || 1) || 1),
162
+ phase,
163
+ type,
164
+ name: clip(raw.name || type, 160),
165
+ summary: clip(raw.summary || raw.description || "", 2000),
166
+ status,
167
+ sideEffect,
168
+ requiresApproval: raw.requiresApproval === true || ["write", "external"].includes(sideEffect),
169
+ startedAt: String(raw.startedAt || now),
170
+ ...(raw.endedAt ? { endedAt: String(raw.endedAt) } : {}),
171
+ ...(raw.inputPreview ? { inputPreview: clip(raw.inputPreview, 2000) } : {}),
172
+ ...(raw.outputPreview ? { outputPreview: clip(raw.outputPreview, 2000) } : {}),
173
+ artifacts: normalizeArtifacts(raw.artifacts),
174
+ };
175
+ }
176
+
177
+ export function appendAiTraceEvents(workspaceRoot, sessionId, rawEvents = [], defaults = {}) {
178
+ const session = readAiExplorationSession(workspaceRoot, sessionId);
179
+ const incoming = Array.isArray(rawEvents) ? rawEvents : [rawEvents];
180
+ if (!incoming.length) return { session, events: [] };
181
+ if (session.eventCount + incoming.length > 5000) throw new Error("Exploration trace exceeds 5000 events");
182
+ const events = incoming.map((event, index) => normalizeAiTraceEvent(event, {
183
+ ...defaults,
184
+ traceId: session.id,
185
+ sequence: session.eventCount + index + 1,
186
+ }));
187
+ fs.appendFileSync(sessionEventsPath(workspaceRoot, session.id), events.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf-8");
188
+ const next = updateAiExplorationSession(workspaceRoot, session.id, {
189
+ eventCount: session.eventCount + events.length,
190
+ mode: defaults.phase || session.mode,
191
+ });
192
+ return { session: next, events };
193
+ }
194
+
195
+ export function parseAiPlanResult(text, sessionId) {
196
+ const raw = String(text || "").trim();
197
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim();
198
+ const candidates = [raw, fenced].filter(Boolean);
199
+ let parsed;
200
+ for (const candidate of candidates) {
201
+ try {
202
+ parsed = JSON.parse(candidate);
203
+ break;
204
+ } catch {
205
+ const start = candidate.indexOf("{");
206
+ const end = candidate.lastIndexOf("}");
207
+ if (start >= 0 && end > start) {
208
+ try { parsed = JSON.parse(candidate.slice(start, end + 1)); break; } catch { /* next */ }
209
+ }
210
+ }
211
+ }
212
+ if (!parsed || typeof parsed !== "object") throw new Error("Plan agent did not return valid JSON");
213
+ const spans = Array.isArray(parsed.spans) ? parsed.spans : Array.isArray(parsed.steps) ? parsed.steps : [];
214
+ if (!spans.length) throw new Error("Plan agent returned no executable spans");
215
+ return {
216
+ title: clip(parsed.title || "AI 执行计划", 160),
217
+ summary: clip(parsed.summary || "", 2000),
218
+ events: spans.slice(0, 120).map((span, index) => normalizeAiTraceEvent({
219
+ ...span,
220
+ id: span.id || `plan_${index + 1}`,
221
+ spanId: span.spanId || span.id || `plan_${index + 1}`,
222
+ parentSpanId: span.parentSpanId || span.parentId || "",
223
+ type: span.type || "turn",
224
+ status: "planned",
225
+ phase: "planned",
226
+ sideEffect: span.sideEffect || "none",
227
+ startedAt: new Date().toISOString(),
228
+ }, { traceId: sessionId, phase: "planned", sequence: index + 1 })),
229
+ };
230
+ }
231
+
232
+ export function materializableAiTraceEvents(session) {
233
+ const events = Array.isArray(session?.events) ? session.events : [];
234
+ const planned = events.filter((event) => event.phase === "planned");
235
+ if (planned.length) return planned;
236
+ return events.filter((event) => event.phase === "observed");
237
+ }
238
+
239
+ export function classifyAiToolSideEffect(toolName, subtype = "") {
240
+ const value = `${toolName || ""} ${subtype || ""}`.trim().toLowerCase();
241
+ if (/(?:^|[_\W])thinking(?:$|[_\W])/.test(value)) return "none";
242
+ if (/(?:^|[_\W])(web|http|curl|fetch|mcp|publish|send|notify|deploy)(?:$|[_\W])/.test(value)) return "external";
243
+ if (/(?:^|[_\W])(read|search|find|grep|glob|list|inspect|status|stat|cat|head|tail)(?:$|[_\W])/.test(value)) return "read";
244
+ return "write";
245
+ }
246
+
247
+ export function aiPlanPrompt({ goal = "", workspaceSource = "" } = {}) {
248
+ return [
249
+ "你是 AgentFlow 的只读 Plan Agent。只规划,不执行工具,不修改文件。",
250
+ "把用户目标转换为一张预计 AI 运行图。只输出合法 JSON,不要 Markdown 代码围栏。",
251
+ "JSON 格式:",
252
+ '{"title":"短标题","summary":"计划摘要","spans":[{"id":"step_1","parentSpanId":"","type":"turn|decision|agent|tool|command|file|artifact","name":"步骤名","summary":"做什么以及为什么","sideEffect":"none|read|write|external","requiresApproval":false,"inputPreview":"预计输入","outputPreview":"预期输出"}]}',
253
+ "要求:id 唯一;parentSpanId 表达父子关系;写文件、发布、发送、删除、外部写请求必须标记 requiresApproval=true;搜索与读取标记 read;不要假装已经得到任何执行结果。",
254
+ workspaceSource ? `\n## 当前 Workspace DSL\n\n${workspaceSource}` : "",
255
+ `\n## 用户目标\n\n${clip(goal, 12000)}`,
256
+ ].filter(Boolean).join("\n");
257
+ }
258
+
259
+ export function aiMaterializationPrompt(session, workspaceSource = "") {
260
+ const events = materializableAiTraceEvents(session);
261
+ const plan = events.map((event) => ({
262
+ spanId: event.spanId,
263
+ parentSpanId: event.parentSpanId,
264
+ type: event.type,
265
+ name: event.name,
266
+ summary: event.summary,
267
+ sideEffect: event.sideEffect,
268
+ requiresApproval: event.requiresApproval,
269
+ }));
270
+ return [
271
+ "你是 AgentFlow 流程固化 Agent。把已审核的 AI Plan 固化到当前 Workspace DSL 调整态。",
272
+ "必须实际编辑 workspace.flow.js;稳定脚本放入 nodes/<name>/index.mjs;不要执行该流程,不要发布。",
273
+ "过滤纯搜索噪声;把输入参数化;把判断映射为 control.if,把受控重复映射为 control.while,把产物映射为 Display;副作用步骤必须保留清晰名称和输入。",
274
+ "修改完成后运行 `agentflow flow dsl lint <当前流程目录>`。最终只简短说明生成了哪些节点以及仍需人工确认的副作用。",
275
+ workspaceSource ? `\n## 当前 Workspace DSL\n\n${workspaceSource}` : "",
276
+ `\n## 探索目标\n\n${clip(session?.goal || "", 8000)}`,
277
+ `\n## 已审核 Plan Trace\n\n${JSON.stringify(plan, null, 2)}`,
278
+ ].filter(Boolean).join("\n");
279
+ }
280
+
281
+ export function writeAiExplorationMaterialization(workspaceRoot, sessionId, value = {}) {
282
+ const session = readAiExplorationSession(workspaceRoot, sessionId);
283
+ const payload = {
284
+ version: 1,
285
+ sessionId: session.id,
286
+ materializedAt: new Date().toISOString(),
287
+ spanIds: materializableAiTraceEvents(session).map((event) => event.spanId).filter(Boolean),
288
+ nodeIds: (Array.isArray(value.nodeIds) ? value.nodeIds : []).map((id) => clip(id, 160)).filter(Boolean),
289
+ ...(value.designRevision ? { designRevision: clip(value.designRevision, 160) } : {}),
290
+ };
291
+ writeJsonAtomic(path.join(sessionDir(workspaceRoot, session.id), "materialization.json"), payload);
292
+ return payload;
293
+ }
@@ -319,6 +319,11 @@ function runCursorAgentWithPrivateMcp(cliWorkspace, prompt, options, userId) {
319
319
  * @param {string} [opts.modelKey]
320
320
  * @param {Record<string, string>} [opts.extraEnv]
321
321
  * @param {boolean} [opts.force]
322
+ * @param {"ask" | "plan"} [opts.mode]
323
+ * @param {boolean} [opts.sandboxDisabled]
324
+ * @param {boolean} [opts.approveMcps]
325
+ * @param {"read-only" | "workspace-write" | "danger-full-access"} [opts.sandboxMode]
326
+ * @param {boolean} [opts.includeJsonResult]
322
327
  * @param {(ev: object) => void} [opts.onStreamEvent]
323
328
  * @param {(subtype: string, toolName: string) => void} [opts.onToolCall]
324
329
  * @returns {{ child: import('child_process').ChildProcess, finished: Promise<void> }}
@@ -343,6 +348,12 @@ export function startComposerAgent(opts) {
343
348
  onChild: opts.onChild,
344
349
  detached: Boolean(opts.detached),
345
350
  force: Boolean(opts.force),
351
+ ...(opts.mode ? { mode: String(opts.mode) } : {}),
352
+ ...(opts.sandboxDisabled === false ? { sandboxDisabled: false } : {}),
353
+ ...(typeof opts.approveMcps === "boolean" ? { approveMcps: opts.approveMcps } : {}),
354
+ ...(opts.sandboxMode ? { sandboxMode: String(opts.sandboxMode) } : {}),
355
+ ...(opts.allowDanger === false ? { allowDanger: false } : {}),
356
+ ...(opts.includeJsonResult === true ? { includeJsonResult: true } : {}),
346
357
  env,
347
358
  addDirs: Array.isArray(opts.writableDirs)
348
359
  ? opts.writableDirs.map((dir) => String(dir || "").trim()).filter(Boolean)
@@ -18,6 +18,7 @@ export function parseCursorApiKeyRecords(value = "") {
18
18
  id: String(item.id || "").trim() || legacyKeyId(key),
19
19
  name: String(item.name || "").trim() || `Key ${index + 1}`,
20
20
  key,
21
+ createdAt: String(item.createdAt || "").trim(),
21
22
  };
22
23
  };
23
24
  try {
@@ -89,6 +90,7 @@ export function markCursorApiKeyLaneBlocked(
89
90
  cooldownMinutes = 30,
90
91
  errorText = "",
91
92
  now = Date.now(),
93
+ evidence = {},
92
94
  ) {
93
95
  const keyId = selectionId(keyOrSelection);
94
96
  if (!keyId || !["auto", "fallback"].includes(lane)) return 0;
@@ -97,12 +99,23 @@ export function markCursorApiKeyLaneBlocked(
97
99
  const currentLane = keyState[lane] || {};
98
100
  const blockedUntil = Math.max(currentLane.blockedUntil || 0, now + minutes * 60 * 1000);
99
101
  const errorCategory = classifyCursorApiKeyLimitError(errorText);
102
+ const fallbackModel = keyState.fallbackModel;
103
+ const lastFailure = {
104
+ triggeredAt: new Date(now).toISOString(),
105
+ ...(errorCategory ? { errorCategory } : {}),
106
+ errorPreview: sanitizeErrorPreview(errorText),
107
+ lane,
108
+ modelId: String(evidence?.modelId || (lane === "auto" ? "auto" : fallbackModel?.id || "fallback")),
109
+ modelName: String(evidence?.modelName || (lane === "auto" ? "Auto" : fallbackModel?.displayName || "降级模型")),
110
+ };
100
111
  keyState[lane] = {
101
112
  ...currentLane,
102
113
  blockedUntil,
103
114
  ...(errorCategory ? { errorCategory } : {}),
104
115
  ...(lane === "auto" ? { fallbackEligible: isCursorAutoFallbackEligible(errorText) } : {}),
116
+ lastFailure,
105
117
  };
118
+ keyState.lastFailure = lastFailure;
106
119
  cursorApiKeyStates.set(keyId, keyState);
107
120
  return blockedUntil;
108
121
  }
@@ -116,6 +129,72 @@ export function clearCursorApiKeyLaneCooldown(keyOrSelection, lane) {
116
129
  return true;
117
130
  }
118
131
 
132
+ export function clearCursorApiKeyCooldown(keyOrSelection) {
133
+ const keyState = cursorApiKeyStates.get(selectionId(keyOrSelection));
134
+ if (!keyState) return false;
135
+ let changed = false;
136
+ for (const lane of ["auto", "fallback"]) {
137
+ if (!keyState[lane] || (keyState[lane].blockedUntil || 0) <= 0) continue;
138
+ keyState[lane].blockedUntil = 0;
139
+ delete keyState[lane].errorCategory;
140
+ delete keyState[lane].fallbackEligible;
141
+ changed = true;
142
+ }
143
+ return changed;
144
+ }
145
+
146
+ export function recordCursorApiKeyUsage(keyOrSelection, selection = {}, now = Date.now()) {
147
+ const keyId = selectionId(keyOrSelection);
148
+ if (!keyId || keyId === "default") return;
149
+ const keyState = cursorApiKeyStates.get(keyId) || {};
150
+ keyState.lastUsedAt = new Date(now).toISOString();
151
+ keyState.lastSelection = {
152
+ lane: selection?.lane === "fallback" ? "fallback" : "auto",
153
+ modelId: String(selection?.modelId || "auto"),
154
+ modelName: String(selection?.modelName || "Auto"),
155
+ };
156
+ cursorApiKeyStates.set(keyId, keyState);
157
+ }
158
+
159
+ export function getCursorApiKeyPoolStatuses(records = [], now = Date.now()) {
160
+ return (Array.isArray(records) ? records : []).map((record) => {
161
+ const id = selectionId(record);
162
+ const keyState = cursorApiKeyStates.get(id);
163
+ const selection = getCursorApiKeyModelSelection(id, now);
164
+ const laneCooldowns = buildLaneCooldowns(keyState, now);
165
+ const common = {
166
+ id,
167
+ ...(keyState?.lastUsedAt ? { lastUsedAt: keyState.lastUsedAt } : {}),
168
+ ...(keyState?.fallbackModel ? { fallbackModel: keyState.fallbackModel } : {}),
169
+ laneCooldowns,
170
+ ...(keyState?.lastFailure ? { lastFailure: keyState.lastFailure } : {}),
171
+ };
172
+ if (selection) {
173
+ return {
174
+ ...common,
175
+ status: "available",
176
+ activeLane: selection.lane,
177
+ activeModelId: selection.modelId,
178
+ activeModelName: selection.modelName,
179
+ degraded: selection.lane === "fallback",
180
+ };
181
+ }
182
+ const activeCooldowns = laneCooldowns.filter((item) => item.remainingSeconds > 0);
183
+ const earliest = activeCooldowns.reduce(
184
+ (result, item) => !result || item.remainingSeconds < result.remainingSeconds ? item : result,
185
+ undefined,
186
+ );
187
+ const autoState = keyState?.auto;
188
+ return {
189
+ ...common,
190
+ status: "cooling_down",
191
+ ...(autoState?.errorCategory ? { errorCategory: autoState.errorCategory } : {}),
192
+ blockedUntil: earliest?.blockedUntil || new Date(Math.max(now, autoState?.blockedUntil || now)).toISOString(),
193
+ remainingSeconds: earliest?.remainingSeconds || Math.max(0, Math.ceil(((autoState?.blockedUntil || now) - now) / 1000)),
194
+ };
195
+ });
196
+ }
197
+
119
198
  export function cursorApiKeyEnv(selection) {
120
199
  return selection?.key ? { CURSOR_API_KEY: selection.key } : {};
121
200
  }
@@ -159,7 +238,10 @@ export function isCursorQuotaError(error = "") {
159
238
  return classifyCursorApiKeyLimitError(error) !== undefined;
160
239
  }
161
240
 
162
- export function cursorApiKeyCooldownMinutes(env = {}) {
241
+ export function cursorApiKeyCooldownMinutes(env = {}, errorText = "") {
242
+ if (classifyCursorApiKeyLimitError(errorText) === "resource_exhausted") {
243
+ return Math.max(1, Number(env.AGENTFLOW_CURSOR_API_KEY_RESOURCE_EXHAUSTED_COOLDOWN_MINUTES || 3) || 3);
244
+ }
163
245
  return Math.max(1, Number(env.AGENTFLOW_CURSOR_API_KEY_COOLDOWN_MINUTES || env.CURSOR_API_KEY_COOLDOWN_MINUTES || 30) || 30);
164
246
  }
165
247
 
@@ -178,3 +260,26 @@ function selectionId(keyOrSelection) {
178
260
  function legacyKeyId(key) {
179
261
  return `legacy_${createHash("sha256").update(String(key || "")).digest("hex").slice(0, 16)}`;
180
262
  }
263
+
264
+ function buildLaneCooldowns(keyState, now) {
265
+ if (!keyState) return [];
266
+ const lanes = [];
267
+ for (const lane of ["auto", "fallback"]) {
268
+ const laneState = keyState[lane];
269
+ if (!laneState || (laneState.blockedUntil || 0) <= now) continue;
270
+ const fallbackModel = keyState.fallbackModel;
271
+ lanes.push({
272
+ lane,
273
+ modelId: lane === "auto" ? "auto" : fallbackModel?.id || "fallback",
274
+ modelName: lane === "auto" ? "Auto" : fallbackModel?.displayName || "降级模型",
275
+ ...(laneState.errorCategory ? { errorCategory: laneState.errorCategory } : {}),
276
+ blockedUntil: new Date(laneState.blockedUntil).toISOString(),
277
+ remainingSeconds: Math.ceil((laneState.blockedUntil - now) / 1000),
278
+ });
279
+ }
280
+ return lanes;
281
+ }
282
+
283
+ function sanitizeErrorPreview(errorText) {
284
+ return String(errorText || "").replace(/(?:sk|key)[-_][A-Za-z0-9_-]{8,}/gi, "[redacted]").trim().slice(0, 500);
285
+ }
@@ -0,0 +1,17 @@
1
+ const runFinishedListeners = new Set();
2
+
3
+ export function onRepositoryRunFinished(listener) {
4
+ if (typeof listener !== "function") return () => {};
5
+ runFinishedListeners.add(listener);
6
+ return () => runFinishedListeners.delete(listener);
7
+ }
8
+
9
+ export function emitRepositoryRunFinished(workspaceRoot, run, status) {
10
+ for (const listener of runFinishedListeners) {
11
+ try {
12
+ listener(workspaceRoot, run, status);
13
+ } catch {
14
+ // Derived repository updates must never break the authoritative run ledger.
15
+ }
16
+ }
17
+ }