pi-subagents 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.0] - 2026-02-09
6
+
7
+ ### Added
8
+ - **Management mode for `subagent` tool** via `action` field — the LLM can now discover, create, modify, and delete agent/chain definitions at runtime without manual file editing or restarts. Five actions:
9
+ - `list` — discover agents and chains with scope + description
10
+ - `get` — full detail for agent or chain, including path and system prompt/steps
11
+ - `create` — create agent (`.md`) or chain (`.chain.md`) definitions from `config`; immediately usable
12
+ - `update` — merge-update agent or chain fields, including rename with chain reference warnings
13
+ - `delete` — remove agent or chain definitions with dangling reference warnings
14
+ - **New `agent-management.ts` module** with all management handlers, validation, and serialization helpers
15
+ - **New management params** in tool schema: `action`, `chainName`, `config`
16
+ - **Agent/chain CRUD safeguards**
17
+ - Name sanitization (lowercase-hyphenated) for create/rename
18
+ - Scope-aware uniqueness checks across agents and chains
19
+ - File-path collision checks to prevent overwriting non-agent markdown files
20
+ - Scope disambiguation for update/delete when names exist in both user and project scope
21
+ - Not-found errors include available names for fast self-correction
22
+ - Per-step validation warnings for model registry and skill availability
23
+ - Validate-then-mutate ordering — all validation completes before any filesystem mutations
24
+ - **Config field mapping**: `tools` (comma-separated with `mcp:` prefix support), `reads` -> `defaultReads`, `progress` -> `defaultProgress`
25
+ - **Uniform field clearing** — all optional string fields accept both `false` and `""` to clear
26
+ - **JSON string parsing for `config` param** — handles `Type.Any()` delivering objects as JSON strings through the tool framework
27
+
5
28
  ## [0.7.0] - 2026-02-09
6
29
 
7
30
  ### Added
package/README.md CHANGED
@@ -218,6 +218,7 @@ Chains can be created from the Agents Manager template picker ("Blank Chain"), o
218
218
 
219
219
  - **Slash Commands**: `/run`, `/chain`, `/parallel` with tab-completion and live progress
220
220
  - **Agents Manager Overlay**: Browse, view, edit, create, delete, and launch agents/chains from a TUI (`Ctrl+Shift+A`)
221
+ - **Management Actions**: LLM can list, inspect, create, update, and delete agent/chain definitions via `action` field
221
222
  - **Chain Files**: Reusable `.chain.md` files with per-step config, saveable from the clarify TUI
222
223
  - **Multi-select & Parallel**: Select agents in the overlay, launch as chain or parallel
223
224
  - **Run History**: Per-agent JSONL recording of task, exit code, duration; shown on detail screen
@@ -399,14 +400,78 @@ Skills are specialized instructions loaded from SKILL.md files and injected into
399
400
  { dir: "/tmp/pi-async-subagent-runs/a53ebe46-..." }
