@maxiaochao/pi-toolkit 0.11.22 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 2026-10-19 - v0.12.1
2
+
3
+ - herdr-subagents v0.7.0: add global and project default extension sources with Persona overrides
4
+ - Add Herdr Subagents `defaultExtensions` in global/project settings as a fallback below Persona-specific extension lists, with replacement semantics, explicit `[]` clearing, source diagnostics, and real child-loading coverage.
5
+
6
+ ## 2026-10-15 - v0.12.0
7
+
8
+ - herdr-subagents v0.6.0: require explicit task Personas and clarify concise tool descriptions
9
+ - Require an explicit Herdr Subagents Persona for every new task, clarify concise tool descriptions, and identify tasks with conflicting write access in parallel requests.
10
+
1
11
  ## 2026-10-15 - v0.11.22
2
12
 
3
13
  - herdr-subagents v0.5.4: preserve configured model and authentication extensions in isolated real-Herdr smoke tests
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maxiaochao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -39,7 +39,7 @@ Restart Pi after updating. `pi update --extensions` has the same effect when upd
39
39
 
40
40
  Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
41
41
 
42
- Herdr Subagents personas can opt into local extension files or npm/git Pi packages through `extensions`. This uses native Pi package semantics: package-provided skills, prompt templates, and themes may also load, and `--no-skills` does not exclude explicitly selected package skills. See the [extension-source contract](packages/herdr-subagents/README.md#explicit-extension-sources-and-pi-packages) for precedence, isolation, and examples. The [real Herdr smoke test](packages/herdr-subagents/TESTING.md) preserves the configured analyst model and explicit authentication extensions while isolating unrelated settings.
42
+ Herdr Subagents tasks require an explicit `agent` Persona; no default `worker` is selected. Personas can opt into local extension files or npm/git Pi packages through `extensions`. Set top-level `defaultExtensions` in `~/.pi/agent/herdr-subagents.json` (or project `.pi/herdr-subagents.json`) to share a fallback list; Persona-specific lists take precedence, and `[]` clears inherited sources. This uses native Pi package semantics: package-provided skills, prompt templates, and themes may also load, and `--no-skills` does not exclude explicitly selected package skills. See the [extension-source contract](packages/herdr-subagents/README.md#explicit-extension-sources-and-pi-packages) for precedence, isolation, and examples. The [real Herdr smoke test](packages/herdr-subagents/TESTING.md) preserves the configured analyst model and explicit authentication extensions while isolating unrelated settings.
43
43
 
44
44
  Multiple standalone children may be installed together. `pi-openai-account-pool` may also be installed alongside the complete toolkit because the toolkit does not embed it. Every other child in the table above is embedded, so a second install exposes the same resource twice.
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.11.22",
3
+ "version": "0.12.1",
4
4
  "description": "Personal pi coding agent toolkit: scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -69,5 +69,6 @@
69
69
  },
70
70
  "publishConfig": {
71
71
  "access": "public"
72
- }
72
+ },
73
+ "license": "MIT"
73
74
  }
@@ -1,3 +1,13 @@
1
+ ## 2026-10-19 - v0.7.0
2
+
3
+ - add global and project default extension sources with Persona overrides
4
+ - Add Herdr Subagents `defaultExtensions` in global/project settings as a fallback below Persona-specific extension lists, with replacement semantics, explicit `[]` clearing, source diagnostics, and real child-loading coverage.
5
+
6
+ ## 2026-10-15 - v0.6.0
7
+
8
+ - require explicit task Personas and clarify concise tool descriptions
9
+ - Require an explicit Herdr Subagents Persona for every new task, clarify concise tool descriptions, and identify tasks with conflicting write access in parallel requests.
10
+
1
11
  ## 2026-10-15 - v0.5.4
2
12
 
3
13
  - preserve configured model and authentication extensions in isolated real-Herdr smoke tests
@@ -26,7 +26,7 @@ The tools and command are registered only when Pi is running inside Herdr with a
26
26
 
27
27
  ## Execution contract
28
28
 
29
- `herdr_subagents` accepts an ordered, non-empty `tasks` array.
29
+ `herdr_subagents` accepts an ordered, non-empty `tasks` array. Every task must explicitly set `agent`; there is no default Persona.
30
30
 
31
31
  - `concurrency: 1` runs tasks strictly in input order.
32
32
  - `concurrency: N` runs at most N read-only tasks at once.
@@ -110,7 +110,7 @@ Starts one ordered batch and waits until every task reaches a terminal state. It
110
110
  | `concurrency` | No | `defaultConcurrency` | Maximum number of simultaneously active read-only tasks. It is capped by `maxConcurrency`. |
111
111
  | `tasks[].name` | Yes | — | Short pane/task name, at most 48 characters. |
112
112
  | `tasks[].prompt` | Yes | — | Self-contained task instructions. |
113
- | `tasks[].agent` | No | `"worker"` | Effective Persona name. |
113
+ | `tasks[].agent` | Yes | — | Effective Persona name; use control `list` to inspect available Personas. |
114
114
  | `tasks[].model` | No | Persona/default model | Model override for this task. |
115
115
 
116
116
  A batch containing a write-capable Persona must explicitly or effectively use `concurrency: 1`; an unsafe parallel request is rejected.
@@ -170,6 +170,7 @@ These are all supported top-level fields in `herdr-subagents.json`:
170
170
  | `stalledWarningSeconds` | Positive integer | `300` | Emit a warning after a child runs this long. This is not a timeout and does not stop it. |
