@wolido/async-subagent-isolation 1.0.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 (24) hide show
  1. package/ADVANCED.en.md +305 -0
  2. package/ADVANCED.md +305 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +294 -0
  5. package/README.md +294 -0
  6. package/examples/README.en.md +100 -0
  7. package/examples/README.md +100 -0
  8. package/examples/pi/agent/agents/coder.md +36 -0
  9. package/examples/pi/agent/agents/reviewer.md +39 -0
  10. package/examples/pi/agent/agents/writer.md +36 -0
  11. package/examples/pi/agent/master.md +63 -0
  12. package/examples/pi/agent/skills/brainstorming/SKILL.md +54 -0
  13. package/examples/pi/agent/skills/systematic-debugging/SKILL.md +319 -0
  14. package/examples/pi/agent/skills/writing-clearly-and-concisely/SKILL.md +88 -0
  15. package/examples/pi/agent/skills/writing-clearly-and-concisely/references/02-elementary-rules-of-usage.md +214 -0
  16. package/examples/pi/agent/skills/writing-clearly-and-concisely/references/03-elementary-principles-of-composition.md +394 -0
  17. package/examples/pi/agent/skills/writing-clearly-and-concisely/references/04-a-few-matters-of-form.md +90 -0
  18. package/examples/pi/agent/skills/writing-clearly-and-concisely/references/05-words-and-expressions-commonly-misused.md +346 -0
  19. package/examples/pi/agent/skills/writing-clearly-and-concisely/references/common-issues.md +22 -0
  20. package/examples/pi/agent/skills/writing-clearly-and-concisely/references/full-example.md +19 -0
  21. package/examples/pi/agent/skills/writing-clearly-and-concisely/references/signs-of-ai-writing.md +345 -0
  22. package/logo.svg +33 -0
  23. package/package.json +72 -0
  24. package/src/index.ts +2300 -0
