pi-subagents 0.21.2 → 0.21.4

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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.21.4] - 2026-05-01
6
+
7
+ ### Added
8
+ - Added explicit frontmatter `package` identifiers for agents and saved chains, registering runtime names like `code-analysis.scout` while preserving separate `name` and `package` fields on save.
9
+ - Added recursive subdirectory discovery for user and project agent and chain definitions.
10
+ - Added `outputMode: "inline" | "file-only"` for saved subagent outputs. `inline` remains the default, while `file-only` returns a concise saved-file reference instead of injecting full saved output back into the parent context.
11
+
12
+ ### Fixed
13
+ - Marked Pi runtime peer dependencies as optional so npm package installs do not auto-install duplicate Pi packages or emit unrelated transitive dependency warnings.
14
+
15
+ ## [0.21.3] - 2026-04-30
16
+
17
+ ### Fixed
18
+ - Debounce foreground `needs_attention` notices, make them non-triggering, and cancel them when the run finishes so stale chain-step alerts do not launch parent turns after completion.
19
+
5
20
  ## [0.21.2] - 2026-04-30
6
21
 
7
22
  ### Added
package/README.md CHANGED
@@ -298,12 +298,13 @@ Append `[key=value,...]` to an agent name to override defaults for that step:
298
298
  | Key | Example | Description |
299
299
  |-----|---------|-------------|
300
300
  | `output` | `output=context.md` | Write results to a file. For `/chain` and `/parallel`, relative paths live under the chain directory; for `/run`, relative paths resolve against cwd. |
301
+ | `outputMode` | `outputMode=file-only` | Return only a concise file reference for saved output instead of the full saved content. Requires `output`; default is `inline`. |
301
302
  | `reads` | `reads=a.md+b.md` | Read files before executing. `+` separates multiple paths. |
302
303
  | `model` | `model=anthropic/claude-sonnet-4` | Override model for this step. |
303
304
  | `skills` | `skills=planning+review` | Override injected skills. `+` separates multiple skills. |
304
305
  | `progress` | `progress` | Enable progress tracking. |
305
306
 
306
- Set `output=false`, `reads=false`, or `skills=false` to disable that behavior explicitly.
307
+ Set `output=false`, `reads=false`, or `skills=false` to disable that behavior explicitly. Do not use `output=false` for file-only returns; use `outputMode=file-only` with an `output` path.
307
308
 
308
309
  ### Background and forked runs
309
310
 
@@ -396,10 +397,10 @@ Agent locations, lowest to highest priority:
396
397
  | Scope | Path |
397
398
  |-------|------|
398
399
  | Builtin | `~/.pi/agent/extensions/subagent/agents/` |
399
- | User | `~/.pi/agent/agents/{name}.md` |
400
- | Project | `.pi/agents/{name}.md` |
400
+ | User | `~/.pi/agent/agents/**/*.md` |
401
+ | Project | `.pi/agents/**/*.md` |
401
402
 
402
- Project discovery also reads legacy `.agents/{name}.md` files. If both `.agents/` and `.pi/agents/` define the same project agent, `.pi/agents/` wins. Use `agentScope: "user" | "project" | "both"` to control discovery; `both` is the default and project definitions win name collisions.
403
+ Project discovery also reads legacy `.agents/**/*.md` files. Nested subdirectories are discovered recursively. `.chain.md` files are treated as chains, not agents. If both `.agents/` and `.pi/agents/` define the same parsed runtime agent name, `.pi/agents/` wins. Use `agentScope: "user" | "project" | "both"` to control discovery; `both` is the default and project definitions win runtime-name collisions.
403
404
 
404
405
  Builtin agents load at the lowest priority, so a user or project agent with the same name overrides them. `oracle` is an advisory reviewer that critiques direction and proposes an execution prompt without editing files. `worker` is the implementation agent for normal tasks and approved oracle handoffs.
405
406
 
