@tintinweb/pi-subagents 0.2.2 → 0.2.5

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.5] - 2026-03-06
9
+
10
+ ### Added
11
+ - **Interactive `/agents` menu** — single command replaces `/agent` and `/agents` with a full management wizard
12
+ - Browse and manage running agents
13
+ - Custom agents submenu — edit or delete existing agents
14
+ - Create new custom agents via manual wizard or AI-generated (with comprehensive frontmatter documentation for the generator)
15
+ - Settings: configure max concurrency, default max turns, and grace turns at runtime
16
+ - Built-in agent types shown with model info (e.g. `Explore · haiku`)
17
+ - Aligned formatting for agent lists
18
+ - **Configurable turn limits** — `defaultMaxTurns` and `graceTurns` are now runtime-adjustable via `/agents` → Settings
19
+ - Sub-menus return to main menu instead of exiting
20
+
21
+ ### Removed
22
+ - `/agent <type> <prompt>` command (use `Agent` tool directly, or create custom agents via `/agents`)
23
+
24
+ ## [0.2.4] - 2026-03-06
25
+
26
+ ### Added
27
+ - **Global custom agents** — agents in `~/.pi/agent/agents/*.md` are now discovered automatically and available across all projects
28
+ - Two-tier discovery hierarchy: project-level (`.pi/agents/`) overrides global (`~/.pi/agent/agents/`)
29
+
30
+ ## [0.2.3] - 2026-03-05
31
+
32
+ ### Added
33
+ - Screenshot in README
34
+
8
35
  ## [0.2.2] - 2026-03-05
9
36
 
10
37
  ### Changed
@@ -77,6 +104,9 @@ Initial release.
77
104
  - **Thinking level** — per-agent extended thinking control
78
105
  - **`/agent` and `/agents` commands**
79
106
 
107
+ [0.2.5]: https://github.com/tintinweb/pi-subagents/compare/v0.2.4...v0.2.5
108
+ [0.2.4]: https://github.com/tintinweb/pi-subagents/compare/v0.2.3...v0.2.4
109
+ [0.2.3]: https://github.com/tintinweb/pi-subagents/compare/v0.2.2...v0.2.3
80
110
  [0.2.2]: https://github.com/tintinweb/pi-subagents/compare/v0.2.1...v0.2.2
81
111
  [0.2.1]: https://github.com/tintinweb/pi-subagents/compare/v0.2.0...v0.2.1
82
112
  [0.2.0]: https://github.com/tintinweb/pi-subagents/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -4,6 +4,12 @@ A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-
4
4
 
5
5
  > **Status:** Early release.
6
6
 
7
+ <img width="600" alt="pi-subagents screenshot" src="https://github.com/tintinweb/pi-subagents/raw/master/media/screenshot.png" />
8
+
9
+
10
+ https://github.com/user-attachments/assets/5d1331e8-6d02-420b-b30a-dcbf838b1660
11
+
12
+
7
13
  ## Features
8
14
 
9
15
  - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
@@ -81,7 +87,16 @@ Completed results can be expanded (ctrl+o in pi) to show the full agent output i
81
87
 
82
88
  ## Custom Agents
83
89
 
84
- Define custom agent types by creating `.pi/agents/<name>.md` files. The filename becomes the agent type name.
90
+ Define custom agent types by creating `.md` files. The filename becomes the agent type name.
91
+
92
+ Custom agents are discovered from two locations (higher priority wins):
93
+
94
+ | Priority | Location | Scope |
95
+ |----------|----------|-------|
96
+ | 1 (highest) | `.pi/agents/<name>.md` | Project — per-repo agents |
97
+ | 2 | `~/.pi/agent/agents/<name>.md` | Global — available everywhere |
98
+
99
+ Project-level agents override global ones with the same name, so you can customize a global agent for a specific project.
85
100
 
86
101
  ### Example: `.pi/agents/auditor.md`
87
102
 
@@ -171,15 +186,27 @@ Send a steering message to a running agent. The message interrupts after the cur
171
186
 
172
187
  | Command | Description |
173
188
  |---------|-------------|
174
- | `/agent <type> <prompt>` | Spawn a sub-agent interactively |
175
- | `/agents` | List all agents with status tree |
189
+ | `/agents` | Interactive agent management menu |
190
+
191
+ The `/agents` command opens an interactive menu:
176
192
 
