@mystilleef/pi-subagent 0.7.0 → 0.9.0

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
@@ -130,8 +130,9 @@ delegation.
130
130
  - `task`: task prompt for the child agent.
131
131
  - `agentScope`: optional lookup scope, one of `user`, `project`, or
132
132
  `both`.
133
- - `debug`: optional flag that includes full child messages in result
134
- details.
133
+ - `debug`: optional flag that requests child diagnostic details. Full child
134
+ messages and raw internals require `PI_SUBAGENT_DEBUG_ENABLED=1` in the
135
+ host environment.
135
136
 
136
137
  ## Security
137
138
 
@@ -143,6 +144,12 @@ executable automation.
143
144
 
144
145
  - Review project-local agents before running them.
145
146
  - Avoid delegating secrets unless the agent and tools need them.
147
+ - Treat child-agent prompts, tool arguments, stderr, and debug transcripts as
148
+ potentially sensitive.
149
+ - Enable debug details only for trusted investigations. `debug: true` or
150
+ `/run --debug` can expose child conversation transcripts, termination
151
+ internals, and stderr only when the host explicitly sets
152
+ `PI_SUBAGENT_DEBUG_ENABLED=1`.
146
153
  - Prefer trusted repositories for shared agent definitions.
147
154
  - Remember that child agents can call their configured tools.
148
155
 
@@ -150,12 +157,26 @@ executable automation.
150
157
 
151
158
  **Environment variables:**
152
159
 
153
- - `PI_SUBAGENT_DEPTH`: nested subagent depth guard. Nested calls stop at
154
- depth `2`.
160
+ - `PI_SUBAGENT_DEPTH`: current nested subagent depth counter set internally
161
+ for child processes.
162
+ - `PI_SUBAGENT_MAX_DEPTH`: max nested subagent depth. Default: `3`. Values
163
+ above `10` clamp to the internal ceiling `10`; deeper nesting increases
164
+ cost, latency, and runaway delegation risk.
165
+ - `PI_SUBAGENT_AGENT_END_GRACE_MS`: child process grace period after
166
+ `agent_end` before forced termination. Default: `250`.
167
+ - `PI_SUBAGENT_MAX_STDERR_BYTES`: max captured child stderr bytes. Default:
168
+ `10000`.
155
169
  - `PI_SUBAGENT_MAX_OUTPUT_BYTES`: max returned output bytes. Default:
156
170
  `50000`.
157
171
  - `PI_SUBAGENT_MAX_OUTPUT_LINES`: max returned output lines. Default:
158
172
  `500`.
173
+ - `PI_SUBAGENT_DEBUG_ENABLED`: debug detail authorization. Set to `1` to
174
+ allow `debug: true` or `/run --debug` to include sanitized child messages,
175
+ termination internals, and stderr; unset values keep non-debug detail
176
+ behavior.
177
+
178
+ Limit variables parse as positive integers. Empty, zero, negative, decimal,
179
+ `Infinity`, and non-numeric values fall back to defaults.
159
180
 
160
181
  ## Troubleshooting
161
182
 
@@ -173,7 +194,7 @@ executable automation.
173
194
 
174
195
  **Nested subagent blocked:**
175
196
 
176
- - Nested delegation hits the `PI_SUBAGENT_DEPTH` safety limit.
197
+ - Nested delegation hits the `PI_SUBAGENT_MAX_DEPTH` safety limit.
177
198
  - Run the child task directly from the parent session instead.
178
199
 
179
200
  **Truncated output:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mystilleef/pi-subagent",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Pi subagent for the SPAE Framework",
5
5
  "author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
6
6
  "license": "MIT",
@@ -48,12 +48,11 @@
48
48
  },
49
49
  "scripts": {
50
50
  "typecheck": "tsc --noEmit",
51
- "lint": "biome check --error-on-warnings .",
52
- "fix": "biome check --write --unsafe .",
51
+ "lint": "biome check --write --unsafe --error-on-warnings .",
53
52
  "migrate": "biome migrate --write",
54
- "coverage": "bun test --coverage",
53
+ "coverage": "bun test --parallel --parallel-delay=0",
55
54
  "check": "bun lint && bun typecheck",
56
- "verify": "bun migrate && bun fix && bun typecheck && bun coverage",
55
+ "verify": "bun migrate && bun check && bun coverage",
57
56
  "pack:smoke": "bun scripts/pack-smoke.ts",
58
57
  "release": "sh -c 'npm version \"$1\" -m \"chore(release): %s\" && git push --follow-tags' --"
59
58
  },
