@ghyper9023/pi-dev-workflow 0.1.7

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,741 @@
1
+ /**
2
+ * Sub-Agents Extension
3
+ *
4
+ * Provides specialized sub-agents that run in isolated pi processes.
5
+ * Agents are defined as markdown files in ./agents/ with YAML frontmatter.
6
+ *
7
+ * Provides:
8
+ * - subagent tool (for LLM to delegate tasks to agents like git-agent, review-agent)
9
+ * - review-agent input interception with non-blocking async mode
10
+ *
11
+ * Git commands (/git-commit, /git-push, /git-commit-push) are in git-commands.ts,
12
+ * which imports spawnSubagent from here.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { Type } from "typebox";
20
+
21
+ // ── Configuration ────────────────────────────────────────────
22
+
23
+ /** Default timeout for git-type subagents (milliseconds). */
24
+ const GIT_TIMEOUT_MS = 120_000;
25
+
26
+ /** Default timeout for review-type subagents (longer = needs more time). */
27
+ const REVIEW_TIMEOUT_MS = 300_000; // 5 min
28
+
29
+ /** Fallback default. */
30
+ const DEFAULT_TIMEOUT_MS = 180_000;
31
+
32
+ /** Report sub-agent progress every N ms. */
33
+ const PROGRESS_INTERVAL_MS = 1_500;
34
+
35
+ /** Hard limit on accumulated output per subagent (prevents OOM on runaway). */
36
+ const MAX_BUFFER_BYTES = 500_000;
37
+
38
+ /** Locations to auto-load APPEND_SYSTEM.md / append.system.md from (checked in order). */
39
+ const APPEND_SYSTEM_PATHS = [
40
+ path.join(osHomedir(), ".pi", "agent", "append.system.md"),
41
+ ".pi/append.system.md",
42
+ "APPEND_SYSTEM.md",
43
+ ];
44
+
45
+ // Lazily resolved
46
+ let _appendSystemContent: string | null | undefined; // undefined = not checked yet
47
+
48
+ function loadAppendSystem(cwd: string): string | null {
49
+ if (_appendSystemContent !== undefined) return _appendSystemContent;
50
+ for (const loc of APPEND_SYSTEM_PATHS) {
51
+ const abs = path.isAbsolute(loc) ? loc : path.join(cwd, loc);
52
+ try {
53
+ const content = fs.readFileSync(abs, "utf-8").trim();
54
+ if (content) {
55
+ _appendSystemContent = content;
56
+ return content;
57
+ }
58
+ } catch {
59
+ // file not found or unreadable, try next
60
+ }
61
+ }
62
+ _appendSystemContent = null;
63
+ return null;
64
+ }
65
+
66
+ /** Find the newest HTML review file in pi-review/ directory. */
67
+ function findNewestReviewHtml(cwd: string): string {
68
+ try {
69
+ const reviewDir = path.join(cwd, "pi-review");
70
+ if (fs.existsSync(reviewDir)) {
71
+ const files = fs.readdirSync(reviewDir)
72
+ .filter(f => f.endsWith(".html"))
73
+ .map(f => ({
74
+ name: f,
75
+ mtime: fs.statSync(path.join(reviewDir, f)).mtimeMs,
76
+ }))
77
+ .sort((a, b) => b.mtime - a.mtime);
78
+ if (files.length > 0) {
79
+ return "pi-review/" + files[0].name;
80
+ }
81
+ }
82
+ } catch {
83
+ // ignore fs errors
84
+ }
85
+ return "";
86
+ }
87
+
88
+ function osHomedir(): string {
89
+ try {
90
+ return require("node:os").homedir();
91
+ } catch {
92
+ return process.env.HOME || process.env.USERPROFILE || "/root";
93
+ }
94
+ }
95
+
96
+ // ── Process lifecycle management ─────────────────────────────
97
+ //
98
+ // Fix #1: Prevent orphan sub-agents when pi exits or crashes.
99
+ // - process.on("exit", ...) - fires on ALL exits (sync only)
100
+ // - process.on("SIGTERM"|"SIGINT"|"SIGHUP"|"SIGQUIT", ...)
101
+ // - process.on("uncaughtException", ...) - crash protection
102
+ // - process.on("unhandledRejection", ...)
103
+ //
104
+ // Fix #2: CPU spike / deadlock mitigation
105
+ // - Hard buffer size limit (MAX_BUFFER_BYTES) to cap memory
106
+ // - Simplified settle() - no listener remove/reattach race
107
+
108
+ const activeChildren = new Set<import("node:child_process").ChildProcess>();
109
+
110
+ function killAllChildren(): void {
111
+ for (const proc of activeChildren) {
112
+ try {
113
+ if (proc.pid !== undefined && !proc.killed) {
114
+ proc.kill("SIGTERM");
115
+ }
116
+ } catch {
117
+ // already dead
118
+ }
119
+ }
120
+ activeChildren.clear();
121
+ }
122
+
123
+ // process.on("exit") - synchronous, fires on process.exit() and normal termination
124
+ process.on("exit", () => {
125
+ killAllChildren();
126
+ });
127
+
128
+ // Signal handlers - fire once per signal
129
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"] as const) {
130
+ process.once(sig, () => {
131
+ killAllChildren();
132
+ });
133
+ }
134
+
135
+ // Crash handlers - clean up children before the crash propagates
136
+ process.on("uncaughtException", (err) => {
137
+ console.error("[sub-agents] Uncaught exception, killing sub-agents:", err.message);
138
+ killAllChildren();
139
+ });
140
+ process.on("unhandledRejection", (reason) => {
141
+ console.error("[sub-agents] Unhandled rejection, killing sub-agents:", reason);
142
+ killAllChildren();
143
+ });
144
+
145
+ // ── Agent config ─────────────────────────────────────────────
146
+
147
+ export interface AgentDef {
148
+ name: string;
149
+ description: string;
150
+ tools?: string[];
151
+ systemPrompt: string;
152
+ /** Custom timeout in ms for this agent type. */
153
+ timeoutMs?: number;
154
+ }
155
+
156
+ export interface SubagentResult {
157
+ exitCode: number;
158
+ output: string;
159
+ stderr: string;
160
+ /** How long the subagent ran (ms). */
161
+ durationMs: number;
162
+ }
163
+
164
+ function inferTimeout(name: string): number {
165
+ const lc = name.toLowerCase();
166
+ if (lc.includes("review") || lc.includes("审查")) return REVIEW_TIMEOUT_MS;
167
+ if (lc.includes("git")) return GIT_TIMEOUT_MS;
168
+ return DEFAULT_TIMEOUT_MS;
169
+ }
170
+
171
+ function loadAgent(filePath: string): AgentDef | null {
172
+ try {
173
+ const content = fs.readFileSync(filePath, "utf-8");
174
+ // Simple frontmatter parser (no external deps needed)
175
+ const frontMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
176
+ if (!frontMatch) return null;
177
+ const frontRaw = frontMatch[1];
178
+ const body = frontMatch[2].trim();
179
+
180
+ const fields: Record<string, string> = {};
181
+ for (const line of frontRaw.split("\n")) {
182
+ const m = line.match(/^(\w+):\s*(.*)$/);
183
+ if (m) fields[m[1]] = m[2];
184
+ }
185
+
186
+ if (!fields.name || !fields.description) return null;
187
+
188
+ const tools = fields.tools?.split(",").map((t) => t.trim()).filter(Boolean);
189
+ return {
190
+ name: fields.name,
191
+ description: fields.description,
192
+ tools: tools && tools.length > 0 ? tools : undefined,
193
+ systemPrompt: body,
194
+ timeoutMs: inferTimeout(fields.name),
195
+ };
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ let _discoveredAgents: AgentDef[] | null = null;
202
+
203
+ export function discoverAgents(): AgentDef[] {
204
+ if (_discoveredAgents) return _discoveredAgents;
205
+
206
+ // Walk up from __dirname to find the package root with agents/
207
+ let dir = __dirname;
208
+ let agentsDir: string | null = null;
209
+ for (let i = 0; i < 10; i++) {
210
+ const candidate = path.join(dir, "agents");
211
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
212
+ agentsDir = candidate;
213
+ break;
214
+ }
215
+ const parent = path.dirname(dir);
216
+ if (parent === dir) break;
217
+ dir = parent;
218
+ }
219
+ if (!agentsDir) return (_discoveredAgents = []);
220
+
221
+ const agents: AgentDef[] = [];
222
+ let entries: fs.Dirent[];
223
+ try {
224
+ entries = fs.readdirSync(agentsDir, { withFileTypes: true });
225
+ } catch {
226
+ return (_discoveredAgents = agents);
227
+ }
228
+
229
+ for (const entry of entries) {
230
+ if (!entry.name.endsWith(".md")) continue;
231
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
232
+ const filePath = path.join(agentsDir, entry.name);
233
+ const agent = loadAgent(filePath);
234
+ if (agent) agents.push(agent);
235
+ }
236
+ return (_discoveredAgents = agents);
237
+ }
238
+
239
+ // ── Spawn subagent process ───────────────────────────────────
240
+ // Fix #2: Simplified settle logic - no listener manipulation races.
241
+ // Buffer is capped at MAX_BUFFER_BYTES to prevent OOM on runaway output.
242
+ // Fix #3: onProgress callback streams sub-agent output in real-time.
243
+ // Fix #4: -nc (no context files), -ne (no extensions) to reduce startup overhead.
244
+ // Auto-load append.system.md. Thinking=off for faster responses.
245
+
246
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
247
+ const currentScript = process.argv[1];
248
+ const isBunVirtual = currentScript?.startsWith("/$bunfs/root/");
249
+ if (currentScript && !isBunVirtual && fs.existsSync(currentScript)) {
250
+ return { command: process.execPath, args: [currentScript, ...args] };
251
+ }
252
+ const execName = path.basename(process.execPath).toLowerCase();
253
+ const isGeneric = /^(node|bun)(\.exe)?$/.test(execName);
254
+ if (!isGeneric) {
255
+ return { command: process.execPath, args };
256
+ }
257
+ return { command: "pi", args };
258
+ }
259
+
260
+ export async function spawnSubagent(
261
+ agent: AgentDef,
262
+ task: string,
263
+ cwd: string,
264
+ signal?: AbortSignal,
265
+ timeoutMs?: number,
266
+ onProgress?: (msg: string) => void,
267
+ ): Promise<SubagentResult> {
268
+ const effectiveTimeout = timeoutMs ?? agent.timeoutMs ?? DEFAULT_TIMEOUT_MS;
269
+ const systemPrompt = agent.systemPrompt.trim();
270
+
271
+ // Build pi arguments for the subagent
272
+ const args: string[] = [
273
+ "-p", // non-interactive
274
+ "--no-session", // ephemeral
275
+ "-nc", // no context files (AGENTS.md / CLAUDE.md)
276
+ "-ne", // no extensions (less startup overhead)
277
+ "--mode", "json", // structured output
278
+ ];
279
+
280
+ // Sub-agents don't need high thinking - off for speed
281
+ // (system prompt + task text provides enough context)
282
+ args.push("--thinking", "off");
283
+
284
+ // Only grant the tools the agent needs
285
+ if (agent.tools && agent.tools.length > 0) {
286
+ args.push("--tools", agent.tools.join(","));
287
+ }
288
+
289
+ // Append agent system prompt
290
+ if (systemPrompt) {
291
+ args.push("--append-system-prompt", systemPrompt);
292
+ }
293
+
294
+ // Auto-load append.system.md (global or project-level)
295
+ // Fix #3: Sub-agents now follow append.system.md instructions
296
+ try {
297
+ const appendContent = loadAppendSystem(cwd);
298
+ if (appendContent) {
299
+ args.push("--append-system-prompt", appendContent);
300
+ }
301
+ } catch {
302
+ // fail silently
303
+ }
304
+
305
+ // The task itself
306
+ args.push(task);
307
+
308
+ const startTime = Date.now();
309
+
310
+ return new Promise((resolve) => {
311
+ const invocation = getPiInvocation(args);
312
+ const proc = spawn(invocation.command, invocation.args, {
313
+ cwd,
314
+ shell: false,
315
+ stdio: ["ignore", "pipe", "pipe"],
316
+ });
317
+
318
+ activeChildren.add(proc);
319
+ proc.on("exit", () => {
320
+ activeChildren.delete(proc);
321
+ });
322
+
323
+ let stdout = "";
324
+ let stderr = "";
325
+ let settled = false;
326
+
327
+ // ── Real-time progress reporting ───────────────────────
328
+ // Fix #3: More frequent updates (every 1.5s) + on every data chunk
329
+ const progressTimer = setInterval(() => {
330
+ if (settled || !onProgress) return;
331
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
332
+ const lines = stdout.split("\n").filter((l) => l.trim());
333
+ if (lines.length > 0) {
334
+ const recent = lines.slice(-3).join("\n");
335
+ onProgress(`[${agent.name}] (${elapsed}s) ${recent.slice(0, 200)}`);
336
+ } else {
337
+ onProgress(`[${agent.name}] (${elapsed}s) ⏳ 处理中...`);
338
+ }
339
+ }, PROGRESS_INTERVAL_MS);
340
+ if (typeof progressTimer === "object" && "unref" in progressTimer) {
341
+ progressTimer.unref();
342
+ }
343
+
344
+ const settle = (result: SubagentResult) => {
345
+ if (settled) return;
346
+ settled = true;
347
+ clearInterval(progressTimer);
348
+ result.durationMs = Date.now() - startTime;
349
+ resolve(result);
350
+ };
351
+
352
+ // ── Stream stdout data + immediate progress callback ──
353
+ // Fix #3: Flush progress on every data chunk
354
+ proc.stdout.on("data", (data: Buffer) => {
355
+ const chunk = data.toString();
356
+ if (stdout.length < MAX_BUFFER_BYTES) {
357
+ stdout += chunk;
358
+ if (stdout.length > MAX_BUFFER_BYTES) {
359
+ stdout = stdout.slice(0, MAX_BUFFER_BYTES);
360
+ }
361
+ }
362
+ // Immediate progress on new data
363
+ if (!settled && onProgress) {
364
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
365
+ const lines = chunk.split("\n").filter((l) => l.trim());
366
+ if (lines.length > 0) {
367
+ onProgress(`[${agent.name}] (${elapsed}s) ${lines.slice(-1)[0].slice(0, 150)}`);
368
+ }
369
+ }
370
+ });
371
+
372
+ proc.stderr.on("data", (data: Buffer) => {
373
+ const chunk = data.toString();
374
+ if (stderr.length < MAX_BUFFER_BYTES) {
375
+ stderr += chunk;
376
+ if (stderr.length > MAX_BUFFER_BYTES) {
377
+ stderr = stderr.slice(0, MAX_BUFFER_BYTES);
378
+ }
379
+ }
380
+ });
381
+
382
+ proc.on("close", (code) => {
383
+ settle({ exitCode: code ?? 0, output: stdout, stderr, durationMs: 0 });
384
+ });
385
+ proc.on("error", () => {
386
+ settle({ exitCode: 1, output: "", stderr: "Failed to spawn subagent process", durationMs: 0 });
387
+ });
388
+
389
+ // ── Timeout protection ──────────────────────────────
390
+ const timer = setTimeout(() => {
391
+ if (settled) return;
392
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
393
+ try { proc.kill("SIGTERM"); } catch { /* already dead */ }
394
+ // Give it a moment to terminate gracefully, then force kill
395
+ setTimeout(() => {
396
+ try { proc.kill("SIGKILL"); } catch { /* already dead */ }
397
+ }, 2000);
398
+ settle({
399
+ exitCode: -1,
400
+ output: stdout,
401
+ stderr: stderr + `\n[ERROR] Subagent timed out after ${(effectiveTimeout / 1000).toFixed(0)}s (${elapsed}s elapsed)`,
402
+ durationMs: 0,
403
+ });
404
+ }, effectiveTimeout);
405
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
406
+
407
+ // ── Abort signal wiring ─────────────────────────────
408
+ if (signal) {
409
+ if (signal.aborted) {
410
+ try { proc.kill("SIGTERM"); } catch { /* already dead */ }
411
+ } else {
412
+ const abortHandler = () => {
413
+ try { proc.kill("SIGTERM"); } catch { /* already dead */ }
414
+ };
415
+ signal.addEventListener("abort", abortHandler, { once: true });
416
+ }
417
+ }
418
+ });
419
+ }
420
+
421
+ export function extractFinalOutput(jsonOutput: string): string {
422
+ // Parse JSON lines from --mode json output
423
+ // Try multiple event formats to find the final assistant response
424
+ let result = "";
425
+
426
+ for (const line of jsonOutput.split("\n")) {
427
+ if (!line.trim()) continue;
428
+ try {
429
+ const event = JSON.parse(line);
430
+
431
+ // Format 1: pi's --mode json message_update with text_delta (streaming)
432
+ // {"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"..."}}
433
+ if (event.type === "message_update" &&
434
+ event.assistantMessageEvent?.type === "text_delta") {
435
+ result += event.assistantMessageEvent.delta || "";
436
+ }
437
+
438
+ // Format 1b: text_end has the complete accumulated text
439
+ // {"type":"message_update","assistantMessageEvent":{"type":"text_end","content":"..."}}
440
+ if (event.type === "message_update" &&
441
+ event.assistantMessageEvent?.type === "text_end" &&
442
+ event.assistantMessageEvent.content) {
443
+ result = event.assistantMessageEvent.content;
444
+ }
445
+
446
+ // Format 2: Anthropic-style message events
447
+ // message_stop / message_end with content array
448
+ if ((event.type === "message_stop" || event.type === "message_end" ||
449
+ event.type === "message_complete") &&
450
+ event.message?.content) {
451
+ const parts = Array.isArray(event.message.content)
452
+ ? event.message.content
453
+ : [event.message.content];
454
+ for (const part of parts) {
455
+ if (typeof part === "string") result = part;
456
+ else if (part?.type === "text") result = part.text;
457
+ }
458
+ }
459
+
460
+ // Format 3: content_block_delta with text deltas (fallback format)
461
+ if (event.type === "content_block_delta" &&
462
+ event.delta?.type === "text_delta") {
463
+ result += event.delta.text;
464
+ }
465
+ if (event.type === "content_block_delta" &&
466
+ event.delta?.text) {
467
+ result += event.delta.text;
468
+ }
469
+
470
+ // Format 4: generic assistant response
471
+ if (event.type === "assistant_message" && event.content) {
472
+ result = typeof event.content === "string"
473
+ ? event.content
474
+ : (event.content.text || event.content.join?.(""));
475
+ }
476
+
477
+ // Format 5: text key at top level
478
+ if (event.type === "complete" && event.text) {
479
+ result = event.text;
480
+ }
481
+ } catch {
482
+ // If a line isn't JSON, it might be raw text output - collect it
483
+ if (!result) {
484
+ const trimmed = line.trim();
485
+ if (trimmed && !trimmed.startsWith("{") && trimmed.length > 20) {
486
+ result = (result + "\n" + trimmed).trim();
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ // Fallback: if nothing parsed, return the raw stdout (truncated to reasonable size)
493
+ if (!result && jsonOutput.trim()) {
494
+ const cleaned = jsonOutput
495
+ .split("\n")
496
+ .filter((l) => {
497
+ const t = l.trim();
498
+ // Skip JSON lines and empty lines
499
+ if (!t || t.startsWith("{")) return false;
500
+ // Skip thinking blocks
501
+ if (t.includes("thinking") && t.length < 30) return false;
502
+ return true;
503
+ })
504
+ .join("\n")
505
+ .trim();
506
+ if (cleaned) result = cleaned;
507
+ }
508
+
509
+ return result;
510
+ }
511
+
512
+ // ── Extension ────────────────────────────────────────────────
513
+
514
+ export default function (pi: ExtensionAPI) {
515
+ const agents = discoverAgents();
516
+ const gitAgent = agents.find((a) => a.name === "git-agent");
517
+ const reviewAgent = agents.find((a) => a.name === "review-agent");
518
+
519
+ if (agents.length === 0) {
520
+ console.warn("[sub-agents] No agent markdown files found in package's agents/ directory");
521
+ }
522
+
523
+ // ── /subagent-stop - 主动终止所有正在运行的 sub-agent ──────
524
+ pi.registerCommand("subagent-stop", {
525
+ description: "Terminate all running sub-agents immediately",
526
+ handler: async (_args, ctx) => {
527
+ const count = activeChildren.size;
528
+ if (count === 0) {
529
+ ctx.ui.notify("i️ 当前没有运行中的 sub-agent", "info");
530
+ return;
531
+ }
532
+ killAllChildren();
533
+ ctx.ui.notify(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, "info");
534
+ ctx.ui.notify(`🛑 已终止 ${count} 个 sub-agent 进程`, "warning");
535
+ ctx.ui.notify(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, "info");
536
+ },
537
+ });
538
+
539
+ // ── Register subagent tool (for LLM to use directly) ──────
540
+ pi.registerTool({
541
+ name: "subagent",
542
+ label: "Subagent",
543
+ description: [
544
+ "Delegate a task to a specialized sub-agent with an isolated context window.",
545
+ "The sub-agent runs in a separate pi process.",
546
+ `Available agents: ${agents.map((a) => a.name).join(", ") || "none"}`,
547
+ "For quick git operations (commit/push), prefer the /git-commit, /git-push, /git-commit-push commands.",
548
+ ].join(" "),
549
+ parameters: Type.Object({
550
+ agent: Type.String({ description: "Name of the agent to invoke" }),
551
+ task: Type.String({ description: "Task description to delegate" }),
552
+ }),
553
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
554
+ const agent = agents.find((a) => a.name === params.agent);
555
+ if (!agent) {
556
+ return {
557
+ content: [{
558
+ type: "text",
559
+ text: `Unknown agent "${params.agent}". Available: ${agents.map((a) => a.name).join(", ")}`,
560
+ }],
561
+ details: {},
562
+ };
563
+ }
564
+ const abortSignal = signal ?? ctx.signal;
565
+
566
+ // Fix #3: Report progress via onUpdate
567
+ onUpdate?.({ state: "running", message: `🤖 正在委派 ${agent.name} 处理...` });
568
+
569
+ const result = await spawnSubagent(
570
+ agent,
571
+ params.task,
572
+ ctx.cwd,
573
+ abortSignal,
574
+ undefined, // use agent's default timeout
575
+ (progress) => {
576
+ onUpdate?.({ state: "running", message: progress });
577
+ },
578
+ );
579
+ const output = extractFinalOutput(result.output);
580
+ const dur = (result.durationMs / 1000).toFixed(1);
581
+ const isError = result.exitCode !== 0 && !output;
582
+ return {
583
+ content: [{ type: "text", text: output || result.stderr || "(no output)" }],
584
+ details: { agent: agent.name, exitCode: result.exitCode, durationMs: result.durationMs },
585
+ isError,
586
+ };
587
+ },
588
+ });
589
+
590
+ // ── Review sub-agent (input interception) ─────────────────
591
+ // NON-BLOCKING MODE: 用户可选择"后台审查",不阻塞主对话,
592
+ // 审查完成后通过 sendMessage 自动将结果注入会话。
593
+ // 同时保留原有的阻塞模式供需要同步等待的场景使用。
594
+
595
+ pi.on("input", async (event, ctx) => {
596
+ if (!reviewAgent) return { action: "continue" };
597
+
598
+ const text = event.text.trim().toLowerCase();
599
+
600
+ // Detect review-html skill invocation or explicit review request.
601
+ const isReviewSkill = text.startsWith("/skill:review-html");
602
+ const hasReviewIntent = text.includes("review") ||
603
+ text.includes("审查") || text.includes("审阅") || text.includes("review-html");
604
+ const hasCodeTarget = text.includes("code") || text.includes("代码") ||
605
+ text.includes("diff") || text.includes("commit") ||
606
+ text.includes("html") || text.includes("report") || text.includes("报告") ||
607
+ text.includes("本次改动") || text.includes("这次改动");
608
+ const isReviewRequest = !isReviewSkill && hasReviewIntent && hasCodeTarget;
609
+
610
+ if (!isReviewSkill && !isReviewRequest) return { action: "continue" };
611
+
612
+ // 自动触发 /skill:review-html 不询问,直接以"仅审查"模式运行(阻塞,不发送原消息)
613
+ if (isReviewSkill) {
614
+ ctx.ui.setStatus("subagent", "🔍 review-sub-agent reviewing...");
615
+ ctx.ui.notify("🤖 review-sub-agent 正在审查代码(最长 5 分钟),请稍候...", "info");
616
+
617
+ const startTime = Date.now();
618
+ const result = await spawnSubagent(
619
+ reviewAgent,
620
+ event.text,
621
+ ctx.cwd,
622
+ ctx.signal,
623
+ undefined,
624
+ (progress) => {
625
+ ctx.ui.setStatus("subagent", progress.slice(0, 50));
626
+ },
627
+ );
628
+ const dur = ((Date.now() - startTime) / 1000).toFixed(1);
629
+ ctx.ui.setStatus("subagent", undefined);
630
+
631
+ const filePath = findNewestReviewHtml(ctx.cwd);
632
+
633
+ if (filePath) {
634
+ ctx.ui.notify(`📄 ${filePath} (${dur}s)`, "success");
635
+ } else {
636
+ ctx.ui.notify(`✅ review-sub-agent 完成 (${dur}s)`, "info");
637
+ }
638
+ return { action: "handled" };
639
+ }
640
+
641
+ // 对于普通关键词触发的审查请求,询问用户选择模式
642
+ // ctx.ui.select 接受 string[],返回选中的字符串
643
+ const mode = await ctx.ui.select(
644
+ "🔍 检测到审查意图",
645
+ [
646
+ "1. 后台审查(非阻塞,异步通知)",
647
+ "2. 仅审查(阻塞,等待结果)",
648
+ "3. 不是审查(放行给主代理)",
649
+ ],
650
+ );
651
+
652
+ if (!mode || mode.startsWith("3")) {
653
+ // 用户取消(Esc)或选择"不是审查",直接放行给主代理
654
+ return { action: "continue" };
655
+ }
656
+
657
+ const isAsync = mode.startsWith("1");
658
+
659
+ if (!isAsync) {
660
+ // 阻塞模式:拦截原消息,同步等待审查完成
661
+ ctx.ui.setStatus("subagent", "🔍 review-sub-agent reviewing...");
662
+ ctx.ui.notify("🤖 review-sub-agent 正在审查代码(最长 5 分钟),请稍候...", "info");
663
+
664
+ const startTime = Date.now();
665
+ const result = await spawnSubagent(
666
+ reviewAgent,
667
+ event.text,
668
+ ctx.cwd,
669
+ ctx.signal,
670
+ undefined,
671
+ (progress) => {
672
+ ctx.ui.setStatus("subagent", progress.slice(0, 50));
673
+ },
674
+ );
675
+ const dur = ((Date.now() - startTime) / 1000).toFixed(1);
676
+ ctx.ui.setStatus("subagent", undefined);
677
+
678
+ const filePath = findNewestReviewHtml(ctx.cwd);
679
+
680
+ if (filePath) {
681
+ ctx.ui.notify(`📄 ${filePath} (${dur}s)`, "success");
682
+ } else {
683
+ ctx.ui.notify(`✅ review-sub-agent 完成 (${dur}s)`, "info");
684
+ }
685
+ return { action: "handled" };
686
+ }
687
+
688
+ // 非阻塞异步模式:后台运行审查,不阻塞主对话,也不将原消息发给主代理
689
+ ctx.ui.notify("🔍 已在后台启动代码审查,完成后会在此对话中通知您。", "info");
690
+
691
+ // 注意:不能在 async 回调中直接使用 ctx,因为 ctx 可能已失效,
692
+ // 但我们可以使用闭包捕获的 pi 和必要参数。
693
+ const userTask = event.text;
694
+ const cwd = ctx.cwd;
695
+ const abortSignal = ctx.signal;
696
+
697
+ // 启动后台任务(不等待),完成后通过 sendMessage 注入结果
698
+ (async () => {
699
+ try {
700
+ const startTime = Date.now();
701
+ const result = await spawnSubagent(
702
+ reviewAgent,
703
+ userTask,
704
+ cwd,
705
+ abortSignal,
706
+ undefined,
707
+ );
708
+ const dur = ((Date.now() - startTime) / 1000).toFixed(1);
709
+
710
+ const filePath = findNewestReviewHtml(cwd);
711
+
712
+ if (filePath) {
713
+ pi.sendMessage({
714
+ customType: "review-result",
715
+ content: `🔍 **代码审查完成** (耗时 ${dur}s)\n\n报告已生成:\n\`${filePath}\`\n\n您可以打开该文件查看详细审查意见。`,
716
+ display: true,
717
+ details: { filePath, durationMs: result.durationMs },
718
+ });
719
+ } else {
720
+ const output = extractFinalOutput(result.output) || result.stderr || "无输出";
721
+ pi.sendMessage({
722
+ customType: "review-result",
723
+ content: `🔍 **代码审查完成** (耗时 ${dur}s)\n\n\`\`\`\n${output.slice(0, 2000)}${output.length > 2000 ? "\n...(内容已截断)" : ""}\n\`\`\``,
724
+ display: true,
725
+ details: { durationMs: result.durationMs },
726
+ });
727
+ }
728
+ } catch (err) {
729
+ console.error("[sub-agents] Background review failed:", err);
730
+ pi.sendMessage({
731
+ customType: "review-result",
732
+ content: `❌ 后台代码审查失败:${err instanceof Error ? err.message : String(err)}`,
733
+ display: true,
734
+ });
735
+ }
736
+ })();
737
+
738
+ // 异步审查也不放行原消息给主代理
739
+ return { action: "handled" };
740
+ });
741
+ }