177
193
  ```
178
- /agent Explore Find all TypeScript files that handle authentication
179
- /agent Plan Design a caching layer for the API
180
- /agent auditor Review the payment processing module
194
+ Running agents (2) 1 running, 1 done ← only shown when agents exist
195
+ Custom agents (3) ← submenu: edit or delete agents
196
+ Create new agent ← manual wizard or AI-generated
197
+ Settings ← max concurrency, max turns, grace turns
198
+
199
+ Built-in (always available):
200
+ general-purpose · inherit
201
+ Explore · haiku
202
+ Plan · inherit
203
+ ...
181
204
  ```
182
205
 
206
+ - **Custom agents submenu** — select an agent to edit (opens editor) or delete
207
+ - **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do and a sub-agent writes the `.md` file)
208
+ - **Settings** — configure max concurrency, default max turns, and grace turns at runtime
209
+
183
210
  ## Graceful Max Turns
184
211
 
185
212
  Instead of hard-aborting at the turn limit, agents get a graceful shutdown:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "A pi extension providing autonomous sub-agents with Claude Code-style UI",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -23,10 +23,20 @@ import type { SubagentType, ThinkingLevel } from "./types.js";
23
23
  const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
24
24
 
25
25
  /** Default max turns to prevent subagents from looping indefinitely. */
26
- const DEFAULT_MAX_TURNS = 50;
26
+ let defaultMaxTurns = 50;
27
+
28
+ /** Get the default max turns value. */
29
+ export function getDefaultMaxTurns(): number { return defaultMaxTurns; }
30
+ /** Set the default max turns value (minimum 1). */
31
+ export function setDefaultMaxTurns(n: number): void { defaultMaxTurns = Math.max(1, n); }
27
32
 
28
33
  /** Additional turns allowed after the soft limit steer message. */
29
- const GRACE_TURNS = 5;
34
+ let graceTurns = 5;
35
+
36
+ /** Get the grace turns value. */
37
+ export function getGraceTurns(): number { return graceTurns; }
38
+ /** Set the grace turns value (minimum 1). */
39
+ export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
30
40
 
31
41
  /** Haiku model IDs to try for Explore agents (in preference order). */