package/src/index.ts ADDED
@@ -0,0 +1,2300 @@
1
+ /**
2
+ * Subagent Tool - Delegate tasks to specialized agents
3
+ *
4
+ * Spawns a separate `pi` process for each subagent invocation,
5
+ * giving it an isolated context window.
6
+ *
7
+ * Supports single mode: { agent: "name", task: "..." }
8
+ *
9
+ * Uses JSON mode to capture structured output from subagents.
10
+ *
11
+ * Modified: per-agent skill directory isolation via --no-skills --skill args.
12
+ */
13
+
14
+ import { spawn, type ChildProcess } from "node:child_process";
15
+ import * as fs from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+ import * as crypto from "node:crypto";
19
+ import type { Message } from "@earendil-works/pi-ai";
20
+ import { StringEnum } from "@earendil-works/pi-ai";
21
+ import {
22
+ DynamicBorder,
23
+ type ExtensionAPI,
24
+ type ExtensionContext,
25
+ getMarkdownTheme,
26
+ withFileMutationQueue,
27
+ getAgentDir,
28
+ parseFrontmatter,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import { Box, Container, Key, Markdown, matchesKey, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
31
+ import { Type } from "typebox";
32
+
33
+ // ===== UUID v7 helper =====
34
+
35
+ /** Generate a UUID v7 (timestamp + random) without external dependencies. */
36
+ function uuidv7(): string {
37
+ const timestamp = Date.now();
38
+ const rand = crypto.randomBytes(10);
39
+ const bytes = new Uint8Array(16);
40
+ const view = new DataView(bytes.buffer);
41
+ // high 16 bits of the 48-bit millisecond timestamp
42
+ view.setUint16(0, Math.floor(timestamp / 0x100000000));
43
+ // low 32 bits of the 48-bit millisecond timestamp
44
+ view.setUint32(2, timestamp & 0xffffffff);
45
+ // version = 7 (high nibble)
46
+ bytes[6] = (rand[0] & 0x0f) | 0x70;
47
+ bytes[7] = rand[1];
48
+ // variant = 10xxxxxx
49
+ bytes[8] = (rand[2] & 0x3f) | 0x80;
50
+ bytes.set(rand.subarray(3), 9);
51
+
52
+ let hex = "";
53
+ for (const b of bytes) {
54
+ hex += b.toString(16).padStart(2, "0");
55
+ }
56
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
57
+ }
58
+
59
+ // ===== Inlined agents.ts with skills support =====
60
+
61
+ type AgentScope = "user" | "project" | "both";
62
+
63
+ /** Minimal model info for passing current model to subagents */
64
+ interface CurrentModel {
65
+ provider: string;
66
+ id: string;
67
+ }
68
+
69
+ interface AgentConfig {
70
+ name: string;
71
+ description: string;
72
+ tools?: string[];
73
+ model?: string;
74
+ thinking?: string;
75
+ skills?: string[];
76
+ systemPrompt: string;
77
+ source: "user" | "project";
78
+ filePath: string;
79
+ }
80
+
81
+ interface AgentDiscoveryResult {
82
+ agents: AgentConfig[];
83
+ projectAgentsDir: string | null;
84
+ }
85
+
86
+ function parseListField(value: unknown): string[] | undefined {
87
+ if (value === undefined || value === null) return undefined;
88
+ if (Array.isArray(value)) {
89
+ return (value as unknown[]).map(s => String(s).trim()).filter(Boolean);
90
+ }
91
+ if (typeof value === "string") {
92
+ return value.split(",").map(s => s.trim()).filter(Boolean);
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
98
+ const agents: AgentConfig[] = [];
99
+ if (!fs.existsSync(dir)) return agents;
100
+ let entries: fs.Dirent[];
101
+ try {
102
+ entries = fs.readdirSync(dir, { withFileTypes: true });
103
+ } catch {
104
+ return agents;
105
+ }
106
+ for (const entry of entries) {
107
+ if (!entry.name.endsWith(".md")) continue;
108
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
109
+ const filePath = path.join(dir, entry.name);
110
+ let content: string;
111
+ try {
112
+ content = fs.readFileSync(filePath, "utf-8");
113
+ } catch {
114
+ continue;
115
+ }
116
+ const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
117
+ if (!frontmatter.name || !frontmatter.description) continue;
118
+ const tools = parseListField(frontmatter.tools);
119
+ const hasSkills = "skills" in frontmatter;
120
+ const skills = hasSkills ? parseListField(frontmatter.skills) ?? [] : undefined;
121
+ const rawThinking = frontmatter.thinking;
122
+ const thinking =
123
+ typeof rawThinking === "string" && isThinkingLevel(rawThinking)
124
+ ? rawThinking
125
+ : undefined;
126
+ agents.push({
127
+ name: frontmatter.name as string,
128
+ description: frontmatter.description as string,
129
+ tools: tools && tools.length > 0 ? tools : undefined,
130
+ model: frontmatter.model as string | undefined,
131
+ thinking,
132
+ skills,
133
+ systemPrompt: body,
134
+ source,
135
+ filePath,
136
+ });
137
+ }
138
+ return agents;
139
+ }
140
+
141
+ function isDirectory(p: string): boolean {
142
+ try {
143
+ return fs.statSync(p).isDirectory();
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ function findNearestProjectAgentsDir(cwd: string): string | null {
150
+ let currentDir = cwd;
151
+ while (true) {
152
+ const candidate = path.join(currentDir, ".pi", "agents");
153
+ if (isDirectory(candidate)) return candidate;
154
+ const parentDir = path.dirname(currentDir);
155
+ if (parentDir === currentDir) return null;
156
+ currentDir = parentDir;
157
+ }
158
+ }
159
+
160
+ // ===== Thinking level / model override config =====
161
+
162
+ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
163
+
164
+ /** Check whether a value is a valid thinking level (case-sensitive). */
165
+ export function isThinkingLevel(value: string): boolean {
166
+ return typeof value === "string" && THINKING_LEVELS.has(value);
167
+ }
168
+
169
+ export interface ModelOverride {
170
+ model?: string;
171
+ thinking?: string;
172
+ }
173
+
174
+ /**
175
+ * Normalize a raw override value from the config file.
176
+ * - Non-empty string -> { model: value } (legacy format)
177
+ * - Object -> keep only valid `model` (non-empty string) and `thinking`
178
+ * (valid thinking level) fields
179
+ * - Anything else -> undefined
180
+ */
181
+ export function normalizeOverride(value: unknown): ModelOverride | undefined {
182
+ if (typeof value === "string") {
183
+ const trimmed = value.trim();
184
+ return trimmed ? { model: trimmed } : undefined;
185
+ }
186
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
187
+ const record = value as Record<string, unknown>;
188
+ const result: ModelOverride = {};
189
+ if (typeof record.model === "string") {
190
+ const modelTrimmed = record.model.trim();
191
+ if (modelTrimmed) result.model = modelTrimmed;
192
+ }
193
+ if (typeof record.thinking === "string" && isThinkingLevel(record.thinking)) result.thinking = record.thinking;
194
+ return result.model !== undefined || result.thinking !== undefined ? result : undefined;
195
+ }
196
+
197
+ /**
198
+ * Load a single model-overrides JSON file. Returns {} on any error
199
+ * (missing/unreadable file, invalid JSON, or invalid values), logging a
200
+ * warning to help troubleshoot configuration problems.
201
+ */
202
+ export function loadModelOverridesFile(filePath: string): Record<string, ModelOverride> {
203
+ try {
204
+ const content = fs.readFileSync(filePath, "utf-8");
205
+ const parsed: unknown = JSON.parse(content);
206
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
207
+ console.warn(`[async-subagent-isolation] ${filePath}: expected a JSON object, ignoring.`);
208
+ return {};
209
+ }
210
+ const overrides: Record<string, ModelOverride> = {};
211
+ for (const [key, value] of Object.entries(parsed)) {
212
+ const normalized = normalizeOverride(value);
213
+ if (normalized) overrides[key] = normalized;
214
+ }
215
+ return overrides;
216
+ } catch (err) {
217
+ if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) {
218
+ console.warn(`[async-subagent-isolation] failed to load ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
219
+ }
220
+ return {};
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Load model overrides from the user-level config
226
+ * (~/.pi/agent/subagent-isolation.json) merged with the nearest project-level
227
+ * config (.pi/subagent-isolation.json found by walking up from cwd).
228
+ * Project-level entries override user-level entries by key.
229
+ */
230
+ export function loadModelOverrides(cwd: string): Record<string, ModelOverride> {
231
+ const userOverrides = loadModelOverridesFile(path.join(getAgentDir(), "subagent-isolation.json"));
232
+ let currentDir = cwd;
233
+ while (true) {
234
+ const candidate = path.join(currentDir, ".pi", "subagent-isolation.json");
235
+ if (fs.existsSync(candidate)) {
236
+ const projectOverrides = loadModelOverridesFile(candidate);
237
+ return { ...userOverrides, ...projectOverrides };
238
+ }
239
+ const parentDir = path.dirname(currentDir);
240
+ if (parentDir === currentDir) return userOverrides;
241
+ currentDir = parentDir;
242
+ }
243
+ }
244
+
245
+ function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
246
+ const userDir = path.join(getAgentDir(), "agents");
247
+ const projectAgentsDir = findNearestProjectAgentsDir(cwd);
248
+ const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
249
+ const projectAgents =
250
+ scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
251
+ const agentMap = new Map<string, AgentConfig>();
252
+ if (scope === "both") {
253
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
254
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
255
+ } else if (scope === "user") {
256
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
257
+ } else {
258
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
259
+ }
260
+ return { agents: Array.from(agentMap.values()), projectAgentsDir };
261
+ }
262
+
263
+ // ===== Original index.ts =====
264
+
265
+ const COLLAPSED_ITEM_COUNT = 10;
266
+
267
+ function formatTokens(count: number): string {
268
+ if (count < 1000) return count.toString();
269
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
270
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
271
+ return `${(count / 1000000).toFixed(1)}M`;
272
+ }
273
+
274
+ function formatPhase(phase: string): string {
275
+ if (phase === "thinking") return "🤔 thinking...";
276
+ if (phase === "waiting") return "⏳ waiting for next step...";
277
+ if (phase.startsWith("tooling:")) {
278
+ const tool = phase.slice(8);
279
+ return `⚡ ${tool}...`;
280
+ }
281
+ return "(running...)";
282
+ }
283
+
284
+ function formatUsageStats(
285
+ usage: {
286
+ input: number;
287
+ output: number;
288
+ cacheRead: number;
289
+ cacheWrite: number;
290
+ cost: number;
291
+ contextTokens?: number;
292
+ turns?: number;
293
+ },
294
+ model?: string,
295
+ ): string {
296
+ const parts: string[] = [];
297
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
298
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
299
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
300
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
301
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
302
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
303
+ if (usage.contextTokens && usage.contextTokens > 0) {
304
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
305
+ }
306
+ if (model) parts.push(model);
307
+ return parts.join(" ");
308
+ }
309
+
310
+ function formatToolCall(
311
+ toolName: string,
312
+ args: Record<string, unknown>,
313
+ themeFg: (color: any, text: string) => string,
314
+ ): string {
315
+ const shortenPath = (p: string) => {
316
+ const home = os.homedir();
317
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
318
+ };
319
+
320
+ switch (toolName) {
321
+ case "bash": {
322
+ // Flatten newlines so the preview stays on a single rendered line.
323
+ const command = ((args.command as string) || "...").replace(/\n/g, "↵");
324
+ const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
325
+ return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
326
+ }
327
+ case "read": {
328
+ const rawPath = (args.file_path || args.path || "...") as string;
329
+ const filePath = shortenPath(rawPath);
330
+ const offset = args.offset as number | undefined;
331
+ const limit = args.limit as number | undefined;
332
+ let text = themeFg("accent", filePath);
333
+ if (offset !== undefined || limit !== undefined) {
334
+ const startLine = offset ?? 1;
335
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
336
+ text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
337
+ }
338
+ return themeFg("muted", "read ") + text;
339
+ }
340
+ case "write": {
341
+ const rawPath = (args.file_path || args.path || "...") as string;
342
+ const filePath = shortenPath(rawPath);
343
+ const content = (args.content || "") as string;
344
+ const lines = content.split("\n").length;
345
+ let text = themeFg("muted", "write ") + themeFg("accent", filePath);
346
+ if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
347
+ return text;
348
+ }
349
+ case "edit": {
350
+ const rawPath = (args.file_path || args.path || "...") as string;
351
+ return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
352
+ }
353
+ case "ls": {
354
+ const rawPath = (args.path || ".") as string;
355
+ return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
356
+ }
357
+ case "find": {
358
+ const pattern = (args.pattern || "*") as string;
359
+ const rawPath = (args.path || ".") as string;
360
+ return (
361
+ themeFg("muted", "find ") +
362
+ themeFg("accent", pattern) +
363
+ themeFg("dim", ` in ${shortenPath(rawPath)}`)
364
+ );
365
+ }
366
+ case "grep": {
367
+ const pattern = (args.pattern || "") as string;
368
+ const rawPath = (args.path || ".") as string;
369
+ return (
370
+ themeFg("muted", "grep ") +
371
+ themeFg("accent", `/${pattern}/`) +
372
+ themeFg("dim", ` in ${shortenPath(rawPath)}`)
373
+ );
374
+ }
375
+ default: {
376
+ let argsStr: string;
377
+ try {
378
+ argsStr = JSON.stringify(args);
379
+ } catch {
380
+ argsStr = "[unserializable]";
381
+ }
382
+ const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
383
+ return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
384
+ }
385
+ }
386
+ }
387
+
388
+ interface UsageStats {
389
+ input: number;
390
+ output: number;
391
+ cacheRead: number;
392
+ cacheWrite: number;
393
+ cost: number;
394
+ contextTokens: number;
395
+ turns: number;
396
+ }
397
+
398
+ interface SingleResult {
399
+ agent: string;
400
+ agentSource: "user" | "project" | "unknown";
401
+ task: string;
402
+ exitCode: number;
403
+ messages: Message[];
404
+ stderr: string;
405
+ usage: UsageStats;
406
+ model?: string;
407
+ stopReason?: string;
408
+ errorMessage?: string;
409
+ step?: number;
410
+ phase: "idle" | "thinking" | `tooling:${string}` | "waiting";
411
+ lastPhaseChange: number;
412
+ thinkingBuffer?: string;
413
+ sessionId: string;
414
+ }
415
+
416
+ interface SubagentDetails {
417
+ mode: "single";
418
+ agentScope: AgentScope;
419
+ projectAgentsDir: string | null;
420
+ results: SingleResult[];
421
+ }
422
+
423
+ function getFinalOutput(messages: Message[]): string {
424
+ for (let i = messages.length - 1; i >= 0; i--) {
425
+ const msg = messages[i];
426
+ if (msg.role === "assistant") {
427
+ for (const part of msg.content) {
428
+ if (part.type === "text") return part.text;
429
+ }
430
+ }
431
+ }
432
+ return "";
433
+ }
434
+
435
+ type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
436
+
437
+ function getDisplayItems(messages: Message[]): DisplayItem[] {
438
+ const items: DisplayItem[] = [];
439
+ for (const msg of messages) {
440
+ if (msg.role === "assistant") {
441
+ for (const part of msg.content) {
442
+ if (part.type === "text") items.push({ type: "text", text: part.text });
443
+ else if (part.type === "toolCall")
444
+ items.push({ type: "toolCall", name: part.name, args: part.arguments });
445
+ }
446
+ }
447
+ }
448
+ return items;
449
+ }
450
+
451
+ function formatSubagentDiagnostics(result: SingleResult, maxTraceItems = 15): string {
452
+ const lines: string[] = [];
453
+ lines.push(`Agent: ${result.agent} (${result.agentSource})`);
454
+ lines.push(`Exit code: ${result.exitCode}`);
455
+ if (result.stopReason) lines.push(`Stop reason: ${result.stopReason}`);
456
+ if (result.errorMessage) lines.push(`Error message: ${result.errorMessage}`);
457
+
458
+ const stderr = result.stderr.trim();
459
+ if (stderr) {
460
+ lines.push(`Stderr:\n${stderr.slice(0, 800)}${stderr.length > 800 ? "\n..." : ""}`);
461
+ }
462
+
463
+ const items = getDisplayItems(result.messages);
464
+ if (items.length > 0) {
465
+ lines.push("Execution trace:");
466
+ const start = Math.max(0, items.length - maxTraceItems);
467
+ if (start > 0) lines.push(` ... ${start} earlier items omitted`);
468
+ for (let i = start; i < items.length; i++) {
469
+ const item = items[i];
470
+ if (item.type === "text") {
471
+ const text = item.text.trim();
472
+ if (text) {
473
+ const firstLine = text.split("\n")[0];
474
+ const preview = firstLine.slice(0, 120);
475
+ lines.push(` [text] ${preview}${firstLine.length > 120 ? "..." : ""}`);
476
+ }
477
+ } else {
478
+ let argsStr: string;
479
+ try {
480
+ argsStr = JSON.stringify(item.args);
481
+ } catch {
482
+ argsStr = "[unserializable]";
483
+ }
484
+ const preview = argsStr.slice(0, 100);
485
+ lines.push(` [tool] ${item.name}: ${preview}${argsStr.length > 100 ? "..." : ""}`);
486
+ }
487
+ }
488
+ }
489
+
490
+ const finalOutput = getFinalOutput(result.messages);
491
+ if (finalOutput && finalOutput !== result.errorMessage) {
492
+ lines.push(`Final output:\n${finalOutput.trim().slice(0, 500)}${finalOutput.length > 500 ? "\n..." : ""}`);
493
+ }
494
+
495
+ return lines.join("\n");
496
+ }
497
+
498
+ async function writePromptToTempFile(
499
+ agentName: string,
500
+ prompt: string,
501
+ ): Promise<{ dir: string; filePath: string }> {
502
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
503
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
504
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
505
+ await withFileMutationQueue(filePath, async () => {
506
+ await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
507
+ });
508
+ return { dir: tmpDir, filePath };
509
+ }
510
+
511
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
512
+ const currentScript = process.argv[1];
513
+ if (currentScript && fs.existsSync(currentScript)) {
514
+ return { command: process.execPath, args: [currentScript, ...args] };
515
+ }
516
+
517
+ const execName = path.basename(process.execPath).toLowerCase();
518
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
519
+ if (!isGenericRuntime) {
520
+ return { command: process.execPath, args };
521
+ }
522
+
523
+ return { command: "pi", args };
524
+ }
525
+
526
+ // ===== Subagent progress widget =====
527
+
528
+ interface SubagentProgressUpdate {
529
+ phase?: SingleResult["phase"];
530
+ currentTool?: string;
531
+ recentTools?: string[];
532
+ }
533
+
534
+ type SubagentProgressCallback = (update: SubagentProgressUpdate) => void;
535
+
536
+ interface AgentProgress {
537
+ taskId: string;
538
+ name: string;
539
+ phase: SingleResult["phase"];
540
+ currentTool?: string;
541
+ recentTools: string[];
542
+ startedAt: number;
543
+ }
544
+
545
+ const PROGRESS_WIDGET_KEY = "async-subagent-isolation-progress";
546
+ const MAX_WIDGET_LINES = 20;
547
+ const MAX_RECENT_TOOLS = 3;
548
+
549
+ /** Plain-text one-line summary of a tool call for the progress widget (no theme colors). */
550
+ function summarizeToolCall(toolName: string, args: Record<string, any>): string {
551
+ const raw = ((args.file_path || args.path || args.command || args.pattern || "") as string).replace(/\n/g, "↵");
552
+ const home = os.homedir();
553
+ const shortened = raw.startsWith(home) ? `~${raw.slice(home.length)}` : raw;
554
+ return truncateToWidth(shortened ? `${toolName} ${shortened}` : toolName, 60);
555
+ }
556
+
557
+ function getRecentToolSummaries(messages: Message[]): string[] {
558
+ return getDisplayItems(messages)
559
+ .filter((item): item is Extract<DisplayItem, { type: "toolCall" }> => item.type === "toolCall")
560
+ .slice(-MAX_RECENT_TOOLS)
561
+ .map((item) => summarizeToolCall(item.name, item.args));
562
+ }
563
+
564
+ export function formatElapsed(startedAt: number): string {
565
+ const totalSec = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
566
+ const mm = String(Math.floor(totalSec / 60)).padStart(2, "0");
567
+ const ss = String(totalSec % 60).padStart(2, "0");
568
+ return `${mm}:${ss}`;
569
+ }
570
+
571
+ /**
572
+ * Tracks progress of all running subagents and renders it as a widget above
573
+ * the editor. A single 1Hz interval drives widget refreshes; update() only
574
+ * mutates in-memory state and never triggers a render.
575
+ */
576
+ export class SubagentProgressManager {
577
+ private agents = new Map<string, AgentProgress>();
578
+ private timer: ReturnType<typeof setInterval> | null = null;
579
+ private ctx: ExtensionContext | null = null;
580
+ private widgetSet = false;
581
+
582
+ register(ctx: ExtensionContext, sessionId: string, name: string): void {
583
+ this.ctx = ctx;
584
+ this.agents.set(sessionId, {
585
+ taskId: sessionId,
586
+ name,
587
+ phase: "idle",
588
+ recentTools: [],
589
+ startedAt: Date.now(),
590
+ });
591
+ this.ensureTimer();
592
+ this.refresh();
593
+ }
594
+
595
+ update(sessionId: string, update: SubagentProgressUpdate): void {
596
+ const agent = this.agents.get(sessionId);
597
+ if (!agent) return;
598
+ if (update.phase !== undefined) agent.phase = update.phase;
599
+ if (update.currentTool !== undefined) agent.currentTool = update.currentTool;
600
+ if (update.recentTools !== undefined) agent.recentTools = update.recentTools;
601
+ }
602
+
603
+ unregister(sessionId: string): void {
604
+ if (!this.agents.has(sessionId)) return;
605
+ this.agents.delete(sessionId);
606
+ if (this.agents.size === 0) {
607
+ this.stopTimer();
608
+ if (this.widgetSet && this.ctx?.hasUI) this.ctx.ui.setWidget(PROGRESS_WIDGET_KEY, undefined);
609
+ this.widgetSet = false;
610
+ this.ctx = null;
611
+ } else {
612
+ this.refresh();
613
+ }
614
+ }
615
+
616
+ refresh(): void {
617
+ if (!this.ctx?.hasUI) return;
618
+ const sorted = [...this.agents.values()].sort((a, b) => a.startedAt - b.startedAt);
619
+ if (sorted.length === 0) return;
620
+ // Over the row budget, truncate the earliest-started agents first
621
+ // (total widget lines = maxAgentRows + 2 border lines).
622
+ const maxAgentRows = Math.max(1, Math.min(process.stdout.rows || MAX_WIDGET_LINES, MAX_WIDGET_LINES));
623
+ const visible = sorted.slice(-maxAgentRows);
624
+ const total = sorted.length;
625
+ // Note: the factory closes over `theme`; between a theme change and the next
626
+ // refresh (≤1s) the widget may briefly use the stale theme. The 1Hz timer
627
+ // replaces the factory on every tick, so this self-heals.
628
+ this.ctx.ui.setWidget(PROGRESS_WIDGET_KEY, (_tui, theme) => {
629
+ const borderColor = (s: string) => theme.fg("border", s);
630
+ const bottomBorder = new DynamicBorder(borderColor);
631
+ // Top horizontal separator with the agent count embedded in the line.
632
+ const renderTopBorder = (width: number): string => {
633
+ const label = ` Subagents (${total}) `;
634
+ const labelWidth = visibleWidth(label);
635
+ // Too narrow for the label: fall back to a plain separator. When
636
+ // width = labelWidth + 1 the label still fits (leading "─" + label,
637
+ // no trailing "─"); this asymmetric look is accepted on narrow
638
+ // terminals.
639
+ if (labelWidth + 1 > width) return bottomBorder.render(width)[0];
640
+ return borderColor("─") + theme.fg("accent", label) + borderColor("─".repeat(width - labelWidth - 1));
641
+ };
642
+ const renderRow = (a: AgentProgress, width: number): string => {
643
+ const nameCol = a.name;
644
+ const phaseCol = truncateToWidth(formatPhase(a.phase), 20, "...", true);
645
+ const toolHint = a.recentTools.length > 0 ? ` → ${a.recentTools[a.recentTools.length - 1]}` : "";
646
+ const line =
647
+ `${theme.fg("success", "●")} ${theme.fg("dim", a.taskId)} ${nameCol} ${theme.fg("warning", phaseCol)} ${formatElapsed(a.startedAt)}` +
648
+ (toolHint ? theme.fg("dim", toolHint) : "");
649
+ return truncateToWidth(line, width);
650
+ };
651
+ // Stateless render: themed strings are rebuilt on every render() call,
652
+ // so invalidate() has no cached state to rebuild. Note that render() is
653
+ // not a pure function: the elapsed time (formatElapsed -> Date.now())
654
+ // is computed live on each render.
655
+ return {
656
+ render: (width: number) => [
657
+ renderTopBorder(width),
658
+ ...visible.map((a) => renderRow(a, width)),
659
+ ...bottomBorder.render(width),
660
+ ],
661
+ invalidate: () => bottomBorder.invalidate(),
662
+ };
663
+ });
664
+ this.widgetSet = true;
665
+ }
666
+
667
+ private ensureTimer(): void {
668
+ if (this.timer) return;
669
+ this.timer = setInterval(() => this.refresh(), 1000);
670
+ this.timer.unref?.();
671
+ }
672
+
673
+ private stopTimer(): void {
674
+ if (this.timer) {
675
+ clearInterval(this.timer);
676
+ this.timer = null;
677
+ }
678
+ }
679
+ }
680
+
681
+ const progressManager = new SubagentProgressManager();
682
+
683
+ /**
684
+ * Build the isolated session directory for a subagent.
685
+ * All subagent sessions live under a dedicated root, independent of the main
686
+ * agent's session directory and independent of cwd:
687
+ * ~/.pi/agent/subagent-sessions/<session-id>/
688
+ */
689
+ function getSubagentSessionDir(sessionId: string): string {
690
+ const root = path.resolve(path.join(getAgentDir(), "subagent-sessions"));
691
+ const resolved = path.resolve(path.join(root, sessionId));
692
+ const rel = path.relative(root, resolved);
693
+ if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(".." + path.sep) || resolved === root) {
694
+ throw new Error(`Invalid sessionId: path traversal detected for "${sessionId}"`);
695
+ }
696
+ return resolved;
697
+ }
698
+
699
+ /**
700
+ * Locate the session JSONL file for a finished subagent task.
701
+ * A task's session dir may contain multiple .jsonl files (e.g. after a
702
+ * resume); pick the most recently modified one. Returns null when the dir
703
+ * does not exist, the taskId fails the traversal guard, or no .jsonl file
704
+ * is present.
705
+ */
706
+ export function findSessionFile(taskId: string): string | null {
707
+ let sessionDir: string;
708
+ try {
709
+ sessionDir = getSubagentSessionDir(taskId);
710
+ } catch {
711
+ return null;
712
+ }
713
+ let entries: fs.Dirent[];
714
+ try {
715
+ entries = fs.readdirSync(sessionDir, { withFileTypes: true });
716
+ } catch {
717
+ return null;
718
+ }
719
+ let newest: string | null = null;
720
+ let newestMtime = -1;
721
+ for (const entry of entries) {
722
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
723
+ const filePath = path.join(sessionDir, entry.name);
724
+ try {
725
+ const mtime = fs.statSync(filePath).mtimeMs;
726
+ if (mtime > newestMtime) {
727
+ newestMtime = mtime;
728
+ newest = filePath;
729
+ }
730
+ } catch {
731
+ /* ignore unreadable entries */
732
+ }
733
+ }
734
+ return newest;
735
+ }
736
+
737
+ /**
738
+ * Extract the last assistant text from a pi session JSONL file. Lines look
739
+ * like {"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}.
740
+ * Returns null when the file is unreadable or has no assistant text message.
741
+ */
742
+ export function extractFinalAssistantText(filePath: string): string | null {
743
+ let raw: string;
744
+ try {
745
+ raw = fs.readFileSync(filePath, "utf-8");
746
+ } catch {
747
+ return null;
748
+ }
749
+ let last: string | null = null;
750
+ for (const line of raw.split("\n")) {
751
+ const trimmed = line.trim();
752
+ if (!trimmed) continue;
753
+ let record: any;
754
+ try {
755
+ record = JSON.parse(trimmed);
756
+ } catch {
757
+ continue; // skip malformed lines
758
+ }
759
+ if (record?.type !== "message" || record?.message?.role !== "assistant") continue;
760
+ const parts = record.message.content;
761
+ if (!Array.isArray(parts)) continue;
762
+ const text = parts
763
+ .filter((p: any) => p?.type === "text" && typeof p.text === "string")
764
+ .map((p: any) => p.text)
765
+ .join("\n");
766
+ if (text) last = text;
767
+ }
768
+ return last;
769
+ }
770
+
771
+ /** Truncate a single-line-ish summary to maxLen chars, appending an ellipsis. */
772
+ function truncateSummary(text: string, maxLen: number): string {
773
+ return text.length > maxLen ? `${text.slice(0, maxLen)}…` : text;
774
+ }
775
+
776
+ /**
777
+ * Extract the full session transcript (方案 A) from a pi session JSONL file:
778
+ * the task text (first user message) followed by the conversation in original
779
+ * order — assistant texts, tool calls (`→ name args`) and tool results
780
+ * (`← name: summary`). Returns null when the file is unreadable or contains
781
+ * no assistant text (same contract as extractFinalAssistantText).
782
+ */
783
+ export function extractSessionTranscript(filePath: string): string | null {
784
+ let raw: string;
785
+ try {
786
+ raw = fs.readFileSync(filePath, "utf-8");
787
+ } catch {
788
+ return null;
789
+ }
790
+ let taskText: string | null = null;
791
+ const entries: string[] = [];
792
+ let hasAssistantText = false;
793
+ for (const line of raw.split("\n")) {
794
+ const trimmed = line.trim();
795
+ if (!trimmed) continue;
796
+ let record: any;
797
+ try {
798
+ record = JSON.parse(trimmed);
799
+ } catch {
800
+ continue; // skip malformed lines
801
+ }
802
+ if (record?.type !== "message") continue;
803
+ const message = record.message;
804
+ const role = message?.role;
805
+ const parts = message?.content;
806
+ if (!Array.isArray(parts)) continue;
807
+ const textOf = () =>
808
+ parts
809
+ .filter((p: any) => p?.type === "text" && typeof p.text === "string")
810
+ .map((p: any) => p.text)
811
+ .join("\n");
812
+ if (role === "user") {
813
+ // The first user message is the dispatched task ("Task: ...");
814
+ // later user messages (follow-ups) are not shown.
815
+ if (taskText === null) {
816
+ const text = textOf();
817
+ if (text) taskText = text;
818
+ }
819
+ } else if (role === "assistant") {
820
+ for (const part of parts) {
821
+ if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
822
+ hasAssistantText = true;
823
+ entries.push(`[assistant] ${part.text}`);
824
+ } else if (part?.type === "toolCall" && typeof part.name === "string") {
825
+ const args = truncateSummary(JSON.stringify(part.arguments ?? {}), 200);
826
+ entries.push(`→ ${part.name} ${args}`);
827
+ }
828
+ }
829
+ } else if (role === "toolResult") {
830
+ const text = textOf();
831
+ if (text) {
832
+ const toolName = typeof message.toolName === "string" ? `${message.toolName}: ` : "";
833
+ entries.push(`← ${toolName}${truncateSummary(text, 500)}`);
834
+ }
835
+ }
836
+ // other roles (thinking etc.) are skipped
837
+ }
838
+ if (!hasAssistantText) return null;
839
+ const sections: string[] = [];
840
+ // Plain-text section labels (not markdown headings): headings would invoke
841
+ // theme closures that throw when the global theme is uninitialized (tests).
842
+ if (taskText) sections.push(`任务原文\n\n${taskText}`);
843
+ sections.push(`会话记录\n\n${entries.join("\n\n")}`);
844
+ return sections.join("\n\n");
845
+ }
846
+
847
+ /**
848
+ * Parse an integer environment variable with NaN fallback. Garbage values
849
+ * (e.g. PI_SUBAGENT_DEPTH=abc) must fail safe to the fallback instead of
850
+ * becoming NaN, which would silently bypass numeric guards.
851
+ */
852
+ function parseEnvInt(raw: string | undefined, fallback: number): number {
853
+ const parsed = parseInt(raw || String(fallback), 10);
854
+ return Number.isNaN(parsed) ? fallback : parsed;
855
+ }
856
+
857
+ /** Validate an explicit sessionId. Returns an error message, or null when valid. */
858
+ function validateSessionId(sessionId: string): string | null {
859
+ const trimmed = sessionId.trim();
860
+ if (trimmed === "") return "Invalid sessionId: must not be empty";
861
+ if (trimmed === "." || trimmed === "..") return `Invalid sessionId: "${trimmed}" is not allowed`;
862
+ if (!/^[A-Za-z0-9_.-]+$/.test(trimmed))
863
+ return `Invalid sessionId: "${trimmed}" contains disallowed characters. Only letters, digits, underscore, dot, and hyphen are allowed.`;
864
+ return null;
865
+ }
866
+
867
+ async function runSingleAgent(
868
+ defaultCwd: string,
869
+ agents: AgentConfig[],
870
+ agentName: string,
871
+ task: string,
872
+ cwd: string | undefined,
873
+ step: number | undefined,
874
+ sessionId: string | undefined,
875
+ signal: AbortSignal | undefined,
876
+ progressCallback: SubagentProgressCallback | undefined,
877
+ parentModel?: CurrentModel,
878
+ modelOverrides?: Record<string, ModelOverride>,
879
+ onProcSpawn?: (proc: ChildProcess) => void,
880
+ ): Promise<SingleResult> {
881
+ let effectiveSessionId: string;
882
+ if (sessionId !== undefined) {
883
+ const trimmed = sessionId.trim();
884
+ const invalidSessionIdMessage = validateSessionId(sessionId);
885
+ if (invalidSessionIdMessage) {
886
+ return {
887
+ agent: agentName,
888
+ agentSource: "unknown",
889
+ task,
890
+ exitCode: 1,
891
+ messages: [],
892
+ stderr: invalidSessionIdMessage,
893
+ errorMessage: invalidSessionIdMessage,
894
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
895
+ step,
896
+ phase: "idle",
897
+ lastPhaseChange: Date.now(),
898
+ sessionId: trimmed,
899
+ };
900
+ }
901
+ effectiveSessionId = trimmed;
902
+ } else {
903
+ effectiveSessionId = uuidv7();
904
+ }
905
+ const agent = agents.find((a) => a.name === agentName);
906
+
907
+ if (!agent) {
908
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
909
+ return {
910
+ agent: agentName,
911
+ agentSource: "unknown",
912
+ task,
913
+ exitCode: 1,
914
+ messages: [],
915
+ stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
916
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
917
+ step,
918
+ phase: "idle",
919
+ lastPhaseChange: Date.now(),
920
+ sessionId: effectiveSessionId,
921
+ };
922
+ }
923
+
924
+ // Model priority: config file override > agent frontmatter model > inherited parent model
925
+ const override = modelOverrides?.[agent.name];
926
+ const effectiveModel =
927
+ override?.model || agent.model || (parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined);
928
+ // Thinking priority: config file override > agent frontmatter thinking
929
+ const effectiveThinking = override?.thinking || agent.thinking;
930
+ const args: string[] = ["--mode", "json", "-p", "--session-id", effectiveSessionId];
931
+ if (effectiveModel) args.push("--model", effectiveModel);
932
+ if (effectiveThinking) args.push("--thinking", effectiveThinking);
933
+ if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
934
+
935
+ // Effective working directory: agent-specific cwd > session default.
936
+ // Used both for resolving relative skill paths and as the spawned process cwd.
937
+ const effectiveCwd = cwd ?? defaultCwd;
938
+
939
+ // MODIFIED: inject per-agent skill isolation
940
+ const skillWarnings: string[] = [];
941
+ if (agent.skills !== undefined) {
942
+ args.push("--no-skills");
943
+ if (agent.skills.length > 0) {
944
+ for (const skillPath of agent.skills) {
945
+ const resolved = skillPath.startsWith("~/")
946
+ ? path.join(os.homedir(), skillPath.slice(2))
947
+ : path.isAbsolute(skillPath)
948
+ ? skillPath
949
+ : path.resolve(effectiveCwd, skillPath);
950
+
951
+ // Reject relative skill paths that escape effectiveCwd
952
+ if (!skillPath.startsWith("~/") && !path.isAbsolute(skillPath)) {
953
+ const rel = path.relative(effectiveCwd, resolved);
954
+ if (rel === ".." || rel.startsWith(".." + path.sep)) {
955
+ skillWarnings.push(
956
+ `[async-subagent-isolation] skill path "${skillPath}" resolves outside the agent base directory and was ignored.\n`,
957
+ );
958
+ continue;
959
+ }
960
+ }
961
+
962
+ args.push("--skill", resolved);
963
+ }
964
+ }
965
+ }
966
+
967
+ // Isolate subagent sessions under a dedicated root independent of cwd and
968
+ // the main agent's session directory.
969
+ const subagentSessionDir = getSubagentSessionDir(effectiveSessionId);
970
+ args.push("--session-dir", subagentSessionDir);
971
+
972
+ let tmpPromptDir: string | null = null;
973
+ let tmpPromptPath: string | null = null;
974
+
975
+ const currentResult: SingleResult = {
976
+ agent: agentName,
977
+ agentSource: agent.source,
978
+ task,
979
+ exitCode: 0,
980
+ messages: [],
981
+ stderr: skillWarnings.join(""),
982
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
983
+ model: effectiveModel,
984
+ step,
985
+ phase: "idle",
986
+ lastPhaseChange: Date.now(),
987
+ sessionId: effectiveSessionId,
988
+ };
989
+
990
+ const emitProgress = () => {
991
+ if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; }
992
+ if (progressCallback) {
993
+ const phase = currentResult.phase;
994
+ progressCallback({
995
+ phase,
996
+ currentTool: phase.startsWith("tooling:") ? phase.slice(8) : undefined,
997
+ recentTools: getRecentToolSummaries(currentResult.messages),
998
+ });
999
+ }
1000
+ };
1001
+
1002
+ let emitTimer: ReturnType<typeof setTimeout> | null = null;
1003
+ const throttledEmitProgress = () => {
1004
+ if (emitTimer) return;
1005
+ emitTimer = setTimeout(() => {
1006
+ emitTimer = null;
1007
+ emitProgress();
1008
+ }, 100);
1009
+ };
1010
+
1011
+ // Notify immediately only on the first idle -> busy transition so the user
1012
+ // sees the subagent start; all later intermediate events are throttled.
1013
+ let hasNotifiedStart = false;
1014
+
1015
+ try {
1016
+ if (agent.systemPrompt.trim()) {
1017
+ const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
1018
+ tmpPromptDir = tmp.dir;
1019
+ tmpPromptPath = tmp.filePath;
1020
+ args.push("--append-system-prompt", tmpPromptPath);
1021
+ }
1022
+
1023
+ args.push(`Task: ${task}`);
1024
+ let wasAborted = false;
1025
+
1026
+ const POST_EXIT_GRACE_MS = 500;
1027
+ const ABORT_FORCE_TIMEOUT_MS = 2000;
1028
+ const DEFAULT_ACTIVITY_TIMEOUT_MS = 600_000;
1029
+ const DEFAULT_HARD_TIMEOUT_MS = 0;
1030
+
1031
+ const exitCode = await new Promise<number>((resolve) => {
1032
+ const invocation = getPiInvocation(args);
1033
+ const currentDepth = parseEnvInt(process.env.PI_SUBAGENT_DEPTH, 0);
1034
+ const proc = spawn(invocation.command, invocation.args, {
1035
+ cwd: effectiveCwd,
1036
+ shell: false,
1037
+ stdio: ["ignore", "pipe", "pipe"],
1038
+ env: {
1039
+ ...process.env,
1040
+ PI_SUBAGENT_DEPTH: String(currentDepth + 1),
1041
+ PI_CURRENT_AGENT_NAME: agent.name,
1042
+ },
1043
+ });
1044
+ onProcSpawn?.(proc);
1045
+ let buffer = "";
1046
+ let resolved = false;
1047
+ let exitCodeValue: number | null = null;
1048
+ let stdoutEnded = false;
1049
+ let postExitTimer: ReturnType<typeof setTimeout> | undefined;
1050
+ let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
1051
+ let activityTimer: ReturnType<typeof setTimeout> | undefined;
1052
+ let lastActivityAt = Date.now();
1053
+ let hardTimer: ReturnType<typeof setTimeout> | undefined;
1054
+ let abortForceTimer: ReturnType<typeof setTimeout> | undefined;
1055
+ let killProc: (() => void) | undefined;
1056
+
1057
+ const finalize = (code: number) => {
1058
+ if (resolved) return;
1059
+ resolved = true;
1060
+ if (postExitTimer) {
1061
+ clearTimeout(postExitTimer);
1062
+ postExitTimer = undefined;
1063
+ }
1064
+ if (sigkillTimer) {
1065
+ clearTimeout(sigkillTimer);
1066
+ sigkillTimer = undefined;
1067
+ }
1068
+ if (activityTimer) {
1069
+ clearTimeout(activityTimer);
1070
+ activityTimer = undefined;
1071
+ }
1072
+ if (hardTimer) {
1073
+ clearTimeout(hardTimer);
1074
+ hardTimer = undefined;
1075
+ }
1076
+ if (abortForceTimer) {
1077
+ clearTimeout(abortForceTimer);
1078
+ abortForceTimer = undefined;
1079
+ }
1080
+ if (emitTimer) {
1081
+ clearTimeout(emitTimer);
1082
+ emitTimer = null;
1083
+ }
1084
+ proc.stdout?.removeAllListeners();
1085
+ proc.stderr?.removeAllListeners();
1086
+ proc.removeAllListeners();
1087
+ if (signal && killProc) {
1088
+ signal.removeEventListener("abort", killProc);
1089
+ }
1090
+ if (buffer.trim()) processLineRaw(buffer);
1091
+ const effectiveCode =
1092
+ currentResult.stopReason === "error" || currentResult.errorMessage ? 1 : code;
1093
+ resolve(effectiveCode);
1094
+ };
1095
+
1096
+ const maybeFinalizeAfterExit = () => {
1097
+ if (exitCodeValue !== null && stdoutEnded) {
1098
+ finalize(exitCodeValue);
1099
+ }
1100
+ };
1101
+
1102
+ const processLineRaw = (line: string) => {
1103
+ if (!line.trim()) return;
1104
+ let event: any;
1105
+ try {
1106
+ event = JSON.parse(line);
1107
+ } catch {
1108
+ return;
1109
+ }
1110
+
1111
+ if (event.type === "turn_start") {
1112
+ const wasIdle = currentResult.phase === "idle";
1113
+ currentResult.phase = "thinking";
1114
+ currentResult.lastPhaseChange = Date.now();
1115
+ currentResult.thinkingBuffer = "";
1116
+ resetActivityTimer();
1117
+ if (wasIdle && !hasNotifiedStart) {
1118
+ hasNotifiedStart = true;
1119
+ emitProgress();
1120
+ } else {
1121
+ throttledEmitProgress();
1122
+ }
1123
+ }
1124
+
1125
+ // message_start: assistant message begins; typically no text yet, so just update phase
1126
+ if (event.type === "message_start") {
1127
+ currentResult.phase = "thinking";
1128
+ currentResult.lastPhaseChange = Date.now();
1129
+ resetActivityTimer();
1130
+ throttledEmitProgress();
1131
+ }
1132
+
1133
+ if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
1134
+ const delta = event.assistantMessageEvent.delta as string;
1135
+ if (delta) {
1136
+ currentResult.thinkingBuffer = (currentResult.thinkingBuffer || "") + delta;
1137
+ }
1138
+ if (currentResult.thinkingBuffer && currentResult.thinkingBuffer.length > 2048) {
1139
+ // Keep last 2048 chars; try to keep whole lines if possible
1140
+ const idx = currentResult.thinkingBuffer.indexOf("\n", currentResult.thinkingBuffer.length - 2048);
1141
+ currentResult.thinkingBuffer = currentResult.thinkingBuffer.slice(idx >= 0 ? idx + 1 : -2048);
1142
+ }
1143
+ currentResult.phase = "thinking";
1144
+ currentResult.lastPhaseChange = Date.now();
1145
+ resetActivityTimer();
1146
+ throttledEmitProgress();
1147
+ }
1148
+
1149
+ if (event.type === "tool_execution_start") {
1150
+ currentResult.phase = `tooling:${event.toolName}`;
1151
+ currentResult.lastPhaseChange = Date.now();
1152
+ resetActivityTimer();
1153
+ throttledEmitProgress();
1154
+ }
1155
+
1156
+ if (event.type === "tool_execution_update") {
1157
+ currentResult.phase = `tooling:${event.toolName}`;
1158
+ resetActivityTimer();
1159
+ throttledEmitProgress();
1160
+ }
1161
+
1162
+ if (event.type === "tool_execution_end") {
1163
+ if (event.message) {
1164
+ currentResult.messages.push(event.message as Message);
1165
+ }
1166
+ currentResult.phase = "waiting";
1167
+ currentResult.lastPhaseChange = Date.now();
1168
+ resetActivityTimer();
1169
+ throttledEmitProgress();
1170
+ }
1171
+
1172
+ if (event.type === "turn_end") {
1173
+ currentResult.phase = "idle";
1174
+ currentResult.lastPhaseChange = Date.now();
1175
+ resetActivityTimer();
1176
+ throttledEmitProgress();
1177
+ }
1178
+
1179
+ if (event.type === "message_end" && event.message) {
1180
+ const msg = event.message as Message;
1181
+ currentResult.messages.push(msg);
1182
+ resetActivityTimer();
1183
+
1184
+ if (msg.role === "assistant") {
1185
+ currentResult.usage.turns++;
1186
+ const usage = msg.usage;
1187
+ if (usage) {
1188
+ currentResult.usage.input += usage.input || 0;
1189
+ currentResult.usage.output += usage.output || 0;
1190
+ currentResult.usage.cacheRead += usage.cacheRead || 0;
1191
+ currentResult.usage.cacheWrite += usage.cacheWrite || 0;
1192
+ currentResult.usage.cost += usage.cost?.total || 0;
1193
+ currentResult.usage.contextTokens = usage.totalTokens || 0;
1194
+ }
1195
+ if (!currentResult.model && msg.model) currentResult.model = msg.model;
1196
+ // A timeout stopReason is set by the activity/hard timers right before
1197
+ // finalize() flushes the leftover buffer through processLineRaw (which
1198
+ // bypasses the `resolved` guard in processLine). Never override it;
1199
+ // all other stopReasons keep the last-one-wins multi-turn semantics.
1200
+ if (
1201
+ msg.stopReason &&
1202
+ currentResult.stopReason !== "activity_timeout" &&
1203
+ currentResult.stopReason !== "hard_timeout"
1204
+ ) {
1205
+ currentResult.stopReason = msg.stopReason;
1206
+ }
1207
+ if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
1208
+ if (msg.stopReason === "error" || msg.errorMessage) {
1209
+ try {
1210
+ proc.kill("SIGKILL");
1211
+ } catch {
1212
+ /* ignore ESRCH */
1213
+ }
1214
+ emitProgress();
1215
+ finalize(1);
1216
+ return;
1217
+ }
1218
+ }
1219
+ emitProgress();
1220
+ }
1221
+ };
1222
+ const processLine = (line: string) => {
1223
+ if (resolved) return;
1224
+ processLineRaw(line);
1225
+ };
1226
+
1227
+ const resetActivityTimer = () => {
1228
+ // Don't (re)arm after resolution, once abort started teardown, or after
1229
+ // the process exited: in those states an activity timeout is meaningless.
1230
+ if (resolved || wasAborted || exitCodeValue !== null) return;
1231
+ lastActivityAt = Date.now();
1232
+ if (activityTimer) clearTimeout(activityTimer);
1233
+ const activityMs = parseEnvInt(
1234
+ process.env.PI_SUBAGENT_ACTIVITY_TIMEOUT_MS,
1235
+ DEFAULT_ACTIVITY_TIMEOUT_MS,
1236
+ );
1237
+ if (activityMs > 0) {
1238
+ activityTimer = setTimeout(() => {
1239
+ currentResult.stopReason = "activity_timeout";
1240
+ const elapsed = Date.now() - lastActivityAt;
1241
+ const phase = currentResult.phase;
1242
+ const turns = currentResult.usage.turns;
1243
+ currentResult.stderr += `[async-subagent-isolation] activity timeout exceeded after ${Math.round(elapsed / 1000)}s idle (phase: ${phase}, turns: ${turns}), killing...\n`;
1244
+ try {
1245
+ proc.kill("SIGKILL");
1246
+ } catch {
1247
+ /* ignore ESRCH */
1248
+ }
1249
+ finalize(1);
1250
+ }, activityMs);
1251
+ }
1252
+ };
1253
+
1254
+ const setupHardTimer = () => {
1255
+ // Don't arm after resolution, once abort started teardown, or after
1256
+ // the process exited (same guard as resetActivityTimer): a hard
1257
+ // timeout is meaningless in those states.
1258
+ if (resolved || wasAborted || exitCodeValue !== null) return;
1259
+ const hardMs = parseEnvInt(
1260
+ process.env.PI_SUBAGENT_HARD_TIMEOUT_MS,
1261
+ DEFAULT_HARD_TIMEOUT_MS,
1262
+ );
1263
+ if (hardMs > 0) {
1264
+ hardTimer = setTimeout(() => {
1265
+ currentResult.stopReason = "hard_timeout";
1266
+ const turns = currentResult.usage.turns;
1267
+ const phase = currentResult.phase;
1268
+ currentResult.stderr += `[async-subagent-isolation] hard timeout exceeded (phase: ${phase}, turns: ${turns}), killing...\n`;
1269
+ try {
1270
+ proc.kill("SIGKILL");
1271
+ } catch {
1272
+ /* ignore ESRCH */
1273
+ }
1274
+ finalize(1);
1275
+ }, hardMs);
1276
+ }
1277
+ };
1278
+
1279
+ proc.stdout.on("data", (data) => {
1280
+ resetActivityTimer();
1281
+ buffer += data.toString();
1282
+ const lines = buffer.split("\n");
1283
+ buffer = lines.pop() || "";
1284
+ for (const line of lines) processLine(line);
1285
+ });
1286
+
1287
+ proc.stdout.on("end", () => {
1288
+ stdoutEnded = true;
1289
+ maybeFinalizeAfterExit();
1290
+ });
1291
+
1292
+ proc.stderr.on("data", (data) => {
1293
+ currentResult.stderr += data.toString();
1294
+ resetActivityTimer();
1295
+ });
1296
+
1297
+ proc.on("exit", (code, signal) => {
1298
+ exitCodeValue = signal ? 1 : (code ?? 0);
1299
+ // The process is gone: disarm the timeout timers so they can't fire
1300
+ // during the post-exit grace window and mislabel a normal exit.
1301
+ if (activityTimer) {
1302
+ clearTimeout(activityTimer);
1303
+ activityTimer = undefined;
1304
+ }
1305
+ if (hardTimer) {
1306
+ clearTimeout(hardTimer);
1307
+ hardTimer = undefined;
1308
+ }
1309
+ maybeFinalizeAfterExit();
1310
+ if (!resolved) {
1311
+ postExitTimer = setTimeout(() => finalize(exitCodeValue ?? 0), POST_EXIT_GRACE_MS);
1312
+ }
1313
+ });
1314
+
1315
+ proc.on("close", (code, signal) => {
1316
+ finalize(signal ? 1 : (code ?? 0));
1317
+ });
1318
+
1319
+ proc.on("error", (err) => {
1320
+ currentResult.stderr += `[async-subagent-isolation] process error: ${err?.message ?? String(err)}\n`;
1321
+ finalize(1);
1322
+ });
1323
+
1324
+ if (signal) {
1325
+ killProc = () => {
1326
+ wasAborted = true;
1327
+ // Abort takes over process teardown: disarm the timeout timers so
1328
+ // they can't fire during the SIGTERM grace period and mislabel the
1329
+ // abort as a timeout (or SIGKILL prematurely).
1330
+ if (activityTimer) {
1331
+ clearTimeout(activityTimer);
1332
+ activityTimer = undefined;
1333
+ }
1334
+ if (hardTimer) {
1335
+ clearTimeout(hardTimer);
1336
+ hardTimer = undefined;
1337
+ }
1338
+ try {
1339
+ proc.kill("SIGTERM");
1340
+ } catch {
1341
+ /* ignore ESRCH */
1342
+ }
1343
+ // Cancel/completion race — "cancel wins": if the process was
1344
+ // already exiting when the cancel arrived, the task is still
1345
+ // reported as cancelled (wasAborted -> rejection), never as a
1346
+ // success. This matches user intent (they asked to abort, so the
1347
+ // outcome is discarded) and avoids diffing partial results.
1348
+ if (exitCodeValue !== null || proc.exitCode !== null || proc.signalCode !== null) {
1349
+ finalize(1);
1350
+ return;
1351
+ }
1352
+ sigkillTimer = setTimeout(() => {
1353
+ try {
1354
+ if (proc.exitCode === null && proc.signalCode === null) {
1355
+ proc.kill("SIGKILL");
1356
+ abortForceTimer = setTimeout(() => {
1357
+ finalize(1);
1358
+ }, ABORT_FORCE_TIMEOUT_MS);
1359
+ }
1360
+ } catch {
1361
+ /* ignore ESRCH */
1362
+ }
1363
+ }, 5000);
1364
+ };
1365
+ if (signal.aborted) killProc();
1366
+ else signal.addEventListener("abort", killProc, { once: true });
1367
+ }
1368
+
1369
+ setupHardTimer();
1370
+ resetActivityTimer();
1371
+ });
1372
+
1373
+ currentResult.exitCode = exitCode;
1374
+ if (wasAborted) throw new Error("Subagent was aborted");
1375
+ return currentResult;
1376
+ } finally {
1377
+ if (tmpPromptPath)
1378
+ try {
1379
+ fs.unlinkSync(tmpPromptPath);
1380
+ } catch {
1381
+ /* ignore */
1382
+ }
1383
+ if (tmpPromptDir)
1384
+ try {
1385
+ fs.rmdirSync(tmpPromptDir);
1386
+ } catch {
1387
+ /* ignore */
1388
+ }
1389
+ }
1390
+ }
1391
+
1392
+ // ===== Async dispatch mode (TUI) =====
1393
+
1394
+ /**
1395
+ * Maximum subagent delegation depth. Recursive delegation is blocked entirely:
1396
+ * a subagent (depth >= 1) can never spawn another subagent.
1397
+ */
1398
+ const MAX_SUBAGENT_DEPTH = 1;
1399
+
1400
+ /** Envelope status words for a finished async subagent task. */
1401
+ export const STATUS_WORDS = {
1402
+ success: "成功",
1403
+ failure: "失败",
1404
+ timeout: "超时",
1405
+ cancelled: "已取消",
1406
+ } as const;
1407
+
1408
+ export type SubagentTaskStatus = keyof typeof STATUS_WORDS;
1409
+
1410
+ /** A background subagent task, keyed in the registry by taskId (= sessionId). */
1411
+ export interface AsyncSubagentTask {
1412
+ taskId: string;
1413
+ agentName: string;
1414
+ task: string;
1415
+ startedAt: number;
1416
+ /**
1417
+ * Per-task abort controller. The turn-level signal ends with the
1418
+ * dispatching turn and would wrongly kill the background process, so async
1419
+ * tasks always get their own controller; cancellation reuses the existing
1420
+ * SIGTERM -> 5s -> SIGKILL abort cascade in runSingleAgent.
1421
+ */
1422
+ abortController: AbortController;
1423
+ /** "running" while in flight; "cancelled" / "killed_on_shutdown" once teardown started. */
1424
+ status: "running" | "cancelled" | "killed_on_shutdown";
1425
+ /**
1426
+ * Who cancelled the task: "user" via /subagent-cancel, "agent" via the
1427
+ * subagent_cancel tool. Set by cancelTask so the result envelope can name
1428
+ * the cancel's origin; undefined for non-cancelled endings.
1429
+ */
1430
+ cancelledBy?: "user" | "agent";
1431
+ /**
1432
+ * Child process handle, set once runSingleAgent has spawned. Lets the
1433
+ * session_shutdown handler SIGKILL directly: on "quit" the main process
1434
+ * exits before the 5s SIGKILL-escalation timer inside runSingleAgent can
1435
+ * fire, so without this backstop a SIGTERM-ignoring child would be orphaned.
1436
+ */
1437
+ proc?: ChildProcess;
1438
+ }
1439
+
1440
+ /**
1441
+ * Registry of in-flight async subagent tasks (module-level, keyed by taskId).
1442
+ * Entries are removed once the task's completion notification has been sent.
1443
+ */
1444
+ export const taskRegistry = new Map<string, AsyncSubagentTask>();
1445
+
1446
+ /**
1447
+ * Cancel a running async subagent task: mark it cancelled, record who
1448
+ * cancelled it (for the envelope), and fire its abort controller — reusing
1449
+ * the SIGTERM -> 5s -> SIGKILL cascade in runSingleAgent. Shared by the
1450
+ * /subagent-cancel command and the subagent_cancel tool. Returns false when
1451
+ * no running task with that id exists.
1452
+ */
1453
+ function cancelTask(taskId: string, cancelledBy: "user" | "agent"): boolean {
1454
+ const task = taskRegistry.get(taskId);
1455
+ if (!task || task.status !== "running") return false;
1456
+ task.status = "cancelled";
1457
+ task.cancelledBy = cancelledBy;
1458
+ task.abortController.abort();
1459
+ return true;
1460
+ }
1461
+
1462
+ /** Truncate a task description for the envelope's 任务 line (default: 200 chars). */
1463
+ export function truncateTaskDescription(task: string, maxLen = 200): string {
1464
+ const oneLine = task.replace(/\s+/g, " ").trim();
1465
+ return oneLine.length > maxLen ? `${oneLine.slice(0, maxLen)}...` : oneLine;
1466
+ }
1467
+
1468
+ /**
1469
+ * Format the in-flight task list (status === "running") shared by the
1470
+ * subagent_status tool, the result envelope's 在途 block, and the
1471
+ * subagent_cancel receipt. Deliberately carries no elapsed time: the list
1472
+ * answers "what is still running", not "how long has it run".
1473
+ */
1474
+ export function formatActiveTasks(): string {
1475
+ const running = [...taskRegistry.values()].filter((t) => t.status === "running");
1476
+ if (running.length === 0) return "当前无在途任务。";
1477
+ const lines = running.map((t) => `- ${t.taskId} (${t.agentName}): ${truncateTaskDescription(t.task)}`);
1478
+ return `在途任务: ${running.length}\n${lines.join("\n")}`;
1479
+ }
1480
+
1481
+ /** Derive the envelope status from a finished SingleResult. */
1482
+ function getTaskStatus(result: SingleResult): SubagentTaskStatus {
1483
+ const stopReason = result.stopReason;
1484
+ if (stopReason === "aborted" || stopReason === "killed_on_shutdown") return "cancelled";
1485
+ if (stopReason === "activity_timeout" || stopReason === "hard_timeout") return "timeout";
1486
+ if (result.exitCode !== 0 || stopReason === "error") return "failure";
1487
+ return "success";
1488
+ }
1489
+
1490
+ /** Structured payload carried by the subagent-result message's details field. */
1491
+ export interface SubagentResultDetails {
1492
+ taskId: string;
1493
+ agent: string;
1494
+ status: string;
1495
+ exitCode: number | null;
1496
+ stopReason?: string;
1497
+ /** Present only on cancelled tasks: who cancelled ("user" | "agent"). */
1498
+ cancelledBy?: "user" | "agent";
1499
+ usage: UsageStats;
1500
+ sessionId: string;
1501
+ output: string;
1502
+ }
1503
+
1504
+ /**
1505
+ * Cap for details.output. The full output already lives in the envelope
1506
+ * content; the structured details field carries only a bounded copy so a
1507
+ * huge result is not stored twice at full length.
1508
+ */
1509
+ const DETAILS_OUTPUT_MAX_CHARS = 16 * 1024;
1510
+
1511
+ /**
1512
+ * Build the [subagent-result] notification envelope: a markdown content text
1513
+ * carrying the full, untruncated result, plus structured details (details.output
1514
+ * is capped at DETAILS_OUTPUT_MAX_CHARS; content always keeps the full text).
1515
+ */
1516
+ /**
1517
+ * Empty-body fallback for an aborted task, keyed on the abort's origin so the
1518
+ * main agent can tell a deliberate user cancel, an agent-initiated cancel and
1519
+ * a session shutdown apart (and does not auto-retry a user cancel).
1520
+ */
1521
+ function abortedFallbackBody(stopReason?: string, cancelledBy?: "user" | "agent"): string {
1522
+ if (stopReason === "killed_on_shutdown") return "任务因会话关闭被终止(session_shutdown)。";
1523
+ if (cancelledBy === "agent") return "该任务已由主 agent 通过 subagent_cancel 工具取消。";
1524
+ return "该任务已由用户通过 /subagent-cancel 取消,属用户主动操作。请勿自动重新派发;如需重新派发,先询问用户。";
1525
+ }
1526
+
1527
+ export function buildResultEnvelope(
1528
+ task: AsyncSubagentTask,
1529
+ result: SingleResult | null,
1530
+ status: SubagentTaskStatus,
1531
+ stopReason?: string,
1532
+ errorMessage?: string,
1533
+ ): { content: string; details: SubagentResultDetails } {
1534
+ const statusWord = STATUS_WORDS[status];
1535
+ const output = result ? getFinalOutput(result.messages) : "";
1536
+ const usage: UsageStats =
1537
+ result?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
1538
+ const sessionId = result?.sessionId ?? task.taskId;
1539
+ let body = output;
1540
+ if (!body && result) body = result.errorMessage || result.stderr.trim();
1541
+ // Only genuine failures are labelled "内部错误"; a user cancel or session
1542
+ // shutdown rejection is an expected abort, so it gets a note carrying the
1543
+ // abort's origin (user cancel vs session shutdown).
1544
+ if (!body && errorMessage) body = status === "failure" ? `内部错误: ${errorMessage}` : abortedFallbackBody(stopReason, task.cancelledBy);
1545
+ const lines = [
1546
+ `## [subagent-result] ${task.agentName} ${statusWord} (taskId: ${task.taskId})`,
1547
+ "",
1548
+ `- 状态: ${statusWord}`,
1549
+ `- 任务: ${truncateTaskDescription(task.task)}`,
1550
+ `- 耗时: ${formatElapsed(task.startedAt)} · 用量: ${formatUsageStats(usage, result?.model) || "-"}`,
1551
+ `- 会话: ${sessionId}`,
1552
+ "",
1553
+ // 在途 block: completeAsyncTask deletes this task from the registry
1554
+ // before building the envelope, so the list naturally excludes self.
1555
+ formatActiveTasks(),
1556
+ "",
1557
+ "---",
1558
+ body || (status === "cancelled" ? abortedFallbackBody(stopReason, task.cancelledBy) : "(no output)"),
1559
+ ];
1560
+ return {
1561
+ content: lines.join("\n"),
1562
+ details: {
1563
+ taskId: task.taskId,
1564
+ agent: task.agentName,
1565
+ status: statusWord,
1566
+ exitCode: result?.exitCode ?? null,
1567
+ stopReason,
1568
+ cancelledBy: task.cancelledBy,
1569
+ usage,
1570
+ sessionId,
1571
+ output:
1572
+ output.length > DETAILS_OUTPUT_MAX_CHARS
1573
+ ? `${output.slice(0, DETAILS_OUTPUT_MAX_CHARS)}\n... (truncated; full output in content)`
1574
+ : output,
1575
+ },
1576
+ };
1577
+ }
1578
+
1579
+ /** Build the dispatch receipt returned immediately by execute() in TUI mode. */
1580
+ function buildDispatchReceipt(agentName: string, taskId: string): string {
1581
+ // Async-semantics guidance (don't poll, don't fabricate, result arrives as a
1582
+ // [subagent-result] notification) lives in the tool description /
1583
+ // promptGuidelines; the receipt stays a single line.
1584
+ return `已派出 ${agentName}. taskId: ${taskId}`;
1585
+ }
1586
+
1587
+ /**
1588
+ * Finalize an async task: unregister progress, drop it from the registry, and
1589
+ * push the [subagent-result] notification. Called exactly once per task, on
1590
+ * both success (result) and abort (result === null; the task record carries
1591
+ * whether it was a user cancel or a session shutdown).
1592
+ */
1593
+ function completeAsyncTask(pi: ExtensionAPI, task: AsyncSubagentTask, result: SingleResult | null, error?: unknown): void {
1594
+ // unregister is best-effort: a widget failure must not skip registry
1595
+ // cleanup or the result notification, nor become an unhandled rejection
1596
+ // in the .then chain that invoked us.
1597
+ try {
1598
+ progressManager.unregister(task.taskId);
1599
+ } catch {
1600
+ /* progress widget is non-critical */
1601
+ }
1602
+ taskRegistry.delete(task.taskId);
1603
+ // result === null means runSingleAgent rejected. Abort rejections carry
1604
+ // the reason on the task record (user cancel / session shutdown); a
1605
+ // rejection with the task still "running" is an internal failure (e.g.
1606
+ // the prompt temp-file write failed) and must not be misreported as a
1607
+ // cancellation.
1608
+ const status: SubagentTaskStatus = !result
1609
+ ? task.status === "running"
1610
+ ? "failure"
1611
+ : "cancelled"
1612
+ : task.status !== "running"
1613
+ ? "cancelled"
1614
+ : getTaskStatus(result);
1615
+ const stopReason =
1616
+ result?.stopReason ??
1617
+ (task.status === "killed_on_shutdown"
1618
+ ? "killed_on_shutdown"
1619
+ : task.status === "cancelled"
1620
+ ? "aborted"
1621
+ : !result
1622
+ ? "internal_error"
1623
+ : undefined);
1624
+ // Carry the rejection reason into the envelope so internal failures
1625
+ // (e.g. the prompt temp-file write failed) are diagnosable instead of
1626
+ // showing a bare "(no output)".
1627
+ const errorMessage = !result && error ? (error instanceof Error ? error.message : String(error)) : undefined;
1628
+ try {
1629
+ // buildResultEnvelope is pure and low-risk, but a throw here would
1630
+ // become an unhandled rejection in the .then chain — keep it inside
1631
+ // the same guard as sendMessage.
1632
+ const envelope = buildResultEnvelope(task, result, status, stopReason, errorMessage);
1633
+ pi.sendMessage(
1634
+ { customType: "subagent-result", content: envelope.content, display: true, details: envelope.details },
1635
+ { deliverAs: "followUp", triggerTurn: true },
1636
+ );
1637
+ } catch {
1638
+ // The session may already be gone (e.g. after session_shutdown); the
1639
+ // task is finished either way, so notification errors are swallowed.
1640
+ }
1641
+ }
1642
+
1643
+ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
1644
+ description: 'Which agent directories to use. Default: "both". Use "user" or "project" to limit scope.',
1645
+ default: "both",
1646
+ });
1647
+
1648
+ const SubagentParams = Type.Object({
1649
+ agent: Type.String({ description: "Name of the agent to invoke" }),
1650
+ task: Type.String({ description: "Task to delegate. Must be non-empty and include background, input, requirements, output format, and acceptance criteria." }),
1651
+ sessionId: Type.Optional(Type.String({
1652
+ pattern: "^[A-Za-z0-9_.-]+$",
1653
+ description: "Optional session ID to reuse; a new UUID v7 is generated if omitted. Allowed characters: letters, digits, underscore, dot, and hyphen.",
1654
+ })),
1655
+ agentScope: Type.Optional(AgentScopeSchema),
1656
+ confirmProjectAgents: Type.Optional(
1657
+ Type.Boolean({ description: "Prompt before running project-local agents. Default: false.", default: false }),
1658
+ ),
1659
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
1660
+ });
1661
+
1662
+ export default function (pi: ExtensionAPI) {
1663
+ pi.registerTool({
1664
+ name: "subagent",
1665
+ label: "Subagent",
1666
+ description: [
1667
+ "Delegate a task to a specialized subagent with isolated context.",
1668
+ "",
1669
+ "ASYNC (TUI mode): returns immediately with a dispatch receipt (taskId + session id).",
1670
+ "The result arrives later as a system notification message prefixed with",
1671
+ "[subagent-result] — that is a system notification, NOT a user request.",
1672
+ "- Do NOT treat the receipt as the result. Do NOT fabricate results.",
1673
+ "- Do NOT poll for results; they arrive automatically. To confirm which",
1674
+ " tasks are still in flight (e.g. after a /tree rewind), use the",
1675
+ " subagent_status tool.",
1676
+ "- Continue with independent work, or end the turn. Process the result when",
1677
+ " the [subagent-result] notification arrives. Reuse the session id from the",
1678
+ " receipt to continue the same task later.",
1679
+ "",
1680
+ "SYNC (non-TUI modes): waits for the subagent to finish and returns the full",
1681
+ "result directly (no notification follows).",
1682
+ "",
1683
+ "Task must be non-empty and include background, input, requirements, output",
1684
+ "format, and acceptance criteria.",
1685
+ ].join("\n"),
1686
+ promptSnippet:
1687
+ "Delegate a task to a specialized subagent in an isolated process (async dispatch in TUI mode, blocking otherwise).",
1688
+ promptGuidelines: [
1689
+ "subagent: In TUI mode this tool is asynchronous — it returns a dispatch receipt, not the result; the real result arrives later as a [subagent-result] system notification, so never fabricate results and never poll for status.",
1690
+ "subagent: A message prefixed with [subagent-result] is a system notification carrying a finished subagent result, not a user request; process it in the context of the task that dispatched it.",
1691
+ "subagent: Dispatch subagents driven by task dependencies — delegate only work whose result you actually need, prefer reusing the session id from the receipt to continue a previous subagent task, and keep independent work in the main context.",
1692
+ "subagent: A [subagent-result] notification with status 已取消 (cancelled) can come from the user (/subagent-cancel) or from you (subagent_cancel); the envelope body states the source. A user-initiated cancel is a deliberate user action, so do NOT automatically retry or re-dispatch it; ask the user before re-dispatching.",
1693
+ "subagent: Before dispatching multiple tasks in parallel, consider whether they touch the same files or code areas — parallel tasks modifying the same files can conflict. When in doubt, dispatch sequentially or ask the user.",
1694
+ ],
1695
+ parameters: SubagentParams,
1696
+
1697
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
1698
+ const currentDepth = parseEnvInt(process.env.PI_SUBAGENT_DEPTH, 0);
1699
+
1700
+ // Recursive delegation is blocked entirely: a subagent (depth >= 1)
1701
+ // can never spawn another subagent.
1702
+ if (currentDepth >= MAX_SUBAGENT_DEPTH) {
1703
+ const agentName = process.env.PI_CURRENT_AGENT_NAME || "current agent";
1704
+ return {
1705
+ content: [{
1706
+ type: "text",
1707
+ text: `Subagent delegation is blocked: depth limit reached (depth: ${currentDepth}, max: ${MAX_SUBAGENT_DEPTH}). Agent \`${agentName}\` runs inside a subagent and recursive delegation is not allowed.`,
1708
+ }],
1709
+ details: {
1710
+ mode: "single",
1711
+ agentScope: params.agentScope ?? "both",
1712
+ projectAgentsDir: null,
1713
+ results: [],
1714
+ } as SubagentDetails,
1715
+ isError: true,
1716
+ };
1717
+ }
1718
+
1719
+ const agentName = params.agent;
1720
+ const task = typeof params.task === "string" ? params.task.trim() : "";
1721
+
1722
+ if (!agentName) {
1723
+ return {
1724
+ content: [
1725
+ {
1726
+ type: "text",
1727
+ text: 'Missing required parameter: "agent". Please specify the name of the agent to invoke.',
1728
+ },
1729
+ ],
1730
+ details: {
1731
+ mode: "single",
1732
+ agentScope: (params.agentScope ?? "both") as AgentScope,
1733
+ projectAgentsDir: null,
1734
+ results: [],
1735
+ } as SubagentDetails,
1736
+ isError: true,
1737
+ };
1738
+ }
1739
+
1740
+ if (!task) {
1741
+ return {
1742
+ content: [
1743
+ {
1744
+ type: "text",
1745
+ text: 'Missing or empty required parameter: "task". The task must be non-empty and should include the five-section structure from master.md: 背景 (background), 输入 (input), 要求 (requirements), 输出格式 (output format), and 验收标准 (acceptance criteria).',
1746
+ },
1747
+ ],
1748
+ details: {
1749
+ mode: "single",
1750
+ agentScope: (params.agentScope ?? "both") as AgentScope,
1751
+ projectAgentsDir: null,
1752
+ results: [],
1753
+ } as SubagentDetails,
1754
+ isError: true,
1755
+ };
1756
+ }
1757
+
1758
+ // Validate an explicit sessionId up front: in async mode the failure
1759
+ // must surface before the dispatch receipt, not after it.
1760
+ if (params.sessionId !== undefined) {
1761
+ const invalidSessionIdMessage = validateSessionId(params.sessionId);
1762
+ if (invalidSessionIdMessage) {
1763
+ return {
1764
+ content: [{ type: "text", text: invalidSessionIdMessage }],
1765
+ details: {
1766
+ mode: "single",
1767
+ agentScope: (params.agentScope ?? "both") as AgentScope,
1768
+ projectAgentsDir: null,
1769
+ results: [],
1770
+ } as SubagentDetails,
1771
+ isError: true,
1772
+ };
1773
+ }
1774
+ }
1775
+
1776
+ const agentScope: AgentScope = params.agentScope ?? "both";
1777
+ const discovery = discoverAgents(ctx.cwd, agentScope);
1778
+ const agents = discovery.agents;
1779
+ const modelOverrides = loadModelOverrides(ctx.cwd);
1780
+ const confirmProjectAgents = params.confirmProjectAgents ?? false;
1781
+
1782
+ const makeDetails = (results: SingleResult[]): SubagentDetails => ({
1783
+ mode: "single",
1784
+ agentScope,
1785
+ projectAgentsDir: discovery.projectAgentsDir,
1786
+ results,
1787
+ });
1788
+
1789
+ if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
1790
+ const projectAgent = agents.find((a) => a.name === params.agent && a.source === "project");
1791
+
1792
+ if (projectAgent) {
1793
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
1794
+ const ok = await ctx.ui.confirm(
1795
+ "Run project-local agents?",
1796
+ `Agents: ${projectAgent.name}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
1797
+ );
1798
+ if (!ok)
1799
+ return {
1800
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
1801
+ details: makeDetails([]),
1802
+ };
1803
+ }
1804
+ }
1805
+
1806
+ // The tool area is rendered only once, when execute() returns the final
1807
+ // result. Live progress goes through the progress manager's widget, not
1808
+ // the TUI render pipeline.
1809
+ const effectiveSessionId = params.sessionId?.trim() ?? uuidv7();
1810
+
1811
+ // Refuse to clobber an in-flight async task with the same id (the
1812
+ // receipt encourages sessionId reuse, so the model can legitimately
1813
+ // re-send one). Overwriting the registry entry would orphan the first
1814
+ // process, break its completion callback and /subagent-cancel, and mix
1815
+ // two writers into the same session directory.
1816
+ if (ctx.mode === "tui" && taskRegistry.has(effectiveSessionId)) {
1817
+ return {
1818
+ content: [
1819
+ {
1820
+ type: "text",
1821
+ text: `A background subagent task with id "${effectiveSessionId}" is already running (同 sessionId 的任务仍在运行). Wait for its [subagent-result] notification, cancel it with /subagent-cancel ${effectiveSessionId}, or omit sessionId to start a new task.`,
1822
+ },
1823
+ ],
1824
+ details: makeDetails([]),
1825
+ isError: true,
1826
+ };
1827
+ }
1828
+
1829
+ progressManager.register(ctx, effectiveSessionId, params.agent);
1830
+
1831
+ // TUI mode: dispatch asynchronously. execute() returns a receipt
1832
+ // immediately; the finished result is pushed later as a
1833
+ // [subagent-result] notification via pi.sendMessage. Non-TUI modes
1834
+ // (mode undefined included) fall through to the sync path below.
1835
+ if (ctx.mode === "tui") {
1836
+ const taskRecord: AsyncSubagentTask = {
1837
+ taskId: effectiveSessionId,
1838
+ agentName: params.agent,
1839
+ task,
1840
+ startedAt: Date.now(),
1841
+ // Per-task controller: the turn-level `signal` fires when the
1842
+ // dispatching turn ends, which would wrongly kill the
1843
+ // background subagent.
1844
+ abortController: new AbortController(),
1845
+ status: "running",
1846
+ };
1847
+ taskRegistry.set(effectiveSessionId, taskRecord);
1848
+ runSingleAgent(
1849
+ ctx.cwd,
1850
+ agents,
1851
+ params.agent,
1852
+ task,
1853
+ params.cwd,
1854
+ undefined,
1855
+ effectiveSessionId,
1856
+ taskRecord.abortController.signal,
1857
+ (update) => progressManager.update(effectiveSessionId, update),
1858
+ ctx.model,
1859
+ modelOverrides,
1860
+ (proc) => {
1861
+ taskRecord.proc = proc;
1862
+ // Shutdown may have fired while the prompt temp file was being
1863
+ // written (no proc handle existed yet); apply the session_shutdown
1864
+ // SIGKILL backstop now.
1865
+ if (taskRecord.status === "killed_on_shutdown") {
1866
+ try {
1867
+ proc.kill("SIGKILL");
1868
+ } catch {
1869
+ /* ignore ESRCH */
1870
+ }
1871
+ }
1872
+ },
1873
+ ).then(
1874
+ (result) => completeAsyncTask(pi, taskRecord, result),
1875
+ // Rejections: abort (cancel/shutdown — the reason is on the task
1876
+ // record) or an internal failure; completeAsyncTask maps the record
1877
+ // to the right status.
1878
+ (err) => completeAsyncTask(pi, taskRecord, null, err),
1879
+ );
1880
+ return {
1881
+ content: [{ type: "text", text: buildDispatchReceipt(params.agent, effectiveSessionId) }],
1882
+ details: makeDetails([]),
1883
+ };
1884
+ }
1885
+
1886
+ try {
1887
+ const result = await runSingleAgent(
1888
+ ctx.cwd,
1889
+ agents,
1890
+ params.agent,
1891
+ task,
1892
+ params.cwd,
1893
+ undefined,
1894
+ effectiveSessionId,
1895
+ signal,
1896
+ (update) => progressManager.update(effectiveSessionId, update),
1897
+ ctx.model,
1898
+ modelOverrides,
1899
+ );
1900
+ const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
1901
+ if (isError) {
1902
+ const diagnostics = formatSubagentDiagnostics(result) + `\n\n[subagent session: ${result.sessionId}]`;
1903
+ return {
1904
+ content: [{ type: "text", text: diagnostics }],
1905
+ details: makeDetails([result]),
1906
+ isError: true,
1907
+ };
1908
+ }
1909
+ const rawOutput = getFinalOutput(result.messages);
1910
+ const outputText = rawOutput
1911
+ ? `${rawOutput}\n\n[subagent session: ${result.sessionId}]`
1912
+ : `[subagent session: ${result.sessionId}]`;
1913
+ return {
1914
+ content: [{ type: "text", text: outputText }],
1915
+ details: makeDetails([result]),
1916
+ };
1917
+ } finally {
1918
+ progressManager.unregister(effectiveSessionId);
1919
+ }
1920
+ },
1921
+
1922
+ renderCall(args, theme, context) {
1923
+ const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1924
+ if (context.isPartial) {
1925
+ // Still executing: render nothing so the tool row is invisible.
1926
+ component.setText("");
1927
+ return component;
1928
+ }
1929
+ const agentName = args.agent || "...";
1930
+ const text =
1931
+ theme.fg("toolTitle", theme.bold("subagent ")) +
1932
+ theme.fg("accent", agentName);
1933
+ component.setText(text);
1934
+ return component;
1935
+ },
1936
+
1937
+ renderResult(result, { expanded }, theme, context) {
1938
+ const details = result.details as SubagentDetails | undefined;
1939
+ if (!details || details.results.length === 0) {
1940
+ return new Text(result.content[0]?.type === "text" ? result.content[0].text : "(no output)", 0, 0);
1941
+ }
1942
+
1943
+ const mdTheme = getMarkdownTheme();
1944
+
1945
+ const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
1946
+ const toShow = limit ? items.slice(-limit) : items;
1947
+ const skipped = limit && items.length > limit ? items.length - limit : 0;
1948
+ let text = "";
1949
+ if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
1950
+ for (const item of toShow) {
1951
+ if (item.type === "text") {
1952
+ const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
1953
+ text += `${theme.fg("toolOutput", preview)}\n`;
1954
+ } else {
1955
+ text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
1956
+ }
1957
+ }
1958
+ return text.trimEnd();
1959
+ };
1960
+
1961
+ if (details.mode === "single" && details.results.length === 1) {
1962
+ const r = details.results[0];
1963
+ const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
1964
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
1965
+ const displayItems = getDisplayItems(r.messages);
1966
+ const finalOutput = getFinalOutput(r.messages);
1967
+
1968
+ if (expanded) {
1969
+ const container = new Container();
1970
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
1971
+ if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
1972
+ if (r.phase !== "idle") header += ` ${theme.fg("warning", formatPhase(r.phase))}`;
1973
+ header += ` ${theme.fg("muted", `[session: ${r.sessionId}]`)}`;
1974
+ container.addChild(new Text(header, 0, 0));
1975
+ if (isError && r.errorMessage)
1976
+ container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
1977
+ if (r.thinkingBuffer) {
1978
+ container.addChild(new Spacer(1));
1979
+ container.addChild(new Text(theme.fg("muted", "─── Thinking ───"), 0, 0));
1980
+ const lines = r.thinkingBuffer.trim().split("\n");
1981
+ const recent = lines.slice(-5).join("\n");
1982
+ container.addChild(new Text(theme.fg("dim", recent), 0, 0));
1983
+ }
1984
+ container.addChild(new Spacer(1));
1985
+ container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
1986
+ container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
1987
+ container.addChild(new Spacer(1));
1988
+ container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
1989
+ if (displayItems.length === 0 && !finalOutput) {
1990
+ container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
1991
+ } else {
1992
+ for (const item of displayItems) {
1993
+ if (item.type === "toolCall")
1994
+ container.addChild(
1995
+ new Text(
1996
+ theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
1997
+ 0,
1998
+ 0,
1999
+ ),
2000
+ );
2001
+ }
2002
+ if (finalOutput) {
2003
+ container.addChild(new Spacer(1));
2004
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
2005
+ }
2006
+ }
2007
+ const usageStr = formatUsageStats(r.usage, r.model);
2008
+ if (usageStr) {
2009
+ container.addChild(new Spacer(1));
2010
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
2011
+ }
2012
+ return container;
2013
+ }
2014
+
2015
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
2016
+ if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
2017
+ if (r.phase !== "idle") text += ` ${theme.fg("warning", formatPhase(r.phase))}`;
2018
+ text += ` ${theme.fg("muted", `[session: ${r.sessionId}]`)}`;
2019
+ if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
2020
+ if (displayItems.length === 0) {
2021
+ if (!isError || !r.errorMessage) text += `\n${theme.fg("muted", "(no output)")}`;
2022
+ } else {
2023
+ text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
2024
+ if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
2025
+ }
2026
+ const usageStr = formatUsageStats(r.usage, r.model);
2027
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
2028
+ const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
2029
+ component.setText(text);
2030
+ return component;
2031
+ }
2032
+
2033
+ return new Text(theme.fg("muted", "(no subagent result)"), 0, 0);
2034
+ }
2035
+ });
2036
+
2037
+ // Read-only in-flight query. Results still arrive automatically as
2038
+ // [subagent-result] notifications; this tool exists only to confirm which
2039
+ // tasks are still running (e.g. after a /tree rewind wiped the receipts).
2040
+ pi.registerTool({
2041
+ name: "subagent_status",
2042
+ label: "Subagent Status",
2043
+ description: [
2044
+ "List currently running background subagent tasks (在途任务: taskId、agent、任务描述).",
2045
+ "Results arrive automatically as [subagent-result] notifications — do NOT use",
2046
+ "this tool to poll for completion. Use it only to confirm which tasks are still",
2047
+ "in flight (e.g. after a /tree rewind), or to pick a taskId for subagent_cancel.",
2048
+ ].join("\n"),
2049
+ promptSnippet:
2050
+ "List in-flight background subagent tasks (not for polling — results arrive as [subagent-result] notifications).",
2051
+ parameters: Type.Object({}),
2052
+
2053
+ async execute() {
2054
+ return {
2055
+ content: [{ type: "text", text: formatActiveTasks() }],
2056
+ details: { activeTasks: [...taskRegistry.values()].filter((t) => t.status === "running").map((t) => t.taskId) },
2057
+ };
2058
+ },
2059
+ });
2060
+
2061
+ // Cancellation has two paths sharing cancelTask (which reuses the SIGTERM
2062
+ // -> 5s -> SIGKILL abort cascade and records who cancelled on the task so
2063
+ // the result envelope can name the origin): the main agent's subagent_cancel
2064
+ // tool below, and the user's /subagent-cancel command.
2065
+ pi.registerTool({
2066
+ name: "subagent_cancel",
2067
+ label: "Subagent Cancel",
2068
+ description: [
2069
+ "Cancel a running background subagent task (取消一个仍在运行的后台 subagent 任务) by taskId.",
2070
+ "Use only when the task is clearly wrong (错误) or no longer needed (不再需要).",
2071
+ "Do NOT cancel just because it is taking a long time — background subagents are",
2072
+ "expected to run long; be patient (耐心等待) and let the [subagent-result]",
2073
+ "notification arrive.",
2074
+ ].join("\n"),
2075
+ promptSnippet:
2076
+ "Cancel a running background subagent task by taskId (only when it is clearly wrong or no longer needed).",
2077
+ promptGuidelines: [
2078
+ "subagent_cancel: Cancel a background subagent task only when it is clearly wrong or no longer needed — never cancel merely because it is taking long; the cancellation arrives later as a [subagent-result] notification with status 已取消 (cancelled).",
2079
+ ],
2080
+ parameters: Type.Object({
2081
+ taskId: Type.String({
2082
+ description: "taskId of the running background subagent task to cancel (from the subagent dispatch receipt).",
2083
+ }),
2084
+ }),
2085
+
2086
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
2087
+ const taskId = typeof params.taskId === "string" ? params.taskId.trim() : "";
2088
+ if (!taskId) {
2089
+ return {
2090
+ content: [{ type: "text", text: 'Missing or empty required parameter: "taskId" (taskId 必填,不能为空).' }],
2091
+ details: { taskId: "", cancelled: false },
2092
+ isError: true,
2093
+ };
2094
+ }
2095
+ // Only registry (async/TUI) tasks are cancellable; sync-mode tasks are
2096
+ // awaited inline and never enter the registry.
2097
+ if (!cancelTask(taskId, "agent")) {
2098
+ return {
2099
+ content: [{ type: "text", text: `无此运行中任务: ${taskId} (no running subagent task with this id).` }],
2100
+ details: { taskId, cancelled: false },
2101
+ isError: true,
2102
+ };
2103
+ }
2104
+ return {
2105
+ content: [{ type: "text", text: `已发送取消请求: ${taskId} (cancel request sent); 结果稍后以 [subagent-result] 通知返回。\n${formatActiveTasks()}` }],
2106
+ details: { taskId, cancelled: true },
2107
+ };
2108
+ },
2109
+ });
2110
+
2111
+ // /subagent-cancel <taskId> is the user's cancel path.
2112
+ // (Optional-call guards keep minimal mock `pi` objects in tests working.)
2113
+ pi.registerCommand?.("subagent-cancel", {
2114
+ description: "Cancel a running background subagent task (usage: /subagent-cancel <taskId>)",
2115
+ handler: async (args, cmdCtx) => {
2116
+ const taskId = (args ?? "").trim();
2117
+ if (!taskId) {
2118
+ // No argument: list the running tasks so the user knows what to cancel.
2119
+ const running = [...taskRegistry.values()]
2120
+ .filter((t) => t.status === "running")
2121
+ .map((t) => t.taskId);
2122
+ const hint = running.length > 0 ? ` Running tasks: ${running.join(", ")}.` : " No running tasks.";
2123
+ cmdCtx.ui?.notify?.(`No running subagent task with id "(none)".${hint}`, "warning");
2124
+ return;
2125
+ }
2126
+ if (!cancelTask(taskId, "user")) {
2127
+ cmdCtx.ui?.notify?.(`No running subagent task with id "${taskId}".`, "warning");
2128
+ return;
2129
+ }
2130
+ cmdCtx.ui?.notify?.(`Subagent task ${taskId} cancelled.`, "info");
2131
+ },
2132
+ });
2133
+
2134
+ // /subagent-cancel-all cancels every running background subagent task at once.
2135
+ // Each task goes through the shared cancelTask path (cancelledBy = "user"), so
2136
+ // the abort -> SIGTERM cascade and the per-task cancelled envelope are
2137
+ // identical to cancelling them one by one via /subagent-cancel.
2138
+ pi.registerCommand?.("subagent-cancel-all", {
2139
+ description: "Cancel all running background subagent tasks (usage: /subagent-cancel-all)",
2140
+ handler: async (_args, cmdCtx) => {
2141
+ const running = [...taskRegistry.values()].filter((t) => t.status === "running");
2142
+ if (running.length === 0) {
2143
+ cmdCtx.ui?.notify?.("无运行中任务可取消 (no running subagent tasks).", "info");
2144
+ return;
2145
+ }
2146
+ let cancelled = 0;
2147
+ for (const task of running) {
2148
+ if (cancelTask(task.taskId, "user")) cancelled++;
2149
+ }
2150
+ cmdCtx.ui?.notify?.(`已取消全部 ${cancelled} 个运行中任务 (cancelled ${cancelled} running subagent task(s)).`, "info");
2151
+ },
2152
+ });
2153
+
2154
+ // Read-back belongs to the user only: /subagent-result <taskId> prints the
2155
+ // full final assistant text of a finished background subagent task. The
2156
+ // notification card stays minimal on purpose; the full result lives in the
2157
+ // task's session file under subagent-sessions/<taskId>/.
2158
+ pi.registerCommand?.("subagent-result", {
2159
+ description: "Show the full final result of a background subagent task (usage: /subagent-result <taskId>)",
2160
+ handler: async (args, cmdCtx) => {
2161
+ const taskId = (args ?? "").trim();
2162
+ if (!taskId) {
2163
+ cmdCtx.ui?.notify?.("Usage: /subagent-result <taskId> — 查看某子 agent 的完整返回。", "warning");
2164
+ return;
2165
+ }
2166
+ // Refuse mid-flight reads: while the task is in the registry its
2167
+ // session file only holds a partial snapshot.
2168
+ if (taskRegistry.has(taskId)) {
2169
+ cmdCtx.ui?.notify?.(`任务仍在运行,完成后才能查看: ${taskId}`, "warning");
2170
+ return;
2171
+ }
2172
+ const file = findSessionFile(taskId);
2173
+ if (!file) {
2174
+ cmdCtx.ui?.notify?.(`无此任务记录: ${taskId}`, "warning");
2175
+ return;
2176
+ }
2177
+ const text = extractSessionTranscript(file);
2178
+ if (!text) {
2179
+ cmdCtx.ui?.notify?.(`任务无最终输出(未产生 assistant 文本,可能已被终止): ${taskId}\n会话文件: ${file}`, "warning");
2180
+ return;
2181
+ }
2182
+ // pi discards a command handler's return value, so the full text is
2183
+ // shown in a fullscreen read-only viewer (same pattern as the
2184
+ // summarize example); outside the TUI fall back to console.log.
2185
+ if (cmdCtx.hasUI && cmdCtx.mode === "tui") {
2186
+ await cmdCtx.ui.custom((tui, theme, _kb, done) => {
2187
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
2188
+ // Title row doubles as the key-hint row (the footer row was pushed
2189
+ // off-screen). Truncated from the tail at render time so the front
2190
+ // keys stay visible when the combined line exceeds the width.
2191
+ const titleText =
2192
+ theme.fg("accent", theme.bold(`Subagent Result: ${taskId}`)) +
2193
+ theme.fg("dim", " ↑↓/jk 滚动 · Space/b 翻页 · g/G 首尾 · Enter/Esc/q 关闭");
2194
+ const md = new Markdown(text.trim(), 1, 1, getMarkdownTheme());
2195
+ // Scroll state: render(width) slices the fully-rendered markdown
2196
+ // lines to the visible window; handleInput moves the window.
2197
+ let scrollOffset = 0;
2198
+ let lastWidth = 80;
2199
+ // Overhead: top border + title + bottom border = 3 rows.
2200
+ const visibleHeight = () => Math.max(1, (process.stdout.rows || 24) - 3);
2201
+ const maxScroll = () => Math.max(0, md.render(lastWidth).length - visibleHeight());
2202
+ return {
2203
+ render: (width: number) => {
2204
+ lastWidth = width;
2205
+ scrollOffset = Math.min(scrollOffset, maxScroll());
2206
+ const body = md.render(width).slice(scrollOffset, scrollOffset + visibleHeight());
2207
+ const title = new Text(truncateToWidth(titleText, width - 2), 1, 0);
2208
+ return [
2209
+ ...border.render(width),
2210
+ ...title.render(width),
2211
+ ...body,
2212
+ ...border.render(width),
2213
+ ];
2214
+ },
2215
+ invalidate: () => md.invalidate(),
2216
+ handleInput: (data: string) => {
2217
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, "q")) {
2218
+ done(undefined);
2219
+ return;
2220
+ }
2221
+ if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
2222
+ scrollOffset = Math.max(0, scrollOffset - 1);
2223
+ } else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
2224
+ scrollOffset = Math.min(maxScroll(), scrollOffset + 1);
2225
+ } else if (matchesKey(data, Key.pageUp) || matchesKey(data, "b")) {
2226
+ // 整页翻页:一页 = 当前可见行数
2227
+ scrollOffset = Math.max(0, scrollOffset - visibleHeight());
2228
+ } else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.space)) {
2229
+ scrollOffset = Math.min(maxScroll(), scrollOffset + visibleHeight());
2230
+ } else if (matchesKey(data, Key.home) || matchesKey(data, "g")) {
2231
+ scrollOffset = 0;
2232
+ } else if (matchesKey(data, Key.end) || matchesKey(data, Key.shift("g"))) {
2233
+ scrollOffset = maxScroll();
2234
+ } else {
2235
+ return;
2236
+ }
2237
+ tui?.requestRender?.();
2238
+ },
2239
+ };
2240
+ });
2241
+ } else {
2242
+ console.log(`\n[subagent-result] taskId: ${taskId}\n\n${text}\n`);
2243
+ }
2244
+ },
2245
+ });
2246
+
2247
+ // Kill all in-flight background subagents when the session goes away
2248
+ // (quit / reload / session switch).
2249
+ pi.on?.("session_shutdown", async () => {
2250
+ for (const task of taskRegistry.values()) {
2251
+ if (task.status === "running") {
2252
+ task.status = "killed_on_shutdown";
2253
+ task.abortController.abort();
2254
+ }
2255
+ // SIGKILL backstop: abort() only sends SIGTERM, and the 5s SIGKILL
2256
+ // escalation timer inside runSingleAgent never fires when the main
2257
+ // process quits right after shutdown — a SIGTERM-ignoring child would
2258
+ // be orphaned. The session is going away either way, so skip the
2259
+ // grace period and SIGKILL any still-alive process immediately —
2260
+ // including already-cancelled tasks still inside their SIGTERM grace
2261
+ // window.
2262
+ const proc = task.proc;
2263
+ if (proc && proc.exitCode === null && proc.signalCode === null) {
2264
+ try {
2265
+ proc.kill("SIGKILL");
2266
+ } catch {
2267
+ /* ignore ESRCH */
2268
+ }
2269
+ }
2270
+ }
2271
+ });
2272
+
2273
+ // Render [subagent-result] notifications as a minimal card: the full result
2274
+ // text stays in the envelope content (LLM context) but is NOT rendered in
2275
+ // the UI; the user can read it with /subagent-result <taskId>.
2276
+ pi.registerMessageRenderer?.("subagent-result", (message, _options, theme) => {
2277
+ try {
2278
+ const details = message.details as SubagentResultDetails | undefined;
2279
+ const status = details?.status ?? "";
2280
+ const isOk = status === STATUS_WORDS.success;
2281
+ const icon = isOk ? theme.fg("success", "✓") : theme.fg("error", "✗");
2282
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(details?.agent ?? "subagent"))}`;
2283
+ if (status) text += ` ${theme.fg(isOk ? "success" : "error", status)}`;
2284
+ if (details?.taskId) text += ` ${theme.fg("muted", `(taskId: ${details.taskId})`)}`;
2285
+ const usageStr = details ? formatUsageStats(details.usage) : "";
2286
+ if (usageStr) text += ` ${theme.fg("dim", usageStr)}`;
2287
+ if (details?.taskId) text += `\n${theme.fg("muted", `查看全文: /subagent-result ${details.taskId}`)}`;
2288
+ // Background tint mirrors the dispatch-receipt tool rows: success and
2289
+ // failure reuse the tool-row colors; timeout, cancelled and unknown
2290
+ // states fall back to the neutral pending tint.
2291
+ const bg = isOk ? "toolSuccessBg" : status === STATUS_WORDS.failure ? "toolErrorBg" : "toolPendingBg";
2292
+ const box = new Box(1, 0, (s) => theme.bg(bg, s));
2293
+ box.addChild(new Text(text, 0, 0));
2294
+ return box;
2295
+ } catch {
2296
+ // Rendering must never break the session; fall back to raw content.
2297
+ return new Text(typeof message.content === "string" ? message.content : "", 0, 0);
2298
+ }
2299
+ });
2300
+ }