@@ -458,6 +459,8 @@ A typical agent looks like this:
458
459
  ```yaml
459
460
  ---
460
461
  name: scout
462
+ # Optional: registers this as code-analysis.scout while preserving name: scout
463
+ package: code-analysis
461
464
  description: Fast codebase recon
462
465
  tools: read, grep, find, ls, bash, mcp:chrome-devtools
463
466
  extensions:
@@ -482,6 +485,7 @@ Important fields:
482
485
 
483
486
  | Field | Notes |
484
487
  |-------|-------|
488
+ | `package` | Optional package identifier. A file with `name: scout` and `package: code-analysis` registers as `code-analysis.scout`; serialization keeps `name` and `package` separate. |
485
489
  | `tools` | Builtin tool allowlist. `mcp:` entries select direct MCP tools when `pi-mcp-adapter` is installed. |
486
490
  | `extensions` | Omitted means normal extensions; empty means no extensions; comma-separated values allowlist specific extensions. |
487
491
  | `model` | Default model. Bare ids prefer the current provider when possible, then unique registry matches. |
@@ -530,10 +534,10 @@ Chains are reusable `.chain.md` workflows stored next to agent files.
530
534
 
531
535
  | Scope | Path |
532
536
  |-------|------|
533
- | User | `~/.pi/agent/agents/{name}.chain.md` |
534
- | Project | `.pi/agents/{name}.chain.md` |
537
+ | User | `~/.pi/agent/agents/**/*.chain.md` |
538
+ | Project | `.pi/agents/**/*.chain.md` |
535
539
 
536
- Project discovery also reads legacy `.agents/{name}.chain.md` files. If both locations define the same parsed chain name, `.pi/agents/` wins.
540
+ Project discovery also reads legacy `.agents/**/*.chain.md` files. Nested subdirectories are discovered recursively. If both locations define the same parsed runtime chain name, `.pi/agents/` wins. Chains support the same optional `package` frontmatter as agents; `name: review-flow` plus `package: code-analysis` runs as `code-analysis.review-flow`.
537
541
 
538
542
  Example:
539
543
 
@@ -556,9 +560,9 @@ progress: true
556
560
  Create an implementation plan based on {previous}
557
561
  ```
558
562
 
559
- Each `## agent-name` section is a step. Config lines such as `output`, `reads`, `model`, `skills`, and `progress` go immediately after the header. A blank line separates config from task text.
563
+ Each `## agent-name` section is a step. Config lines such as `output`, `outputMode`, `reads`, `model`, `skills`, and `progress` go immediately after the header. A blank line separates config from task text.
560
564
 
561
- Chains support three-state behavior: omitted inherits from the agent, a value overrides, and `false` disables.
565
+ For `output`, `reads`, `skills`, and `progress`, chain behavior is three-state: omitted inherits from the agent, a value overrides, and `false` disables.
562
566
 
563
567
  Create chains from the Agents Manager template picker, save them from the chain-clarify TUI, or write them by hand. Run them with natural language, `/agents`, or:
564
568
 
@@ -645,6 +649,7 @@ These are the parameters the LLM passes when it calls the `subagent` tool. Most
645
649
  { agent: "worker", task: "refactor auth" }
646
650
  { agent: "scout", task: "find todos", maxOutput: { lines: 1000 } }
647
651
  { agent: "scout", task: "investigate", output: false }
652
+ { agent: "scout", task: "write a large report", output: "reports/scout.md", outputMode: "file-only" }
648
653
 
649
654
  // Forked context
650
655
  { agent: "worker", task: "continue this thread", context: "fork" }
@@ -690,10 +695,12 @@ Agent definitions are not loaded into context by default. Management actions let
690
695
  { action: "list" }
691
696
  { action: "list", agentScope: "project" }
692
697
  { action: "get", agent: "scout" }
698
+ { action: "get", agent: "code-analysis.scout" }
693
699
  { action: "get", chainName: "review-pipeline" }
694
700
 
695
701
  { action: "create", config: {
696
702
  name: "Code Scout",
703
+ package: "code-analysis",
697
704
  description: "Scans codebases for patterns and issues",
698
705
  scope: "user",
699
706
  systemPrompt: "You are a code scout...",
@@ -721,13 +728,13 @@ Agent definitions are not loaded into context by default. Management actions let
721
728
  ]
722
729
  }}
723
730
 
724
- { action: "update", agent: "scout", config: { model: "openai/gpt-4o" } }
731
+ { action: "update", agent: "code-analysis.scout", config: { model: "openai/gpt-4o" } }
725
732
  { action: "update", chainName: "review-pipeline", config: { steps: [...] } }
726
733
  { action: "delete", agent: "scout" }
727
734
  { action: "delete", chainName: "review-pipeline" }