32
42
  const HAIKU_MODEL_IDS = [
@@ -215,7 +225,7 @@ export async function runAgent(
215
225
 
216
226
  // Track turns for graceful max_turns enforcement
217
227
  let turnCount = 0;
218
- const maxTurns = options.maxTurns ?? customConfig?.maxTurns ?? DEFAULT_MAX_TURNS;
228
+ const maxTurns = options.maxTurns ?? customConfig?.maxTurns ?? defaultMaxTurns;
219
229
  let softLimitReached = false;
220
230
  let aborted = false;
221
231
 
@@ -226,7 +236,7 @@ export async function runAgent(
226
236
  if (!softLimitReached && turnCount >= maxTurns) {
227
237
  softLimitReached = true;
228
238
  session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
229
- } else if (softLimitReached && turnCount >= maxTurns + GRACE_TURNS) {
239
+ } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
230
240
  aborted = true;
231
241
  session.abort();
232
242
  }
@@ -1,30 +1,43 @@
1
1
  /**
2
- * custom-agents.ts — Load user-defined agents from .pi/agents/*.md files.
2
+ * custom-agents.ts — Load user-defined agents from project (.pi/agents/) and global (~/.pi/agent/agents/) locations.
3
3
  */
4
4
 
5
5
  import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
6
6
  import { readFileSync, readdirSync, existsSync } from "node:fs";
7
7
  import { join, basename } from "node:path";
8
+ import { homedir } from "node:os";
8
9
  import { SUBAGENT_TYPES, type CustomAgentConfig, type ThinkingLevel } from "./types.js";
9
10
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
10
11
 
11
12
  /**
12
- * Scan .pi/agents/*.md and return a map of custom agent configs.
13
- * Filename (without .md) becomes the agent name.
13
+ * Scan for custom agent .md files from multiple locations.
14
+ * Discovery hierarchy (higher priority wins):
15
+ * 1. Project: <cwd>/.pi/agents/*.md
16
+ * 2. Global: ~/.pi/agent/agents/*.md
17
+ *
18
+ * Project-level agents override global ones with the same name.
14
19
  */
15
20
  export function loadCustomAgents(cwd: string): Map<string, CustomAgentConfig> {
16
- const dir = join(cwd, ".pi", "agents");
17
- if (!existsSync(dir)) return new Map();
21
+ const globalDir = join(homedir(), ".pi", "agent", "agents");
22
+ const projectDir = join(cwd, ".pi", "agents");
23
+
24
+ const agents = new Map<string, CustomAgentConfig>();
25
+ loadFromDir(globalDir, agents); // lower priority
26
+ loadFromDir(projectDir, agents); // higher priority (overwrites)
27
+ return agents;
28
+ }
29
+
30
+ /** Load agent configs from a directory into the map. */
31
+ function loadFromDir(dir: string, agents: Map<string, CustomAgentConfig>): void {
32
+ if (!existsSync(dir)) return;
18
33
 
19
34
  let files: string[];
20
35
  try {
21
36
  files = readdirSync(dir).filter(f => f.endsWith(".md"));
22
37
  } catch {
23
- return new Map();
38
+ return;
24
39
  }
25
40
 
26
- const agents = new Map<string, CustomAgentConfig>();
27
-
28
41
  for (const file of files) {
29
42
  const name = basename(file, ".md");
30
43
  if ((SUBAGENT_TYPES as readonly string[]).includes(name)) continue;
@@ -54,8 +67,6 @@ export function loadCustomAgents(cwd: string): Map<string, CustomAgentConfig> {
54
67
  isolated: fm.isolated === true,
55
68
  });
56
69
  }
57
-
58
- return agents;
59
70
  }
60
71
 
61
72
  // ---- Field parsers ----
package/src/index.ts CHANGED
@@ -7,17 +7,19 @@
7
7
  * steer_subagent — LLM-callable: send a steering message to a running agent
8
8
  *
9
9
  * Commands:
10
- * /agent <type> <prompt> User-invocable agent spawning
11
- * /agents — List all agents with status
10
+ * /agents Interactive agent management menu
12
11
  */
13
12
 
14
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
14
+ import { existsSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
15
17
  import { Text } from "@mariozechner/pi-tui";
16
18
  import { Type } from "@sinclair/typebox";
17
19
  import { AgentManager } from "./agent-manager.js";
18
- import { steerAgent, getAgentConversation } from "./agent-runner.js";
20
+ import { steerAgent, getAgentConversation, getDefaultMaxTurns, setDefaultMaxTurns, getGraceTurns, setGraceTurns } from "./agent-runner.js";
19
21
  import { SUBAGENT_TYPES, type SubagentType, type ThinkingLevel, type CustomAgentConfig } from "./types.js";
20
- import { getConfig, getAvailableTypes, getCustomAgentNames, getCustomAgentConfig, isValidType, registerCustomAgents } from "./agent-types.js";
22
+ import { getAvailableTypes, getCustomAgentNames, getCustomAgentConfig, isValidType, registerCustomAgents, BUILTIN_TOOL_NAMES } from "./agent-types.js";
21
23
  import { loadCustomAgents } from "./custom-agents.js";
22
24
  import {
23
25
  AgentWidget,
@@ -256,7 +258,7 @@ export default function (pi: ExtensionAPI) {
256
258
  ...builtinDescs,
257
259
  ...(customDescs.length > 0 ? ["", "Custom types:", ...customDescs] : []),
258
260
  "",
259
- "Custom agents can be defined in .pi/agents/<name>.md — they are picked up automatically.",
261
+ "Custom agents can be defined in .pi/agents/<name>.md (project) or ~/.pi/agent/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones.",
260
262
  ].join("\n");
261
263
  };
262
264
 
@@ -295,7 +297,7 @@ Guidelines:
295
297
  description: "A short (3-5 word) description of the task (shown in UI).",
296
298
  }),
297
299
  subagent_type: Type.String({
298
- description: `The type of specialized agent to use. Built-in: ${SUBAGENT_TYPES.join(", ")}. Custom agents from .pi/agents/*.md are also available.`,
300
+ description: `The type of specialized agent to use. Built-in: ${SUBAGENT_TYPES.join(", ")}. Custom agents from .pi/agents/*.md (project) or ~/.pi/agent/agents/*.md (global) are also available.`,
299
301
  }),
