@mystilleef/pi-subagent 0.11.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/README.md CHANGED
@@ -25,7 +25,7 @@ pi -e npm:@mystilleef/pi-subagent
25
25
 
26
26
  ## Features
27
27
 
28
- - **Asynchronous:** Agents run in the background.
28
+ - **Asynchronous:** Agents always run in the background.
29
29
  - **Parallel:** Run many agents simultaneously.
30
30
  - **Isolated:** Each delegated task receives a separate context window.
31
31
  - **Nested:** `Subagents` can spawn other `subagents`.
@@ -108,9 +108,54 @@ YAML `frontmatter` and a Markdown system prompt body.
108
108
 
109
109
  ### Discovery locations
110
110
 
111
- - User-global agents: `~/.pi/agents/*.md`
111
+ - User-global agents: `~/.pi/agent/agents/*.md`
112
112
  - Project-local agents: nearest `.pi/agents/*.md`
113
113
 
114
+ ### Example of an agent file
115
+
116
+ **PATH:** _~/.pi/agent/agents/lifehacks.md_
117
+
118
+ <!-- prettier-ignore-start -->
119
+ ```md
120
+ ---
121
+ name: lifehacks
122
+ description: Daily lifehacks
123
+ replace_prompt: true
124
+ context: false
125
+ thinking: xhigh
126
+ skills: false
127
+ extensions: pi-mcp-adapter
128
+ tools: read, grep, find, ls, mcp
129
+ ---
130
+
131
+ # Role
132
+
133
+ Embody an expert philosopher and life coach. Your specialize in
134
+ _lifehacks_.
135
+
136
+ ## Workflow
137
+
138
+ - Research and deliver 3 _profound_ and _transformative lifehacks_.
139
+ - Expound upon each of them.
140
+ - Emit unmodified result to the calling agent.
141
+
142
+ ## Directives
143
+
144
+ - Forbid `copular` verb forms.
145
+ - **Minimum** words. **Maximum** signal.
146
+ - Keep prose vivid but terse.
147
+ - Optimize prose for token and context efficiency.
148
+ - Use lists and sub-lists over paragraphs and long sentences.
149
+ - Use elegant, well-structured, idiomatic markdown.
150
+
151
+ ## Constraints
152
+
153
+ - Operate in read-only mode.
154
+ - Forbid all write operations.
155
+ - **NEVER** wrap the entire result in a code block.
156
+ ```
157
+ <!-- prettier-ignore-end -->
158
+
114
159
  ### Required front matter
115
160
 
116
161
  ```yaml
@@ -123,21 +168,49 @@ description: Review code for correctness and maintainability.
123
168
  ```yaml
124
169
  tools: read, bash, edit
125
170
  skills: code-review
171
+ extensions: git-summary
172
+ context: false
126
173
  thinking: medium
127
174
  provider: deepseek
128
175
  model: deepseek-v4-flash
129
176
  temperature: 0.7
130
177
  top_p: 0.9