728
735
  ```
729
736
 
730
- `create` uses `config.scope`, not `agentScope`. `update` and `delete` use `agentScope` only when the same name exists in multiple scopes. To clear optional string fields, set them to `false` or `""`.
737
+ `create` uses `config.scope`, not `agentScope`. `config.name` is the local frontmatter name; optional `config.package` registers the runtime name as `{package}.{name}` and is saved as separate `name` and `package` frontmatter. `update` and `delete` use the runtime name and `agentScope` only when the same runtime name exists in multiple scopes. To clear optional string fields, including `package`, set them to `false` or `""`.
731
738
 
732
739
  ### Parameter reference
733
740
 
@@ -739,9 +746,10 @@ Agent definitions are not loaded into context by default. Management actions let
739
746
  | `chainName` | string | - | Chain name for management actions. |
740
747
  | `config` | object/string | - | Agent or chain config for create/update. |
741
748
  | `output` | `string \| false` | agent default | Override single-agent output file. |
749
+ | `outputMode` | `"inline" \| "file-only"` | `inline` | Return saved output inline or as a concise saved-file reference. `file-only` requires an `output` path. |
742
750
  | `skill` | `string \| string[] \| false` | agent default | Override skills or disable all. |
743
751
  | `model` | string | agent default | Override model. |
744
- | `tasks` | array | - | Top-level parallel tasks. Supports `agent`, `task`, `cwd`, `count`, `output`, `reads`, `progress`, `skill`, and `model`. |
752
+ | `tasks` | array | - | Top-level parallel tasks. Supports `agent`, `task`, `cwd`, `count`, `output`, `outputMode`, `reads`, `progress`, `skill`, and `model`. |
745
753
  | `concurrency` | number | config or `4` | Top-level parallel concurrency. |
746
754
  | `worktree` | boolean | false | Create isolated git worktrees for parallel tasks. |
747
755
  | `chain` | array | - | Sequential and parallel chain steps. |
@@ -759,7 +767,9 @@ Agent definitions are not loaded into context by default. Management actions let
759
767
 
760
768
  `context: "fork"` fails fast when the parent session is not persisted, the current leaf is missing, or the branched child session cannot be created. It never silently downgrades to `fresh`. In multi-agent runs, if any requested agent has `defaultContext: fork` and the launch omits `context`, the whole invocation uses forked context; pass `context: "fresh"` when you intentionally want a fresh run.
761
769
 
762
- Sequential and parallel chain tasks accept `agent`, `task`, `cwd`, `output`, `reads`, `progress`, `skill`, and `model`. Parallel tasks also accept `count`. Parallel step groups accept `parallel`, `concurrency`, `failFast`, and `worktree`.
770
+ Use `outputMode: "file-only"` when a saved output may be large and the parent only needs a pointer. The returned text is a compact reference like `Output saved to: /abs/report.md (48.2 KB, 2847 lines). Read this file if needed.` Failed runs and save errors still return normal inline output for debugging. In chains, later `{previous}` steps receive the same compact reference when the prior step used file-only mode.
771
+
772
+ Sequential and parallel chain tasks accept `agent`, `task`, `cwd`, `output`, `outputMode`, `reads`, `progress`, `skill`, and `model`. Parallel tasks also accept `count`. Parallel step groups accept `parallel`, `concurrency`, `failFast`, and `worktree`.
763
773
 
764
774
  Status and control actions:
765
775
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
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",
@@ -57,6 +57,20 @@
57
57
  "@mariozechner/pi-coding-agent": "*",
58
58
  "@mariozechner/pi-tui": "*"
59
59
  },
60
+ "peerDependenciesMeta": {
61
+ "@mariozechner/pi-agent-core": {
62
+ "optional": true
63
+ },
64
+ "@mariozechner/pi-ai": {
65
+ "optional": true
66
+ },
67
+ "@mariozechner/pi-coding-agent": {
68
+ "optional": true
69
+ },
70
+ "@mariozechner/pi-tui": {
71
+ "optional": true
72
+ }
73
+ },
60
74
  "dependencies": {
61
75
  "typebox": "^1.1.24"
62
76
  },
@@ -178,16 +178,18 @@ agent with the same name only when you want a substantially different agent.
178
178
  ## Discovery and Scope Rules
179
179
 
180
180
  Agent files can live in:
181
- - `~/.pi/agent/agents/*.md` — user scope
182
- - `.pi/agents/*.md` — canonical project scope
183
- - legacy `.agents/*.md` — still read for compatibility, but `.pi/agents/` wins on conflicts
181
+ - `~/.pi/agent/agents/**/*.md` — user scope
182
+ - `.pi/agents/**/*.md` — canonical project scope
183
+ - legacy `.agents/**/*.md` — still read for compatibility, but `.pi/agents/` wins on conflicts
184
184
 
185
185
  Chains live in:
186
- - `~/.pi/agent/agents/*.chain.md`
187
- - `.pi/agents/*.chain.md`
188
- - legacy `.agents/*.chain.md`
186
+ - `~/.pi/agent/agents/**/*.chain.md`
187
+ - `.pi/agents/**/*.chain.md`
188
+ - legacy `.agents/**/*.chain.md`
189
189
 
190
- Precedence is:
190
+ Discovery is recursive. `.chain.md` files are chains, not agents. Agents and chains can set optional frontmatter `package: code-analysis`; `name: scout` plus `package: code-analysis` registers as runtime name `code-analysis.scout` while serialization keeps `name` and `package` separate.
191
+
192
+ Precedence is by parsed runtime name:
191
193
  1. project scope
192
194
  2. user scope
193
195
  3. builtin agents
@@ -241,7 +243,7 @@ subagent({
241
243
  })
242
244
  ```
243
245
 