300
302
  model: Type.Optional(
301
303
  Type.String({
@@ -717,139 +719,418 @@ Guidelines:
717
719
  },
718
720
  });
719
721
 
720
- // ---- /agent command ----
721
-
722
- pi.registerCommand("agent", {
723
- description: "Spawn a sub-agent: /agent <type> <prompt>",
724
- handler: async (args, ctx) => {
725
- const trimmed = args?.trim() ?? "";
726
-
727
- if (!trimmed) {
728
- const lines = [
729
- "Usage: /agent <type> <prompt>",
730
- "",
731
- "Agent types:",
732
- ...getAvailableTypes().map(
733
- (t) => ` ${t.padEnd(20)} ${getConfig(t).description}`,
734
- ),
735
- "",
736
- "Examples:",
737
- " /agent Explore Find all TypeScript files that handle authentication",
738
- " /agent Plan Design a caching layer for the API",
739
- " /agent general-purpose Refactor the auth module to use JWT",
740
- ];
741
- ctx.ui.notify(lines.join("\n"), "info");
742
- return;
743
- }
722
+ // ---- /agents interactive menu ----
744
723
 
745
- // Parse: first word is type, rest is prompt
746
- const spaceIdx = trimmed.indexOf(" ");
747
- if (spaceIdx === -1) {
748
- ctx.ui.notify(
749
- `Missing prompt. Usage: /agent <type> <prompt>\nTypes: ${getAvailableTypes().join(", ")}`,
750
- "warning",
751
- );
752
- return;
753
- }
724
+ const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
725
+ const personalAgentsDir = () => join(homedir(), ".pi", "agent", "agents");
754
726
 
755
- const typeName = trimmed.slice(0, spaceIdx);
756
- const prompt = trimmed.slice(spaceIdx + 1).trim();
727
+ /** Find the file path of a custom agent by name (project first, then global). */
728
+ function findAgentFile(name: string): { path: string; location: "project" | "personal" } | undefined {
729
+ const projectPath = join(projectAgentsDir(), `${name}.md`);
730
+ if (existsSync(projectPath)) return { path: projectPath, location: "project" };
731
+ const personalPath = join(personalAgentsDir(), `${name}.md`);
732
+ if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
733
+ return undefined;
734
+ }
757
735
 
758
- if (!isValidType(typeName)) {
759
- ctx.ui.notify(
760
- `Unknown agent type: "${typeName}"\nValid types: ${getAvailableTypes().join(", ")}`,
761
- "warning",
762
- );
763
- return;
764
- }
736
+ /** Model label for display: built-in types have known defaults, custom agents show their config. */
737
+ const BUILTIN_MODEL_LABELS: Record<string, string> = {
738
+ "general-purpose": "inherit",
739
+ "Explore": "haiku",
740
+ "Plan": "inherit",
741
+ "statusline-setup": "inherit",
742
+ "claude-code-guide": "inherit",
743
+ };
765
744
 
766
- if (!prompt) {
767
- ctx.ui.notify("Missing prompt.", "warning");
768
- return;
769
- }
745
+ function getModelLabel(type: string): string {
746
+ const builtin = BUILTIN_MODEL_LABELS[type];
747
+ if (builtin) return builtin;
748
+ const custom = getCustomAgentConfig(type);
749
+ if (custom?.model) {
750
+ // Show short form: "anthropic/claude-haiku-4-5-20251001" → "haiku"
751
+ const id = custom.model.toLowerCase();
752
+ if (id.includes("haiku")) return "haiku";
753
+ if (id.includes("sonnet")) return "sonnet";
754
+ if (id.includes("opus")) return "opus";
755
+ return custom.model;
756
+ }
757
+ return "inherit";
758
+ }
770
759
 
771
- const displayName = getDisplayName(typeName);
772
- ctx.ui.notify(`Spawning ${displayName} agent...`, "info");
760
+ async function showAgentsMenu(ctx: ExtensionCommandContext) {
761
+ reloadCustomAgents();
762
+ const customNames = getCustomAgentNames();
773
763
 
774
- const customConfig = getCustomAgentConfig(typeName);
775
- const { systemPromptOverride, systemPromptAppend } = resolveCustomPrompt(customConfig);
764
+ // Build select options
765
+ const options: string[] = [];
776
766
 
777
- const record = await manager.spawnAndWait(pi, ctx, typeName, prompt, {
778
- description: prompt.slice(0, 40),
779
- thinkingLevel: customConfig?.thinking,
780
- systemPromptOverride,
781
- systemPromptAppend,
782
- });
767
+ // Running agents entry (only if there are active agents)
768
+ const agents = manager.listAgents();
769
+ if (agents.length > 0) {
770
+ const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
771
+ const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
772
+ options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
773
+ }
783
774
 
784
- if (record.status === "error") {
785
- ctx.ui.notify(`Agent failed: ${record.error}`, "warning");
786
- return;
787
- }
775
+ // Custom agents submenu (only if there are custom agents)
776
+ if (customNames.length > 0) {
777
+ options.push(`Custom agents (${customNames.length})`);
778
+ }
788
779
 
789
- const duration = formatDuration(record.startedAt, record.completedAt);
790
- const statusNote = getStatusNote(record.status);
791
-
792
- // Send the result as a message so it appears in the conversation
793
- pi.sendMessage(
794
- {
795
- customType: "agent-result",
796
- content: [
797
- {
798
- type: "text",
799
- text:
800
- `**${displayName}** agent completed in ${duration} (${record.toolUses} tool uses)${statusNote}\n\n` +
801
- (record.result ?? "No output."),
802
- },
803
- ],
804
- display: true,
805
- },
806
- { triggerTurn: false },
807
- );
808
- },
809
- });
780
+ // Actions
781
+ options.push("Create new agent");
782
+ options.push("Settings");
810
783
 
811
- // ---- /agents command ----
784
+ // Show built-in types below the select as informational text (like Claude does)
785
+ const maxBuiltin = Math.max(...SUBAGENT_TYPES.map(t => t.length));
786
+ const builtinLines = SUBAGENT_TYPES.map(t => {
787
+ const model = BUILTIN_MODEL_LABELS[t] ?? "inherit";
788
+ return ` ${t.padEnd(maxBuiltin)} · ${model}`;
789
+ });
812
790
 
813
- pi.registerCommand("agents", {
814
- description: "List all agents with status",
815
- handler: async (_args, ctx) => {
816
- const agents = manager.listAgents();
791
+ const noAgentsMsg = customNames.length === 0 && agents.length === 0
792
+ ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
793
+ "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
794
+ "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
795
+ : "";
796
+
797
+ ctx.ui.notify(
798
+ `${noAgentsMsg}Built-in (always available):\n${builtinLines.join("\n")}`,
799
+ "info",
800
+ );
801
+
802
+ const choice = await ctx.ui.select("Agents", options);
803
+ if (!choice) return;
804
+
805
+ if (choice.startsWith("Running agents (")) {
806
+ await showRunningAgents(ctx);
807
+ await showAgentsMenu(ctx);
808
+ } else if (choice.startsWith("Custom agents (")) {
809
+ await showCustomAgentsList(ctx);
810
+ await showAgentsMenu(ctx);
811
+ } else if (choice === "Create new agent") {
812
+ await showCreateWizard(ctx);
813
+ } else if (choice === "Settings") {
814
+ await showSettings(ctx);
815
+ await showAgentsMenu(ctx);
816
+ }
817
+ }
818
+
819
+ async function showCustomAgentsList(ctx: ExtensionCommandContext) {
820
+ const customNames = getCustomAgentNames();
821
+ if (customNames.length === 0) {
822
+ ctx.ui.notify("No custom agents.", "info");
823
+ return;
824
+ }
825
+
826
+ // Compute max width of "name · model" for alignment
827
+ const entries = customNames.map(name => {
828
+ const cfg = getCustomAgentConfig(name);
829
+ const model = getModelLabel(name);
830
+ const prefix = `${name} · ${model}`;
831
+ return { prefix, desc: cfg?.description ?? name };
832
+ });
833
+ const maxPrefix = Math.max(...entries.map(e => e.prefix.length));
817
834
 
818
- if (agents.length === 0) {
819
- ctx.ui.notify("No agents have been spawned yet.", "info");
820
- return;
835
+ const options = entries.map(({ prefix, desc }) =>
836
+ `${prefix.padEnd(maxPrefix)} ${desc}`,
837
+ );
838
+
839
+ const choice = await ctx.ui.select("Custom agents", options);
840
+ if (!choice) return;
841
+
842
+ const agentName = choice.split(" · ")[0];
843
+ if (getCustomAgentConfig(agentName)) {
844
+ await showAgentDetail(ctx, agentName);
845
+ }
846
+ }
847
+
848
+ async function showRunningAgents(ctx: ExtensionCommandContext) {
849
+ const agents = manager.listAgents();
850
+ if (agents.length === 0) {
851
+ ctx.ui.notify("No agents.", "info");
852
+ return;
853
+ }
854
+
855
+ // Show as a selectable list for potential future actions
856
+ const options = agents.map(a => {
857
+ const dn = getDisplayName(a.type);
858
+ const dur = formatDuration(a.startedAt, a.completedAt);
859
+ return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
860
+ });
861
+
862
+ await ctx.ui.select("Running agents", options);
863
+ }
864
+
865
+ async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
866
+ const file = findAgentFile(name);
867
+ if (!file) {
868
+ ctx.ui.notify(`Agent file not found for "${name}".`, "warning");
869
+ return;
870
+ }
871
+
872
+ const choice = await ctx.ui.select(name, ["Edit", "Delete", "Back"]);
873
+ if (!choice || choice === "Back") return;
874
+
875
+ if (choice === "Edit") {
876
+ const content = readFileSync(file.path, "utf-8");
877
+ const edited = await ctx.ui.editor(`Edit ${name}`, content);
878
+ if (edited !== undefined && edited !== content) {
879
+ const { writeFileSync } = await import("node:fs");
880
+ writeFileSync(file.path, edited, "utf-8");
881
+ reloadCustomAgents();
882
+ ctx.ui.notify(`Updated ${file.path}`, "info");
821
883
  }
884
+ } else if (choice === "Delete") {
885
+ const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
886
+ if (confirmed) {
887
+ unlinkSync(file.path);
888
+ reloadCustomAgents();
889
+ ctx.ui.notify(`Deleted ${file.path}`, "info");
890
+ }
891
+ }
892
+ }
822
893
 
823
- const lines: string[] = [];
824
- const counts: Record<string, number> = {};
825
- for (const a of agents) counts[a.status] = (counts[a.status] ?? 0) + 1;
894
+ async function showCreateWizard(ctx: ExtensionCommandContext) {
895
+ const location = await ctx.ui.select("Choose location", [
896
+ "Project (.pi/agents/)",
897
+ "Personal (~/.pi/agent/agents/)",
898
+ ]);
899
+ if (!location) return;
900
+
901
+ const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
902
+
903
+ const method = await ctx.ui.select("Creation method", [
904
+ "Generate with Claude (recommended)",
905
+ "Manual configuration",
906
+ ]);
907
+ if (!method) return;
908
+
909
+ if (method.startsWith("Generate")) {
910
+ await showGenerateWizard(ctx, targetDir);
911
+ } else {
912
+ await showManualWizard(ctx, targetDir);
913
+ }
914
+ }
826
915
 
827
- lines.push(
828
- `${agents.length} agent(s): ${counts.running ?? 0} running, ${(counts.completed ?? 0) + (counts.steered ?? 0)} completed, ${counts.stopped ?? 0} stopped, ${counts.aborted ?? 0} aborted, ${counts.error ?? 0} errored`,
829
- );
830
- lines.push("");
916
+ async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) {
917
+ const description = await ctx.ui.input("Describe what this agent should do");
918
+ if (!description) return;
831
919
 
832
- for (let i = 0; i < agents.length; i++) {
833
- const a = agents[i];
834
- const connector = i === agents.length - 1 ? "└─" : "├─";
835
- const displayName = getDisplayName(a.type);
836
- const duration = formatDuration(a.startedAt, a.completedAt);
920
+ const name = await ctx.ui.input("Agent name (filename, no spaces)");
921
+ if (!name) return;
837
922
 
838
- lines.push(
839
- `${connector} ${displayName} (${a.description}) · ${a.toolUses} tool uses · ${a.status} · ${duration}`,
840
- );
923
+ // Validate name
924
+ if (isValidType(name) && !getCustomAgentConfig(name)) {
925
+ ctx.ui.notify(`"${name}" conflicts with a built-in agent type.`, "warning");
926
+ return;
927
+ }
928
+
929
+ if (!mkdirSync(targetDir, { recursive: true }) && !existsSync(targetDir)) {
930
+ mkdirSync(targetDir, { recursive: true });
931
+ }
932
+
933
+ const targetPath = join(targetDir, `${name}.md`);
934
+ if (existsSync(targetPath)) {
935
+ const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
936
+ if (!overwrite) return;
937
+ }
938
+
939
+ ctx.ui.notify("Generating agent definition...", "info");
940
+
941
+ const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
942
+
943
+ Write a markdown file to: ${targetPath}
944
+
945
+ The file format is a markdown file with YAML frontmatter and a system prompt body:
946
+
947
+ \`\`\`markdown
948
+ ---
949
+ description: <one-line description shown in UI>
950
+ tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
951
+ model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
952
+ thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
953
+ max_turns: <optional max agentic turns, default 50. Omit for default>
954
+ prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
955
+ extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
956
+ skills: <true (inherit all), false (none). Default: true>
957
+ inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
958
+ run_in_background: <true to run in background by default. Default: false>
959
+ isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
960
+ ---
961
+
962
+ <system prompt body — instructions for the agent>
963
+ \`\`\`
964
+
965
+ Guidelines for choosing settings:
966
+ - For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
967
+ - For code modification tasks: include edit, write
968
+ - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
969
+ - Use prompt_mode: replace for fully custom agents with their own personality/instructions
970
+ - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
971
+ - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
972
+ - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
973
+
974
+ Write the file using the write tool. Only write the file, nothing else.`;
975
+
976
+ const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
977
+ description: `Generate ${name} agent`,
978
+ maxTurns: 5,
979
+ });
980
+
981
+ if (record.status === "error") {
982
+ ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
983
+ return;
984
+ }
985
+
986
+ reloadCustomAgents();
987
+
988
+ if (existsSync(targetPath)) {
989
+ ctx.ui.notify(`Created ${targetPath}`, "info");
990
+ } else {
991
+ ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
992
+ }
993
+ }
994
+
995
+ async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) {
996
+ // 1. Name
997
+ const name = await ctx.ui.input("Agent name (filename, no spaces)");
998
+ if (!name) return;
999
+
1000
+ if (isValidType(name) && !getCustomAgentConfig(name)) {
1001
+ ctx.ui.notify(`"${name}" conflicts with a built-in agent type.`, "warning");
1002
+ return;
1003
+ }
1004
+
1005
+ // 2. Description
1006
+ const description = await ctx.ui.input("Description (one line)");
1007
+ if (!description) return;
1008
+
1009
+ // 3. Tools
1010
+ const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]);
1011
+ if (!toolChoice) return;
1012
+
1013
+ let tools: string;
1014
+ if (toolChoice === "all") {
1015
+ tools = BUILTIN_TOOL_NAMES.join(", ");
1016
+ } else if (toolChoice === "none") {
1017
+ tools = "none";
1018
+ } else if (toolChoice.startsWith("read-only")) {
1019
+ tools = "read, bash, grep, find, ls";
1020
+ } else {
1021
+ const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", "));
1022
+ if (!customTools) return;
1023
+ tools = customTools;
1024
+ }
1025
+
1026
+ // 4. Model
1027
+ const modelChoice = await ctx.ui.select("Model", [
1028
+ "inherit (parent model)",
1029
+ "haiku",
1030
+ "sonnet",
1031
+ "opus",
1032
+ "custom...",
1033
+ ]);
1034
+ if (!modelChoice) return;
1035
+
1036
+ let modelLine = "";
1037
+ if (modelChoice === "haiku") modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001";
1038
+ else if (modelChoice === "sonnet") modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
1039
+ else if (modelChoice === "opus") modelLine = "\nmodel: anthropic/claude-opus-4-6";
1040
+ else if (modelChoice === "custom...") {
1041
+ const customModel = await ctx.ui.input("Model (provider/modelId)");
1042
+ if (customModel) modelLine = `\nmodel: ${customModel}`;
1043
+ }
1044
+
1045
+ // 5. Thinking
1046
+ const thinkingChoice = await ctx.ui.select("Thinking level", [
1047
+ "inherit",
1048
+ "off",
1049
+ "minimal",
1050
+ "low",
1051
+ "medium",
1052
+ "high",
1053
+ "xhigh",
1054
+ ]);
1055
+ if (!thinkingChoice) return;
1056
+
1057
+ let thinkingLine = "";
1058
+ if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
1059
+
1060
+ // 6. System prompt
1061
+ const systemPrompt = await ctx.ui.editor("System prompt", "");
1062
+ if (systemPrompt === undefined) return;
1063
+
1064
+ // Build the file
1065
+ const content = `---
1066
+ description: ${description}
1067
+ tools: ${tools}${modelLine}${thinkingLine}
1068
+ prompt_mode: replace
1069
+ ---
1070
+
1071
+ ${systemPrompt}
1072
+ `;
1073
+
1074
+ mkdirSync(targetDir, { recursive: true });
1075
+ const targetPath = join(targetDir, `${name}.md`);
1076
+
1077
+ if (existsSync(targetPath)) {
1078
+ const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
1079
+ if (!overwrite) return;
1080
+ }
1081
+
1082
+ const { writeFileSync } = await import("node:fs");
1083
+ writeFileSync(targetPath, content, "utf-8");
1084
+ reloadCustomAgents();
1085
+ ctx.ui.notify(`Created ${targetPath}`, "info");
1086
+ }
841
1087
 