171
171
  | `defaultModel` | Non-empty Pi model ID, normally `provider/model` | Unset | Fallback child model. If unset, the parent model is inherited. |
172
172
  | `defaultThinking` | Non-empty Pi thinking value | Unset | Fallback thinking level below Persona-specific settings. Typical Pi values are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; model support varies. |
173
+ | `defaultExtensions` | Array of non-empty Pi source strings | Unset (no optional extensions) | Fallback extension sources for Personas without an explicit `extensions` list. Project replaces global; `[]` clears inherited defaults. |
173
174
  | `personas` | Object keyed by an existing Persona name | `{}` | Per-Persona overrides. It cannot create a Persona without a matching Markdown definition. |
174
175
 
175
176
  Each `personas.<name>` object supports:
@@ -213,12 +214,12 @@ Save this as `~/.pi/agent/herdr-subagents.json`. Model IDs, skills, and extensio
213
214
  "stalledWarningSeconds": 300,
214
215
  "defaultModel": "anthropic/claude-sonnet-4-5",
215
216
  "defaultThinking": "medium",
217
+ "defaultExtensions": ["~/.pi/agent/extensions/provider-routing.ts"],
216
218
  "personas": {
217
219
  "explorer": {
218
220
  "model": "anthropic/claude-haiku-4-5",
219
221
  "thinking": "low",
220
- "skills": ["code-search"],
221
- "extensions": ["~/.pi/agent/extensions/provider-routing.ts"]
222
+ "skills": ["code-search"]
222
223
  },
223
224
  "reviewer": {
224
225
  "thinking": "high",
@@ -247,7 +248,7 @@ A project can override only what differs in `.pi/herdr-subagents.json`:
247
248
  }
248
249
  ```
249
250
 
250
- Here the project keeps global `maxConcurrency`, `defaultModel`, and `defaultThinking`; replaces the explorer model and skill list; and replaces the reviewer extension list. It does not append arrays.
251
+ Here the project keeps global `maxConcurrency`, `defaultModel`, `defaultThinking`, and `defaultExtensions`; replaces the explorer model and skill list; and replaces the reviewer extension list. It does not append arrays.
251
252
 
252
253
  ### Built-in Personas
253
254
 
@@ -294,7 +295,11 @@ The Markdown body is the child system prompt. Frontmatter is intentionally a sma
294
295
  | `skills` | Comma-separated list, optionally `[bracketed]` | None | Explicit child skills; normal automatic skill discovery is disabled. |
295
296
  | `extensions` | One source or a comma-separated list; `[]` clears | None | Additional Pi extension/package sources loaded into the child. |
296
297
 
297
- Relative extension paths resolve from the project root. `~` resolves to the home directory. Pi-native local, `npm:`, `git:`, and `builtin:` sources are accepted. Children always load the required report extension and Codemode; configured sources are additional. Pi packages may provide resources beyond extensions, so load only trusted code and pin versions/refs when reproducibility matters.
298
+ ### Explicit extension sources and Pi packages
299
+
300
+ Use top-level `defaultExtensions` in `~/.pi/agent/herdr-subagents.json` to share trusted extension sources across Personas. Set the same field in `<project>/.pi/herdr-subagents.json` to replace the global default list for that project. Defaults are a fallback, not an appended list: any Persona-specific `extensions`, including Markdown frontmatter and explicit `[]`, takes precedence. An empty default list clears inherited defaults but does not remove Persona-specific sources or the mandatory child extensions.
301
+
302
+ Relative extension paths resolve from the project root, including paths in global `defaultExtensions`. `~` resolves to the home directory. Pi-native local, `npm:`, `git:`, and `builtin:` sources are accepted. Children always load the required report extension and Codemode; configured sources are additional. Pi packages may provide resources beyond extensions, so load only trusted code and pin versions/refs when reproducibility matters.
298
303
 
299
304
  ### Effective option precedence
300
305
 
@@ -315,7 +320,18 @@ Thinking is selected in this order:
315
320
  4. effective `defaultThinking` (project, then global)
316
321
  5. parent thinking when the child also inherits the parent model
317
322
 
318
- There is no task-level thinking override. `skills` and `extensions` use project Persona JSON, then global Persona JSON, then effective Persona Markdown. Each winning list replaces the whole lower-precedence list.
323
+ There is no task-level thinking override. `skills` uses project Persona JSON, then global Persona JSON, then effective Persona Markdown.
324
+
325
+ Extension sources are selected in this order:
326
+
327
+ 1. project `personas.<name>.extensions`
328
+ 2. global `personas.<name>.extensions`
329
+ 3. effective Persona Markdown `extensions`
330
+ 4. project `defaultExtensions`
331
+ 5. global `defaultExtensions`
332
+ 6. no optional extensions
333
+
334
+ Each winning list replaces the whole lower-precedence list, including when it is `[]`. The required report extension and Codemode always remain loaded. `/herdr-subagents settings` shows the effective defaults and their source; the agents listing shows each resolved extension list and its source.
319
335
 
320
336
  Use `/herdr-subagents agents`, `/herdr-subagents models`, and `/herdr-subagents settings` to inspect the result before starting work.
321
337
 
@@ -8,11 +8,15 @@ npm test --workspace @maxiaochao/pi-herdr-subagents
8
8
 
9
9
  It verifies:
10
10
 
11
+ - global/project `defaultExtensions` replacement, trimming, source attribution, Persona JSON/Markdown precedence, explicit empty-list clearing, invalid-value diagnostics, and per-call reloads;
12
+ - resolved defaults in control listings, settings output, and child CLI arguments while preserving mandatory extensions;
11
13
  - strict serial start order at concurrency one;
12
14
  - bounded read-only parallelism;
13
15
  - ordered results despite concurrent completion;
14
16
  - sibling continuation after failure and all-failed aggregation;
15
- - explicit rejection of parallel write-capable tasks;
17
+ - required explicit Personas in the tool schema and execution, including legacy single-task argument normalization;
18
+ - rejection of missing, blank, invalid, and unknown Personas before creating runs, while custom Personas remain supported;
19
+ - explicit rejection of parallel write-capable tasks with the conflicting task, Persona, and corrective action;
16
20
  - waiting through a delayed authoritative report after a prompt transport error while the Pi process is alive;
17
21
  - explicit failure when the process exits without a report;
18
22
  - parent cancellation of running and queued tasks;
@@ -26,7 +30,7 @@ It verifies:
26
30
 
27
31
  The previous broad lifecycle suite remains available temporarily as `npm run test:legacy --workspace @maxiaochao/pi-herdr-subagents` for migration auditing. Scenarios for background queues, blocked/respond, and historical resume intentionally no longer describe the public contract.
28
32
 
29
- Run extension source loading. The direct-Pi fixture validates explicit file/package sources; Herdr's `builtin:codemode` alias is validated by the real-Herdr smoke because the alias is translated by `herdr agent start`:
33
+ Run extension source loading. The direct-Pi fixture resolves global `defaultExtensions` and validates real file/package loading; Herdr's `builtin:codemode` alias is validated by the real-Herdr smoke because the alias is translated by `herdr agent start`:
30
34
 
31
35
  ```sh
32
36
  npm run load-test --workspace @maxiaochao/pi-herdr-subagents
@@ -38,7 +42,7 @@ Run the real Herdr smoke test from a Herdr session:
38
42
  npm run test:real-herdr --workspace @maxiaochao/pi-herdr-subagents
39
43
  ```
40
44
 
41
- The smoke reads the effective `analyst` model, thinking level, and explicit `extensions` from the invoking project's normal Herdr configuration (npm's `INIT_CWD`, or the current directory when run directly). It copies only those fields into its temporary configuration. It keeps the bundled read-only Persona, tool allowlist, and test concurrency; user skills, prompts, and unrelated settings are not copied. Local extension paths remain relative to the invoking project. The concurrent package-loading probe appends its test package rather than replacing the configured extensions.
45
+ The smoke reads the effective `analyst` model, thinking level, and explicit `extensions` from the invoking project's normal Herdr configuration (npm's `INIT_CWD`, or the current directory when run directly). It copies only those fields into its temporary configuration. It keeps the bundled read-only Persona, tool allowlist, and test concurrency; user skills, prompts, and unrelated settings are not copied. Local extension paths remain relative to the invoking project. The concurrent package-loading probe appends its test package rather than replacing the configured extensions. The isolated smoke supplies these sources through global `defaultExtensions`, so both single and concurrent runs exercise default resolution, actual child loading, durable task records, and tab cleanup.
42
46
 
43
47
  This preserves authentication extensions such as `npm:@maxiaochao/pi-openai-account-pool` in both the single and concurrent cases. Children still use `--no-extensions`: only the mandatory child extensions, configured explicit sources, and test probe load. The smoke does not copy or edit credentials. If no Herdr model is configured, Pi's normal startup model selection still applies.
44
48
 
@@ -42,7 +42,7 @@ export function normalizeHerdrSubagentsArguments(args: unknown): HerdrSubagentsR
42
42
  tasks: [{
43
43
  name: label || "task",
44
44
  prompt: legacy.prompt,
45
- ...(typeof legacy.agent === "string" ? { agent: legacy.agent } : {}),
45
+ agent: legacy.agent as string,
46
46
  ...(typeof legacy.model === "string" ? { model: legacy.model } : {}),
47
47
  }],
48
48
  ...(label ? { label } : {}),
@@ -89,7 +89,7 @@ async function handleHerdrCommand(args: string, ctx: ExtensionCommandContext, fl
89
89
  }
90
90
  if (section === "settings") {
91
91
  const settings = configuration.settings;
92
- const values: Record<string, string | number | undefined> = { defaultConcurrency: settings.defaultConcurrency, maxConcurrency: settings.maxConcurrency, stalledWarningSeconds: settings.stalledWarningSeconds, defaultModel: settings.defaultModel, defaultThinking: settings.defaultThinking };
92
+ const values: Record<string, string | number | undefined> = { defaultConcurrency: settings.defaultConcurrency, maxConcurrency: settings.maxConcurrency, stalledWarningSeconds: settings.stalledWarningSeconds, defaultModel: settings.defaultModel, defaultThinking: settings.defaultThinking, defaultExtensions: JSON.stringify(settings.defaultExtensions ?? []) };
93
93
  const lines = [`exposure=${registeredExposure.value ?? "Pi default"} (${registeredExposure.source}; loaded at extension registration)`, ...Object.entries(values).map(([key, value]) => `${key}=${String(value ?? "unset")} (${configuration.settingSources[key] ?? "built-in"})`)];
94
94
  if (configuration.diagnostics.length) lines.push("Diagnostics:", ...configuration.diagnostics);
95
95
  ctx.ui.notify(lines.join("\n"), "info");
@@ -148,7 +148,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
148
148
  name: "herdr_subagents",
149
149
  ...(exposure ? { exposure } : {}),
150
150
  label: "Herdr Subagents",
151
- description: "Run one or more ordered tasks in visible, focusable Herdr Pi panes. concurrency 1 is strict serial execution; higher values form a bounded pool for read-only tasks. Write-capable tasks require concurrency 1. The call returns only after every task has one terminal completed, failed, or cancelled result. Complete results and Pi sessions stay in project-local records.",
151
+ description: "Run ordered tasks in visible Herdr Pi panes, with an explicit agent for each task. concurrency 1 is serial; higher values require read-only Personas. Returns after all tasks complete, fail, or cancel; full results and sessions remain in project-local records.",
152
152
  promptSnippet: "Run focused work in visible Herdr subagents",
153
153
  parameters: HerdrSubagentsParams,
154
154
  prepareArguments: normalizeHerdrSubagentsArguments,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.5.4",
3
+ "version": "0.7.0",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -37,7 +37,7 @@
37
37
  "typebox": "*"
38
38
  },
39
39
  "scripts": {
40
- "test": "node --experimental-strip-types tests/core-state-machine.mjs && node --experimental-strip-types tests/real-herdr-config.test.mjs",
40
+ "test": "node --experimental-strip-types tests/config.mjs && node --experimental-strip-types tests/core-state-machine.mjs && node --experimental-strip-types tests/real-herdr-config.test.mjs",
41
41
  "test:real-herdr": "node --experimental-strip-types tests/real-herdr-smoke.mjs",
42
42
  "load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs",
43
43
  "test:legacy": "node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types tests/cleanup-timing.mjs"
@@ -92,7 +92,8 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
92
92
  const runId = options.runId ?? randomUUID();
93
93
  const requestedConcurrency = Math.max(1, Math.floor(options.concurrency));
94
94
  if (requestedConcurrency > 1 && tasks.some((task) => task.access === "write")) {
95
- throw new Error("Write-capable tasks require concurrency 1; parallel writes are not safe.");
95
+ const writers = tasks.filter((task) => task.access === "write").map((task) => `${JSON.stringify(task.name)} (agent=${task.agent})`).join(", ");
96
+ throw new Error(`Write-capable tasks require concurrency 1: ${writers}. Use concurrency: 1, or select a read-only Persona for read-only work.`);
96
97
  }
97
98
  const effectiveConcurrency = Math.min(requestedConcurrency, options.maxConcurrency, tasks.length);
98
99
  const runDirectory = join(options.cwd, ".pi", "herdr-subagents", "runs", `${timestamp().replace(/[-:.]/g, "").replace("Z", "Z-")}${runId.slice(0, 8)}`);
@@ -13,6 +13,7 @@ export interface SubagentConfig {
13
13
  stalledWarningSeconds: number;
14
14
  defaultModel?: string;
15
15
  defaultThinking?: string;
16
+ defaultExtensions?: string[];
16
17
  personas: Record<string, PersonaConfig>;
17
18
  }
18
19
  export interface EffectivePersona extends AgentDefinition {
@@ -40,6 +41,7 @@ interface ConfigFile {
40
41
  stalledWarningSeconds?: number;
41
42
  defaultModel?: string;
42
43
  defaultThinking?: string;
44
+ defaultExtensions?: string[];
43
45
  personas?: Record<string, PersonaConfig>;
44
46
  }
45
47
  export interface ConfigurationPaths {
@@ -81,6 +83,13 @@ function mergeConfig(base: SubagentConfig, input: ConfigFile, source: string, so
81
83
  if (typeof value === "string" && value.trim()) { result[key] = value.trim(); sources[key] = source; }
82
84
  else diagnostics.push(`${source} ${key} must be a non-empty string`);
83
85
  }
86
+ if (input.defaultExtensions !== undefined) {
87
+ const value = input.defaultExtensions;
88
+ if (Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim())) {
89
+ result.defaultExtensions = value.map((item) => item.trim());
90
+ sources.defaultExtensions = source;
91
+ } else diagnostics.push(`${source} defaultExtensions must be an array of non-empty strings`);
92
+ }
84
93
  if (input.personas !== undefined) {
85
94
  if (!input.personas || typeof input.personas !== "object" || Array.isArray(input.personas)) diagnostics.push(`${source} personas must be an object`);
86
95
  else for (const [name, raw] of Object.entries(input.personas)) {
@@ -134,8 +143,8 @@ export function loadSubagentConfiguration(cwd: string, packageAgentsDirectory: s
134
143
  const thinkingSource = projectPersona.thinking ? "project persona" : globalPersona.thinking ? "global persona" : definition.thinking ? `${source} persona` : settings.defaultThinking ? settingSources.defaultThinking : "default";
135
144
  const skills = projectPersona.skills ?? globalPersona.skills ?? definition.skills ?? [];
136
145
  const skillsSource = projectPersona.skills ? "project persona" : globalPersona.skills ? "global persona" : definition.skills ? `${source} persona` : "none";
137
- const extensions = projectPersona.extensions ?? globalPersona.extensions ?? definition.extensions ?? [];
138
- const extensionsSource = projectPersona.extensions ? "project persona" : globalPersona.extensions ? "global persona" : definition.extensions ? `${source} persona` : "none";
146
+ const extensions = projectPersona.extensions ?? globalPersona.extensions ?? definition.extensions ?? settings.defaultExtensions ?? [];
147
+ const extensionsSource = projectPersona.extensions ? "project persona" : globalPersona.extensions ? "global persona" : definition.extensions ? `${source} persona` : settings.defaultExtensions ? settingSources.defaultExtensions : "none";
139
148
  return { ...definition, source, access: definition.access ?? "write", model, modelSource, thinking, thinkingSource, skills, skillsSource, extensions, extensionsSource };
140
149
  }).sort((left, right) => left.name.localeCompare(right.name));
141
150
  return { settings, settingSources, personas, diagnostics };
@@ -4,14 +4,14 @@ import { Type } from "typebox";
4
4
  const HerdrSubagentsTaskParams = Type.Object({
5
5
  name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
6
6
  prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
7
- agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for this task. Defaults to worker." })),
7
+ agent: Type.String({ minLength: 1, description: "Persona name, use control list for available Personas." }),
8
8
  model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
9
9
  }, { additionalProperties: false });
10
10
 
11
11
  export const HerdrSubagentsParams = Type.Object({
12
12
  tasks: Type.Array(HerdrSubagentsTaskParams, { minItems: 1, description: "Ordered tasks to execute. Use one item for a single task; independent read-only tasks may run concurrently." }),
13
13
  label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
14
- concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents. Write-capable tasks force concurrency one." })),
14
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum active Agents. Values above 1 require read-only Personas; write-capable Personas require 1 or the batch is rejected." })),
15
15
  }, { additionalProperties: false });
16
16
 
17
17
  export const HerdrSubagentsControlParams = Type.Object({
@@ -7,7 +7,7 @@ import type { OwnerIdentity } from "./ownership.ts";
7
7
  import { cancelActiveSubagentRuns } from "./reconcile.ts";
8
8
  import { getSubagentRunStatus, runStatusText } from "./status.ts";
9
9
 
10
- export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent?: string; model?: string; }
10
+ export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent: string; model?: string; }
11
11
 
12
12
  export interface HerdrSubagentsRunParams {
13
13
  tasks: HerdrSubagentsTaskParams[];
@@ -140,7 +140,7 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
140
140
  const tasks = params.tasks as HerdrSubagentsTaskParams[];
141
141
  const resolved: BatchTask[] = [];
142
142
  for (const task of tasks) {
143
- const personaName = task.agent?.trim() || "worker";
143
+ const personaName = task.agent.trim();
144
144
  const persona = discovery.personas.find((candidate) => candidate.name === personaName);
145
145
  if (!persona) return errorResponse(`Unknown persona '${personaName}'.\n\n${listText(discovery)}`);
146
146
  resolved.push({ name: task.name, prompt: task.prompt, agent: persona.name, access: persona.access, model: task.model?.trim() || persona.model, thinking: persona.thinking ?? (persona.modelSource === "parent session" && !task.model ? ctx.thinkingLevel : undefined), tools: persona.tools, skills: persona.skills, extensions: persona.extensions, systemPrompt: persona.systemPrompt });
@@ -10,6 +10,9 @@ export const HERDR_ACTIONS = {
10
10
 
11
11
  export function validateRunParams(params: HerdrSubagentsRunParams): string | undefined {
12
12
  if (!params.tasks?.length) return "Provide a non-empty tasks list.";
13
+ for (const [index, task] of params.tasks.entries()) {
14
+ if (typeof task.agent !== "string" || !task.agent.trim()) return `tasks[${index}].agent is required; select a Persona explicitly (use herdr_subagents_control action=list).`;
15
+ }
13
16
  return undefined;
14
17
  }
15
18
 
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadSubagentConfiguration } from "../src/config.ts";
6
+ import { childPiArgs } from "../src/live-agent.ts";
7
+ import { executeHerdrSubagentsControl } from "../src/tool.ts";
8
+
9
+ const root = mkdtempSync(join(tmpdir(), "herdr-default-extensions-"));
10
+ const agents = join(root, "agents");
11
+ const paths = {
12
+ globalConfig: join(root, "global.json"),
13
+ projectConfig: join(root, "project.json"),
14
+ globalAgents: join(root, "global-agents"),
15
+ projectAgents: join(root, "project-agents"),
16
+ };
17
+ const write = (path, value) => writeFileSync(path, JSON.stringify(value));
18
+ const load = () => loadSubagentConfiguration(root, agents, undefined, paths);
19
+ const persona = (configuration, name = "plain") => configuration.personas.find((entry) => entry.name === name);
20
+ try {
21
+ mkdirSync(agents);
22
+ for (const [name, frontmatter] of [["plain", ""], ["pinned", "extensions: ./markdown.ts\n"], ["cleared", "extensions: []\n"]]) {
23
+ writeFileSync(join(agents, `${name}.md`), `---\nname: ${name}\ndescription: Configuration fixture\naccess: read\n${frontmatter}---\nRead only.\n`);
24
+ }
25
+ assert.deepEqual(persona(load()).extensions, []);
26
+ assert.equal(persona(load()).extensionsSource, "none");
27
+ write(paths.globalConfig, { defaultExtensions: [" ./global.ts ", "npm:provider@1.0.0"] });
28
+ let configuration = load();
29
+ assert.deepEqual(configuration.settings.defaultExtensions, ["./global.ts", "npm:provider@1.0.0"]);
30
+ assert.equal(configuration.settingSources.defaultExtensions, "global");
31
+ assert.deepEqual(persona(configuration).extensions, configuration.settings.defaultExtensions);
32
+ assert.equal(persona(configuration).extensionsSource, "global");
33
+ assert.deepEqual(persona(configuration, "pinned").extensions, ["./markdown.ts"]);
34
+ assert.deepEqual(persona(configuration, "cleared").extensions, []);
35
+
36
+ write(paths.projectConfig, { defaultExtensions: ["./project.ts"] });
37
+ configuration = load();
38
+ assert.deepEqual(persona(configuration).extensions, ["./project.ts"]);
39
+ assert.equal(persona(configuration).extensionsSource, "project");
40
+ assert.equal(configuration.settingSources.defaultExtensions, "project");
41
+ assert.deepEqual(persona(configuration, "pinned").extensions, ["./markdown.ts"]);
42
+ assert.deepEqual(persona(configuration, "cleared").extensions, []);
43
+ console.log("PASS project defaults replace global defaults; Markdown lists and [] override defaults");
44
+
45
+ write(paths.globalConfig, { defaultExtensions: ["./global.ts"], personas: { plain: { extensions: ["./global-persona.ts"] }, pinned: { extensions: [] } } });
46
+ assert.deepEqual(persona(load(), "pinned").extensions, []);
47
+ assert.equal(persona(load(), "pinned").extensionsSource, "global persona");
48
+ assert.deepEqual(persona(load()).extensions, ["./global-persona.ts"]);
49
+ assert.equal(persona(load()).extensionsSource, "global persona");
50
+ write(paths.projectConfig, { defaultExtensions: ["./project.ts"], personas: { plain: { extensions: ["./project-persona.ts"] }, pinned: { extensions: ["./project-persona.ts"] } } });
51
+ assert.deepEqual(persona(load(), "pinned").extensions, ["./project-persona.ts"]);
52
+ assert.deepEqual(persona(load()).extensions, ["./project-persona.ts"]);
53
+ assert.equal(persona(load()).extensionsSource, "project persona");
54
+ write(paths.projectConfig, { defaultExtensions: ["./project.ts"], personas: { plain: { extensions: [] } } });
55
+ assert.deepEqual(persona(load()).extensions, []);
56
+ assert.equal(persona(load()).extensionsSource, "project persona");
57
+ console.log("PASS Persona JSON lists win over defaults and [] clears rather than inherits");
58
+
59
+ write(paths.globalConfig, { defaultExtensions: ["./global.ts"] });
60
+ write(paths.projectConfig, { defaultExtensions: [] });
61
+ assert.deepEqual(load().settings.defaultExtensions, []);
62
+ assert.deepEqual(persona(load()).extensions, []);
63
+ assert.equal(persona(load()).extensionsSource, "project");
64
+ assert.deepEqual(persona(load(), "pinned").extensions, ["./markdown.ts"]);
65
+ for (const value of [null, "./invalid.ts", {}, [1], [""], [" \t "], ["./valid.ts", false]]) {
66
+ write(paths.projectConfig, { defaultExtensions: value });
67
+ configuration = load();
68
+ assert.deepEqual(persona(configuration).extensions, ["./global.ts"]);
69
+ assert.equal(configuration.settingSources.defaultExtensions, "global");
70
+ assert.ok(configuration.diagnostics.includes("project defaultExtensions must be an array of non-empty strings"));
71
+ write(paths.globalConfig, { defaultExtensions: value });
72
+ write(paths.projectConfig, {});
73
+ configuration = load();
74
+ assert.deepEqual(persona(configuration).extensions, []);
75
+ assert.ok(configuration.diagnostics.includes("global defaultExtensions must be an array of non-empty strings"));
76
+ write(paths.globalConfig, { defaultExtensions: ["./global.ts"] });
77
+ }
78
+ console.log("PASS empty defaults clear inherited defaults; invalid lists are diagnosed without partial loading");
79
+
80
+ write(paths.projectConfig, { defaultExtensions: ["./updated.ts"] });
81
+ configuration = load();
82
+ const selected = persona(configuration);
83
+ const args = childPiArgs({ ...selected, sessionFile: join(root, "session.jsonl") }, join(root, "system.md"));
84
+ assert.ok(args.includes("--no-extensions"));
85
+ assert.ok(args.includes("builtin:codemode"));
86
+ assert.ok(args.some((arg) => arg.endsWith("/extensions/subagent-report.ts")));
87
+ assert.equal(args[args.indexOf("./updated.ts") - 1], "--extension");
88
+ const response = await executeHerdrSubagentsControl({ action: "list" }, undefined, undefined, { cwd: root }, { agentsDirectory: agents, configurationPaths: paths });
89
+ assert.deepEqual(response.details.agents.find(({ name }) => name === "plain").extensions, ["./updated.ts"]);
90
+ assert.deepEqual(response.details.settings.defaultExtensions, ["./updated.ts"]);
91
+ assert.match(response.content[0].text, /extensions: \.\/updated\.ts \[project\]/);
92
+ write(paths.projectConfig, { defaultExtensions: [] });
93
+ const cleared = persona(load());
94
+ assert.deepEqual(cleared.extensions, []);
95
+ const clearedArgs = childPiArgs({ ...cleared, sessionFile: join(root, "session.jsonl") }, join(root, "system.md"));
96
+ assert.ok(clearedArgs.includes("builtin:codemode"));
97
+ assert.ok(clearedArgs.some((arg) => arg.endsWith("/extensions/subagent-report.ts")));
98
+ assert.ok(!clearedArgs.includes("./updated.ts"));
99
+ assert.deepEqual(selected.extensions, ["./updated.ts"]);
100
+ console.log("PASS defaults reach child arguments and control listings, reload per call, and preserve mandatory extensions");
101
+ } finally {
102
+ rmSync(root, { recursive: true, force: true });
103
+ }
@@ -1,11 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { runHerdrSubagentsBatch } from "../src/batch-runner.ts";
6
7
  import registerSubagentReport from "../extensions/subagent-report.ts";
7
- import { registerHerdrSubagents } from "../extensions/herdr-subagents.ts";
8
+ import { normalizeHerdrSubagentsArguments, registerHerdrSubagents } from "../extensions/herdr-subagents.ts";
8
9
  import { applyTaskStatus } from "../src/records.ts";
10
+ import { executeHerdrSubagents } from "../src/tool.ts";
9
11
 
10
12
  process.env.HERDR_ENV = "1";
11
13
  process.env.HERDR_WORKSPACE_ID = "test-workspace";
@@ -92,6 +94,45 @@ async function run(tasks, concurrency, herdr = fakeHerdr(), signal) {
92
94
  return { result, herdr };
93
95
  }
94
96
 
97
+ {
98
+ const herdr = fakeHerdr();
99
+ const execute = (params) => executeHerdrSubagents(params, undefined, undefined, { cwd: root }, {
100
+ agentsDirectory: fileURLToPath(new URL("../agents/", import.meta.url)),
101
+ configurationPaths: { globalConfig: join(root, "global-config.json") },
102
+ herdr,
103
+ });
104
+ const missing = { name: "review", prompt: "Read-only review" };
105
+ for (const params of [
106
+ { tasks: [missing] },
107
+ ...["", " \t", null, 42].map((agent) => ({ tasks: [{ ...missing, agent }] })),
108
+ normalizeHerdrSubagentsArguments({ prompt: "Read-only review" }),
109
+ normalizeHerdrSubagentsArguments({ prompt: "Read-only review", agent: 42 }),
110
+ ]) {
111
+ const response = await execute(params);
112
+ assert.equal(response.isError, true, "Missing or invalid agent must not silently select worker");
113
+ assert.match(response.content[0].text, /tasks\[0\]\.agent.*required/);
114
+ }
115
+ const laterMissing = await execute({ tasks: [{ ...missing, agent: "analyst" }, missing], concurrency: 2 });
116
+ assert.match(laterMissing.content[0].text, /tasks\[1\]\.agent.*required/);
117
+ const unknown = await execute({ tasks: [{ ...missing, agent: "unknown-persona" }] });
118
+ assert.equal(unknown.isError, true);
119
+ assert.match(unknown.content[0].text, /Unknown persona 'unknown-persona'/);
120
+ assert.deepEqual(herdr.starts, [], "Invalid requests must not start an Agent");
121
+ assert.equal(existsSync(join(root, ".pi", "herdr-subagents", "runs")), false, "Invalid requests must not create run records");
122
+
123
+ const serial = await execute(normalizeHerdrSubagentsArguments({ prompt: "make change", agent: "worker" }));
124
+ assert.equal(serial.details.status, "completed", "Explicit worker must still run serially through legacy argument normalization");
125
+ const parallel = await execute({ tasks: ["analyst", "reviewer", "explorer"].map((agent) => ({ ...missing, agent })), concurrency: 2 });
126
+ assert.equal(parallel.details.status, "completed");
127
+ assert.equal(parallel.details.effectiveConcurrency, 2);
128
+
129
+ const projectAgents = join(root, ".pi", "herdr-subagents", "agents");
130
+ mkdirSync(projectAgents, { recursive: true });
131
+ writeFileSync(join(projectAgents, "auditor.md"), "---\nname: auditor\ndescription: Custom read-only reviewer\naccess: read\n---\nReview the task.\n");
132
+ const custom = await execute({ tasks: [{ ...missing, agent: " auditor " }], concurrency: 2 });
133
+ assert.equal(custom.details.status, "completed", "Custom Persona names must remain valid and trim whitespace");
134
+ }
135
+
95
136
  {
96
137
  const { result, herdr } = await run([task("one", "first slow"), task("two", "second")], 1);
97
138
  assert.equal(result.status, "completed");
@@ -146,7 +187,12 @@ async function run(tasks, concurrency, herdr = fakeHerdr(), signal) {
146
187
  assert.deepEqual(result.activity.map((entry) => entry.status), ["cancelled", "cancelled"]);
147
188
  }
148
189
 
149
- await assert.rejects(() => run([task("writer", "edit", "write"), task("reader", "read")], 2), /require concurrency 1/);
190
+ await assert.rejects(() => run([task("writer", "edit", "write"), task("reader", "read")], 2), (error) => {
191
+ assert.match(error.message, /writer.*worker/);
192
+ assert.match(error.message, /concurrency: 1/);
193
+ assert.match(error.message, /read-only Persona/);
194
+ return true;
195
+ });
150
196
 
151
197
  const terminal = { id: "task", runId: "run", order: 1, status: "completed" };
152
198
  assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are immutable/);
@@ -184,18 +230,25 @@ assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are im
184
230
  });
185
231
  assert.deepEqual(herdrCalls, [], "Pi startup must not inspect or close historical Subagent tabs");
186
232
  assert.deepEqual(JSON.parse(readFileSync(join(runDirectory, "run.json"), "utf8")), historicalRun, "Pi startup must not mutate historical Subagent records");
233
+ const taskSchema = registeredTools.get("herdr_subagents").parameters.properties.tasks.items;
234
+ assert.ok(taskSchema.required.includes("agent"), "The model-facing schema must require agent");
235
+ assert.equal(taskSchema.properties.agent.enum, undefined, "Custom Persona names must remain valid");
187
236
  assert.equal(registeredTools.get("herdr_subagents")?.exposure, undefined, "Unconfigured Subagent execution must inherit Pi's exposure default");
188
237
  assert.equal(registeredTools.get("herdr_subagents_control")?.exposure, undefined, "Unconfigured Subagent control must inherit Pi's exposure default");
189
238
  mkdirSync(join(lifecycleRoot, ".pi"), { recursive: true });
190
- writeFileSync(join(lifecycleRoot, ".pi", "herdr-subagents.json"), JSON.stringify({ exposure: "model-only" }));
239
+ writeFileSync(join(lifecycleRoot, ".pi", "herdr-subagents.json"), JSON.stringify({ exposure: "model-only", defaultExtensions: [] }));
191
240
  const configuredTools = new Map();
241
+ const configuredCommands = new Map();
192
242
  registerHerdrSubagents({
193
243
  on() {},
194
244
  registerTool(tool) { configuredTools.set(tool.name, tool); },
195
- registerCommand() {},
245
+ registerCommand(name, command) { configuredCommands.set(name, command); },
196
246
  }, { herdr: {}, isEditor: () => false, configurationCwd: lifecycleRoot, configurationPaths });
197
247
  assert.equal(configuredTools.get("herdr_subagents")?.exposure, "model-only", "Project configuration can exclude Subagent execution from Codemode");
198
248
  assert.equal(configuredTools.get("herdr_subagents_control")?.exposure, "model-only", "Project configuration applies consistently to both Subagent tools");
249
+ const notifications = [];
250
+ await configuredCommands.get("herdr-subagents").handler("settings", { cwd: lifecycleRoot, ui: { notify(message) { notifications.push(message); } } });
251
+ assert.match(notifications[0], /defaultExtensions=\[\] \(project\)/, "Settings must show empty defaults and their source");
199
252
  }
200
253
 
201
254
  {
@@ -4,6 +4,8 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { childPiArgs } from "../src/live-agent.ts";
7
+ import { loadSubagentConfiguration } from "../src/config.ts";
8
+ import { fileURLToPath } from "node:url";
7
9
 
8
10
  // Exercise native Pi package semantics without provider calls or network installs.
9
11
  const root = mkdtempSync(join(tmpdir(), "pi-herdr-extension-sources-"));
@@ -36,7 +38,12 @@ try {
36
38
  ctx.shutdown();
37
39
  });
38
40
  }`);
