@fieldwangai/agentflow 0.1.165 → 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.
@@ -7,13 +7,19 @@ 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,
20
+ recordCursorApiKeyUsage,
16
21
  } from "./cursor-api-key-pool.mjs";
22
+ import { discoverCursorModels } from "./cursor-model-catalog.mjs";
17
23
  import { outputNodeBasename } from "../pipeline/get-exec-id.mjs";
18
24
 
19
25
  function shouldPassCursorModelArg(model) {
@@ -53,6 +59,16 @@ function nextCursorAttemptOptions(options = {}, attempts = [], attemptIndex = 0)
53
59
  ...options,
54
60
  _agentflowCursorApiKeyAttempts: attempts,
55
61
  _agentflowCursorApiKeyAttemptIndex: attemptIndex + 1,
62
+ _agentflowCursorModelSelection: undefined,
63
+ };
64
+ }
65
+
66
+ function cursorModelAttemptOptions(options = {}, attempts = [], attemptIndex = 0, modelSelection) {
67
+ return {
68
+ ...options,
69
+ _agentflowCursorApiKeyAttempts: attempts,
70
+ _agentflowCursorApiKeyAttemptIndex: attemptIndex,
71
+ _agentflowCursorModelSelection: modelSelection,
56
72
  };
57
73
  }
58
74
 
@@ -108,7 +124,7 @@ function shouldSkipCodexGitCheck(workspace) {
108
124
  return !hasGitMetadataAncestor(workspace);
109
125
  }
110
126
 
111
- function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessagePath, promptText, configArgs = [] }) {
127
+ function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessagePath, promptText, configArgs = [], sandboxMode = "", allowDanger = true }) {
112
128
  const args = [];
113
129
  for (const cfg of Array.isArray(configArgs) ? configArgs : []) {
114
130
  const value = String(cfg || "").trim();
@@ -122,10 +138,10 @@ function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessageP
122
138
  const abs = path.resolve(dir);
123
139
  if (abs && abs !== workspace) args.push("--add-dir", abs);
124
140
  }
125
- if (envFlag("AGENTFLOW_CODEX_DANGER", false)) {
141
+ if (allowDanger && envFlag("AGENTFLOW_CODEX_DANGER", false)) {
126
142
  args.push("--dangerously-bypass-approvals-and-sandbox");
127
143
  } else {
128
- 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");
129
145
  }
130
146
  if (shouldSkipCodexGitCheck(workspace)) args.push("--skip-git-repo-check");
131
147
  if (envFlag("AGENTFLOW_CODEX_EPHEMERAL", false)) args.push("--ephemeral");
@@ -402,7 +418,7 @@ function tryEmitOpenCodeLineAsNatural(line, emit) {
402
418
  export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {}) {
403
419
  const onStreamEvent = typeof options.onStreamEvent === "function" ? options.onStreamEvent : null;
404
420
  const ws = path.resolve(cliWorkspace);
405
- const model = normalizeCursorModelForCli(options.model ?? process.env.CURSOR_AGENT_MODEL ?? null);
421
+ const requestedModel = normalizeCursorModelForCli(options.model ?? process.env.CURSOR_AGENT_MODEL ?? null);
406
422
  const agentCmd = process.env.CURSOR_AGENT_CMD || "agent";
407
423
  const {
408
424
  baseEnv: cursorBaseEnv,
@@ -410,11 +426,23 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
410
426
  attemptIndex: cursorAttemptIndex,
411
427
  selection: cursorSelection,
412
428
  } = cursorAttemptOptions(options);
429
+ const hasExplicitModel = shouldPassCursorModelArg(requestedModel);
430
+ const cursorModelSelection = hasExplicitModel
431
+ ? { lane: "auto", modelId: requestedModel, modelName: requestedModel }
432
+ : options._agentflowCursorModelSelection
433
+ || cursorSelection?.modelSelection
434
+ || { lane: "auto", modelId: "auto", modelName: "Auto" };
435
+ const model = hasExplicitModel ? requestedModel : cursorModelSelection.modelId;
436
+ if (cursorSelection) recordCursorApiKeyUsage(cursorSelection, cursorModelSelection);
413
437
  // Web UI Composer 需要能无交互执行本机 curl 等命令来刷新画布。
414
- const args = ["--print", "--output-format", "stream-json", "--trust", "--sandbox", "disabled", "--workspace", ws];
415
- 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");
416
444
  if (approveMcps) args.push("--approve-mcps");
417
- args.push("--force");
445
+ if (options.force !== false) args.push("--force");
418
446
  if (shouldPassCursorModelArg(model)) args.push("--model", model);
419
447
  args.push(promptText);
420
448
 
@@ -448,6 +476,12 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
448
476
  line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} / ${cursorAttempts.length}`,
449
477
  });
450
478
  }
479
+ if (cursorModelSelection.lane === "fallback") {
480
+ emit({
481
+ type: "status",
482
+ line: `Cursor is using Composer fallback: ${cursorModelSelection.modelName}`,
483
+ });
484
+ }
451
485
 
452
486
  if (!useStderrInherit) {
453
487
  child.stderr.on("data", (chunk) => {
@@ -523,14 +557,18 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
523
557
  if (options.onToolCall) options.onToolCall("thinking", "");
524
558
  } else if (event.type === "result") {
525
559
  lastResult = event;
526
- const resultNl = extractCursorResultNl(event);
560
+ const resultNl = options.includeJsonResult && typeof event.result === "string"
561
+ ? normalizeStreamTextChunk(event.result)
562
+ : extractCursorResultNl(event);
527
563
  if (resultNl) emit({ type: "natural", kind: "result", text: resultNl });
528
564
  if (event.subtype === "success" && !event.is_error) {
529
565
  hadError = false;
530
566
  emit({ type: "status", line: t("runner.completed") });
531
567
  } else {
532
568
  hadError = true;
533
- const errNl = extractCursorResultNl(event);
569
+ const errNl = options.includeJsonResult && typeof event.result === "string"
570
+ ? normalizeStreamTextChunk(event.result)
571
+ : extractCursorResultNl(event);
534
572
  if (errNl) emit({ type: "natural", kind: "error", text: errNl });
535
573
  emit({
536
574
  type: "status",
@@ -588,20 +626,88 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
588
626
  }
589
627
  const retryCursorQuota = (errorText) => {
590
628
  if (!cursorSelection) return false;
591
- if (cursorAttemptIndex >= cursorAttempts.length - 1) return false;
592
629
  if (hadToolActivity) return false;
593
630
  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),
631
+ const errorCategory = classifyCursorApiKeyLimitError(errorText);
632
+ const cooldownMinutes = cursorApiKeyCooldownMinutes(cursorBaseEnv, errorText);
633
+ markCursorApiKeyLaneBlocked(
634
+ cursorSelection,
635
+ cursorModelSelection.lane,
636
+ cooldownMinutes,
637
+ errorText,
638
+ Date.now(),
639
+ {
640
+ modelId: cursorModelSelection.modelId,
641
+ modelName: cursorModelSelection.modelName,
642
+ },
603
643
  );
604
- next.finished.then(resolve).catch(reject);
644
+ const canTryComposer = !hasExplicitModel
645
+ && cursorModelSelection.lane === "auto"
646
+ && errorCategory === "explicit_limit"
647
+ && isCursorAutoFallbackEligible(errorText);
648
+ const hasNextKey = cursorAttemptIndex < cursorAttempts.length - 1;
649
+ if (!canTryComposer && !hasNextKey) return false;
650
+
651
+ const retry = async () => {
652
+ if (canTryComposer) {
653
+ emit({
654
+ type: "status",
655
+ line: `Cursor Auto on API Key ${cursorApiKeyLabel(cursorSelection)} is out of usage; discovering Composer fallback...`,
656
+ });
657
+ const catalog = await discoverCursorModels({
658
+ keyId: cursorSelection.id,
659
+ cwd: ws,
660
+ command: agentCmd,
661
+ env: childEnv(options, cursorApiKeyEnv(cursorSelection)),
662
+ });
663
+ if (catalog.fallbackModel) {
664
+ recordCursorApiKeyFallbackModel(cursorSelection, catalog.fallbackModel);
665
+ const fallbackSelection = {
666
+ lane: "fallback",
667
+ modelId: catalog.fallbackModel.id,
668
+ modelName: catalog.fallbackModel.displayName,
669
+ };
670
+ emit({
671
+ type: "status",
672
+ line: `Switching the same Cursor API Key to ${fallbackSelection.modelName}.`,
673
+ });
674
+ emit({
675
+ type: "raw",
676
+ source: "cursor",
677
+ stream: "runner",
678
+ eventType: "model_fallback",
679
+ text: `auto -> ${fallbackSelection.modelId}`,
680
+ });
681
+ const fallback = runCursorAgentWithPrompt(
682
+ cliWorkspace,
683
+ promptText,
684
+ cursorModelAttemptOptions(options, cursorAttempts, cursorAttemptIndex, fallbackSelection),
685
+ );
686
+ await fallback.finished;
687
+ return;
688
+ }
689
+ emit({
690
+ type: "status",
691
+ line: `Composer fallback is unavailable${catalog.error ? `: ${catalog.error}` : "."}`,
692
+ });
693
+ }
694
+
695
+ if (hasNextKey) {
696
+ emit({
697
+ type: "status",
698
+ line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} reached its limit; retrying ${cursorAttemptIndex + 2}/${cursorAttempts.length}.`,
699
+ });
700
+ const next = runCursorAgentWithPrompt(
701
+ cliWorkspace,
702
+ promptText,
703
+ nextCursorAttemptOptions(options, cursorAttempts, cursorAttemptIndex),
704
+ );
705
+ await next.finished;
706
+ return;
707
+ }
708
+ throw new Error(errorText || "Cursor API Key reached its limit.");
709
+ };
710
+ retry().then(resolve).catch(reject);
605
711
  return true;