178
+ replace_prompt: true
131
179
  ```
132
180
 
133
- ### Accepted thinking values
134
-
135
- - `off`
136
- - `minimal`
137
- - `low`
138
- - `medium`
139
- - `high`
140
- - `xhigh`
181
+ - `tools`: Specifies a comma-separated list of enabled tools. Omission
182
+ defaults to the child process default `toolset`.
183
+ - `skills`: Specifies a comma-separated list of enabled skills. Setting
184
+ `false` disables all skills. Omission inherits the active workspace
185
+ skills.
186
+ - `extensions`: Specifies a comma-separated list of enabled extensions.
187
+ Setting `false` disables all extensions except core subagent
188
+ extensions. Omission inherits the active workspace extensions.
189
+ - `context`: Controls the inclusion of project context files,
190
+ `AGENTS.md`. Setting `false` excludes default workspace files from the
191
+ child context window.
192
+ - `thinking`: Controls the model thinking level (values: `off`,
193
+ `minimal`, `low`, `medium`, `high`, `xhigh`, `max`). `xhigh` and `max`
194
+ are model-specific, opt-in levels. The child runner clamps
195
+ unsupported levels to supported values and prints warnings.
196
+ - `provider`: Specifies the model provider. Requires setting the `model`
197
+ field. Omission of the `model` field when defining a `provider`
198
+ invalidates the agent configuration.
199
+ - `model`: Specifies the model identifier. Agent-level model settings
200
+ override parent settings.
201
+ - `temperature`: Sets the sampling temperature. Accepts numeric values
202
+ between `0.0` and `1.0` inclusive. Incorrect values trigger warnings
203
+ during discovery and the system ignores them.
204
+ - `top_p`: Sets the sampling top-p value. Accepts numeric values between
205
+ `0.0` and `1.0` inclusive. Incorrect values trigger warnings during
206
+ discovery and the system ignores them.
207
+ - `replace_prompt`: Set `true` to replace the child system prompt base
208
+ with the agent body. Omitted or `false` appends the body to the
209
+ existing system prompt, `SYSTEM.md`, preserving current behavior.
210
+ Requires a non-empty agent body; `replace_prompt: true` with an empty
211
+ or whitespace-only body fails discovery. **Warning:** `true` replaces
212
+ pi built-in defaults and any project/global `SYSTEM.md`, including
213
+ their safety and behavior guardrails.
141
214
 
142
215
  ---
143
216
 
@@ -210,7 +283,7 @@ decimal, `Infinity`, and non-numeric values fall back to defaults.
210
283
 
211
284
  **Missing agent:**
212
285
 
213
- - Confirm the file lives under `~/.pi/agents/` or the nearest
286
+ - Confirm the file lives under `~/.pi/agent/agents/` or the nearest
214
287
  `.pi/agents/`.
215
288
  - Confirm `frontmatter` includes `name` and `description`.
216
289
  - Confirm `/run` uses the `name` value, not the filename.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mystilleef/pi-subagent",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Pi subagent for the SPAE Framework",
5
5
  "author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
6
6
  "license": "MIT",
@@ -64,14 +64,14 @@
64
64
  "typebox": "*"
65
65
  },
66
66
  "devDependencies": {
67
- "@biomejs/biome": "^2.5.1",
68
- "@earendil-works/pi-agent-core": "^0.80.2",
69
- "@earendil-works/pi-ai": "^0.80.2",
70
- "@earendil-works/pi-coding-agent": "^0.80.2",
71
- "@earendil-works/pi-tui": "^0.80.2",
67
+ "@biomejs/biome": "^2.5.7",
68
+ "@earendil-works/pi-agent-core": "^0.84.0",
69
+ "@earendil-works/pi-ai": "^0.84.0",
70
+ "@earendil-works/pi-coding-agent": "^0.84.0",
71
+ "@earendil-works/pi-tui": "^0.84.0",
72
72
  "@types/bun": "^1.3.14",
73
- "@types/node": "^26.0.1",
74
- "typebox": "^1.3.0",
75
- "typescript": "^6.0.3"
73
+ "@types/node": "^26.1.2",
74
+ "typebox": "^1.3.11",
75
+ "typescript": "^7.0.2"
76
76
  }
77
77
  }
@@ -14,6 +14,7 @@ const THINKING_LEVELS = [
14
14
  "medium",
15
15
  "high",
16
16
  "xhigh",
17
+ "max",
17
18
  ] as const;
18
19
 
19
20
  export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
@@ -21,13 +22,16 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
21
22
  export interface AgentConfig {
22
23
  name: string;
23
24
  description: string;
25
+ context?: false | undefined;
24
26
  tools?: string[] | undefined;
25
- skills?: string[] | undefined;
27
+ skills?: string[] | false | undefined;
28
+ extensions?: string[] | undefined;
26
29
  thinking?: ThinkingLevel | undefined;
27
30
  model?: string | undefined;
28
31
  provider?: string | undefined;
29
32
  temperature?: number | undefined;
30
33
  topP?: number | undefined;
34
+ replacePrompt?: true | undefined;
31
35
  systemPrompt: string;
32
36
  source: AgentSource;
33
37
  filePath: string;
@@ -71,6 +75,21 @@ function parseCommaList(raw: unknown): string[] | undefined {
71
75
  return items.length > 0 ? items : undefined;
72
76
  }
73
77
 
78
+ function parseExtensions(raw: unknown): string[] | undefined {
79
+ if (raw === undefined) return undefined;
80
+ if (raw === false || Array.isArray(raw)) return [];
81
+ if (typeof raw === "string") {
82
+ const trimmed = raw.trim();
83
+ if (trimmed.length === 0) return [];
84
+ const items = trimmed
85
+ .split(",")
86
+ .map((s) => s.trim())
87
+ .filter(Boolean);
88
+ return items.length > 0 ? items : [];
89
+ }
90
+ return undefined;
91
+ }
92
+
74
93
  function parseThinkingLevel(raw: unknown): ThinkingLevel | undefined {
75
94
  if (typeof raw !== "string") return undefined;
76
95
  const normalized = raw.trim().toLowerCase();
@@ -124,29 +143,49 @@ function parseAgentConfig(
124
143
  const {
125
144
  name,
126
145
  description,
146
+ context: rawContext,
127
147
  tools: rawTools,
128
148
  skills: rawSkills,
149
+ extensions: rawExtensions,
129
150
  thinking: rawThinking,
130
151
  model: rawModel,
131
152
  provider: rawProvider,
132
153
  temperature: rawTemperature,
133
154
  top_p: rawTopP,
155
+ replace_prompt: rawReplacePrompt,
134
156
  } = frontmatter;
135
157
  if (typeof name !== "string" || typeof description !== "string") return null;
158
+ if (rawContext !== undefined && typeof rawContext !== "boolean") return null;
159
+ if (rawReplacePrompt !== undefined && typeof rawReplacePrompt !== "boolean")
160
+ return null;
136
161
  if (isNonStringOptional(rawTools)) return null;
137
- if (isNonStringOptional(rawSkills)) return null;
162
+ if (rawSkills != null && typeof rawSkills !== "string" && rawSkills !== false)
163
+ return null;
164
+ if (
165
+ rawExtensions !== undefined &&
166
+ rawExtensions !== false &&
167
+ typeof rawExtensions !== "string" &&
168
+ !(Array.isArray(rawExtensions) && rawExtensions.length === 0)
169
+ )
170
+ return null;
138
171
  if (isNonStringOptional(rawThinking)) return null;
139
172
  if (isNonStringOptional(rawModel)) return null;
140
173
  if (isNonStringOptional(rawProvider)) return null;
141
174
  const tools = parseCommaList(rawTools);
142
175
  const skills =
143
- rawSkills !== undefined ? (parseCommaList(rawSkills) ?? []) : undefined;
176
+ rawSkills === false
177
+ ? false
178
+ : rawSkills !== undefined
179
+ ? (parseCommaList(rawSkills) ?? [])
180
+ : undefined;
144
181
  const thinking = parseThinkingLevel(rawThinking);
145
182
  const model = parseOptionalString(rawModel);
146
183
  const provider = parseOptionalString(rawProvider);
147
184
  if (provider !== undefined && model === undefined) return null;
148
185
  const temperature = parseSamplingValue(rawTemperature, "temperature", name);
149
186
  const topP = parseSamplingValue(rawTopP, "top_p", name);
187
+ const extensions = parseExtensions(rawExtensions);
188
+ if (rawReplacePrompt === true && body.trim().length === 0) return null;
150
189
  return {
151
190
  name,
152
191
  description,
@@ -158,8 +197,11 @@ function parseAgentConfig(
158
197
  systemPrompt: body,
159
198
  source,
160
199
  filePath,
200
+ ...(rawContext === false && { context: false }),
161
201
  ...(temperature !== undefined && { temperature }),
162
202
  ...(topP !== undefined && { topP }),
203
+ ...(extensions !== undefined && { extensions }),
204
+ ...(rawReplacePrompt === true && { replacePrompt: true }),
163
205
  };
164
206
  }
165
207
 
@@ -4,8 +4,10 @@
4
4
  */
5
5
 
6
6
  import {
7
+ type Api,
7
8
  clampThinkingLevel,
8
9
  getSupportedThinkingLevels,
10
+ type Model,
9
11
  type ModelThinkingLevel,
10
12
  } from "@earendil-works/pi-ai";
11
13
  import { getModel } from "@earendil-works/pi-ai/compat";
@@ -17,22 +19,87 @@ export type ChildModelSettings = {
17
19
  };
18
20
 
19
21
  /**
20
- * Resolves the effective thinking level for a model, clamping to supported levels.
21
- * Returns a warning message if the requested level differs from the effective level.
22
+ * Minimal live-registry seam consumed by thinking-level resolution.
23
+ * Accepts the public {@link ModelRegistry} from the host extension context
24
+ * or any test double that provides the same lookup contract.
22
25
  */
23
- export function resolveThinkingLevel(
26
+ export interface ModelRegistry {
27
+ find(provider: string, modelId: string): Model<Api> | undefined;
28
+ }
29
+
30
+ export interface ResolveThinkingLevelOptions {
31
+ registry?: ModelRegistry | undefined;
32
+ }
33
+
34
+ export interface ResolvedThinkingLevel {
35
+ level: ThinkingLevel;
36
+ warning?: string | undefined;
37
+ diagnostic?: string | undefined;
38
+ }
39
+
40
+ const FALLBACK_LEVEL: ThinkingLevel = "off";
41
+
42
+ function formatProviderModelLabel(
43
+ provider: string | undefined,
44
+ modelId: string | undefined,
45
+ ): string {
46
+ const providerLabel = provider ?? "unknown";
47
+ const modelLabel = modelId ?? "unknown";
48
+ return `(provider: ${providerLabel}, model: ${modelLabel})`;
49
+ }
50
+
51
+ function formatUnsupportedWarning(
24
52
  requested: ThinkingLevel,
53
+ effective: ThinkingLevel,
54
+ provider: string | undefined,
55
+ modelId: string | undefined,
56
+ ): string {
57
+ return `Thinking level "${requested}" is not supported; using "${effective}" instead ${formatProviderModelLabel(provider, modelId)}`;
58
+ }
59
+
60
+ function formatUnconfirmedWarning(
61
+ requested: ThinkingLevel,
62
+ provider: string | undefined,
63
+ modelId: string | undefined,
64
+ ): string {
65
+ return `Thinking level "${requested}" support could not be confirmed; requesting as-is ${formatProviderModelLabel(provider, modelId)}`;
66
+ }
67
+
68
+ function resolveModel(
25
69
  provider: string,
26
70
  modelId: string,
27
- ): { level: ThinkingLevel; warning?: string } {
28
- const model = getModel(provider as never, modelId as never);
29
- if (!model) return { level: requested };
30
- const mkWarning = (effective: ThinkingLevel) =>
31
- `Thinking level "${requested}" not supported by model "${provider}/${modelId}"; using "${effective}" instead`;
71
+ registry: ModelRegistry | undefined,
72
+ ): { model: Model<Api> | undefined; diagnostic: string | undefined } {
73
+ if (registry) {
74
+ const live = registry.find(provider, modelId);
75
+ if (live) return { model: live, diagnostic: undefined };
76
+ }
77
+ const diagnostic = registry
78
+ ? undefined
79
+ : "Live model registry unavailable; falling back to static catalog.";
80
+ const staticModel = getModel(provider as never, modelId as never) as
81
+ | Model<Api>
82
+ | undefined;
83
+ return { model: staticModel, diagnostic };
84
+ }
85
+
86
+ function resolveForModel(
87
+ requested: ThinkingLevel,
88
+ provider: string | undefined,
89
+ modelId: string | undefined,
90
+ model: Model<Api>,
91
+ ): ResolvedThinkingLevel {
32
92
  if (model.reasoning === false) {
33
- return { level: "off", warning: mkWarning("off") };
93
+ return {
94
+ level: FALLBACK_LEVEL,
95
+ warning: formatUnsupportedWarning(
96
+ requested,
97
+ FALLBACK_LEVEL,
98
+ provider,
99
+ modelId,
100
+ ),
101
+ };
34
102
  }
35
- if (!model.thinkingLevelMap) return { level: requested };
36
103
  const supported = getSupportedThinkingLevels(model);
37
104
  if (supported.length === 0) return { level: requested };
38
105
  const clamped = clampThinkingLevel(
@@ -40,7 +107,54 @@ export function resolveThinkingLevel(
40
107
  requested as ModelThinkingLevel,
41
108
  ) as ThinkingLevel;
42
109
  if (clamped === requested) return { level: requested };
43
- return { level: clamped, warning: mkWarning(clamped) };
110
+ return {
111
+ level: clamped,
112
+ warning: formatUnsupportedWarning(requested, clamped, provider, modelId),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Resolves the effective thinking level for a model, preferring the live
118
+ * registry over the static catalog. Confirmed matches (live or static) clamp
119
+ * unsupported levels with the standard unsupported-level warning. Unconfirmed
120
+ * misses—missing provider or model identifiers, missing/unavailable registries,
121
+ * and live or static catalog misses—preserve the requested level for display
122
+ * while warning that support could not be confirmed. Requested `off` always
123
+ * stays warning-free in unconfirmed paths. Registry diagnostics are returned
124
+ * separately and never included in the user-facing warning.
125
+ */
126
+ export function resolveThinkingLevel(
127
+ requested: ThinkingLevel,
128
+ provider: string | undefined,
129
+ modelId: string | undefined,
130
+ options?: ResolveThinkingLevelOptions,
131
+ ): ResolvedThinkingLevel {
132
+ if (!provider || !modelId) {
133
+ if (requested === FALLBACK_LEVEL) return { level: FALLBACK_LEVEL };
134
+ return {
135
+ level: requested,
136
+ warning: formatUnconfirmedWarning(requested, provider, modelId),
137
+ };
138
+ }
139
+ const { model, diagnostic } = resolveModel(
140
+ provider,
141
+ modelId,
142
+ options?.registry,
143
+ );
144
+ if (!model) {
145
+ if (requested === FALLBACK_LEVEL) {
146
+ return diagnostic
147
+ ? { level: FALLBACK_LEVEL, diagnostic }
148
+ : { level: FALLBACK_LEVEL };
149
+ }
150
+ return {
151
+ level: requested,
152
+ warning: formatUnconfirmedWarning(requested, provider, modelId),
153
+ diagnostic,
154
+ };
155
+ }
156
+ const result = resolveForModel(requested, provider, modelId, model);
157
+ return diagnostic ? { ...result, diagnostic } : result;
44
158
  }
45
159
 
46
160
  /**
@@ -43,6 +43,17 @@ export function resolveSamplingExtensionPath(dir?: string): string {
43
43
  return resolveExtensionPath("sampling-extension", dir);
44
44
  }
45
45
 
46
+ /**
47
+ * Resolves the absolute path to the pi-subagent package entry extension file.
48
+ * This is the main index that registers the subagent tool and commands.
49
+ * Prefers the extension matching the current runtime, then falls back to the
50
+ * alternate extension. Throws if neither exists.
51
+ * Pass `dir` in tests to use a controlled directory.
52
+ */
53
+ export function resolvePackageExtensionPath(dir?: string): string {
54
+ return resolveExtensionPath("../index", dir);
55
+ }
56
+
46
57
  /**
47
58
  * Appends data to a string while enforcing a maximum byte limit.
48
59
  * Handles both string and Buffer inputs, truncating at valid UTF-8 boundaries.
@@ -79,7 +90,7 @@ export function truncateValidUtf8(buffer: Buffer, max: number): string {
79
90
  /**
80
91
  * Resolves the context window size in tokens for a given message.
81
92
  * Returns undefined if the message doesn't have valid provider/model info
82
- * or if the model lookup fails.
93
+ * or the model isn't in the built-in catalog.
83
94
  *
84
95
  * Rationale: Subagent usage reporting needs context window awareness to provide
85
96
  * meaningful "context full" indicators to the parent.
@@ -88,16 +99,11 @@ export function resolveContextWindowTokens(msg: Message): number | undefined {
88
99
  const m = msg as unknown as Record<string, unknown>;
89
100
  if (typeof m["provider"] !== "string" || typeof m["model"] !== "string")
90
101
  return;
91
- try {
92
- const contextWindow = getModel(
93
- m["provider"] as never,
94
- m["model"] as never,
95
- )?.contextWindow;
96
- return Number.isFinite(contextWindow) && contextWindow > 0
97
- ? contextWindow
98
- : undefined;
99
- } catch {
100
- /* model lookup failures return undefined to skip context window tracking */
101
- return;
102
- }
102
+ const contextWindow = getModel(
103
+ m["provider"] as never,
104
+ m["model"] as never,
105
+ )?.contextWindow;
106
+ return Number.isFinite(contextWindow) && contextWindow > 0
107
+ ? contextWindow
108
+ : undefined;
103
109
  }