39
- const args = childPiArgs({ id: "probe", sessionFile: join(root, "session.jsonl"), tools: ["read"], extensions: [fileExtension, "./local package", probe] }, join(root, "system.md"));
41
+ const globalConfig = join(root, "global.json");
42
+ writeFileSync(globalConfig, JSON.stringify({ defaultExtensions: [fileExtension, "./local package", probe] }));
43
+ const configuration = loadSubagentConfiguration(root, fileURLToPath(new URL("../agents", import.meta.url)), undefined, { globalConfig, globalAgents: join(root, "global-agents") });
44
+ const analyst = configuration.personas.find(({ name }) => name === "analyst");
45
+ assert.equal(analyst.extensionsSource, "global");
46
+ const args = childPiArgs({ id: "probe", sessionFile: join(root, "session.jsonl"), tools: ["read"], extensions: analyst.extensions }, join(root, "system.md"));
40
47
  // `builtin:codemode` is translated by `herdr agent start` and is covered by
41
48
  // the real-Herdr smoke test. Direct Pi has no portable form of that alias.
42
49
  const codemode = args.indexOf("builtin:codemode");
@@ -50,7 +57,7 @@ try {
50
57
  const loaded = JSON.parse(readFileSync(resultPath, "utf8"));
51
58
  for (const command of ["file-extension", "package-extension", "skill:package-skill", "package-prompt", "parent"]) assert.ok(loaded.commands.includes(command), `Missing ${command}: ${JSON.stringify(loaded)}`);
52
59
  assert.deepEqual([...loaded.tools].sort(), ["read", "subagent_report"]);
53
- console.log("PASS real Pi loads explicit file and package sources, including package skills/prompts despite discovery flags, without broadening the direct-Pi tool allowlist");
60
+ console.log("PASS real Pi loads defaultExtensions file and package sources, including package skills/prompts despite discovery flags, without broadening the direct-Pi tool allowlist");
54
61
  } finally {
55
62
  rmSync(root, { recursive: true, force: true });
56
63
  }
