@mystilleef/pi-subagent 0.7.0 → 0.8.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
@@ -151,7 +151,7 @@ executable automation.
151
151
  **Environment variables:**
152
152
 
153
153
  - `PI_SUBAGENT_DEPTH`: nested subagent depth guard. Nested calls stop at
154
- depth `2`.
154
+ depth `3`.
155
155
  - `PI_SUBAGENT_MAX_OUTPUT_BYTES`: max returned output bytes. Default:
156
156
  `50000`.
157
157
  - `PI_SUBAGENT_MAX_OUTPUT_LINES`: max returned output lines. Default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mystilleef/pi-subagent",
3
- "version": "0.7.0",
3
+ "version": "0.8.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
53
  "coverage": "bun test --coverage",
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
73
  "@types/node": "^25.9.1",
75
- "typebox": "^1.1.39",
74
+ "typebox": "^1.2.1",
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(
@@ -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
 
@@ -73,7 +73,7 @@ export function resolveThinkingLevel(
73
73
  return { level: clamped, warning: mkWarning(clamped) };
74
74
  }
75
75
 
76
- const MAX_SUBAGENT_DEPTH = 2;
76
+ const MAX_SUBAGENT_DEPTH = 3;
77
77
  export const TOOL_RESULT_FAILED_MESSAGE = "Subagent tool result failed.";
78
78
 
79
79
  type RuntimeResult = SingleResult & { messages: Message[] };
@@ -89,15 +89,54 @@ export function getPiInvocation(args: string[]): {
89
89
  return { command: "pi", args };
90
90
  }
91
91
 
92
+ const SKILL_DISCOVERY_CACHE_TTL_MS = 300_000;
93
+
94
+ type ResolvedSkillArgsCacheEntry = {
95
+ skillPaths: Map<string, string>;
96
+ ts: number;
97
+ };
98
+
99
+ const resolvedSkillArgsCache = new Map<string, ResolvedSkillArgsCacheEntry>();
100
+
101
+ async function canonicalPath(filePath: string): Promise<string> {
102
+ try {
103
+ return await fs.promises.realpath(filePath);
104
+ } catch {
105
+ return path.resolve(filePath);
106
+ }
107
+ }
108
+
109
+ function buildSkillArgs(
110
+ requested: string[],
111
+ skillPaths: Map<string, string>,
112
+ ): string[] {
113
+ return requested.flatMap((name) => ["--skill", skillPaths.get(name) ?? name]);
114
+ }
115
+
116
+ export function resetResolvedAgentSkillArgsCache(): void {
117
+ resolvedSkillArgsCache.clear();
118
+ }
119
+
92
120
  export async function resolveAgentSkillArgs(
93
121
  cwd: string,
94
122
  skillNames: string[],
95
123
  ): Promise<{ args: string[] } | { error: string }> {
96
124
  const requested = Array.from(new Set(skillNames));
97
125
  if (requested.length === 0) return { args: [] };
126
+ const cacheIdentitySkills = [...requested].sort();
127
+ const agentDir = getAgentDir();
128
+ const cacheKey = JSON.stringify({
129
+ cwd: await canonicalPath(cwd),
130
+ agentDir: await canonicalPath(agentDir),
131
+ skills: cacheIdentitySkills,
132
+ });
133
+ const cached = resolvedSkillArgsCache.get(cacheKey);
134
+ if (cached && Date.now() - cached.ts <= SKILL_DISCOVERY_CACHE_TTL_MS) {
135
+ return { args: buildSkillArgs(requested, cached.skillPaths) };
136
+ }
98
137
  const loader = new DefaultResourceLoader({
99
138
  cwd,
100
- agentDir: getAgentDir(),
139
+ agentDir,
101
140
  noContextFiles: true,
102
141
  noPromptTemplates: true,
103
142
  noThemes: true,
@@ -124,12 +163,12 @@ export async function resolveAgentSkillArgs(
124
163
  .join(", ")}. Available skills: ${available}.`,
125
164
  };
126
165
  }
127
- return {
128
- args: requested.flatMap((name) => [
129
- "--skill",
130
- skillMap.get(name)?.filePath ?? name,
131
- ]),
132
- };
166
+ const skillPaths = new Map(
167
+ requested.map((name) => [name, skillMap.get(name)?.filePath ?? name]),
168
+ );
169
+ const args = buildSkillArgs(requested, skillPaths);
170
+ resolvedSkillArgsCache.set(cacheKey, { skillPaths, ts: Date.now() });
171
+ return { args };
133
172
  }
134
173
 
135
174
  export function getSubagentDepth(): number {