@johnnygreco/pizza-pi 0.1.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.
Files changed (27) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +82 -0
  3. package/extensions/context.ts +578 -0
  4. package/extensions/control.ts +1782 -0
  5. package/extensions/loop.ts +454 -0
  6. package/extensions/pizza-ui.ts +93 -0
  7. package/extensions/todos.ts +2066 -0
  8. package/node_modules/pi-interactive-subagents/.pi/settings.json +13 -0
  9. package/node_modules/pi-interactive-subagents/.pi/skills/release/SKILL.md +133 -0
  10. package/node_modules/pi-interactive-subagents/LICENSE +21 -0
  11. package/node_modules/pi-interactive-subagents/README.md +362 -0
  12. package/node_modules/pi-interactive-subagents/agents/planner.md +270 -0
  13. package/node_modules/pi-interactive-subagents/agents/reviewer.md +153 -0
  14. package/node_modules/pi-interactive-subagents/agents/scout.md +103 -0
  15. package/node_modules/pi-interactive-subagents/agents/spec.md +339 -0
  16. package/node_modules/pi-interactive-subagents/agents/visual-tester.md +202 -0
  17. package/node_modules/pi-interactive-subagents/agents/worker.md +104 -0
  18. package/node_modules/pi-interactive-subagents/package.json +34 -0
  19. package/node_modules/pi-interactive-subagents/pi-extension/session-artifacts/index.ts +252 -0
  20. package/node_modules/pi-interactive-subagents/pi-extension/subagents/cmux.ts +647 -0
  21. package/node_modules/pi-interactive-subagents/pi-extension/subagents/index.ts +1343 -0
  22. package/node_modules/pi-interactive-subagents/pi-extension/subagents/plan-skill.md +225 -0
  23. package/node_modules/pi-interactive-subagents/pi-extension/subagents/session.ts +124 -0
  24. package/node_modules/pi-interactive-subagents/pi-extension/subagents/subagent-done.ts +166 -0
  25. package/package.json +62 -0
  26. package/prompts/.gitkeep +0 -0
  27. package/skills/.gitkeep +0 -0