@@ -21,8 +21,8 @@ try {
21
21
  maxConcurrency: 1,
22
22
  defaultModel: "openai-codex/test-model",
23
23
  defaultThinking: "high",
24
+ defaultExtensions: ["npm:@maxiaochao/pi-openai-account-pool"],
24
25
  personas: { analyst: {
25
- extensions: ["npm:@maxiaochao/pi-openai-account-pool"],
26
26
  skills: ["unrelated-skill"],
27
27
  } },
28
28
  });
@@ -97,7 +97,7 @@ async function waitForCleanup(result) {
97
97
 
98
98
  async function runCase(label, names, concurrency, extraExtensions = []) {
99
99
  const extensions = [...runtimePersona.extensions, ...extraExtensions];
100
- writeFileSync(configurationPaths.globalConfig, JSON.stringify({ personas: { analyst: { ...runtimePersona, extensions } } }));
100
+ writeFileSync(configurationPaths.globalConfig, JSON.stringify({ defaultExtensions: extensions, personas: { analyst: { model: runtimePersona.model, thinking: runtimePersona.thinking } } }));
101
101
  if (concurrency > 1 && process.env.HERDR_SUBAGENTS_E2E_FOCUS_CLEANUP === "1") {
102
102
  const workspaces = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["workspace", "list"], { encoding: "utf8", timeout: 10_000 });
103
103
  assert.equal(workspaces.status, 0, workspaces.stderr);