@maxiaochao/pi-toolkit 0.12.0 → 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,8 @@
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
+
1
6
  ## 2026-10-15 - v0.12.0
2
7
 
3
8
  - herdr-subagents v0.6.0: require explicit task Personas and clarify concise tool descriptions
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 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`. 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.12.0",
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,8 @@
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
+
1
6
  ## 2026-10-15 - v0.6.0
2
7
 
3
8
  - require explicit task Personas and clarify concise tool descriptions
@@ -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,6 +8,8 @@ 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;
@@ -28,7 +30,7 @@ It verifies:
28
30
 
29
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.
30
32
 
31
- 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`:
32
34
 
33
35
  ```sh
34
36
  npm run load-test --workspace @maxiaochao/pi-herdr-subagents
@@ -40,7 +42,7 @@ Run the real Herdr smoke test from a Herdr session:
40
42
  npm run test:real-herdr --workspace @maxiaochao/pi-herdr-subagents
41
43
  ```
42
44
 
43
- 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.
44
46
 
45
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.
46
48
 
@@ -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");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.6.0",
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"
@@ -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 };
@@ -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
+ }
@@ -236,15 +236,19 @@ assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are im
236
236
  assert.equal(registeredTools.get("herdr_subagents")?.exposure, undefined, "Unconfigured Subagent execution must inherit Pi's exposure default");
237
237
  assert.equal(registeredTools.get("herdr_subagents_control")?.exposure, undefined, "Unconfigured Subagent control must inherit Pi's exposure default");
238
238
  mkdirSync(join(lifecycleRoot, ".pi"), { recursive: true });
239
- 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: [] }));
240
240
  const configuredTools = new Map();
241
+ const configuredCommands = new Map();
241
242
  registerHerdrSubagents({
242
243
  on() {},
243
244
  registerTool(tool) { configuredTools.set(tool.name, tool); },
244
- registerCommand() {},
245
+ registerCommand(name, command) { configuredCommands.set(name, command); },
245
246
  }, { herdr: {}, isEditor: () => false, configurationCwd: lifecycleRoot, configurationPaths });
246
247
  assert.equal(configuredTools.get("herdr_subagents")?.exposure, "model-only", "Project configuration can exclude Subagent execution from Codemode");
247
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");
248
252
  }
249
253
 
250
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);