@maplezzk/pi-interactive-subagents 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2248 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { Type, type Static } from "@sinclair/typebox";
4
+ import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ readdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ copyFileSync,
14
+ unlinkSync,
15
+ } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import {
18
+ createSurface,
19
+ sendLongCommand,
20
+ pollForExit,
21
+ closeSurface,
22
+ muxLog,
23
+ getMuxBackend,
24
+ isMuxAvailable,
25
+ sendEscape,
26
+ shellEscape,
27
+ renameCurrentTab,
28
+ renameWorkspace,
29
+ renameAgent,
30
+ readScreen,
31
+ getLastSplitSource,
32
+ clearLastSplitSource,
33
+ } from "pi-terminal-mux";
34
+
35
+ import {
36
+ findLastAssistantMessage,
37
+ getNewEntries,
38
+ seedSubagentSessionFile,
39
+ } from "./session.ts";
40
+ import {
41
+ type StatusSnapshot,
42
+ type SubagentStatusState,
43
+ advanceStatusState,
44
+ capStatusLines,
45
+ classifyStatus,
46
+ createStatusState,
47
+ forceStatusAfterInterrupt,
48
+ formatStatusAggregate,
49
+ formatTransitionLine,
50
+ observeStatus,
51
+ loadStatusConfig,
52
+ } from "./status.ts";
53
+ import {
54
+ getSubagentActivityFile,
55
+ readSubagentActivityFile,
56
+ type ActivityReadResult,
57
+ type SubagentActivityState,
58
+ } from "./activity.ts";
59
+
60
+ /** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */
61
+ const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
62
+
63
+ /** 从 content 数组提取第一个 TextContent 的文本(若存在) */
64
+ function extractFirstText(content: Array<{ type: string; text?: string }>): string {
65
+ const first = content[0];
66
+ return first && typeof first.text === "string" ? first.text : "";
67
+ }
68
+
69
+ /**
70
+ * 默认 subagent 模型。
71
+ *
72
+ * 解析优先级(高 → 低):
73
+ * 1. 调用方传入的 `model` 参数(subagent tool 调用时显式指定)
74
+ * 2. agent 配置文件 frontmatter 里的 `model` 字段
75
+ * 3. 环境变量 PI_SUBAGENT_DEFAULT_MODEL
76
+ * 4. undefined — 不传 --model,子 pi 继承父 session 的默认模型
77
+ */
78
+ function getDefaultSubagentModel(): string | undefined {
79
+ return process.env.PI_SUBAGENT_DEFAULT_MODEL?.trim() || undefined;
80
+ }
81
+
82
+ // Survive /reload: clear timers and abort poll loops from the previous module load.
83
+ // /reload re-imports this file, giving fresh module-level state, but closures from
84
+ // the old module keep running. See https://github.com/HazAT/pi-interactive-subagents/issues/5
85
+ const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval");
86
+ const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval");
87
+ const POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller");
88
+
89
+ {
90
+ const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY];
91
+ if (prevInterval) {
92
+ clearInterval(prevInterval);
93
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
94
+ }
95
+ const prevStatusInterval = (globalThis as any)[STATUS_INTERVAL_KEY];
96
+ if (prevStatusInterval) {
97
+ clearInterval(prevStatusInterval);
98
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
99
+ }
100
+ const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
101
+ if (prevAbort) prevAbort.abort();
102
+ (globalThis as any)[POLL_ABORT_KEY] = new AbortController();
103
+ }
104
+
105
+ function getModuleAbortSignal(): AbortSignal {
106
+ // 自愈:若 controller 已被 abort(session_shutdown 会调 abort 但不重置),
107
+ // 自动重建一个新 controller,避免下一次 watchSubagent 拿到 aborted signal
108
+ // 导致 pollForExit 第一行 throw "Aborted while waiting..."、子 pi 来不及启动
109
+ // 就被 close。现象:pane run 后 ~0ms pane close、无任何 pane read 轮询日志、
110
+ // workflow 报 "subagent never actually started"。
111
+ const existing = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
112
+ if (!existing || existing.signal.aborted) {
113
+ const fresh = new AbortController();
114
+ (globalThis as any)[POLL_ABORT_KEY] = fresh;
115
+ muxLog(`[getModuleAbortSignal] self-healed: ${existing ? "was aborted" : "missing"} → created new controller\n`);
116
+ return fresh.signal;
117
+ }
118
+ return existing.signal;
119
+ }
120
+
121
+ const SubagentParams = Type.Object({
122
+ name: Type.String({ description: "Display name for the subagent" }),
123
+ task: Type.String({ description: "Task/prompt for the sub-agent" }),
124
+ agent: Type.Optional(
125
+ Type.String({
126
+ description:
127
+ "Agent name to load defaults from (e.g. 'worker', 'scout', 'reviewer'). Reads ~/.pi/agent/agents/<name>.md for model, tools, skills.",
128
+ }),
129
+ ),
130
+ systemPrompt: Type.Optional(
131
+ Type.String({ description: "Appended to system prompt (role instructions)" }),
132
+ ),
133
+ model: Type.Optional(Type.String({ description: "Model override (overrides agent default)" })),
134
+ skills: Type.Optional(
135
+ Type.String({ description: "Comma-separated skills (overrides agent default)" }),
136
+ ),
137
+ tools: Type.Optional(
138
+ Type.String({ description: "Comma-separated tools (overrides agent default)" }),
139
+ ),
140
+ cwd: Type.Optional(
141
+ Type.String({
142
+ description:
143
+ "Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.",
144
+ }),
145
+ ),
146
+ fork: Type.Optional(
147
+ Type.Boolean({
148
+ description:
149
+ "Force the full-context fork mode for this spawn. The sub-agent inherits the current session conversation, overriding any agent frontmatter session-mode.",
150
+ }),
151
+ ),
152
+ interactive: Type.Optional(
153
+ Type.Boolean({
154
+ description:
155
+ "Mark the subagent as interactive (long-running, user drives the conversation in its own pane). When true, the main session is not woken by status transitions (stalled/recovered) for this subagent. If omitted, falls back to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit` (agents that auto-exit are autonomous and get stall pings; agents that don't are interactive and stay quiet).",
156
+ }),
157
+ ),
158
+ resumeSessionId: Type.Optional(
159
+ Type.String({
160
+ description:
161
+ "Resume a previous Claude Code session by its ID. Loads the conversation history and continues where it left off. The session ID is returned in details of every claude tool call. Use this to retry cancelled runs or ask follow-up questions.",
162
+ }),
163
+ ),
164
+ structuredOutputSchema: Type.Optional(
165
+ // 用带 type:"object" 的定义,避免 Type.Any 无 type 导致 LLM 把对象
166
+ // 序列化成字符串(进而触发子进程 ajv.compile(字符串) 崩
167
+ // "schema must be object or boolean")。
168
+ Type.Object(
169
+ {},
170
+ {
171
+ additionalProperties: true,
172
+ description:
173
+ "Optional JSON Schema object. When set, the sub-agent will be provisioned with a structured_output tool that validates its final arguments against this schema before returning. On validation failure the sub-agent sees the errors and can retry; on success the validated value is delivered via the .exit sidecar file.",
174
+ },
175
+ ),
176
+ ),
177
+ });
178
+
179
+ type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
180
+
181
+ interface AgentDefaults {
182
+ model?: string;
183
+ tools?: string;
184
+ skills?: string;
185
+ thinking?: string;
186
+ denyTools?: string;
187
+ spawning?: boolean;
188
+ autoExit?: boolean;
189
+ interactive?: boolean;
190
+ systemPromptMode?: "append" | "replace";
191
+ sessionMode?: SubagentSessionMode;
192
+ cwd?: string;
193
+ cli?: string;
194
+ body?: string;
195
+ disableModelInvocation?: boolean;
196
+ }
197
+
198
+ type AgentSource = "package" | "global" | "project";
199
+
200
+ interface AgentDefinition extends AgentDefaults {
201
+ name: string;
202
+ description?: string;
203
+ disableModelInvocation: boolean;
204
+ }
205
+
206
+ interface ListedAgentDefinition extends AgentDefinition {
207
+ source: AgentSource;
208
+ }
209
+
210
+ /** Tools that are gated by `spawning: false` */
211
+ const SPAWNING_TOOLS = new Set([
212
+ "subagent",
213
+ "subagent_interrupt",
214
+ "subagents_list",
215
+ "subagent_resume",
216
+ ]);
217
+
218
+ /**
219
+ * Resolve the effective set of denied tool names from agent defaults.
220
+ * `spawning: false` expands to all SPAWNING_TOOLS.
221
+ * `deny-tools` adds individual tool names on top.
222
+ */
223
+ function resolveDenyTools(agentDefs: AgentDefaults | null): Set<string> {
224
+ const denied = new Set<string>();
225
+ if (!agentDefs) return denied;
226
+
227
+ // spawning: false → deny all spawning tools
228
+ if (agentDefs.spawning === false) {
229
+ for (const t of SPAWNING_TOOLS) denied.add(t);
230
+ }
231
+
232
+ // deny-tools: explicit list
233
+ if (agentDefs.denyTools) {
234
+ for (const t of agentDefs.denyTools
235
+ .split(",")
236
+ .map((s) => s.trim())
237
+ .filter(Boolean)) {
238
+ denied.add(t);
239
+ }
240
+ }
241
+
242
+ return denied;
243
+ }
244
+
245
+ /** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */
246
+ function getAgentConfigDir(): string {
247
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
248
+ }
249
+
250
+ function getBundledAgentsDir(): string {
251
+ return join(SUBAGENTS_DIR, "../../agents");
252
+ }
253
+
254
+ function getFrontmatterValue(frontmatter: string, key: string): string | undefined {
255
+ const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
256
+ return match ? match[1].trim() : undefined;
257
+ }
258
+
259
+ function parseOptionalBoolean(value: string | undefined): boolean | undefined {
260
+ return value != null ? value === "true" : undefined;
261
+ }
262
+
263
+ function parseSessionMode(value: string | undefined): SubagentSessionMode | undefined {
264
+ if (value === "standalone" || value === "lineage-only" || value === "fork") {
265
+ return value;
266
+ }
267
+ return undefined;
268
+ }
269
+
270
+ function parseAgentDefinition(content: string, fallbackName: string): AgentDefinition | null {
271
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
272
+ if (!match) return null;
273
+
274
+ const frontmatter = match[1];
275
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
276
+ const systemPromptMode = getFrontmatterValue(frontmatter, "system-prompt");
277
+
278
+ return {
279
+ name: getFrontmatterValue(frontmatter, "name") ?? fallbackName,
280
+ description: getFrontmatterValue(frontmatter, "description"),
281
+ model: getFrontmatterValue(frontmatter, "model"),
282
+ tools: getFrontmatterValue(frontmatter, "tools"),
283
+ systemPromptMode:
284
+ systemPromptMode === "replace"
285
+ ? "replace"
286
+ : systemPromptMode === "append"
287
+ ? "append"
288
+ : undefined,
289
+ skills: getFrontmatterValue(frontmatter, "skill") ?? getFrontmatterValue(frontmatter, "skills"),
290
+ thinking: getFrontmatterValue(frontmatter, "thinking"),
291
+ denyTools: getFrontmatterValue(frontmatter, "deny-tools"),
292
+ spawning: parseOptionalBoolean(getFrontmatterValue(frontmatter, "spawning")),
293
+ autoExit: parseOptionalBoolean(getFrontmatterValue(frontmatter, "auto-exit")),
294
+ interactive: parseOptionalBoolean(getFrontmatterValue(frontmatter, "interactive")),
295
+ sessionMode: parseSessionMode(getFrontmatterValue(frontmatter, "session-mode")),
296
+ cwd: getFrontmatterValue(frontmatter, "cwd"),
297
+ cli: getFrontmatterValue(frontmatter, "cli"),
298
+ body: body || undefined,
299
+ disableModelInvocation:
300
+ getFrontmatterValue(frontmatter, "disable-model-invocation")?.toLowerCase() === "true",
301
+ };
302
+ }
303
+
304
+ function discoverAgentDefinitions(): ListedAgentDefinition[] {
305
+ const agents = new Map<string, ListedAgentDefinition>();
306
+ const dirs: Array<{ path: string; source: AgentSource }> = [
307
+ { path: getBundledAgentsDir(), source: "package" },
308
+ { path: join(getAgentConfigDir(), "agents"), source: "global" },
309
+ { path: join(process.cwd(), ".pi", "agents"), source: "project" },
310
+ ];
311
+
312
+ for (const { path: dir, source } of dirs) {
313
+ if (!existsSync(dir)) continue;
314
+ for (const file of readdirSync(dir).filter((entry) => entry.endsWith(".md"))) {
315
+ const parsed = parseAgentDefinition(
316
+ readFileSync(join(dir, file), "utf8"),
317
+ file.replace(/\.md$/, ""),
318
+ );
319
+ if (!parsed) continue;
320
+ agents.set(parsed.name, { ...parsed, source });
321
+ }
322
+ }
323
+
324
+ return [...agents.values()];
325
+ }
326
+
327
+ function resolveSubagentPaths(
328
+ params: Static<typeof SubagentParams>,
329
+ agentDefs: AgentDefaults | null,
330
+ ): { effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string } {
331
+ const rawCwd = params.cwd ?? agentDefs?.cwd ?? null;
332
+ const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null;
333
+ const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : process.cwd();
334
+ const effectiveCwd = rawCwd
335
+ ? rawCwd.startsWith("/")
336
+ ? rawCwd
337
+ : join(cwdBase, rawCwd)
338
+ : null;
339
+ const localAgentDir = effectiveCwd ? join(effectiveCwd, ".pi", "agent") : null;
340
+ const effectiveAgentDir =
341
+ localAgentDir && existsSync(localAgentDir) ? localAgentDir : getAgentConfigDir();
342
+ return { effectiveCwd, localAgentDir, effectiveAgentDir };
343
+ }
344
+
345
+ function getDefaultSessionDirFor(cwd: string, _agentDir: string): string {
346
+ const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
347
+ const sessionDir = join(homedir(), ".pi", "subagent-sessions", safePath);
348
+ if (!existsSync(sessionDir)) {
349
+ mkdirSync(sessionDir, { recursive: true });
350
+ }
351
+ return sessionDir;
352
+ }
353
+
354
+ function resolveEffectiveSessionMode(
355
+ params: Static<typeof SubagentParams>,
356
+ agentDefs: AgentDefaults | null,
357
+ ): SubagentSessionMode {
358
+ if (params.fork) return "fork";
359
+ return agentDefs?.sessionMode ?? "standalone";
360
+ }
361
+
362
+ function resolveLaunchBehavior(
363
+ params: Static<typeof SubagentParams>,
364
+ agentDefs: AgentDefaults | null,
365
+ ): {
366
+ sessionMode: SubagentSessionMode;
367
+ seededSessionMode: "lineage-only" | "fork" | null;
368
+ inheritsConversationContext: boolean;
369
+ taskDelivery: "direct" | "artifact";
370
+ } {
371
+ const sessionMode = resolveEffectiveSessionMode(params, agentDefs);
372
+ const inheritsConversationContext = sessionMode === "fork";
373
+ return {
374
+ sessionMode,
375
+ seededSessionMode: sessionMode === "standalone" ? null : sessionMode,
376
+ inheritsConversationContext,
377
+ taskDelivery: inheritsConversationContext ? "direct" : "artifact",
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Decide whether a subagent is interactive (user-driven, long-running).
383
+ *
384
+ * Resolution order:
385
+ * 1. Explicit `interactive` tool parameter wins.
386
+ * 2. Explicit `interactive` frontmatter field on the agent.
387
+ * 3. Default: the inverse of `auto-exit`. Agents that auto-exit are
388
+ * autonomous (scout, worker, reviewer) and the parent session should be
389
+ * woken on stall/recovery transitions. Agents that don't auto-exit are
390
+ * driven by the user in their own pane (planner, iterate/fork) and
391
+ * stall pings are noise.
392
+ *
393
+ * When no agent defs exist at all (bare `subagent({ name, task })` call,
394
+ * typical for `/iterate` with `fork: true`), `autoExit` is undefined and the
395
+ * subagent is treated as interactive — matching the intent of iterate.
396
+ */
397
+ function resolveEffectiveInteractive(
398
+ params: Static<typeof SubagentParams>,
399
+ agentDefs: AgentDefaults | null,
400
+ ): boolean {
401
+ if (params.interactive != null) return params.interactive;
402
+ if (agentDefs?.interactive != null) return agentDefs.interactive;
403
+ return !(agentDefs?.autoExit ?? false);
404
+ }
405
+
406
+ function loadAgentDefaults(agentName: string): AgentDefaults | null {
407
+ const configDir = getAgentConfigDir();
408
+ const paths = [
409
+ join(process.cwd(), ".pi", "agents", `${agentName}.md`),
410
+ join(configDir, "agents", `${agentName}.md`),
411
+ join(getBundledAgentsDir(), `${agentName}.md`),
412
+ ];
413
+
414
+ for (const p of paths) {
415
+ if (!existsSync(p)) continue;
416
+ const parsed = parseAgentDefinition(readFileSync(p, "utf8"), agentName);
417
+ if (parsed) return parsed;
418
+ }
419
+
420
+ return null;
421
+ }
422
+
423
+ function formatElapsed(seconds: number): string {
424
+ if (seconds < 60) return `${seconds}s`;
425
+ const m = Math.floor(seconds / 60);
426
+ const s = seconds % 60;
427
+ return `${m}m ${s}s`;
428
+ }
429
+
430
+ /**
431
+ * Wait long enough for a freshly created pane to finish shell startup.
432
+ *
433
+ * Some environments do extra shell-init work before the prompt is ready
434
+ * (for example direnv/devenv), so the delay is configurable for users who hit
435
+ * dropped commands. Keep the historical default at 500ms.
436
+ */
437
+ function getShellReadyDelayMs(): number {
438
+ const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
439
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
440
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
441
+ }
442
+
443
+ /**
444
+ * Build the internal artifact directory path for the current session.
445
+ * Used by the subagents extension to stash task files, system prompts, and
446
+ * launch scripts for sub-agents. Path convention:
447
+ * <sessionDir>/artifacts/<session-id>/
448
+ */
449
+ function getArtifactDir(sessionDir: string, sessionId: string): string {
450
+ return join(sessionDir, "artifacts", sessionId);
451
+ }
452
+
453
+ const statusConfig = loadStatusConfig();
454
+
455
+ function formatWidgetRightLabel(snapshot: StatusSnapshot): string {
456
+ if (snapshot.kind === "starting") return " starting… ";
457
+ if (snapshot.kind === "running") return ` running ${snapshot.elapsedText} `;
458
+ if (snapshot.kind === "active") {
459
+ const label = snapshot.activityLabel ?? snapshot.activeScope;
460
+ const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
461
+ return label ? ` active · ${label}${duration} ` : " active ";
462
+ }
463
+ if (snapshot.kind === "waiting") {
464
+ const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
465
+ const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
466
+ return ` waiting${duration}${detail} `;
467
+ }
468
+
469
+ const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
470
+ const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
471
+ return ` stalled${detail}${duration} `;
472
+ }
473
+
474
+ function resolveResultPresentation(
475
+ result: Pick<SubagentResult, "exitCode" | "elapsed" | "summary" | "sessionFile">,
476
+ name: string,
477
+ ): string {
478
+ const sessionRef = result.sessionFile
479
+ ? `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`
480
+ : "";
481
+
482
+ return result.exitCode !== 0
483
+ ? `Sub-agent "${name}" failed (exit code ${result.exitCode}).\n\n${result.summary}${sessionRef}`
484
+ : `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}${sessionRef}`;
485
+ }
486
+
487
+ /**
488
+ * Result from running a single subagent.
489
+ */
490
+ interface SubagentResult {
491
+ name: string;
492
+ task: string;
493
+ summary: string;
494
+ sessionFile?: string;
495
+ claudeSessionId?: string;
496
+ exitCode: number;
497
+ elapsed: number;
498
+ error?: string;
499
+ ping?: { name: string; message: string };
500
+ /** Validated structured output (when subagent called structured_output tool) */
501
+ structuredOutput?: unknown;
502
+ }
503
+
504
+ /**
505
+ * State for a launched (but not yet completed) subagent.
506
+ */
507
+ interface RunningSubagent {
508
+ id: string;
509
+ name: string;
510
+ task: string;
511
+ agent?: string;
512
+ surface: string;
513
+ startTime: number;
514
+ sessionFile: string;
515
+ launchScriptFile?: string;
516
+ activityFile?: string;
517
+ activity?: SubagentActivityState;
518
+ activityRead?: {
519
+ ok: boolean;
520
+ reason?: "missing" | "invalid" | "wrong-id";
521
+ error?: string;
522
+ };
523
+ abortController?: AbortController;
524
+ cli?: string;
525
+ sentinelFile?: string;
526
+ statusState: SubagentStatusState;
527
+ /**
528
+ * When true, status transitions (stalled/recovered) do not wake the parent
529
+ * session via a steer message. The widget still updates locally. Used for
530
+ * long-running agents where the user drives the conversation in the
531
+ * subagent's pane (e.g. planner).
532
+ */
533
+ interactive: boolean;
534
+ /** 新分屏的来源 pane ID,用于在 TUI 中展示 */
535
+ splitFrom?: string;
536
+ }
537
+
538
+ /** All currently running subagents, keyed by id. */
539
+ const runningSubagents = new Map<string, RunningSubagent>();
540
+
541
+ // ── Widget management ──
542
+
543
+ /** Latest ExtensionContext from session_start, used for widget updates. */
544
+ let latestCtx: ExtensionContext | null = null;
545
+
546
+ /** Interval timer for widget re-renders. */
547
+ let widgetInterval: ReturnType<typeof setInterval> | null = null;
548
+
549
+ /** Interval timer for status transition checks. */
550
+ let statusInterval: ReturnType<typeof setInterval> | null = null;
551
+
552
+ function formatElapsedMMSS(startTime: number): string {
553
+ const seconds = Math.floor((Date.now() - startTime) / 1000);
554
+ const m = Math.floor(seconds / 60);
555
+ const s = seconds % 60;
556
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
557
+ }
558
+
559
+ const ACCENT = "\x1b[38;2;77;163;255m";
560
+ const RST = "\x1b[0m";
561
+
562
+ /**
563
+ * Build a bordered content line: │left right│
564
+ * Left content is truncated if needed, right is preserved, padded to fill width.
565
+ */
566
+ function borderLine(left: string, right: string, width: number): string {
567
+ if (width <= 0) return "";
568
+ if (width === 1) return `${ACCENT}│${RST}`;
569
+
570
+ // width = total visible chars for the whole line including │ and │
571
+ const contentWidth = Math.max(0, width - 2); // space inside the two │ chars
572
+ const rightVis = visibleWidth(right);
573
+
574
+ // If the status chunk alone is too wide, prefer preserving it in compact form
575
+ // rather than overflowing the terminal.
576
+ if (rightVis >= contentWidth) {
577
+ const truncRight = truncateToWidth(right, contentWidth);
578
+ const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
579
+ return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`;
580
+ }
581
+
582
+ const maxLeft = Math.max(0, contentWidth - rightVis);
583
+ const truncLeft = truncateToWidth(left, maxLeft);
584
+ const leftVis = visibleWidth(truncLeft);
585
+ const pad = Math.max(0, contentWidth - leftVis - rightVis);
586
+ return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`;
587
+ }
588
+
589
+ /**
590
+ * Build the bordered top line: ╭─ Title ──── info ─╮
591
+ * All chars are accounted for within `width`.
592
+ */
593
+ function borderTop(title: string, info: string, width: number): string {
594
+ if (width <= 0) return "";
595
+ if (width === 1) return `${ACCENT}╭${RST}`;
596
+
597
+ // ╭─ Title ───...─── info ─╮
598
+ // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify
599
+ const inner = Math.max(0, width - 2); // inside ╭ and ╮
600
+ const titlePart = `─ ${title} `;
601
+ const infoPart = ` ${info} ─`;
602
+ const fillLen = Math.max(0, inner - titlePart.length - infoPart.length);
603
+ const fill = "─".repeat(fillLen);
604
+ const content = `${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─");
605
+ return `${ACCENT}╭${content}╮${RST}`;
606
+ }
607
+
608
+ /**
609
+ * Build the bordered bottom line: ╰──────────────────╯
610
+ */
611
+ function borderBottom(width: number): string {
612
+ if (width <= 0) return "";
613
+ if (width === 1) return `${ACCENT}╰${RST}`;
614
+
615
+ const inner = Math.max(0, width - 2);
616
+ return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`;
617
+ }
618
+
619
+ function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
620
+ const count = agents.length;
621
+ const title = "Subagents";
622
+ const info = `${count} running`;
623
+
624
+ const lines: string[] = [borderTop(title, info, width)];
625
+
626
+ for (const agent of agents) {
627
+ const elapsed = formatElapsedMMSS(agent.startTime);
628
+ const agentTag = agent.agent ? ` (${agent.agent})` : "";
629
+ const left = ` ${elapsed} ${agent.name}${agentTag} `;
630
+ const snapshot = classifyStatus(agent.statusState, Date.now());
631
+ const right = statusConfig.enabled
632
+ ? formatWidgetRightLabel(snapshot)
633
+ : agent.cli === "claude"
634
+ ? " running… "
635
+ : " starting… ";
636
+
637
+ lines.push(borderLine(left, right, width));
638
+ }
639
+
640
+ lines.push(borderBottom(width));
641
+ return lines;
642
+ }
643
+
644
+ function updateWidget() {
645
+ if (!latestCtx?.hasUI) return;
646
+
647
+ if (runningSubagents.size === 0) {
648
+ latestCtx.ui.setWidget("subagent-status", undefined);
649
+ if (widgetInterval) {
650
+ clearInterval(widgetInterval);
651
+ widgetInterval = null;
652
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
653
+ }
654
+ return;
655
+ }
656
+
657
+ latestCtx.ui.setWidget(
658
+ "subagent-status",
659
+ (_tui: any, _theme: any) => {
660
+ return {
661
+ invalidate() {},
662
+ render(width: number) {
663
+ return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
664
+ },
665
+ };
666
+ },
667
+ { placement: "aboveEditor" },
668
+ );
669
+ }
670
+
671
+ /**
672
+ * Build the positional prompt args for a Pi CLI subagent launch.
673
+ *
674
+ * In artifact-backed launches (lineage-only, standalone), Pi's buildInitialMessage()
675
+ * concatenates @file content with messages[0] into one initial prompt. That breaks
676
+ * /skill: expansion because the message no longer starts with "/skill:". Only
677
+ * messages[1..] are sent as separate follow-up prompts where /skill: is recognized.
678
+ *
679
+ * When there are skill prompts AND artifact-backed delivery, we prepend an empty
680
+ * first positional message so that /skill: args land in messages[1..] and arrive
681
+ * as standalone prompts in the child session.
682
+ */
683
+ const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const;
684
+
685
+ /**
686
+ * Build the child --tools allowlist.
687
+ *
688
+ * Pi 0.70+ applies --tools to built-in, extension, and custom tools. If a
689
+ * subagent definition restricts tools to e.g. "read,bash,write", the child
690
+ * control tools from subagent-done.ts would otherwise be hidden, leaving a
691
+ * manually resumed or user-touched subagent unable to call subagent_done.
692
+ */
693
+ function buildSubagentToolAllowlist(effectiveTools?: string): string | null {
694
+ const requested = (effectiveTools ?? "")
695
+ .split(",")
696
+ .map((tool) => tool.trim())
697
+ .filter(Boolean);
698
+
699
+ if (requested.length === 0) return null;
700
+
701
+ const allow = new Set(requested);
702
+ for (const tool of SUBAGENT_CONTROL_TOOLS) {
703
+ allow.add(tool);
704
+ }
705
+
706
+ return [...allow].join(",");
707
+ }
708
+
709
+ function buildPiPromptArgs(params: {
710
+ effectiveSkills?: string;
711
+ taskDelivery: "direct" | "artifact";
712
+ taskArg: string;
713
+ }): string[] {
714
+ const skillPrompts = (params.effectiveSkills ?? "")
715
+ .split(",")
716
+ .map((s) => s.trim())
717
+ .filter(Boolean)
718
+ .map((skill) => `/skill:${skill}`);
719
+
720
+ const needsSeparator = params.taskDelivery === "artifact" && skillPrompts.length > 0;
721
+
722
+ return [
723
+ ...(needsSeparator ? [""] : []),
724
+ ...skillPrompts,
725
+ params.taskArg,
726
+ ];
727
+ }
728
+
729
+ function activityLabel(activity: SubagentActivityState): string | undefined {
730
+ if (activity.phase !== "active") return undefined;
731
+ if (activity.activeScope === "tool") return activity.toolName ?? "tool";
732
+ if (activity.activeScope === "provider") return "provider";
733
+ if (activity.activeScope === "streaming") return "streaming";
734
+ return activity.activeScope;
735
+ }
736
+
737
+ /** 类型守卫:判别联合在 strictNullChecks 关闭时控制流收窄失效,用 predicate 强制收窄到失败分支。 */
738
+ function isReadFailure(
739
+ r: ActivityReadResult,
740
+ ): r is { ok: false; reason: "missing" | "invalid" | "wrong-id"; error?: string } {
741
+ return !r.ok;
742
+ }
743
+
744
+ function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now()) {
745
+ if (running.cli === "claude") return;
746
+
747
+ const activityFile = running.activityFile;
748
+ const read: ActivityReadResult = activityFile
749
+ ? readSubagentActivityFile(activityFile, running.id)
750
+ : { ok: false, reason: "missing" };
751
+
752
+ if (!isReadFailure(read)) {
753
+ running.activityRead = { ok: true };
754
+ running.activity = read.activity;
755
+ running.statusState = observeStatus(running.statusState, {
756
+ snapshot: "present",
757
+ updatedAt: read.activity.updatedAt,
758
+ sequence: read.activity.sequence,
759
+ phase: read.activity.phase,
760
+ active: read.activity.phase === "active",
761
+ activeScope: read.activity.activeScope,
762
+ activeSince: read.activity.activeSince,
763
+ waitingSince: read.activity.waitingSince,
764
+ latestEvent: read.activity.latestEvent,
765
+ activityLabel: activityLabel(read.activity),
766
+ }, observedAt);
767
+ } else {
768
+ running.activityRead = { ok: false, reason: read.reason, error: read.error };
769
+ running.statusState = observeStatus(running.statusState, {
770
+ snapshot: read.reason,
771
+ snapshotError: read.error,
772
+ }, observedAt);
773
+ }
774
+ }
775
+
776
+ function resolveInterruptTarget(params: { id?: string; name?: string }):
777
+ | { running: RunningSubagent }
778
+ | { error: string } {
779
+ const requestedId = params.id?.trim();
780
+ if (requestedId) {
781
+ const running = runningSubagents.get(requestedId);
782
+ return running ? { running } : { error: `No running subagent with id "${requestedId}".` };
783
+ }
784
+
785
+ const requestedName = params.name?.trim();
786
+ if (!requestedName) {
787
+ return { error: "Provide a running subagent id or exact display name." };
788
+ }
789
+
790
+ const matches = Array.from(runningSubagents.values()).filter((running) => running.name === requestedName);
791
+ if (matches.length === 1) return { running: matches[0] };
792
+ if (matches.length === 0) {
793
+ return { error: `No running subagent named "${requestedName}".` };
794
+ }
795
+
796
+ const candidates = matches.map((running) => `${running.name} [${running.id}]`).join(", ");
797
+ return { error: `Ambiguous subagent name "${requestedName}". Matches: ${candidates}` };
798
+ }
799
+
800
+ function requestSubagentInterrupt(
801
+ running: RunningSubagent,
802
+ sendEscapeKey: (surface: string) => void = sendEscape,
803
+ ): { ok: true } | { error: string } {
804
+ try {
805
+ sendEscapeKey(running.surface);
806
+ return { ok: true };
807
+ } catch (error: any) {
808
+ const backend = getMuxBackend() ?? "unknown";
809
+ return {
810
+ error:
811
+ `Failed to send Escape to subagent "${running.name}" via ${backend}: ` +
812
+ `${error?.message ?? String(error)}`,
813
+ };
814
+ }
815
+ }
816
+
817
+ interface InterruptResult {
818
+ content: Array<{ type: "text"; text: string }>;
819
+ details: { error?: string; id?: string; name?: string; status?: string };
820
+ }
821
+
822
+ function handleSubagentInterrupt(
823
+ params: { id?: string; name?: string },
824
+ sendEscapeKey: (surface: string) => void = sendEscape,
825
+ ): InterruptResult {
826
+ const resolved = resolveInterruptTarget(params);
827
+ if ("error" in resolved) {
828
+ return {
829
+ content: [{ type: "text" as const, text: resolved.error }],
830
+ details: { error: resolved.error },
831
+ };
832
+ }
833
+
834
+ const running = resolved.running;
835
+ if (running.cli === "claude") {
836
+ return {
837
+ content: [{
838
+ type: "text" as const,
839
+ text:
840
+ "Turn-only Escape interrupt is currently supported only for Pi-backed subagents. Claude-backed semantics have not been verified yet.",
841
+ }],
842
+ details: { error: "claude interrupt unsupported", id: running.id, name: running.name },
843
+ };
844
+ }
845
+
846
+ const now = Date.now();
847
+ observeRunningSubagent(running, now);
848
+
849
+ const interruption = requestSubagentInterrupt(running, sendEscapeKey);
850
+ if ("error" in interruption) {
851
+ return {
852
+ content: [{ type: "text" as const, text: interruption.error }],
853
+ details: { error: interruption.error, id: running.id, name: running.name },
854
+ };
855
+ }
856
+
857
+ running.statusState = forceStatusAfterInterrupt(running.statusState, now);
858
+ updateWidget();
859
+
860
+ // After aborting the current generation, also signal done so the subagent
861
+ // properly finishes (parent's pollForExit detects the .exit file and
862
+ // returns the result).
863
+ if (running.sessionFile) {
864
+ const exitFile = `${running.sessionFile}.exit`;
865
+ try {
866
+ writeFileSync(exitFile, JSON.stringify({ type: "done" }));
867
+ } catch (writeErr: any) {
868
+ process.stderr.write(
869
+ `[interrupt] .exit 写入失败 file=${exitFile} err=${writeErr?.message ?? String(writeErr)}\n`,
870
+ );
871
+ }
872
+ }
873
+
874
+ return {
875
+ content: [{ type: "text" as const, text: `Interrupt requested for subagent "${running.name}".` }],
876
+ details: { id: running.id, name: running.name, status: "interrupt_requested" },
877
+ };
878
+ }
879
+
880
+ function startStatusRefresh(pi: ExtensionAPI) {
881
+ if (!statusConfig.enabled || statusInterval) return;
882
+
883
+ statusInterval = setInterval(() => {
884
+ if (runningSubagents.size === 0) {
885
+ if (statusInterval) {
886
+ clearInterval(statusInterval);
887
+ statusInterval = null;
888
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
889
+ }
890
+ return;
891
+ }
892
+
893
+ const transitionLines: string[] = [];
894
+ const now = Date.now();
895
+ let shouldRefreshWidget = false;
896
+
897
+ for (const running of runningSubagents.values()) {
898
+ observeRunningSubagent(running, now);
899
+ const { nextState, snapshot, transition } = advanceStatusState(running.statusState, now);
900
+ if (nextState.currentKind !== running.statusState.currentKind) {
901
+ shouldRefreshWidget = true;
902
+ }
903
+ running.statusState = nextState;
904
+
905
+ // Interactive subagents (long-running, user-driven) intentionally don't
906
+ // wake the parent session on stalled/recovered transitions — the user is
907
+ // working in the subagent's pane, and a steer message here would burn an
908
+ // orchestrator turn on a no-op "still waiting" ping. Widget still updates.
909
+ if (transition && !running.interactive) {
910
+ transitionLines.push(formatTransitionLine(running.name, snapshot, transition));
911
+ }
912
+ }
913
+
914
+ if (shouldRefreshWidget) updateWidget();
915
+
916
+ if (transitionLines.length > 0) {
917
+ const capped = capStatusLines(transitionLines, statusConfig.lineLimit);
918
+ pi.sendMessage(
919
+ {
920
+ customType: "subagent_status",
921
+ content: formatStatusAggregate(transitionLines, statusConfig.lineLimit),
922
+ display: true,
923
+ details: { lines: capped.visibleLines, overflow: capped.overflow },
924
+ },
925
+ { triggerTurn: true, deliverAs: "steer" },
926
+ );
927
+ }
928
+ }, 1000);
929
+
930
+ (globalThis as any)[STATUS_INTERVAL_KEY] = statusInterval;
931
+ }
932
+
933
+ function resolveResumeLaunchBehavior(params: { autoExit?: boolean }): { autoExit: boolean; interactive: boolean } {
934
+ const autoExit = params.autoExit ?? true;
935
+ return { autoExit, interactive: !autoExit };
936
+ }
937
+
938
+ export const __test__ = {
939
+ borderLine,
940
+ getShellReadyDelayMs,
941
+ renderSubagentWidgetLines,
942
+ loadAgentDefaults,
943
+ discoverAgentDefinitions,
944
+ resolveEffectiveSessionMode,
945
+ resolveLaunchBehavior,
946
+ resolveEffectiveInteractive,
947
+ buildSubagentToolAllowlist,
948
+ buildPiPromptArgs,
949
+ formatWidgetRightLabel,
950
+ observeRunningSubagent,
951
+ resolveDenyTools,
952
+ resolveInterruptTarget,
953
+ requestSubagentInterrupt,
954
+ handleSubagentInterrupt,
955
+ resolveResultPresentation,
956
+ resolveResumeLaunchBehavior,
957
+ runningSubagents,
958
+ };
959
+
960
+ function startWidgetRefresh() {
961
+ if (widgetInterval) return;
962
+ updateWidget(); // immediate first render
963
+ widgetInterval = setInterval(() => {
964
+ updateWidget();
965
+ }, 1000);
966
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = widgetInterval;
967
+ }
968
+
969
+ /**
970
+ * Launch a subagent: creates the multiplexer pane, builds the command, and
971
+ * sends it. Returns a RunningSubagent — does NOT poll.
972
+ *
973
+ * Call watchSubagent() on the returned object to observe completion.
974
+ */
975
+ async function launchSubagent(
976
+ params: typeof SubagentParams.static,
977
+ ctx: { sessionManager: { getSessionFile(): string | null; getSessionId(): string; getSessionDir(): string }; cwd: string },
978
+ options?: { surface?: string },
979
+ ): Promise<RunningSubagent> {
980
+ const startTime = Date.now();
981
+ const id = Math.random().toString(16).slice(2, 10);
982
+
983
+ const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null;
984
+ const effectiveModel = params.model ?? agentDefs?.model ?? getDefaultSubagentModel();
985
+ const effectiveTools = params.tools ?? agentDefs?.tools;
986
+ const effectiveSkills = params.skills ?? agentDefs?.skills;
987
+ const effectiveThinking = agentDefs?.thinking;
988
+ const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
989
+
990
+ const sessionFile = ctx.sessionManager.getSessionFile();
991
+ if (!sessionFile) throw new Error("No session file");
992
+ const sessionId = ctx.sessionManager.getSessionId();
993
+ const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
994
+
995
+ const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs);
996
+ const targetCwdForSession = effectiveCwd ?? ctx.cwd;
997
+ const sessionDir = getDefaultSessionDirFor(targetCwdForSession, effectiveAgentDir);
998
+
999
+ // Generate a deterministic session file path for this subagent.
1000
+ // This eliminates race conditions when multiple agents launch simultaneously —
1001
+ // each agent knows exactly which file is theirs.
1002
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23) + "Z";
1003
+ const uuid = [
1004
+ id,
1005
+ Math.random().toString(16).slice(2, 10),
1006
+ Math.random().toString(16).slice(2, 10),
1007
+ Math.random().toString(16).slice(2, 6),
1008
+ ].join("-");
1009
+ const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
1010
+
1011
+ // Use pre-created surface (parallel mode) or create a new one.
1012
+ // For new surfaces, pause briefly so the shell is ready before sending the command.
1013
+ const surfacePreCreated = !!options?.surface;
1014
+ const surface = options?.surface ?? createSurface(params.name);
1015
+ const splitFrom = surfacePreCreated ? undefined : (getLastSplitSource() ?? undefined);
1016
+ if (!surfacePreCreated) clearLastSplitSource();
1017
+ if (!surfacePreCreated) {
1018
+ await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
1019
+ }
1020
+
1021
+ const launchBehavior = resolveLaunchBehavior(params, agentDefs);
1022
+
1023
+ if (launchBehavior.seededSessionMode) {
1024
+ seedSubagentSessionFile({
1025
+ mode: launchBehavior.seededSessionMode,
1026
+ parentSessionFile: sessionFile,
1027
+ childSessionFile: subagentSessionFile,
1028
+ childCwd: targetCwdForSession,
1029
+ });
1030
+ }
1031
+
1032
+ const activityFile = getSubagentActivityFile(artifactDir, id);
1033
+ mkdirSync(dirname(activityFile), { recursive: true });
1034
+ const { inheritsConversationContext } = launchBehavior;
1035
+
1036
+ // Build the task message
1037
+ // Only full-context fork mode inherits prior conversation state.
1038
+ // Blank-session modes need the wrapper instructions and artifact-backed handoff.
1039
+ const modeHint = agentDefs?.autoExit
1040
+ ? "Complete your task autonomously."
1041
+ : "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time.";
1042
+ const summaryInstruction = agentDefs?.autoExit
1043
+ ? "Your FINAL assistant message should summarize what you accomplished."
1044
+ : "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished.";
1045
+ const denySet = resolveDenyTools(agentDefs);
1046
+ const identity = agentDefs?.body ?? params.systemPrompt ?? null;
1047
+ const systemPromptMode = agentDefs?.systemPromptMode;
1048
+ const identityInSystemPrompt = systemPromptMode && identity;
1049
+ const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
1050
+ const fullTask = inheritsConversationContext
1051
+ ? params.task
1052
+ : `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`;
1053
+ // ── Claude Code CLI path ──
1054
+ if (agentDefs?.cli === "claude") {
1055
+ const sentinelFile = `/tmp/pi-claude-${id}-done`;
1056
+ const pluginDir = join(SUBAGENTS_DIR, "plugin");
1057
+
1058
+ const cmdParts: string[] = [];
1059
+ cmdParts.push(`PI_CLAUDE_SENTINEL=${shellEscape(sentinelFile)}`);
1060
+ cmdParts.push("claude");
1061
+ cmdParts.push("--dangerously-skip-permissions");
1062
+
1063
+ if (existsSync(pluginDir)) {
1064
+ cmdParts.push("--plugin-dir", shellEscape(pluginDir));
1065
+ }
1066
+
1067
+ if (effectiveModel) {
1068
+ cmdParts.push("--model", shellEscape(effectiveModel));
1069
+ }
1070
+
1071
+ const sp = params.systemPrompt ?? agentDefs.body;
1072
+ if (sp) {
1073
+ cmdParts.push("--append-system-prompt", shellEscape(sp));
1074
+ }
1075
+
1076
+ if (params.resumeSessionId) {
1077
+ cmdParts.push("--resume", shellEscape(params.resumeSessionId));
1078
+ }
1079
+
1080
+ // Always pass the task as the prompt — even for resumed sessions,
1081
+ // the caller's task is the follow-up instruction.
1082
+ cmdParts.push(shellEscape(params.task));
1083
+
1084
+ const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
1085
+ const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
1086
+
1087
+ const launchScriptName = `${(params.name || "subagent")
1088
+ .toLowerCase()
1089
+ .replace(/[^a-z0-9\s-]/g, "")
1090
+ .replace(/\s+/g, "-")
1091
+ .replace(/-+/g, "-")
1092
+ .replace(/^-|-$/g, "") || "subagent"}-${id}.sh`;
1093
+ const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName);
1094
+
1095
+ sendLongCommand(surface, command, {
1096
+ scriptPath: launchScriptFile,
1097
+ scriptPreamble: [
1098
+ `# Claude Code subagent launch script for ${params.name}`,
1099
+ `# Generated: ${new Date().toISOString()}`,
1100
+ `# Surface: ${surface}`,
1101
+ ].join("\n"),
1102
+ });
1103
+
1104
+ const running: RunningSubagent = {
1105
+ id,
1106
+ name: params.name,
1107
+ task: params.task,
1108
+ agent: params.agent,
1109
+ surface,
1110
+ startTime,
1111
+ sessionFile: subagentSessionFile,
1112
+ launchScriptFile,
1113
+ cli: "claude",
1114
+ sentinelFile,
1115
+ interactive: effectiveInteractive,
1116
+ splitFrom,
1117
+ statusState: createStatusState({
1118
+ source: "claude",
1119
+ startTimeMs: startTime,
1120
+ }),
1121
+ };
1122
+
1123
+ runningSubagents.set(id, running);
1124
+ return running;
1125
+ }
1126
+
1127
+ // ── Pi CLI path ──
1128
+
1129
+ // Build pi command
1130
+ const parts: string[] = ["pi"];
1131
+ parts.push("--session", shellEscape(subagentSessionFile));
1132
+
1133
+ const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts");
1134
+ parts.push("-e", shellEscape(subagentDonePath));
1135
+
1136
+ if (effectiveModel) {
1137
+ const model = effectiveThinking ? `${effectiveModel}:${effectiveThinking}` : effectiveModel;
1138
+ parts.push("--model", shellEscape(model));
1139
+ }
1140
+
1141
+ // Pass agent body as system prompt via file to avoid shell escaping issues
1142
+ // with multiline content. Pi's --append-system-prompt and --system-prompt
1143
+ // auto-detect file paths and read their contents.
1144
+ if (identityInSystemPrompt && identity) {
1145
+ const flag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
1146
+ const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1147
+ const spSafeName = params.name
1148
+ .toLowerCase()
1149
+ .replace(/[^a-z0-9\s-]/g, "")
1150
+ .replace(/\s+/g, "-")
1151
+ .replace(/-+/g, "-")
1152
+ .replace(/^-|-$/g, "");
1153
+ const syspromptPath = join(artifactDir, `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`);
1154
+ mkdirSync(dirname(syspromptPath), { recursive: true });
1155
+ writeFileSync(syspromptPath, identity, "utf8");
1156
+ parts.push(flag, shellEscape(syspromptPath));
1157
+ }
1158
+
1159
+ const toolAllowlist = buildSubagentToolAllowlist(effectiveTools);
1160
+ if (toolAllowlist) {
1161
+ parts.push("--tools", shellEscape(toolAllowlist));
1162
+ }
1163
+
1164
+ // Build env prefix: denied tools + subagent identity + config dir propagation
1165
+ const envParts: string[] = [];
1166
+
1167
+ // If the target cwd has its own .pi/agent/, use that as the config root.
1168
+ // Otherwise propagate the current/global agent dir.
1169
+ if (localAgentDir && existsSync(localAgentDir)) {
1170
+ envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(localAgentDir)}`);
1171
+ } else if (process.env.PI_CODING_AGENT_DIR) {
1172
+ envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
1173
+ }
1174
+
1175
+ if (denySet.size > 0) {
1176
+ envParts.push(`PI_DENY_TOOLS=${shellEscape([...denySet].join(","))}`);
1177
+ }
1178
+ envParts.push(`PI_SUBAGENT_NAME=${shellEscape(params.name)}`);
1179
+ if (params.agent) {
1180
+ envParts.push(`PI_SUBAGENT_AGENT=${shellEscape(params.agent)}`);
1181
+ }
1182
+ if (agentDefs?.autoExit) {
1183
+ envParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
1184
+ }
1185
+ envParts.push(`PI_SUBAGENT_SESSION=${shellEscape(subagentSessionFile)}`);
1186
+ envParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`);
1187
+ envParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`);
1188
+ envParts.push(`PI_SUBAGENT_SURFACE=${shellEscape(surface)}`);
1189
+ if (params.structuredOutputSchema) {
1190
+ envParts.push(
1191
+ `PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA=${shellEscape(JSON.stringify(params.structuredOutputSchema))}`,
1192
+ );
1193
+ }
1194
+ const envPrefix = envParts.join(" ") + " ";
1195
+
1196
+ // Pass task and skill prompts to the sub-agent.
1197
+ // Only full-context fork mode gets a direct task argument because it already
1198
+ // inherits the parent conversation. Blank-session modes use artifact-backed
1199
+ // handoff so the wrapper instructions arrive as the initial user message.
1200
+ let taskArg: string;
1201
+ if (launchBehavior.taskDelivery === "direct") {
1202
+ taskArg = fullTask;
1203
+ } else {
1204
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1205
+ const safeName = params.name
1206
+ .toLowerCase()
1207
+ .replace(/[^a-z0-9\s-]/g, "") // strip everything except alphanumeric, spaces, hyphens
1208
+ .replace(/\s+/g, "-") // spaces to hyphens
1209
+ .replace(/-+/g, "-") // collapse multiple hyphens
1210
+ .replace(/^-|-$/g, ""); // trim leading/trailing hyphens
1211
+ const artifactName = `context/${safeName || "subagent"}-${timestamp}.md`;
1212
+ const artifactPath = join(artifactDir, artifactName);
1213
+ mkdirSync(dirname(artifactPath), { recursive: true });
1214
+ writeFileSync(artifactPath, fullTask, "utf8");
1215
+ taskArg = `@${artifactPath}`;
1216
+ }
1217
+
1218
+ for (const promptArg of buildPiPromptArgs({
1219
+ effectiveSkills,
1220
+ taskDelivery: launchBehavior.taskDelivery,
1221
+ taskArg,
1222
+ })) {
1223
+ parts.push(shellEscape(promptArg));
1224
+ }
1225
+
1226
+ // Resolve cwd — param overrides agent default, supports absolute and relative paths.
1227
+ // This was already computed above so session placement, PI_CODING_AGENT_DIR, and cd agree.
1228
+ const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
1229
+
1230
+ const piCommand = cdPrefix + envPrefix + parts.join(" ");
1231
+ const command = `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`;
1232
+ const launchScriptName = `${(params.name || "subagent")
1233
+ .toLowerCase()
1234
+ .replace(/[^a-z0-9\s-]/g, "")
1235
+ .replace(/\s+/g, "-")
1236
+ .replace(/-+/g, "-")
1237
+ .replace(/^-|-$/g, "") || "subagent"}-${id}.sh`;
1238
+ const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName);
1239
+ sendLongCommand(surface, command, {
1240
+ scriptPath: launchScriptFile,
1241
+ scriptPreamble: [
1242
+ `# Subagent launch script for ${params.name}`,
1243
+ `# Generated: ${new Date().toISOString()}`,
1244
+ `# Session: ${subagentSessionFile}`,
1245
+ `# Surface: ${surface}`,
1246
+ ].join("\n"),
1247
+ });
1248
+
1249
+ // 延迟重命名 agent 标题(左侧侧栏),需要等 pi 启动被 herdr 检测到
1250
+ const agentName = params.name;
1251
+ const agentSurface = surface;
1252
+ setTimeout(() => renameAgent(agentSurface, agentName), 3000);
1253
+ setTimeout(() => renameAgent(agentSurface, agentName), 5000);
1254
+
1255
+ const running: RunningSubagent = {
1256
+ id,
1257
+ name: params.name,
1258
+ task: params.task,
1259
+ agent: params.agent,
1260
+ surface,
1261
+ startTime,
1262
+ sessionFile: subagentSessionFile,
1263
+ launchScriptFile,
1264
+ activityFile,
1265
+ interactive: effectiveInteractive,
1266
+ splitFrom,
1267
+ statusState: createStatusState({
1268
+ source: "pi",
1269
+ startTimeMs: startTime,
1270
+ }),
1271
+ };
1272
+
1273
+ runningSubagents.set(id, running);
1274
+ return running;
1275
+ }
1276
+
1277
+ /**
1278
+ * Watch a launched subagent until it exits. Polls for completion, extracts
1279
+ * the summary from the session file, cleans up the surface,
1280
+ * and removes the entry from runningSubagents.
1281
+ */
1282
+ const CLAUDE_SESSIONS_DIR = join(
1283
+ process.env.HOME ?? "/tmp",
1284
+ ".pi", "agent", "sessions", "claude-code",
1285
+ );
1286
+
1287
+ function copyClaudeSession(sentinelFile: string): string | null {
1288
+ try {
1289
+ const transcriptFile = sentinelFile + ".transcript";
1290
+ if (!existsSync(transcriptFile)) return null;
1291
+ const transcriptPath = readFileSync(transcriptFile, "utf-8").trim();
1292
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
1293
+ mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
1294
+ const filename = transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`;
1295
+ const dest = join(CLAUDE_SESSIONS_DIR, filename);
1296
+ copyFileSync(transcriptPath, dest);
1297
+ return filename;
1298
+ } catch {
1299
+ return null;
1300
+ }
1301
+ }
1302
+
1303
+ async function watchSubagent(
1304
+ running: RunningSubagent,
1305
+ signal: AbortSignal,
1306
+ ): Promise<SubagentResult> {
1307
+ const { name, task, surface, startTime, sessionFile } = running;
1308
+
1309
+ // ── 诊断日志:检查 module abort controller 状态 ──
1310
+ // 若 session_shutdown 调用了 moduleAbort.abort() 但 session_start 没重置,
1311
+ // watchSubagent 会拿到 aborted signal,子 pi 来不及启动就被 close。
1312
+ const moduleSig = getModuleAbortSignal();
1313
+ const compositeSig = AbortSignal.any([signal, moduleSig]);
1314
+ muxLog(`[watchSubagent] ENTER surface=${surface} session=${sessionFile} callerSignal.aborted=${signal.aborted} moduleSignal.aborted=${moduleSig.aborted} compositeSignal.aborted=${compositeSig.aborted}\n`);
1315
+
1316
+ try {
1317
+ const result = await pollForExit(surface, compositeSig, {
1318
+ interval: 1000,
1319
+ sessionFile,
1320
+ sentinelFile: running.sentinelFile,
1321
+ onTick() {
1322
+ observeRunningSubagent(running);
1323
+ },
1324
+ });
1325
+
1326
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
1327
+
1328
+ if (running.cli === "claude") {
1329
+ // Claude Code result extraction
1330
+ let summary = "";
1331
+
1332
+ if (running.sentinelFile) {
1333
+ try {
1334
+ summary = readFileSync(running.sentinelFile, "utf-8").trim();
1335
+ } catch {}
1336
+ }
1337
+
1338
+ if (!summary) {
1339
+ summary = readScreen(surface, 200)
1340
+ .replace(/__SUBAGENT_DONE_\d+__/, "")
1341
+ .trimEnd();
1342
+ }
1343
+
1344
+ if (!summary) {
1345
+ summary = result.exitCode !== 0
1346
+ ? `Claude Code exited with code ${result.exitCode}`
1347
+ : "Claude Code exited without output";
1348
+ }
1349
+
1350
+ // Copy Claude session transcript
1351
+ let sessionId: string | null = null;
1352
+ if (running.sentinelFile) {
1353
+ sessionId = copyClaudeSession(running.sentinelFile);
1354
+ try { unlinkSync(running.sentinelFile); } catch {}
1355
+ try { unlinkSync(running.sentinelFile + ".transcript"); } catch {}
1356
+ }
1357
+
1358
+ closeSurface(surface);
1359
+ runningSubagents.delete(running.id);
1360
+
1361
+ return { name, task, summary, exitCode: result.exitCode, elapsed, ...(sessionId ? { claudeSessionId: sessionId } : {}) };
1362
+ }
1363
+
1364
+ // Pi subagent result extraction
1365
+ let summary: string;
1366
+ let structuredOutput: unknown;
1367
+ if (existsSync(sessionFile)) {
1368
+ const allEntries = getNewEntries(sessionFile, 0);
1369
+ summary =
1370
+ findLastAssistantMessage(allEntries) ??
1371
+ (result.exitCode !== 0
1372
+ ? `Sub-agent exited with code ${result.exitCode}`
1373
+ : "Sub-agent exited without output");
1374
+ } else {
1375
+ summary =
1376
+ result.exitCode !== 0
1377
+ ? `Sub-agent exited with code ${result.exitCode}`
1378
+ : "Sub-agent exited without output";
1379
+ }
1380
+
1381
+ // Extract structured output if the subagent used the structured_output tool
1382
+ if (result.reason === "structured_output") {
1383
+ structuredOutput = result.structuredOutput;
1384
+ }
1385
+
1386
+ closeSurface(surface);
1387
+ runningSubagents.delete(running.id);
1388
+
1389
+ return {
1390
+ name,
1391
+ task,
1392
+ summary,
1393
+ sessionFile,
1394
+ exitCode: result.exitCode,
1395
+ elapsed,
1396
+ ping: result.ping,
1397
+ ...(structuredOutput !== undefined ? { structuredOutput } : {}),
1398
+ };
1399
+ } catch (err: any) {
1400
+ try {
1401
+ closeSurface(surface);
1402
+ } catch (closeErr: any) {
1403
+ // closeSurface 失败 → pane 可能残留!让用户事后能看到。
1404
+ const sessionRef = running.sessionFile ?? "<no-session>";
1405
+ process.stderr.write(
1406
+ `[watchSubagent] closeSurface FAILED surface=${surface} session=${sessionRef} err=${closeErr?.message ?? String(closeErr)}\n`,
1407
+ );
1408
+ }
1409
+ runningSubagents.delete(running.id);
1410
+
1411
+ if (signal.aborted) {
1412
+ return {
1413
+ name,
1414
+ task,
1415
+ summary: "Subagent cancelled.",
1416
+ exitCode: 1,
1417
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
1418
+ error: "cancelled",
1419
+ sessionFile,
1420
+ };
1421
+ }
1422
+ return {
1423
+ name,
1424
+ task,
1425
+ summary: `Subagent error: ${err?.message ?? String(err)}`,
1426
+ exitCode: 1,
1427
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
1428
+ error: err?.message ?? String(err),
1429
+ };
1430
+ }
1431
+ }
1432
+
1433
+ export default function subagentsExtension(pi: ExtensionAPI) {
1434
+ // Expose launchSubagent / watchSubagent for programmatic use by other extensions
1435
+ (globalThis as any).__pi_subagents = { launchSubagent, watchSubagent };
1436
+
1437
+ // Capture the UI context for widget updates
1438
+ pi.on("session_start", (_event, ctx) => {
1439
+ latestCtx = ctx;
1440
+ });
1441
+
1442
+ // Clean up on session shutdown
1443
+ pi.on("session_shutdown", (_event, _ctx) => {
1444
+ if (widgetInterval) {
1445
+ clearInterval(widgetInterval);
1446
+ widgetInterval = null;
1447
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
1448
+ }
1449
+ if (statusInterval) {
1450
+ clearInterval(statusInterval);
1451
+ statusInterval = null;
1452
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
1453
+ }
1454
+ const moduleAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
1455
+ if (moduleAbort) moduleAbort.abort();
1456
+ for (const [_id, agent] of runningSubagents) {
1457
+ agent.abortController?.abort();
1458
+ }
1459
+ runningSubagents.clear();
1460
+ });
1461
+
1462
+ // Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter)
1463
+ const deniedTools = new Set(
1464
+ (process.env.PI_DENY_TOOLS ?? "")
1465
+ .split(",")
1466
+ .map((s) => s.trim())
1467
+ .filter(Boolean),
1468
+ );
1469
+
1470
+ const shouldRegister = (name: string) => !deniedTools.has(name);
1471
+
1472
+ // ── subagent tool ──
1473
+ if (shouldRegister("subagent"))
1474
+ pi.registerTool({
1475
+ name: "subagent",
1476
+ label: "Subagent",
1477
+ description:
1478
+ "Spawn a sub-agent in a background process or terminal multiplexer pane. " +
1479
+ "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
1480
+ "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
1481
+ "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
1482
+ "DO NOT fabricate, assume, or summarize results after calling this tool. " +
1483
+ "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
1484
+ promptSnippet:
1485
+ "Spawn a sub-agent in a background process or terminal multiplexer pane. " +
1486
+ "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
1487
+ "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
1488
+ "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
1489
+ "DO NOT fabricate, assume, or summarize results after calling this tool. " +
1490
+ "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
1491
+ parameters: SubagentParams,
1492
+
1493
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1494
+ // Prevent self-spawning (e.g. planner spawning another planner)
1495
+ const currentAgent = process.env.PI_SUBAGENT_AGENT;
1496
+ if (params.agent && currentAgent && params.agent === currentAgent) {
1497
+ return {
1498
+ content: [
1499
+ {
1500
+ type: "text",
1501
+ text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. You were spawned to do this work yourself. Complete the task directly.`,
1502
+ },
1503
+ ],
1504
+ details: { error: "self-spawn blocked" },
1505
+ };
1506
+ }
1507
+
1508
+ if (!ctx.sessionManager.getSessionFile()) {
1509
+ return {
1510
+ content: [
1511
+ {
1512
+ type: "text",
1513
+ text: "Error: no session file. Start pi with a persistent session to use subagents.",
1514
+ },
1515
+ ],
1516
+ details: { error: "no session file" },
1517
+ };
1518
+ }
1519
+
1520
+ // Launch the subagent (creates pane, sends command)
1521
+ const running = await launchSubagent(params, ctx);
1522
+
1523
+ // Create a separate AbortController for the watcher
1524
+ // (the tool's signal completes when we return)
1525
+ const watcherAbort = new AbortController();
1526
+ running.abortController = watcherAbort;
1527
+
1528
+ // Start widget refresh and status supervision when the first agent launches
1529
+ startWidgetRefresh();
1530
+ startStatusRefresh(pi);
1531
+
1532
+ // Fire-and-forget: start watching in background
1533
+ watchSubagent(running, watcherAbort.signal)
1534
+ .then((result) => {
1535
+ updateWidget(); // reflect removal from Map immediately
1536
+
1537
+ if (result.ping) {
1538
+ // Subagent is requesting help — steer a ping message with session path for resume
1539
+ const sessionRef = `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`;
1540
+ pi.sendMessage(
1541
+ {
1542
+ customType: "subagent_ping",
1543
+ content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
1544
+ display: true,
1545
+ details: {
1546
+ name: result.ping.name,
1547
+ message: result.ping.message,
1548
+ agent: running.agent,
1549
+ sessionFile: result.sessionFile,
1550
+ },
1551
+ },
1552
+ { triggerTurn: true, deliverAs: "steer" },
1553
+ );
1554
+ return;
1555
+ }
1556
+
1557
+ const presentation = resolveResultPresentation(result, running.name);
1558
+
1559
+ pi.sendMessage(
1560
+ {
1561
+ customType: "subagent_result",
1562
+ content: presentation,
1563
+ display: true,
1564
+ details: {
1565
+ name: running.name,
1566
+ task: running.task,
1567
+ agent: running.agent,
1568
+ exitCode: result.exitCode,
1569
+ elapsed: result.elapsed,
1570
+ sessionFile: result.sessionFile,
1571
+ ...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}),
1572
+ ...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}),
1573
+ },
1574
+ },
1575
+ { triggerTurn: true, deliverAs: "steer" },
1576
+ );
1577
+ })
1578
+ .catch((err) => {
1579
+ updateWidget();
1580
+ pi.sendMessage(
1581
+ {
1582
+ customType: "subagent_result",
1583
+ content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
1584
+ display: true,
1585
+ details: { name: running.name, task: running.task, error: err?.message },
1586
+ },
1587
+ { triggerTurn: true, deliverAs: "steer" },
1588
+ );
1589
+ });
1590
+
1591
+ // Return immediately
1592
+ return {
1593
+ content: [
1594
+ {
1595
+ type: "text",
1596
+ text:
1597
+ `Sub-agent "${params.name}" launched and is now running in the background. ` +
1598
+ `Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
1599
+ `The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
1600
+ `Until then, move on to other work or tell the user you're waiting.`,
1601
+ },
1602
+ ],
1603
+ details: {
1604
+ id: running.id,
1605
+ name: params.name,
1606
+ task: params.task,
1607
+ agent: params.agent,
1608
+ sessionFile: running.sessionFile,
1609
+ launchScriptFile: running.launchScriptFile,
1610
+ surface: running.surface,
1611
+ splitFrom: running.splitFrom,
1612
+ status: "started",
1613
+ },
1614
+ };
1615
+ },
1616
+
1617
+ renderCall(args, theme) {
1618
+ const partialArgs = args as Record<string, unknown>;
1619
+ const name = typeof partialArgs.name === "string" && partialArgs.name ? partialArgs.name : "(unnamed)";
1620
+ const task = typeof partialArgs.task === "string" ? partialArgs.task : "";
1621
+ const agent = typeof partialArgs.agent === "string" && partialArgs.agent
1622
+ ? theme.fg("dim", ` (${partialArgs.agent})`)
1623
+ : "";
1624
+ const cwdHint = typeof partialArgs.cwd === "string" && partialArgs.cwd
1625
+ ? theme.fg("dim", ` in ${partialArgs.cwd}`)
1626
+ : "";
1627
+ let text =
1628
+ "▸ " +
1629
+ theme.fg("toolTitle", theme.bold(name)) +
1630
+ agent +
1631
+ cwdHint;
1632
+
1633
+ // Show a one-line task preview. renderCall is called repeatedly as the
1634
+ // LLM generates tool arguments, so args.task grows token by token.
1635
+ // We keep it compact here — Ctrl+O on renderResult expands the full content.
1636
+ if (task) {
1637
+ const firstLine = task.split("\n").find((l: string) => l.trim()) ?? "";
1638
+ const preview = firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine;
1639
+ if (preview) {
1640
+ text += "\n" + theme.fg("toolOutput", preview);
1641
+ }
1642
+ const totalLines = task.split("\n").length;
1643
+ if (totalLines > 1) {
1644
+ text += theme.fg("muted", ` (${totalLines} lines)`);
1645
+ }
1646
+ }
1647
+
1648
+ return new Text(text, 0, 0);
1649
+ },
1650
+
1651
+ renderResult(result, _opts, theme) {
1652
+ const details = result.details as any;
1653
+ const name = details?.name ?? "(unnamed)";
1654
+
1655
+ // "Started" result — tool returned immediately
1656
+ if (details?.status === "started") {
1657
+ const splitHint = details?.splitFrom
1658
+ ? theme.fg("dim", ` (from ${details.splitFrom})`)
1659
+ : "";
1660
+ return new Text(
1661
+ theme.fg("accent", "▸") +
1662
+ " " +
1663
+ theme.fg("toolTitle", theme.bold(name)) +
1664
+ theme.fg("dim", " — started") +
1665
+ splitHint,
1666
+ 0,
1667
+ 0,
1668
+ );
1669
+ }
1670
+
1671
+ // Fallback (shouldn't happen)
1672
+ const text = extractFirstText(result.content);
1673
+ return new Text(theme.fg("dim", text), 0, 0);
1674
+ },
1675
+ });
1676
+
1677
+ // ── subagent_interrupt tool ──
1678
+ if (shouldRegister("subagent_interrupt"))
1679
+ pi.registerTool({
1680
+ name: "subagent_interrupt",
1681
+ label: "Interrupt Subagent",
1682
+ description:
1683
+ "Send Escape to the active turn of a currently running Pi-backed subagent. " +
1684
+ "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " +
1685
+ "and does not emit a subagent_result solely because of this request.",
1686
+ promptSnippet:
1687
+ "Send Escape to the active turn of a currently running Pi-backed subagent. " +
1688
+ "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " +
1689
+ "and does not emit a subagent_result solely because of this request.",
1690
+ parameters: Type.Object({
1691
+ id: Type.Optional(Type.String({ description: "Exact running subagent id" })),
1692
+ name: Type.Optional(Type.String({ description: "Exact running subagent display name" })),
1693
+ }),
1694
+
1695
+ async execute(_toolCallId, params) {
1696
+ return handleSubagentInterrupt(params);
1697
+ },
1698
+
1699
+ renderCall(args, theme) {
1700
+ const target = args.id ? `${args.id}` : args.name ?? "(unknown)";
1701
+ return new Text(
1702
+ theme.fg("accent", "▸") +
1703
+ " " +
1704
+ theme.fg("toolTitle", theme.bold(target)) +
1705
+ theme.fg("dim", " — interrupt turn"),
1706
+ 0,
1707
+ 0,
1708
+ );
1709
+ },
1710
+
1711
+ renderResult(result, _opts, theme) {
1712
+ const details = result.details as any;
1713
+ if (details?.status === "interrupt_requested") {
1714
+ return new Text(
1715
+ theme.fg("accent", "▸") +
1716
+ " " +
1717
+ theme.fg("toolTitle", theme.bold(details.name ?? details.id ?? "subagent")) +
1718
+ theme.fg("dim", " — interrupt requested"),
1719
+ 0,
1720
+ 0,
1721
+ );
1722
+ }
1723
+
1724
+ const text = extractFirstText(result.content);
1725
+ return new Text(theme.fg("dim", text), 0, 0);
1726
+ },
1727
+ });
1728
+
1729
+ // ── subagents_list tool ──
1730
+ if (shouldRegister("subagents_list"))
1731
+ pi.registerTool({
1732
+ name: "subagents_list",
1733
+ label: "List Subagents",
1734
+ description:
1735
+ "List all available subagent definitions. " +
1736
+ "Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " +
1737
+ "Project-local agents override global ones with the same name.",
1738
+ promptSnippet:
1739
+ "List all available subagent definitions. " +
1740
+ "Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " +
1741
+ "Project-local agents override global ones with the same name.",
1742
+ parameters: Type.Object({}),
1743
+
1744
+ async execute() {
1745
+ const list = discoverAgentDefinitions().filter((agent) => !agent.disableModelInvocation);
1746
+
1747
+ if (list.length === 0) {
1748
+ return {
1749
+ content: [{ type: "text", text: "No subagent definitions found." }],
1750
+ details: { agents: [] },
1751
+ };
1752
+ }
1753
+
1754
+ const lines = list.map((a) => {
1755
+ const badge = a.source === "project" ? " (project)" : "";
1756
+ const desc = a.description ? ` — ${a.description}` : "";
1757
+ const model = a.model ? ` [${a.model}]` : "";
1758
+ return `• ${a.name}${badge}${model}${desc}`;
1759
+ });
1760
+
1761
+ return {
1762
+ content: [{ type: "text", text: lines.join("\n") }],
1763
+ details: { agents: list },
1764
+ };
1765
+ },
1766
+
1767
+ renderResult(result, _opts, theme) {
1768
+ const details = result.details as any;
1769
+ const agents = details?.agents ?? [];
1770
+ if (agents.length === 0) {
1771
+ return new Text(theme.fg("dim", "No subagent definitions found."), 0, 0);
1772
+ }
1773
+ const lines = agents.map((a: any) => {
1774
+ const badge = a.source === "project" ? theme.fg("accent", " (project)") : "";
1775
+ const desc = a.description ? theme.fg("dim", ` — ${a.description}`) : "";
1776
+ const model = a.model ? theme.fg("dim", ` [${a.model}]`) : "";
1777
+ return ` ${theme.fg("toolTitle", theme.bold(a.name))}${badge}${model}${desc}`;
1778
+ });
1779
+ return new Text(lines.join("\n"), 0, 0);
1780
+ },
1781
+ });
1782
+
1783
+
1784
+
1785
+ // ── subagent_resume tool ──
1786
+ if (shouldRegister("subagent_resume"))
1787
+ pi.registerTool({
1788
+ name: "subagent_resume",
1789
+ label: "Resume Subagent",
1790
+ description:
1791
+ "Resume a previous sub-agent session in a background process or new multiplexer pane. " +
1792
+ "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
1793
+ "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
1794
+ "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness delivers the result for you. " +
1795
+ "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " +
1796
+ "Use when a sub-agent was cancelled or needs follow-up work.",
1797
+ promptSnippet:
1798
+ "Resume a previous sub-agent session in a background process or new multiplexer pane. " +
1799
+ "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
1800
+ "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
1801
+ "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness delivers the result for you. " +
1802
+ "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " +
1803
+ "Use when a sub-agent was cancelled or needs follow-up work.",
1804
+ parameters: Type.Object({
1805
+ sessionPath: Type.String({ description: "Path to the session .jsonl file to resume" }),
1806
+ name: Type.Optional(
1807
+ Type.String({ description: "Display name for the terminal tab. Default: 'Resume'" }),
1808
+ ),
1809
+ message: Type.Optional(
1810
+ Type.String({
1811
+ description: "Optional message to send after resuming (e.g. follow-up instructions)",
1812
+ }),
1813
+ ),
1814
+ autoExit: Type.Optional(
1815
+ Type.Boolean({
1816
+ description:
1817
+ "Whether the resumed session should automatically exit after completing its response. Defaults to true for autonomous follow-up work; set false for interactive resumed sessions.",
1818
+ }),
1819
+ ),
1820
+ }),
1821
+
1822
+ renderCall(args, theme) {
1823
+ const name = args.name ?? "Resume";
1824
+ const text =
1825
+ "▸ " +
1826
+ theme.fg("toolTitle", theme.bold(name)) +
1827
+ theme.fg("dim", " — resuming session");
1828
+ return new Text(text, 0, 0);
1829
+ },
1830
+
1831
+ renderResult(result, _opts, theme) {
1832
+ const details = result.details as any;
1833
+ const name = details?.name ?? "Resume";
1834
+
1835
+ if (details?.status === "started") {
1836
+ return new Text(
1837
+ theme.fg("accent", "▸") +
1838
+ " " +
1839
+ theme.fg("toolTitle", theme.bold(name)) +
1840
+ theme.fg("dim", " — resumed"),
1841
+ 0,
1842
+ 0,
1843
+ );
1844
+ }
1845
+
1846
+ // Fallback
1847
+ const text = extractFirstText(result.content);
1848
+ return new Text(theme.fg("dim", text), 0, 0);
1849
+ },
1850
+
1851
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1852
+ const name = params.name ?? "Resume";
1853
+ const { autoExit, interactive } = resolveResumeLaunchBehavior(params);
1854
+ const startTime = Date.now();
1855
+ const id = Math.random().toString(16).slice(2, 10);
1856
+
1857
+ if (!existsSync(params.sessionPath)) {
1858
+ return {
1859
+ content: [
1860
+ { type: "text", text: `Error: session file not found: ${params.sessionPath}` },
1861
+ ],
1862
+ details: { error: "session not found" },
1863
+ };
1864
+ }
1865
+
1866
+ // Record entry count before resuming so we can extract new messages
1867
+ const entryCountBefore = getNewEntries(params.sessionPath, 0).length;
1868
+
1869
+ const surface = createSurface(name);
1870
+ await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
1871
+
1872
+ // Build pi resume command
1873
+ const parts = ["pi", "--session", shellEscape(params.sessionPath)];
1874
+
1875
+ // Load subagent-done extension so the agent can self-terminate if needed
1876
+ const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts");
1877
+ parts.push("-e", shellEscape(subagentDonePath));
1878
+
1879
+ const sessionId = ctx.sessionManager.getSessionId();
1880
+ const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
1881
+ const activityFile = getSubagentActivityFile(artifactDir, id);
1882
+ mkdirSync(dirname(activityFile), { recursive: true });
1883
+
1884
+ let resumeMsgFile: string | undefined;
1885
+ if (params.message) {
1886
+ const msgTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1887
+ resumeMsgFile = join(
1888
+ artifactDir,
1889
+ "subagent-resume",
1890
+ `${name
1891
+ .toLowerCase()
1892
+ .replace(/[^a-z0-9\s-]/g, "")
1893
+ .replace(/\s+/g, "-")
1894
+ .replace(/-+/g, "-")
1895
+ .replace(/^-|-$/g, "") || "resume"}-${msgTimestamp}.md`,
1896
+ );
1897
+ mkdirSync(dirname(resumeMsgFile), { recursive: true });
1898
+ writeFileSync(resumeMsgFile, params.message, "utf8");
1899
+ parts.push(shellEscape(`@${resumeMsgFile}`));
1900
+ }
1901
+
1902
+ // Build env prefix — propagate PI_CODING_AGENT_DIR for config isolation
1903
+ const resumeEnvParts: string[] = [];
1904
+ if (process.env.PI_CODING_AGENT_DIR) {
1905
+ resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
1906
+ }
1907
+ resumeEnvParts.push(`PI_SUBAGENT_NAME=${shellEscape(name)}`);
1908
+ resumeEnvParts.push(`PI_SUBAGENT_SESSION=${shellEscape(params.sessionPath)}`);
1909
+ resumeEnvParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`);
1910
+ resumeEnvParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`);
1911
+ if (autoExit) {
1912
+ resumeEnvParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
1913
+ }
1914
+ const resumeEnvPrefix = resumeEnvParts.join(" ") + " ";
1915
+
1916
+ const command = `${resumeEnvPrefix}${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
1917
+ const launchScriptFile = join(
1918
+ artifactDir,
1919
+ "subagent-scripts",
1920
+ `${name
1921
+ .toLowerCase()
1922
+ .replace(/[^a-z0-9\s-]/g, "")
1923
+ .replace(/\s+/g, "-")
1924
+ .replace(/-+/g, "-")
1925
+ .replace(/^-|-$/g, "") || "resume"}-resume-${Date.now()}.sh`,
1926
+ );
1927
+ sendLongCommand(surface, command, {
1928
+ scriptPath: launchScriptFile,
1929
+ scriptPreamble: [
1930
+ `# Subagent resume script for ${name}`,
1931
+ `# Generated: ${new Date().toISOString()}`,
1932
+ `# Session: ${params.sessionPath}`,
1933
+ `# Surface: ${surface}`,
1934
+ ...(resumeMsgFile ? [`# Resume message file: ${resumeMsgFile}`] : []),
1935
+ ].join("\n"),
1936
+ });
1937
+
1938
+ // Register as a running subagent for widget tracking
1939
+ const running: RunningSubagent = {
1940
+ id,
1941
+ name,
1942
+ task: params.message ?? "resumed session",
1943
+ surface,
1944
+ startTime,
1945
+ sessionFile: params.sessionPath,
1946
+ launchScriptFile,
1947
+ activityFile,
1948
+ interactive,
1949
+ statusState: createStatusState({
1950
+ source: "pi",
1951
+ startTimeMs: startTime,
1952
+ }),
1953
+ };
1954
+ runningSubagents.set(id, running);
1955
+ startWidgetRefresh();
1956
+ startStatusRefresh(pi);
1957
+
1958
+ // Fire-and-forget watcher
1959
+ const watcherAbort = new AbortController();
1960
+ running.abortController = watcherAbort;
1961
+
1962
+ watchSubagent(running, watcherAbort.signal)
1963
+ .then((result) => {
1964
+ updateWidget();
1965
+
1966
+ if (result.ping) {
1967
+ const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`;
1968
+ pi.sendMessage(
1969
+ {
1970
+ customType: "subagent_ping",
1971
+ content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
1972
+ display: true,
1973
+ details: {
1974
+ name: result.ping.name,
1975
+ message: result.ping.message,
1976
+ sessionFile: params.sessionPath,
1977
+ },
1978
+ },
1979
+ { triggerTurn: true, deliverAs: "steer" },
1980
+ );
1981
+ return;
1982
+ }
1983
+
1984
+ const allEntries = getNewEntries(params.sessionPath, entryCountBefore);
1985
+ const summary = findLastAssistantMessage(allEntries) ??
1986
+ (result.exitCode !== 0
1987
+ ? `Resumed session exited with code ${result.exitCode}`
1988
+ : "Resumed session exited without new output");
1989
+ const presentation = resolveResultPresentation(
1990
+ { ...result, summary, sessionFile: params.sessionPath },
1991
+ name,
1992
+ );
1993
+
1994
+ pi.sendMessage(
1995
+ {
1996
+ customType: "subagent_result",
1997
+ content: presentation,
1998
+ display: true,
1999
+ details: {
2000
+ name,
2001
+ task: params.message ?? "resumed session",
2002
+ exitCode: result.exitCode,
2003
+ elapsed: result.elapsed,
2004
+ sessionFile: params.sessionPath,
2005
+ },
2006
+ },
2007
+ { triggerTurn: true, deliverAs: "steer" },
2008
+ );
2009
+ })
2010
+ .catch((err) => {
2011
+ updateWidget();
2012
+ pi.sendMessage(
2013
+ {
2014
+ customType: "subagent_result",
2015
+ content: `Resume error: ${err?.message ?? String(err)}`,
2016
+ display: true,
2017
+ details: { name, error: err?.message },
2018
+ },
2019
+ { triggerTurn: true, deliverAs: "steer" },
2020
+ );
2021
+ });
2022
+
2023
+ return {
2024
+ content: [{ type: "text", text: `Session "${name}" resumed.` }],
2025
+ details: {
2026
+ id,
2027
+ name,
2028
+ sessionPath: params.sessionPath,
2029
+ launchScriptFile,
2030
+ status: "started",
2031
+ },
2032
+ };
2033
+ },
2034
+ });
2035
+
2036
+ // /iterate command — fork the session into a subagent
2037
+ pi.registerCommand("iterate", {
2038
+ description: "Fork session into a subagent for focused work (bugfixes, iteration)",
2039
+ handler: async (args, _ctx) => {
2040
+ const task = args.trim() || "";
2041
+ const toolCall = task
2042
+ ? `Use subagent to fork a session. fork: true, name: "Iterate", task: ${JSON.stringify(task)}`
2043
+ : `Use subagent to fork a session. fork: true, name: "Iterate", task: "The user wants to do some hands-on work. Help them with whatever they need."`;
2044
+ pi.sendUserMessage(toolCall);
2045
+ },
2046
+ });
2047
+
2048
+ // /subagent command — spawn a subagent by name
2049
+ pi.registerCommand("subagent", {
2050
+ description: "Spawn a subagent: /subagent <agent> <task>",
2051
+ handler: async (args, ctx) => {
2052
+ const trimmed = args.trim();
2053
+ if (!trimmed) {
2054
+ ctx.ui.notify("Usage: /subagent <agent> [task]", "warning");
2055
+ return;
2056
+ }
2057
+
2058
+ const spaceIdx = trimmed.indexOf(" ");
2059
+ const agentName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
2060
+ const task = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
2061
+
2062
+ const defs = loadAgentDefaults(agentName);
2063
+ if (!defs) {
2064
+ ctx.ui.notify(
2065
+ `Agent "${agentName}" not found in ~/.pi/agent/agents/ or .pi/agents/`,
2066
+ "error",
2067
+ );
2068
+ return;
2069
+ }
2070
+
2071
+ const taskText = task || `You are the ${agentName} agent. Wait for instructions.`;
2072
+ const displayName = agentName[0].toUpperCase() + agentName.slice(1);
2073
+ const toolCall = `Use subagent with agent: "${agentName}", name: "${displayName}", task: ${JSON.stringify(taskText)}`;
2074
+ pi.sendUserMessage(toolCall);
2075
+ },
2076
+ });
2077
+
2078
+ // ── subagent_result message renderer ──
2079
+ pi.registerMessageRenderer("subagent_result", (message, options, theme) => {
2080
+ const details = message.details as any;
2081
+ if (!details) return undefined;
2082
+
2083
+ return {
2084
+ render(width: number): string[] {
2085
+ const name = details.name ?? "subagent";
2086
+ const exitCode = details.exitCode ?? 0;
2087
+ const elapsed = details.elapsed != null ? formatElapsed(details.elapsed) : "?";
2088
+ const bgFn = exitCode === 0
2089
+ ? (text: string) => theme.bg("toolSuccessBg", text)
2090
+ : (text: string) => theme.bg("toolErrorBg", text);
2091
+ const icon = exitCode === 0
2092
+ ? theme.fg("success", "✓")
2093
+ : theme.fg("error", "✗");
2094
+ const status = exitCode === 0
2095
+ ? "completed"
2096
+ : `failed (exit ${exitCode})`;
2097
+ const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : "";
2098
+
2099
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`;
2100
+ const rawContent = typeof message.content === "string" ? message.content : "";
2101
+
2102
+ // Clean summary (remove session ref and leading label for display)
2103
+ const summary = rawContent
2104
+ .replace(/\n\nSession: .+\nResume: .+$/, "")
2105
+ .replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "")
2106
+ .replace(`Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`, "");
2107
+
2108
+ // Build content for the box
2109
+ const contentLines = [header];
2110
+
2111
+ if (options.expanded) {
2112
+ // Full view: complete summary + session info
2113
+ if (summary) {
2114
+ for (const line of summary.split("\n")) {
2115
+ contentLines.push(line.slice(0, width - 6));
2116
+ }
2117
+ }
2118
+ if (details.sessionFile) {
2119
+ contentLines.push("");
2120
+ contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`));
2121
+ contentLines.push(theme.fg("dim", `Resume: pi --session ${details.sessionFile}`));
2122
+ }
2123
+ } else {
2124
+ // Collapsed: preview + expand hint
2125
+ if (summary) {
2126
+ const previewLines = summary.split("\n").slice(0, 5);
2127
+ for (const line of previewLines) {
2128
+ contentLines.push(theme.fg("dim", line.slice(0, width - 6)));
2129
+ }
2130
+ const totalLines = summary.split("\n").length;
2131
+ if (totalLines > 5) {
2132
+ contentLines.push(theme.fg("muted", `… ${totalLines - 5} more lines`));
2133
+ }
2134
+ }
2135
+ contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand")));
2136
+ }
2137
+
2138
+ // Render via Box for background + padding, with blank line above for separation
2139
+ const box = new Box(1, 1, bgFn);
2140
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
2141
+ return ["", ...box.render(width)];
2142
+ },
2143
+ invalidate(): void {},
2144
+ };
2145
+ });
2146
+
2147
+ // ── subagent_status message renderer ──
2148
+ pi.registerMessageRenderer("subagent_status", (message, options, theme) => {
2149
+ const details = message.details as any;
2150
+ const lines = Array.isArray(details?.lines) ? details.lines : [];
2151
+ const overflow = typeof details?.overflow === "number" ? details.overflow : 0;
2152
+ if (lines.length === 0 && overflow === 0) return undefined;
2153
+
2154
+ return {
2155
+ render(width: number): string[] {
2156
+ const lineWidth = Math.max(0, width - 6);
2157
+ const contentLines = [
2158
+ `${theme.fg("accent", "•")} ${theme.fg("toolTitle", theme.bold("Subagent status"))}`,
2159
+ ...lines.map((line: string) => theme.fg("dim", truncateToWidth(line, lineWidth))),
2160
+ ];
2161
+
2162
+ if (overflow > 0) {
2163
+ contentLines.push(theme.fg("muted", `+${overflow} more running.`));
2164
+ }
2165
+ if (!options.expanded) {
2166
+ contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand")));
2167
+ }
2168
+
2169
+ const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text));
2170
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
2171
+ return ["", ...box.render(width)];
2172
+ },
2173
+ invalidate(): void {},
2174
+ };
2175
+ });
2176
+
2177
+ // ── subagent_ping message renderer ──
2178
+ pi.registerMessageRenderer("subagent_ping", (message, options, theme) => {
2179
+ const details = message.details as any;
2180
+ if (!details) return undefined;
2181
+
2182
+ return {
2183
+ render(width: number): string[] {
2184
+ const name = details.name ?? "subagent";
2185
+ const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : "";
2186
+ const bgFn = (text: string) => theme.bg("toolSuccessBg", text);
2187
+
2188
+ const icon = theme.fg("accent", "?");
2189
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— needs help")}`;
2190
+
2191
+ const contentLines = [header];
2192
+
2193
+ if (options.expanded) {
2194
+ contentLines.push("");
2195
+ contentLines.push(details.message ?? "");
2196
+ if (details.sessionFile) {
2197
+ contentLines.push("");
2198
+ contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`));
2199
+ }
2200
+ } else {
2201
+ const preview = (details.message ?? "").split("\n")[0].slice(0, width - 10);
2202
+ contentLines.push(theme.fg("dim", preview));
2203
+ contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand")));
2204
+ }
2205
+
2206
+ const box = new Box(1, 1, bgFn);
2207
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
2208
+ return ["", ...box.render(width)];
2209
+ },
2210
+ invalidate(): void {},
2211
+ };
2212
+ });
2213
+
2214
+ // /plan command — start the full planning workflow
2215
+ pi.registerCommand("plan", {
2216
+ description: "Start a planning session: /plan <what to build>",
2217
+ handler: async (args, ctx) => {
2218
+ const task = args.trim();
2219
+ if (!task) {
2220
+ ctx.ui.notify("Usage: /plan <what to build>", "warning");
2221
+ return;
2222
+ }
2223
+
2224
+ // Rename workspace and tab to show this is a planning session
2225
+ if (isMuxAvailable()) {
2226
+ try {
2227
+ const label = task.length > 40 ? task.slice(0, 40) + "..." : task;
2228
+ renameWorkspace(`🎯 ${label}`);
2229
+ renameCurrentTab(`🎯 Plan: ${label}`);
2230
+ } catch {
2231
+ // non-critical -- do not block the plan
2232
+ }
2233
+ }
2234
+
2235
+ // Load the plan skill from the subagents extension directory
2236
+ const planSkillPath = join(SUBAGENTS_DIR, "plan-skill.md");
2237
+ let content = readFileSync(planSkillPath, "utf8");
2238
+ content = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
2239
+ pi.sendUserMessage(
2240
+ `<skill name="plan" location="${planSkillPath}">\n${content.trim()}\n</skill>\n\n${task}`,
2241
+ );
2242
+ },
2243
+ });
2244
+ }
2245
+
2246
+ // ── Exported for direct programmatic use (e.g. pi-dynamic-workflows) ──
2247
+ export { launchSubagent, watchSubagent };
2248
+ export type { RunningSubagent, SubagentResult };