@@ -66,13 +65,13 @@
66
65
  },
67
66
  "devDependencies": {
68
67
  "@biomejs/biome": "^2.4.16",
69
- "@earendil-works/pi-agent-core": "^0.78.0",
70
- "@earendil-works/pi-ai": "^0.78.0",
71
- "@earendil-works/pi-coding-agent": "^0.78.0",
72
- "@earendil-works/pi-tui": "^0.78.0",
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",
73
72
  "@types/bun": "^1.3.14",
74
- "@types/node": "^25.9.1",
75
- "typebox": "^1.1.39",
73
+ "@types/node": "^25.9.2",
74
+ "typebox": "^1.2.2",
76
75
  "typescript": "^6.0.3"
77
76
  }
78
77
  }
@@ -9,12 +9,14 @@ import {
9
9
  discoverAgentsAsync,
10
10
  emptyScopeResult,
11
11
  getUserAgentsDir,
12
- readMarkdownDirEntriesAsync,
12
+ readMarkdownDirWithStatusAsync,
13
13
  } from "./agents.js";
14
14
 
15
15
  interface AgentDiscoveryScopeSnapshot {
16
16
  markdownFiles: string[];
17
17
  fileHashes: Record<string, string | null>;
18
+ listingTrusted: boolean;
19
+ directory: string | null;
18
20
  }
19
21
 
20
22
  type AgentDiscoverySnapshots = Record<AgentSource, AgentDiscoveryScopeSnapshot>;
@@ -52,7 +54,12 @@ function getFreshCacheEntry(
52
54
  }
53
55
 
54
56
  function emptySnapshot(): AgentDiscoveryScopeSnapshot {
55
- return { markdownFiles: [], fileHashes: {} };
57
+ return {
58
+ markdownFiles: [],
59
+ fileHashes: {},
60
+ listingTrusted: false,
61
+ directory: null,
62
+ };
56
63
  }
57
64
 
