@mystilleef/pi-subagent 0.8.0 → 0.10.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
@@ -1,16 +1,12 @@
1
- # Subagent
1
+ # Subagents for pi
2
2
 
3
- `pi-subagent` adds isolated subagent orchestration to
4
- [Pi](https://github.com/earendil-works/pi). It provides a `subagent`
5
- tool and `/run` command for delegating work to specialized agents in
6
- separate child Pi processes. Designed especially for the `SPAE`
7
- framework, but doesn't require it.
8
-
9
- ## Action
10
-
11
- Agents in
3
+ Designed to orchestrate agents for the
4
+ [SPAE framework](https://github.com/mystilleef/spae-framework). Agents
5
+ in
12
6
  [action](https://raw.githubusercontent.com/mystilleef/pi-subagent/main/assets/parallel-agents-demo.mp4).
13
7
 
8
+ ---
9
+
14
10
  ## Installation
15
11
 
16
12
  **Install from `npm`:**
@@ -25,13 +21,19 @@ pi install npm:@mystilleef/pi-subagent
25
21
  pi -e npm:@mystilleef/pi-subagent
26
22
  ```
27
23
 
24
+ ---
25
+
28
26
  ## Features
29
27
 
30
28
  - **Asynchronous:** Agents run in the background.
31
29
  - **Parallel:** Run many agents simultaneously.
32
30
  - **Isolated:** Each delegated task receives a separate context window.
33
- - **Simple:** No complex orchestration workflow required.
34
- - **Bloat-free:** No bundled agents.
31
+ - **Nested:** `Subagents` can spawn other `subagents`.
32
+ - **Simple:** No complex orchestration syntax.
33
+ - **Bloat-free:** No bundled agents. Write your own. Or use those from
34
+ the [SPAE Framework](https://github.com/mystilleef/spae-framework).
35
+
36
+ ---
35
37
 
36
38
  ## Usage
37
39
 
@@ -70,12 +72,16 @@ commits; use the query agent to summarize the project.
70
72
  /cancel-subagent
71
73
  ```
72
74
 
75
+ ---
76
+
73
77
  ## _SPAE_ Workflow
74
78
 
75
- `pi-subagent` supports the
76
- [`SPAE` Framework](https://github.com/mystilleef/spae-framework), but
77
- doesn't require it. `SPAE` provides pre-built agents and skills for a
78
- structured workflow.
79
+ The official extension for the
80
+ [SPAE Framework](https://github.com/mystilleef/spae-framework). The
81
+ framework provides pre-built agents and skills for a structured, or
82
+ orchestrated, workflow.
83
+
84
+ ### Structured
79
85
 
80
86
  | Phase | Agent | Purpose |
81
87
  | ----- | ------------------------- | --------------------------------------------- |
@@ -85,32 +91,44 @@ structured workflow.
85
91
  | 4 | `/run build` | Carry out tasks from `PLAN.md` |
86
92
  | 5 | `/run verify` | Verify implementation against `SPEC.md` |
87
93
 
94
+ ### Orchestrated
95
+
96
+ | Agent | Purpose |
97
+ | -------------------------------- | --------------------------------------------- |
98
+ | `/run orchestrate <requirement>` | Run all phases of the `SPAE` workflow |
99
+ | `/run prepare <requirement>` | Run preparatory phases of the `SPAE` workflow |
100
+ | `/run spawn` | Spawn build agents for each task in a plan |
101
+
102
+ ---
103
+
88
104
  ## Agent definitions
89
105
 
90
- This package ships no agents. Define agents as Markdown files with YAML
91
- `frontmatter` and a Markdown system prompt body.
106
+ This extension ships no agents. Define agents as Markdown files with
107
+ YAML `frontmatter` and a Markdown system prompt body.
92
108
 
93
- **Discovery locations:**
109
+ ### Discovery locations
94
110
 
95
111
  - User-global agents: `~/.pi/agents/*.md`
96
112
  - Project-local agents: nearest `.pi/agents/*.md`
97
113
 
98
- **Required `frontmatter`:**
114
+ ### Required front matter
99
115
 
100
116
  ```yaml
101
117
  name: review
102
118
  description: Review code for correctness and maintainability.
103
119
  ```
104
120
 
105
- **Optional `frontmatter`:**
121
+ ### Optional front matter
106
122
 
107
123
  ```yaml
108
124
  tools: read, bash, edit
109
125
  skills: code-review
110
126
  thinking: medium
127
+ provider: deepseek
128
+ model: deepseek-v4-flash
111
129
  ```
112
130
 
113
- **Accepted `thinking` values:**
131
+ ### Accepted thinking values
114
132
 
115
133
  - `off`
116
134
  - `minimal`
@@ -119,6 +137,8 @@ thinking: medium
119
137
  - `high`
120
138
  - `xhigh`
121
139
 
140
+ ---
141
+
122
142
  ## Tool
123
143
 
124
144
  The extension also registers a `subagent` tool for model-driven
@@ -130,8 +150,11 @@ delegation.
130
150
  - `task`: task prompt for the child agent.
131
151
  - `agentScope`: optional lookup scope, one of `user`, `project`, or
132
152
  `both`.
133
- - `debug`: optional flag that includes full child messages in result
134
- details.
153
+ - `debug`: optional flag that requests child diagnostic details. Full
154
+ child messages and raw internals require `PI_SUBAGENT_DEBUG_ENABLED=1`
155
+ in the host environment.
156
+
157
+ ---
135
158
 
136
159
  ## Security
137
160
 
@@ -143,19 +166,43 @@ executable automation.
143
166
 
144
167
  - Review project-local agents before running them.
145
168
  - Avoid delegating secrets unless the agent and tools need them.
169
+ - Treat child-agent prompts, tool arguments, `stderr`, and debug
170
+ transcripts as potentially sensitive.
171
+ - Enable debug details only for trusted investigations. `debug: true` or
172
+ `/run --debug` can expose child conversation transcripts, termination
173
+ internals, and `stderr` only when the host explicitly sets
174
+ `PI_SUBAGENT_DEBUG_ENABLED=1`.
146
175
  - Prefer trusted repositories for shared agent definitions.
147
176
  - Remember that child agents can call their configured tools.
148
177
 
178
+ ---
179
+
149
180
  ## Configuration and limits
150
181
 
151
182
  **Environment variables:**
152
183
 
153
- - `PI_SUBAGENT_DEPTH`: nested subagent depth guard. Nested calls stop at
154
- depth `3`.
184
+ - `PI_SUBAGENT_DEPTH`: current nested subagent depth counter set
185
+ internally for child processes.
186
+ - `PI_SUBAGENT_MAX_DEPTH`: max nested subagent depth. Default: `3`.
187
+ Values above `10` clamp to the internal ceiling `10`; deeper nesting
188
+ increases cost, latency, and runaway delegation risk.
189
+ - `PI_SUBAGENT_AGENT_END_GRACE_MS`: child process grace period after
190
+ `agent_end` before forced termination. Default: `250`.
191
+ - `PI_SUBAGENT_MAX_STDERR_BYTES`: max captured child `stderr` bytes.
192
+ Default: `10000`.
155
193
  - `PI_SUBAGENT_MAX_OUTPUT_BYTES`: max returned output bytes. Default:
156
194
  `50000`.
157
195
  - `PI_SUBAGENT_MAX_OUTPUT_LINES`: max returned output lines. Default:
158
196
  `500`.
197
+ - `PI_SUBAGENT_DEBUG_ENABLED`: debug detail authorization. Set to `1` to
198
+ allow `debug: true` or `/run --debug` to include sanitized child
199
+ messages, termination internals, and `stderr`; unset values keep
200
+ non-debug detail behavior.
201
+
202
+ Limit variables parse as positive integers. Empty, zero, negative,
203
+ decimal, `Infinity`, and non-numeric values fall back to defaults.
204
+
205
+ ---
159
206
 
160
207
  ## Troubleshooting
161
208
 
@@ -173,7 +220,7 @@ executable automation.
173
220
 
174
221
  **Nested subagent blocked:**
175
222
 
176
- - Nested delegation hits the `PI_SUBAGENT_DEPTH` safety limit.
223
+ - Nested delegation hits the `PI_SUBAGENT_MAX_DEPTH` safety limit.
177
224
  - Run the child task directly from the parent session instead.
178
225
 
179
226
  **Truncated output:**
@@ -182,6 +229,8 @@ executable automation.
182
229
  `PI_SUBAGENT_MAX_OUTPUT_LINES`.
183
230
  - Ask the child agent for a shorter summary.
184
231
 
232
+ ---
233
+
185
234
  ## Development
186
235
 
187
236
  **Install dependencies:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mystilleef/pi-subagent",
3
- "version": "0.8.0",
3
+ "version": "0.10.1",
4
4
  "description": "Pi subagent for the SPAE Framework",
5
5
  "author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
6
6
  "license": "MIT",
@@ -50,7 +50,7 @@
50
50
  "typecheck": "tsc --noEmit",
51
51
  "lint": "biome check --write --unsafe --error-on-warnings .",
52
52
  "migrate": "biome migrate --write",
53
- "coverage": "bun test --coverage",
53
+ "coverage": "bun test --parallel --parallel-delay=0",
54
54
  "check": "bun lint && bun typecheck",
55
55
  "verify": "bun migrate && bun check && bun coverage",
56
56
  "pack:smoke": "bun scripts/pack-smoke.ts",
@@ -64,14 +64,14 @@
64
64
  "typebox": "*"
65
65
  },
66
66
  "devDependencies": {
67
- "@biomejs/biome": "^2.4.16",
68
- "@earendil-works/pi-agent-core": "^0.78.1",
69
- "@earendil-works/pi-ai": "^0.78.1",
70
- "@earendil-works/pi-coding-agent": "^0.78.1",
71
- "@earendil-works/pi-tui": "^0.78.1",
67
+ "@biomejs/biome": "^2.5.0",
68
+ "@earendil-works/pi-agent-core": "^0.79.3",
69
+ "@earendil-works/pi-ai": "^0.79.3",
70
+ "@earendil-works/pi-coding-agent": "^0.79.3",
71
+ "@earendil-works/pi-tui": "^0.79.3",
72
72
  "@types/bun": "^1.3.14",
73
- "@types/node": "^25.9.1",
74
- "typebox": "^1.2.1",
73
+ "@types/node": "^25.9.3",
74
+ "typebox": "^1.2.9",
75
75
  "typescript": "^6.0.3"
76
76
  }
77
77
  }
@@ -123,6 +123,7 @@ async function hashMarkdownFileAsync(
123
123
  .update(await fsPromises.readFile(path.join(dir, fileName)))
124
124
  .digest("hex");
125
125
  } catch {
126
+ /* missing or unreadable files return null to skip caching */
126
127
  return null;
127
128
  }
128
129
  }
@@ -20,9 +20,11 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
20
20
  export interface AgentConfig {
21
21
  name: string;
22
22
  description: string;
23
- tools?: string[];
24
- skills?: string[];
25
- thinking?: ThinkingLevel;
23
+ tools?: string[] | undefined;
24
+ skills?: string[] | undefined;
25
+ thinking?: ThinkingLevel | undefined;
26
+ model?: string | undefined;
27
+ provider?: string | undefined;
26
28
  systemPrompt: string;
27
29
  source: AgentSource;
28
30
  filePath: string;
@@ -74,6 +76,12 @@ function parseThinkingLevel(raw: unknown): ThinkingLevel | undefined {
74
76
  : undefined;
75
77
  }
76
78
 
79
+ function parseOptionalString(raw: unknown): string | undefined {
80
+ if (typeof raw !== "string") return undefined;
81
+ const normalized = raw.trim();
82
+ return normalized.length > 0 ? normalized : undefined;
83
+ }
84
+
77
85
  function parseAgentConfig(
78
86
  content: string,
79
87
  source: AgentSource,
@@ -83,6 +91,7 @@ function parseAgentConfig(
83
91
  try {
84
92
  parsed = parseFrontmatter<Record<string, unknown>>(content);
85
93
  } catch {
94
+ /* malformed frontmatter returns null to skip invalid agent files */
86
95
  return null;
87
96
  }
88
97
  const { frontmatter, body } = parsed;
@@ -98,22 +107,31 @@ function parseAgentConfig(
98
107
  tools: rawTools,
99
108
  skills: rawSkills,
100
109
  thinking: rawThinking,
110
+ model: rawModel,
111
+ provider: rawProvider,
101
112
  } = frontmatter;
102
113
  if (typeof name !== "string" || typeof description !== "string") return null;
103
114
  if (rawTools != null && typeof rawTools !== "string") return null;
104
115
  if (rawSkills != null && typeof rawSkills !== "string") return null;
105
116
  if (rawThinking != null && typeof rawThinking !== "string") return null;
117
+ if (rawModel != null && typeof rawModel !== "string") return null;
118
+ if (rawProvider != null && typeof rawProvider !== "string") return null;
106
119
  const tools = parseCommaList(rawTools);
107
120
  const skills = Object.hasOwn(frontmatter, "skills")
108
121
  ? (parseCommaList(rawSkills) ?? [])
109
122
  : undefined;
110
123
  const thinking = parseThinkingLevel(rawThinking);
124
+ const model = parseOptionalString(rawModel);
125
+ const provider = parseOptionalString(rawProvider);
126
+ if (provider !== undefined && model === undefined) return null;
111
127
  return {
112
128
  name,
113
129
  description,
114
130
  tools,
115
131
  skills,
116
132
  thinking,
133
+ model,
134
+ provider,
117
135
  systemPrompt: body,
118
136
  source,
119
137
  filePath,
@@ -130,6 +148,7 @@ async function loadAgentEntryAsync(
130
148
  try {
131
149
  content = await fsPromises.readFile(filePath, "utf-8");
132
150
  } catch {
151
+ /* unreadable files are skipped silently during agent discovery */
133
152
  return null;
134
153
  }
135
154
  return parseAgentConfig(content, source, filePath);
@@ -156,7 +175,7 @@ async function loadAgentsFromDirAsync(
156
175
  return { agents, markdownFiles };
157
176
  }
158
177
 
159
- export function isMarkdownDirent(entry: Dirent): boolean {
178
+ function isMarkdownDirent(entry: Dirent): boolean {
160
179
  return (
161
180
  entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink())
162
181
  );
@@ -175,6 +194,7 @@ export async function readMarkdownDirWithStatusAsync(
175
194
  const entries = await fsPromises.readdir(dir, { withFileTypes: true });
176
195
  return { entries: entries.filter(isMarkdownDirent), ok: true };
177
196
  } catch {
197
+ /* missing or inaccessible directories return empty listing */
178
198
  return { entries: [], ok: false };
179
199
  }
180
200
  }
@@ -187,6 +207,7 @@ export async function isDirectoryAsync(p: string): Promise<boolean> {
187
207
  try {
188
208
  return (await fsPromises.stat(p)).isDirectory();
189
209
  } catch {
210
+ /* stat failures indicate non-existent or inaccessible paths */
190
211
  return false;
191
212
  }
192
213
  }
@@ -1,21 +1,20 @@
1
1
  import { makeToolPreview } from "../output/normalize.js";
2
2
  import type { ToolActivity } from "../shared/types.js";
3
3
 
4
- // Extracts results[0] from details; null-safe for malformed input.
5
4
  function tryFirstResult(details: unknown): Record<string, unknown> | null {
6
5
  try {
7
6
  if (typeof details !== "object" || details === null) return null;
8
- const results = (details as Record<string, unknown>).results;
7
+ const results = (details as Record<string, unknown>)["results"];
9
8
  if (!Array.isArray(results) || results.length === 0) return null;
10
9
  const nested = results[0];
11
10
  if (typeof nested !== "object" || nested === null) return null;
12
11
  return nested as Record<string, unknown>;
13
12
  } catch {
13
+ /* malformed details structure returns null to use fallback preview */
14
14
  return null;
15
15
  }
16
16
  }
17
17
 
18
- // Malformed details (null result) falls back to { toolName }.
19
18
  function parseToolActivity(
20
19
  toolName: string,
21
20
  partialResult: { content?: unknown; details?: unknown },
@@ -24,23 +23,25 @@ function parseToolActivity(
24
23
  if (!nestedRecord) return { toolName, inputSummary: toolName };
25
24
  const isSubagent = toolName === "subagent";
26
25
  const activity: ToolActivity = { toolName };
27
- const agent = typeof nestedRecord.agent === "string" && nestedRecord.agent;
26
+ const agent =
27
+ typeof nestedRecord["agent"] === "string" && nestedRecord["agent"];
28
28
  activity.inputSummary =
29
29
  isSubagent && agent ? makeToolPreview(toolName, nestedRecord) : toolName;
30
30
  if (
31
- typeof nestedRecord.instanceName === "string" &&
32
- nestedRecord.instanceName
31
+ typeof nestedRecord["instanceName"] === "string" &&
32
+ nestedRecord["instanceName"]
33
33
  ) {
34
- activity.instanceName = nestedRecord.instanceName;
34
+ activity.instanceName = nestedRecord["instanceName"] as string;
35
35
  }
36
- const progress = nestedRecord.progress;
36
+ const progress = nestedRecord["progress"];
37
37
  if (typeof progress === "object" && progress !== null) {
38
- const activeToolActivity = (progress as Record<string, unknown>)
39
- .activeToolActivity;
38
+ const activeToolActivity = (progress as Record<string, unknown>)[
39
+ "activeToolActivity"
40
+ ];
40
41
  if (
41
42
  typeof activeToolActivity === "object" &&
42
43
  activeToolActivity !== null &&
43
- typeof (activeToolActivity as Record<string, unknown>).toolName ===
44
+ typeof (activeToolActivity as Record<string, unknown>)["toolName"] ===
44
45
  "string"
45
46
  ) {
46
47
  const childActivity = activeToolActivity as ToolActivity;
@@ -90,27 +91,28 @@ export function parseChildEventLine(line: string): ChildEventParseResult {
90
91
  try {
91
92
  event = JSON.parse(line);
92
93
  } catch {
94
+ /* malformed JSON lines are marked invalid for diagnostic reporting */
93
95
  return { kind: "invalid", line };
94
96
  }
95
97
  if (
96
98
  typeof event === "object" &&
97
99
  event !== null &&
98
100
  "type" in event &&
99
- typeof (event as Record<string, unknown>).type === "string" &&
100
- KNOWN_TYPES.has((event as Record<string, unknown>).type as string)
101
+ typeof (event as Record<string, unknown>)["type"] === "string" &&
102
+ KNOWN_TYPES.has((event as Record<string, unknown>)["type"] as string)
101
103
  ) {
102
104
  const record = event as Record<string, unknown>;
103
- if (record.type === TOOL_EXECUTION_UPDATE_EVENT) {
105
+ if (record["type"] === TOOL_EXECUTION_UPDATE_EVENT) {
104
106
  if (
105
- typeof record.toolName !== "string" ||
106
- typeof record.partialResult !== "object" ||
107
- record.partialResult === null
107
+ typeof record["toolName"] !== "string" ||
108
+ typeof record["partialResult"] !== "object" ||
109
+ record["partialResult"] === null
108
110
  ) {
109
111
  return { kind: "unknown", event };
110
112
  }
111
- record.toolActivity = parseToolActivity(
112
- record.toolName as string,
113
- record.partialResult as { content?: unknown; details?: unknown },
113
+ record["toolActivity"] = parseToolActivity(
114
+ record["toolName"] as string,
115
+ record["partialResult"] as { content?: unknown; details?: unknown },
114
116
  );
115
117
  }
116
118
  return { kind: "known", event: event as ChildKnownEvent };