@ghyper9023/pi-dev-workflow 0.6.3 → 0.8.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.
- package/.github/workflows/release.yml +56 -0
- package/.pre-commit-config.yaml +75 -0
- package/.version/RELEASE-v0.7.0.md +85 -0
- package/.version/RELEASE-v0.8.0.md +93 -0
- package/README.md +178 -294
- package/extensions/dev-prompts.ts +121 -1033
- package/extensions/git-commands.ts +62 -171
- package/extensions/grill-me-agent.ts +160 -164
- package/extensions/pre-check.ts +190 -0
- package/extensions/review-detect.ts +123 -0
- package/extensions/session-utils.ts +168 -0
- package/extensions/ui-helpers.ts +22 -780
- package/package.json +2 -2
- package/prompts/APPEND_SYSTEM.md +73 -59
- package/skills/review-html/SKILL.md +1 -1
- package/tests/test-no-subagents.mjs +264 -0
- package/.doc/AGENT-FRONTMATTER-REFERENCE.md +0 -198
- package/agents/git-agent.md +0 -44
- package/agents/grill/dev-doc-grill-agent.md +0 -42
- package/agents/grill/dev-fix-grill-agent.md +0 -44
- package/agents/grill/dev-grill-agent.md +0 -40
- package/agents/grill/dev-perf-grill-agent.md +0 -45
- package/agents/grill/dev-prd-agent.md +0 -60
- package/agents/grill/dev-refactor-grill-agent.md +0 -46
- package/agents/grill/dev-test-grill-agent.md +0 -45
- package/agents/review-agent.md +0 -53
- package/agents/workflow/docWriter-agent.md +0 -53
- package/agents/workflow/planner-agent.md +0 -131
- package/agents/workflow/reviewer-agent.md +0 -128
- package/agents/workflow/trimmer-agent.md +0 -78
- package/agents/workflow/worker-agent.md +0 -70
- package/extensions/sub-agents.ts +0 -954
- package/extensions/workflow-engine.ts +0 -2005
- package/tests/test-grill-json-fix.mjs +0 -243
- package/tests/test-loopcount-timeout-fix.mjs +0 -336
- package/tests/test-output-directory-structure.mjs +0 -177
- package/tests/test-save-answer-file-workflow.mjs +0 -187
- package/tests/test-workflow-config.mjs +0 -244
- package/tests/test-workflow-engine-bugs.mjs +0 -908
- package/tests/test-workflow-engine.mjs +0 -518
|
@@ -1,2005 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* workflow-engine.ts — 工作流编排引擎
|
|
3
|
-
*
|
|
4
|
-
* 职责:
|
|
5
|
-
* 1. runWorkflow() — 主入口,编排多步骤工作流(后台异步执行)
|
|
6
|
-
* 2. 支持 值守/全自动/完全值守 三种模式
|
|
7
|
-
* 3. 支持 {} loop 组(worker→reviewer, trimmer→reviewer)
|
|
8
|
-
* 4. 支持 [] 标记的确认步骤
|
|
9
|
-
* 5. Checkpoint 保存/恢复(断点续传)
|
|
10
|
-
* 6. 超时处理(按 mode 策略分支)
|
|
11
|
-
* 7. 进度面板 UI — 使用 ctx.ui.setWidget() 持久化面板,支持 Ctrl+O 展开
|
|
12
|
-
*
|
|
13
|
-
* 被 dev-prompts.ts 引入,不独立作为 extension 加载。
|
|
14
|
-
*
|
|
15
|
-
* 设计要点:
|
|
16
|
-
* - 非阻塞执行:通过 AbortController 管理取消,widget 动画更新进度
|
|
17
|
-
* - 步骤详情:记录 agent 的工具调用、输出路径等子步骤信息
|
|
18
|
-
* - 归档:工作流完成后 checkpoint 重命名而非删除
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import * as fs from "node:fs";
|
|
22
|
-
import * as path from "node:path";
|
|
23
|
-
import { randomBytes } from "node:crypto";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Generate a UUID v7 (time-ordered) string.
|
|
27
|
-
* Format: tttttttt-tttt-7xxx-yxxx-xxxxxxxxxxxx
|
|
28
|
-
* - t = Unix timestamp (ms) as 48-bit big-endian
|
|
29
|
-
* - 7 = version (0111)
|
|
30
|
-
* - y = variant (10xx = 8, 9, a, b)
|
|
31
|
-
* - x = random
|
|
32
|
-
*/
|
|
33
|
-
function uuidv7(): string {
|
|
34
|
-
const ts = BigInt(Date.now());
|
|
35
|
-
const buf = new Uint8Array(16);
|
|
36
|
-
// 48-bit timestamp (big-endian) in bytes 0-5
|
|
37
|
-
buf[0] = Number((ts >> 40n) & 0xFFn);
|
|
38
|
-
buf[1] = Number((ts >> 32n) & 0xFFn);
|
|
39
|
-
buf[2] = Number((ts >> 24n) & 0xFFn);
|
|
40
|
-
buf[3] = Number((ts >> 16n) & 0xFFn);
|
|
41
|
-
buf[4] = Number((ts >> 8n) & 0xFFn);
|
|
42
|
-
buf[5] = Number(ts & 0xFFn);
|
|
43
|
-
// 10 random bytes for version/variant/rand
|
|
44
|
-
const r = randomBytes(10);
|
|
45
|
-
// Byte 6: version (0111) | rand_a high 4 bits
|
|
46
|
-
buf[6] = 0x70 | (r[0] >> 4);
|
|
47
|
-
// Byte 7: rand_a low 8 bits
|
|
48
|
-
buf[7] = (r[0] << 4) | (r[1] >> 4);
|
|
49
|
-
// Byte 8: variant (10) | rand_b high 6 bits
|
|
50
|
-
buf[8] = 0x80 | (r[2] >> 2);
|
|
51
|
-
// Bytes 9-15: remaining rand_b (56 bits)
|
|
52
|
-
buf[9] = r[3]; buf[10] = r[4]; buf[11] = r[5];
|
|
53
|
-
buf[12] = r[6]; buf[13] = r[7]; buf[14] = r[8]; buf[15] = r[9];
|
|
54
|
-
// Format as 8-4-4-4-12 hex UUID
|
|
55
|
-
const hex = Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
|
|
56
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
57
|
-
}
|
|
58
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
59
|
-
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
60
|
-
import { execSync } from "child_process";
|
|
61
|
-
import { spawnSubagent, extractFinalOutput, discoverAgents, type AgentDef, type SubagentResult } from "./sub-agents";
|
|
62
|
-
import {
|
|
63
|
-
uiSelect,
|
|
64
|
-
uiConfirm,
|
|
65
|
-
uiInput,
|
|
66
|
-
updateWorkflowWidget,
|
|
67
|
-
buildWidgetState,
|
|
68
|
-
sendWorkflowResult,
|
|
69
|
-
formatTimeout,
|
|
70
|
-
setWorkflowCancelCallback,
|
|
71
|
-
cancelWorkflow,
|
|
72
|
-
BACK_MARKER,
|
|
73
|
-
BACK_OPTION_TEXT,
|
|
74
|
-
type WorkflowStepWidgetState,
|
|
75
|
-
type WorkflowSubStepWidgetState,
|
|
76
|
-
type WorkflowWidgetState,
|
|
77
|
-
} from "./ui-helpers";
|
|
78
|
-
|
|
79
|
-
// ═══════════════════════════════════════════════════════════════
|
|
80
|
-
// Types
|
|
81
|
-
// ═══════════════════════════════════════════════════════════════
|
|
82
|
-
|
|
83
|
-
export type WorkflowMode = "attended" | "full-auto" | "full-attended";
|
|
84
|
-
|
|
85
|
-
export interface WorkflowStepDef {
|
|
86
|
-
id: string;
|
|
87
|
-
label: string;
|
|
88
|
-
type: "auto" | "confirm" | "loop-group";
|
|
89
|
-
agentName?: string;
|
|
90
|
-
loopAgentName?: string;
|
|
91
|
-
reviewAgentName?: string;
|
|
92
|
-
maxLoops?: number;
|
|
93
|
-
timeoutMs: number;
|
|
94
|
-
/** 独立于 loopAgent 的 reviewer 超时时间(ms),默认使用 timeoutMs */
|
|
95
|
-
reviewTimeoutMs?: number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
interface WorkflowStepState {
|
|
99
|
-
status: "pending" | "running" | "done" | "failed" | "skipped";
|
|
100
|
-
durationMs?: number;
|
|
101
|
-
loopCount?: number;
|
|
102
|
-
error?: string;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
interface FileChangeEntry {
|
|
106
|
-
agent: string;
|
|
107
|
-
stepIndex: number;
|
|
108
|
-
type: "edit" | "new" | "delete" | "read";
|
|
109
|
-
filePath: string;
|
|
110
|
-
timestamp: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
interface AgentRunEntry {
|
|
114
|
-
agent: string;
|
|
115
|
-
stepIndex: number;
|
|
116
|
-
startedAt: string;
|
|
117
|
-
durationMs: number;
|
|
118
|
-
exitCode: number;
|
|
119
|
-
toolCount: number;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Baseline entry: records the git object hash of a file at workflow start.
|
|
124
|
-
* Used to distinguish pre-existing dirty files from workflow-generated changes.
|
|
125
|
-
*/
|
|
126
|
-
interface BaselineEntry {
|
|
127
|
-
path: string;
|
|
128
|
-
hash: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
interface CheckpointData {
|
|
132
|
-
version: 2;
|
|
133
|
-
/** UUID that uniquely identifies this workflow run across all output files */
|
|
134
|
-
workflowId: string;
|
|
135
|
-
createdAt: string;
|
|
136
|
-
updatedAt: string;
|
|
137
|
-
prompt: string;
|
|
138
|
-
mode: WorkflowMode;
|
|
139
|
-
steps: WorkflowStepState[];
|
|
140
|
-
currentStepIndex: number;
|
|
141
|
-
loopCounts: Record<string, number>;
|
|
142
|
-
planFilePath?: string;
|
|
143
|
-
// New fields for better UI and traceability
|
|
144
|
-
taskSummary?: string;
|
|
145
|
-
workflowType?: string;
|
|
146
|
-
fileChanges?: FileChangeEntry[];
|
|
147
|
-
subAgentRuns?: number;
|
|
148
|
-
filesModified?: number;
|
|
149
|
-
filesCreated?: number;
|
|
150
|
-
agentRunHistory?: AgentRunEntry[];
|
|
151
|
-
/** Baseline snapshot taken at workflow start for accurate change tracking. */
|
|
152
|
-
baseline?: BaselineEntry[];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ═══════════════════════════════════════════════════════════════
|
|
156
|
-
// Constants
|
|
157
|
-
// ═══════════════════════════════════════════════════════════════
|
|
158
|
-
|
|
159
|
-
const DEV_OUTPUT_DIR = ".pi-dev-output";
|
|
160
|
-
const CHECKPOINT_FILE = path.join(DEV_OUTPUT_DIR, "pi-workflow", "checkpoint.json");
|
|
161
|
-
const PLANS_DIR = path.join(DEV_OUTPUT_DIR, "pi-plans");
|
|
162
|
-
|
|
163
|
-
// ═══════════════════════════════════════════════════════════════
|
|
164
|
-
// Utility Helpers
|
|
165
|
-
// ═══════════════════════════════════════════════════════════════
|
|
166
|
-
|
|
167
|
-
function ensureOutputDir(cwd: string, subdir: string): string {
|
|
168
|
-
const dir = path.join(cwd, DEV_OUTPUT_DIR, subdir);
|
|
169
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
170
|
-
return dir;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function findLatestPlanFile(cwd: string): string | undefined {
|
|
174
|
-
const dir = path.join(cwd, PLANS_DIR);
|
|
175
|
-
try {
|
|
176
|
-
if (!fs.existsSync(dir)) return undefined;
|
|
177
|
-
const files = fs.readdirSync(dir)
|
|
178
|
-
.filter(f => f.endsWith(".md"))
|
|
179
|
-
.map(f => ({ name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
180
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
181
|
-
return files.length > 0 ? path.join(PLANS_DIR, files[0].name) : undefined;
|
|
182
|
-
} catch {
|
|
183
|
-
return undefined;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function readFileContent(cwd: string, relativePath: string): string | undefined {
|
|
188
|
-
try {
|
|
189
|
-
return fs.readFileSync(path.join(cwd, relativePath), "utf-8");
|
|
190
|
-
} catch {
|
|
191
|
-
return undefined;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function parseReviewerOutput(
|
|
196
|
-
output: string,
|
|
197
|
-
): { maxSeverity: string; critical: number; medium: number; low: number } | null {
|
|
198
|
-
const match = output.match(/\[REVIEW_SUMMARY\]\s*(\{[\s\S]*?\})\s*\[\/REVIEW_SUMMARY\]/);
|
|
199
|
-
if (match) {
|
|
200
|
-
try {
|
|
201
|
-
const parsed = JSON.parse(match[1]);
|
|
202
|
-
if (parsed && typeof parsed.maxSeverity === "string") return parsed;
|
|
203
|
-
} catch { /* fallthrough */ }
|
|
204
|
-
}
|
|
205
|
-
const fallback = output.match(/\{"maxSeverity":\s*"(critical|medium|low)"[\s\S]*?\}/);
|
|
206
|
-
if (fallback) {
|
|
207
|
-
try {
|
|
208
|
-
const parsed = JSON.parse(fallback[0]);
|
|
209
|
-
if (parsed && typeof parsed.maxSeverity === "string") return parsed;
|
|
210
|
-
} catch { /* fallthrough */ }
|
|
211
|
-
}
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export function extractSeverityFromText(
|
|
216
|
-
text: string,
|
|
217
|
-
): { maxSeverity: string; critical: number; medium: number; low: number } | null {
|
|
218
|
-
const headerCritical = [...text.matchAll(/^###\s+C\d+\./gm)].length;
|
|
219
|
-
const headerMedium = [...text.matchAll(/^###\s+M\d+\./gm)].length;
|
|
220
|
-
const headerLow = [...text.matchAll(/^###\s+L\d+\./gm)].length;
|
|
221
|
-
if (headerCritical + headerMedium + headerLow > 0) {
|
|
222
|
-
return {
|
|
223
|
-
maxSeverity: headerCritical > 0 ? "critical" : headerMedium > 0 ? "medium" : "low",
|
|
224
|
-
critical: headerCritical,
|
|
225
|
-
medium: headerMedium,
|
|
226
|
-
low: headerLow,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
const tableCritical = [...text.matchAll(/^\|\s*\w+\s*\|\s*critical/gim)].length;
|
|
230
|
-
const tableMedium = [...text.matchAll(/^\|\s*\w+\s*\|\s*medium/gim)].length;
|
|
231
|
-
const tableLow = [...text.matchAll(/^\|\s*\w+\s*\|\s*low/gim)].length;
|
|
232
|
-
if (tableCritical + tableMedium + tableLow > 0) {
|
|
233
|
-
return {
|
|
234
|
-
maxSeverity: tableCritical > 0 ? "critical" : tableMedium > 0 ? "medium" : "low",
|
|
235
|
-
critical: tableCritical,
|
|
236
|
-
medium: tableMedium,
|
|
237
|
-
low: tableLow,
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
const labelCritical = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*critical/gi)].length;
|
|
241
|
-
const labelMedium = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*medium/gi)].length;
|
|
242
|
-
const labelLow = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*low/gi)].length;
|
|
243
|
-
if (labelCritical + labelMedium + labelLow > 0) {
|
|
244
|
-
return {
|
|
245
|
-
maxSeverity: labelCritical > 0 ? "critical" : labelMedium > 0 ? "medium" : "low",
|
|
246
|
-
critical: labelCritical,
|
|
247
|
-
medium: labelMedium,
|
|
248
|
-
low: labelLow,
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function readLatestReviewMd(cwd: string): string | null {
|
|
255
|
-
const reviewDir = path.join(cwd, DEV_OUTPUT_DIR, "pi-review", "md");
|
|
256
|
-
try {
|
|
257
|
-
if (!fs.existsSync(reviewDir)) return null;
|
|
258
|
-
const files = fs.readdirSync(reviewDir)
|
|
259
|
-
.filter(f => f.endsWith(".md"))
|
|
260
|
-
.map(f => ({ name: f, mtime: fs.statSync(path.join(reviewDir, f)).mtimeMs }))
|
|
261
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
262
|
-
if (files.length === 0) return null;
|
|
263
|
-
return fs.readFileSync(path.join(reviewDir, files[0].name), "utf-8");
|
|
264
|
-
} catch {
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export function isTimeoutResult(r: SubagentResult): boolean {
|
|
270
|
-
return r.exitCode === -1 && r.stderr.includes("timed out");
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Extract a human-readable task summary from the prompt.
|
|
275
|
-
*/
|
|
276
|
-
export function extractTaskSummary(prompt: string): string {
|
|
277
|
-
const firstLine = prompt.split("\n").find(l => l.trim()) ?? "";
|
|
278
|
-
const tagMatch = firstLine.match(/^\[([^\]]+)\]\s*(.+)/);
|
|
279
|
-
if (tagMatch) {
|
|
280
|
-
const tag = tagMatch[1]!.trim();
|
|
281
|
-
const rest = tagMatch[2]!.trim();
|
|
282
|
-
// If the rest looks like placeholder dots, try to find a better summary
|
|
283
|
-
if (rest.replace(/\.\.\./g, "").trim() === "" || rest === "...") {
|
|
284
|
-
const lines = prompt.split("\n").filter(l => l.trim());
|
|
285
|
-
if (lines.length > 1) {
|
|
286
|
-
const secondLine = lines[1]!.replace(/^[*\s#]+/, "").trim();
|
|
287
|
-
if (secondLine && !secondLine.startsWith("**")) {
|
|
288
|
-
return `${tag} - ${secondLine.substring(0, 60)}`;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
for (const line of lines.slice(1, 5)) {
|
|
292
|
-
const cleaned = line.replace(/^[*\s#]+/, "").trim();
|
|
293
|
-
if (cleaned && cleaned.length > 5 && !cleaned.startsWith("**") && !cleaned.startsWith("`")) {
|
|
294
|
-
const summary = cleaned.length > 50 ? cleaned.substring(0, 47) + "..." : cleaned;
|
|
295
|
-
return `${tag} - ${summary}`;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return `${tag} - 工作流任务`;
|
|
299
|
-
}
|
|
300
|
-
return `${tag} - ${rest}`;
|
|
301
|
-
}
|
|
302
|
-
const cleaned = firstLine.replace(/^[*\s#]+/, "").trim();
|
|
303
|
-
return cleaned.length > 60 ? cleaned.substring(0, 57) + "..." : cleaned || "工作流任务";
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// ── Git diff-based file change detection ──────────────────────
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* TypeScript struct for a parsed git file change entry.
|
|
311
|
-
* `status` uses git's canonical single-letter codes: M (modify), A (add), D (delete).
|
|
312
|
-
*/
|
|
313
|
-
interface GitFileChange {
|
|
314
|
-
status: "M" | "A" | "D";
|
|
315
|
-
path: string;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Run `git diff --name-status` to detect file changes against HEAD.
|
|
320
|
-
* This is far more reliable than scraping AI text output for file paths.
|
|
321
|
-
*
|
|
322
|
-
* Uses `git diff --name-status HEAD` for modified/deleted files, and
|
|
323
|
-
* `git status --porcelain` to catch untracked (new) files.
|
|
324
|
-
*
|
|
325
|
-
* Returns an array of { status, path } where status is "M"/"A"/"D" matching
|
|
326
|
-
* git's own format, ready for direct display in the widget.
|
|
327
|
-
*/
|
|
328
|
-
function getGitDiffChanges(cwd: string): GitFileChange[] {
|
|
329
|
-
const changes: GitFileChange[] = [];
|
|
330
|
-
const seen = new Set<string>();
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
// 1. `git diff --name-status` — shows modified (M) and deleted (D) vs HEAD
|
|
334
|
-
// Format: "M\tpath/to/file" (tab-separated) or "M path/to/file" (spaces)
|
|
335
|
-
const diffOutput = execSync("git diff --name-status", { cwd, encoding: "utf8", timeout: 5000 }).trim();
|
|
336
|
-
if (diffOutput) {
|
|
337
|
-
for (const line of diffOutput.split("\n")) {
|
|
338
|
-
const trimmed = line.trim();
|
|
339
|
-
if (!trimmed) continue;
|
|
340
|
-
// 使用正则解析:支持 tab 分隔("M\tpath")和空格填充("M path")两种格式
|
|
341
|
-
const statusMatch = trimmed.match(/^([MAD])\s+(.+)$/);
|
|
342
|
-
if (statusMatch) {
|
|
343
|
-
const status = statusMatch[1]!.trim();
|
|
344
|
-
const filePath = statusMatch[2]!.trim();
|
|
345
|
-
if (filePath && !seen.has(filePath) && (status === "M" || status === "A" || status === "D")) {
|
|
346
|
-
seen.add(filePath);
|
|
347
|
-
changes.push({ status: status as "M" | "A" | "D", path: filePath });
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
// 后备:tab split(兼容部分 git 版本输出的 tab 格式)
|
|
351
|
-
else if (trimmed.includes("\t")) {
|
|
352
|
-
const parts = trimmed.split("\t");
|
|
353
|
-
if (parts.length === 2) {
|
|
354
|
-
const status = parts[0]!.trim();
|
|
355
|
-
const filePath = parts[1]!.trim();
|
|
356
|
-
if (filePath && !seen.has(filePath) && (status === "M" || status === "A" || status === "D")) {
|
|
357
|
-
seen.add(filePath);
|
|
358
|
-
changes.push({ status: status as "M" | "A" | "D", path: filePath });
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// 2. `git status --porcelain` — find untracked files (??) missing from git diff
|
|
366
|
-
// Format: "XY filepath" (e.g., " M .gitignore", "?? newfile.ts", "A filepath")
|
|
367
|
-
const statusOutput = execSync("git status --porcelain", { cwd, encoding: "utf8", timeout: 5000 }).trim();
|
|
368
|
-
if (statusOutput) {
|
|
369
|
-
for (const line of statusOutput.split("\n")) {
|
|
370
|
-
const trimmed = line.trim();
|
|
371
|
-
if (!trimmed) continue;
|
|
372
|
-
// 使用正则解析 --porcelain 格式:前 2 字符状态码 + 空格 + 路径
|
|
373
|
-
const statusMatch2 = trimmed.match(/^(..)\s+(.+)$/);
|
|
374
|
-
if (statusMatch2) {
|
|
375
|
-
const statusPrefix = statusMatch2[1]!.trim();
|
|
376
|
-
const filePath = statusMatch2[2]!.trim();
|
|
377
|
-
if (filePath && !seen.has(filePath) && (statusPrefix === "??" || statusPrefix === "A " || statusPrefix.startsWith("A"))) {
|
|
378
|
-
seen.add(filePath);
|
|
379
|
-
changes.push({ status: "A", path: filePath });
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
} catch {
|
|
385
|
-
// Git not available or not a repo — silently skip
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
return changes;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* 检查路径是否为工作流生成的产物路径(.pi-dev-output/),
|
|
393
|
-
* 若是则不应出现在 A: M: D: 文件变更列表中。
|
|
394
|
-
* 该目录已加入 .gitignore,git diff 本身已忽略,
|
|
395
|
-
* 此函数作为防御性过滤。
|
|
396
|
-
*/
|
|
397
|
-
function isWorkflowArtifactPath(filePath: string): boolean {
|
|
398
|
-
return filePath.startsWith(".pi-dev-output/");
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Check whether a file's current content hash differs from its baseline.
|
|
404
|
-
* Returns true if the hash changed or the file is no longer accessible.
|
|
405
|
-
*/
|
|
406
|
-
function hasContentChanged(cwd: string, path: string, baselineHash: string): boolean {
|
|
407
|
-
try {
|
|
408
|
-
const currentHash = execSync(`git hash-object "${path}"`, { cwd, encoding: 'utf8', timeout: 3000 }).trim();
|
|
409
|
-
return currentHash !== baselineHash;
|
|
410
|
-
} catch {
|
|
411
|
-
// file deleted or inaccessible — consider changed
|
|
412
|
-
return true;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Capture baseline snapshot of all dirty files at workflow start.
|
|
418
|
-
* Records git object hashes so we can later distinguish pre-existing
|
|
419
|
-
* changes from workflow-generated ones.
|
|
420
|
-
*/
|
|
421
|
-
function captureBaseline(cwd: string): void {
|
|
422
|
-
_workflowBaseline = [];
|
|
423
|
-
try {
|
|
424
|
-
const changes = getGitDiffChanges(cwd);
|
|
425
|
-
for (const change of changes) {
|
|
426
|
-
let hash = "";
|
|
427
|
-
try {
|
|
428
|
-
hash = execSync(`git hash-object "${change.path}"`, { cwd, encoding: "utf8", timeout: 3000 }).trim();
|
|
429
|
-
} catch {
|
|
430
|
-
// file might not exist (deleted in diff)
|
|
431
|
-
}
|
|
432
|
-
_workflowBaseline.push({ path: change.path, hash });
|
|
433
|
-
}
|
|
434
|
-
} catch {
|
|
435
|
-
// git not available or not a repo
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Update widget tool list from git diff changes, filtered against the baseline.
|
|
441
|
-
* Only reports changes that are genuinely new or modified BY the workflow,
|
|
442
|
-
* not pre-existing dirty files that the workflow didn't touch.
|
|
443
|
-
*
|
|
444
|
-
* Uses git-format status codes (M, A, D) for display consistency.
|
|
445
|
-
* Deduplicates against existing _workflowFileChanges.
|
|
446
|
-
*
|
|
447
|
-
* The dedup key includes stepIndex (filePath:stepIndex) so the same file
|
|
448
|
-
* can appear across different workflow steps (e.g., created by worker in
|
|
449
|
-
* loop-group, then reviewed by reviewer) without being collapsed into one.
|
|
450
|
-
* Without :stepIndex, the Set dedup would wrongly skip a file modified in
|
|
451
|
-
* a later step just because it was already tracked in an earlier step.
|
|
452
|
-
*
|
|
453
|
-
*/
|
|
454
|
-
function updateToolsFromGit(cwd: string, stepIndex: number, agentName: string): void {
|
|
455
|
-
const currentChanges = getGitDiffChanges(cwd);
|
|
456
|
-
const seen = new Set(_workflowFileChanges.map(c => `${c.filePath}:${c.stepIndex}`));
|
|
457
|
-
|
|
458
|
-
for (const change of currentChanges) {
|
|
459
|
-
if (seen.has(`${change.path}:${stepIndex}`)) continue;
|
|
460
|
-
|
|
461
|
-
// ── Baseline filtering ──────────────────────────────
|
|
462
|
-
// Skip files that were already dirty at workflow start and haven't been touched.
|
|
463
|
-
if (_workflowBaseline.length > 0) {
|
|
464
|
-
const baselineEntry = _workflowBaseline.find(b => b.path === change.path);
|
|
465
|
-
if (baselineEntry) {
|
|
466
|
-
if (!hasContentChanged(cwd, change.path, baselineEntry.hash)) {
|
|
467
|
-
// Pre-existing dirty file, content unchanged — workflow didn't touch it
|
|
468
|
-
continue;
|
|
469
|
-
}
|
|
470
|
-
// Content changed → workflow modified it, report as new change
|
|
471
|
-
// (Remove old baseline entry so next git diff sees it as workflow-owned)
|
|
472
|
-
_workflowBaseline = _workflowBaseline.filter(b => b.path !== change.path);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Skip files in .pi-dev-output/ — these are workflow-generated artifacts (plans, reviews)
|
|
477
|
-
// and should NOT be reported as user repo changes. Real git clients (VSCode, Zed) also
|
|
478
|
-
// ignore this directory (it's in .gitignore). This filter is defense-in-depth.
|
|
479
|
-
// Note: git always uses forward slashes in paths, even on Windows.
|
|
480
|
-
if (isWorkflowArtifactPath(change.path)) continue;
|
|
481
|
-
|
|
482
|
-
seen.add(`${change.path}:${stepIndex}`);
|
|
483
|
-
const type: FileChangeEntry["type"] =
|
|
484
|
-
change.status === "A" ? "new" :
|
|
485
|
-
change.status === "D" ? "delete" :
|
|
486
|
-
"edit";
|
|
487
|
-
_workflowFileChanges.push({
|
|
488
|
-
agent: agentName,
|
|
489
|
-
stepIndex,
|
|
490
|
-
type,
|
|
491
|
-
filePath: change.path,
|
|
492
|
-
timestamp: new Date().toISOString(),
|
|
493
|
-
});
|
|
494
|
-
// Use git-style format for display: "M path", "A path", "D path"
|
|
495
|
-
addWidgetSubStepTool(stepIndex, agentName, `${change.status} ${change.path}`);
|
|
496
|
-
_widgetExtraToolCount++;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
// ═══════════════════════════════════════════════════════════════
|
|
500
|
-
// Checkpoint
|
|
501
|
-
// ═══════════════════════════════════════════════════════════════
|
|
502
|
-
|
|
503
|
-
function saveCheckpoint(cwd: string, data: CheckpointData): void {
|
|
504
|
-
ensureOutputDir(cwd, "pi-workflow");
|
|
505
|
-
data.updatedAt = new Date().toISOString();
|
|
506
|
-
// Always version 2
|
|
507
|
-
(data as CheckpointData).version = 2;
|
|
508
|
-
// Enrich with file changes and agent history from module state
|
|
509
|
-
if (_workflowFileChanges.length > 0 && !data.fileChanges) {
|
|
510
|
-
data.fileChanges = [..._workflowFileChanges];
|
|
511
|
-
}
|
|
512
|
-
if (_workflowAgentRunHistory.length > 0 && !data.agentRunHistory) {
|
|
513
|
-
data.agentRunHistory = [..._workflowAgentRunHistory];
|
|
514
|
-
}
|
|
515
|
-
fs.writeFileSync(path.join(cwd, CHECKPOINT_FILE), JSON.stringify(data, null, 2), "utf-8");
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
export function loadCheckpointFromFile(cwd: string): CheckpointData | null {
|
|
519
|
-
try {
|
|
520
|
-
const content = fs.readFileSync(path.join(cwd, CHECKPOINT_FILE), "utf-8");
|
|
521
|
-
const data = JSON.parse(content) as CheckpointData;
|
|
522
|
-
// Backfill missing fields for v1 checkpoints
|
|
523
|
-
if (!data.version || data.version < 2) {
|
|
524
|
-
data.version = 2;
|
|
525
|
-
data.fileChanges = data.fileChanges ?? [];
|
|
526
|
-
data.agentRunHistory = data.agentRunHistory ?? [];
|
|
527
|
-
}
|
|
528
|
-
return data;
|
|
529
|
-
} catch {
|
|
530
|
-
return null;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Archive checkpoint after completion: rename to checkpoint-<uuid>-<plan-id>.json
|
|
536
|
-
*/
|
|
537
|
-
export function archiveCheckpointFile(cwd: string, planFileRelPath?: string): void {
|
|
538
|
-
try {
|
|
539
|
-
const cpPath = path.join(cwd, CHECKPOINT_FILE);
|
|
540
|
-
if (!fs.existsSync(cpPath)) return;
|
|
541
|
-
const uuidPart = _workflowId ? `-${_workflowId}` : "";
|
|
542
|
-
const planId = planFileRelPath
|
|
543
|
-
? path.basename(planFileRelPath, ".md").replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
544
|
-
: `archive-${Date.now().toString(36)}`;
|
|
545
|
-
const archiveName = `checkpoint${uuidPart}-${planId}.json`;
|
|
546
|
-
const archiveDir = path.join(cwd, DEV_OUTPUT_DIR, "pi-workflow");
|
|
547
|
-
fs.renameSync(cpPath, path.join(archiveDir, archiveName));
|
|
548
|
-
} catch { /* ignore */ }
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// ═══════════════════════════════════════════════════════════════
|
|
552
|
-
// Task builders
|
|
553
|
-
// ═══════════════════════════════════════════════════════════════
|
|
554
|
-
|
|
555
|
-
function buildTaskForStep(
|
|
556
|
-
agentName: string,
|
|
557
|
-
prompt: string,
|
|
558
|
-
planFileRelPath: string | undefined,
|
|
559
|
-
cwd: string,
|
|
560
|
-
workflowId: string,
|
|
561
|
-
chainContext?: string,
|
|
562
|
-
): string {
|
|
563
|
-
if (agentName === "planner") {
|
|
564
|
-
return [
|
|
565
|
-
"请根据以下功能需求,分析代码库结构,生成详细的实施计划,并写入 .pi-dev-output/pi-plans/ 目录。",
|
|
566
|
-
"",
|
|
567
|
-
"## 文件名格式",
|
|
568
|
-
`<YYYYMMDD-HHmmss>-<简短功能名>-${workflowId}.md`,
|
|
569
|
-
"请在文件名末尾添加工作流 UUID,以便同一工作流的所有文件可关联追溯。",
|
|
570
|
-
"",
|
|
571
|
-
"## 功能需求",
|
|
572
|
-
prompt,
|
|
573
|
-
...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []),
|
|
574
|
-
].join("\n");
|
|
575
|
-
}
|
|
576
|
-
if (agentName === "worker") {
|
|
577
|
-
const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined;
|
|
578
|
-
const planLocation = planFileRelPath
|
|
579
|
-
? `\n实施计划文件位于: ${planFileRelPath}\n(可在 .pi-dev-output/pi-plans/ 目录中 grep UUID "${workflowId}" 找到)`
|
|
580
|
-
: "";
|
|
581
|
-
if (planContent) {
|
|
582
|
-
return [
|
|
583
|
-
"请根据以下实施计划逐步实现代码改动。",
|
|
584
|
-
"",
|
|
585
|
-
"## 实施计划",
|
|
586
|
-
planContent,
|
|
587
|
-
planLocation,
|
|
588
|
-
"",
|
|
589
|
-
"## 原始需求与修改反馈",
|
|
590
|
-
prompt,
|
|
591
|
-
...(chainContext ? ["", chainContext] : []),
|
|
592
|
-
"",
|
|
593
|
-
"请严格按照计划中的步骤实施,不要做计划外的修改。",
|
|
594
|
-
"",
|
|
595
|
-
"实施完成后,在输出中列出你修改的所有文件,供后续审查者参考。",
|
|
596
|
-
...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []),
|
|
597
|
-
].join("\n");
|
|
598
|
-
}
|
|
599
|
-
return [
|
|
600
|
-
"请根据以下功能需求实施代码改动。",
|
|
601
|
-
"",
|
|
602
|
-
"## 功能需求",
|
|
603
|
-
prompt,
|
|
604
|
-
planLocation,
|
|
605
|
-
...(chainContext ? ["", chainContext] : []),
|
|
606
|
-
"",
|
|
607
|
-
"请先分析代码库,制定简要计划,再逐步实施。",
|
|
608
|
-
"实施完成后,在输出中列出你修改的所有文件,供后续审查者参考。",
|
|
609
|
-
...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []),
|
|
610
|
-
].join("\n");
|
|
611
|
-
}
|
|
612
|
-
if (agentName === "trimmer") {
|
|
613
|
-
const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined;
|
|
614
|
-
return [
|
|
615
|
-
"请精简当前代码库的代码。",
|
|
616
|
-
"缩短不必要的冗长行,优化可读性,消除可合并的重复逻辑。",
|
|
617
|
-
"注意:以下实施计划列出了本次工作流的核心新增内容,精简时请确保不影响这些改动。",
|
|
618
|
-
"",
|
|
619
|
-
"## 原始功能需求",
|
|
620
|
-
prompt,
|
|
621
|
-
...(planContent ? ["", "## 实施计划(改动范围)", planContent] : []),
|
|
622
|
-
...(chainContext ? ["", chainContext] : []),
|
|
623
|
-
...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []),
|
|
624
|
-
].join("\n");
|
|
625
|
-
}
|
|
626
|
-
if (agentName === "docWriter") {
|
|
627
|
-
const planContent = planFileRelPath
|
|
628
|
-
? `\n\n## 实施计划\n${readFileContent(cwd, planFileRelPath) ?? ""}`
|
|
629
|
-
: "";
|
|
630
|
-
return [
|
|
631
|
-
"请根据当前代码状态,更新 README.md 文档,必要时添加关键代码注释。",
|
|
632
|
-
"",
|
|
633
|
-
"## 功能需求",
|
|
634
|
-
prompt,
|
|
635
|
-
planContent,
|
|
636
|
-
...(chainContext ? ["", chainContext] : []),
|
|
637
|
-
...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []),
|
|
638
|
-
].join("\n");
|
|
639
|
-
}
|
|
640
|
-
return prompt;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
function buildReviewTask(
|
|
644
|
-
prompt: string,
|
|
645
|
-
planFileRelPath: string | undefined,
|
|
646
|
-
cwd: string,
|
|
647
|
-
workflowId: string,
|
|
648
|
-
chainContext?: string,
|
|
649
|
-
): string {
|
|
650
|
-
const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined;
|
|
651
|
-
const parts = [
|
|
652
|
-
"请审查当前代码库中针对以下功能的实现。",
|
|
653
|
-
"检查是否有 bug、逻辑错误、未完成的功能、代码质量问题。",
|
|
654
|
-
"将详细审查报告写入 .pi-dev-output/pi-review/md/ 目录。",
|
|
655
|
-
"",
|
|
656
|
-
"## 文件名格式",
|
|
657
|
-
`review-<YYYYMMDD-HHmmss>-${workflowId}.md`,
|
|
658
|
-
"请在文件名末尾添加工作流 UUID,以便同一工作流的所有文件可关联追溯。",
|
|
659
|
-
"",
|
|
660
|
-
"在回复末尾输出以下格式的结构化摘要(必须包含):",
|
|
661
|
-
"[REVIEW_SUMMARY]",
|
|
662
|
-
'{"maxSeverity":"critical|medium|low","critical":N,"medium":N,"low":N}',
|
|
663
|
-
"[/REVIEW_SUMMARY]",
|
|
664
|
-
"",
|
|
665
|
-
"⚠️ 重要:这个 JSON 摘要不是可选的,而是强制性的。",
|
|
666
|
-
"如果缺少此 JSON 摘要,工作流将无法判断是否需要继续修复循环。",
|
|
667
|
-
"请在回复的末尾单独输出,确保前后无其他文本(除换行符外)。",
|
|
668
|
-
"注意最大严重级别字段名是 maxSeverity(注意大小写)。",
|
|
669
|
-
"",
|
|
670
|
-
"## 功能需求",
|
|
671
|
-
prompt,
|
|
672
|
-
];
|
|
673
|
-
if (planContent) parts.push("", "## 实施计划", planContent);
|
|
674
|
-
if (chainContext) parts.push("", chainContext);
|
|
675
|
-
if (buildWorkflowInfoBlock()) parts.push("", buildWorkflowInfoBlock());
|
|
676
|
-
return parts.join("\n");
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// ═══════════════════════════════════════════════════════════════
|
|
680
|
-
// Global state for async execution
|
|
681
|
-
// ═══════════════════════════════════════════════════════════════
|
|
682
|
-
|
|
683
|
-
interface StepRuntimeInfo {
|
|
684
|
-
widgetStep: WorkflowStepWidgetState;
|
|
685
|
-
state: WorkflowStepState;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
let _workflowAbortController: AbortController | null = null;
|
|
689
|
-
let _workflowPi: ExtensionAPI | null = null;
|
|
690
|
-
let _workflowType: string | undefined;
|
|
691
|
-
let _workflowCwd = "";
|
|
692
|
-
let _workflowPrompt = "";
|
|
693
|
-
let _workflowPlanFileRelPath: string | undefined;
|
|
694
|
-
/** Track loop counts at module level so cancel callback can save a proper checkpoint. */
|
|
695
|
-
let _workflowLoopCounts: Record<string, number> = {};
|
|
696
|
-
/** Original checkpoint creation timestamp, for preserving across cancel. */
|
|
697
|
-
let _workflowCreatedAt: string = new Date().toISOString();
|
|
698
|
-
/** Track file changes globally for checkpoint persistence */
|
|
699
|
-
let _workflowFileChanges: FileChangeEntry[] = [];
|
|
700
|
-
/** Track agent run history */
|
|
701
|
-
let _workflowAgentRunHistory: AgentRunEntry[] = [];
|
|
702
|
-
/** Store step defs for pre-populating sub-steps */
|
|
703
|
-
let _workflowStepDefs: WorkflowStepDef[] = [];
|
|
704
|
-
/** Baseline snapshot: git hashes of dirty files at workflow start */
|
|
705
|
-
let _workflowBaseline: BaselineEntry[] = [];
|
|
706
|
-
/** Workflow UUID for cross-file traceability */
|
|
707
|
-
let _workflowId = "";
|
|
708
|
-
/** Chain context: keyed parts of previous agent output to pass to next agent */
|
|
709
|
-
let _chainContextParts: Record<string, string> = {};
|
|
710
|
-
|
|
711
|
-
let _widgetMode: WorkflowMode = "attended";
|
|
712
|
-
let _widgetSteps: WorkflowStepWidgetState[] = [];
|
|
713
|
-
let _widgetCurrentIdx = 0;
|
|
714
|
-
let _widgetStartTime = 0;
|
|
715
|
-
let _widgetExtraToolCount = 0;
|
|
716
|
-
let _widgetExtraTokenCount = 0;
|
|
717
|
-
let _workflowRunning = false;
|
|
718
|
-
let _cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
719
|
-
|
|
720
|
-
// ── Chain context helpers ──────────────────────────────────────
|
|
721
|
-
|
|
722
|
-
/**
|
|
723
|
-
* Update a named part of the chain context. Each key is a section header;
|
|
724
|
-
* calling with the same key overwrites the previous value.
|
|
725
|
-
*/
|
|
726
|
-
function updateChainContext(key: string, content: string): void {
|
|
727
|
-
if (content) {
|
|
728
|
-
_chainContextParts[key] = content;
|
|
729
|
-
} else {
|
|
730
|
-
delete _chainContextParts[key];
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/**
|
|
735
|
-
* Build the chain context string from all parts, for injection into
|
|
736
|
-
* buildTaskForStep / buildReviewTask prompts.
|
|
737
|
-
*/
|
|
738
|
-
function buildChainContext(): string {
|
|
739
|
-
const parts = Object.entries(_chainContextParts)
|
|
740
|
-
.filter(([_, v]) => v)
|
|
741
|
-
.map(([k, v]) => `## ${k}\n${v}`);
|
|
742
|
-
return parts.length > 0 ? parts.join("\n\n") : "";
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* Reset chain context — called at the start of a new workflow.
|
|
747
|
-
*/
|
|
748
|
-
function resetChainContext(): void {
|
|
749
|
-
_chainContextParts = {};
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/**
|
|
753
|
-
* Build a standard Workflow Info block injected into every subagent prompt.
|
|
754
|
-
*/
|
|
755
|
-
function buildWorkflowInfoBlock(): string {
|
|
756
|
-
if (!_workflowId) return "";
|
|
757
|
-
const modeLabel = _widgetMode === "full-auto" ? "全自动"
|
|
758
|
-
: _widgetMode === "full-attended" ? "完全值守"
|
|
759
|
-
: "值守";
|
|
760
|
-
return [
|
|
761
|
-
"## 工作流信息",
|
|
762
|
-
`- 工作流 UUID: ${_workflowId}`,
|
|
763
|
-
`- 工作流类型: ${_workflowType ?? "通用"}`,
|
|
764
|
-
`- 工作流启动时间: ${_workflowCreatedAt}`,
|
|
765
|
-
`- 工作流模式: ${modeLabel}`,
|
|
766
|
-
"",
|
|
767
|
-
"提示:如果需要在 .pi-dev-output/ 下查找属于本工作流的文件,",
|
|
768
|
-
`请在工作流输出目录中搜索工作流 UUID "${_workflowId}"。`,
|
|
769
|
-
].join("\n");
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
function refreshWidget(): void {
|
|
773
|
-
if (!_lastWorkflowCtx) return;
|
|
774
|
-
const taskSummary = extractTaskSummary(_workflowPrompt);
|
|
775
|
-
const widgetState = buildWidgetState(
|
|
776
|
-
_widgetMode,
|
|
777
|
-
_widgetSteps,
|
|
778
|
-
_widgetCurrentIdx,
|
|
779
|
-
_widgetStartTime,
|
|
780
|
-
_workflowRunning ? "running" :
|
|
781
|
-
_widgetSteps.some(s => s.status === "failed") ? "failed" :
|
|
782
|
-
_widgetSteps.every(s => s.status === "done" || s.status === "skipped") ? "done" :
|
|
783
|
-
"running",
|
|
784
|
-
{ toolCount: _widgetExtraToolCount, tokenCount: _widgetExtraTokenCount },
|
|
785
|
-
taskSummary,
|
|
786
|
-
_workflowId,
|
|
787
|
-
);
|
|
788
|
-
updateWorkflowWidget(_lastWorkflowCtx, widgetState);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
let _lastWorkflowCtx: ExtensionCommandContext | null = null;
|
|
792
|
-
|
|
793
|
-
function initWidget(ctx: ExtensionCommandContext, mode: WorkflowMode, stepsCount: number): void {
|
|
794
|
-
_widgetMode = mode;
|
|
795
|
-
_widgetSteps = [];
|
|
796
|
-
for (let i = 0; i < stepsCount; i++) {
|
|
797
|
-
_widgetSteps.push({ label: "", status: "pending" });
|
|
798
|
-
}
|
|
799
|
-
_widgetCurrentIdx = 0;
|
|
800
|
-
_widgetStartTime = Date.now();
|
|
801
|
-
_widgetExtraToolCount = 0;
|
|
802
|
-
_widgetExtraTokenCount = 0;
|
|
803
|
-
if (_cleanupTimer) {
|
|
804
|
-
clearTimeout(_cleanupTimer);
|
|
805
|
-
_cleanupTimer = null;
|
|
806
|
-
}
|
|
807
|
-
_lastWorkflowCtx = ctx;
|
|
808
|
-
_workflowRunning = true;
|
|
809
|
-
refreshWidget();
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
function updateWidgetStep(
|
|
813
|
-
index: number,
|
|
814
|
-
label: string,
|
|
815
|
-
status: WorkflowStepWidgetState["status"],
|
|
816
|
-
extra?: {
|
|
817
|
-
durationMs?: number;
|
|
818
|
-
loopCount?: number;
|
|
819
|
-
maxLoops?: number;
|
|
820
|
-
timeoutMs?: number;
|
|
821
|
-
error?: string;
|
|
822
|
-
subSteps?: WorkflowSubStepWidgetState[];
|
|
823
|
-
startedAt?: number;
|
|
824
|
-
},
|
|
825
|
-
): void {
|
|
826
|
-
if (index < _widgetSteps.length) {
|
|
827
|
-
const existing = _widgetSteps[index];
|
|
828
|
-
_widgetSteps[index] = {
|
|
829
|
-
...existing,
|
|
830
|
-
label,
|
|
831
|
-
status,
|
|
832
|
-
...extra,
|
|
833
|
-
};
|
|
834
|
-
}
|
|
835
|
-
refreshWidget();
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
function populatePredefinedSubSteps(stepIndex: number): void {
|
|
839
|
-
const step = _widgetSteps[stepIndex];
|
|
840
|
-
if (!step || !_workflowStepDefs[stepIndex]) return;
|
|
841
|
-
if (step.subSteps && step.subSteps.length > 0) return; // already populated
|
|
842
|
-
|
|
843
|
-
const def = _workflowStepDefs[stepIndex]!;
|
|
844
|
-
const agents = discoverAgents();
|
|
845
|
-
|
|
846
|
-
function agentThinkingLevel(name: string): string | undefined {
|
|
847
|
-
return agents.find(a => a.name === name)?.thinkingLevel;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
const newSubSteps: WorkflowSubStepWidgetState[] = [];
|
|
851
|
-
|
|
852
|
-
if (def.type === "loop-group") {
|
|
853
|
-
if (def.loopAgentName) {
|
|
854
|
-
newSubSteps.push({
|
|
855
|
-
agent: def.loopAgentName,
|
|
856
|
-
thinkingLevel: agentThinkingLevel(def.loopAgentName),
|
|
857
|
-
status: "pending",
|
|
858
|
-
tools: [],
|
|
859
|
-
outputs: [],
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
if (def.reviewAgentName) {
|
|
863
|
-
newSubSteps.push({
|
|
864
|
-
agent: def.reviewAgentName,
|
|
865
|
-
thinkingLevel: agentThinkingLevel(def.reviewAgentName),
|
|
866
|
-
status: "pending",
|
|
867
|
-
tools: [],
|
|
868
|
-
outputs: [],
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
} else if (def.agentName) {
|
|
872
|
-
newSubSteps.push({
|
|
873
|
-
agent: def.agentName,
|
|
874
|
-
thinkingLevel: agentThinkingLevel(def.agentName),
|
|
875
|
-
status: "pending",
|
|
876
|
-
tools: [],
|
|
877
|
-
outputs: [],
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
if (newSubSteps.length > 0) {
|
|
882
|
-
step.subSteps = newSubSteps;
|
|
883
|
-
refreshWidget();
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function addWidgetSubStepTool(stepIndex: number, agentName: string, tool: string): void {
|
|
888
|
-
const step = _widgetSteps[stepIndex];
|
|
889
|
-
if (!step) return;
|
|
890
|
-
const sub = step.subSteps?.find(s => s.agent === agentName);
|
|
891
|
-
if (sub) {
|
|
892
|
-
if (!sub.tools) sub.tools = [];
|
|
893
|
-
sub.tools.push(tool);
|
|
894
|
-
if (sub.tools.length > 20) sub.tools = sub.tools.slice(-20); // keep last 20
|
|
895
|
-
refreshWidget();
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
function addWidgetSubStepOutput(stepIndex: number, agentName: string, output: string): void {
|
|
900
|
-
const step = _widgetSteps[stepIndex];
|
|
901
|
-
if (!step) return;
|
|
902
|
-
const sub = step.subSteps?.find(s => s.agent === agentName);
|
|
903
|
-
if (sub) {
|
|
904
|
-
if (!sub.outputs) sub.outputs = [];
|
|
905
|
-
if (!sub.outputs.includes(output)) {
|
|
906
|
-
sub.outputs.push(output);
|
|
907
|
-
}
|
|
908
|
-
refreshWidget();
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
function setWidgetSubStepStatus(stepIndex: number, agentName: string, status: WorkflowSubStepWidgetState["status"]): void {
|
|
913
|
-
const step = _widgetSteps[stepIndex];
|
|
914
|
-
if (!step) return;
|
|
915
|
-
const sub = step.subSteps?.find(s => s.agent === agentName);
|
|
916
|
-
if (sub) {
|
|
917
|
-
sub.status = status;
|
|
918
|
-
refreshWidget();
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/**
|
|
923
|
-
* 重置子步骤为 pending 状态并清除计时信息(durationMs/startedAt)。
|
|
924
|
-
* 用于循环组中开启新循环时,清除上一轮的计时数据。
|
|
925
|
-
*/
|
|
926
|
-
function resetWidgetSubStepTimers(stepIndex: number, agentName: string): void {
|
|
927
|
-
const step = _widgetSteps[stepIndex];
|
|
928
|
-
if (!step) return;
|
|
929
|
-
const sub = step.subSteps?.find(s => s.agent === agentName);
|
|
930
|
-
if (sub) {
|
|
931
|
-
sub.status = "pending";
|
|
932
|
-
sub.durationMs = undefined;
|
|
933
|
-
sub.startedAt = undefined;
|
|
934
|
-
sub.tools = [];
|
|
935
|
-
sub.outputs = [];
|
|
936
|
-
}
|
|
937
|
-
refreshWidget();
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function setWidgetCurrentStep(index: number): void {
|
|
941
|
-
_widgetCurrentIdx = index;
|
|
942
|
-
refreshWidget();
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
function cleanupWidget(): void {
|
|
946
|
-
if (_cleanupTimer) {
|
|
947
|
-
clearTimeout(_cleanupTimer);
|
|
948
|
-
_cleanupTimer = null;
|
|
949
|
-
}
|
|
950
|
-
_workflowRunning = false;
|
|
951
|
-
if (_lastWorkflowCtx) {
|
|
952
|
-
updateWorkflowWidget(_lastWorkflowCtx, null);
|
|
953
|
-
_lastWorkflowCtx = null;
|
|
954
|
-
}
|
|
955
|
-
_workflowAbortController = null;
|
|
956
|
-
setWorkflowCancelCallback(null);
|
|
957
|
-
// Clean up terminal input listener (Esc)
|
|
958
|
-
if (_terminalInputUnsubscribe) {
|
|
959
|
-
_terminalInputUnsubscribe();
|
|
960
|
-
_terminalInputUnsubscribe = null;
|
|
961
|
-
}
|
|
962
|
-
// Clean up signal handlers
|
|
963
|
-
cleanupSignalHandlers();
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/** Unsubscribe function for terminal input listener (Esc to cancel) */
|
|
967
|
-
let _terminalInputUnsubscribe: (() => void) | null = null;
|
|
968
|
-
|
|
969
|
-
// ── Signal handling (SIGINT/SIGTERM) for graceful workflow cancellation ──
|
|
970
|
-
|
|
971
|
-
let _signalHandlersRegistered = false;
|
|
972
|
-
|
|
973
|
-
function cleanupSignalHandlers(): void {
|
|
974
|
-
if (!_signalHandlersRegistered) return;
|
|
975
|
-
try { process.removeListener("SIGINT", onSigint); } catch { /* ignore */ }
|
|
976
|
-
try { process.removeListener("SIGTERM", onSigterm); } catch { /* ignore */ }
|
|
977
|
-
_signalHandlersRegistered = false;
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function onSigint(): void {
|
|
981
|
-
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
982
|
-
console.log("\n[workflow] SIGINT received, cancelling workflow...");
|
|
983
|
-
cancelWorkflow();
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
function onSigterm(): void {
|
|
988
|
-
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
989
|
-
cancelWorkflow();
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
function registerSignalHandlers(): void {
|
|
994
|
-
if (_signalHandlersRegistered) return;
|
|
995
|
-
try {
|
|
996
|
-
process.on("SIGINT", onSigint);
|
|
997
|
-
process.on("SIGTERM", onSigterm);
|
|
998
|
-
_signalHandlersRegistered = true;
|
|
999
|
-
} catch { /* ignore */ }
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// ── Cancel handler ──
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1006
|
-
// Step change rollback (for "back" navigation)
|
|
1007
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1008
|
-
|
|
1009
|
-
/**
|
|
1010
|
-
* Revert file changes made by a specific step index using git.
|
|
1011
|
-
* This is called when the user selects "back" during step confirmation.
|
|
1012
|
-
*
|
|
1013
|
-
* - "edit" files: git checkout HEAD to restore to committed state
|
|
1014
|
-
* - "new" files: delete the untracked file
|
|
1015
|
-
* - "delete" files: git checkout HEAD to restore the deleted file
|
|
1016
|
-
*/
|
|
1017
|
-
function revertStepChanges(stepIndex: number): void {
|
|
1018
|
-
const changesForStep = _workflowFileChanges.filter(c => c.stepIndex === stepIndex);
|
|
1019
|
-
if (changesForStep.length === 0) return;
|
|
1020
|
-
|
|
1021
|
-
for (const change of changesForStep) {
|
|
1022
|
-
try {
|
|
1023
|
-
const fullPath = path.join(_workflowCwd, change.filePath);
|
|
1024
|
-
switch (change.type) {
|
|
1025
|
-
case "edit":
|
|
1026
|
-
case "delete":
|
|
1027
|
-
// Restore from git HEAD
|
|
1028
|
-
execSync(`git checkout HEAD -- "${change.filePath}"`, {
|
|
1029
|
-
cwd: _workflowCwd,
|
|
1030
|
-
encoding: "utf8",
|
|
1031
|
-
timeout: 5000,
|
|
1032
|
-
timeoutKill: 1000,
|
|
1033
|
-
});
|
|
1034
|
-
break;
|
|
1035
|
-
case "new":
|
|
1036
|
-
// Delete the newly created file
|
|
1037
|
-
try {
|
|
1038
|
-
if (fs.existsSync(fullPath)) {
|
|
1039
|
-
fs.rmSync(fullPath, { force: true });
|
|
1040
|
-
}
|
|
1041
|
-
} catch { /* ignore if file doesn't exist */ }
|
|
1042
|
-
break;
|
|
1043
|
-
}
|
|
1044
|
-
} catch { /* if git fails, skip this file */ }
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// Remove the reverted changes from the tracking list
|
|
1048
|
-
_workflowFileChanges = _workflowFileChanges.filter(c => c.stepIndex !== stepIndex);
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1052
|
-
// Agent runner (non-blocking, widget-based)
|
|
1053
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1054
|
-
|
|
1055
|
-
/**
|
|
1056
|
-
* Run a sub-agent without blocking the main TUI.
|
|
1057
|
-
* Progress is reported via the widget sub-step system.
|
|
1058
|
-
* Uses the global AbortController for cancellation.
|
|
1059
|
-
*/
|
|
1060
|
-
async function runAgentWithProgress(
|
|
1061
|
-
agent: AgentDef,
|
|
1062
|
-
task: string,
|
|
1063
|
-
stepIndex: number,
|
|
1064
|
-
agentName: string,
|
|
1065
|
-
timeoutMs: number,
|
|
1066
|
-
): Promise<SubagentResult> {
|
|
1067
|
-
const signal = _workflowAbortController?.signal;
|
|
1068
|
-
const agentStartTime = Date.now();
|
|
1069
|
-
|
|
1070
|
-
// Initialize sub-step in widget
|
|
1071
|
-
const step = _widgetSteps[stepIndex];
|
|
1072
|
-
if (step) {
|
|
1073
|
-
if (!step.subSteps) step.subSteps = [];
|
|
1074
|
-
const existing = step.subSteps.find(s => s.agent === agentName);
|
|
1075
|
-
if (!existing) {
|
|
1076
|
-
step.subSteps.push({
|
|
1077
|
-
agent: agentName,
|
|
1078
|
-
status: "running",
|
|
1079
|
-
tools: [],
|
|
1080
|
-
outputs: [],
|
|
1081
|
-
startedAt: agentStartTime,
|
|
1082
|
-
detail: `超时时间${formatTimeout(timeoutMs)}`,
|
|
1083
|
-
});
|
|
1084
|
-
refreshWidget();
|
|
1085
|
-
} else {
|
|
1086
|
-
// Update existing sub-step status, startedAt, and detail
|
|
1087
|
-
existing.status = "running";
|
|
1088
|
-
existing.startedAt = agentStartTime;
|
|
1089
|
-
existing.detail = `超时时间${formatTimeout(timeoutMs)}`;
|
|
1090
|
-
refreshWidget();
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
// ── Start git diff polling for real file changes ──
|
|
1095
|
-
// This replaces the unreliable regex-based tool call sniffing that was removed.
|
|
1096
|
-
// git diff --name-status is the same approach used by VSCode, Zed, and every
|
|
1097
|
-
// professional git client — it's deterministic, noise-free, and 100% accurate.
|
|
1098
|
-
let _gitPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
1099
|
-
const _pollSeen = new Set<string>();
|
|
1100
|
-
if (_workflowCwd) {
|
|
1101
|
-
_gitPollTimer = setInterval(() => {
|
|
1102
|
-
try {
|
|
1103
|
-
const currentChanges = getGitDiffChanges(_workflowCwd);
|
|
1104
|
-
for (const change of currentChanges) {
|
|
1105
|
-
const key = `${change.path}:${stepIndex}`;
|
|
1106
|
-
if (_pollSeen.has(key)) continue;
|
|
1107
|
-
_pollSeen.add(key);
|
|
1108
|
-
|
|
1109
|
-
if (isWorkflowArtifactPath(change.path)) continue;
|
|
1110
|
-
if (_workflowBaseline.length > 0) {
|
|
1111
|
-
const baseEntry = _workflowBaseline.find(b => b.path === change.path);
|
|
1112
|
-
if (baseEntry) {
|
|
1113
|
-
if (!hasContentChanged(_workflowCwd, change.path, baseEntry.hash)) continue;
|
|
1114
|
-
// ⭐ 同步删除已变更的基线条目,与 updateToolsFromGit 行为一致
|
|
1115
|
-
_workflowBaseline = _workflowBaseline.filter(b => b.path !== change.path);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
// ⭐ Also record in _workflowFileChanges so the completion report file tree
|
|
1120
|
-
// and revertStepChanges have accurate data. The final updateToolsFromGit call
|
|
1121
|
-
// will skip this via seen, so we must record it here.
|
|
1122
|
-
const type: FileChangeEntry["type"] =
|
|
1123
|
-
change.status === "A" ? "new" :
|
|
1124
|
-
change.status === "D" ? "delete" :
|
|
1125
|
-
"edit";
|
|
1126
|
-
_workflowFileChanges.push({
|
|
1127
|
-
agent: agentName,
|
|
1128
|
-
stepIndex,
|
|
1129
|
-
type,
|
|
1130
|
-
filePath: change.path,
|
|
1131
|
-
timestamp: new Date().toISOString(),
|
|
1132
|
-
});
|
|
1133
|
-
addWidgetSubStepTool(stepIndex, agentName, `${change.status} ${change.path}`);
|
|
1134
|
-
_widgetExtraToolCount++;
|
|
1135
|
-
}
|
|
1136
|
-
} catch { /* 静默忽略 git 错误 */ }
|
|
1137
|
-
}, 5000);
|
|
1138
|
-
_gitPollTimer.unref();
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// Run the sub-agent with progress reporting
|
|
1142
|
-
// The toolMatch regex sniffing has been removed — it was the primary source of
|
|
1143
|
-
// false positives in the A: M: D: panel (matching AI natural language as tool calls).
|
|
1144
|
-
// File change detection is now handled entirely by git diff polling (above) and
|
|
1145
|
-
// the final updateToolsFromGit call (below), both of which use git's own API.
|
|
1146
|
-
let result;
|
|
1147
|
-
try {
|
|
1148
|
-
result = await spawnSubagent(agent, task, _workflowCwd, signal, timeoutMs, (progress) => {
|
|
1149
|
-
// Detect output file paths — only match .pi-dev-output paths belonging to current workflow
|
|
1150
|
-
if (_workflowId) {
|
|
1151
|
-
const outputMatch = progress.match(/\.pi-dev-output\/[a-zA-Z0-9_\/\.-]+/i);
|
|
1152
|
-
if (outputMatch) {
|
|
1153
|
-
const pathCandidate = outputMatch[0]!.trim();
|
|
1154
|
-
// ⭐ 严格白名单:拒绝中文、引号、括号等非路径字符
|
|
1155
|
-
if (pathCandidate.length > 15 && pathCandidate.length < 300 &&
|
|
1156
|
-
pathCandidate.includes(_workflowId) &&
|
|
1157
|
-
/^[\w.\/-]+$/.test(pathCandidate)) {
|
|
1158
|
-
addWidgetSubStepOutput(stepIndex, agentName, pathCandidate);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
}, _workflowId ? { workflowId: _workflowId } : undefined);
|
|
1163
|
-
} finally {
|
|
1164
|
-
if (_gitPollTimer) {
|
|
1165
|
-
clearInterval(_gitPollTimer);
|
|
1166
|
-
_gitPollTimer = null;
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
const agentDuration = Date.now() - agentStartTime;
|
|
1171
|
-
|
|
1172
|
-
// Record agent run in history
|
|
1173
|
-
_workflowAgentRunHistory.push({
|
|
1174
|
-
agent: agentName,
|
|
1175
|
-
stepIndex,
|
|
1176
|
-
startedAt: new Date(agentStartTime).toISOString(),
|
|
1177
|
-
durationMs: agentDuration,
|
|
1178
|
-
exitCode: result.exitCode,
|
|
1179
|
-
toolCount: _widgetExtraToolCount,
|
|
1180
|
-
});
|
|
1181
|
-
|
|
1182
|
-
// ── Post-completion: file change detection via git diff ONLY ──
|
|
1183
|
-
// We do NOT parse agent output text for file paths — that approach is fragile and
|
|
1184
|
-
// produces false positives (e.g., matching JS property names like `.push`, `.length`
|
|
1185
|
-
// from code snippets). Instead, we rely entirely on git diff --name-status, which is
|
|
1186
|
-
// the same approach used by VSCode, Zed, and every other professional git client.
|
|
1187
|
-
// It's deterministic, noise-free, and always produces correct relative paths.
|
|
1188
|
-
const finalOutput = extractFinalOutput(result.output) || result.output;
|
|
1189
|
-
|
|
1190
|
-
// Find output file paths (plans, reviews) for display in the "output:" section
|
|
1191
|
-
// These are workflow artifacts (.pi-dev-output/) — only show files belonging to
|
|
1192
|
-
// the CURRENT workflow to avoid cross-contamination from previous runs.
|
|
1193
|
-
// Note: file change detection itself relies exclusively on git diff below, not text parsing.
|
|
1194
|
-
// ⭐ IMPORTANT: Only search in CLEAN extracted text (finalOutput), NOT raw JSON output.
|
|
1195
|
-
// The old approach concatenated allOutput (raw JSON + stderr) into searchText and
|
|
1196
|
-
// matched against it, producing false positives from JSON stringified content.
|
|
1197
|
-
// By restricting to extractFinalOutput's clean text, we avoid matching against
|
|
1198
|
-
// JSON-escaped paths, code snippets, or natural language in raw agent output.
|
|
1199
|
-
const cleanText = finalOutput || result.output || "";
|
|
1200
|
-
const seenOutputs = new Set<string>();
|
|
1201
|
-
if (_workflowId) {
|
|
1202
|
-
// Only match .pi-dev-output paths containing the current workflow UUID
|
|
1203
|
-
const escapedId = _workflowId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1204
|
-
const outputPattern = new RegExp(`\\.pi-dev-output\\/[a-zA-Z0-9_\\/.-]*${escapedId}[a-zA-Z0-9_\\/.-]*`, 'g');
|
|
1205
|
-
let m;
|
|
1206
|
-
while ((m = outputPattern.exec(cleanText)) !== null) {
|
|
1207
|
-
const path_ = m[0]!.trim();
|
|
1208
|
-
if (path_.length > 15 && path_.length < 300 && !seenOutputs.has(path_)) {
|
|
1209
|
-
// ⭐ 严格白名单:仅允许 [a-zA-Z0-9_./-] 字符
|
|
1210
|
-
// 拒绝包含中文、引号、括号、空格等非路径字符的乱码匹配
|
|
1211
|
-
if (!/^[\w.\/-]+$/.test(path_)) continue;
|
|
1212
|
-
seenOutputs.add(path_);
|
|
1213
|
-
addWidgetSubStepOutput(stepIndex, agentName, path_);
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
} else {
|
|
1217
|
-
// No workflow ID (unlikely): match .pi-dev-output paths conservatively
|
|
1218
|
-
const outputPattern = /\.pi-dev-output\/[a-zA-Z0-9_\/-]+\.[a-zA-Z0-9]+/g;
|
|
1219
|
-
let m;
|
|
1220
|
-
while ((m = outputPattern.exec(cleanText)) !== null) {
|
|
1221
|
-
const path_ = m[0]!.trim();
|
|
1222
|
-
if (path_.length > 15 && path_.length < 300 && !seenOutputs.has(path_)) {
|
|
1223
|
-
// ⭐ 严格白名单
|
|
1224
|
-
if (!/^[\w.\/-]+$/.test(path_)) continue;
|
|
1225
|
-
seenOutputs.add(path_);
|
|
1226
|
-
addWidgetSubStepOutput(stepIndex, agentName, path_);
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
// ── Update file changes from git diff (more accurate than text scraping) ──
|
|
1232
|
-
updateToolsFromGit(_workflowCwd, stepIndex, agentName);
|
|
1233
|
-
// Update sub-step status based on result
|
|
1234
|
-
const subStatus: WorkflowSubStepWidgetState["status"] =
|
|
1235
|
-
result.exitCode === 0 ? "done" :
|
|
1236
|
-
isTimeoutResult(result) ? "failed" :
|
|
1237
|
-
result.exitCode !== 0 ? "failed" :
|
|
1238
|
-
"done";
|
|
1239
|
-
setWidgetSubStepStatus(stepIndex, agentName, subStatus);
|
|
1240
|
-
|
|
1241
|
-
// ⭐ 修复:代理完成后设置最终持续时长并清除 startedAt
|
|
1242
|
-
// 确保 UI 使用记录的 durationMs 而非实时 Date.now() - startedAt
|
|
1243
|
-
const completedSub = _widgetSteps[stepIndex]?.subSteps?.find(s => s.agent === agentName);
|
|
1244
|
-
if (completedSub) {
|
|
1245
|
-
completedSub.durationMs = agentDuration;
|
|
1246
|
-
completedSub.startedAt = undefined;
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
return result;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1253
|
-
// Single-step executor
|
|
1254
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1255
|
-
|
|
1256
|
-
async function executeSingleStep(
|
|
1257
|
-
ctx: ExtensionCommandContext,
|
|
1258
|
-
step: WorkflowStepDef,
|
|
1259
|
-
state: WorkflowStepState,
|
|
1260
|
-
agentMap: Map<string, AgentDef>,
|
|
1261
|
-
prompt: string,
|
|
1262
|
-
planFileRelPath: string | undefined,
|
|
1263
|
-
mode: WorkflowMode,
|
|
1264
|
-
stepIndex: number,
|
|
1265
|
-
): Promise<void> {
|
|
1266
|
-
const agentName = step.agentName!;
|
|
1267
|
-
const agent = agentMap.get(agentName);
|
|
1268
|
-
if (!agent) throw new Error(`未找到 agent: ${agentName}`);
|
|
1269
|
-
|
|
1270
|
-
const task = buildTaskForStep(agentName, prompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext());
|
|
1271
|
-
let retried = false;
|
|
1272
|
-
|
|
1273
|
-
let result = await runAgentWithProgress(agent, task, stepIndex, agentName, step.timeoutMs);
|
|
1274
|
-
|
|
1275
|
-
// Timeout handling
|
|
1276
|
-
if (isTimeoutResult(result)) {
|
|
1277
|
-
if (mode === "full-auto" && !retried) {
|
|
1278
|
-
result = await runAgentWithProgress(agent, `[RETRY]\n\n${task}`, stepIndex, agentName, step.timeoutMs);
|
|
1279
|
-
retried = true;
|
|
1280
|
-
} else {
|
|
1281
|
-
const choice = await uiSelect(ctx, `⏰ ${step.label} 执行超时`, [
|
|
1282
|
-
"1. 重新执行", "2. 跳过此步骤", "3. 取消工作流",
|
|
1283
|
-
]);
|
|
1284
|
-
if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
|
|
1285
|
-
if (choice.startsWith("2")) { state.status = "skipped"; return; }
|
|
1286
|
-
result = await runAgentWithProgress(agent, `[RETRY]\n\n${task}`, stepIndex, agentName, step.timeoutMs);
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
if (isTimeoutResult(result)) {
|
|
1291
|
-
throw new Error(`执行超时 (${(step.timeoutMs / 1000).toFixed(0)}s)`);
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
if (result.exitCode !== 0 && result.stderr) {
|
|
1295
|
-
throw new Error(`Agent 错误 (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
// ── Capture AI work summary as supplementary chain context ──
|
|
1299
|
-
// We intentionally do NOT create chain contexts from _workflowFileChanges here.
|
|
1300
|
-
// File-change-based chain contexts ("计划制定摘要"/"文档更新摘要") were removed in v0.6.0
|
|
1301
|
-
// because their M/A/D counts provided negligible value for downstream agents compared
|
|
1302
|
-
// to the agent's own free-text work summary. The file-change approach also created
|
|
1303
|
-
// tight coupling between executeSingleStep and the _workflowFileChanges data structure.
|
|
1304
|
-
const workSummary = extractFinalOutput(result.output);
|
|
1305
|
-
if (workSummary) {
|
|
1306
|
-
updateChainContext(`${agentName} 工作总结`, `${agentName} 已完成工作,以下是其工作总结:\n\n${workSummary}`);
|
|
1307
|
-
} else if (result.output) {
|
|
1308
|
-
// 保底:使用输出前 200 字符作为简略摘要
|
|
1309
|
-
const fallback = result.output.slice(0, 200).trim();
|
|
1310
|
-
if (fallback) updateChainContext(`${agentName} 工作总结`, fallback);
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1315
|
-
// Loop-group executor
|
|
1316
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1317
|
-
|
|
1318
|
-
async function executeLoopGroup(
|
|
1319
|
-
ctx: ExtensionCommandContext,
|
|
1320
|
-
step: WorkflowStepDef,
|
|
1321
|
-
state: WorkflowStepState,
|
|
1322
|
-
agentMap: Map<string, AgentDef>,
|
|
1323
|
-
prompt: string,
|
|
1324
|
-
planFileRelPath: string | undefined,
|
|
1325
|
-
mode: WorkflowMode,
|
|
1326
|
-
loopCounts: Record<string, number>,
|
|
1327
|
-
stepIndex: number,
|
|
1328
|
-
): Promise<void> {
|
|
1329
|
-
const loopAgent = agentMap.get(step.loopAgentName!);
|
|
1330
|
-
const reviewAgent = agentMap.get(step.reviewAgentName!);
|
|
1331
|
-
if (!loopAgent) throw new Error(`未找到 loop agent: ${step.loopAgentName}`);
|
|
1332
|
-
if (!reviewAgent) throw new Error(`未找到 review agent: ${step.reviewAgentName}`);
|
|
1333
|
-
|
|
1334
|
-
const maxLoops = step.maxLoops ?? 3;
|
|
1335
|
-
let loopCount = loopCounts[step.id] ?? 0;
|
|
1336
|
-
let contextPrompt = prompt;
|
|
1337
|
-
const reviewTimeoutMs = step.reviewTimeoutMs ?? step.timeoutMs;
|
|
1338
|
-
|
|
1339
|
-
while (loopCount < maxLoops) {
|
|
1340
|
-
loopCount++;
|
|
1341
|
-
// 立即更新 UI 显示当前循环次数
|
|
1342
|
-
state.loopCount = loopCount;
|
|
1343
|
-
updateWidgetStep(stepIndex, step.label, "running", {
|
|
1344
|
-
loopCount,
|
|
1345
|
-
maxLoops: step.maxLoops,
|
|
1346
|
-
startedAt: _widgetSteps[stepIndex]?.startedAt || Date.now(),
|
|
1347
|
-
});
|
|
1348
|
-
|
|
1349
|
-
// 每次循环开始时重置 sub-step 状态(清除上一轮的计时和工具记录)
|
|
1350
|
-
resetWidgetSubStepTimers(stepIndex, step.loopAgentName!);
|
|
1351
|
-
resetWidgetSubStepTimers(stepIndex, step.reviewAgentName!);
|
|
1352
|
-
const loopStartTime = Date.now();
|
|
1353
|
-
|
|
1354
|
-
// ── Run loop agent (worker / trimmer) ──
|
|
1355
|
-
const loopTask = buildTaskForStep(step.loopAgentName!, contextPrompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext());
|
|
1356
|
-
|
|
1357
|
-
let agentResult = await runAgentWithProgress(loopAgent, loopTask, stepIndex, step.loopAgentName!, step.timeoutMs);
|
|
1358
|
-
|
|
1359
|
-
// 检查 agent 是否异常退出(非超时非零退出码)
|
|
1360
|
-
while (agentResult.exitCode !== 0 && !isTimeoutResult(agentResult)) {
|
|
1361
|
-
if (mode === "full-auto") {
|
|
1362
|
-
throw new Error(`Agent ${step.loopAgentName} 异常退出 (exit ${agentResult.exitCode}): ${agentResult.stderr.slice(0, 200)}`);
|
|
1363
|
-
} else {
|
|
1364
|
-
const choice = await uiSelect(ctx, `❌ ${step.loopAgentName} 异常退出 (exit ${agentResult.exitCode})`, [
|
|
1365
|
-
"1. 重新执行", "2. 跳过此步骤", "3. 取消工作流",
|
|
1366
|
-
]);
|
|
1367
|
-
if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
|
|
1368
|
-
if (choice.startsWith("2")) { state.status = "skipped"; return; }
|
|
1369
|
-
// 重新执行
|
|
1370
|
-
agentResult = await runAgentWithProgress(loopAgent, `[RETRY]\n\n${loopTask}`, stepIndex, step.loopAgentName!, step.timeoutMs);
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
// ── Capture loop agent work summary as supplementary chain context ──
|
|
1375
|
-
const loopFinalText = extractFinalOutput(agentResult.output);
|
|
1376
|
-
if (loopFinalText) {
|
|
1377
|
-
updateChainContext(`${step.loopAgentName} 工作总结`,
|
|
1378
|
-
`${step.loopAgentName} 已完成工作,以下是其工作总结:\n\n${loopFinalText}`
|
|
1379
|
-
);
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
if (isTimeoutResult(agentResult)) {
|
|
1383
|
-
if (mode === "full-auto") {
|
|
1384
|
-
contextPrompt = `[TIMEOUT_WARNING] 上一个 ${step.loopAgentName} 执行超时。\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext())}`;
|
|
1385
|
-
} else {
|
|
1386
|
-
const choice = await uiSelect(ctx, `⏰ ${step.loopAgentName} 执行超时`, [
|
|
1387
|
-
"1. 重新执行", "2. 进入审查阶段", "3. 跳过此步骤", "4. 取消工作流",
|
|
1388
|
-
]);
|
|
1389
|
-
if (!choice || choice.startsWith("4")) { cancelWorkflow(); return; }
|
|
1390
|
-
if (choice.startsWith("3")) { state.status = "skipped"; return; }
|
|
1391
|
-
if (choice.startsWith("2")) {
|
|
1392
|
-
contextPrompt = `[TIMEOUT_WARNING]\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext())}`;
|
|
1393
|
-
} else {
|
|
1394
|
-
agentResult = await runAgentWithProgress(loopAgent, `[RETRY]\n\n${loopTask}`, stepIndex, step.loopAgentName!, step.timeoutMs);
|
|
1395
|
-
if (isTimeoutResult(agentResult)) {
|
|
1396
|
-
contextPrompt = `[TIMEOUT_WARNING]\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext())}`;
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// ── Run reviewer ──
|
|
1403
|
-
const reviewTask = contextPrompt.includes("[TIMEOUT_WARNING]")
|
|
1404
|
-
? contextPrompt
|
|
1405
|
-
: buildReviewTask(contextPrompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext());
|
|
1406
|
-
|
|
1407
|
-
const reviewResult = await runAgentWithProgress(reviewAgent, reviewTask, stepIndex, step.reviewAgentName!, reviewTimeoutMs);
|
|
1408
|
-
|
|
1409
|
-
const extractedOutput = extractFinalOutput(reviewResult.output) || reviewResult.output;
|
|
1410
|
-
const combinedOutput = extractedOutput + "\n" + reviewResult.stderr;
|
|
1411
|
-
let reviewSummary = parseReviewerOutput(combinedOutput);
|
|
1412
|
-
if (!reviewSummary) reviewSummary = extractSeverityFromText(extractedOutput);
|
|
1413
|
-
if (!reviewSummary) {
|
|
1414
|
-
const reviewContent = readLatestReviewMd(_workflowCwd);
|
|
1415
|
-
if (reviewContent) {
|
|
1416
|
-
reviewSummary = parseReviewerOutput(reviewContent) ?? extractSeverityFromText(reviewContent);
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
// ── Capture reviewer work summary as supplementary chain context ──
|
|
1421
|
-
// The old review-chain-context entries ("代码审查反馈"/"精简审查反馈") based on
|
|
1422
|
-
// _workflowFileChanges were removed in v0.6.0 because they duplicated information
|
|
1423
|
-
// from the review report file and polluted chain context with file-path-heavy data.
|
|
1424
|
-
// Reuse already-parsed extractedOutput to avoid double parsing.
|
|
1425
|
-
if (extractedOutput) {
|
|
1426
|
-
updateChainContext(`审查工作总结`,
|
|
1427
|
-
`审查者已完成审查,以下是其审查总结:\n\n${extractedOutput}`
|
|
1428
|
-
);
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
// ── Decide whether to loop ──
|
|
1432
|
-
if (reviewSummary?.maxSeverity === "critical" && loopCount < maxLoops) {
|
|
1433
|
-
const reviewReportHint = `\n完整审查报告在 .pi-dev-output/pi-review/md/(grep UUID "${_workflowId}" 查找)。`;
|
|
1434
|
-
if (mode === "full-auto") {
|
|
1435
|
-
contextPrompt = [prompt, "", "## 上次审查发现的问题",
|
|
1436
|
-
`审查摘要: ${JSON.stringify(reviewSummary)}`,
|
|
1437
|
-
`请修复 ${reviewSummary.critical} 个严重问题后重新运行。`,
|
|
1438
|
-
reviewReportHint,
|
|
1439
|
-
].join("\n");
|
|
1440
|
-
continue;
|
|
1441
|
-
} else {
|
|
1442
|
-
const shouldLoop = await uiConfirm(ctx, "🔄 检测到严重问题",
|
|
1443
|
-
`审查发现 ${reviewSummary.critical} 个严重问题。是否进入下一轮循环 (${loopCount}/${maxLoops})?`);
|
|
1444
|
-
if (shouldLoop) {
|
|
1445
|
-
contextPrompt = [prompt, "", "## 上次审查发现的问题",
|
|
1446
|
-
`审查摘要: ${JSON.stringify(reviewSummary)}`,
|
|
1447
|
-
`请修复这些严重问题后重新运行。`,
|
|
1448
|
-
reviewReportHint,
|
|
1449
|
-
].join("\n");
|
|
1450
|
-
continue;
|
|
1451
|
-
}
|
|
1452
|
-
break;
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
break;
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
state.loopCount = loopCount;
|
|
1459
|
-
loopCounts[step.id] = loopCount;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1463
|
-
// Main async workflow executor
|
|
1464
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1465
|
-
|
|
1466
|
-
async function executeWorkflowBackground(
|
|
1467
|
-
ctx: ExtensionCommandContext,
|
|
1468
|
-
pi: ExtensionAPI,
|
|
1469
|
-
prompt: string,
|
|
1470
|
-
steps: WorkflowStepDef[],
|
|
1471
|
-
agentMap: Map<string, AgentDef>,
|
|
1472
|
-
mode: WorkflowMode,
|
|
1473
|
-
stepStates: WorkflowStepState[],
|
|
1474
|
-
initialStepIndex: number,
|
|
1475
|
-
initialLoopCounts: Record<string, number>,
|
|
1476
|
-
planFileRelPath: string | undefined,
|
|
1477
|
-
existingCp: CheckpointData | undefined,
|
|
1478
|
-
): Promise<void> {
|
|
1479
|
-
let loopCounts = { ...initialLoopCounts };
|
|
1480
|
-
let currentStepIndex = initialStepIndex;
|
|
1481
|
-
let planFileRelPathInner = planFileRelPath;
|
|
1482
|
-
|
|
1483
|
-
for (; currentStepIndex < steps.length; currentStepIndex++) {
|
|
1484
|
-
// Check abort
|
|
1485
|
-
if (_workflowAbortController?.signal.aborted) {
|
|
1486
|
-
return;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
const step = steps[currentStepIndex]!;
|
|
1490
|
-
const state = stepStates[currentStepIndex]!;
|
|
1491
|
-
|
|
1492
|
-
if (state.status === "done" || state.status === "skipped") continue;
|
|
1493
|
-
|
|
1494
|
-
setWidgetCurrentStep(currentStepIndex);
|
|
1495
|
-
|
|
1496
|
-
// Pre-populate sub-steps for pending steps so UI shows queued agents
|
|
1497
|
-
populatePredefinedSubSteps(currentStepIndex);
|
|
1498
|
-
|
|
1499
|
-
// ── Helper: handle "back" from any confirmation dialog ──
|
|
1500
|
-
// Returns true if back was handled (caller should continue without execution).
|
|
1501
|
-
const handleBack = async (): Promise<boolean> => {
|
|
1502
|
-
if (currentStepIndex > 0) {
|
|
1503
|
-
// Revert file changes made by the previous step
|
|
1504
|
-
revertStepChanges(currentStepIndex - 1);
|
|
1505
|
-
// Reset previous step state
|
|
1506
|
-
stepStates[currentStepIndex - 1]!.status = "pending";
|
|
1507
|
-
stepStates[currentStepIndex - 1]!.durationMs = undefined;
|
|
1508
|
-
stepStates[currentStepIndex - 1]!.error = undefined;
|
|
1509
|
-
stepStates[currentStepIndex - 1]!.loopCount = undefined;
|
|
1510
|
-
updateWidgetStep(
|
|
1511
|
-
currentStepIndex - 1,
|
|
1512
|
-
steps[currentStepIndex - 1]!.label,
|
|
1513
|
-
"pending",
|
|
1514
|
-
);
|
|
1515
|
-
// Clear sub-steps for the reverted step
|
|
1516
|
-
if (_widgetSteps[currentStepIndex - 1]) {
|
|
1517
|
-
_widgetSteps[currentStepIndex - 1]!.subSteps = [];
|
|
1518
|
-
}
|
|
1519
|
-
currentStepIndex -= 2; // loop will ++, landing on the previous step
|
|
1520
|
-
saveCheckpoint(_workflowCwd, buildCp());
|
|
1521
|
-
return true;
|
|
1522
|
-
}
|
|
1523
|
-
return false;
|
|
1524
|
-
};
|
|
1525
|
-
|
|
1526
|
-
// ── User confirmations (BEFORE timer starts) ──
|
|
1527
|
-
|
|
1528
|
-
// Confirmation for [confirm] steps (attended mode)
|
|
1529
|
-
if (step.type === "confirm" && mode !== "full-auto") {
|
|
1530
|
-
const items = [
|
|
1531
|
-
"1. 进入此步骤", "2. 自定义输入", "3. 跳过此步骤", "4. 取消工作流",
|
|
1532
|
-
];
|
|
1533
|
-
if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
|
|
1534
|
-
const choice = await uiSelect(ctx, `📌 ${step.label}`, items);
|
|
1535
|
-
if (choice === BACK_OPTION_TEXT) {
|
|
1536
|
-
if (await handleBack()) continue;
|
|
1537
|
-
return; // can't go back from first step
|
|
1538
|
-
}
|
|
1539
|
-
if (!choice || choice.startsWith("4")) { cancelWorkflow(); return; }
|
|
1540
|
-
if (choice.startsWith("3")) {
|
|
1541
|
-
state.status = "skipped";
|
|
1542
|
-
saveCheckpoint(_workflowCwd, buildCp());
|
|
1543
|
-
updateWidgetStep(currentStepIndex, step.label, "skipped");
|
|
1544
|
-
continue;
|
|
1545
|
-
}
|
|
1546
|
-
if (choice.startsWith("2")) {
|
|
1547
|
-
const customInput = await uiInput(ctx, "✏️ 自定义输入", "输入你的指令或反馈");
|
|
1548
|
-
if (customInput !== undefined && customInput.trim()) {
|
|
1549
|
-
prompt = `${prompt}\n\n## 用户自定义指令\n${customInput.trim()}`;
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
// Full-attended: confirm every step
|
|
1555
|
-
if (mode === "full-attended" && step.type !== "confirm") {
|
|
1556
|
-
const items = ["1. 执行", "2. 跳过", "3. 取消工作流"];
|
|
1557
|
-
if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
|
|
1558
|
-
const choice = await uiSelect(ctx, `📌 ${step.label} — 执行?`, items);
|
|
1559
|
-
if (choice === BACK_OPTION_TEXT) {
|
|
1560
|
-
if (await handleBack()) continue;
|
|
1561
|
-
return;
|
|
1562
|
-
}
|
|
1563
|
-
if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
|
|
1564
|
-
if (choice.startsWith("2")) {
|
|
1565
|
-
state.status = "skipped";
|
|
1566
|
-
saveCheckpoint(_workflowCwd, buildCp());
|
|
1567
|
-
updateWidgetStep(currentStepIndex, step.label, "skipped");
|
|
1568
|
-
continue;
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
// Attended: confirm loop-group steps (e.g. 实施代码 → 审查)
|
|
1573
|
-
if (step.type === "loop-group" && mode === "attended") {
|
|
1574
|
-
const items = [
|
|
1575
|
-
"1. 进入此步骤", "2. 跳过此步骤", "3. 取消工作流",
|
|
1576
|
-
];
|
|
1577
|
-
if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
|
|
1578
|
-
const choice = await uiSelect(ctx, `📌 ${step.label}`, items);
|
|
1579
|
-
if (choice === BACK_OPTION_TEXT) {
|
|
1580
|
-
if (await handleBack()) continue;
|
|
1581
|
-
return;
|
|
1582
|
-
}
|
|
1583
|
-
if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
|
|
1584
|
-
if (choice.startsWith("2")) {
|
|
1585
|
-
state.status = "skipped";
|
|
1586
|
-
saveCheckpoint(_workflowCwd, buildCp());
|
|
1587
|
-
updateWidgetStep(currentStepIndex, step.label, "skipped");
|
|
1588
|
-
continue;
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
// ── Before executing docWriter, rebuild chain context with only relevant info ──
|
|
1593
|
-
if (step.agentName === "docWriter") {
|
|
1594
|
-
// Clear stale context (review feedback from worker/trimmer loops is noise for docWriter)
|
|
1595
|
-
resetChainContext();
|
|
1596
|
-
// Restore plan location
|
|
1597
|
-
if (planFileRelPathInner) {
|
|
1598
|
-
updateChainContext("实施计划",
|
|
1599
|
-
`计划文件: ${planFileRelPathInner}\n` +
|
|
1600
|
-
`(可在 .pi-dev-output/pi-plans/ 中 grep UUID ${_workflowId} 找到)`
|
|
1601
|
-
);
|
|
1602
|
-
}
|
|
1603
|
-
// Build comprehensive change summary from ALL file changes
|
|
1604
|
-
const workerChanges = _workflowFileChanges
|
|
1605
|
-
.filter(c => c.type !== "read" && c.agent === "worker")
|
|
1606
|
-
.map(c => `${c.type === "new" ? "A" : c.type === "delete" ? "D" : "M"} ${c.filePath}`);
|
|
1607
|
-
const trimmerChanges = _workflowFileChanges
|
|
1608
|
-
.filter(c => c.type !== "read" && c.agent === "trimmer")
|
|
1609
|
-
.map(c => `${c.type === "new" ? "A" : c.type === "delete" ? "D" : "M"} ${c.filePath}`);
|
|
1610
|
-
const allChangeLines: string[] = [];
|
|
1611
|
-
if (workerChanges.length > 0) {
|
|
1612
|
-
allChangeLines.push(`🔧 Worker (代码实施) - ${workerChanges.length} 个文件:`);
|
|
1613
|
-
allChangeLines.push(...workerChanges);
|
|
1614
|
-
}
|
|
1615
|
-
if (trimmerChanges.length > 0) {
|
|
1616
|
-
allChangeLines.push(`✂️ Trimmer (代码精简) - ${trimmerChanges.length} 个文件:`);
|
|
1617
|
-
allChangeLines.push(...trimmerChanges);
|
|
1618
|
-
}
|
|
1619
|
-
if (allChangeLines.length > 0) {
|
|
1620
|
-
updateChainContext("本次工作流全部变更",
|
|
1621
|
-
`以下文件已被本次工作流修改:\n${allChangeLines.join("\n")}`
|
|
1622
|
-
);
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
// ── Execute (timer starts NOW, after all user confirmations) ──
|
|
1627
|
-
state.status = "running";
|
|
1628
|
-
const stepStartTime = Date.now();
|
|
1629
|
-
updateWidgetStep(currentStepIndex, step.label, "running", {
|
|
1630
|
-
timeoutMs: step.type === "loop-group" ? undefined : step.timeoutMs,
|
|
1631
|
-
maxLoops: step.maxLoops,
|
|
1632
|
-
startedAt: stepStartTime,
|
|
1633
|
-
});
|
|
1634
|
-
|
|
1635
|
-
try {
|
|
1636
|
-
if (step.type === "loop-group") {
|
|
1637
|
-
await executeLoopGroup(ctx, step, state, agentMap, prompt, planFileRelPathInner, mode, loopCounts, currentStepIndex);
|
|
1638
|
-
} else {
|
|
1639
|
-
await executeSingleStep(ctx, step, state, agentMap, prompt, planFileRelPathInner, mode, currentStepIndex);
|
|
1640
|
-
if (step.agentName === "planner") {
|
|
1641
|
-
planFileRelPathInner = findLatestPlanFile(_workflowCwd);
|
|
1642
|
-
// Update chain context so subsequent agents know where the plan is
|
|
1643
|
-
if (planFileRelPathInner) {
|
|
1644
|
-
updateChainContext("实施计划",
|
|
1645
|
-
`计划文件: ${planFileRelPathInner}\n` +
|
|
1646
|
-
`(可在 .pi-dev-output/pi-plans/ 中 grep UUID ${_workflowId} 找到)`
|
|
1647
|
-
);
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
state.status = "done";
|
|
1652
|
-
state.durationMs = Date.now() - stepStartTime;
|
|
1653
|
-
updateWidgetStep(currentStepIndex, step.label, "done", {
|
|
1654
|
-
durationMs: state.durationMs,
|
|
1655
|
-
loopCount: state.loopCount,
|
|
1656
|
-
maxLoops: step.maxLoops,
|
|
1657
|
-
timeoutMs: step.type === "loop-group" ? undefined : step.timeoutMs,
|
|
1658
|
-
});
|
|
1659
|
-
} catch (err) {
|
|
1660
|
-
state.status = "failed";
|
|
1661
|
-
state.durationMs = Date.now() - stepStartTime;
|
|
1662
|
-
state.error = err instanceof Error ? err.message : String(err);
|
|
1663
|
-
updateWidgetStep(currentStepIndex, step.label, "failed", {
|
|
1664
|
-
durationMs: state.durationMs,
|
|
1665
|
-
error: state.error,
|
|
1666
|
-
loopCount: state.loopCount,
|
|
1667
|
-
});
|
|
1668
|
-
break;
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
setWidgetCurrentStep(currentStepIndex + 1);
|
|
1672
|
-
saveCheckpoint(_workflowCwd, buildCp());
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
// ── Done ──
|
|
1676
|
-
_workflowRunning = false;
|
|
1677
|
-
|
|
1678
|
-
// Archive checkpoint instead of deleting
|
|
1679
|
-
archiveCheckpointFile(_workflowCwd, planFileRelPathInner);
|
|
1680
|
-
|
|
1681
|
-
// Send persistent result
|
|
1682
|
-
const taskSummary = extractTaskSummary(prompt);
|
|
1683
|
-
const finalState = buildWidgetState(
|
|
1684
|
-
mode,
|
|
1685
|
-
_widgetSteps,
|
|
1686
|
-
steps.length,
|
|
1687
|
-
_widgetStartTime,
|
|
1688
|
-
stepStates.every(s => s.status === "done" || s.status === "skipped") ? "done" : "failed",
|
|
1689
|
-
{ toolCount: _widgetExtraToolCount, tokenCount: _widgetExtraTokenCount },
|
|
1690
|
-
taskSummary,
|
|
1691
|
-
_workflowId,
|
|
1692
|
-
);
|
|
1693
|
-
sendWorkflowResult(pi, finalState, prompt, _workflowType);
|
|
1694
|
-
|
|
1695
|
-
// Cleanup widget after delay
|
|
1696
|
-
if (_cleanupTimer) clearTimeout(_cleanupTimer);
|
|
1697
|
-
_cleanupTimer = setTimeout(() => {
|
|
1698
|
-
_cleanupTimer = null;
|
|
1699
|
-
cleanupWidget();
|
|
1700
|
-
}, 5000);
|
|
1701
|
-
|
|
1702
|
-
function buildCp(): CheckpointData {
|
|
1703
|
-
return {
|
|
1704
|
-
version: 2,
|
|
1705
|
-
workflowId: _workflowId,
|
|
1706
|
-
createdAt: existingCp?.createdAt ?? new Date().toISOString(),
|
|
1707
|
-
updatedAt: new Date().toISOString(),
|
|
1708
|
-
prompt,
|
|
1709
|
-
mode,
|
|
1710
|
-
steps: stepStates,
|
|
1711
|
-
currentStepIndex,
|
|
1712
|
-
loopCounts,
|
|
1713
|
-
planFilePath: planFileRelPathInner,
|
|
1714
|
-
taskSummary: extractTaskSummary(prompt),
|
|
1715
|
-
workflowType: _workflowType,
|
|
1716
|
-
fileChanges: [..._workflowFileChanges],
|
|
1717
|
-
subAgentRuns: _workflowAgentRunHistory.length,
|
|
1718
|
-
filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
|
|
1719
|
-
filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
|
|
1720
|
-
agentRunHistory: [..._workflowAgentRunHistory],
|
|
1721
|
-
baseline: _workflowBaseline,
|
|
1722
|
-
};
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1727
|
-
// Main entry
|
|
1728
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1729
|
-
|
|
1730
|
-
export interface WorkflowConfig {
|
|
1731
|
-
steps: WorkflowStepDef[];
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
/**
|
|
1735
|
-
* Launch a workflow asynchronously.
|
|
1736
|
-
* Sets up the widget and runs steps in background.
|
|
1737
|
-
* Does NOT block - the caller (command handler) returns immediately.
|
|
1738
|
-
*
|
|
1739
|
-
* @param ctx 命令上下文
|
|
1740
|
-
* @param pi Extension API
|
|
1741
|
-
* @param prompt 用户原始 prompt
|
|
1742
|
-
* @param config 工作流步骤配置
|
|
1743
|
-
* @param workflowType 可选的类型标签(feat/fix/doc 等),用于完成消息
|
|
1744
|
-
*/
|
|
1745
|
-
export async function runWorkflow(
|
|
1746
|
-
ctx: ExtensionCommandContext,
|
|
1747
|
-
pi: ExtensionAPI,
|
|
1748
|
-
prompt: string,
|
|
1749
|
-
config: WorkflowConfig,
|
|
1750
|
-
workflowType?: string,
|
|
1751
|
-
): Promise<void> {
|
|
1752
|
-
const { steps } = config;
|
|
1753
|
-
|
|
1754
|
-
// ── Load & validate agents ──
|
|
1755
|
-
const agents = discoverAgents();
|
|
1756
|
-
const agentMap = new Map<string, AgentDef>();
|
|
1757
|
-
for (const a of agents) agentMap.set(a.name, a);
|
|
1758
|
-
|
|
1759
|
-
for (const step of steps) {
|
|
1760
|
-
const names: string[] = [];
|
|
1761
|
-
if (step.type === "auto" || step.type === "confirm") {
|
|
1762
|
-
if (step.agentName) names.push(step.agentName);
|
|
1763
|
-
}
|
|
1764
|
-
if (step.type === "loop-group") {
|
|
1765
|
-
if (step.loopAgentName) names.push(step.loopAgentName);
|
|
1766
|
-
if (step.reviewAgentName) names.push(step.reviewAgentName);
|
|
1767
|
-
}
|
|
1768
|
-
for (const n of names) {
|
|
1769
|
-
if (!agentMap.has(n)) {
|
|
1770
|
-
await uiSelect(ctx, `❌ 未找到 agent "${n}"`, ["确定"]);
|
|
1771
|
-
return;
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
// ── State init / checkpoint recovery ──
|
|
1777
|
-
let mode: WorkflowMode = "attended";
|
|
1778
|
-
const stepStates: WorkflowStepState[] = steps.map(() => ({ status: "pending" }));
|
|
1779
|
-
let currentStepIndex = 0;
|
|
1780
|
-
let loopCounts: Record<string, number> = {};
|
|
1781
|
-
let planFileRelPath: string | undefined;
|
|
1782
|
-
let resumeFlow = false;
|
|
1783
|
-
|
|
1784
|
-
const existingCp = loadCheckpointFromFile(ctx.cwd);
|
|
1785
|
-
if (existingCp) {
|
|
1786
|
-
const resume = await uiConfirm(ctx, "🔄 恢复工作流",
|
|
1787
|
-
`发现上次未完成的工作流(${existingCp.updatedAt}),是否继续?`);
|
|
1788
|
-
if (resume) {
|
|
1789
|
-
mode = existingCp.mode;
|
|
1790
|
-
Object.assign(stepStates, existingCp.steps);
|
|
1791
|
-
currentStepIndex = existingCp.currentStepIndex;
|
|
1792
|
-
loopCounts = existingCp.loopCounts;
|
|
1793
|
-
planFileRelPath = existingCp.planFilePath;
|
|
1794
|
-
resumeFlow = true;
|
|
1795
|
-
} else {
|
|
1796
|
-
archiveCheckpointFile(ctx.cwd); // archive old checkpoint
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
if (!existingCp || !resumeFlow) {
|
|
1801
|
-
const modeChoice = await uiSelect(ctx, "🤖 选择工作流模式", [
|
|
1802
|
-
"1. 值守(默认)— 自动流程,[]步骤需确认,循环需许可",
|
|
1803
|
-
"2. 完全信任 — 全自动运行,无需任何确认",
|
|
1804
|
-
"3. 完全值守 — 每一步都需用户确认",
|
|
1805
|
-
"4. 取消工作流",
|
|
1806
|
-
]);
|
|
1807
|
-
if (!modeChoice || modeChoice.startsWith("4")) return;
|
|
1808
|
-
mode = modeChoice.startsWith("2") ? "full-auto" :
|
|
1809
|
-
modeChoice.startsWith("3") ? "full-attended" : "attended";
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
// Save initial state
|
|
1813
|
-
_workflowCwd = ctx.cwd;
|
|
1814
|
-
_workflowPrompt = prompt;
|
|
1815
|
-
_workflowPlanFileRelPath = planFileRelPath;
|
|
1816
|
-
_workflowLoopCounts = loopCounts;
|
|
1817
|
-
_workflowCreatedAt = existingCp?.createdAt ?? new Date().toISOString();
|
|
1818
|
-
_workflowType = workflowType;
|
|
1819
|
-
_workflowPi = pi;
|
|
1820
|
-
_workflowStepDefs = steps;
|
|
1821
|
-
_workflowFileChanges = existingCp?.fileChanges ? [...existingCp.fileChanges] : [];
|
|
1822
|
-
_workflowAgentRunHistory = existingCp?.agentRunHistory ? [...existingCp.agentRunHistory] : [];
|
|
1823
|
-
|
|
1824
|
-
// Initialize or restore workflow UUID
|
|
1825
|
-
_workflowId = existingCp?.workflowId ?? uuidv7();
|
|
1826
|
-
|
|
1827
|
-
// Reset chain context for new workflow session
|
|
1828
|
-
resetChainContext();
|
|
1829
|
-
|
|
1830
|
-
// ── Baseline snapshot: restore from checkpoint or capture fresh ──
|
|
1831
|
-
if (resumeFlow && existingCp?.baseline) {
|
|
1832
|
-
_workflowBaseline = [...existingCp.baseline];
|
|
1833
|
-
} else if (!resumeFlow) {
|
|
1834
|
-
captureBaseline(ctx.cwd);
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
saveCheckpoint(ctx.cwd, {
|
|
1838
|
-
version: 2,
|
|
1839
|
-
workflowId: _workflowId,
|
|
1840
|
-
createdAt: existingCp?.createdAt ?? new Date().toISOString(),
|
|
1841
|
-
updatedAt: new Date().toISOString(),
|
|
1842
|
-
prompt,
|
|
1843
|
-
mode,
|
|
1844
|
-
steps: stepStates,
|
|
1845
|
-
currentStepIndex,
|
|
1846
|
-
loopCounts,
|
|
1847
|
-
planFilePath: planFileRelPath,
|
|
1848
|
-
taskSummary: extractTaskSummary(prompt),
|
|
1849
|
-
workflowType,
|
|
1850
|
-
fileChanges: [..._workflowFileChanges],
|
|
1851
|
-
subAgentRuns: _workflowAgentRunHistory.length,
|
|
1852
|
-
filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
|
|
1853
|
-
filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
|
|
1854
|
-
agentRunHistory: [..._workflowAgentRunHistory],
|
|
1855
|
-
baseline: _workflowBaseline,
|
|
1856
|
-
});
|
|
1857
|
-
|
|
1858
|
-
// Initialize widget
|
|
1859
|
-
initWidget(ctx, mode, steps.length);
|
|
1860
|
-
for (let i = 0; i < steps.length; i++) {
|
|
1861
|
-
const isDoneState = stepStates[i]?.status === "done";
|
|
1862
|
-
updateWidgetStep(i, steps[i]!.label, isDoneState ? "done" : "pending", {
|
|
1863
|
-
maxLoops: steps[i]!.maxLoops,
|
|
1864
|
-
timeoutMs: steps[i]!.type === "loop-group" ? undefined : steps[i]!.timeoutMs,
|
|
1865
|
-
});
|
|
1866
|
-
// Pre-populate sub-steps for all steps (shows queued agents)
|
|
1867
|
-
populatePredefinedSubSteps(i);
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
// Set up abort controller & cancel callback
|
|
1871
|
-
_workflowAbortController = new AbortController();
|
|
1872
|
-
setWorkflowCancelCallback(() => {
|
|
1873
|
-
_workflowAbortController?.abort();
|
|
1874
|
-
_workflowRunning = false;
|
|
1875
|
-
if (_lastWorkflowCtx) {
|
|
1876
|
-
const finalState = buildWidgetState(
|
|
1877
|
-
_widgetMode,
|
|
1878
|
-
_widgetSteps,
|
|
1879
|
-
_widgetCurrentIdx,
|
|
1880
|
-
_widgetStartTime,
|
|
1881
|
-
"cancelled",
|
|
1882
|
-
undefined,
|
|
1883
|
-
extractTaskSummary(_workflowPrompt),
|
|
1884
|
-
_workflowId,
|
|
1885
|
-
);
|
|
1886
|
-
updateWorkflowWidget(_lastWorkflowCtx, finalState);
|
|
1887
|
-
|
|
1888
|
-
// ── Save final checkpoint before archiving ──
|
|
1889
|
-
const cancelCp: CheckpointData = {
|
|
1890
|
-
version: 2,
|
|
1891
|
-
workflowId: _workflowId,
|
|
1892
|
-
createdAt: _workflowCreatedAt,
|
|
1893
|
-
updatedAt: new Date().toISOString(),
|
|
1894
|
-
prompt: _workflowPrompt,
|
|
1895
|
-
mode: _widgetMode,
|
|
1896
|
-
steps: _widgetSteps.map(s => ({
|
|
1897
|
-
status: s.status as WorkflowStepState["status"],
|
|
1898
|
-
durationMs: s.durationMs,
|
|
1899
|
-
loopCount: s.loopCount,
|
|
1900
|
-
error: s.error,
|
|
1901
|
-
})),
|
|
1902
|
-
currentStepIndex: _widgetCurrentIdx,
|
|
1903
|
-
loopCounts: { ..._workflowLoopCounts },
|
|
1904
|
-
planFilePath: _workflowPlanFileRelPath,
|
|
1905
|
-
taskSummary: extractTaskSummary(_workflowPrompt),
|
|
1906
|
-
workflowType: _workflowType,
|
|
1907
|
-
fileChanges: [..._workflowFileChanges],
|
|
1908
|
-
subAgentRuns: _workflowAgentRunHistory.length,
|
|
1909
|
-
filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
|
|
1910
|
-
filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
|
|
1911
|
-
agentRunHistory: [..._workflowAgentRunHistory],
|
|
1912
|
-
baseline: _workflowBaseline,
|
|
1913
|
-
};
|
|
1914
|
-
saveCheckpoint(_workflowCwd, cancelCp);
|
|
1915
|
-
|
|
1916
|
-
// ── Send workflow result message for persistence ──
|
|
1917
|
-
if (_workflowPi) {
|
|
1918
|
-
sendWorkflowResult(_workflowPi, finalState, _workflowPrompt, _workflowType);
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
// ── Archive checkpoint on cancel too ──
|
|
1922
|
-
archiveCheckpointFile(_workflowCwd, _workflowPlanFileRelPath);
|
|
1923
|
-
if (_cleanupTimer) clearTimeout(_cleanupTimer);
|
|
1924
|
-
_cleanupTimer = setTimeout(() => {
|
|
1925
|
-
_cleanupTimer = null;
|
|
1926
|
-
cleanupWidget();
|
|
1927
|
-
}, 5000);
|
|
1928
|
-
}
|
|
1929
|
-
});
|
|
1930
|
-
|
|
1931
|
-
// Collapse tools to show widget
|
|
1932
|
-
ctx.ui.setToolsExpanded(false);
|
|
1933
|
-
|
|
1934
|
-
// ── Register terminal input handler (Esc to cancel, with double-press confirmation) ──
|
|
1935
|
-
if (ctx.hasUI) {
|
|
1936
|
-
let _lastEscPressTime = 0;
|
|
1937
|
-
_terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
1938
|
-
if (!matchesKey(data, Key.escape)) return undefined;
|
|
1939
|
-
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
1940
|
-
const now = Date.now();
|
|
1941
|
-
if (_lastEscPressTime > 0 && now - _lastEscPressTime < 3000) {
|
|
1942
|
-
// Second Esc press within 3s → confirm cancel
|
|
1943
|
-
ctx.ui.notify("⏹️ 正在停止工作流...", "warning");
|
|
1944
|
-
cancelWorkflow();
|
|
1945
|
-
_lastEscPressTime = 0;
|
|
1946
|
-
return { consume: true };
|
|
1947
|
-
}
|
|
1948
|
-
// First Esc press (or expired) → show hint
|
|
1949
|
-
_lastEscPressTime = now;
|
|
1950
|
-
ctx.ui.notify("再次按下 Esc 键,停止 Workflow - 3秒内按下有效", "warning");
|
|
1951
|
-
return { consume: true };
|
|
1952
|
-
}
|
|
1953
|
-
return undefined;
|
|
1954
|
-
});
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
// ── Register signal handlers (SIGINT/SIGTERM) for graceful shutdown ──
|
|
1958
|
-
registerSignalHandlers();
|
|
1959
|
-
|
|
1960
|
-
// ── Launch background execution (fire-and-forget) ──
|
|
1961
|
-
executeWorkflowBackground(
|
|
1962
|
-
ctx, pi, prompt, steps, agentMap, mode, stepStates,
|
|
1963
|
-
currentStepIndex, loopCounts, planFileRelPath,
|
|
1964
|
-
existingCp ?? undefined,
|
|
1965
|
-
).catch((err) => {
|
|
1966
|
-
console.error("[workflow] Background execution error:", err);
|
|
1967
|
-
_workflowRunning = false;
|
|
1968
|
-
if (_lastWorkflowCtx) {
|
|
1969
|
-
updateWorkflowWidget(_lastWorkflowCtx, null);
|
|
1970
|
-
}
|
|
1971
|
-
// Clean up terminal input listener and signal handlers
|
|
1972
|
-
if (_terminalInputUnsubscribe) {
|
|
1973
|
-
_terminalInputUnsubscribe();
|
|
1974
|
-
_terminalInputUnsubscribe = null;
|
|
1975
|
-
}
|
|
1976
|
-
cleanupSignalHandlers();
|
|
1977
|
-
});
|
|
1978
|
-
|
|
1979
|
-
// Return immediately - execution continues in background
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1983
|
-
// Extension factory (no-op — imported by dev-prompts.ts)
|
|
1984
|
-
// ═══════════════════════════════════════════════════════════════
|
|
1985
|
-
|
|
1986
|
-
/**
|
|
1987
|
-
* Check if a workflow is currently running.
|
|
1988
|
-
*/
|
|
1989
|
-
export function isWorkflowRunning(): boolean {
|
|
1990
|
-
return _workflowRunning;
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
/**
|
|
1994
|
-
* Cancel the active workflow, if any.
|
|
1995
|
-
* Safe to call even when no workflow is running (no-op in that case).
|
|
1996
|
-
*/
|
|
1997
|
-
export function cancelActiveWorkflow(): void {
|
|
1998
|
-
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
1999
|
-
cancelWorkflow();
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
export default function (_pi: ExtensionAPI) {
|
|
2004
|
-
// workflow-engine is a helper module, not a standalone extension.
|
|
2005
|
-
}
|