@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.
Files changed (40) hide show
  1. package/.github/workflows/release.yml +56 -0
  2. package/.pre-commit-config.yaml +75 -0
  3. package/.version/RELEASE-v0.7.0.md +85 -0
  4. package/.version/RELEASE-v0.8.0.md +93 -0
  5. package/README.md +178 -294
  6. package/extensions/dev-prompts.ts +121 -1033
  7. package/extensions/git-commands.ts +62 -171
  8. package/extensions/grill-me-agent.ts +160 -164
  9. package/extensions/pre-check.ts +190 -0
  10. package/extensions/review-detect.ts +123 -0
  11. package/extensions/session-utils.ts +168 -0
  12. package/extensions/ui-helpers.ts +22 -780
  13. package/package.json +2 -2
  14. package/prompts/APPEND_SYSTEM.md +73 -59
  15. package/skills/review-html/SKILL.md +1 -1
  16. package/tests/test-no-subagents.mjs +264 -0
  17. package/.doc/AGENT-FRONTMATTER-REFERENCE.md +0 -198
  18. package/agents/git-agent.md +0 -44
  19. package/agents/grill/dev-doc-grill-agent.md +0 -42
  20. package/agents/grill/dev-fix-grill-agent.md +0 -44
  21. package/agents/grill/dev-grill-agent.md +0 -40
  22. package/agents/grill/dev-perf-grill-agent.md +0 -45
  23. package/agents/grill/dev-prd-agent.md +0 -60
  24. package/agents/grill/dev-refactor-grill-agent.md +0 -46
  25. package/agents/grill/dev-test-grill-agent.md +0 -45
  26. package/agents/review-agent.md +0 -53
  27. package/agents/workflow/docWriter-agent.md +0 -53
  28. package/agents/workflow/planner-agent.md +0 -131
  29. package/agents/workflow/reviewer-agent.md +0 -128
  30. package/agents/workflow/trimmer-agent.md +0 -78
  31. package/agents/workflow/worker-agent.md +0 -70
  32. package/extensions/sub-agents.ts +0 -954
  33. package/extensions/workflow-engine.ts +0 -2005
  34. package/tests/test-grill-json-fix.mjs +0 -243
  35. package/tests/test-loopcount-timeout-fix.mjs +0 -336
  36. package/tests/test-output-directory-structure.mjs +0 -177
  37. package/tests/test-save-answer-file-workflow.mjs +0 -187
  38. package/tests/test-workflow-config.mjs +0 -244
  39. package/tests/test-workflow-engine-bugs.mjs +0 -908
  40. package/tests/test-workflow-engine.mjs +0 -518