@@ -0,0 +1,1343 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { keyHint } from "@mariozechner/pi-coding-agent";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { Box, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
5
+ import { dirname, join } from "node:path";
6
+ import {
7
+ readdirSync,
8
+ statSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ } from "node:fs";
14
+ import { homedir, tmpdir } from "node:os";
15
+ import { randomUUID } from "node:crypto";
16
+ import {
17
+ isMuxAvailable,
18
+ muxSetupHint,
19
+ createSurface,
20
+ sendCommand,
21
+ pollForExit,
22
+ closeSurface,
23
+ shellEscape,
24
+ exitStatusVar,
25
+ renameCurrentTab,
26
+ renameWorkspace,
27
+ } from "./cmux.ts";
28
+ import { getNewEntries, findLastAssistantMessage } from "./session.ts";
29
+
30
+ const SubagentParams = Type.Object({
31
+ name: Type.String({ description: "Display name for the subagent" }),
32
+ task: Type.String({ description: "Task/prompt for the sub-agent" }),
33
+ agent: Type.Optional(
34
+ Type.String({
35
+ description:
36
+ "Agent name to load defaults from (e.g. 'worker', 'scout', 'reviewer'). Reads ~/.pi/agent/agents/<name>.md for model, tools, skills.",
37
+ }),
38
+ ),
39
+ systemPrompt: Type.Optional(
40
+ Type.String({ description: "Appended to system prompt (role instructions)" }),
41
+ ),
42
+ model: Type.Optional(Type.String({ description: "Model override (overrides agent default)" })),
43
+ skills: Type.Optional(
44
+ Type.String({ description: "Comma-separated skills (overrides agent default)" }),
45
+ ),
46
+ tools: Type.Optional(
47
+ Type.String({ description: "Comma-separated tools (overrides agent default)" }),
48
+ ),
49
+ cwd: Type.Optional(
50
+ Type.String({
51
+ description:
52
+ "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.",
53
+ }),
54
+ ),
55
+ fork: Type.Optional(
56
+ Type.Boolean({
57
+ description:
58
+ "Fork the current session — sub-agent gets full conversation context. Use for iterate/bugfix patterns.",
59
+ }),
60
+ ),
61
+ });
62
+
63
+ interface AgentDefaults {
64
+ model?: string;
65
+ tools?: string;
66
+ skills?: string;
67
+ thinking?: string;
68
+ denyTools?: string;
69
+ spawning?: boolean;
70
+ autoExit?: boolean;
71
+ systemPromptMode?: "append" | "replace";
72
+ cwd?: string;
73
+ body?: string;
74
+ }
75
+
76
+ /** Tools that are gated by `spawning: false` */
77
+ const SPAWNING_TOOLS = new Set(["subagent", "subagents_list", "subagent_resume"]);
78
+
79
+ /**
80
+ * Resolve the effective set of denied tool names from agent defaults.
81
+ * `spawning: false` expands to all SPAWNING_TOOLS.
82
+ * `deny-tools` adds individual tool names on top.
83
+ */
84
+ function resolveDenyTools(agentDefs: AgentDefaults | null): Set<string> {
85
+ const denied = new Set<string>();
86
+ if (!agentDefs) return denied;
87
+
88
+ // spawning: false → deny all spawning tools
89
+ if (agentDefs.spawning === false) {
90
+ for (const t of SPAWNING_TOOLS) denied.add(t);
91
+ }
92
+
93
+ // deny-tools: explicit list
94
+ if (agentDefs.denyTools) {
95
+ for (const t of agentDefs.denyTools
96
+ .split(",")
97
+ .map((s) => s.trim())
98
+ .filter(Boolean)) {
99
+ denied.add(t);
100
+ }
101
+ }
102
+
103
+ return denied;
104
+ }
105
+
106
+ /** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */
107
+ function getAgentConfigDir(): string {
108
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
109
+ }
110
+
111
+ function loadAgentDefaults(agentName: string): AgentDefaults | null {
112
+ const configDir = getAgentConfigDir();
113
+ const paths = [
114
+ join(process.cwd(), ".pi", "agents", `${agentName}.md`),
115
+ join(configDir, "agents", `${agentName}.md`),
116
+ join(dirname(new URL(import.meta.url).pathname), "../../agents", `${agentName}.md`),
117
+ ];
118
+ for (const p of paths) {
119
+ if (!existsSync(p)) continue;
120
+ const content = readFileSync(p, "utf8");
121
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
122
+ if (!match) continue;
123
+ const frontmatter = match[1];
124
+ const get = (key: string) => {
125
+ const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
126
+ return m ? m[1].trim() : undefined;
127
+ };
128
+ // Extract body (everything after frontmatter)
129
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
130
+ const spawningRaw = get("spawning");
131
+ const autoExitRaw = get("auto-exit");
132
+ const spm = get("system-prompt");
133
+ return {
134
+ model: get("model"),
135
+ tools: get("tools"),
136
+ systemPromptMode: spm === "replace" ? "replace" : spm === "append" ? "append" : undefined,
137
+ skills: get("skill") ?? get("skills"),
138
+ thinking: get("thinking"),
139
+ denyTools: get("deny-tools"),
140
+ spawning: spawningRaw != null ? spawningRaw === "true" : undefined,
141
+ autoExit: autoExitRaw != null ? autoExitRaw === "true" : undefined,
142
+ cwd: get("cwd"),
143
+ body: body || undefined,
144
+ };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function formatElapsed(seconds: number): string {
150
+ if (seconds < 60) return `${seconds}s`;
151
+ const m = Math.floor(seconds / 60);
152
+ const s = seconds % 60;
153
+ return `${m}m ${s}s`;
154
+ }
155
+
156
+ function muxUnavailableResult(kind: "subagents" | "tab-title" = "subagents") {
157
+ if (kind === "tab-title") {
158
+ return {
159
+ content: [
160
+ { type: "text" as const, text: `Terminal multiplexer not available. ${muxSetupHint()}` },
161
+ ],
162
+ details: { error: "mux not available" },
163
+ };
164
+ }
165
+
166
+ return {
167
+ content: [
168
+ {
169
+ type: "text" as const,
170
+ text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`,
171
+ },
172
+ ],
173
+ details: { error: "mux not available" },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Build the artifact directory path for the current session.
179
+ * Same convention as the write_artifact tool:
180
+ * <sessionDir>/artifacts/<session-id>/
181
+ */
182
+ function getArtifactDir(sessionDir: string, sessionId: string): string {
183
+ return join(sessionDir, "artifacts", sessionId);
184
+ }
185
+
186
+ function formatBytes(bytes: number): string {
187
+ if (bytes < 1024) return `${bytes}B`;
188
+ const kb = bytes / 1024;
189
+ if (kb < 1024) return `${kb.toFixed(1)}KB`;
190
+ const mb = kb / 1024;
191
+ return `${mb.toFixed(1)}MB`;
192
+ }
193
+
194
+ /**
195
+ * Try to find and measure a specific session file, or discover
196
+ * the right one from new files in the session directory.
197
+ *
198
+ * When `trackedFile` is provided, measures that file directly.
199
+ * Otherwise scans for new files not in `existingFiles` or `excludeFiles`.
200
+ *
201
+ * Returns { file, entries, bytes } — `file` is the path that was measured,
202
+ * so callers can lock onto it for subsequent calls.
203
+ */
204
+ /**
205
+ * Result from running a single subagent.
206
+ */
207
+ interface SubagentResult {
208
+ name: string;
209
+ task: string;
210
+ summary: string;
211
+ sessionFile?: string;
212
+ exitCode: number;
213
+ elapsed: number;
214
+ error?: string;
215
+ }
216
+
217
+ /**
218
+ * State for a launched (but not yet completed) subagent.
219
+ */
220
+ interface RunningSubagent {
221
+ id: string;
222
+ name: string;
223
+ task: string;
224
+ agent?: string;
225
+ surface: string;
226
+ startTime: number;
227
+ sessionFile: string;
228
+ entries?: number;
229
+ bytes?: number;
230
+ abortController?: AbortController;
231
+ }
232
+
233
+ /** All currently running subagents, keyed by id. */
234
+ const runningSubagents = new Map<string, RunningSubagent>();
235
+
236
+ // ── Widget management ──
237
+
238
+ /** Latest ExtensionContext from session_start, used for widget updates. */
239
+ let latestCtx: ExtensionContext | null = null;
240
+
241
+ /** Interval timer for widget re-renders. */
242
+ let widgetInterval: ReturnType<typeof setInterval> | null = null;
243
+
244
+ function formatElapsedMMSS(startTime: number): string {
245
+ const seconds = Math.floor((Date.now() - startTime) / 1000);
246
+ const m = Math.floor(seconds / 60);
247
+ const s = seconds % 60;
248
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
249
+ }
250
+
251
+ const ACCENT = "\x1b[38;2;77;163;255m";
252
+ const RST = "\x1b[0m";
253
+
254
+ /**
255
+ * Build a bordered content line: │left right│
256
+ * Left content is truncated if needed, right is preserved, padded to fill width.
257
+ */
258
+ function borderLine(left: string, right: string, width: number): string {
259
+ if (width <= 0) return "";
260
+ if (width === 1) return `${ACCENT}│${RST}`;
261
+
262
+ // width = total visible chars for the whole line including │ and │
263
+ const contentWidth = Math.max(0, width - 2); // space inside the two │ chars
264
+ const rightVis = visibleWidth(right);
265
+
266
+ // If the status chunk alone is too wide, prefer preserving it in compact form
267
+ // rather than overflowing the terminal.
268
+ if (rightVis >= contentWidth) {
269
+ const truncRight = truncateToWidth(right, contentWidth);
270
+ const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
271
+ return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`;
272
+ }
273
+
274
+ const maxLeft = Math.max(0, contentWidth - rightVis);
275
+ const truncLeft = truncateToWidth(left, maxLeft);
276
+ const leftVis = visibleWidth(truncLeft);
277
+ const pad = Math.max(0, contentWidth - leftVis - rightVis);
278
+ return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`;
279
+ }
280
+
281
+ /**
282
+ * Build the bordered top line: ╭─ Title ──── info ─╮
283
+ * All chars are accounted for within `width`.
284
+ */
285
+ function borderTop(title: string, info: string, width: number): string {
286
+ if (width <= 0) return "";
287
+ if (width === 1) return `${ACCENT}╭${RST}`;
288
+
289
+ // ╭─ Title ───...─── info ─╮
290
+ // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify
291
+ const inner = Math.max(0, width - 2); // inside ╭ and ╮
292
+ const titlePart = `─ ${title} `;
293
+ const infoPart = ` ${info} ─`;
294
+ const fillLen = Math.max(0, inner - titlePart.length - infoPart.length);
295
+ const fill = "─".repeat(fillLen);
296
+ const content = `${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─");
297
+ return `${ACCENT}╭${content}╮${RST}`;
298
+ }
299
+
300
+ /**
301
+ * Build the bordered bottom line: ╰──────────────────╯
302
+ */
303
+ function borderBottom(width: number): string {
304
+ if (width <= 0) return "";
305
+ if (width === 1) return `${ACCENT}╰${RST}`;
306
+
307
+ const inner = Math.max(0, width - 2);
308
+ return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`;
309
+ }
310
+
311
+ function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
312
+ const count = agents.length;
313
+ const title = "Subagents";
314
+ const info = `${count} running`;
315
+
316
+ const lines: string[] = [borderTop(title, info, width)];
317
+
318
+ for (const agent of agents) {
319
+ const elapsed = formatElapsedMMSS(agent.startTime);
320
+ const agentTag = agent.agent ? ` (${agent.agent})` : "";
321
+ const left = ` ${elapsed} ${agent.name}${agentTag} `;
322
+ const right =
323
+ agent.entries != null && agent.bytes != null
324
+ ? ` ${agent.entries} msgs (${formatBytes(agent.bytes)}) `
325
+ : " starting… ";
326
+
327
+ lines.push(borderLine(left, right, width));
328
+ }
329
+
330
+ lines.push(borderBottom(width));
331
+ return lines;
332
+ }
333
+
334
+ function updateWidget() {
335
+ if (!latestCtx?.hasUI) return;
336
+
337
+ if (runningSubagents.size === 0) {
338
+ latestCtx.ui.setWidget("subagent-status", undefined);
339
+ if (widgetInterval) {
340
+ clearInterval(widgetInterval);
341
+ widgetInterval = null;
342
+ }
343
+ return;
344
+ }
345
+
346
+ latestCtx.ui.setWidget(
347
+ "subagent-status",
348
+ (_tui: any, _theme: any) => {
349
+ return {
350
+ invalidate() {},
351
+ render(width: number) {
352
+ return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
353
+ },
354
+ };
355
+ },
356
+ { placement: "aboveEditor" },
357
+ );
358
+ }
359
+
360
+ export const __test__ = {
361
+ borderLine,
362
+ renderSubagentWidgetLines,
363
+ };
364
+
365
+ function startWidgetRefresh() {
366
+ if (widgetInterval) return;
367
+ updateWidget(); // immediate first render
368
+ widgetInterval = setInterval(() => {
369
+ updateWidget();
370
+ }, 1000);
371
+ }
372
+
373
+ /**
374
+ * Launch a subagent: creates the multiplexer pane, builds the command, and
375
+ * sends it. Returns a RunningSubagent — does NOT poll.
376
+ *
377
+ * Call watchSubagent() on the returned object to observe completion.
378
+ */
379
+ async function launchSubagent(
380
+ params: typeof SubagentParams.static,
381
+ ctx: { sessionManager: { getSessionFile(): string | null; getSessionId(): string; getSessionDir(): string }; cwd: string },
382
+ options?: { surface?: string },
383
+ ): Promise<RunningSubagent> {
384
+ const startTime = Date.now();
385
+ const id = Math.random().toString(16).slice(2, 10);
386
+
387
+ const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null;
388
+ const effectiveModel = params.model ?? agentDefs?.model;
389
+ const effectiveTools = params.tools ?? agentDefs?.tools;
390
+ const effectiveSkills = params.skills ?? agentDefs?.skills;
391
+ const effectiveThinking = agentDefs?.thinking;
392
+
393
+ const sessionFile = ctx.sessionManager.getSessionFile();
394
+ if (!sessionFile) throw new Error("No session file");
395
+
396
+ const sessionDir = dirname(sessionFile);
397
+
398
+ // Generate a deterministic session file path for this subagent.
399
+ // This eliminates race conditions when multiple agents launch simultaneously —
400
+ // each agent knows exactly which file is theirs.
401
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23) + "Z";
402
+ const uuid = [
403
+ id,
404
+ Math.random().toString(16).slice(2, 10),
405
+ Math.random().toString(16).slice(2, 10),
406
+ Math.random().toString(16).slice(2, 6),
407
+ ].join("-");
408
+ const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
409
+
410
+ // Use pre-created surface (parallel mode) or create a new one.
411
+ // For new surfaces, pause briefly so the shell is ready before sending the command.
412
+ const surfacePreCreated = !!options?.surface;
413
+ const surface = options?.surface ?? createSurface(params.name);
414
+ if (!surfacePreCreated) {
415
+ await new Promise<void>((resolve) => setTimeout(resolve, 500));
416
+ }
417
+
418
+ // Build the task message
419
+ // When forking, the sub-agent already has the full conversation context.
420
+ // Only send the user's task as a clean message — no wrapper instructions
421
+ // that would confuse the agent into thinking it needs to restart.
422
+ const modeHint = agentDefs?.autoExit
423
+ ? "Complete your task autonomously."
424
+ : "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time.";
425
+ const summaryInstruction = agentDefs?.autoExit
426
+ ? "Your FINAL assistant message should summarize what you accomplished."
427
+ : "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished.";
428
+ const denySet = resolveDenyTools(agentDefs);
429
+ const agentType = params.agent ?? params.name;
430
+ const tabTitleInstruction = denySet.has("set_tab_title")
431
+ ? ""
432
+ : `As your FIRST action, set the tab title using set_tab_title. ` +
433
+ `The title MUST start with [${agentType}] followed by a short description of your current task. ` +
434
+ `Example: "[${agentType}] Analyzing auth module". Keep it concise.`;
435
+ // Determine where the agent identity goes: system prompt or user message
436
+ const identity = agentDefs?.body ?? params.systemPrompt ?? null;
437
+ const systemPromptMode = agentDefs?.systemPromptMode;
438
+ const identityInSystemPrompt = systemPromptMode && identity;
439
+ const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
440
+ const fullTask = params.fork
441
+ ? params.task
442
+ : `${roleBlock}\n\n${modeHint}\n\n${tabTitleInstruction}\n\n${params.task}\n\n${summaryInstruction}`;
443
+
444
+ // Build pi command
445
+ const parts: string[] = ["pi"];
446
+ parts.push("--session", shellEscape(subagentSessionFile));
447
+
448
+ // For fork mode, build the forked session file directly at subagentSessionFile.
449
+ // We write a new session header + cleaned entries (excluding the meta-message
450
+ // that triggered this fork). The sub-agent launches with just --session.
451
+ if (params.fork) {
452
+ const raw = readFileSync(sessionFile, "utf8");
453
+ const lines = raw.split("\n").filter((l) => l.trim());
454
+
455
+ // Walk backwards to find the last user message (the meta-instruction)
456
+ // and truncate everything from there onwards
457
+ let truncateAt = lines.length;
458
+ for (let i = lines.length - 1; i >= 0; i--) {
459
+ try {
460
+ const entry = JSON.parse(lines[i]);
461
+ if (entry.type === "message" && entry.message?.role === "user") {
462
+ truncateAt = i;
463
+ break;
464
+ }
465
+ } catch {}
466
+ }
467
+
468
+ // Separate header from content entries
469
+ const cleanLines = lines.slice(0, truncateAt);
470
+ const contentLines = cleanLines.filter((l) => {
471
+ try {
472
+ return JSON.parse(l).type !== "session";
473
+ } catch {
474
+ return true;
475
+ }
476
+ });
477
+
478
+ // Write new session header + cleaned entries to the subagent session file
479
+ const newHeader = JSON.stringify({
480
+ type: "session",
481
+ version: 3,
482
+ id: randomUUID(),
483
+ timestamp: new Date().toISOString(),
484
+ cwd: process.cwd(),
485
+ parentSession: sessionFile,
486
+ });
487
+ mkdirSync(dirname(subagentSessionFile), { recursive: true });
488
+ writeFileSync(
489
+ subagentSessionFile,
490
+ newHeader + "\n" + contentLines.join("\n") + "\n",
491
+ "utf8",
492
+ );
493
+ }
494
+
495
+ const subagentDonePath = join(dirname(new URL(import.meta.url).pathname), "subagent-done.ts");
496
+ parts.push("-e", shellEscape(subagentDonePath));
497
+
498
+ if (effectiveModel) {
499
+ const model = effectiveThinking ? `${effectiveModel}:${effectiveThinking}` : effectiveModel;
500
+ parts.push("--model", shellEscape(model));
501
+ }
502
+
503
+ // Pass agent body as system prompt via file to avoid shell escaping issues
504
+ // with multiline content. Pi's --append-system-prompt and --system-prompt
505
+ // auto-detect file paths and read their contents.
506
+ if (identityInSystemPrompt && identity) {
507
+ const flag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
508
+ const sessionId = ctx.sessionManager.getSessionId();
509
+ const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
510
+ const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
511
+ const spSafeName = (params.name ?? "subagent")
512
+ .toLowerCase()
513
+ .replace(/[^a-z0-9\s-]/g, "")
514
+ .replace(/\s+/g, "-")
515
+ .replace(/-+/g, "-")
516
+ .replace(/^-|-$/g, "");
517
+ const syspromptPath = join(artifactDir, `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`);
518
+ mkdirSync(dirname(syspromptPath), { recursive: true });
519
+ writeFileSync(syspromptPath, identity, "utf8");
520
+ parts.push(flag, shellEscape(syspromptPath));
521
+ }
522
+
523
+ if (effectiveTools) {
524
+ const BUILTIN_TOOLS = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
525
+ const builtins = effectiveTools
526
+ .split(",")
527
+ .map((t) => t.trim())
528
+ .filter((t) => BUILTIN_TOOLS.has(t));
529
+ if (builtins.length > 0) {
530
+ parts.push("--tools", shellEscape(builtins.join(",")));
531
+ }
532
+ }
533
+
534
+ if (effectiveSkills) {
535
+ for (const skill of effectiveSkills
536
+ .split(",")
537
+ .map((s) => s.trim())
538
+ .filter(Boolean)) {
539
+ parts.push(shellEscape(`/skill:${skill}`));
540
+ }
541
+ }
542
+
543
+ // Build env prefix: denied tools + subagent identity + config dir propagation
544
+ const envParts: string[] = [];
545
+
546
+ // Resolve PI_CODING_AGENT_DIR: if cwd is set and the target has its own
547
+ // .pi/agent/, use that as the config root — this gives the sub-agent full
548
+ // config isolation (its own extensions, skills, models, auth). Otherwise
549
+ // propagate the parent's PI_CODING_AGENT_DIR.
550
+ const rawCwdForEnv = params.cwd ?? agentDefs?.cwd ?? null;
551
+ const cwdIsFromAgentForEnv = !params.cwd && agentDefs?.cwd != null;
552
+ const cwdBaseForEnv = cwdIsFromAgentForEnv ? getAgentConfigDir() : process.cwd();
553
+ const resolvedCwdForEnv = rawCwdForEnv
554
+ ? rawCwdForEnv.startsWith("/")
555
+ ? rawCwdForEnv
556
+ : join(cwdBaseForEnv, rawCwdForEnv)
557
+ : null;
558
+ const localAgentDir = resolvedCwdForEnv ? join(resolvedCwdForEnv, ".pi", "agent") : null;
559
+ if (localAgentDir && existsSync(localAgentDir)) {
560
+ envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(localAgentDir)}`);
561
+ } else if (process.env.PI_CODING_AGENT_DIR) {
562
+ envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
563
+ }
564
+
565
+ if (denySet.size > 0) {
566
+ envParts.push(`PI_DENY_TOOLS=${shellEscape([...denySet].join(","))}`);
567
+ }
568
+ envParts.push(`PI_SUBAGENT_NAME=${shellEscape(params.name)}`);
569
+ if (params.agent) {
570
+ envParts.push(`PI_SUBAGENT_AGENT=${shellEscape(params.agent)}`);
571
+ }
572
+ if (agentDefs?.autoExit) {
573
+ envParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
574
+ }
575
+ const envPrefix = envParts.join(" ") + " ";
576
+
577
+ // Pass task to the sub-agent.
578
+ // For fork mode, pass as a plain quoted argument — the forked session already
579
+ // has the full conversation context, so the message arrives as if the user typed it.
580
+ // For non-fork mode, write to an artifact file and pass via @file to handle
581
+ // long task descriptions with role/instructions safely.
582
+ if (params.fork) {
583
+ parts.push(shellEscape(fullTask));
584
+ } else {
585
+ const sessionId = ctx.sessionManager.getSessionId();
586
+ const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
587
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
588
+ const safeName = params.name
589
+ .toLowerCase()
590
+ .replace(/[^a-z0-9\s-]/g, "") // strip everything except alphanumeric, spaces, hyphens
591
+ .replace(/\s+/g, "-") // spaces to hyphens
592
+ .replace(/-+/g, "-") // collapse multiple hyphens
593
+ .replace(/^-|-$/g, ""); // trim leading/trailing hyphens
594
+ const artifactName = `context/${safeName || "subagent"}-${timestamp}.md`;
595
+ const artifactPath = join(artifactDir, artifactName);
596
+ mkdirSync(dirname(artifactPath), { recursive: true });
597
+ writeFileSync(artifactPath, fullTask, "utf8");
598
+ parts.push(`@${artifactPath}`);
599
+ }
600
+
601
+ // Resolve cwd — param overrides agent default, supports absolute and relative paths.
602
+ // For agent-default cwd (from the .md definition), resolve relative to the config dir
603
+ // where the agent was discovered — not process.cwd(). This allows agents to find their
604
+ // role folders when PI_CODING_AGENT_DIR points to a different directory than cwd.
605
+ const rawCwd = params.cwd ?? agentDefs?.cwd ?? null;
606
+ const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null;
607
+ const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : process.cwd();
608
+ const effectiveCwd = rawCwd
609
+ ? rawCwd.startsWith("/")
610
+ ? rawCwd
611
+ : join(cwdBase, rawCwd)
612
+ : null;
613
+ const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
614
+
615
+ const piCommand = cdPrefix + envPrefix + parts.join(" ");
616
+ const command = `${piCommand}; echo '__SUBAGENT_DONE_'${exitStatusVar()}'__'`;
617
+ sendCommand(surface, command);
618
+
619
+ const running: RunningSubagent = {
620
+ id,
621
+ name: params.name,
622
+ task: params.task,
623
+ agent: params.agent,
624
+ surface,
625
+ startTime,
626
+ sessionFile: subagentSessionFile,
627
+ };
628
+
629
+ runningSubagents.set(id, running);
630
+ return running;
631
+ }
632
+
633
+ /**
634
+ * Watch a launched subagent until it exits. Polls for completion, extracts
635
+ * the summary from the session file, cleans up the surface,
636
+ * and removes the entry from runningSubagents.
637
+ */
638
+ async function watchSubagent(
639
+ running: RunningSubagent,
640
+ signal: AbortSignal,
641
+ ): Promise<SubagentResult> {
642
+ const { name, task, surface, startTime, sessionFile } = running;
643
+
644
+ try {
645
+ const exitCode = await pollForExit(surface, signal, {
646
+ interval: 1000,
647
+ onTick() {
648
+ // Update entries/bytes for widget display
649
+ try {
650
+ if (existsSync(sessionFile)) {
651
+ const stat = statSync(sessionFile);
652
+ const raw = readFileSync(sessionFile, "utf8");
653
+ running.entries = raw.split("\n").filter((l) => l.trim()).length;
654
+ running.bytes = stat.size;
655
+ }
656
+ } catch {}
657
+ },
658
+ });
659
+
660
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
661
+
662
+ // Extract summary from the known session file
663
+ let summary: string;
664
+ if (existsSync(sessionFile)) {
665
+ const allEntries = getNewEntries(sessionFile, 0);
666
+ summary =
667
+ findLastAssistantMessage(allEntries) ??
668
+ (exitCode !== 0
669
+ ? `Sub-agent exited with code ${exitCode}`
670
+ : "Sub-agent exited without output");
671
+ } else {
672
+ summary =
673
+ exitCode !== 0
674
+ ? `Sub-agent exited with code ${exitCode}`
675
+ : "Sub-agent exited without output";
676
+ }
677
+
678
+ closeSurface(surface);
679
+ runningSubagents.delete(running.id);
680
+
681
+ return { name, task, summary, sessionFile, exitCode, elapsed };
682
+ } catch (err: any) {
683
+ try {
684
+ closeSurface(surface);
685
+ } catch {}
686
+ runningSubagents.delete(running.id);
687
+
688
+ if (signal.aborted) {
689
+ return {
690
+ name,
691
+ task,
692
+ summary: "Subagent cancelled.",
693
+ exitCode: 1,
694
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
695
+ error: "cancelled",
696
+ };
697
+ }
698
+ return {
699
+ name,
700
+ task,
701
+ summary: `Subagent error: ${err?.message ?? String(err)}`,
702
+ exitCode: 1,
703
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
704
+ error: err?.message ?? String(err),
705
+ };
706
+ }
707
+ }
708
+
709
+ export default function subagentsExtension(pi: ExtensionAPI) {
710
+ // Capture the UI context for widget updates
711
+ pi.on("session_start", (_event, ctx) => {
712
+ latestCtx = ctx;
713
+ });
714
+
715
+ // Clean up on session shutdown
716
+ pi.on("session_shutdown", (_event, _ctx) => {
717
+ if (widgetInterval) {
718
+ clearInterval(widgetInterval);
719
+ widgetInterval = null;
720
+ }
721
+ for (const [_id, agent] of runningSubagents) {
722
+ agent.abortController?.abort();
723
+ }
724
+ runningSubagents.clear();
725
+ });
726
+
727
+ // Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter)
728
+ const deniedTools = new Set(
729
+ (process.env.PI_DENY_TOOLS ?? "")
730
+ .split(",")
731
+ .map((s) => s.trim())
732
+ .filter(Boolean),
733
+ );
734
+
735
+ const shouldRegister = (name: string) => !deniedTools.has(name);
736
+
737
+ // ── subagent tool ──
738
+ if (shouldRegister("subagent"))
739
+ pi.registerTool({
740
+ name: "subagent",
741
+ label: "Subagent",
742
+ description:
743
+ "Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
744
+ "IMPORTANT: This tool returns IMMEDIATELY — the sub-agent runs asynchronously in the background. " +
745
+ "You will NOT have results when this tool returns. Results are delivered later via a steer message. " +
746
+ "Do NOT fabricate, assume, or summarize results after calling this tool. " +
747
+ "Either wait for the steer message or move on to other work.",
748
+ promptSnippet:
749
+ "Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
750
+ "IMPORTANT: This tool returns IMMEDIATELY — the sub-agent runs asynchronously in the background. " +
751
+ "You will NOT have results when this tool returns. Results are delivered later via a steer message. " +
752
+ "Do NOT fabricate, assume, or summarize results after calling this tool. " +
753
+ "Either wait for the steer message or move on to other work.",
754
+ parameters: SubagentParams,
755
+
756
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
757
+ // Prevent self-spawning (e.g. planner spawning another planner)
758
+ const currentAgent = process.env.PI_SUBAGENT_AGENT;
759
+ if (params.agent && currentAgent && params.agent === currentAgent) {
760
+ return {
761
+ content: [
762
+ {
763
+ type: "text",
764
+ text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. You were spawned to do this work yourself. Complete the task directly.`,
765
+ },
766
+ ],
767
+ details: { error: "self-spawn blocked" },
768
+ };
769
+ }
770
+
771
+ // Validate prerequisites
772
+ if (!isMuxAvailable()) {
773
+ return muxUnavailableResult("subagents");
774
+ }
775
+
776
+ if (!ctx.sessionManager.getSessionFile()) {
777
+ return {
778
+ content: [
779
+ {
780
+ type: "text",
781
+ text: "Error: no session file. Start pi with a persistent session to use subagents.",
782
+ },
783
+ ],
784
+ details: { error: "no session file" },
785
+ };
786
+ }
787
+
788
+ // Launch the subagent (creates pane, sends command)
789
+ const running = await launchSubagent(params, ctx);
790
+
791
+ // Create a separate AbortController for the watcher
792
+ // (the tool's signal completes when we return)
793
+ const watcherAbort = new AbortController();
794
+ running.abortController = watcherAbort;
795
+
796
+ // Start widget refresh when first agent launches
797
+ startWidgetRefresh();
798
+
799
+ // Fire-and-forget: start watching in background
800
+ watchSubagent(running, watcherAbort.signal)
801
+ .then((result) => {
802
+ updateWidget(); // reflect removal from Map immediately
803
+ const sessionRef = result.sessionFile
804
+ ? `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`
805
+ : "";
806
+ const content =
807
+ result.exitCode !== 0
808
+ ? `Sub-agent "${running.name}" failed (exit code ${result.exitCode}).\n\n${result.summary}${sessionRef}`
809
+ : `Sub-agent "${running.name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}${sessionRef}`;
810
+
811
+ pi.sendMessage(
812
+ {
813
+ customType: "subagent_result",
814
+ content,
815
+ display: true,
816
+ details: {
817
+ name: running.name,
818
+ task: running.task,
819
+ agent: running.agent,
820
+ exitCode: result.exitCode,
821
+ elapsed: result.elapsed,
822
+ sessionFile: result.sessionFile,
823
+ },
824
+ },
825
+ { triggerTurn: true, deliverAs: "steer" },
826
+ );
827
+ })
828
+ .catch((err) => {
829
+ updateWidget();
830
+ pi.sendMessage(
831
+ {
832
+ customType: "subagent_result",
833
+ content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
834
+ display: true,
835
+ details: { name: running.name, task: running.task, error: err?.message },
836
+ },
837
+ { triggerTurn: true, deliverAs: "steer" },
838
+ );
839
+ });
840
+
841
+ // Return immediately
842
+ return {
843
+ content: [
844
+ {
845
+ type: "text",
846
+ text:
847
+ `Sub-agent "${params.name}" launched and is now running in the background. ` +
848
+ `Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
849
+ `The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
850
+ `Until then, move on to other work or tell the user you're waiting.`,
851
+ },
852
+ ],
853
+ details: {
854
+ id: running.id,
855
+ name: params.name,
856
+ task: params.task,
857
+ agent: params.agent,
858
+ sessionFile: running.sessionFile,
859
+ status: "started",
860
+ },
861
+ };
862
+ },
863
+
864
+ renderCall(args, theme) {
865
+ const agent = args.agent ? theme.fg("dim", ` (${args.agent})`) : "";
866
+ const cwdHint = args.cwd ? theme.fg("dim", ` in ${args.cwd}`) : "";
867
+ let text =
868
+ "▸ " + theme.fg("toolTitle", theme.bold(args.name ?? "(unnamed)")) + agent + cwdHint;
869
+
870
+ // Show a one-line task preview. renderCall is called repeatedly as the
871
+ // LLM generates tool arguments, so args.task grows token by token.
872
+ // We keep it compact here — Ctrl+O on renderResult expands the full content.
873
+ const task = args.task ?? "";
874
+ if (task) {
875
+ const firstLine = task.split("\n").find((l: string) => l.trim()) ?? "";
876
+ const preview = firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine;
877
+ if (preview) {
878
+ text += "\n" + theme.fg("toolOutput", preview);
879
+ }
880
+ const totalLines = task.split("\n").length;
881
+ if (totalLines > 1) {
882
+ text += theme.fg("muted", ` (${totalLines} lines)`);
883
+ }
884
+ }
885
+
886
+ return new Text(text, 0, 0);
887
+ },
888
+
889
+ renderResult(result, _opts, theme) {
890
+ const details = result.details as any;
891
+ const name = details?.name ?? "(unnamed)";
892
+
893
+ // "Started" result — tool returned immediately
894
+ if (details?.status === "started") {
895
+ return new Text(
896
+ theme.fg("accent", "▸") +
897
+ " " +
898
+ theme.fg("toolTitle", theme.bold(name)) +
899
+ theme.fg("dim", " — started"),
900
+ 0,
901
+ 0,
902
+ );
903
+ }
904
+
905
+ // Fallback (shouldn't happen)
906
+ const text = typeof result.content?.[0]?.text === "string" ? result.content[0].text : "";
907
+ return new Text(theme.fg("dim", text), 0, 0);
908
+ },
909
+ });
910
+
911
+ // ── subagents_list tool ──
912
+ if (shouldRegister("subagents_list"))
913
+ pi.registerTool({
914
+ name: "subagents_list",
915
+ label: "List Subagents",
916
+ description:
917
+ "List all available subagent definitions. " +
918
+ "Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " +
919
+ "Project-local agents override global ones with the same name.",
920
+ promptSnippet:
921
+ "List all available subagent definitions. " +
922
+ "Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " +
923
+ "Project-local agents override global ones with the same name.",
924
+ parameters: Type.Object({}),
925
+
926
+ async execute() {
927
+ const agents = new Map<
928
+ string,
929
+ { name: string; description?: string; model?: string; source: string }
930
+ >();
931
+
932
+ const dirs = [
933
+ {
934
+ path: join(dirname(new URL(import.meta.url).pathname), "../../agents"),
935
+ source: "package",
936
+ },
937
+ { path: join(getAgentConfigDir(), "agents"), source: "global" },
938
+ { path: join(process.cwd(), ".pi", "agents"), source: "project" },
939
+ ];
940
+
941
+ for (const { path: dir, source } of dirs) {
942
+ if (!existsSync(dir)) continue;
943
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
944
+ const content = readFileSync(join(dir, file), "utf8");
945
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
946
+ if (!match) continue;
947
+ const frontmatter = match[1];
948
+ const get = (key: string) => {
949
+ const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
950
+ return m ? m[1].trim() : undefined;
951
+ };
952
+ const name = get("name") ?? file.replace(/\.md$/, "");
953
+ agents.set(name, {
954
+ name,
955
+ description: get("description"),
956
+ model: get("model"),
957
+ source,
958
+ });
959
+ }
960
+ }
961
+
962
+ if (agents.size === 0) {
963
+ return {
964
+ content: [{ type: "text", text: "No subagent definitions found." }],
965
+ details: { agents: [] },
966
+ };
967
+ }
968
+
969
+ const list = [...agents.values()];
970
+ const lines = list.map((a) => {
971
+ const badge = a.source === "project" ? " (project)" : "";
972
+ const desc = a.description ? ` — ${a.description}` : "";
973
+ const model = a.model ? ` [${a.model}]` : "";
974
+ return `• ${a.name}${badge}${model}${desc}`;
975
+ });
976
+
977
+ return {
978
+ content: [{ type: "text", text: lines.join("\n") }],
979
+ details: { agents: list },
980
+ };
981
+ },
982
+
983
+ renderResult(result, _opts, theme) {
984
+ const details = result.details as any;
985
+ const agents = details?.agents ?? [];
986
+ if (agents.length === 0) {
987
+ return new Text(theme.fg("dim", "No subagent definitions found."), 0, 0);
988
+ }
989
+ const lines = agents.map((a: any) => {
990
+ const badge = a.source === "project" ? theme.fg("accent", " (project)") : "";
991
+ const desc = a.description ? theme.fg("dim", ` — ${a.description}`) : "";
992
+ const model = a.model ? theme.fg("dim", ` [${a.model}]`) : "";
993
+ return ` ${theme.fg("toolTitle", theme.bold(a.name))}${badge}${model}${desc}`;
994
+ });
995
+ return new Text(lines.join("\n"), 0, 0);
996
+ },
997
+ });
998
+
999
+ // ── set_tab_title tool ──
1000
+ if (shouldRegister("set_tab_title"))
1001
+ pi.registerTool({
1002
+ name: "set_tab_title",
1003
+ label: "Set Tab Title",
1004
+ description:
1005
+ "Update the current tab/window and workspace/session title. Use to show progress during multi-phase workflows " +
1006
+ "(e.g. planning, executing todos, reviewing). Keep titles short and informative.",
1007
+ promptSnippet:
1008
+ "Update the current tab/window and workspace/session title. Use to show progress during multi-phase workflows " +
1009
+ "(e.g. planning, executing todos, reviewing). Keep titles short and informative.",
1010
+ parameters: Type.Object({
1011
+ title: Type.String({
1012
+ description: "New tab title (also applied to workspace/session when supported)",
1013
+ }),
1014
+ }),
1015
+
1016
+ async execute(_toolCallId, params) {
1017
+ if (!isMuxAvailable()) {
1018
+ return muxUnavailableResult("tab-title");
1019
+ }
1020
+ try {
1021
+ renameCurrentTab(params.title);
1022
+ renameWorkspace(params.title);
1023
+ return {
1024
+ content: [{ type: "text", text: `Title set to: ${params.title}` }],
1025
+ details: { title: params.title },
1026
+ };
1027
+ } catch (err: any) {
1028
+ return {
1029
+ content: [{ type: "text", text: `Failed to set title: ${err?.message}` }],
1030
+ details: { error: err?.message },
1031
+ };
1032
+ }
1033
+ },
1034
+ });
1035
+
1036
+ // ── subagent_resume tool ──
1037
+ if (shouldRegister("subagent_resume"))
1038
+ pi.registerTool({
1039
+ name: "subagent_resume",
1040
+ label: "Resume Subagent",
1041
+ description:
1042
+ "Resume a previous sub-agent session in a new multiplexer pane. " +
1043
+ "IMPORTANT: Returns IMMEDIATELY — the resumed session runs asynchronously in the background. " +
1044
+ "Results are delivered later via a steer message. Do NOT fabricate or assume results. " +
1045
+ "Use when a sub-agent was cancelled or needs follow-up work.",
1046
+ promptSnippet:
1047
+ "Resume a previous sub-agent session in a new multiplexer pane. " +
1048
+ "IMPORTANT: Returns IMMEDIATELY — the resumed session runs asynchronously in the background. " +
1049
+ "Results are delivered later via a steer message. Do NOT fabricate or assume results. " +
1050
+ "Use when a sub-agent was cancelled or needs follow-up work.",
1051
+ parameters: Type.Object({
1052
+ sessionPath: Type.String({ description: "Path to the session .jsonl file to resume" }),
1053
+ name: Type.Optional(
1054
+ Type.String({ description: "Display name for the terminal tab. Default: 'Resume'" }),
1055
+ ),
1056
+ message: Type.Optional(
1057
+ Type.String({
1058
+ description: "Optional message to send after resuming (e.g. follow-up instructions)",
1059
+ }),
1060
+ ),
1061
+ }),
1062
+
1063
+ renderCall(args, theme) {
1064
+ const name = args.name ?? "Resume";
1065
+ const text =
1066
+ "▸ " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", " — resuming session");
1067
+ return new Text(text, 0, 0);
1068
+ },
1069
+
1070
+ renderResult(result, _opts, theme) {
1071
+ const details = result.details as any;
1072
+ const name = details?.name ?? "Resume";
1073
+
1074
+ if (details?.status === "started") {
1075
+ return new Text(
1076
+ theme.fg("accent", "▸") +
1077
+ " " +
1078
+ theme.fg("toolTitle", theme.bold(name)) +
1079
+ theme.fg("dim", " — resumed"),
1080
+ 0,
1081
+ 0,
1082
+ );
1083
+ }
1084
+
1085
+ // Fallback
1086
+ const text = typeof result.content?.[0]?.text === "string" ? result.content[0].text : "";
1087
+ return new Text(theme.fg("dim", text), 0, 0);
1088
+ },
1089
+
1090
+ async execute(_toolCallId, params, _signal, _onUpdate) {
1091
+ const name = params.name ?? "Resume";
1092
+ const startTime = Date.now();
1093
+
1094
+ if (!isMuxAvailable()) {
1095
+ return muxUnavailableResult("subagents");
1096
+ }
1097
+
1098
+ if (!existsSync(params.sessionPath)) {
1099
+ return {
1100
+ content: [
1101
+ { type: "text", text: `Error: session file not found: ${params.sessionPath}` },
1102
+ ],
1103
+ details: { error: "session not found" },
1104
+ };
1105
+ }
1106
+
1107
+ // Record entry count before resuming so we can extract new messages
1108
+ const entryCountBefore = getNewEntries(params.sessionPath, 0).length;
1109
+
1110
+ const surface = createSurface(name);
1111
+ await new Promise<void>((resolve) => setTimeout(resolve, 500));
1112
+
1113
+ // Build pi resume command
1114
+ const parts = ["pi", "--session", shellEscape(params.sessionPath)];
1115
+
1116
+ // Load subagent-done extension so the agent can self-terminate if needed
1117
+ const subagentDonePath = join(
1118
+ dirname(new URL(import.meta.url).pathname),
1119
+ "subagent-done.ts",
1120
+ );
1121
+ parts.push("-e", shellEscape(subagentDonePath));
1122
+
1123
+ let cleanupMsgFile: string | undefined;
1124
+ if (params.message) {
1125
+ const msgFile = join(tmpdir(), `subagent-resume-${Date.now()}.md`);
1126
+ writeFileSync(msgFile, params.message, "utf8");
1127
+ cleanupMsgFile = msgFile;
1128
+ parts.push(`@${msgFile}`);
1129
+ }
1130
+
1131
+ // Build env prefix — propagate PI_CODING_AGENT_DIR for config isolation
1132
+ const resumeEnvParts: string[] = [];
1133
+ if (process.env.PI_CODING_AGENT_DIR) {
1134
+ resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
1135
+ }
1136
+ const resumeEnvPrefix = resumeEnvParts.length > 0 ? resumeEnvParts.join(" ") + " " : "";
1137
+
1138
+ const command = `${resumeEnvPrefix}${parts.join(" ")}${cleanupMsgFile ? `; rm -f ${shellEscape(cleanupMsgFile)}` : ""}; echo '__SUBAGENT_DONE_'${exitStatusVar()}'__'`;
1139
+ sendCommand(surface, command);
1140
+
1141
+ // Register as a running subagent for widget tracking
1142
+ const id = Math.random().toString(16).slice(2, 10);
1143
+ const running: RunningSubagent = {
1144
+ id,
1145
+ name,
1146
+ task: params.message ?? "resumed session",
1147
+ surface,
1148
+ startTime,
1149
+ sessionFile: params.sessionPath,
1150
+ };
1151
+ runningSubagents.set(id, running);
1152
+ startWidgetRefresh();
1153
+
1154
+ // Fire-and-forget watcher
1155
+ const watcherAbort = new AbortController();
1156
+ running.abortController = watcherAbort;
1157
+
1158
+ watchSubagent(running, watcherAbort.signal)
1159
+ .then((result) => {
1160
+ updateWidget();
1161
+ const allEntries = getNewEntries(params.sessionPath, entryCountBefore);
1162
+ const summary =
1163
+ findLastAssistantMessage(allEntries) ??
1164
+ (result.exitCode !== 0
1165
+ ? `Resumed session exited with code ${result.exitCode}`
1166
+ : "Resumed session exited without new output");
1167
+ const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`;
1168
+
1169
+ pi.sendMessage(
1170
+ {
1171
+ customType: "subagent_result",
1172
+ content: `${summary}${sessionRef}`,
1173
+ display: true,
1174
+ details: {
1175
+ name,
1176
+ task: params.message ?? "resumed session",
1177
+ exitCode: result.exitCode,
1178
+ elapsed: result.elapsed,
1179
+ sessionFile: params.sessionPath,
1180
+ },
1181
+ },
1182
+ { triggerTurn: true, deliverAs: "steer" },
1183
+ );
1184
+ })
1185
+ .catch((err) => {
1186
+ updateWidget();
1187
+ pi.sendMessage(
1188
+ {
1189
+ customType: "subagent_result",
1190
+ content: `Resume error: ${err?.message ?? String(err)}`,
1191
+ display: true,
1192
+ details: { name, error: err?.message },
1193
+ },
1194
+ { triggerTurn: true, deliverAs: "steer" },
1195
+ );
1196
+ });
1197
+
1198
+ return {
1199
+ content: [{ type: "text", text: `Session "${name}" resumed.` }],
1200
+ details: { id, name, sessionPath: params.sessionPath, status: "started" },
1201
+ };
1202
+ },
1203
+ });
1204
+
1205
+ // /iterate command — fork the session into a subagent
1206
+ pi.registerCommand("iterate", {
1207
+ description: "Fork session into a subagent for focused work (bugfixes, iteration)",
1208
+ handler: async (args, _ctx) => {
1209
+ const task = args?.trim() || "";
1210
+ const toolCall = task
1211
+ ? `Use subagent to fork a session. fork: true, name: "Iterate", task: ${JSON.stringify(task)}`
1212
+ : `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."`;
1213
+ pi.sendUserMessage(toolCall);
1214
+ },
1215
+ });
1216
+
1217
+ // /subagent command — spawn a subagent by name
1218
+ pi.registerCommand("subagent", {
1219
+ description: "Spawn a subagent: /subagent <agent> <task>",
1220
+ handler: async (args, ctx) => {
1221
+ const trimmed = (args ?? "").trim();
1222
+ if (!trimmed) {
1223
+ ctx.ui.notify("Usage: /subagent <agent> [task]", "warning");
1224
+ return;
1225
+ }
1226
+
1227
+ const spaceIdx = trimmed.indexOf(" ");
1228
+ const agentName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
1229
+ const task = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
1230
+
1231
+ const defs = loadAgentDefaults(agentName);
1232
+ if (!defs) {
1233
+ ctx.ui.notify(
1234
+ `Agent "${agentName}" not found in ~/.pi/agent/agents/ or .pi/agents/`,
1235
+ "error",
1236
+ );
1237
+ return;
1238
+ }
1239
+
1240
+ const taskText = task || `You are the ${agentName} agent. Wait for instructions.`;
1241
+ const displayName = agentName[0].toUpperCase() + agentName.slice(1);
1242
+ const toolCall = `Use subagent with agent: "${agentName}", name: "${displayName}", task: ${JSON.stringify(taskText)}`;
1243
+ pi.sendUserMessage(toolCall);
1244
+ },
1245
+ });
1246
+
1247
+ // ── subagent_result message renderer ──
1248
+ pi.registerMessageRenderer("subagent_result", (message, options, theme) => {
1249
+ const details = message.details as any;
1250
+ if (!details) return undefined;
1251
+
1252
+ return {
1253
+ render(width: number): string[] {
1254
+ const name = details.name ?? "subagent";
1255
+ const exitCode = details.exitCode ?? 0;
1256
+ const elapsed = details.elapsed != null ? formatElapsed(details.elapsed) : "?";
1257
+ const bgFn =
1258
+ exitCode === 0
1259
+ ? (text: string) => theme.bg("toolSuccessBg", text)
1260
+ : (text: string) => theme.bg("toolErrorBg", text);
1261
+ const icon = exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1262
+ const status = exitCode === 0 ? "completed" : `failed (exit ${exitCode})`;
1263
+ const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : "";
1264
+
1265
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`;
1266
+ const rawContent = typeof message.content === "string" ? message.content : "";
1267
+
1268
+ // Clean summary (remove session ref and leading label for display)
1269
+ const summary = rawContent
1270
+ .replace(/\n\nSession: .+\nResume: .+$/, "")
1271
+ .replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "")
1272
+ .replace(`Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`, "");
1273
+
1274
+ // Build content for the box
1275
+ const contentLines = [header];
1276
+
1277
+ if (options.expanded) {
1278
+ // Full view: complete summary + session info
1279
+ if (summary) {
1280
+ for (const line of summary.split("\n")) {
1281
+ contentLines.push(line.slice(0, width - 6));
1282
+ }
1283
+ }
1284
+ if (details.sessionFile) {
1285
+ contentLines.push("");
1286
+ contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`));
1287
+ contentLines.push(theme.fg("dim", `Resume: pi --session ${details.sessionFile}`));
1288
+ }
1289
+ } else {
1290
+ // Collapsed: preview + expand hint
1291
+ if (summary) {
1292
+ const previewLines = summary.split("\n").slice(0, 5);
1293
+ for (const line of previewLines) {
1294
+ contentLines.push(theme.fg("dim", line.slice(0, width - 6)));
1295
+ }
1296
+ const totalLines = summary.split("\n").length;
1297
+ if (totalLines > 5) {
1298
+ contentLines.push(theme.fg("muted", `… ${totalLines - 5} more lines`));
1299
+ }
1300
+ }
1301
+ contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand")));
1302
+ }
1303
+
1304
+ // Render via Box for background + padding, with blank line above for separation
1305
+ const box = new Box(1, 1, bgFn);
1306
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
1307
+ return ["", ...box.render(width)];
1308
+ },
1309
+ };
1310
+ });
1311
+
1312
+ // /plan command — start the full planning workflow
1313
+ pi.registerCommand("plan", {
1314
+ description: "Start a planning session: /plan <what to build>",
1315
+ handler: async (args, ctx) => {
1316
+ const task = (args ?? "").trim();
1317
+ if (!task) {
1318
+ ctx.ui.notify("Usage: /plan <what to build>", "warning");
1319
+ return;
1320
+ }
1321
+
1322
+ // Rename workspace and tab to show this is a planning session
1323
+ if (isMuxAvailable()) {
1324
+ try {
1325
+ const label = task.length > 40 ? task.slice(0, 40) + "..." : task;
1326
+ renameWorkspace(`🎯 ${label}`);
1327
+ renameCurrentTab(`🎯 Plan: ${label}`);
1328
+ } catch {
1329
+ // non-critical -- do not block the plan
1330
+ }
1331
+ }
1332
+
1333
+ // Load the plan skill from the subagents extension directory
1334
+ const planSkillPath = join(dirname(new URL(import.meta.url).pathname), "plan-skill.md");
1335
+ let content = readFileSync(planSkillPath, "utf8");
1336
+ content = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
1337
+ pi.sendUserMessage(
1338
+ `<skill name="plan" location="${planSkillPath}">\n${content.trim()}\n</skill>\n\n${task}`,
1339
+ );
1340
+ },
1341
+ });
1342
+ }
1343
+ // test