842
- if (a.status === "error" && a.error) {
843
- const indent = i === agents.length - 1 ? " " : "│ ";
844
- lines.push(`${indent} ⎿ Error: ${a.error.slice(0, 100)}`);
1088
+ async function showSettings(ctx: ExtensionCommandContext) {
1089
+ const choice = await ctx.ui.select("Settings", [
1090
+ `Max concurrency (current: ${manager.getMaxConcurrent()})`,
1091
+ `Default max turns (current: ${getDefaultMaxTurns()})`,
1092
+ `Grace turns (current: ${getGraceTurns()})`,
1093
+ ]);
1094
+ if (!choice) return;
1095
+
1096
+ if (choice.startsWith("Max concurrency")) {
1097
+ const val = await ctx.ui.input("Max concurrent background agents", String(manager.getMaxConcurrent()));
1098
+ if (val) {
1099
+ const n = parseInt(val, 10);
1100
+ if (n >= 1) {
1101
+ manager.setMaxConcurrent(n);
1102
+ ctx.ui.notify(`Max concurrency set to ${n}`, "info");
1103
+ } else {
1104
+ ctx.ui.notify("Must be a positive integer.", "warning");
845
1105
  }
846
- if (a.session) {
847
- const indent = i === agents.length - 1 ? " " : "│ ";
848
- lines.push(`${indent} ⎿ ID: ${a.id} (resumable)`);
1106
+ }
1107
+ } else if (choice.startsWith("Default max turns")) {
1108
+ const val = await ctx.ui.input("Default max turns before wrap-up", String(getDefaultMaxTurns()));
1109
+ if (val) {
1110
+ const n = parseInt(val, 10);
1111
+ if (n >= 1) {
1112
+ setDefaultMaxTurns(n);
1113
+ ctx.ui.notify(`Default max turns set to ${n}`, "info");
1114
+ } else {
1115
+ ctx.ui.notify("Must be a positive integer.", "warning");
849
1116
  }
850
1117
  }
1118
+ } else if (choice.startsWith("Grace turns")) {
1119
+ const val = await ctx.ui.input("Grace turns after wrap-up steer", String(getGraceTurns()));
1120
+ if (val) {
1121
+ const n = parseInt(val, 10);
1122
+ if (n >= 1) {
1123
+ setGraceTurns(n);
1124
+ ctx.ui.notify(`Grace turns set to ${n}`, "info");
1125
+ } else {
1126
+ ctx.ui.notify("Must be a positive integer.", "warning");
1127
+ }
1128
+ }
1129
+ }
1130
+ }
851
1131
 
852
- ctx.ui.notify(lines.join("\n"), "info");
853
- },
1132
+ pi.registerCommand("agents", {
1133
+ description: "Manage agents",
1134
+ handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
854
1135
  });
855
1136
  }