@@ -1,954 +0,0 @@
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
- import { uiSelect } from "./ui-helpers";
21
-
22
- // ── Configuration ────────────────────────────────────────────
23
-
24
- /** Default timeout for git-type subagents (milliseconds). */
25
- const GIT_TIMEOUT_MS = 120_000;
26
-
27
- /** Default timeout for review-type subagents (longer = needs more time). */
28
- const REVIEW_TIMEOUT_MS = 300_000; // 5 min
29
-
30
- /** Fallback default. */
31
- const DEFAULT_TIMEOUT_MS = 180_000;
32
-
33
- /** Report sub-agent progress every N ms. */
34
- const PROGRESS_INTERVAL_MS = 1_500;
35
-
36
- /** Hard limit on accumulated output per subagent (prevents OOM on runaway). */
37
- // Keep up to 10MB of output — large enough to hold complete session event streams
38
- // including cargo test compilation output (commonly 100-400 KB).
39
- // We preserve the END of the buffer (last N bytes) so extractFinalOutput can
40
- // find the final AI response even when the total output is large.
41
- const MAX_BUFFER_BYTES = 10_000_000;
42
-
43
- /**
44
- * Locations to auto-load APPEND_SYSTEM.md / append.system.md from (checked in order).
45
- * Defined as a getter so that osHomedir() is called lazily rather than at module init,
46
- * avoiding a potential crash when the module is loaded in restricted environments.
47
- */
48
- function getAppendSystemPaths(): string[] {
49
- return [
50
- path.join(osHomedir(), ".pi", "agent", "append.system.md"),
51
- ".pi/append.system.md",
52
- "APPEND_SYSTEM.md",
53
- ];
54
- }
55
-
56
- /** Cache for append.system.md content, keyed by cwd to support multi-project isolation. */
57
- const _appendSystemCache = new Map<string, string | null>();
58
-
59
- function loadAppendSystem(cwd: string): string | null {
60
- const cached = _appendSystemCache.get(cwd);
61
- if (cached !== undefined) return cached;
62
- for (const loc of getAppendSystemPaths()) {
63
- const abs = path.isAbsolute(loc) ? loc : path.join(cwd, loc);
64
- try {
65
- const content = fs.readFileSync(abs, "utf-8").trim();
66
- if (content) {
67
- _appendSystemCache.set(cwd, content);
68
- return content;
69
- }
70
- } catch {
71
- // file not found or unreadable, try next
72
- }
73
- }
74
- _appendSystemCache.set(cwd, null);
75
- return null;
76
- }
77
-
78
- /** Find the newest HTML review file in .pi-dev-output/pi-review/html/, pi-review/, or .pi-dev-output/pi-review/ directory. */
79
- function findNewestReviewHtml(cwd: string): string {
80
- const candidates = [
81
- path.join(cwd, ".pi-dev-output", "pi-review", "html"),
82
- path.join(cwd, "pi-review"),
83
- path.join(cwd, ".pi-dev-output", "pi-review"),
84
- ];
85
-
86
- for (const reviewDir of candidates) {
87
- try {
88
- if (fs.existsSync(reviewDir)) {
89
- const files = fs.readdirSync(reviewDir)
90
- .filter(f => f.endsWith(".html"))
91
- .map(f => ({
92
- name: f,
93
- mtime: fs.statSync(path.join(reviewDir, f)).mtimeMs,
94
- }))
95
- .sort((a, b) => b.mtime - a.mtime);
96
- if (files.length > 0) {
97
- // Return relative path from cwd
98
- const rel = path.relative(cwd, reviewDir);
99
- return rel + "/" + files[0].name;
100
- }
101
- }
102
- } catch {
103
- // ignore fs errors
104
- }
105
- }
106
-
107
- return "";
108
- }
109
-
110
- function osHomedir(): string {
111
- try {
112
- return require("node:os").homedir();
113
- } catch {
114
- return process.env.HOME || process.env.USERPROFILE || "/root";
115
- }
116
- }
117
-
118
- /** Parse boolean-like frontmatter values: true/yes/1, false/no/0, or undefined. */
119
- function parseBool(val: string | undefined): boolean | undefined {
120
- if (val === undefined) return undefined;
121
- const lc = val.toLowerCase().trim();
122
- if (lc === "true" || lc === "yes" || lc === "1") return true;
123
- if (lc === "false" || lc === "no" || lc === "0") return false;
124
- return undefined;
125
- }
126
-
127
- // ── Process lifecycle management ─────────────────────────────
128
- //
129
- // Fix #1: Prevent orphan sub-agents when pi exits or crashes.
130
- // - process.on("exit", ...) - fires on ALL exits (sync only)
131
- // - process.on("SIGTERM"|"SIGINT"|"SIGHUP"|"SIGQUIT", ...)
132
- // - process.on("uncaughtException", ...) - crash protection
133
- // - process.on("unhandledRejection", ...)
134
- //
135
- // Fix #2: CPU spike / deadlock mitigation
136
- // - Hard buffer size limit (MAX_BUFFER_BYTES) to cap memory
137
- // - Simplified settle() - no listener remove/reattach race
138
-
139
- const activeChildren = new Set<import("node:child_process").ChildProcess>();
140
-
141
- function killAllChildren(): void {
142
- // Snapshot the Set to avoid potential iteration issues if exit callbacks
143
- // modify activeChildren during the loop.
144
- const children = [...activeChildren];
145
- for (const proc of children) {
146
- try {
147
- if (proc.pid !== undefined && !proc.killed) {
148
- proc.kill("SIGTERM");
149
- }
150
- } catch {
151
- // already dead
152
- }
153
- }
154
- activeChildren.clear();
155
- }
156
-
157
- // process.on("exit") - synchronous, fires on process.exit() and normal termination
158
- process.on("exit", () => {
159
- killAllChildren();
160
- });
161
-
162
- // Signal handlers - fire once per signal
163
- for (const sig of ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"] as const) {
164
- process.once(sig, () => {
165
- killAllChildren();
166
- });
167
- }
168
-
169
- // Crash handlers - clean up children before the crash propagates
170
- process.on("uncaughtException", (err) => {
171
- console.error("[sub-agents] Uncaught exception, killing sub-agents:", err.message);
172
- killAllChildren();
173
- });
174
- process.on("unhandledRejection", (reason) => {
175
- console.error("[sub-agents] Unhandled rejection, killing sub-agents:", reason);
176
- killAllChildren();
177
- });
178
-
179
- // ── Agent config ─────────────────────────────────────────────
180
-
181
- export interface AgentDef {
182
- name: string;
183
- description: string;
184
- tools?: string[];
185
- systemPrompt: string;
186
- /** Custom timeout in ms for this agent type. */
187
- timeoutMs?: number;
188
-
189
- // ── Subprocess CLI args (from frontmatter) ──
190
- /** Thinking level: "off" | "low" | "medium" | "high" | "xhigh" */
191
- thinkingLevel?: string;
192
- /** Persist session (default: false = --no-session) */
193
- session?: boolean;
194
- /** Session directory override */
195
- sessionDir?: string;
196
- /** Skip context files AGENTS.md/CLAUDE.md (default: true = -nc) */
197
- noContext?: boolean;
198
- /** Disable extension loading (default: true = -ne) */
199
- noExtensions?: boolean;
200
- /** Output mode: "json" | "text" (default: "json") */
201
- mode?: string;
202
- /** Extra raw CLI args to append */
203
- extraArgs?: string[];
204
- }
205
-
206
- export interface SubagentResult {
207
- exitCode: number;
208
- output: string;
209
- stderr: string;
210
- /** How long the subagent ran (ms). */
211
- durationMs: number;
212
- }
213
-
214
- /**
215
- * Caller-provided overrides for subprocess CLI args.
216
- * Each field, when set, takes precedence over the AgentDef (frontmatter) value.
217
- */
218
- export interface SubagentArgs {
219
- /** Override thinking level */
220
- thinkingLevel?: string;
221
- /** Override session persistence */
222
- session?: boolean;
223
- /** Override session directory */
224
- sessionDir?: string;
225
- /** Override -nc (no context files) */
226
- noContext?: boolean;
227
- /** Override -ne (no extensions) */
228
- noExtensions?: boolean;
229
- /** Override --mode */
230
- mode?: string;
231
- /** Override --tools whitelist */
232
- tools?: string[];
233
- /** Extra --append-system-prompt (appended after agent's own) */
234
- appendSystemPrompt?: string;
235
- /** Extra raw CLI args (appended last, highest priority) */
236
- extraArgs?: string[];
237
- /** Workflow UUID to include in session name for cross-workflow traceability */
238
- workflowId?: string;
239
- }
240
-
241
- function inferTimeout(name: string): number {
242
- const lc = name.toLowerCase();
243
- if (lc.includes("review") || lc.includes("审查")) return REVIEW_TIMEOUT_MS;
244
- if (lc.includes("git")) return GIT_TIMEOUT_MS;
245
- if (lc.includes("prd")) return REVIEW_TIMEOUT_MS;
246
- return DEFAULT_TIMEOUT_MS;
247
- }
248
-
249
- function loadAgent(filePath: string): AgentDef | null {
250
- try {
251
- const content = fs.readFileSync(filePath, "utf-8");
252
- // Simple frontmatter parser (no external deps needed)
253
- const frontMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
254
- if (!frontMatch) return null;
255
- const frontRaw = frontMatch[1];
256
- const body = frontMatch[2].trim();
257
-
258
- const fields: Record<string, string> = {};
259
- for (const line of frontRaw.split("\n")) {
260
- const m = line.match(/^([\w-]+):\s*(.*)$/);
261
- if (m) fields[m[1]] = m[2];
262
- }
263
-
264
- if (!fields.name || !fields.description) return null;
265
-
266
- const tools = fields.tools?.split(",").map((t) => t.trim()).filter(Boolean);
267
- return {
268
- name: fields.name,
269
- description: fields.description,
270
- tools: tools && tools.length > 0 ? tools : undefined,
271
- systemPrompt: body,
272
- timeoutMs: inferTimeout(fields.name),
273
- // ── Subprocess args from frontmatter ──
274
- thinkingLevel: fields.thinking || undefined,
275
- session: parseBool(fields.session),
276
- sessionDir: fields["session-dir"] || undefined,
277
- noContext: parseBool(fields["no-context"]),
278
- noExtensions: parseBool(fields["no-extensions"]),
279
- mode: fields.mode || undefined,
280
- extraArgs: fields["extra-args"] ? fields["extra-args"].split(/\s+/).filter(Boolean) : undefined,
281
- };
282
- } catch {
283
- return null;
284
- }
285
- }
286
-
287
- let _discoveredAgents: AgentDef[] | null = null;
288
-
289
- export function discoverAgents(): AgentDef[] {
290
- if (_discoveredAgents) return _discoveredAgents;
291
-
292
- // Walk up from __dirname to find the package root with agents/
293
- let dir = __dirname;
294
- let agentsDir: string | null = null;
295
- for (let i = 0; i < 10; i++) {
296
- const candidate = path.join(dir, "agents");
297
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
298
- agentsDir = candidate;
299
- break;
300
- }
301
- const parent = path.dirname(dir);
302
- if (parent === dir) break;
303
- dir = parent;
304
- }
305
- if (!agentsDir) return (_discoveredAgents = []);
306
-
307
- const agents: AgentDef[] = [];
308
-
309
- /** Recursively walk directory to collect all .md agent definitions. */
310
- function scanAgentsDir(dir: string): void {
311
- let entries: fs.Dirent[];
312
- try {
313
- entries = fs.readdirSync(dir, { withFileTypes: true });
314
- } catch {
315
- return;
316
- }
317
- for (const entry of entries) {
318
- if (entry.name.startsWith(".")) continue; // skip hidden files/dirs
319
- const fullPath = path.join(dir, entry.name);
320
- if (entry.isDirectory()) {
321
- scanAgentsDir(fullPath);
322
- } else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md")) {
323
- const agent = loadAgent(fullPath);
324
- if (agent) agents.push(agent);
325
- }
326
- }
327
- }
328
-
329
- scanAgentsDir(agentsDir);
330
- return (_discoveredAgents = agents);
331
- }
332
-
333
- // ── Spawn subagent process ───────────────────────────────────
334
- // Fix #2: Simplified settle logic - no listener manipulation races.
335
- // Buffer is capped at MAX_BUFFER_BYTES to prevent OOM on runaway output.
336
- // Fix #3: onProgress callback streams sub-agent output in real-time.
337
- // Fix #4: Subprocess CLI args are resolved from caller > AgentDef frontmatter > defaults.
338
- // Defaults: -nc, -ne, --no-session, --mode json, --thinking off.
339
- // Auto-load append.system.md for project-level instructions.
340
-
341
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
342
- const currentScript = process.argv[1];
343
- const isBunVirtual = currentScript?.startsWith("/$bunfs/root/");
344
- if (currentScript && !isBunVirtual && fs.existsSync(currentScript)) {
345
- return { command: process.execPath, args: [currentScript, ...args] };
346
- }
347
- const execName = path.basename(process.execPath).toLowerCase();
348
- const isGeneric = /^(node|bun)(\.exe)?$/.test(execName);
349
- if (!isGeneric) {
350
- return { command: process.execPath, args };
351
- }
352
- return { command: "pi", args };
353
- }
354
-
355
- export async function spawnSubagent(
356
- agent: AgentDef,
357
- task: string,
358
- cwd: string,
359
- signal?: AbortSignal,
360
- timeoutMs?: number,
361
- onProgress?: (msg: string) => void,
362
- argsOverride?: SubagentArgs,
363
- ): Promise<SubagentResult> {
364
- const effectiveTimeout = timeoutMs ?? agent.timeoutMs ?? DEFAULT_TIMEOUT_MS;
365
- const systemPrompt = agent.systemPrompt.trim();
366
-
367
- // ── Resolve arg overrides (caller > AgentDef frontmatter > hardcoded default) ──
368
- const override = (argsOverride as SubagentArgs) ?? {};
369
-
370
- const finalThinking = override.thinkingLevel ?? agent.thinkingLevel ?? "off";
371
- const finalSession = override.session ?? agent.session ?? false;
372
- const finalSessionDir = override.sessionDir ?? agent.sessionDir;
373
- const finalNoContext = override.noContext ?? agent.noContext ?? true;
374
- const finalNoExtensions = override.noExtensions ?? agent.noExtensions ?? true;
375
- const finalMode = override.mode ?? agent.mode ?? "json";
376
- const finalTools = override.tools ?? agent.tools;
377
- const finalExtraArgs = override.extraArgs ?? agent.extraArgs;
378
-
379
- // Build pi arguments for the subagent
380
- const args: string[] = ["-p"]; // non-interactive
381
-
382
- if (!finalSession) {
383
- args.push("--no-session");
384
- } else {
385
- // Session enabled — auto-name with timestamp + agent name
386
- // Use full file path with .jsonl extension (pi --session <path> creates/opens file)
387
- const sessionDir = finalSessionDir || ".pi-dev-output/pi-subagent-sessions";
388
- const safeAgentName = agent.name.replace(/[^a-zA-Z0-9_-]/g, "_");
389
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
390
- const wfSuffix = override.workflowId ? `_${override.workflowId}` : "";
391
- const sessionName = `${ts}_${safeAgentName}${wfSuffix}`;
392
- // Ensure session directory exists before spawning subagent
393
- try {
394
- fs.mkdirSync(sessionDir, { recursive: true });
395
- } catch { /* ignore if already exists */ }
396
- const sessionPath = path.join(sessionDir, `${sessionName}.jsonl`);
397
- args.push("--session", sessionPath);
398
- }
399
-
400
- if (finalNoContext) args.push("-nc");
401
- if (finalNoExtensions) args.push("-ne");
402
-
403
- args.push("--mode", finalMode);
404
- args.push("--thinking", finalThinking);
405
-
406
- // Only grant the tools the agent needs
407
- if (finalTools && finalTools.length > 0) {
408
- args.push("--tools", finalTools.join(","));
409
- }
410
-
411
- // Append agent system prompt (from AgentDef markdown body)
412
- if (systemPrompt) {
413
- args.push("--append-system-prompt", systemPrompt);
414
- }
415
-
416
- // Caller override system prompt (appended after agent's own)
417
- if (override.appendSystemPrompt) {
418
- args.push("--append-system-prompt", override.appendSystemPrompt);
419
- }
420
-
421
- // Auto-load append.system.md (global or project-level)
422
- // Fix #3: Sub-agents now follow append.system.md instructions
423
- try {
424
- const appendContent = loadAppendSystem(cwd);
425
- if (appendContent) {
426
- args.push("--append-system-prompt", appendContent);
427
- }
428
- } catch {
429
- // fail silently
430
- }
431
-
432
- // Extra raw args appended last (highest priority)
433
- if (finalExtraArgs && finalExtraArgs.length > 0) {
434
- args.push(...finalExtraArgs);
435
- }
436
-
437
- // The task itself
438
- args.push(task);
439
-
440
- const startTime = Date.now();
441
-
442
- return new Promise((resolve) => {
443
- const invocation = getPiInvocation(args);
444
- const proc = spawn(invocation.command, invocation.args, {
445
- cwd,
446
- shell: false,
447
- stdio: ["ignore", "pipe", "pipe"],
448
- });
449
-
450
- activeChildren.add(proc);
451
- proc.on("exit", () => {
452
- activeChildren.delete(proc);
453
- });
454
-
455
- let stdout = "";
456
- let stderr = "";
457
- let settled = false;
458
-
459
- // ── Real-time progress reporting ───────────────────────
460
- // Fix #3: More frequent updates (every 1.5s) + on every data chunk
461
- const progressTimer = setInterval(() => {
462
- if (settled || !onProgress) return;
463
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
464
- const lines = stdout.split("\n").filter((l) => l.trim());
465
- if (lines.length > 0) {
466
- const recent = lines.slice(-3).join("\n");
467
- onProgress(`[${agent.name}] (${elapsed}s) ${recent.slice(0, 200)}`);
468
- } else {
469
- onProgress(`[${agent.name}] (${elapsed}s) ⏳ 处理中...`);
470
- }
471
- }, PROGRESS_INTERVAL_MS);
472
- progressTimer.unref();
473
-
474
- const settle = (result: SubagentResult) => {
475
- if (settled) return;
476
- settled = true;
477
- clearInterval(progressTimer);
478
- result.durationMs = Date.now() - startTime;
479
- resolve(result);
480
- };
481
-
482
- // ── Stream stdout data + immediate progress callback ──
483
- // Fix #3: Flush progress on every data chunk
484
- // Fix #4: Preserve the END of the buffer (last N bytes) instead of the beginning,
485
- // because extractFinalOutput needs the final AI response which is at the end.
486
- proc.stdout.on("data", (data: Buffer) => {
487
- const chunk = data.toString();
488
- stdout += chunk;
489
- if (stdout.length > MAX_BUFFER_BYTES) {
490
- // Keep only the last MAX_BUFFER_BYTES bytes
491
- stdout = stdout.slice(stdout.length - MAX_BUFFER_BYTES);
492
- }
493
- // Immediate progress on new data
494
- if (!settled && onProgress) {
495
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
496
- const lines = chunk.split("\n").filter((l) => l.trim());
497
- if (lines.length > 0) {
498
- onProgress(`[${agent.name}] (${elapsed}s) ${lines.slice(-1)[0].slice(0, 150)}`);
499
- }
500
- }
501
- });
502
-
503
- proc.stderr.on("data", (data: Buffer) => {
504
- const chunk = data.toString();
505
- stderr += chunk;
506
- if (stderr.length > MAX_BUFFER_BYTES) {
507
- stderr = stderr.slice(stderr.length - MAX_BUFFER_BYTES);
508
- }
509
- });
510
-
511
- proc.on("close", (code) => {
512
- settle({ exitCode: code ?? 0, output: stdout, stderr, durationMs: 0 });
513
- });
514
- proc.on("error", () => {
515
- settle({ exitCode: 1, output: "", stderr: "Failed to spawn subagent process", durationMs: 0 });
516
- });
517
-
518
- // ── Timeout protection ──────────────────────────────
519
- const timer = setTimeout(() => {
520
- if (settled) return;
521
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
522
- try { proc.kill("SIGTERM"); } catch { /* already dead */ }
523
- // Give it a moment to terminate gracefully, then force kill
524
- setTimeout(() => {
525
- try { proc.kill("SIGKILL"); } catch { /* already dead */ }
526
- }, 2000);
527
- settle({
528
- exitCode: -1,
529
- output: stdout,
530
- stderr: stderr + `\n[ERROR] Subagent timed out after ${(effectiveTimeout / 1000).toFixed(0)}s (${elapsed}s elapsed)`,
531
- durationMs: 0,
532
- });
533
- }, effectiveTimeout);
534
- timer.unref();
535
-
536
- // ── Abort signal wiring ─────────────────────────────
537
- if (signal) {
538
- if (signal.aborted) {
539
- try { proc.kill("SIGTERM"); } catch { /* already dead */ }
540
- } else {
541
- const abortHandler = () => {
542
- try { proc.kill("SIGTERM"); } catch { /* already dead */ }
543
- };
544
- signal.addEventListener("abort", abortHandler, { once: true });
545
- }
546
- }
547
- });
548
- }
549
-
550
- export function extractFinalOutput(jsonOutput: string): string {
551
- // Parse JSON lines from --mode json output
552
- // Try multiple event formats to find the final assistant response
553
- let result = "";
554
- let textEndSeen = false;
555
-
556
- for (const line of jsonOutput.split("\n")) {
557
- if (!line.trim()) continue;
558
- try {
559
- const event = JSON.parse(line);
560
-
561
- // Format 1: pi's --mode json message_update with text_delta (streaming)
562
- // {"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"..."}}
563
- // Once text_end has been seen, skip subsequent text_delta events to
564
- // avoid appending stale deltas that arrive out of order.
565
- if (!textEndSeen && event.type === "message_update" &&
566
- event.assistantMessageEvent?.type === "text_delta") {
567
- result += event.assistantMessageEvent.delta || "";
568
- }
569
-
570
- // Format 1b: text_end has the complete accumulated text
571
- // {"type":"message_update","assistantMessageEvent":{"type":"text_end","content":"..."}}
572
- if (event.type === "message_update" &&
573
- event.assistantMessageEvent?.type === "text_end" &&
574
- event.assistantMessageEvent.content) {
575
- result = event.assistantMessageEvent.content;
576
- textEndSeen = true;
577
- }
578
-
579
- // Format 2: pi agent event — message_end / message_stop / message_complete
580
- // Only process assistant messages; toolResult and user messages contain
581
- // raw tool output (e.g. ls -la) that would pollute the extracted summary.
582
- if ((event.type === "message_stop" || event.type === "message_end" ||
583
- event.type === "message_complete") &&
584
- event.message?.content &&
585
- event.message?.role === "assistant") {
586
- const parts = Array.isArray(event.message.content)
587
- ? event.message.content
588
- : [event.message.content];
589
- for (const part of parts) {
590
- if (typeof part === "string") result = part;
591
- else if (part?.type === "text") result = part.text;
592
- }
593
- }
594
-
595
- // Format 3: content_block_delta with text deltas (fallback format)
596
- if (event.type === "content_block_delta" &&
597
- event.delta?.type === "text_delta") {
598
- result += event.delta.text;
599
- }
600
- if (event.type === "content_block_delta" &&
601
- event.delta?.text) {
602
- result += event.delta.text;
603
- }
604
-
605
- // Format 4: generic assistant response
606
- if (event.type === "assistant_message" && event.content) {
607
- result = typeof event.content === "string"
608
- ? event.content
609
- : (event.content.text || event.content.join?.(""));
610
- }
611
-
612
- // Format 5: text key at top level
613
- if (event.type === "complete" && event.text) {
614
- result = event.text;
615
- }
616
-
617
- // Format 5b: turn_end — extract text from the turn's assistant message.
618
- // This provides an additional extraction point when message_end is lost
619
- // due to buffer truncation (large tool results like cargo test output
620
- // can push the final assistant message beyond the buffer limit).
621
- if (event.type === "turn_end" &&
622
- event.message?.content &&
623
- event.message?.role === "assistant") {
624
- const parts = Array.isArray(event.message.content)
625
- ? event.message.content
626
- : [event.message.content];
627
- for (const part of parts) {
628
- if (typeof part === "string") result = part;
629
- else if (part?.type === "text") result = part.text;
630
- }
631
- }
632
-
633
- // Format 6: agent_end — iterate messages backwards to find the last
634
- // assistant message with text content. This catches edge cases where
635
- // the final assistant response has no type:text block (e.g. only
636
- // thinking/toolCall), which left result polluted by a prior tool result.
637
- if (event.type === "agent_end" && Array.isArray(event.messages)) {
638
- for (let i = event.messages.length - 1; i >= 0; i--) {
639
- const msg = event.messages[i];
640
- if (msg?.role === "assistant" && Array.isArray(msg.content)) {
641
- for (let j = msg.content.length - 1; j >= 0; j--) {
642
- const block = msg.content[j];
643
- if (typeof block === "string") {
644
- result = block;
645
- break;
646
- }
647
- if (block?.type === "text" && block.text) {
648
- result = block.text;
649
- break;
650
- }
651
- }
652
- if (result) break;
653
- }
654
- }
655
- }
656
- } catch {
657
- // If a line isn't JSON, it might be raw text output - collect it
658
- if (!result) {
659
- const trimmed = line.trim();
660
- if (trimmed && !trimmed.startsWith("{") && trimmed.length > 20) {
661
- result = (result + "\n" + trimmed).trim();
662
- }
663
- }
664
- }
665
- }
666
-
667
- // Fallback: if nothing parsed, return the raw stdout (truncated to reasonable size)
668
- if (!result && jsonOutput.trim()) {
669
- const cleaned = jsonOutput
670
- .split("\n")
671
- .filter((l) => {
672
- const t = l.trim();
673
- // Skip JSON lines and empty lines
674
- if (!t || t.startsWith("{")) return false;
675
- // Skip thinking blocks
676
- if (t.includes("thinking") && t.length < 30) return false;
677
- return true;
678
- })
679
- .join("\n")
680
- .trim();
681
- if (cleaned) result = cleaned;
682
- }
683
-
684
- return result;
685
- }
686
-
687
- // ── Extension ────────────────────────────────────────────────
688
-
689
- export default function (pi: ExtensionAPI) {
690
- const agents = discoverAgents();
691
- const gitAgent = agents.find((a) => a.name === "git-agent");
692
- const reviewAgent = agents.find((a) => a.name === "review-agent");
693
-
694
- if (agents.length === 0) {
695
- console.warn("[sub-agents] No agent markdown files found in package's agents/ directory");
696
- }
697
-
698
- // ── /subagent-stop - 主动终止所有正在运行的 sub-agent ──────
699
- pi.registerCommand("subagent-stop", {
700
- description: "Terminate all running sub-agents immediately. Also cancels any active workflow.",
701
- handler: async (_args, ctx) => {
702
- const childCount = activeChildren.size;
703
-
704
- // Also try to cancel any active workflow
705
- let workflowCancelled = false;
706
- try {
707
- const { cancelActiveWorkflow, isWorkflowRunning } = await import("./workflow-engine");
708
- if (isWorkflowRunning()) {
709
- cancelActiveWorkflow();
710
- workflowCancelled = true;
711
- }
712
- } catch { /* workflow-engine not available, ignore */ }
713
-
714
- if (childCount === 0 && !workflowCancelled) {
715
- ctx.ui.notify("i️ 当前没有运行中的 sub-agent 或工作流", "info");
716
- return;
717
- }
718
-
719
- if (childCount > 0) {
720
- killAllChildren();
721
- ctx.ui.notify(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, "info");
722
- ctx.ui.notify(`🛑 已终止 ${childCount} 个 sub-agent 进程`, "warning");
723
- ctx.ui.notify(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, "info");
724
- }
725
-
726
- if (workflowCancelled) {
727
- ctx.ui.notify(`🛑 已取消运行中的工作流`, "warning");
728
- }
729
- },
730
- });
731
-
732
- // ── Register subagent tool (for LLM to use directly) ──────
733
- pi.registerTool({
734
- name: "subagent",
735
- label: "Subagent",
736
- description: [
737
- "Delegate a task to a specialized sub-agent with an isolated context window.",
738
- "The sub-agent runs in a separate pi process.",
739
- `Available agents: ${agents.map((a) => a.name).join(", ") || "none"}`,
740
- "For quick git operations (commit/push), prefer the /git-commit, /git-push, /git-commit-push commands.",
741
- ].join(" "),
742
- parameters: Type.Object({
743
- agent: Type.String({ description: "Name of the agent to invoke" }),
744
- task: Type.String({ description: "Task description to delegate" }),
745
- // Optional overrides (pass-through to SubagentArgs)
746
- thinkingLevel: Type.Optional(Type.String({ description: "Override thinking level: off | low | medium | high | xhigh" })),
747
- session: Type.Optional(Type.Boolean({ description: "Override session persistence" })),
748
- noContext: Type.Optional(Type.Boolean({ description: "Override -nc (skip AGENTS.md/CLAUDE.md)" })),
749
- noExtensions: Type.Optional(Type.Boolean({ description: "Override -ne (disable extensions)" })),
750
- mode: Type.Optional(Type.String({ description: "Override output mode: json | text" })),
751
- tools: Type.Optional(Type.String({ description: "Override tool whitelist (comma-separated)" })),
752
- extraArgs: Type.Optional(Type.String({ description: "Extra raw CLI args (space-separated)" })),
753
- }),
754
- async execute(toolCallId, params, signal, onUpdate, ctx) {
755
- const agent = agents.find((a) => a.name === params.agent);
756
- if (!agent) {
757
- return {
758
- content: [{
759
- type: "text",
760
- text: `Unknown agent "${params.agent}". Available: ${agents.map((a) => a.name).join(", ")}`,
761
- }],
762
- details: {},
763
- };
764
- }
765
- const abortSignal = signal ?? ctx.signal;
766
-
767
- // Fix #3: Report progress via onUpdate
768
- onUpdate?.({ state: "running", message: `🤖 正在委派 ${agent.name} 处理...` });
769
-
770
- // Build SubagentArgs from optional tool params
771
- const toolArgs: SubagentArgs = {};
772
- if (params.thinkingLevel !== undefined) toolArgs.thinkingLevel = params.thinkingLevel as string;
773
- if (params.session !== undefined) toolArgs.session = params.session as boolean;
774
- if (params.noContext !== undefined) toolArgs.noContext = params.noContext as boolean;
775
- if (params.noExtensions !== undefined) toolArgs.noExtensions = params.noExtensions as boolean;
776
- if (params.mode !== undefined) toolArgs.mode = params.mode as string;
777
- if (params.tools !== undefined) toolArgs.tools = (params.tools as string).split(",").map(t => t.trim()).filter(Boolean);
778
- if (params.extraArgs !== undefined) toolArgs.extraArgs = (params.extraArgs as string).split(/\s+/).filter(Boolean);
779
-
780
- const result = await spawnSubagent(
781
- agent,
782
- params.task,
783
- ctx.cwd,
784
- abortSignal,
785
- undefined, // use agent's default timeout
786
- (progress) => {
787
- onUpdate?.({ state: "running", message: progress });
788
- },
789
- Object.keys(toolArgs).length > 0 ? toolArgs : undefined,
790
- );
791
- const output = extractFinalOutput(result.output);
792
- const dur = (result.durationMs / 1000).toFixed(1);
793
- const isError = result.exitCode !== 0 && !output;
794
- return {
795
- content: [{ type: "text", text: output || result.stderr || "(no output)" }],
796
- details: { agent: agent.name, exitCode: result.exitCode, durationMs: result.durationMs },
797
- isError,
798
- };
799
- },
800
- });
801
-
802
- // ── Review sub-agent (input interception) ─────────────────
803
- // NON-BLOCKING MODE: 用户可选择"后台审查",不阻塞主对话,
804
- // 审查完成后通过 sendMessage 自动将结果注入会话。
805
- // 同时保留原有的阻塞模式供需要同步等待的场景使用。
806
-
807
- pi.on("input", async (event, ctx) => {
808
- if (!reviewAgent) return { action: "continue" };
809
-
810
- const text = event.text.trim().toLowerCase();
811
-
812
- // Detect review-html skill invocation or explicit review request.
813
- const isReviewSkill = text.startsWith("/skill:review-html");
814
- const hasReviewIntent = text.includes("review") ||
815
- text.includes("审查") || text.includes("审阅") || text.includes("review-html");
816
- const hasCodeTarget = text.includes("code") || text.includes("代码") ||
817
- text.includes("diff") || text.includes("commit") ||
818
- text.includes("html") || text.includes("report") || text.includes("报告") ||
819
- text.includes("本次改动") || text.includes("这次改动");
820
- const isReviewRequest = !isReviewSkill && hasReviewIntent && hasCodeTarget;
821
-
822
- if (!isReviewSkill && !isReviewRequest) return { action: "continue" };
823
-
824
- // 自动触发 /skill:review-html 不询问,直接以"仅审查"模式运行(阻塞,不发送原消息)
825
- if (isReviewSkill) {
826
- ctx.ui.setStatus("subagent", "🔍 review-sub-agent reviewing...");
827
- ctx.ui.notify("🤖 review-sub-agent 正在审查代码(最长 5 分钟),请稍候...", "info");
828
-
829
- const startTime = Date.now();
830
- const result = await spawnSubagent(
831
- reviewAgent,
832
- event.text,
833
- ctx.cwd,
834
- ctx.signal,
835
- undefined,
836
- (progress) => {
837
- ctx.ui.setStatus("subagent", progress.slice(0, 50));
838
- },
839
- );
840
- const dur = ((Date.now() - startTime) / 1000).toFixed(1);
841
- ctx.ui.setStatus("subagent", undefined);
842
-
843
- const filePath = findNewestReviewHtml(ctx.cwd);
844
-
845
- if (filePath) {
846
- ctx.ui.notify(`📄 ${filePath} (${dur}s)`, "success");
847
- } else {
848
- ctx.ui.notify(`✅ review-sub-agent 完成 (${dur}s)`, "info");
849
- }
850
- return { action: "handled" };
851
- }
852
-
853
- // 对于普通关键词触发的审查请求,询问用户选择模式
854
- // ctx.ui.select 接受 string[],返回选中的字符串
855
- const mode = await uiSelect(
856
- ctx,
857
- "🔍 检测到审查意图",
858
- [
859
- "1. 后台审查(非阻塞,异步通知)",
860
- "2. 仅审查(阻塞,等待结果)",
861
- "3. 不是审查(放行给主代理)",
862
- ],
863
- );
864
-
865
- if (!mode || mode.startsWith("3")) {
866
- // 用户取消(Esc)或选择"不是审查",直接放行给主代理
867
- return { action: "continue" };
868
- }
869
-
870
- const isAsync = mode.startsWith("1");
871
-
872
- if (!isAsync) {
873
- // 阻塞模式:拦截原消息,同步等待审查完成
874
- ctx.ui.setStatus("subagent", "🔍 review-sub-agent reviewing...");
875
- ctx.ui.notify("🤖 review-sub-agent 正在审查代码(最长 5 分钟),请稍候...", "info");
876
-
877
- const startTime = Date.now();
878
- const result = await spawnSubagent(
879
- reviewAgent,
880
- event.text,
881
- ctx.cwd,
882
- ctx.signal,
883
- undefined,
884
- (progress) => {
885
- ctx.ui.setStatus("subagent", progress.slice(0, 50));
886
- },
887
- );
888
- const dur = ((Date.now() - startTime) / 1000).toFixed(1);
889
- ctx.ui.setStatus("subagent", undefined);
890
-
891
- const filePath = findNewestReviewHtml(ctx.cwd);
892
-
893
- if (filePath) {
894
- ctx.ui.notify(`📄 ${filePath} (${dur}s)`, "success");
895
- } else {
896
- ctx.ui.notify(`✅ review-sub-agent 完成 (${dur}s)`, "info");
897
- }
898
- return { action: "handled" };
899
- }
900
-
901
- // 非阻塞异步模式:后台运行审查,不阻塞主对话,也不将原消息发给主代理
902
- ctx.ui.notify("🔍 已在后台启动代码审查,完成后会在此对话中通知您。", "info");
903
-
904
- // 注意:不能在 async 回调中直接使用 ctx,因为 ctx 可能已失效,
905
- // 但我们可以使用闭包捕获的 pi 和必要参数。
906
- const userTask = event.text;
907
- const cwd = ctx.cwd;
908
- const abortSignal = ctx.signal;
909
-
910
- // 启动后台任务(不等待),完成后通过 sendMessage 注入结果
911
- (async () => {
912
- try {
913
- const startTime = Date.now();
914
- const result = await spawnSubagent(
915
- reviewAgent,
916
- userTask,
917
- cwd,
918
- abortSignal,
919
- undefined,
920
- );
921
- const dur = ((Date.now() - startTime) / 1000).toFixed(1);
922
-
923
- const filePath = findNewestReviewHtml(cwd);
924
-
925
- if (filePath) {
926
- pi.sendMessage({
927
- customType: "review-result",
928
- content: `🔍 **代码审查完成** (耗时 ${dur}s)\n\n报告已生成:\n\`${filePath}\`\n\n您可以打开该文件查看详细审查意见。`,
929
- display: true,
930
- details: { filePath, durationMs: result.durationMs },
931
- });
932
- } else {
933
- const output = extractFinalOutput(result.output) || result.stderr || "无输出";
934
- pi.sendMessage({
935
- customType: "review-result",
936
- content: `🔍 **代码审查完成** (耗时 ${dur}s)\n\n\`\`\`\n${output.slice(0, 2000)}${output.length > 2000 ? "\n...(内容已截断)" : ""}\n\`\`\``,
937
- display: true,
938
- details: { durationMs: result.durationMs },
939
- });
940
- }
941
- } catch (err) {
942
- console.error("[sub-agents] Background review failed:", err);
943
- pi.sendMessage({
944
- customType: "review-result",
945
- content: `❌ 后台代码审查失败:${err instanceof Error ? err.message : String(err)}`,
946
- display: true,
947
- });
948
- }
949
- })();
950
-
951
- // 异步审查也不放行原消息给主代理
952
- return { action: "handled" };
953
- });
954
- }