58
65
  function cloneScopeSnapshot(
@@ -62,6 +69,8 @@ function cloneScopeSnapshot(
62
69
  return {
63
70
  markdownFiles: [...snapshot.markdownFiles],
64
71
  fileHashes: { ...snapshot.fileHashes },
72
+ listingTrusted: snapshot.listingTrusted,
73
+ directory: snapshot.directory,
65
74
  };
66
75
  }
67
76
 
@@ -71,14 +80,17 @@ function equalStringSets(a: string[], b: string[]): boolean {
71
80
  return b.every((value) => set.has(value));
72
81
  }
73
82
 
74
- function snapshotsEqual(
75
- left: AgentDiscoveryScopeSnapshot,
76
- right: AgentDiscoveryScopeSnapshot,
83
+ function snapshotHasRequiredMetadata(
84
+ snapshot: AgentDiscoveryScopeSnapshot,
77
85
  ): boolean {
78
- if (!equalStringSets(left.markdownFiles, right.markdownFiles)) return false;
79
- return left.markdownFiles.every(
80
- (fileName) => left.fileHashes[fileName] === right.fileHashes[fileName],
81
- );
86
+ if (typeof snapshot.listingTrusted !== "boolean") return false;
87
+ if (snapshot.directory !== null && typeof snapshot.directory !== "string")
88
+ return false;
89
+ if (
90
+ !equalStringSets(snapshot.markdownFiles, Object.keys(snapshot.fileHashes))
91
+ )
92
+ return false;
93
+ return true;
82
94
  }
83
95
 
84
96
  function scopeAgentsMatchListing(
@@ -118,16 +130,20 @@ async function hashMarkdownFileAsync(
118
130
  async function buildScopeSnapshotAsync(
119
131
  dir: string | null,
120
132
  ): Promise<AgentDiscoveryScopeSnapshot> {
121
- if (!dir) return emptySnapshot();
122
- const entries = await readMarkdownDirEntriesAsync(dir);
123
- const markdownFiles = entries.map((entry) => entry.name);
133
+ const listing = await readMarkdownDirWithStatusAsync(dir);
134
+ const markdownFiles = listing.entries.map((entry) => entry.name);
124
135
  const hashPairs = await Promise.all(
125
136
  markdownFiles.map(
126
137
  async (fileName) =>
127
- [fileName, await hashMarkdownFileAsync(dir, fileName)] as const,
138
+ [fileName, await hashMarkdownFileAsync(dir ?? "", fileName)] as const,
128
139
  ),
129
140
  );
130
- return { markdownFiles, fileHashes: Object.fromEntries(hashPairs) };
141
+ return {
142
+ markdownFiles,
143
+ fileHashes: Object.fromEntries(hashPairs),
144
+ listingTrusted: listing.ok,
145
+ directory: dir ? path.resolve(dir) : null,
146
+ };
131
147
  }
132
148
 
133
149
  async function buildCacheSnapshotsAsync(
@@ -146,13 +162,22 @@ async function canTrustDerivedScopeAsync(
146
162
  ): Promise<boolean> {
147
163
  const dir =
148
164
  source === "user" ? getUserAgentsDir() : bothEntry.projectAgentsDir;
165
+ const resolvedDir = dir ? path.resolve(dir) : null;
149
166
  const scopeResult = bothEntry.scopes[source];
150
167
  const cachedSnapshot = bothEntry.snapshots?.[source];
151
168
  if (!cachedSnapshot) return false;
169
+ if (!snapshotHasRequiredMetadata(cachedSnapshot)) return false;
170
+ if (!cachedSnapshot.listingTrusted) return false;
171
+ if (cachedSnapshot.directory !== resolvedDir) return false;
152
172
  if (!scopeAgentsMatchListing(scopeResult, source, dir)) return false;
153
173
  if (!equalStringSets(scopeResult.markdownFiles, cachedSnapshot.markdownFiles))
154
174
  return false;
155
- return snapshotsEqual(cachedSnapshot, await buildScopeSnapshotAsync(dir));
175
+ const listing = await readMarkdownDirWithStatusAsync(dir);
176
+ if (!listing.ok) return false;
177
+ return equalStringSets(
178
+ cachedSnapshot.markdownFiles,
179
+ listing.entries.map((entry) => entry.name),
180
+ );
156
181
  }
157
182
 
158
183
  function buildSourceRecord<T>(
@@ -220,11 +245,14 @@ async function primeScopedCacheEntriesAsync(
220
245
  ctx: CacheOperationContext,
221
246
  bothEntry: AgentDiscoveryCacheEntry,
222
247
  ): Promise<void> {
223
- for (const source of AGENT_SOURCES) {
224
- const scopedKey = cacheKey(ctx.cwd, source);
225
- if (getFreshCacheEntry(ctx, scopedKey)) continue;
226
- await deriveOrDiscoverScopedEntryAsync(ctx, source, bothEntry);
227
- }
248
+ const missingSources = AGENT_SOURCES.filter(
249
+ (source) => !getFreshCacheEntry(ctx, cacheKey(ctx.cwd, source)),
250
+ );
251
+ await Promise.all(
252
+ missingSources.map((source) =>
253
+ deriveOrDiscoverScopedEntryAsync(ctx, source, bothEntry),
254
+ ),
255
+ );
228
256
  }
229
257
 
230
258
  export async function getCachedAgentDiscovery(
@@ -20,9 +20,9 @@ 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
26
  systemPrompt: string;
27
27
  source: AgentSource;
28
28
  filePath: string;
@@ -143,7 +143,7 @@ async function loadAgentsFromDirAsync(
143
143
  dir: string,
144
144
  source: AgentSource,
145
145
  ): Promise<AgentDiscoveryScopeResult> {
146
- const markdownEntries = await readMarkdownDirEntriesAsync(dir);
146
+ const markdownEntries = (await readMarkdownDirWithStatusAsync(dir)).entries;
147
147
  const markdownFiles = markdownEntries.map((entry) => entry.name);
148
148
  const parsedAgents = await Promise.all(
149
149
  markdownEntries.map((entry) =>
@@ -162,15 +162,20 @@ export function isMarkdownDirent(entry: Dirent): boolean {
162
162
  );
163
163
  }
164
164
 
165
- export async function readMarkdownDirEntriesAsync(
165
+ export interface MarkdownDirListing {
166
+ entries: Dirent[];
167
+ ok: boolean;
168
+ }
169
+
170
+ export async function readMarkdownDirWithStatusAsync(
166
171
  dir: string | null,
167
- ): Promise<Dirent[]> {
168
- if (!dir) return [];
172
+ ): Promise<MarkdownDirListing> {
173
+ if (!dir) return { entries: [], ok: true };
169
174
  try {
170
175
  const entries = await fsPromises.readdir(dir, { withFileTypes: true });
171
- return entries.filter(isMarkdownDirent);
176
+ return { entries: entries.filter(isMarkdownDirent), ok: true };
172
177
  } catch {
173
- return [];
178
+ return { entries: [], ok: false };
174
179
  }
175
180
  }
176
181
 
@@ -1,11 +1,10 @@
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;
@@ -15,7 +14,6 @@ function tryFirstResult(details: unknown): Record<string, unknown> | null {
15
14
  }
16
15
  }
17
16
 
18
- // Malformed details (null result) falls back to { toolName }.
19
17
  function parseToolActivity(
20
18
  toolName: string,
21
19
  partialResult: { content?: unknown; details?: unknown },
@@ -24,23 +22,25 @@ function parseToolActivity(
24
22
  if (!nestedRecord) return { toolName, inputSummary: toolName };
25
23
  const isSubagent = toolName === "subagent";
26
24
  const activity: ToolActivity = { toolName };
27
- const agent = typeof nestedRecord.agent === "string" && nestedRecord.agent;
25
+ const agent =
26
+ typeof nestedRecord["agent"] === "string" && nestedRecord["agent"];
28
27
  activity.inputSummary =
29
28
  isSubagent && agent ? makeToolPreview(toolName, nestedRecord) : toolName;
30
29
  if (
31
- typeof nestedRecord.instanceName === "string" &&
32
- nestedRecord.instanceName
30
+ typeof nestedRecord["instanceName"] === "string" &&
31
+ nestedRecord["instanceName"]
33
32
  ) {
34
- activity.instanceName = nestedRecord.instanceName;
33
+ activity.instanceName = nestedRecord["instanceName"] as string;
35
34
  }
36
- const progress = nestedRecord.progress;
35
+ const progress = nestedRecord["progress"];
37
36
  if (typeof progress === "object" && progress !== null) {
38
- const activeToolActivity = (progress as Record<string, unknown>)
39
- .activeToolActivity;
37
+ const activeToolActivity = (progress as Record<string, unknown>)[
38
+ "activeToolActivity"
39
+ ];
40
40
  if (
41
41
  typeof activeToolActivity === "object" &&
42
42
  activeToolActivity !== null &&
43
- typeof (activeToolActivity as Record<string, unknown>).toolName ===
43
+ typeof (activeToolActivity as Record<string, unknown>)["toolName"] ===
44
44
  "string"
45
45
  ) {
46
46
  const childActivity = activeToolActivity as ToolActivity;
@@ -96,21 +96,21 @@ export function parseChildEventLine(line: string): ChildEventParseResult {
96
96
  typeof event === "object" &&
97
97
  event !== null &&
98
98
  "type" in event &&
99
- typeof (event as Record<string, unknown>).type === "string" &&
100
- KNOWN_TYPES.has((event as Record<string, unknown>).type as string)
99
+ typeof (event as Record<string, unknown>)["type"] === "string" &&
100
+ KNOWN_TYPES.has((event as Record<string, unknown>)["type"] as string)
101
101
  ) {
102
102
  const record = event as Record<string, unknown>;
103
- if (record.type === TOOL_EXECUTION_UPDATE_EVENT) {
103
+ if (record["type"] === TOOL_EXECUTION_UPDATE_EVENT) {
104
104
  if (
105
- typeof record.toolName !== "string" ||
106
- typeof record.partialResult !== "object" ||
107
- record.partialResult === null
105
+ typeof record["toolName"] !== "string" ||
106
+ typeof record["partialResult"] !== "object" ||
107
+ record["partialResult"] === null
108
108
  ) {
109
109
  return { kind: "unknown", event };
110
110
  }
111
- record.toolActivity = parseToolActivity(
112
- record.toolName as string,
113
- record.partialResult as { content?: unknown; details?: unknown },
111
+ record["toolActivity"] = parseToolActivity(
112
+ record["toolName"] as string,
113
+ record["partialResult"] as { content?: unknown; details?: unknown },
114
114
  );
115
115
  }
116
116
  return { kind: "known", event: event as ChildKnownEvent };