244
- Avoid duplicate output paths in parallel tasks. Concurrent children should not write to the same file.
246
+ Avoid duplicate output paths in parallel tasks. Concurrent children should not write to the same file. For large saved outputs, set `outputMode: "file-only"` together with an `output` path. The parent result then contains only a compact reference like `Output saved to: /abs/report.md (48.2 KB, 2847 lines). Read this file if needed.` instead of the full saved content. Do not use `output: false` for this; `output: false` means no file output. Failed runs and save errors still return inline details for debugging.
245
247
 
246
248
  ### Chain execution
247
249
 
@@ -261,6 +263,8 @@ without forcing each step to rediscover everything.
261
263
 
262
264
  ### Async/background
263
265
 
266
+ Use async mode whenever the parent agent should keep working while a child runs. A normal foreground `subagent(...)` call blocks the parent until the child completes; it is appropriate when the next parent step depends on the child result. If you say you will "ask a reviewer while I continue auditing" or otherwise run local work in parallel with a child, launch with `async: true`. Do not end your turn immediately after launching that async child if you promised to keep working; continue the local inspection or other independent work, then check the async run when its result is needed.
267
+
264
268
  ```typescript
265
269
  subagent({
266
270
  agent: "worker",
@@ -269,6 +273,20 @@ subagent({
269
273
  })
270
274
  ```
271
275
 
276
+ File-only output mode also works for async single runs, top-level parallel task items, sequential chain steps, and chain parallel task items. In chains, `{previous}` receives the compact saved-file reference when the prior step used file-only mode.
277
+
278
+ For review fanout where the parent continues a local audit:
279
+
280
+ ```typescript
281
+ const run = subagent({
282
+ agent: "reviewer",
283
+ task: "Review the current diff for correctness issues. Do not edit files.",
284
+ async: true,
285
+ context: "fresh"
286
+ })
287
+ // Continue local inspection, then later call status with the returned id.
288
+ ```
289
+
272
290
  Inspect async runs with `subagent({ action: "status", id: "..." })`, `subagent({ action: "status" })` for active runs, or the `/subagents-status` slash command.
273
291
 
274
292
  Use diagnostics when setup or child startup looks wrong:
@@ -437,6 +455,7 @@ subagent({
437
455
  action: "create",
438
456
  config: {
439
457
  name: "my-agent",
458
+ package: "code-analysis",
440
459
  description: "Project-specific implementation helper",
441
460
  systemPrompt: "Your system prompt here.",
442
461
  systemPromptMode: "replace",
@@ -451,7 +470,7 @@ subagent({
451
470
  ```typescript
452
471
  subagent({
453
472
  action: "update",
454
- agent: "my-agent",
473
+ agent: "code-analysis.my-agent",
455
474
  config: {
456
475
  thinking: "high"
457
476
  }
@@ -461,13 +480,13 @@ subagent({
461
480
  ### Delete an agent
462
481
 
463
482
  ```typescript
464
- subagent({ action: "delete", agent: "my-agent" })
483
+ subagent({ action: "delete", agent: "code-analysis.my-agent" })
465
484
  ```
466
485
 
467
486
  Use management actions when the system needs to create or edit subagents on
468
487
  demand without dropping into raw file editing.
469
488
 
470
- Management actions create or update user/project agent files. For small builtin changes such as a model swap, prefer `/agents` builtin overrides or `subagents.agentOverrides` in settings.
489
+ Management actions create or update user/project agent files. `config.name` is the local frontmatter name; optional `config.package` registers and looks up the runtime name as `{package}.{name}`. Use the dotted runtime name for `get`, `update`, `delete`, slash commands, and chain steps. For small builtin changes such as a model swap, prefer `/agents` builtin overrides or `subagents.agentOverrides` in settings.
471
490
 
472
491
  ## Creating and Editing Agents by File
473
492
 
@@ -476,6 +495,7 @@ A minimal agent file looks like this:
476
495
  ```markdown
477
496
  ---
478
497
  name: my-agent
498
+ package: code-analysis
479
499
  description: What this agent does
480
500
  model: openai-codex/gpt-5.4
481
501
  thinking: high
@@ -488,7 +508,7 @@ inheritSkills: false
488
508
  Your system prompt here.
489
509
  ```
490
510
 
491
- That is only a starting point. Common optional fields include:
511
+ That is only a starting point. Omit `package` for the traditional unqualified runtime name. Common optional fields include:
492
512
  - `defaultProgress`
493
513
  - `defaultReads`
494
514
  - `output`
@@ -12,6 +12,9 @@ import {
12
12
  defaultInheritSkills,
13
13
  defaultSystemPromptMode,
14
14
  discoverAgentsAll,
15
+ buildRuntimeName,
16
+ frontmatterNameForConfig,
17
+ parsePackageName,
15
18
  } from "./agents.ts";
16
19
  import { serializeAgent } from "./agent-serializer.ts";
17
20
  import { serializeChain } from "./chain-serializer.ts";
@@ -71,6 +74,10 @@ function sanitizeName(name: string): string {
71
74
  return name.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
72
75
  }
73
76
 
77
+ function parsePackageConfig(value: unknown): { packageName?: string; error?: string } {
78
+ return parsePackageName(value, "config.package");
79
+ }
80
+
74
81
  function allAgents(d: { builtin: AgentConfig[]; user: AgentConfig[]; project: AgentConfig[] }): AgentConfig[] {
75
82
  return [...d.builtin, ...d.user, ...d.project];
76
83
  }
@@ -167,6 +174,10 @@ function parseStepList(raw: unknown): { steps?: ChainStepConfig[]; error?: strin
167
174
  else if (typeof s.output === "string") step.output = s.output;
168
175
  else return { error: `config.steps[${i}].output must be a string or false.` };
169
176
  }
177
+ if (hasKey(s, "outputMode")) {
178
+ if (s.outputMode === "inline" || s.outputMode === "file-only") step.outputMode = s.outputMode;
179
+ else return { error: `config.steps[${i}].outputMode must be 'inline' or 'file-only'.` };
180
+ }
170
181
  if (hasKey(s, "reads")) {
171
182
  if (s.reads === false) step.reads = false;
172
183
  else if (Array.isArray(s.reads)) step.reads = s.reads.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean);
@@ -336,6 +347,10 @@ function renamePath(
336
347
  function formatAgentDetail(agent: AgentConfig): string {
337
348
  const tools = [...(agent.tools ?? []), ...(agent.mcpDirectTools ?? []).map((t) => `mcp:${t}`)];
338
349
  const lines: string[] = [`Agent: ${agent.name} (${agent.source})`, `Path: ${agent.filePath}`, `Description: ${agent.description}`];
350
+ if (agent.packageName) {
351
+ lines.push(`Local name: ${frontmatterNameForConfig(agent)}`);
352
+ lines.push(`Package: ${agent.packageName}`);
353
+ }
339
354
  if (agent.model) lines.push(`Model: ${agent.model}`);
340
355
  if (agent.fallbackModels?.length) lines.push(`Fallback models: ${agent.fallbackModels.join(", ")}`);
341
356
  if (tools.length) lines.push(`Tools: ${tools.join(", ")}`);
@@ -356,13 +371,19 @@ function formatAgentDetail(agent: AgentConfig): string {
356
371
  }
357
372
 
358
373
  function formatChainDetail(chain: ChainConfig): string {
359
- const lines: string[] = [`Chain: ${chain.name} (${chain.source})`, `Path: ${chain.filePath}`, `Description: ${chain.description}`, "", "Steps:"];
374
+ const lines: string[] = [`Chain: ${chain.name} (${chain.source})`, `Path: ${chain.filePath}`, `Description: ${chain.description}`];
375
+ if (chain.packageName) {
376
+ lines.push(`Local name: ${frontmatterNameForConfig(chain)}`);
377
+ lines.push(`Package: ${chain.packageName}`);
378
+ }
379
+ lines.push("", "Steps:");
360
380
  for (let i = 0; i < chain.steps.length; i++) {
361
381
  const s = chain.steps[i]!;
362
382
  lines.push(`${i + 1}. ${s.agent}`);
363
383
  if (s.task.trim()) lines.push(` Task: ${s.task}`);
364
384
  if (s.output === false) lines.push(" Output: false");
365
385
  else if (s.output) lines.push(` Output: ${s.output}`);
386
+ if (s.outputMode) lines.push(` Output mode: ${s.outputMode}`);
366
387
  if (s.reads === false) lines.push(" Reads: false");
367
388
  else if (Array.isArray(s.reads) && s.reads.length > 0) lines.push(` Reads: ${s.reads.join(", ")}`);
368
389
  if (s.model) lines.push(` Model: ${s.model}`);
@@ -430,6 +451,9 @@ export function handleCreate(params: ManagementParams, ctx: ManagementContext):
430
451
  if (typeof cfg.description !== "string" || !cfg.description.trim()) return result("config.description is required and must be a non-empty string.", true);
431
452
  const name = sanitizeName(cfg.name);
432
453
  if (!name) return result("config.name is invalid after sanitization. Use letters, numbers, spaces, or hyphens.", true);
454
+ const parsedPackage = parsePackageConfig(cfg.package);
455
+ if (parsedPackage.error) return result(parsedPackage.error, true);
456
+ const runtimeName = buildRuntimeName(name, parsedPackage.packageName);
433
457
  const scopeRaw = cfg.scope ?? "user";
434
458
  if (scopeRaw !== "user" && scopeRaw !== "project") return result("config.scope must be 'user' or 'project'.", true);
435
459
  const scope = scopeRaw as ManagementScope;
@@ -437,23 +461,25 @@ export function handleCreate(params: ManagementParams, ctx: ManagementContext):
437
461
  const d = discoverAgentsAll(ctx.cwd);
438
462
  const targetDir = scope === "user" ? d.userDir : d.projectDir ?? path.join(ctx.cwd, ".pi", "agents");
439
463
  fs.mkdirSync(targetDir, { recursive: true });
440
- if (nameExistsInScope(ctx.cwd, scope, name)) return result(`Name '${name}' already exists in ${scope} scope. Use update instead.`, true);
441
- const targetPath = path.join(targetDir, isChain ? `${name}.chain.md` : `${name}.md`);
464
+ if (nameExistsInScope(ctx.cwd, scope, runtimeName)) return result(`Name '${runtimeName}' already exists in ${scope} scope. Use update instead.`, true);
465
+ const targetPath = path.join(targetDir, isChain ? `${runtimeName}.chain.md` : `${runtimeName}.md`);
442
466
  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);
443
467
  const warnings: string[] = [];
444
- if (!isChain && d.builtin.some((a) => a.name === name)) warnings.push(`Note: this shadows the builtin agent '${name}'.`);
468
+ if (!isChain && d.builtin.some((a) => a.name === runtimeName)) warnings.push(`Note: this shadows the builtin agent '${runtimeName}'.`);
445
469
  if (isChain) {
446
470
  const parsed = parseStepList(cfg.steps);
447
471
  if (parsed.error) return result(parsed.error, true);
448
- const chain: ChainConfig = { name, description: cfg.description.trim(), source: scope, filePath: targetPath, steps: parsed.steps! };
472
+ const chain: ChainConfig = { name: runtimeName, localName: name, packageName: parsedPackage.packageName, description: cfg.description.trim(), source: scope, filePath: targetPath, steps: parsed.steps! };
449
473
  fs.writeFileSync(targetPath, serializeChain(chain), "utf-8");
450
474
  const missing = unknownChainAgents(ctx.cwd, chain.steps);
451
475
  if (missing.length) warnings.push(`Warning: chain steps reference unknown agents: ${missing.join(", ")}.`);
452
476
  warnings.push(...chainStepWarnings(ctx, chain.steps));
453
- return result([`Created chain '${name}' at ${targetPath}.`, ...warnings].join("\n"));
477
+ return result([`Created chain '${runtimeName}' at ${targetPath}.`, ...warnings].join("\n"));
454
478
  }
455
479
  const agent: AgentConfig = {
456
- name,
480
+ name: runtimeName,
481
+ localName: name,
482
+ packageName: parsedPackage.packageName,
457
483
  description: cfg.description.trim(),
458
484
  source: scope,
459
485
  filePath: targetPath,
@@ -471,7 +497,7 @@ export function handleCreate(params: ManagementParams, ctx: ManagementContext):
471
497
  const sw = skillsWarning(ctx.cwd, agent.skills);
472
498
  if (sw) warnings.push(sw);
473
499
  fs.writeFileSync(targetPath, serializeAgent(agent), "utf-8");
474
- return result([`Created agent '${name}' at ${targetPath}.`, ...warnings].join("\n"));
500
+ return result([`Created agent '${runtimeName}' at ${targetPath}.`, ...warnings].join("\n"));
475
501
  }
476
502
 
477
503
  export function handleUpdate(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
@@ -491,14 +517,22 @@ export function handleUpdate(params: ManagementParams, ctx: ManagementContext):
491
517
  const oldName = target.name;
492
518
  if (hasKey(cfg, "name") && (typeof cfg.name !== "string" || !cfg.name.trim())) return result("config.name must be a non-empty string when provided.", true);
493
519
  if (hasKey(cfg, "description") && (typeof cfg.description !== "string" || !cfg.description.trim())) return result("config.description must be a non-empty string when provided.", true);
494
- let newName: string | undefined;
520
+ let newLocalName = target.localName ?? frontmatterNameForConfig(target);
495
521
  if (hasKey(cfg, "name")) {
496
- newName = sanitizeName(cfg.name as string);
497
- if (!newName) return result("config.name is invalid after sanitization.", true);
522
+ newLocalName = sanitizeName(cfg.name as string);
523
+ if (!newLocalName) return result("config.name is invalid after sanitization.", true);
524
+ }
525
+ let newPackageName = target.packageName;
526
+ if (hasKey(cfg, "package")) {
527
+ const parsedPackage = parsePackageConfig(cfg.package);
528
+ if (parsedPackage.error) return result(parsedPackage.error, true);
529
+ newPackageName = parsedPackage.packageName;
498
530
  }
499
531
  const applyError = applyAgentConfig(updated, cfg);
500
532
  if (applyError) return result(applyError, true);
501
- if (newName !== undefined) updated.name = newName;
533
+ updated.localName = newLocalName;
534
+ updated.packageName = newPackageName;
535
+ updated.name = buildRuntimeName(newLocalName, newPackageName);
502
536
  if (hasKey(cfg, "description")) updated.description = (cfg.description as string).trim();
503
537
  if (hasKey(cfg, "model")) {
504
538
  const mw = modelWarning(ctx, updated.model);
@@ -535,10 +569,16 @@ export function handleUpdate(params: ManagementParams, ctx: ManagementContext):
535
569
  const oldName = target.name;
536
570
  if (hasKey(cfg, "name") && (typeof cfg.name !== "string" || !cfg.name.trim())) return result("config.name must be a non-empty string when provided.", true);
537
571
  if (hasKey(cfg, "description") && (typeof cfg.description !== "string" || !cfg.description.trim())) return result("config.description must be a non-empty string when provided.", true);
538
- let newName: string | undefined;
572
+ let newLocalName = target.localName ?? frontmatterNameForConfig(target);
539
573
  if (hasKey(cfg, "name")) {
540
- newName = sanitizeName(cfg.name as string);
541
- if (!newName) return result("config.name is invalid after sanitization.", true);
574
+ newLocalName = sanitizeName(cfg.name as string);
575
+ if (!newLocalName) return result("config.name is invalid after sanitization.", true);
576
+ }
577
+ let newPackageName = target.packageName;
578
+ if (hasKey(cfg, "package")) {
579
+ const parsedPackage = parsePackageConfig(cfg.package);
580
+ if (parsedPackage.error) return result(parsedPackage.error, true);
581
+ newPackageName = parsedPackage.packageName;
542
582
  }
543
583
  let parsedSteps: ChainStepConfig[] | undefined;
544
584
  if (hasKey(cfg, "steps")) {
@@ -546,7 +586,9 @@ export function handleUpdate(params: ManagementParams, ctx: ManagementContext):
546
586
  if (parsed.error) return result(parsed.error, true);
547
587
  parsedSteps = parsed.steps!;
548
588
  }
549
- if (newName !== undefined) updated.name = newName;
589
+ updated.localName = newLocalName;
590
+ updated.packageName = newPackageName;
591
+ updated.name = buildRuntimeName(newLocalName, newPackageName);
550
592
  if (hasKey(cfg, "description")) updated.description = (cfg.description as string).trim();
551
593
  if (parsedSteps) {
552
594
  updated.steps = parsedSteps;
@@ -1,8 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AgentConfig } from "./agents.ts";
3
+ import { frontmatterNameForConfig } from "./identity.ts";
3
4
 
4
5
  export const KNOWN_FIELDS = new Set([
5
6
  "name",
7
+ "package",
6
8
  "description",
7
9
  "tools",
8
10
  "model",
@@ -30,7 +32,8 @@ function joinComma(values: string[] | undefined): string | undefined {
30
32
  export function serializeAgent(config: AgentConfig): string {
31
33
  const lines: string[] = [];
32
34
  lines.push("---");
33
- lines.push(`name: ${config.name}`);
35
+ lines.push(`name: ${frontmatterNameForConfig(config)}`);
36
+ if (config.packageName) lines.push(`package: ${config.packageName}`);
34
37
  lines.push(`description: ${config.description}`);
35
38
 
36
39
  const tools = [
@@ -6,10 +6,13 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import type { OutputMode } from "../shared/types.ts";
9
10
  import { KNOWN_FIELDS } from "./agent-serializer.ts";
10
11
  import { parseChain } from "./chain-serializer.ts";
11
12
  import { mergeAgentsForScope } from "./agent-selection.ts";
12
13
  import { parseFrontmatter } from "./frontmatter.ts";
14
+ import { buildRuntimeName, parsePackageName } from "./identity.ts";
15
+ export { buildRuntimeName, frontmatterNameForConfig, parsePackageName } from "./identity.ts";
13
16
 
14
17
  export type AgentScope = "user" | "project" | "both";
15
18
 
@@ -66,6 +69,8 @@ interface BuiltinAgentOverrideInfo {
66
69
 
67
70
  export interface AgentConfig {
68
71
  name: string;
72
+ localName?: string;
73
+ packageName?: string;
69
74
  description: string;
70
75
  tools?: string[];
71
76
  mcpDirectTools?: string[];
@@ -102,6 +107,7 @@ export interface ChainStepConfig {
102
107
  agent: string;
103
108
  task: string;
104
109
  output?: string | false;
110
+ outputMode?: OutputMode;
105
111
  reads?: string[] | false;
106
112
  model?: string;
107
113
  skills?: string[] | false;
@@ -110,6 +116,8 @@ export interface ChainStepConfig {
110
116
 
111
117
  export interface ChainConfig {
112
118
  name: string;
119
+ localName?: string;
120
+ packageName?: string;
113
121
  description: string;
114
122
  source: AgentSource;
115
123
  filePath: string;
@@ -505,26 +513,34 @@ export function removeBuiltinAgentOverride(cwd: string, name: string, scope: "us
505
513
  return filePath;
506
514
  }
507
515
 
508
- function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
509
- const agents: AgentConfig[] = [];
510
-
511
- if (!fs.existsSync(dir)) {
512
- return agents;
513
- }
516
+ function listMarkdownFilesRecursive(dir: string, predicate: (fileName: string) => boolean): string[] {
517
+ const files: string[] = [];
518
+ if (!fs.existsSync(dir)) return files;
514
519
 
515
520
  let entries: fs.Dirent[];
516
521
  try {
517
- entries = fs.readdirSync(dir, { withFileTypes: true });
522
+ entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
518
523
  } catch {
519
- return agents;
524
+ return files;
520
525
  }
521
526
 
522
527
  for (const entry of entries) {
523
- if (!entry.name.endsWith(".md")) continue;
524
- if (entry.name.endsWith(".chain.md")) continue;
528
+ const filePath = path.join(dir, entry.name);
529
+ if (entry.isDirectory()) {
530
+ files.push(...listMarkdownFilesRecursive(filePath, predicate));
531
+ continue;
532
+ }
525
533
  if (!entry.isFile() && !entry.isSymbolicLink()) continue;
534
+ if (!predicate(entry.name)) continue;
535
+ files.push(filePath);
536
+ }
537
+ return files;
538
+ }
526
539
 
527
- const filePath = path.join(dir, entry.name);
540
+ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
541
+ const agents: AgentConfig[] = [];
542
+
543
+ for (const filePath of listMarkdownFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) {
528
544
  let content: string;
529
545
  try {
530
546
  content = fs.readFileSync(filePath, "utf-8");
@@ -538,6 +554,12 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
538
554
  continue;
539
555
  }
540
556
 
557
+ const localName = frontmatter.name;
558
+ const parsedPackage = parsePackageName(frontmatter.package, `Agent '${localName}' package`);
559
+ if (parsedPackage.error) continue;
560
+ const packageName = parsedPackage.packageName;
561
+ const runtimeName = buildRuntimeName(localName, packageName);
562
+
541
563
  const rawTools = frontmatter.tools
542
564
  ?.split(",")
543
565
  .map((t) => t.trim())
@@ -573,12 +595,12 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
573
595
  ? "replace"
574
596
  : frontmatter.systemPromptMode === "append"
575
597
  ? "append"
576
- : defaultSystemPromptMode(frontmatter.name);
598
+ : defaultSystemPromptMode(localName);
577
599
  const inheritProjectContext = frontmatter.inheritProjectContext === "true"
578
600
  ? true
579
601
  : frontmatter.inheritProjectContext === "false"
580
602
  ? false
581
- : defaultInheritProjectContext(frontmatter.name);
603
+ : defaultInheritProjectContext(localName);
582
604
  const inheritSkills = frontmatter.inheritSkills === "true"
583
605
  ? true
584
606
  : frontmatter.inheritSkills === "false"
@@ -606,7 +628,9 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
606
628
  const parsedMaxSubagentDepth = Number(frontmatter.maxSubagentDepth);
607
629
 
608
630
  agents.push({
609
- name: frontmatter.name,
631
+ name: runtimeName,
632
+ localName,
633
+ packageName,
610
634
  description: frontmatter.description,
611
635
  tools: tools.length > 0 ? tools : undefined,
612
636
  mcpDirectTools: mcpDirectTools.length > 0 ? mcpDirectTools : undefined,
@@ -640,22 +664,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
640
664
  function loadChainsFromDir(dir: string, source: AgentSource): ChainConfig[] {
641
665
  const chains: ChainConfig[] = [];
642
666
 
643
- if (!fs.existsSync(dir)) {
644
- return chains;
645
- }
646
-
647
- let entries: fs.Dirent[];
648
- try {
649
- entries = fs.readdirSync(dir, { withFileTypes: true });
650
- } catch {
651
- return chains;
652
- }
653
-
654
- for (const entry of entries) {
655
- if (!entry.name.endsWith(".chain.md")) continue;
656
- if (!entry.isFile() && !entry.isSymbolicLink()) continue;
657
-
658
- const filePath = path.join(dir, entry.name);
667
+ for (const filePath of listMarkdownFilesRecursive(dir, (fileName) => fileName.endsWith(".chain.md"))) {
659
668
  let content: string;
660
669
  try {
661
670
  content = fs.readFileSync(filePath, "utf-8");