@ghyper9023/pi-dev-workflow 0.3.3 → 0.4.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.
@@ -0,0 +1,1715 @@
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 type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
24
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
25
+ import { execSync } from "child_process";
26
+ import { spawnSubagent, extractFinalOutput, discoverAgents, type AgentDef, type SubagentResult } from "./sub-agents";
27
+ import {
28
+ uiSelect,
29
+ uiConfirm,
30
+ uiInput,
31
+ updateWorkflowWidget,
32
+ buildWidgetState,
33
+ sendWorkflowResult,
34
+ setWorkflowCancelCallback,
35
+ cancelWorkflow,
36
+ BACK_MARKER,
37
+ BACK_OPTION_TEXT,
38
+ type WorkflowStepWidgetState,
39
+ type WorkflowSubStepWidgetState,
40
+ type WorkflowWidgetState,
41
+ } from "./ui-helpers";
42
+
43
+ // ═══════════════════════════════════════════════════════════════
44
+ // Types
45
+ // ═══════════════════════════════════════════════════════════════
46
+
47
+ export type WorkflowMode = "attended" | "full-auto" | "full-attended";
48
+
49
+ export interface WorkflowStepDef {
50
+ id: string;
51
+ label: string;
52
+ type: "auto" | "confirm" | "loop-group";
53
+ agentName?: string;
54
+ loopAgentName?: string;
55
+ reviewAgentName?: string;
56
+ maxLoops?: number;
57
+ timeoutMs: number;
58
+ }
59
+
60
+ interface WorkflowStepState {
61
+ status: "pending" | "running" | "done" | "failed" | "skipped";
62
+ durationMs?: number;
63
+ loopCount?: number;
64
+ error?: string;
65
+ }
66
+
67
+ interface FileChangeEntry {
68
+ agent: string;
69
+ stepIndex: number;
70
+ type: "edit" | "new" | "delete" | "read";
71
+ filePath: string;
72
+ timestamp: string;
73
+ }
74
+
75
+ interface AgentRunEntry {
76
+ agent: string;
77
+ stepIndex: number;
78
+ startedAt: string;
79
+ durationMs: number;
80
+ exitCode: number;
81
+ toolCount: number;
82
+ }
83
+
84
+ /**
85
+ * Baseline entry: records the git object hash of a file at workflow start.
86
+ * Used to distinguish pre-existing dirty files from workflow-generated changes.
87
+ */
88
+ interface BaselineEntry {
89
+ path: string;
90
+ hash: string;
91
+ }
92
+
93
+ interface CheckpointData {
94
+ version: 2;
95
+ createdAt: string;
96
+ updatedAt: string;
97
+ prompt: string;
98
+ mode: WorkflowMode;
99
+ steps: WorkflowStepState[];
100
+ currentStepIndex: number;
101
+ loopCounts: Record<string, number>;
102
+ planFilePath?: string;
103
+ // New fields for better UI and traceability
104
+ taskSummary?: string;
105
+ workflowType?: string;
106
+ fileChanges?: FileChangeEntry[];
107
+ subAgentRuns?: number;
108
+ filesModified?: number;
109
+ filesCreated?: number;
110
+ agentRunHistory?: AgentRunEntry[];
111
+ /** Baseline snapshot taken at workflow start for accurate change tracking. */
112
+ baseline?: BaselineEntry[];
113
+ }
114
+
115
+ // ═══════════════════════════════════════════════════════════════
116
+ // Constants
117
+ // ═══════════════════════════════════════════════════════════════
118
+
119
+ const DEV_OUTPUT_DIR = ".pi-dev-output";
120
+ const CHECKPOINT_FILE = path.join(DEV_OUTPUT_DIR, "pi-workflow", "checkpoint.json");
121
+ const PLANS_DIR = path.join(DEV_OUTPUT_DIR, "pi-plans");
122
+
123
+ // ═══════════════════════════════════════════════════════════════
124
+ // Utility Helpers
125
+ // ═══════════════════════════════════════════════════════════════
126
+
127
+ function ensureOutputDir(cwd: string, subdir: string): string {
128
+ const dir = path.join(cwd, DEV_OUTPUT_DIR, subdir);
129
+ fs.mkdirSync(dir, { recursive: true });
130
+ return dir;
131
+ }
132
+
133
+ function findLatestPlanFile(cwd: string): string | undefined {
134
+ const dir = path.join(cwd, PLANS_DIR);
135
+ try {
136
+ if (!fs.existsSync(dir)) return undefined;
137
+ const files = fs.readdirSync(dir)
138
+ .filter(f => f.endsWith(".md"))
139
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
140
+ .sort((a, b) => b.mtime - a.mtime);
141
+ return files.length > 0 ? path.join(PLANS_DIR, files[0].name) : undefined;
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }
146
+
147
+ function readFileContent(cwd: string, relativePath: string): string | undefined {
148
+ try {
149
+ return fs.readFileSync(path.join(cwd, relativePath), "utf-8");
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ export function parseReviewerOutput(
156
+ output: string,
157
+ ): { maxSeverity: string; critical: number; medium: number; low: number } | null {
158
+ const match = output.match(/\[REVIEW_SUMMARY\]\s*(\{[\s\S]*?\})\s*\[\/REVIEW_SUMMARY\]/);
159
+ if (match) {
160
+ try {
161
+ const parsed = JSON.parse(match[1]);
162
+ if (parsed && typeof parsed.maxSeverity === "string") return parsed;
163
+ } catch { /* fallthrough */ }
164
+ }
165
+ const fallback = output.match(/\{"maxSeverity":\s*"(critical|medium|low)"[\s\S]*?\}/);
166
+ if (fallback) {
167
+ try {
168
+ const parsed = JSON.parse(fallback[0]);
169
+ if (parsed && typeof parsed.maxSeverity === "string") return parsed;
170
+ } catch { /* fallthrough */ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ export function extractSeverityFromText(
176
+ text: string,
177
+ ): { maxSeverity: string; critical: number; medium: number; low: number } | null {
178
+ const headerCritical = [...text.matchAll(/^###\s+C\d+\./gm)].length;
179
+ const headerMedium = [...text.matchAll(/^###\s+M\d+\./gm)].length;
180
+ const headerLow = [...text.matchAll(/^###\s+L\d+\./gm)].length;
181
+ if (headerCritical + headerMedium + headerLow > 0) {
182
+ return {
183
+ maxSeverity: headerCritical > 0 ? "critical" : headerMedium > 0 ? "medium" : "low",
184
+ critical: headerCritical,
185
+ medium: headerMedium,
186
+ low: headerLow,
187
+ };
188
+ }
189
+ const tableCritical = [...text.matchAll(/^\|\s*\w+\s*\|\s*critical/gim)].length;
190
+ const tableMedium = [...text.matchAll(/^\|\s*\w+\s*\|\s*medium/gim)].length;
191
+ const tableLow = [...text.matchAll(/^\|\s*\w+\s*\|\s*low/gim)].length;
192
+ if (tableCritical + tableMedium + tableLow > 0) {
193
+ return {
194
+ maxSeverity: tableCritical > 0 ? "critical" : tableMedium > 0 ? "medium" : "low",
195
+ critical: tableCritical,
196
+ medium: tableMedium,
197
+ low: tableLow,
198
+ };
199
+ }
200
+ const labelCritical = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*critical/gi)].length;
201
+ const labelMedium = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*medium/gi)].length;
202
+ const labelLow = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*low/gi)].length;
203
+ if (labelCritical + labelMedium + labelLow > 0) {
204
+ return {
205
+ maxSeverity: labelCritical > 0 ? "critical" : labelMedium > 0 ? "medium" : "low",
206
+ critical: labelCritical,
207
+ medium: labelMedium,
208
+ low: labelLow,
209
+ };
210
+ }
211
+ return null;
212
+ }
213
+
214
+ export function readLatestReviewMd(cwd: string): string | null {
215
+ const reviewDir = path.join(cwd, DEV_OUTPUT_DIR, "pi-review", "md");
216
+ try {
217
+ if (!fs.existsSync(reviewDir)) return null;
218
+ const files = fs.readdirSync(reviewDir)
219
+ .filter(f => f.endsWith(".md"))
220
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(reviewDir, f)).mtimeMs }))
221
+ .sort((a, b) => b.mtime - a.mtime);
222
+ if (files.length === 0) return null;
223
+ return fs.readFileSync(path.join(reviewDir, files[0].name), "utf-8");
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ export function isTimeoutResult(r: SubagentResult): boolean {
230
+ return r.exitCode === -1 && r.stderr.includes("timed out");
231
+ }
232
+
233
+ /**
234
+ * Extract a human-readable task summary from the prompt.
235
+ */
236
+ export function extractTaskSummary(prompt: string): string {
237
+ const firstLine = prompt.split("\n").find(l => l.trim()) ?? "";
238
+ const tagMatch = firstLine.match(/^\[([^\]]+)\]\s*(.+)/);
239
+ if (tagMatch) {
240
+ const tag = tagMatch[1]!.trim();
241
+ const rest = tagMatch[2]!.trim();
242
+ // If the rest looks like placeholder dots, try to find a better summary
243
+ if (rest.replace(/\.\.\./g, "").trim() === "" || rest === "...") {
244
+ const lines = prompt.split("\n").filter(l => l.trim());
245
+ if (lines.length > 1) {
246
+ const secondLine = lines[1]!.replace(/^[*\s#]+/, "").trim();
247
+ if (secondLine && !secondLine.startsWith("**")) {
248
+ return `${tag} - ${secondLine.substring(0, 60)}`;
249
+ }
250
+ }
251
+ for (const line of lines.slice(1, 5)) {
252
+ const cleaned = line.replace(/^[*\s#]+/, "").trim();
253
+ if (cleaned && cleaned.length > 5 && !cleaned.startsWith("**") && !cleaned.startsWith("`")) {
254
+ const summary = cleaned.length > 50 ? cleaned.substring(0, 47) + "..." : cleaned;
255
+ return `${tag} - ${summary}`;
256
+ }
257
+ }
258
+ return `${tag} - 工作流任务`;
259
+ }
260
+ return `${tag} - ${rest}`;
261
+ }
262
+ const cleaned = firstLine.replace(/^[*\s#]+/, "").trim();
263
+ return cleaned.length > 60 ? cleaned.substring(0, 57) + "..." : cleaned || "工作流任务";
264
+ }
265
+
266
+
267
+ // ── Git diff-based file change detection ──────────────────────
268
+
269
+ /**
270
+ * TypeScript struct for a parsed git file change entry.
271
+ * `status` uses git's canonical single-letter codes: M (modify), A (add), D (delete).
272
+ */
273
+ interface GitFileChange {
274
+ status: "M" | "A" | "D";
275
+ path: string;
276
+ }
277
+
278
+ /**
279
+ * Run `git diff --name-status` to detect file changes against HEAD.
280
+ * This is far more reliable than scraping AI text output for file paths.
281
+ *
282
+ * Uses `git diff --name-status HEAD` for modified/deleted files, and
283
+ * `git status --porcelain` to catch untracked (new) files.
284
+ *
285
+ * Returns an array of { status, path } where status is "M"/"A"/"D" matching
286
+ * git's own format, ready for direct display in the widget.
287
+ */
288
+ function getGitDiffChanges(cwd: string): GitFileChange[] {
289
+ const changes: GitFileChange[] = [];
290
+ const seen = new Set<string>();
291
+
292
+ try {
293
+ // 1. `git diff --name-status` — shows modified (M) and deleted (D) vs HEAD
294
+ const diffOutput = execSync("git diff --name-status", { cwd, encoding: "utf8", timeout: 5000 }).trim();
295
+ if (diffOutput) {
296
+ for (const line of diffOutput.split("\n")) {
297
+ const trimmed = line.trim();
298
+ if (!trimmed) continue;
299
+ // Format: "M\tpath/to/file" or "D\tpath/to/file"
300
+ const match = trimmed.match(/^([MAD])\s+(.+)$/);
301
+ if (match) {
302
+ const status = match[1]! as "M" | "A" | "D";
303
+ const path = match[2]!.trim();
304
+ if (path && !seen.has(path)) {
305
+ seen.add(path);
306
+ changes.push({ status, path });
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ // 2. `git status --porcelain` — find untracked files (??) missing from git diff
313
+ const statusOutput = execSync("git status --porcelain", { cwd, encoding: "utf8", timeout: 5000 }).trim();
314
+ if (statusOutput) {
315
+ for (const line of statusOutput.split("\n")) {
316
+ const trimmed = line.trim();
317
+ if (!trimmed) continue;
318
+ // "?? path" means untracked/new
319
+ // Also catch "A path" for staged new files
320
+ const match = trimmed.match(/^(\?\?|A\s)\s+(.+)$/);
321
+ if (match) {
322
+ const path = match[2]!.trim();
323
+ if (path && !seen.has(path)) {
324
+ seen.add(path);
325
+ changes.push({ status: "A", path });
326
+ }
327
+ }
328
+ }
329
+ }
330
+ } catch {
331
+ // Git not available or not a repo — silently skip
332
+ }
333
+
334
+ return changes;
335
+ }
336
+
337
+ /**
338
+ * Convert an internal tool type ("edit"/"new"/"delete") to a git status letter ("M"/"A"/"D").
339
+ * Used to unify all tool entries to git-format display: "M path", "A path", "D path".
340
+ */
341
+ function toGitStatus(toolType: string): string {
342
+ switch (toolType.toLowerCase()) {
343
+ case "new": case "write": case "create": case "add": case "created": case "added":
344
+ return "A";
345
+ case "delete": case "remove": case "deleted": case "removed":
346
+ return "D";
347
+ default:
348
+ return "M";
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Check whether a file's current content hash differs from its baseline.
354
+ * Returns true if the hash changed or the file is no longer accessible.
355
+ */
356
+ function hasContentChanged(cwd: string, path: string, baselineHash: string): boolean {
357
+ try {
358
+ const currentHash = execSync(`git hash-object "${path}"`, { cwd, encoding: "utf8", timeout: 3000 }).trim();
359
+ return currentHash !== baselineHash;
360
+ } catch {
361
+ // file deleted or inaccessible — consider changed
362
+ return true;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Capture baseline snapshot of all dirty files at workflow start.
368
+ * Records git object hashes so we can later distinguish pre-existing
369
+ * changes from workflow-generated ones.
370
+ */
371
+ function captureBaseline(cwd: string): void {
372
+ _workflowBaseline = [];
373
+ try {
374
+ const changes = getGitDiffChanges(cwd);
375
+ for (const change of changes) {
376
+ let hash = "";
377
+ try {
378
+ hash = execSync(`git hash-object "${change.path}"`, { cwd, encoding: "utf8", timeout: 3000 }).trim();
379
+ } catch {
380
+ // file might not exist (deleted in diff)
381
+ }
382
+ _workflowBaseline.push({ path: change.path, hash });
383
+ }
384
+ } catch {
385
+ // git not available or not a repo
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Update widget tool list from git diff changes, filtered against the baseline.
391
+ * Only reports changes that are genuinely new or modified BY the workflow,
392
+ * not pre-existing dirty files that the workflow didn't touch.
393
+ *
394
+ * Uses git-format status codes (M, A, D) for display consistency.
395
+ * Deduplicates against existing _workflowFileChanges.
396
+ */
397
+ function updateToolsFromGit(cwd: string, stepIndex: number, agentName: string): void {
398
+ const currentChanges = getGitDiffChanges(cwd);
399
+ const seen = new Set(_workflowFileChanges.map(c => c.filePath));
400
+
401
+ for (const change of currentChanges) {
402
+ if (seen.has(change.path)) continue;
403
+
404
+ // ── Baseline filtering ──────────────────────────────
405
+ // Skip files that were already dirty at workflow start and haven't been touched.
406
+ if (_workflowBaseline.length > 0) {
407
+ const baselineEntry = _workflowBaseline.find(b => b.path === change.path);
408
+ if (baselineEntry) {
409
+ if (!hasContentChanged(cwd, change.path, baselineEntry.hash)) {
410
+ // Pre-existing dirty file, content unchanged — workflow didn't touch it
411
+ continue;
412
+ }
413
+ // Content changed → workflow modified it, report as new change
414
+ // (Remove old baseline entry so next git diff sees it as workflow-owned)
415
+ _workflowBaseline = _workflowBaseline.filter(b => b.path !== change.path);
416
+ }
417
+ }
418
+
419
+ seen.add(change.path);
420
+ const type: FileChangeEntry["type"] =
421
+ change.status === "A" ? "new" :
422
+ change.status === "D" ? "delete" :
423
+ "edit";
424
+ _workflowFileChanges.push({
425
+ agent: agentName,
426
+ stepIndex,
427
+ type,
428
+ filePath: change.path,
429
+ timestamp: new Date().toISOString(),
430
+ });
431
+ // Use git-style format for display: "M path", "A path", "D path"
432
+ addWidgetSubStepTool(stepIndex, agentName, `${change.status} ${change.path}`);
433
+ _widgetExtraToolCount++;
434
+ }
435
+ }
436
+ // ═══════════════════════════════════════════════════════════════
437
+ // Checkpoint
438
+ // ═══════════════════════════════════════════════════════════════
439
+
440
+ function saveCheckpoint(cwd: string, data: CheckpointData): void {
441
+ ensureOutputDir(cwd, "pi-workflow");
442
+ data.updatedAt = new Date().toISOString();
443
+ // Always version 2
444
+ (data as CheckpointData).version = 2;
445
+ // Enrich with file changes and agent history from module state
446
+ if (_workflowFileChanges.length > 0 && !data.fileChanges) {
447
+ data.fileChanges = [..._workflowFileChanges];
448
+ }
449
+ if (_workflowAgentRunHistory.length > 0 && !data.agentRunHistory) {
450
+ data.agentRunHistory = [..._workflowAgentRunHistory];
451
+ }
452
+ fs.writeFileSync(path.join(cwd, CHECKPOINT_FILE), JSON.stringify(data, null, 2), "utf-8");
453
+ }
454
+
455
+ export function loadCheckpointFromFile(cwd: string): CheckpointData | null {
456
+ try {
457
+ const content = fs.readFileSync(path.join(cwd, CHECKPOINT_FILE), "utf-8");
458
+ const data = JSON.parse(content) as CheckpointData;
459
+ // Backfill missing fields for v1 checkpoints
460
+ if (!data.version || data.version < 2) {
461
+ data.version = 2;
462
+ data.fileChanges = data.fileChanges ?? [];
463
+ data.agentRunHistory = data.agentRunHistory ?? [];
464
+ }
465
+ return data;
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Archive checkpoint after completion: rename to checkpoint-<plan-id>.json
473
+ */
474
+ export function archiveCheckpointFile(cwd: string, planFileRelPath?: string): void {
475
+ try {
476
+ const cpPath = path.join(cwd, CHECKPOINT_FILE);
477
+ if (!fs.existsSync(cpPath)) return;
478
+ const planId = planFileRelPath
479
+ ? path.basename(planFileRelPath, ".md").replace(/[^a-zA-Z0-9_-]/g, "_")
480
+ : `archive-${Date.now().toString(36)}`;
481
+ const archiveName = `checkpoint-${planId}.json`;
482
+ const archiveDir = path.join(cwd, DEV_OUTPUT_DIR, "pi-workflow");
483
+ fs.renameSync(cpPath, path.join(archiveDir, archiveName));
484
+ } catch { /* ignore */ }
485
+ }
486
+
487
+ // ═══════════════════════════════════════════════════════════════
488
+ // Task builders
489
+ // ═══════════════════════════════════════════════════════════════
490
+
491
+ function buildTaskForStep(
492
+ agentName: string,
493
+ prompt: string,
494
+ planFileRelPath: string | undefined,
495
+ cwd: string,
496
+ ): string {
497
+ if (agentName === "planner") {
498
+ return [
499
+ "请根据以下功能需求,分析代码库结构,生成详细的实施计划,并写入 .pi-dev-output/pi-plans/ 目录。",
500
+ "",
501
+ "## 功能需求",
502
+ prompt,
503
+ ].join("\n");
504
+ }
505
+ if (agentName === "worker") {
506
+ const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined;
507
+ if (planContent) {
508
+ return [
509
+ "请根据以下实施计划逐步实现代码改动。",
510
+ "",
511
+ "## 实施计划",
512
+ planContent,
513
+ "",
514
+ "请严格按照计划中的步骤实施,不要做计划外的修改。",
515
+ ].join("\n");
516
+ }
517
+ return [
518
+ "请根据以下功能需求实施代码改动。",
519
+ "",
520
+ "## 功能需求",
521
+ prompt,
522
+ "",
523
+ "请先分析代码库,制定简要计划,再逐步实施。",
524
+ ].join("\n");
525
+ }
526
+ if (agentName === "trimmer") {
527
+ return [
528
+ "请精简当前代码库的代码。",
529
+ "缩短不必要的冗长行,优化可读性,消除可合并的重复逻辑。",
530
+ "",
531
+ "## 原始功能需求",
532
+ prompt,
533
+ ].join("\n");
534
+ }
535
+ if (agentName === "docWriter") {
536
+ const planContent = planFileRelPath
537
+ ? `\n\n## 实施计划\n${readFileContent(cwd, planFileRelPath) ?? ""}`
538
+ : "";
539
+ return [
540
+ "请根据当前代码状态,更新 README.md 文档,必要时添加关键代码注释。",
541
+ "",
542
+ "## 功能需求",
543
+ prompt,
544
+ planContent,
545
+ ].join("\n");
546
+ }
547
+ return prompt;
548
+ }
549
+
550
+ function buildReviewTask(
551
+ prompt: string,
552
+ planFileRelPath: string | undefined,
553
+ cwd: string,
554
+ ): string {
555
+ const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined;
556
+ const parts = [
557
+ "请审查当前代码库中针对以下功能的实现。",
558
+ "检查是否有 bug、逻辑错误、未完成的功能、代码质量问题。",
559
+ "将详细审查报告写入 .pi-dev-output/pi-review/md/ 目录。",
560
+ "在回复末尾输出以下格式的结构化摘要(必须包含):",
561
+ "[REVIEW_SUMMARY]",
562
+ '{"maxSeverity":"critical|medium|low","critical":N,"medium":N,"low":N}',
563
+ "[/REVIEW_SUMMARY]",
564
+ "",
565
+ "## 功能需求",
566
+ prompt,
567
+ ];
568
+ if (planContent) parts.push("", "## 实施计划", planContent);
569
+ return parts.join("\n");
570
+ }
571
+
572
+ // ═══════════════════════════════════════════════════════════════
573
+ // Global state for async execution
574
+ // ═══════════════════════════════════════════════════════════════
575
+
576
+ interface StepRuntimeInfo {
577
+ widgetStep: WorkflowStepWidgetState;
578
+ state: WorkflowStepState;
579
+ }
580
+
581
+ let _workflowAbortController: AbortController | null = null;
582
+ let _workflowPi: ExtensionAPI | null = null;
583
+ let _workflowType: string | undefined;
584
+ let _workflowCwd = "";
585
+ let _workflowPrompt = "";
586
+ let _workflowPlanFileRelPath: string | undefined;
587
+ /** Track loop counts at module level so cancel callback can save a proper checkpoint. */
588
+ let _workflowLoopCounts: Record<string, number> = {};
589
+ /** Original checkpoint creation timestamp, for preserving across cancel. */
590
+ let _workflowCreatedAt: string = new Date().toISOString();
591
+ /** Track file changes globally for checkpoint persistence */
592
+ let _workflowFileChanges: FileChangeEntry[] = [];
593
+ /** Track agent run history */
594
+ let _workflowAgentRunHistory: AgentRunEntry[] = [];
595
+ /** Store step defs for pre-populating sub-steps */
596
+ let _workflowStepDefs: WorkflowStepDef[] = [];
597
+ /** Baseline snapshot: git hashes of dirty files at workflow start */
598
+ let _workflowBaseline: BaselineEntry[] = [];
599
+
600
+ let _widgetMode: WorkflowMode = "attended";
601
+ let _widgetSteps: WorkflowStepWidgetState[] = [];
602
+ let _widgetCurrentIdx = 0;
603
+ let _widgetStartTime = 0;
604
+ let _widgetExtraToolCount = 0;
605
+ let _widgetExtraTokenCount = 0;
606
+ let _workflowRunning = false;
607
+
608
+ function refreshWidget(): void {
609
+ if (!_lastWorkflowCtx) return;
610
+ const taskSummary = extractTaskSummary(_workflowPrompt);
611
+ const widgetState = buildWidgetState(
612
+ _widgetMode,
613
+ _widgetSteps,
614
+ _widgetCurrentIdx,
615
+ _widgetStartTime,
616
+ _workflowRunning ? "running" :
617
+ _widgetSteps.some(s => s.status === "failed") ? "failed" :
618
+ _widgetSteps.every(s => s.status === "done" || s.status === "skipped") ? "done" :
619
+ "running",
620
+ { toolCount: _widgetExtraToolCount, tokenCount: _widgetExtraTokenCount },
621
+ taskSummary,
622
+ );
623
+ updateWorkflowWidget(_lastWorkflowCtx, widgetState);
624
+ }
625
+
626
+ let _lastWorkflowCtx: ExtensionCommandContext | null = null;
627
+
628
+ function initWidget(ctx: ExtensionCommandContext, mode: WorkflowMode, stepsCount: number): void {
629
+ _widgetMode = mode;
630
+ _widgetSteps = [];
631
+ for (let i = 0; i < stepsCount; i++) {
632
+ _widgetSteps.push({ label: "", status: "pending" });
633
+ }
634
+ _widgetCurrentIdx = 0;
635
+ _widgetStartTime = Date.now();
636
+ _widgetExtraToolCount = 0;
637
+ _widgetExtraTokenCount = 0;
638
+ _lastWorkflowCtx = ctx;
639
+ _workflowRunning = true;
640
+ refreshWidget();
641
+ }
642
+
643
+ function updateWidgetStep(
644
+ index: number,
645
+ label: string,
646
+ status: WorkflowStepWidgetState["status"],
647
+ extra?: {
648
+ durationMs?: number;
649
+ loopCount?: number;
650
+ maxLoops?: number;
651
+ timeoutMs?: number;
652
+ error?: string;
653
+ subSteps?: WorkflowSubStepWidgetState[];
654
+ startedAt?: number;
655
+ },
656
+ ): void {
657
+ if (index < _widgetSteps.length) {
658
+ const existing = _widgetSteps[index];
659
+ _widgetSteps[index] = {
660
+ ...existing,
661
+ label,
662
+ status,
663
+ ...extra,
664
+ };
665
+ }
666
+ refreshWidget();
667
+ }
668
+
669
+ function populatePredefinedSubSteps(stepIndex: number): void {
670
+ const step = _widgetSteps[stepIndex];
671
+ if (!step || !_workflowStepDefs[stepIndex]) return;
672
+ if (step.subSteps && step.subSteps.length > 0) return; // already populated
673
+
674
+ const def = _workflowStepDefs[stepIndex]!;
675
+ const newSubSteps: WorkflowSubStepWidgetState[] = [];
676
+
677
+ if (def.type === "loop-group") {
678
+ if (def.loopAgentName) {
679
+ newSubSteps.push({
680
+ agent: def.loopAgentName,
681
+ status: "pending",
682
+ tools: [],
683
+ outputs: [],
684
+ });
685
+ }
686
+ if (def.reviewAgentName) {
687
+ newSubSteps.push({
688
+ agent: def.reviewAgentName,
689
+ status: "pending",
690
+ tools: [],
691
+ outputs: [],
692
+ });
693
+ }
694
+ } else if (def.agentName) {
695
+ newSubSteps.push({
696
+ agent: def.agentName,
697
+ status: "pending",
698
+ tools: [],
699
+ outputs: [],
700
+ });
701
+ }
702
+
703
+ if (newSubSteps.length > 0) {
704
+ step.subSteps = newSubSteps;
705
+ refreshWidget();
706
+ }
707
+ }
708
+
709
+ function addWidgetSubStepTool(stepIndex: number, agentName: string, tool: string): void {
710
+ const step = _widgetSteps[stepIndex];
711
+ if (!step) return;
712
+ const sub = step.subSteps?.find(s => s.agent === agentName);
713
+ if (sub) {
714
+ if (!sub.tools) sub.tools = [];
715
+ sub.tools.push(tool);
716
+ if (sub.tools.length > 20) sub.tools = sub.tools.slice(-20); // keep last 20
717
+
718
+ // Also track as file change for checkpoint
719
+ // Support both old format ("edit: path") and new git-format ("M path", "A path", "D path")
720
+ const oldMatch = tool.match(/^(edit|new|delete|read):\s*(.+)/i);
721
+ const gitMatch = !oldMatch ? tool.match(/^([MAD])\s{2,}(.+)$/) : null;
722
+ if (oldMatch) {
723
+ const changeType = oldMatch[1]!.toLowerCase() as FileChangeEntry["type"];
724
+ const filePath = oldMatch[2]!.trim();
725
+ const exists = _workflowFileChanges.some(
726
+ c => c.filePath === filePath && c.type === changeType && c.stepIndex === stepIndex && c.agent === agentName,
727
+ );
728
+ if (!exists && filePath.length > 3) {
729
+ _workflowFileChanges.push({
730
+ agent: agentName,
731
+ stepIndex,
732
+ type: changeType,
733
+ filePath,
734
+ timestamp: new Date().toISOString(),
735
+ });
736
+ }
737
+ } else if (gitMatch) {
738
+ const gitStatus = gitMatch[1]!;
739
+ const changeType: FileChangeEntry["type"] =
740
+ gitStatus === "A" ? "new" :
741
+ gitStatus === "D" ? "delete" :
742
+ "edit";
743
+ const filePath = gitMatch[2]!.trim();
744
+ const exists = _workflowFileChanges.some(
745
+ c => c.filePath === filePath && c.type === changeType && c.stepIndex === stepIndex && c.agent === agentName,
746
+ );
747
+ if (!exists && filePath.length > 3) {
748
+ _workflowFileChanges.push({
749
+ agent: agentName,
750
+ stepIndex,
751
+ type: changeType,
752
+ filePath,
753
+ timestamp: new Date().toISOString(),
754
+ });
755
+ }
756
+ }
757
+
758
+ refreshWidget();
759
+ }
760
+ }
761
+
762
+ function addWidgetSubStepOutput(stepIndex: number, agentName: string, output: string): void {
763
+ const step = _widgetSteps[stepIndex];
764
+ if (!step) return;
765
+ const sub = step.subSteps?.find(s => s.agent === agentName);
766
+ if (sub) {
767
+ if (!sub.outputs) sub.outputs = [];
768
+ if (!sub.outputs.includes(output)) {
769
+ sub.outputs.push(output);
770
+ }
771
+ refreshWidget();
772
+ }
773
+ }
774
+
775
+ function setWidgetSubStepStatus(stepIndex: number, agentName: string, status: WorkflowSubStepWidgetState["status"]): void {
776
+ const step = _widgetSteps[stepIndex];
777
+ if (!step) return;
778
+ const sub = step.subSteps?.find(s => s.agent === agentName);
779
+ if (sub) {
780
+ sub.status = status;
781
+ refreshWidget();
782
+ }
783
+ }
784
+
785
+ function setWidgetCurrentStep(index: number): void {
786
+ _widgetCurrentIdx = index;
787
+ refreshWidget();
788
+ }
789
+
790
+ function cleanupWidget(): void {
791
+ _workflowRunning = false;
792
+ if (_lastWorkflowCtx) {
793
+ updateWorkflowWidget(_lastWorkflowCtx, null);
794
+ _lastWorkflowCtx = null;
795
+ }
796
+ _workflowAbortController = null;
797
+ setWorkflowCancelCallback(null);
798
+ // Clean up terminal input listener (Esc)
799
+ if (_terminalInputUnsubscribe) {
800
+ _terminalInputUnsubscribe();
801
+ _terminalInputUnsubscribe = null;
802
+ }
803
+ // Clean up signal handlers
804
+ cleanupSignalHandlers();
805
+ }
806
+
807
+ /** Unsubscribe function for terminal input listener (Esc to cancel) */
808
+ let _terminalInputUnsubscribe: (() => void) | null = null;
809
+
810
+ // ── Signal handling (SIGINT/SIGTERM) for graceful workflow cancellation ──
811
+
812
+ let _signalHandlersRegistered = false;
813
+
814
+ function cleanupSignalHandlers(): void {
815
+ if (!_signalHandlersRegistered) return;
816
+ try { process.removeListener("SIGINT", onSigint); } catch { /* ignore */ }
817
+ try { process.removeListener("SIGTERM", onSigterm); } catch { /* ignore */ }
818
+ _signalHandlersRegistered = false;
819
+ }
820
+
821
+ function onSigint(): void {
822
+ if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
823
+ console.log("\n[workflow] SIGINT received, cancelling workflow...");
824
+ cancelWorkflow();
825
+ }
826
+ }
827
+
828
+ function onSigterm(): void {
829
+ if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
830
+ cancelWorkflow();
831
+ }
832
+ }
833
+
834
+ function registerSignalHandlers(): void {
835
+ if (_signalHandlersRegistered) return;
836
+ try {
837
+ process.on("SIGINT", onSigint);
838
+ process.on("SIGTERM", onSigterm);
839
+ _signalHandlersRegistered = true;
840
+ } catch { /* ignore */ }
841
+ }
842
+
843
+ // ── Cancel handler ──
844
+
845
+
846
+ // ═══════════════════════════════════════════════════════════════
847
+ // Step change rollback (for "back" navigation)
848
+ // ═══════════════════════════════════════════════════════════════
849
+
850
+ /**
851
+ * Revert file changes made by a specific step index using git.
852
+ * This is called when the user selects "back" during step confirmation.
853
+ *
854
+ * - "edit" files: git checkout HEAD to restore to committed state
855
+ * - "new" files: delete the untracked file
856
+ * - "delete" files: git checkout HEAD to restore the deleted file
857
+ */
858
+ function revertStepChanges(stepIndex: number): void {
859
+ const changesForStep = _workflowFileChanges.filter(c => c.stepIndex === stepIndex);
860
+ if (changesForStep.length === 0) return;
861
+
862
+ for (const change of changesForStep) {
863
+ try {
864
+ const fullPath = path.join(_workflowCwd, change.filePath);
865
+ switch (change.type) {
866
+ case "edit":
867
+ case "delete":
868
+ // Restore from git HEAD
869
+ execSync(`git checkout HEAD -- "${change.filePath}"`, {
870
+ cwd: _workflowCwd,
871
+ encoding: "utf8",
872
+ timeout: 5000,
873
+ timeoutKill: 1000,
874
+ });
875
+ break;
876
+ case "new":
877
+ // Delete the newly created file
878
+ try {
879
+ if (fs.existsSync(fullPath)) {
880
+ fs.rmSync(fullPath, { force: true });
881
+ }
882
+ } catch { /* ignore if file doesn't exist */ }
883
+ break;
884
+ }
885
+ } catch { /* if git fails, skip this file */ }
886
+ }
887
+
888
+ // Remove the reverted changes from the tracking list
889
+ _workflowFileChanges = _workflowFileChanges.filter(c => c.stepIndex !== stepIndex);
890
+ }
891
+
892
+ // ═══════════════════════════════════════════════════════════════
893
+ // Agent runner (non-blocking, widget-based)
894
+ // ═══════════════════════════════════════════════════════════════
895
+
896
+ /**
897
+ * Run a sub-agent without blocking the main TUI.
898
+ * Progress is reported via the widget sub-step system.
899
+ * Uses the global AbortController for cancellation.
900
+ */
901
+ async function runAgentWithProgress(
902
+ agent: AgentDef,
903
+ task: string,
904
+ stepIndex: number,
905
+ agentName: string,
906
+ timeoutMs: number,
907
+ ): Promise<SubagentResult> {
908
+ const signal = _workflowAbortController?.signal;
909
+ const agentStartTime = Date.now();
910
+
911
+ // Initialize sub-step in widget
912
+ const step = _widgetSteps[stepIndex];
913
+ if (step) {
914
+ if (!step.subSteps) step.subSteps = [];
915
+ const existing = step.subSteps.find(s => s.agent === agentName);
916
+ if (!existing) {
917
+ step.subSteps.push({
918
+ agent: agentName,
919
+ status: "running",
920
+ tools: [],
921
+ outputs: [],
922
+ startedAt: agentStartTime,
923
+ });
924
+ refreshWidget();
925
+ } else {
926
+ // Update existing sub-step status and startedAt
927
+ existing.status = "running";
928
+ existing.startedAt = agentStartTime;
929
+ refreshWidget();
930
+ }
931
+ }
932
+
933
+ // Parse progress messages for tool calls and outputs
934
+ // NOTE: In-progress parsing is best-effort and intentionally conservative.
935
+ // Final file change detection relies on git diff --name-status (see updateToolsFromGit below)
936
+ // which is 100% accurate and free of AI text noise.
937
+ const result = await spawnSubagent(agent, task, _workflowCwd, signal, timeoutMs, (progress) => {
938
+ // Try to parse tool calls from progress messages
939
+ // Only match if it looks like a file path (contains a dot or path separator)
940
+ const toolMatch = progress.match(/(edit|read|write|new|bash|grep|find|ls|delete|remove)\s*[::]\s*(\S+)/i);
941
+ if (toolMatch) {
942
+ const toolType = toolMatch[1]!.toLowerCase();
943
+ const target = toolMatch[2]!;
944
+ // Only classify as file operation if it's a file path-like string
945
+ if (target.includes(".") || target.includes("/") || target.includes("\\")) {
946
+ const gitStatus = toGitStatus(toolType);
947
+ addWidgetSubStepTool(stepIndex, agentName, `${gitStatus} ${target}`);
948
+ _widgetExtraToolCount++;
949
+ }
950
+ }
951
+ // Detect output file paths — ONLY match explicit .pi-dev-output paths, never free-form text
952
+ const outputMatch = progress.match(/\.pi-dev-output\/[^\s,;)\]}]{5,}\.\w+/i);
953
+ if (outputMatch) {
954
+ const pathCandidate = outputMatch[0]!.trim();
955
+ if (pathCandidate.length > 15 && pathCandidate.length < 300) {
956
+ addWidgetSubStepOutput(stepIndex, agentName, pathCandidate);
957
+ }
958
+ }
959
+ });
960
+
961
+ const agentDuration = Date.now() - agentStartTime;
962
+
963
+ // Record agent run in history
964
+ _workflowAgentRunHistory.push({
965
+ agent: agentName,
966
+ stepIndex,
967
+ startedAt: new Date(agentStartTime).toISOString(),
968
+ durationMs: agentDuration,
969
+ exitCode: result.exitCode,
970
+ toolCount: _widgetExtraToolCount,
971
+ });
972
+
973
+ // ── Post-completion: parse subagent output for tool calls and file paths ──
974
+ // Progress messages from spawnSubagent rarely contain tool info,
975
+ // so we scan the full output after completion.
976
+ const allOutput = (result.output || "") + "\n" + (result.stderr || "");
977
+ const finalOutput = extractFinalOutput(result.output) || result.output;
978
+ const searchText = allOutput + "\n" + finalOutput;
979
+
980
+ // Detect file creation/modification patterns from agent's final output text
981
+ // The agent's response typically lists files using markdown backticks or bullet points
982
+ const filePatterns = [
983
+ // Markdown code blocks with file paths: `src/main.rs`, `path/to/file.ts`
984
+ /`([^`]+\.[a-zA-Z0-9_]+)`/g,
985
+ // Bullet points with file operation verbs: - Modify `src/main.rs`, * Created `file.ts`
986
+ /(?:^|\n)\s*[-*]\s*(?:modified|created|updated|edited|added|deleted|removed|changed|wrote|writes?)\s*[`"']?([^`"'\n,]+\.[a-zA-Z0-9_]+)[`"']?/gim,
987
+ // Descriptive: "I've modified src/main.rs", "reading config.json"
988
+ /(?:modified|created|updated|edited|added|deleted|removed|changed|wrote|write|writes|read|reads?)\s+(?:the\s+)?[`"']?([^`"'\n,]+\.[a-zA-Z0-9_]+)[`"']?/gi,
989
+ // Chinese patterns
990
+ /(?:编写|创建|修改|删除|读取|写入|更新)\s*(?:了|文件)?\s*[::]?\s*[`"']?([^`"'\s,,]+\.[a-zA-Z0-9_]+)[`"']?/gi,
991
+ // File path with action prefix: "edit: src/file.ts", "new: src/file.ts"
992
+ /(?:^|\n)\s*(?:edit|new|delete|read|modify|create|update|add|remove)\s*[::]\s*([^\n]+\.[a-zA-Z0-9_]+)/gim,
993
+ ];
994
+ const seenTools = new Set<string>();
995
+ for (const pattern of filePatterns) {
996
+ let m;
997
+ while ((m = pattern.exec(searchText)) !== null) {
998
+ const filePath = m[1]!.trim()
999
+ .replace(/[`'"\)\(\]]+$/, "")
1000
+ .replace(/^[`'"\)\(\[]+/, "")
1001
+ .split(/[\s,;]/)[0]!;
1002
+ // Validate it's a real file path
1003
+ if (filePath.length > 3 && filePath.length < 300 && !seenTools.has(filePath)) {
1004
+ // Skip common non-file matches
1005
+ if (filePath.match(/^(the|a|an|this|that|it|its|my|your|our|their|some|any|all|each|every|both|few|many|several|most|other|another|such|what|which|whose|whom|when|where|why|how|who|being|having|doing|making|taking|giving|getting|setting|using|running|going|coming|looking|finding|keeping|putting)/i)) continue;
1006
+ if (filePath.startsWith("http")) continue;
1007
+ if (filePath.length < 6 && !filePath.includes("/")) continue;
1008
+
1009
+ seenTools.add(filePath);
1010
+ const fullMatch = m[0]!.toLowerCase();
1011
+ // Determine operation type and convert to git status
1012
+ let toolType = "edit";
1013
+ if (fullMatch.includes("write") || fullMatch.includes("创建") || fullMatch.includes("new") || fullMatch.includes("add") || fullMatch.includes("created") || fullMatch.includes("added")) {
1014
+ toolType = "new";
1015
+ } else if (fullMatch.includes("delete") || fullMatch.includes("删除") || fullMatch.includes("remove") || fullMatch.includes("deleted") || fullMatch.includes("removed")) {
1016
+ toolType = "delete";
1017
+ } else if (fullMatch.includes("read") || fullMatch.includes("读取")) {
1018
+ toolType = "read";
1019
+ }
1020
+ if (toolType !== "read") {
1021
+ const gitStatus = toGitStatus(toolType);
1022
+ addWidgetSubStepTool(stepIndex, agentName, `${gitStatus} ${filePath}`);
1023
+ _widgetExtraToolCount++;
1024
+ }
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ // If we found no file tools from text patterns, try alternative approaches
1030
+ // Look for explicit tool call patterns in the raw JSON output
1031
+ if (seenTools.size === 0) {
1032
+ const jsonLines = (result.output || "").split("\n");
1033
+ for (const line of jsonLines) {
1034
+ try {
1035
+ const event = JSON.parse(line);
1036
+ // Look for tool_use events in the JSON stream
1037
+ if (event.type === "message_update" && event.assistantMessageEvent?.type === "tool_use") {
1038
+ const toolName = event.assistantMessageEvent.name;
1039
+ const args = event.assistantMessageEvent.args || {};
1040
+ // write tool: args contains file_path
1041
+ if (toolName === "write" && args.file_path) {
1042
+ const fp = args.file_path.trim();
1043
+ if (!seenTools.has(fp)) {
1044
+ seenTools.add(fp);
1045
+ addWidgetSubStepTool(stepIndex, agentName, `A ${fp}`);
1046
+ _widgetExtraToolCount++;
1047
+ }
1048
+ }
1049
+ // edit tool: args contains file_path
1050
+ if (toolName === "edit" && args.file_path) {
1051
+ const fp = args.file_path.trim();
1052
+ if (!seenTools.has(fp)) {
1053
+ seenTools.add(fp);
1054
+ addWidgetSubStepTool(stepIndex, agentName, `M ${fp}`);
1055
+ _widgetExtraToolCount++;
1056
+ }
1057
+ }
1058
+ }
1059
+ } catch { /* not JSON, skip */ }
1060
+ }
1061
+ }
1062
+
1063
+ // Find output file paths (.pi-dev-output, review reports, plan files)
1064
+ // NOTE: Only match explicit paths in .pi-dev-output/ or known review/plan file patterns.
1065
+ // The old pattern matching loose "output:" text was the root cause of "output::0.00004508" noise.
1066
+ // File change detection now relies on git diff --name-status (updateToolsFromGit below),
1067
+ // which is deterministic and noise-free.
1068
+ const outputPathPatterns = [
1069
+ // Direct reference to .pi-dev-output paths: ".pi-dev-output/pi-plans/xxx.md"
1070
+ /\.pi-dev-output\/[a-zA-Z0-9_\/-]+\.[a-zA-Z0-9]+/g,
1071
+ // Review file patterns: "review-20260520-162800.md"
1072
+ /review-\d{8}-\d{6}\.md/g,
1073
+ // Plan file patterns: "20260520-1628-*.md"
1074
+ /\d{8}-\d{4,6}-[a-zA-Z0-9_-]+\.md/g,
1075
+ ];
1076
+ const seenOutputs = new Set<string>();
1077
+ for (const pattern of outputPathPatterns) {
1078
+ let m;
1079
+ while ((m = pattern.exec(searchText)) !== null) {
1080
+ const path_ = m[0]!.trim();
1081
+ if (path_.length > 10 && path_.length < 300 && !seenOutputs.has(path_)) {
1082
+ seenOutputs.add(path_);
1083
+ addWidgetSubStepOutput(stepIndex, agentName, path_);
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+
1089
+ // ── Update file changes from git diff (more accurate than text scraping) ──
1090
+ updateToolsFromGit(_workflowCwd, stepIndex, agentName);
1091
+ // Update sub-step status based on result
1092
+ const subStatus: WorkflowSubStepWidgetState["status"] =
1093
+ result.exitCode === 0 ? "done" :
1094
+ isTimeoutResult(result) ? "failed" :
1095
+ result.exitCode !== 0 ? "failed" :
1096
+ "done";
1097
+ setWidgetSubStepStatus(stepIndex, agentName, subStatus);
1098
+
1099
+ return result;
1100
+ }
1101
+
1102
+ // ═══════════════════════════════════════════════════════════════
1103
+ // Single-step executor
1104
+ // ═══════════════════════════════════════════════════════════════
1105
+
1106
+ async function executeSingleStep(
1107
+ ctx: ExtensionCommandContext,
1108
+ step: WorkflowStepDef,
1109
+ state: WorkflowStepState,
1110
+ agentMap: Map<string, AgentDef>,
1111
+ prompt: string,
1112
+ planFileRelPath: string | undefined,
1113
+ mode: WorkflowMode,
1114
+ stepIndex: number,
1115
+ ): Promise<void> {
1116
+ const agentName = step.agentName!;
1117
+ const agent = agentMap.get(agentName);
1118
+ if (!agent) throw new Error(`未找到 agent: ${agentName}`);
1119
+
1120
+ const task = buildTaskForStep(agentName, prompt, planFileRelPath, _workflowCwd);
1121
+ let retried = false;
1122
+
1123
+ let result = await runAgentWithProgress(agent, task, stepIndex, agentName, step.timeoutMs);
1124
+
1125
+ // Timeout handling
1126
+ if (isTimeoutResult(result)) {
1127
+ if (mode === "full-auto" && !retried) {
1128
+ result = await runAgentWithProgress(agent, `[RETRY]\n\n${task}`, stepIndex, agentName, step.timeoutMs);
1129
+ retried = true;
1130
+ } else {
1131
+ const choice = await uiSelect(ctx, `⏰ ${step.label} 执行超时`, [
1132
+ "1. 重新执行", "2. 跳过此步骤", "3. 取消工作流",
1133
+ ]);
1134
+ if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
1135
+ if (choice.startsWith("2")) { state.status = "skipped"; return; }
1136
+ result = await runAgentWithProgress(agent, `[RETRY]\n\n${task}`, stepIndex, agentName, step.timeoutMs);
1137
+ }
1138
+ }
1139
+
1140
+ if (isTimeoutResult(result)) {
1141
+ throw new Error(`执行超时 (${(step.timeoutMs / 1000).toFixed(0)}s)`);
1142
+ }
1143
+
1144
+ if (result.exitCode !== 0 && result.stderr) {
1145
+ throw new Error(`Agent 错误 (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`);
1146
+ }
1147
+ }
1148
+
1149
+ // ═══════════════════════════════════════════════════════════════
1150
+ // Loop-group executor
1151
+ // ═══════════════════════════════════════════════════════════════
1152
+
1153
+ async function executeLoopGroup(
1154
+ ctx: ExtensionCommandContext,
1155
+ step: WorkflowStepDef,
1156
+ state: WorkflowStepState,
1157
+ agentMap: Map<string, AgentDef>,
1158
+ prompt: string,
1159
+ planFileRelPath: string | undefined,
1160
+ mode: WorkflowMode,
1161
+ loopCounts: Record<string, number>,
1162
+ stepIndex: number,
1163
+ ): Promise<void> {
1164
+ const loopAgent = agentMap.get(step.loopAgentName!);
1165
+ const reviewAgent = agentMap.get(step.reviewAgentName!);
1166
+ if (!loopAgent) throw new Error(`未找到 loop agent: ${step.loopAgentName}`);
1167
+ if (!reviewAgent) throw new Error(`未找到 review agent: ${step.reviewAgentName}`);
1168
+
1169
+ const maxLoops = step.maxLoops ?? 3;
1170
+ let loopCount = loopCounts[step.id] ?? 0;
1171
+ let contextPrompt = prompt;
1172
+
1173
+ while (loopCount < maxLoops) {
1174
+ const loopStartTime = Date.now();
1175
+
1176
+ // Run loop agent
1177
+ const loopTask = buildTaskForStep(step.loopAgentName!, contextPrompt, planFileRelPath, _workflowCwd);
1178
+
1179
+ let agentResult = await runAgentWithProgress(loopAgent, loopTask, stepIndex, step.loopAgentName!, step.timeoutMs);
1180
+
1181
+ if (isTimeoutResult(agentResult)) {
1182
+ if (mode === "full-auto") {
1183
+ contextPrompt = `[TIMEOUT_WARNING] 上一个 ${step.loopAgentName} 执行超时。\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd)}`;
1184
+ } else {
1185
+ const choice = await uiSelect(ctx, `⏰ ${step.loopAgentName} 执行超时`, [
1186
+ "1. 重新执行", "2. 进入审查阶段", "3. 跳过此步骤", "4. 取消工作流",
1187
+ ]);
1188
+ if (!choice || choice.startsWith("4")) { cancelWorkflow(); return; }
1189
+ if (choice.startsWith("3")) { state.status = "skipped"; return; }
1190
+ if (choice.startsWith("2")) {
1191
+ contextPrompt = `[TIMEOUT_WARNING]\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd)}`;
1192
+ } else {
1193
+ agentResult = await runAgentWithProgress(loopAgent, `[RETRY]\n\n${loopTask}`, stepIndex, step.loopAgentName!, step.timeoutMs);
1194
+ if (isTimeoutResult(agentResult)) {
1195
+ contextPrompt = `[TIMEOUT_WARNING]\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd)}`;
1196
+ }
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ // Run reviewer
1202
+ const reviewTask = contextPrompt.includes("[TIMEOUT_WARNING]")
1203
+ ? contextPrompt
1204
+ : buildReviewTask(contextPrompt, planFileRelPath, _workflowCwd);
1205
+
1206
+ const reviewResult = await runAgentWithProgress(reviewAgent, reviewTask, stepIndex, step.reviewAgentName!, step.timeoutMs);
1207
+
1208
+ const extractedOutput = extractFinalOutput(reviewResult.output) || reviewResult.output;
1209
+ const combinedOutput = extractedOutput + "\n" + reviewResult.stderr;
1210
+ let reviewSummary = parseReviewerOutput(combinedOutput);
1211
+ if (!reviewSummary) reviewSummary = extractSeverityFromText(extractedOutput);
1212
+ if (!reviewSummary) {
1213
+ const reviewContent = readLatestReviewMd(_workflowCwd);
1214
+ if (reviewContent) {
1215
+ reviewSummary = parseReviewerOutput(reviewContent) ?? extractSeverityFromText(reviewContent);
1216
+ }
1217
+ }
1218
+
1219
+ loopCount++;
1220
+
1221
+ if (reviewSummary?.maxSeverity === "critical" && loopCount < maxLoops) {
1222
+ if (mode === "full-auto") {
1223
+ contextPrompt = [prompt, "", "## 上次审查发现的问题",
1224
+ `审查摘要: ${JSON.stringify(reviewSummary)}`,
1225
+ `请修复 ${reviewSummary.critical} 个严重问题后重新运行。`,
1226
+ ].join("\n");
1227
+ continue;
1228
+ } else {
1229
+ const shouldLoop = await uiConfirm(ctx, "🔄 检测到严重问题",
1230
+ `审查发现 ${reviewSummary.critical} 个严重问题。是否进入下一轮循环 (${loopCount}/${maxLoops})?`);
1231
+ if (shouldLoop) {
1232
+ contextPrompt = [prompt, "", "## 上次审查发现的问题",
1233
+ `审查摘要: ${JSON.stringify(reviewSummary)}`,
1234
+ `请修复这些严重问题后重新运行。`,
1235
+ ].join("\n");
1236
+ continue;
1237
+ }
1238
+ break;
1239
+ }
1240
+ }
1241
+ break;
1242
+ }
1243
+
1244
+ state.loopCount = loopCount;
1245
+ loopCounts[step.id] = loopCount;
1246
+ }
1247
+
1248
+ // ═══════════════════════════════════════════════════════════════
1249
+ // Main async workflow executor
1250
+ // ═══════════════════════════════════════════════════════════════
1251
+
1252
+ async function executeWorkflowBackground(
1253
+ ctx: ExtensionCommandContext,
1254
+ pi: ExtensionAPI,
1255
+ prompt: string,
1256
+ steps: WorkflowStepDef[],
1257
+ agentMap: Map<string, AgentDef>,
1258
+ mode: WorkflowMode,
1259
+ stepStates: WorkflowStepState[],
1260
+ initialStepIndex: number,
1261
+ initialLoopCounts: Record<string, number>,
1262
+ planFileRelPath: string | undefined,
1263
+ existingCp: CheckpointData | undefined,
1264
+ ): Promise<void> {
1265
+ let loopCounts = { ...initialLoopCounts };
1266
+ let currentStepIndex = initialStepIndex;
1267
+ let planFileRelPathInner = planFileRelPath;
1268
+
1269
+ for (; currentStepIndex < steps.length; currentStepIndex++) {
1270
+ // Check abort
1271
+ if (_workflowAbortController?.signal.aborted) {
1272
+ return;
1273
+ }
1274
+
1275
+ const step = steps[currentStepIndex]!;
1276
+ const state = stepStates[currentStepIndex]!;
1277
+
1278
+ if (state.status === "done" || state.status === "skipped") continue;
1279
+
1280
+ setWidgetCurrentStep(currentStepIndex);
1281
+
1282
+ // Pre-populate sub-steps for pending steps so UI shows queued agents
1283
+ populatePredefinedSubSteps(currentStepIndex);
1284
+
1285
+ // ── Helper: handle "back" from any confirmation dialog ──
1286
+ // Returns true if back was handled (caller should continue without execution).
1287
+ const handleBack = async (): Promise<boolean> => {
1288
+ if (currentStepIndex > 0) {
1289
+ // Revert file changes made by the previous step
1290
+ revertStepChanges(currentStepIndex - 1);
1291
+ // Reset previous step state
1292
+ stepStates[currentStepIndex - 1]!.status = "pending";
1293
+ stepStates[currentStepIndex - 1]!.durationMs = undefined;
1294
+ stepStates[currentStepIndex - 1]!.error = undefined;
1295
+ stepStates[currentStepIndex - 1]!.loopCount = undefined;
1296
+ updateWidgetStep(
1297
+ currentStepIndex - 1,
1298
+ steps[currentStepIndex - 1]!.label,
1299
+ "pending",
1300
+ );
1301
+ // Clear sub-steps for the reverted step
1302
+ if (_widgetSteps[currentStepIndex - 1]) {
1303
+ _widgetSteps[currentStepIndex - 1]!.subSteps = [];
1304
+ }
1305
+ currentStepIndex -= 2; // loop will ++, landing on the previous step
1306
+ saveCheckpoint(_workflowCwd, buildCp());
1307
+ return true;
1308
+ }
1309
+ return false;
1310
+ };
1311
+
1312
+ // ── User confirmations (BEFORE timer starts) ──
1313
+
1314
+ // Confirmation for [confirm] steps (attended mode)
1315
+ if (step.type === "confirm" && mode !== "full-auto") {
1316
+ const items = [
1317
+ "1. 进入此步骤", "2. 自定义输入", "3. 跳过此步骤", "4. 取消工作流",
1318
+ ];
1319
+ if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
1320
+ const choice = await uiSelect(ctx, `📌 ${step.label}`, items);
1321
+ if (choice === BACK_OPTION_TEXT) {
1322
+ if (await handleBack()) continue;
1323
+ return; // can't go back from first step
1324
+ }
1325
+ if (!choice || choice.startsWith("4")) { cancelWorkflow(); return; }
1326
+ if (choice.startsWith("3")) {
1327
+ state.status = "skipped";
1328
+ saveCheckpoint(_workflowCwd, buildCp());
1329
+ updateWidgetStep(currentStepIndex, step.label, "skipped");
1330
+ continue;
1331
+ }
1332
+ if (choice.startsWith("2")) {
1333
+ const customInput = await uiInput(ctx, "✏️ 自定义输入", "输入你的指令或反馈");
1334
+ if (customInput !== undefined && customInput.trim()) {
1335
+ prompt = `${prompt}\n\n## 用户自定义指令\n${customInput.trim()}`;
1336
+ }
1337
+ }
1338
+ }
1339
+
1340
+ // Full-attended: confirm every step
1341
+ if (mode === "full-attended" && step.type !== "confirm") {
1342
+ const items = ["1. 执行", "2. 跳过", "3. 取消工作流"];
1343
+ if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
1344
+ const choice = await uiSelect(ctx, `📌 ${step.label} — 执行?`, items);
1345
+ if (choice === BACK_OPTION_TEXT) {
1346
+ if (await handleBack()) continue;
1347
+ return;
1348
+ }
1349
+ if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
1350
+ if (choice.startsWith("2")) {
1351
+ state.status = "skipped";
1352
+ saveCheckpoint(_workflowCwd, buildCp());
1353
+ updateWidgetStep(currentStepIndex, step.label, "skipped");
1354
+ continue;
1355
+ }
1356
+ }
1357
+
1358
+ // Attended: confirm loop-group steps (e.g. 实施代码 → 审查)
1359
+ if (step.type === "loop-group" && mode === "attended") {
1360
+ const items = [
1361
+ "1. 进入此步骤", "2. 跳过此步骤", "3. 取消工作流",
1362
+ ];
1363
+ if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
1364
+ const choice = await uiSelect(ctx, `📌 ${step.label}`, items);
1365
+ if (choice === BACK_OPTION_TEXT) {
1366
+ if (await handleBack()) continue;
1367
+ return;
1368
+ }
1369
+ if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
1370
+ if (choice.startsWith("2")) {
1371
+ state.status = "skipped";
1372
+ saveCheckpoint(_workflowCwd, buildCp());
1373
+ updateWidgetStep(currentStepIndex, step.label, "skipped");
1374
+ continue;
1375
+ }
1376
+ }
1377
+
1378
+ // ── Execute (timer starts NOW, after all user confirmations) ──
1379
+ state.status = "running";
1380
+ const stepStartTime = Date.now();
1381
+ updateWidgetStep(currentStepIndex, step.label, "running", { timeoutMs: step.timeoutMs, maxLoops: step.maxLoops, startedAt: stepStartTime });
1382
+
1383
+ try {
1384
+ if (step.type === "loop-group") {
1385
+ await executeLoopGroup(ctx, step, state, agentMap, prompt, planFileRelPathInner, mode, loopCounts, currentStepIndex);
1386
+ } else {
1387
+ await executeSingleStep(ctx, step, state, agentMap, prompt, planFileRelPathInner, mode, currentStepIndex);
1388
+ if (step.agentName === "planner") {
1389
+ planFileRelPathInner = findLatestPlanFile(_workflowCwd);
1390
+ }
1391
+ }
1392
+ state.status = "done";
1393
+ state.durationMs = Date.now() - stepStartTime;
1394
+ updateWidgetStep(currentStepIndex, step.label, "done", {
1395
+ durationMs: state.durationMs,
1396
+ loopCount: state.loopCount,
1397
+ maxLoops: step.maxLoops,
1398
+ timeoutMs: step.timeoutMs,
1399
+ });
1400
+ } catch (err) {
1401
+ state.status = "failed";
1402
+ state.durationMs = Date.now() - stepStartTime;
1403
+ state.error = err instanceof Error ? err.message : String(err);
1404
+ updateWidgetStep(currentStepIndex, step.label, "failed", {
1405
+ durationMs: state.durationMs,
1406
+ error: state.error,
1407
+ loopCount: state.loopCount,
1408
+ });
1409
+ }
1410
+
1411
+ setWidgetCurrentStep(currentStepIndex + 1);
1412
+ saveCheckpoint(_workflowCwd, buildCp());
1413
+ }
1414
+
1415
+ // ── Done ──
1416
+ _workflowRunning = false;
1417
+
1418
+ // Archive checkpoint instead of deleting
1419
+ archiveCheckpointFile(_workflowCwd, planFileRelPathInner);
1420
+
1421
+ // Send persistent result
1422
+ const taskSummary = extractTaskSummary(prompt);
1423
+ const finalState = buildWidgetState(
1424
+ mode,
1425
+ _widgetSteps,
1426
+ steps.length,
1427
+ _widgetStartTime,
1428
+ stepStates.every(s => s.status === "done" || s.status === "skipped") ? "done" : "failed",
1429
+ { toolCount: _widgetExtraToolCount, tokenCount: _widgetExtraTokenCount },
1430
+ taskSummary,
1431
+ );
1432
+ sendWorkflowResult(pi, finalState, prompt, _workflowType);
1433
+
1434
+ // Cleanup widget after delay
1435
+ setTimeout(() => cleanupWidget(), 5000);
1436
+
1437
+ function buildCp(): CheckpointData {
1438
+ return {
1439
+ version: 2,
1440
+ createdAt: existingCp?.createdAt ?? new Date().toISOString(),
1441
+ updatedAt: new Date().toISOString(),
1442
+ prompt,
1443
+ mode,
1444
+ steps: stepStates,
1445
+ currentStepIndex,
1446
+ loopCounts,
1447
+ planFilePath: planFileRelPathInner,
1448
+ taskSummary: extractTaskSummary(prompt),
1449
+ workflowType: _workflowType,
1450
+ fileChanges: [..._workflowFileChanges],
1451
+ subAgentRuns: _workflowAgentRunHistory.length,
1452
+ filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
1453
+ filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
1454
+ agentRunHistory: [..._workflowAgentRunHistory],
1455
+ baseline: _workflowBaseline,
1456
+ };
1457
+ }
1458
+ }
1459
+
1460
+ // ═══════════════════════════════════════════════════════════════
1461
+ // Main entry
1462
+ // ═══════════════════════════════════════════════════════════════
1463
+
1464
+ export interface WorkflowConfig {
1465
+ steps: WorkflowStepDef[];
1466
+ }
1467
+
1468
+ /**
1469
+ * Launch a workflow asynchronously.
1470
+ * Sets up the widget and runs steps in background.
1471
+ * Does NOT block - the caller (command handler) returns immediately.
1472
+ *
1473
+ * @param ctx 命令上下文
1474
+ * @param pi Extension API
1475
+ * @param prompt 用户原始 prompt
1476
+ * @param config 工作流步骤配置
1477
+ * @param workflowType 可选的类型标签(feat/fix/doc 等),用于完成消息
1478
+ */
1479
+ export async function runWorkflow(
1480
+ ctx: ExtensionCommandContext,
1481
+ pi: ExtensionAPI,
1482
+ prompt: string,
1483
+ config: WorkflowConfig,
1484
+ workflowType?: string,
1485
+ ): Promise<void> {
1486
+ const { steps } = config;
1487
+
1488
+ // ── Load & validate agents ──
1489
+ const agents = discoverAgents();
1490
+ const agentMap = new Map<string, AgentDef>();
1491
+ for (const a of agents) agentMap.set(a.name, a);
1492
+
1493
+ for (const step of steps) {
1494
+ const names: string[] = [];
1495
+ if (step.type === "auto" || step.type === "confirm") {
1496
+ if (step.agentName) names.push(step.agentName);
1497
+ }
1498
+ if (step.type === "loop-group") {
1499
+ if (step.loopAgentName) names.push(step.loopAgentName);
1500
+ if (step.reviewAgentName) names.push(step.reviewAgentName);
1501
+ }
1502
+ for (const n of names) {
1503
+ if (!agentMap.has(n)) {
1504
+ await uiSelect(ctx, `❌ 未找到 agent "${n}"`, ["确定"]);
1505
+ return;
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+ // ── State init / checkpoint recovery ──
1511
+ let mode: WorkflowMode = "attended";
1512
+ const stepStates: WorkflowStepState[] = steps.map(() => ({ status: "pending" }));
1513
+ let currentStepIndex = 0;
1514
+ let loopCounts: Record<string, number> = {};
1515
+ let planFileRelPath: string | undefined;
1516
+ let resumeFlow = false;
1517
+
1518
+ const existingCp = loadCheckpointFromFile(ctx.cwd);
1519
+ if (existingCp) {
1520
+ const resume = await uiConfirm(ctx, "🔄 恢复工作流",
1521
+ `发现上次未完成的工作流(${existingCp.updatedAt}),是否继续?`);
1522
+ if (resume) {
1523
+ mode = existingCp.mode;
1524
+ Object.assign(stepStates, existingCp.steps);
1525
+ currentStepIndex = existingCp.currentStepIndex;
1526
+ loopCounts = existingCp.loopCounts;
1527
+ planFileRelPath = existingCp.planFilePath;
1528
+ resumeFlow = true;
1529
+ } else {
1530
+ archiveCheckpointFile(ctx.cwd); // archive old checkpoint
1531
+ }
1532
+ }
1533
+
1534
+ if (!existingCp || !resumeFlow) {
1535
+ const modeChoice = await uiSelect(ctx, "🤖 选择工作流模式", [
1536
+ "1. 值守(默认)— 自动流程,[]步骤需确认,循环需许可",
1537
+ "2. 完全信任 — 全自动运行,无需任何确认",
1538
+ "3. 完全值守 — 每一步都需用户确认",
1539
+ "4. 取消工作流",
1540
+ ]);
1541
+ if (!modeChoice || modeChoice.startsWith("4")) return;
1542
+ mode = modeChoice.startsWith("2") ? "full-auto" :
1543
+ modeChoice.startsWith("3") ? "full-attended" : "attended";
1544
+ }
1545
+
1546
+ // Save initial state
1547
+ _workflowCwd = ctx.cwd;
1548
+ _workflowPrompt = prompt;
1549
+ _workflowPlanFileRelPath = planFileRelPath;
1550
+ _workflowLoopCounts = loopCounts;
1551
+ _workflowCreatedAt = existingCp?.createdAt ?? new Date().toISOString();
1552
+ _workflowType = workflowType;
1553
+ _workflowPi = pi;
1554
+ _workflowStepDefs = steps;
1555
+ _workflowFileChanges = existingCp?.fileChanges ? [...existingCp.fileChanges] : [];
1556
+ _workflowAgentRunHistory = existingCp?.agentRunHistory ? [...existingCp.agentRunHistory] : [];
1557
+
1558
+ // ── Baseline snapshot: restore from checkpoint or capture fresh ──
1559
+ if (resumeFlow && existingCp?.baseline) {
1560
+ _workflowBaseline = [...existingCp.baseline];
1561
+ } else if (!resumeFlow) {
1562
+ captureBaseline(ctx.cwd);
1563
+ }
1564
+
1565
+ saveCheckpoint(ctx.cwd, {
1566
+ version: 2,
1567
+ createdAt: existingCp?.createdAt ?? new Date().toISOString(),
1568
+ updatedAt: new Date().toISOString(),
1569
+ prompt,
1570
+ mode,
1571
+ steps: stepStates,
1572
+ currentStepIndex,
1573
+ loopCounts,
1574
+ planFilePath: planFileRelPath,
1575
+ taskSummary: extractTaskSummary(prompt),
1576
+ workflowType,
1577
+ fileChanges: [..._workflowFileChanges],
1578
+ subAgentRuns: _workflowAgentRunHistory.length,
1579
+ filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
1580
+ filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
1581
+ agentRunHistory: [..._workflowAgentRunHistory],
1582
+ baseline: _workflowBaseline,
1583
+ });
1584
+
1585
+ // Initialize widget
1586
+ initWidget(ctx, mode, steps.length);
1587
+ for (let i = 0; i < steps.length; i++) {
1588
+ const isDoneState = stepStates[i]?.status === "done";
1589
+ updateWidgetStep(i, steps[i]!.label, isDoneState ? "done" : "pending", {
1590
+ maxLoops: steps[i]!.maxLoops,
1591
+ timeoutMs: steps[i]!.timeoutMs,
1592
+ });
1593
+ // Pre-populate sub-steps for all steps (shows queued agents)
1594
+ populatePredefinedSubSteps(i);
1595
+ }
1596
+
1597
+ // Set up abort controller & cancel callback
1598
+ _workflowAbortController = new AbortController();
1599
+ setWorkflowCancelCallback(() => {
1600
+ _workflowAbortController?.abort();
1601
+ _workflowRunning = false;
1602
+ if (_lastWorkflowCtx) {
1603
+ const finalState = buildWidgetState(
1604
+ _widgetMode,
1605
+ _widgetSteps,
1606
+ _widgetCurrentIdx,
1607
+ _widgetStartTime,
1608
+ "cancelled",
1609
+ extractTaskSummary(_workflowPrompt),
1610
+ );
1611
+ updateWorkflowWidget(_lastWorkflowCtx, finalState);
1612
+
1613
+ // ── Save final checkpoint before archiving ──
1614
+ const cancelCp: CheckpointData = {
1615
+ version: 2,
1616
+ createdAt: _workflowCreatedAt,
1617
+ updatedAt: new Date().toISOString(),
1618
+ prompt: _workflowPrompt,
1619
+ mode: _widgetMode,
1620
+ steps: _widgetSteps.map(s => ({
1621
+ status: s.status as WorkflowStepState["status"],
1622
+ durationMs: s.durationMs,
1623
+ loopCount: s.loopCount,
1624
+ error: s.error,
1625
+ })),
1626
+ currentStepIndex: _widgetCurrentIdx,
1627
+ loopCounts: { ..._workflowLoopCounts },
1628
+ planFilePath: _workflowPlanFileRelPath,
1629
+ taskSummary: extractTaskSummary(_workflowPrompt),
1630
+ workflowType: _workflowType,
1631
+ fileChanges: [..._workflowFileChanges],
1632
+ subAgentRuns: _workflowAgentRunHistory.length,
1633
+ filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
1634
+ filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
1635
+ agentRunHistory: [..._workflowAgentRunHistory],
1636
+ baseline: _workflowBaseline,
1637
+ };
1638
+ saveCheckpoint(_workflowCwd, cancelCp);
1639
+
1640
+ // ── Send workflow result message for persistence ──
1641
+ if (_workflowPi) {
1642
+ sendWorkflowResult(_workflowPi, finalState, _workflowPrompt, _workflowType);
1643
+ }
1644
+
1645
+ // ── Archive checkpoint on cancel too ──
1646
+ archiveCheckpointFile(_workflowCwd, _workflowPlanFileRelPath);
1647
+ setTimeout(() => cleanupWidget(), 5000);
1648
+ }
1649
+ });
1650
+
1651
+ // Collapse tools to show widget
1652
+ ctx.ui.setToolsExpanded(false);
1653
+
1654
+ // ── Register terminal input handler (Esc to cancel) ──
1655
+ if (ctx.hasUI) {
1656
+ _terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
1657
+ if (!matchesKey(data, Key.escape)) return undefined;
1658
+ if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
1659
+ ctx.ui.notify("⏹️ 用户取消工作流", "warning");
1660
+ cancelWorkflow();
1661
+ return { consume: true };
1662
+ }
1663
+ return undefined;
1664
+ });
1665
+ }
1666
+
1667
+ // ── Register signal handlers (SIGINT/SIGTERM) for graceful shutdown ──
1668
+ registerSignalHandlers();
1669
+
1670
+ // ── Launch background execution (fire-and-forget) ──
1671
+ executeWorkflowBackground(
1672
+ ctx, pi, prompt, steps, agentMap, mode, stepStates,
1673
+ currentStepIndex, loopCounts, planFileRelPath,
1674
+ existingCp ?? undefined,
1675
+ ).catch((err) => {
1676
+ console.error("[workflow] Background execution error:", err);
1677
+ _workflowRunning = false;
1678
+ if (_lastWorkflowCtx) {
1679
+ updateWorkflowWidget(_lastWorkflowCtx, null);
1680
+ }
1681
+ // Clean up terminal input listener and signal handlers
1682
+ if (_terminalInputUnsubscribe) {
1683
+ _terminalInputUnsubscribe();
1684
+ _terminalInputUnsubscribe = null;
1685
+ }
1686
+ cleanupSignalHandlers();
1687
+ });
1688
+
1689
+ // Return immediately - execution continues in background
1690
+ }
1691
+
1692
+ // ═══════════════════════════════════════════════════════════════
1693
+ // Extension factory (no-op — imported by dev-prompts.ts)
1694
+ // ═══════════════════════════════════════════════════════════════
1695
+
1696
+ /**
1697
+ * Check if a workflow is currently running.
1698
+ */
1699
+ export function isWorkflowRunning(): boolean {
1700
+ return _workflowRunning;
1701
+ }
1702
+
1703
+ /**
1704
+ * Cancel the active workflow, if any.
1705
+ * Safe to call even when no workflow is running (no-op in that case).
1706
+ */
1707
+ export function cancelActiveWorkflow(): void {
1708
+ if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
1709
+ cancelWorkflow();
1710
+ }
1711
+ }
1712
+
1713
+ export default function (_pi: ExtensionAPI) {
1714
+ // workflow-engine is a helper module, not a standalone extension.
1715
+ }