400
401
  ```
401
402
 
403
+ ## Management Actions
404
+
405
+ Agent definitions are not loaded into LLM context by default. Management actions let the LLM discover, inspect, create, and modify agent and chain definitions at runtime through the `subagent` tool — no manual file editing or restart required. Newly created agents are immediately usable in the same session. Set `action` and omit execution payloads (`task`, `chain`, `tasks`).
406
+
407
+ ```typescript
408
+ // Discover all agents and chains (management defaults to both scopes)
409
+ { action: "list" }
410
+ { action: "list", agentScope: "project" }
411
+
412
+ // Inspect one agent or chain (searches both scopes)
413
+ { action: "get", agent: "scout" }
414
+ { action: "get", chainName: "review-pipeline" }
415
+
416
+ // Create agent
417
+ { action: "create", config: {
418
+ name: "Code Scout",
419
+ description: "Scans codebases for patterns and issues",
420
+ scope: "user",
421
+ systemPrompt: "You are a code scout...",
422
+ model: "anthropic/claude-sonnet-4",
423
+ tools: "read, bash, mcp:github/search_repositories",
424
+ skills: "parallel-scout",
425
+ thinking: "high",
426
+ output: "context.md",
427
+ reads: "shared-context.md",
428
+ progress: true
429
+ }}
430
+
431
+ // Create chain (presence of steps creates .chain.md)
432
+ { action: "create", config: {
433
+ name: "review-pipeline",
434
+ description: "Scout then review",
435
+ scope: "project",
436
+ steps: [
437
+ { agent: "scout", task: "Scan {task}", output: "context.md" },
438
+ { agent: "reviewer", task: "Review {previous}", reads: ["context.md"] }
439
+ ]
440
+ }}
441
+
442
+ // Update agent fields (merge semantics)
443
+ { action: "update", agent: "scout", config: { model: "openai/gpt-4o" } }
444
+ { action: "update", agent: "scout", config: { output: false, skills: "" } } // clear optional fields
445
+ { action: "update", chainName: "review-pipeline", config: {
446
+ steps: [
447
+ { agent: "scout", task: "Scan {task}", output: "context.md" },
448
+ { agent: "reviewer", task: "Improved review of {previous}", reads: ["context.md"] }
449
+ ]
450
+ }}
451
+
452
+ // Delete definitions
453
+ { action: "delete", agent: "scout" }
454
+ { action: "delete", chainName: "review-pipeline" }
455
+ ```
456
+
457
+ Notes:
458
+ - `create` uses `config.scope` (`"user"` or `"project"`), not `agentScope`.
459
+ - `update`/`delete` use `agentScope` only for scope disambiguation when the same name exists in both scopes.
460
+ - Agent config mapping: `reads -> defaultReads`, `progress -> defaultProgress`, and `tools` supports `mcp:` entries that map to direct MCP tools.
461
+ - To clear any optional field, set it to `false` or `""` (e.g., `{ model: false }` or `{ skills: "" }`). Both work for all string-typed fields.
462
+
402
463
  ## Parameters
403
464
 
404
465
  | Param | Type | Default | Description |
405
466
  |-------|------|---------|-------------|
406
- | `agent` | string | - | Agent name (single mode) |
467
+ | `agent` | string | - | Agent name (single mode) or target for management get/update/delete |
407
468
  | `task` | string | - | Task string (single mode) |
469
+ | `action` | string | - | Management action: `list`, `get`, `create`, `update`, `delete` |
470
+ | `chainName` | string | - | Chain name for management get/update/delete |
471
+ | `config` | object | - | Agent or chain config for management create/update |
408
472
  | `output` | `string \| false` | agent default | Override output file for single agent |
409
473
  | `skill` | `string \| string[] \| false` | agent default | Override skills (comma-separated string, array, or false to disable) |
474
+ | `model` | string | agent default | Override model for single agent |
410
475
  | `tasks` | `{agent, task, cwd?, skill?}[]` | - | Parallel tasks (sync only) |
411
476
  | `chain` | ChainItem[] | - | Sequential steps with behavior overrides (see below) |
412
477
  | `clarify` | boolean | true (chains) | Show TUI to preview/edit chain; implies sync mode |
@@ -584,6 +649,7 @@ Legacy events (still emitted):
584
649
  ├── agent-manager-edit.ts # Edit screen (pickers, prompt editor)
585
650
  ├── agent-manager-parallel.ts # Parallel builder screen (slot management, agent picker)
586
651
  ├── agent-manager-chain-detail.ts # Chain detail screen (flow visualization)
652
+ ├── agent-management.ts # Management action handlers (list, get, create, update, delete)
587
653
  ├── agent-serializer.ts # Serialize agents to markdown frontmatter
588
654
  ├── agent-templates.ts # Agent/chain creation templates
589
655
  ├── render-helpers.ts # Shared pad/row/header/footer helpers
@@ -0,0 +1,502 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
4
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import {
6
+ type AgentConfig,
7
+ type AgentScope,
8
+ type ChainConfig,
9
+ type ChainStepConfig,
10
+ discoverAgentsAll,
11
+ } from "./agents.js";
12
+ import { serializeAgent } from "./agent-serializer.js";
13
+ import { serializeChain } from "./chain-serializer.js";
14
+ import { discoverAvailableSkills } from "./skills.js";
15
+ import type { Details } from "./types.js";
16
+
17
+ type ManagementAction = "list" | "get" | "create" | "update" | "delete";
18
+ type ManagementScope = "user" | "project";
19
+ type ManagementContext = Pick<ExtensionContext, "cwd" | "modelRegistry">;
20
+
21
+ interface ManagementParams {
22
+ action?: string;
23
+ agent?: string;
24
+ chainName?: string;
25
+ agentScope?: string;
26
+ config?: unknown;
27
+ }
28
+
29
+ function result(text: string, isError = false): AgentToolResult<Details> {
30
+ return { content: [{ type: "text", text }], isError, details: { mode: "management", results: [] } };
31
+ }
32
+
33
+ function parseCsv(value: string): string[] {
34
+ return [...new Set(value.split(",").map((v) => v.trim()).filter(Boolean))];
35
+ }
36
+
37
+ function configObject(config: unknown): Record<string, unknown> | undefined {
38
+ let val = config;
39
+ if (typeof val === "string") {
40
+ try { val = JSON.parse(val); } catch { return undefined; }
41
+ }
42
+ if (!val || typeof val !== "object" || Array.isArray(val)) return undefined;
43
+ return val as Record<string, unknown>;
44
+ }
45
+
46
+ function hasKey(obj: Record<string, unknown>, key: string): boolean {
47
+ return Object.prototype.hasOwnProperty.call(obj, key);
48
+ }
49
+
50
+ function asDisambiguationScope(scope: unknown): ManagementScope | undefined {
51
+ if (scope === "user" || scope === "project") return scope;
52
+ return undefined;
53
+ }
54
+
55
+ function normalizeListScope(scope: unknown): AgentScope | undefined {
56
+ if (scope === undefined) return "both";
57
+ if (scope === "user" || scope === "project" || scope === "both") return scope;
58
+ return undefined;
59
+ }
60
+
61
+ export function sanitizeName(name: string): string {
62
+ return name.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
63
+ }
64
+
65
+ function availableNames(cwd: string, kind: "agent" | "chain"): string[] {
66
+ const d = discoverAgentsAll(cwd);
67
+ const items = kind === "agent" ? [...d.user, ...d.project] : d.chains;
68
+ return [...new Set(items.map((x) => x.name))].sort((a, b) => a.localeCompare(b));
69
+ }
70
+
71
+ export function findAgents(name: string, cwd: string, scope: AgentScope = "both"): AgentConfig[] {
72
+ const d = discoverAgentsAll(cwd);
73
+ const raw = name.trim();
74
+ const sanitized = sanitizeName(raw);
75
+ return [...d.user, ...d.project]
76
+ .filter((a) => (scope === "both" || a.source === scope) && (a.name === raw || a.name === sanitized))
77
+ .sort((a, b) => a.source.localeCompare(b.source));
78
+ }
79
+
80
+ export function findChains(name: string, cwd: string, scope: AgentScope = "both"): ChainConfig[] {
81
+ const raw = name.trim();
82
+ const sanitized = sanitizeName(raw);
83
+ return discoverAgentsAll(cwd).chains
84
+ .filter((c) => (scope === "both" || c.source === scope) && (c.name === raw || c.name === sanitized))
85
+ .sort((a, b) => a.source.localeCompare(b.source));
86
+ }
87
+
88
+ function nameExistsInScope(cwd: string, scope: ManagementScope, name: string, excludePath?: string): boolean {
89
+ const d = discoverAgentsAll(cwd);
90
+ for (const a of scope === "user" ? d.user : d.project) {
91
+ if (a.name === name && a.filePath !== excludePath) return true;
92
+ }
93
+ for (const c of d.chains) {
94
+ if (c.source === scope && c.name === name && c.filePath !== excludePath) return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ function unknownChainAgents(cwd: string, steps: ChainStepConfig[]): string[] {
100
+ const d = discoverAgentsAll(cwd);
101
+ const known = new Set([...d.user, ...d.project].map((a) => a.name));
102
+ return [...new Set(steps.map((s) => s.agent).filter((a) => !known.has(a)))].sort((a, b) => a.localeCompare(b));
103
+ }
104
+
105
+ function chainStepWarnings(ctx: ManagementContext, steps: ChainStepConfig[]): string[] {
106
+ const warnings: string[] = [];
107
+ const available = new Set(discoverAvailableSkills(ctx.cwd).map((s) => s.name));
108
+ for (let i = 0; i < steps.length; i++) {
109
+ const s = steps[i]!;
110
+ if (s.model) {
111
+ const found = ctx.modelRegistry.getAvailable().some((m) => `${m.provider}/${m.id}` === s.model || m.id === s.model);
112
+ if (!found) warnings.push(`Warning: step ${i + 1} (${s.agent}): model '${s.model}' is not in the current model registry.`);
113
+ }
114
+ if (Array.isArray(s.skills) && s.skills.length > 0) {
115
+ const missing = s.skills.filter((sk) => !available.has(sk));
116
+ if (missing.length) warnings.push(`Warning: step ${i + 1} (${s.agent}): skills not found: ${missing.join(", ")}.`);
117
+ }
118
+ }
119
+ return warnings;
120
+ }
121
+
122
+ function modelWarning(ctx: ManagementContext, model: string | undefined): string | undefined {
123
+ if (!model) return undefined;
124
+ const found = ctx.modelRegistry.getAvailable().some((m) => `${m.provider}/${m.id}` === model || m.id === model);
125
+ return found ? undefined : `Warning: model '${model}' is not in the current model registry.`;
126
+ }
127
+
128
+ function skillsWarning(cwd: string, skills: string[] | undefined): string | undefined {
129
+ if (!skills || skills.length === 0) return undefined;
130
+ const available = new Set(discoverAvailableSkills(cwd).map((s) => s.name));
131
+ const missing = skills.filter((s) => !available.has(s));
132
+ return missing.length ? `Warning: skills not found: ${missing.join(", ")}.` : undefined;
133
+ }
134
+
135
+ function parseStepList(raw: unknown): { steps?: ChainStepConfig[]; error?: string } {
136
+ if (!Array.isArray(raw)) return { error: "config.steps must be an array." };
137
+ if (raw.length === 0) return { error: "config.steps must include at least one step." };
138
+ const steps: ChainStepConfig[] = [];
139
+ for (let i = 0; i < raw.length; i++) {
140
+ const item = raw[i];
141
+ if (!item || typeof item !== "object" || Array.isArray(item)) return { error: `config.steps[${i}] must be an object.` };
142
+ const s = item as Record<string, unknown>;
143
+ if (typeof s.agent !== "string" || !s.agent.trim()) return { error: `config.steps[${i}].agent must be a non-empty string.` };
144
+ const step: ChainStepConfig = { agent: s.agent.trim(), task: typeof s.task === "string" ? s.task : "" };
145
+ if (hasKey(s, "output")) {
146
+ if (s.output === false) step.output = false;
147
+ else if (typeof s.output === "string") step.output = s.output;
148
+ else return { error: `config.steps[${i}].output must be a string or false.` };
149
+ }
150
+ if (hasKey(s, "reads")) {
151
+ if (s.reads === false) step.reads = false;
152
+ else if (Array.isArray(s.reads)) step.reads = s.reads.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean);
153
+ else return { error: `config.steps[${i}].reads must be an array or false.` };
154
+ }
155
+ if (hasKey(s, "model")) {
156
+ if (typeof s.model === "string") step.model = s.model;
157
+ else return { error: `config.steps[${i}].model must be a string.` };
158
+ }
159
+ if (hasKey(s, "skills")) {
160
+ if (s.skills === false) step.skills = false;
161
+ else if (Array.isArray(s.skills)) step.skills = s.skills.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean);
162
+ else return { error: `config.steps[${i}].skills must be an array or false.` };
163
+ }
164
+ if (hasKey(s, "progress")) {
165
+ if (typeof s.progress === "boolean") step.progress = s.progress;
166
+ else return { error: `config.steps[${i}].progress must be a boolean.` };
167
+ }
168
+ steps.push(step);
169
+ }
170
+ return { steps };
171
+ }
172
+
173
+ function parseTools(raw: string): { tools?: string[]; mcpDirectTools?: string[] } {
174
+ const tools: string[] = [];
175
+ const mcpDirectTools: string[] = [];
176
+ for (const item of parseCsv(raw)) {
177
+ if (item.startsWith("mcp:")) {
178
+ const direct = item.slice(4).trim();
179
+ if (direct) mcpDirectTools.push(direct);
180
+ } else tools.push(item);
181
+ }
182
+ return { tools: tools.length ? tools : undefined, mcpDirectTools: mcpDirectTools.length ? mcpDirectTools : undefined };
183
+ }
184
+
185
+ function applyAgentConfig(target: AgentConfig, cfg: Record<string, unknown>): string | undefined {
186
+ if (hasKey(cfg, "systemPrompt")) {
187
+ if (cfg.systemPrompt === false || cfg.systemPrompt === "") target.systemPrompt = "";
188
+ else if (typeof cfg.systemPrompt === "string") target.systemPrompt = cfg.systemPrompt;
189
+ else return "config.systemPrompt must be a string or false when provided.";
190
+ }
191
+ if (hasKey(cfg, "model")) {
192
+ if (cfg.model === false || cfg.model === "") target.model = undefined;
193
+ else if (typeof cfg.model === "string") target.model = cfg.model.trim() || undefined;
194
+ else return "config.model must be a string or false when provided.";
195
+ }
196
+ if (hasKey(cfg, "tools")) {
197
+ if (cfg.tools === false || cfg.tools === "") { target.tools = undefined; target.mcpDirectTools = undefined; }
198
+ else if (typeof cfg.tools === "string") { const parsed = parseTools(cfg.tools); target.tools = parsed.tools; target.mcpDirectTools = parsed.mcpDirectTools; }
199
+ else return "config.tools must be a comma-separated string or false when provided.";
200
+ }
201
+ if (hasKey(cfg, "skills")) {
202
+ if (cfg.skills === false || cfg.skills === "") target.skills = undefined;
203
+ else if (typeof cfg.skills === "string") { const skills = parseCsv(cfg.skills); target.skills = skills.length ? skills : undefined; }
204
+ else return "config.skills must be a comma-separated string or false when provided.";
205
+ }
206
+ if (hasKey(cfg, "thinking")) {
207
+ if (cfg.thinking === false || cfg.thinking === "") target.thinking = undefined;
208
+ else if (typeof cfg.thinking === "string") target.thinking = cfg.thinking.trim() || undefined;
209
+ else return "config.thinking must be a string or false when provided.";
210
+ }
211
+ if (hasKey(cfg, "output")) {
212
+ if (cfg.output === false || cfg.output === "") target.output = undefined;
213
+ else if (typeof cfg.output === "string") target.output = cfg.output;
214
+ else return "config.output must be a string or false when provided.";
215
+ }
216
+ if (hasKey(cfg, "reads")) {
217
+ if (cfg.reads === false || cfg.reads === "") target.defaultReads = undefined;
218
+ else if (typeof cfg.reads === "string") {
219
+ const reads = parseCsv(cfg.reads);
220
+ target.defaultReads = reads.length ? reads : undefined;
221
+ } else return "config.reads must be a comma-separated string or false when provided.";
222
+ }
223
+ if (hasKey(cfg, "progress")) {
224
+ if (typeof cfg.progress !== "boolean") return "config.progress must be a boolean when provided.";
225
+ target.defaultProgress = cfg.progress;
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ function resolveTarget<T extends { source: "user" | "project"; filePath: string }>(
231
+ kind: "agent" | "chain",
232
+ name: string,
233
+ matches: T[],
234
+ cwd: string,
235
+ scopeHint?: string,
236
+ ): T | AgentToolResult<Details> {
237
+ if (matches.length === 0) {
238
+ const available = availableNames(cwd, kind);
239
+ return result(`${kind === "agent" ? "Agent" : "Chain"} '${name}' not found. Available: ${available.join(", ") || "none"}.`, true);
240
+ }
241
+ if (matches.length === 1) return matches[0]!;
242
+ const scope = asDisambiguationScope(scopeHint);
243
+ if (!scope) {
244
+ const paths = matches.map((m) => `${m.source}: ${m.filePath}`).join("\n");
245
+ return result(`${kind === "agent" ? "Agent" : "Chain"} '${name}' exists in both scopes. Specify agentScope: 'user' or 'project'.\n${paths}`, true);
246
+ }
247
+ const scoped = matches.filter((m) => m.source === scope);
248
+ if (scoped.length === 0) return result(`${kind === "agent" ? "Agent" : "Chain"} '${name}' not found in scope '${scope}'.`, true);
249
+ if (scoped.length > 1) return result(`Multiple ${kind}s named '${name}' found in scope '${scope}': ${scoped.map((m) => m.filePath).join(", ")}`, true);
250
+ return scoped[0]!;
251
+ }
252
+
253
+ function renamePath(
254
+ kind: "agent" | "chain",
255
+ currentPath: string,
256
+ newName: string,
257
+ scope: ManagementScope,
258
+ cwd: string,
259
+ ): { filePath?: string; error?: string } {
260
+ if (nameExistsInScope(cwd, scope, newName, currentPath)) return { error: `Name '${newName}' already exists in ${scope} scope.` };
261
+ const ext = kind === "agent" ? ".md" : ".chain.md";
262
+ const filePath = path.join(path.dirname(currentPath), `${newName}${ext}`);
263
+ if (fs.existsSync(filePath) && filePath !== currentPath) {
264
+ return { error: `File already exists at ${filePath} but is not a valid ${kind} definition. Remove or rename it first.` };
265
+ }
266
+ fs.renameSync(currentPath, filePath);
267
+ return { filePath };
268
+ }
269
+
270
+ export function formatAgentDetail(agent: AgentConfig): string {
271
+ const tools = [...(agent.tools ?? []), ...(agent.mcpDirectTools ?? []).map((t) => `mcp:${t}`)];
272
+ const lines: string[] = [`Agent: ${agent.name} (${agent.source})`, `Path: ${agent.filePath}`, `Description: ${agent.description}`];
273
+ if (agent.model) lines.push(`Model: ${agent.model}`);
274
+ if (tools.length) lines.push(`Tools: ${tools.join(", ")}`);
275
+ if (agent.skills?.length) lines.push(`Skills: ${agent.skills.join(", ")}`);
276
+ if (agent.thinking) lines.push(`Thinking: ${agent.thinking}`);
277
+ if (agent.output) lines.push(`Output: ${agent.output}`);
278
+ if (agent.defaultReads?.length) lines.push(`Reads: ${agent.defaultReads.join(", ")}`);
279
+ if (agent.defaultProgress) lines.push("Progress: true");
280
+ if (agent.systemPrompt.trim()) lines.push("", "System Prompt:", agent.systemPrompt);
281
+ return lines.join("\n");
282
+ }
283
+
284
+ export function formatChainDetail(chain: ChainConfig): string {
285
+ const lines: string[] = [`Chain: ${chain.name} (${chain.source})`, `Path: ${chain.filePath}`, `Description: ${chain.description}`, "", "Steps:"];
286
+ for (let i = 0; i < chain.steps.length; i++) {
287
+ const s = chain.steps[i]!;
288
+ lines.push(`${i + 1}. ${s.agent}`);
289
+ if (s.task.trim()) lines.push(` Task: ${s.task}`);
290
+ if (s.output === false) lines.push(" Output: false");
291
+ else if (s.output) lines.push(` Output: ${s.output}`);
292
+ if (s.reads === false) lines.push(" Reads: false");
293
+ else if (Array.isArray(s.reads) && s.reads.length > 0) lines.push(` Reads: ${s.reads.join(", ")}`);
294
+ if (s.model) lines.push(` Model: ${s.model}`);
295
+ if (s.skills === false) lines.push(" Skills: false");
296
+ else if (Array.isArray(s.skills) && s.skills.length > 0) lines.push(` Skills: ${s.skills.join(", ")}`);
297
+ if (s.progress !== undefined) lines.push(` Progress: ${s.progress ? "true" : "false"}`);
298
+ }
299
+ return lines.join("\n");
300
+ }
301
+
302
+ export function handleList(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
303
+ const scope = normalizeListScope(params.agentScope) ?? "both";
304
+ const d = discoverAgentsAll(ctx.cwd);
305
+ const agents = [...d.user, ...d.project].filter((a) => scope === "both" || a.source === scope).sort((a, b) => a.name.localeCompare(b.name));
306
+ const chains = d.chains.filter((c) => scope === "both" || c.source === scope).sort((a, b) => a.name.localeCompare(b.name));
307
+ const lines = ["Agents:", ...(agents.length ? agents.map((a) => `- ${a.name} (${a.source}): ${a.description}`) : ["- (none)"]), "", "Chains:", ...(chains.length ? chains.map((c) => `- ${c.name} (${c.source}): ${c.description}`) : ["- (none)"])];
308
+ return result(lines.join("\n"));
309
+ }
310
+
311
+ export function handleGet(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
312
+ if (!params.agent && !params.chainName) return result("Specify 'agent' or 'chainName' for get.", true);
313
+ const hasBoth = Boolean(params.agent && params.chainName);
314
+ const blocks: string[] = [];
315
+ let anyFound = false;
316
+ if (params.agent) {
317
+ const matches = findAgents(params.agent, ctx.cwd, "both");
318
+ if (!matches.length) {
319
+ const msg = `Agent '${params.agent}' not found. Available: ${availableNames(ctx.cwd, "agent").join(", ") || "none"}.`;
320
+ if (!hasBoth) return result(msg, true);
321
+ blocks.push(msg);
322
+ } else {
323
+ anyFound = true;
324
+ blocks.push(...matches.map(formatAgentDetail));
325
+ }
326
+ }
327
+ if (params.chainName) {
328
+ const matches = findChains(params.chainName, ctx.cwd, "both");
329
+ if (!matches.length) {
330
+ const msg = `Chain '${params.chainName}' not found. Available: ${availableNames(ctx.cwd, "chain").join(", ") || "none"}.`;
331
+ if (!hasBoth) return result(msg, true);
332
+ blocks.push(msg);
333
+ } else {
334
+ anyFound = true;
335
+ blocks.push(...matches.map(formatChainDetail));
336
+ }
337
+ }
338
+ return result(blocks.join("\n\n"), !anyFound);
339
+ }
340
+
341
+ export function handleCreate(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
342
+ const cfg = configObject(params.config);
343
+ if (!cfg) return result("config required for create.", true);
344
+ if (typeof cfg.name !== "string" || !cfg.name.trim()) return result("config.name is required and must be a non-empty string.", true);
345
+ if (typeof cfg.description !== "string" || !cfg.description.trim()) return result("config.description is required and must be a non-empty string.", true);
346
+ const name = sanitizeName(cfg.name);
347
+ if (!name) return result("config.name is invalid after sanitization. Use letters, numbers, spaces, or hyphens.", true);
348
+ const scopeRaw = cfg.scope ?? "user";
349
+ if (scopeRaw !== "user" && scopeRaw !== "project") return result("config.scope must be 'user' or 'project'.", true);
350
+ const scope = scopeRaw as ManagementScope;
351
+ const isChain = hasKey(cfg, "steps");
352
+ const d = discoverAgentsAll(ctx.cwd);
353
+ const targetDir = scope === "user" ? d.userDir : d.projectDir ?? path.join(ctx.cwd, ".pi", "agents");
354
+ fs.mkdirSync(targetDir, { recursive: true });
355
+ if (nameExistsInScope(ctx.cwd, scope, name)) return result(`Name '${name}' already exists in ${scope} scope. Use update instead.`, true);
356
+ const targetPath = path.join(targetDir, isChain ? `${name}.chain.md` : `${name}.md`);
357
+ if (fs.existsSync(targetPath)) return result(`File already exists at ${targetPath} but is not a valid ${isChain ? "chain" : "agent"} definition. Remove or rename it first.`, true);
358
+ const warnings: string[] = [];
359
+ if (isChain) {
360
+ const parsed = parseStepList(cfg.steps);
361
+ if (parsed.error) return result(parsed.error, true);
362
+ const chain: ChainConfig = { name, description: cfg.description.trim(), source: scope, filePath: targetPath, steps: parsed.steps! };
363
+ fs.writeFileSync(targetPath, serializeChain(chain), "utf-8");
364
+ const missing = unknownChainAgents(ctx.cwd, chain.steps);
365
+ if (missing.length) warnings.push(`Warning: chain steps reference unknown agents: ${missing.join(", ")}.`);
366
+ warnings.push(...chainStepWarnings(ctx, chain.steps));
367
+ return result([`Created chain '${name}' at ${targetPath}.`, ...warnings].join("\n"));
368
+ }
369
+ const agent: AgentConfig = { name, description: cfg.description.trim(), source: scope, filePath: targetPath, systemPrompt: "" };
370
+ const applyError = applyAgentConfig(agent, cfg);
371
+ if (applyError) return result(applyError, true);
372
+ const mw = modelWarning(ctx, agent.model);
373
+ if (mw) warnings.push(mw);
374
+ const sw = skillsWarning(ctx.cwd, agent.skills);
375
+ if (sw) warnings.push(sw);
376
+ fs.writeFileSync(targetPath, serializeAgent(agent), "utf-8");
377
+ return result([`Created agent '${name}' at ${targetPath}.`, ...warnings].join("\n"));
378
+ }
379
+
380
+ export function handleUpdate(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
381
+ if (!params.agent && !params.chainName) return result("Specify 'agent' or 'chainName' for update.", true);
382
+ if (params.agent && params.chainName) return result("Specify either 'agent' or 'chainName', not both.", true);
383
+ const cfg = configObject(params.config);
384
+ if (!cfg) return result("config required for update.", true);
385
+ const warnings: string[] = [];
386
+ if (params.agent) {
387
+ const scopeHint = asDisambiguationScope(params.agentScope);
388
+ const targetOrError = resolveTarget("agent", params.agent, findAgents(params.agent, ctx.cwd, scopeHint ?? "both"), ctx.cwd, params.agentScope);
389
+ if ("content" in targetOrError) return targetOrError;
390
+ const target = targetOrError;
391
+ const updated: AgentConfig = { ...target };
392
+ const oldName = target.name;
393
+ // Validate all fields before any filesystem mutation
394
+ if (hasKey(cfg, "name") && (typeof cfg.name !== "string" || !cfg.name.trim())) return result("config.name must be a non-empty string when provided.", true);
395
+ if (hasKey(cfg, "description") && (typeof cfg.description !== "string" || !cfg.description.trim())) return result("config.description must be a non-empty string when provided.", true);
396
+ let newName: string | undefined;
397
+ if (hasKey(cfg, "name")) {
398
+ newName = sanitizeName(cfg.name as string);
399
+ if (!newName) return result("config.name is invalid after sanitization.", true);
400
+ }
401
+ const applyError = applyAgentConfig(updated, cfg);
402
+ if (applyError) return result(applyError, true);
403
+ // Apply name/description (validated above)
404
+ if (newName !== undefined) updated.name = newName;
405
+ if (hasKey(cfg, "description")) updated.description = (cfg.description as string).trim();
406
+ if (hasKey(cfg, "model")) {
407
+ const mw = modelWarning(ctx, updated.model);
408
+ if (mw) warnings.push(mw);
409
+ }
410
+ if (hasKey(cfg, "skills")) {
411
+ const sw = skillsWarning(ctx.cwd, updated.skills);
412
+ if (sw) warnings.push(sw);
413
+ }
414
+ // Filesystem mutations last
415
+ if (updated.name !== oldName) {
416
+ const renamed = renamePath("agent", target.filePath, updated.name, target.source, ctx.cwd);
417
+ if (renamed.error) return result(renamed.error, true);
418
+ updated.filePath = renamed.filePath!;
419
+ }
420
+ fs.writeFileSync(updated.filePath, serializeAgent(updated), "utf-8");
421
+ if (updated.name !== oldName) {
422
+ const refs = discoverAgentsAll(ctx.cwd).chains.filter((c) => c.steps.some((s) => s.agent === oldName)).map((c) => `${c.name} (${c.source})`);
423
+ if (refs.length) warnings.push(`Warning: chains still reference '${oldName}': ${refs.join(", ")}.`);
424
+ }
425
+ const headline = updated.name === oldName
426
+ ? `Updated agent '${updated.name}' at ${updated.filePath}.`
427
+ : `Updated agent '${oldName}' to '${updated.name}' at ${updated.filePath}.`;
428
+ return result([headline, ...warnings].join("\n"));
429
+ }
430
+ const scopeHint = asDisambiguationScope(params.agentScope);
431
+ const targetOrError = resolveTarget("chain", params.chainName!, findChains(params.chainName!, ctx.cwd, scopeHint ?? "both"), ctx.cwd, params.agentScope);
432
+ if ("content" in targetOrError) return targetOrError;
433
+ const target = targetOrError;
434
+ const updated: ChainConfig = { ...target, steps: [...target.steps] };
435
+ const oldName = target.name;
436
+ // Validate all fields before any filesystem mutation
437
+ if (hasKey(cfg, "name") && (typeof cfg.name !== "string" || !cfg.name.trim())) return result("config.name must be a non-empty string when provided.", true);
438
+ if (hasKey(cfg, "description") && (typeof cfg.description !== "string" || !cfg.description.trim())) return result("config.description must be a non-empty string when provided.", true);
439
+ let newName: string | undefined;
440
+ if (hasKey(cfg, "name")) {
441
+ newName = sanitizeName(cfg.name as string);
442
+ if (!newName) return result("config.name is invalid after sanitization.", true);
443
+ }
444
+ let parsedSteps: ChainStepConfig[] | undefined;
445
+ if (hasKey(cfg, "steps")) {
446
+ const parsed = parseStepList(cfg.steps);
447
+ if (parsed.error) return result(parsed.error, true);
448
+ parsedSteps = parsed.steps!;
449
+ }
450
+ // Apply validated changes to in-memory object
451
+ if (newName !== undefined) updated.name = newName;
452
+ if (hasKey(cfg, "description")) updated.description = (cfg.description as string).trim();
453
+ if (parsedSteps) {
454
+ updated.steps = parsedSteps;
455
+ const missing = unknownChainAgents(ctx.cwd, updated.steps);
456
+ if (missing.length) warnings.push(`Warning: chain steps reference unknown agents: ${missing.join(", ")}.`);
457
+ warnings.push(...chainStepWarnings(ctx, updated.steps));
458
+ }
459
+ // Filesystem mutations last
460
+ if (updated.name !== oldName) {
461
+ const renamed = renamePath("chain", target.filePath, updated.name, target.source, ctx.cwd);
462
+ if (renamed.error) return result(renamed.error, true);
463
+ updated.filePath = renamed.filePath!;
464
+ }
465
+ fs.writeFileSync(updated.filePath, serializeChain(updated), "utf-8");
466
+ const headline = updated.name === oldName
467
+ ? `Updated chain '${updated.name}' at ${updated.filePath}.`
468
+ : `Updated chain '${oldName}' to '${updated.name}' at ${updated.filePath}.`;
469
+ return result([headline, ...warnings].join("\n"));
470
+ }
471
+
472
+ export function handleDelete(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
473
+ if (!params.agent && !params.chainName) return result("Specify 'agent' or 'chainName' for delete.", true);
474
+ if (params.agent && params.chainName) return result("Specify either 'agent' or 'chainName', not both.", true);
475
+ const scopeHint = asDisambiguationScope(params.agentScope);
476
+ if (params.agent) {
477
+ const targetOrError = resolveTarget("agent", params.agent, findAgents(params.agent, ctx.cwd, scopeHint ?? "both"), ctx.cwd, params.agentScope);
478
+ if ("content" in targetOrError) return targetOrError;
479
+ const target = targetOrError;
480
+ fs.unlinkSync(target.filePath);
481
+ const refs = discoverAgentsAll(ctx.cwd).chains.filter((c) => c.steps.some((s) => s.agent === target.name)).map((c) => `${c.name} (${c.source})`);
482
+ const lines = [`Deleted agent '${target.name}' at ${target.filePath}.`];
483
+ if (refs.length) lines.push(`Warning: chains reference deleted agent '${target.name}': ${refs.join(", ")}.`);
484
+ return result(lines.join("\n"));
485
+ }
486
+ const targetOrError = resolveTarget("chain", params.chainName!, findChains(params.chainName!, ctx.cwd, scopeHint ?? "both"), ctx.cwd, params.agentScope);
487
+ if ("content" in targetOrError) return targetOrError;
488
+ const target = targetOrError;
489
+ fs.unlinkSync(target.filePath);
490
+ return result(`Deleted chain '${target.name}' at ${target.filePath}.`);
491
+ }
492
+
493
+ export function handleManagementAction(action: string, params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
494
+ switch (action as ManagementAction) {
495
+ case "list": return handleList(params, ctx);
496
+ case "get": return handleGet(params, ctx);
497
+ case "create": return handleCreate(params, ctx);
498
+ case "update": return handleUpdate(params, ctx);
499
+ case "delete": return handleDelete(params, ctx);
500
+ default: return result(`Unknown action: ${action}`, true);
501
+ }
502
+ }
package/index.ts CHANGED
@@ -48,6 +48,7 @@ import { isAsyncAvailable, executeAsyncChain, executeAsyncSingle } from "./async
48
48
  import { discoverAvailableSkills, normalizeSkillInput } from "./skills.js";
49
49
  import { AgentManagerComponent, type ManagerResult } from "./agent-manager.js";
50
50
  import { recordRun } from "./run-history.js";
51
+ import { handleManagementAction } from "./agent-management.js";
51
52
 
52
53
  // ExtensionConfig is now imported from ./types.js
53
54
 
@@ -147,7 +148,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
147
148
  const tool: ToolDefinition<typeof SubagentParams, Details> = {
148
149
  name: "subagent",
149
150
  label: "Subagent",
150
- description: `Delegate to subagents. Use exactly ONE mode:
151
+ description: `Delegate to subagents or manage agent definitions.
152
+
153
+ EXECUTION (use exactly ONE mode):
151
154
  • SINGLE: { agent, task } - one task
152
155
  • CHAIN: { chain: [{agent:"scout"}, {agent:"planner"}] } - sequential pipeline
153
156
  • PARALLEL: { tasks: [{agent,task}, ...] } - concurrent execution
@@ -162,12 +165,31 @@ CHAIN DATA FLOW:
162
165
  2. Steps can also write files to {chain_dir} (via agent's "output" config)
163
166
  3. Later steps can read those files (via agent's "reads" config)
164
167
 
165
- Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }`,
168
+ Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }
169
+
170
+ MANAGEMENT (use action field — omit agent/task/chain/tasks):
171
+ • { action: "list" } - discover available agents and chains
172
+ • { action: "get", agent: "name" } - full agent detail with system prompt
173
+ • { action: "create", config: { name, description, systemPrompt, ... } } - create agent/chain
174
+ • { action: "update", agent: "name", config: { ... } } - modify fields (merge)
175
+ • { action: "delete", agent: "name" } - remove definition
176
+ • Use chainName instead of agent for chain operations`,
166
177
  parameters: SubagentParams,
167
178
 
168
179
  async execute(_id, params, signal, onUpdate, ctx) {
169
- const scope: AgentScope = params.agentScope ?? "user";
170
180
  baseCwd = ctx.cwd;
181
+ if (params.action) {
182
+ const validActions = ["list", "get", "create", "update", "delete"];
183
+ if (!validActions.includes(params.action)) {
184
+ return {
185
+ content: [{ type: "text", text: `Unknown action: ${params.action}. Valid: ${validActions.join(", ")}` }],
186
+ isError: true,
187
+ details: { mode: "management" as const, results: [] },
188
+ };
189
+ }
190
+ return handleManagementAction(params.action, params, ctx);
191
+ }
192
+ const scope: AgentScope = params.agentScope ?? "user";
171
193
  currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
172
194
  const agents = discoverAgents(ctx.cwd, scope).agents;
173
195
  const runId = randomUUID().slice(0, 8);
@@ -630,6 +652,13 @@ Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", tas
630
652
  },
631
653
 
632
654
  renderCall(args, theme) {
655
+ if (args.action) {
656
+ const target = args.agent || args.chainName || "";
657
+ return new Text(
658
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${args.action}${target ? ` ${theme.fg("accent", target)}` : ""}`,
659
+ 0, 0,
660
+ );
661
+ }
633
662
  const isParallel = (args.tasks?.length ?? 0) > 0;