606
712
  };
607
713
  if (code !== 0 && lastResult == null) {
@@ -622,6 +728,7 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
622
728
  reject(new Error(msg));
623
729
  return;
624
730
  }
731
+ if (cursorSelection) clearCursorApiKeyLaneCooldown(cursorSelection, cursorModelSelection.lane);
625
732
  resolve();
626
733
  });
627
734
  });
@@ -748,6 +855,7 @@ export function runClaudeCodeAgentWithPrompt(cliWorkspace, promptText, options =
748
855
  const model = options.model && String(options.model).trim();
749
856
  const claudeCmd = process.env.CLAUDE_CODE_CMD || "claude";
750
857
  const bypassPermissions =
858
+ options.allowDanger !== false &&
751
859
  process.env.AGENTFLOW_CLAUDE_CODE_BYPASS_PERMISSIONS !== "0" &&
752
860
  process.env.AGENTFLOW_CLAUDE_CODE_BYPASS_PERMISSIONS !== "false";
753
861
  const args = ["-p", "--output-format", "stream-json", "--verbose", "--add-dir", ws];
@@ -941,6 +1049,8 @@ export function runCodexAgentWithPrompt(cliWorkspace, promptText, options = {})
941
1049
  outputLastMessagePath,
942
1050
  promptText,
943
1051
  configArgs: options.codexConfigArgs,
1052
+ sandboxMode: options.sandboxMode,
1053
+ allowDanger: options.allowDanger !== false,
944
1054
  });
945
1055
 
946
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)