634
663
  const asyncLabel = args.async === true && !isParallel ? theme.fg("warning", " [async]") : "";
635
664
  if (args.chain?.length)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/schemas.ts CHANGED
@@ -59,8 +59,20 @@ export const MaxOutputSchema = Type.Optional(
59
59
  );
60
60
 
61
61
  export const SubagentParams = Type.Object({
62
- agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode)" })),
62
+ agent: Type.Optional(Type.String({ description: "Agent name (SINGLE mode) or target for management get/update/delete" })),
63
63
  task: Type.Optional(Type.String({ description: "Task (SINGLE mode)" })),
64
+ // Management action (when present, tool operates in management mode)
65
+ action: Type.Optional(Type.String({
66
+ description: "Management action: 'list' (discover agents/chains), 'get' (full detail), 'create', 'update', 'delete'. Omit for execution mode."
67
+ })),
68
+ // Chain identifier for management (can't reuse 'chain' — that's the execution array)
69
+ chainName: Type.Optional(Type.String({
70
+ description: "Chain name for get/update/delete management actions"
71
+ })),
72
+ // Agent/chain configuration for create/update (nested to avoid conflicts with execution fields)
73
+ config: Type.Optional(Type.Any({
74
+ description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, model, tools (comma-separated), skills (comma-separated), thinking, output, reads, progress. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent."
75
+ })),
64
76
  tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task}, ...]" })),
65
77
  chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. Use {task}, {previous}, {chain_dir} in task templates." })),
66
78
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
package/types.ts CHANGED
@@ -96,7 +96,7 @@ export interface SingleResult {
96
96
  }
97
97
 
98
98
  export interface Details {
99
- mode: "single" | "parallel" | "chain";
99
+ mode: "single" | "parallel" | "chain" | "management";
100
100
  results: SingleResult[];
101
101
  asyncId?: string;
102
102
  asyncDir?: string;