@mystilleef/pi-subagent 0.5.0 → 0.7.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 +1 -1
- package/package.json +13 -11
- package/src/agent/agent-cache.ts +258 -0
- package/src/agent/agents.ts +232 -0
- package/src/child/child-events.ts +119 -0
- package/src/{process.ts → child/process.ts} +268 -122
- package/src/{prompt-contract.ts → child/prompt-contract.ts} +2 -0
- package/src/index.ts +43 -24
- package/src/{jobs-command.ts → orchestration/jobs-command.ts} +2 -3
- package/src/{run.ts → orchestration/run.ts} +2 -2
- package/src/{subagent-orchestrator.ts → orchestration/subagent-orchestrator.ts} +246 -124
- package/src/{normalize.ts → output/normalize.ts} +35 -49
- package/src/{summary.ts → output/summary.ts} +4 -2
- package/src/{ui.ts → output/ui.ts} +45 -48
- package/src/{progress-state.ts → progress/progress-state.ts} +164 -52
- package/src/{progress.ts → progress/progress.ts} +21 -21
- package/src/{result-details.ts → progress/result-details.ts} +55 -17
- package/src/shared/instance-name.ts +117 -0
- package/src/{types.ts → shared/types.ts} +13 -2
- package/src/{utils.ts → shared/utils.ts} +4 -6
- package/src/agent-cache.ts +0 -41
- package/src/agents.ts +0 -178
- package/src/child-events.ts +0 -32
- package/src/instance-name.ts +0 -164
- /package/src/{termination.ts → child/termination.ts} +0 -0
- /package/src/{cancel-command.ts → orchestration/cancel-command.ts} +0 -0
- /package/src/{run-command.ts → orchestration/run-command.ts} +0 -0
- /package/src/{run-registry.ts → orchestration/run-registry.ts} +0 -0
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 `
|
|
154
|
+
depth `2`.
|
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Pi subagent for the SPAE Framework",
|
|
5
5
|
"author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,12 +47,14 @@
|
|
|
47
47
|
"access": "public"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"check": "biome check . && tsc --noEmit",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"lint": "biome check --error-on-warnings .",
|
|
53
52
|
"fix": "biome check --write --unsafe .",
|
|
54
|
-
"pack:smoke": "bun scripts/pack-smoke.ts",
|
|
55
53
|
"migrate": "biome migrate --write",
|
|
54
|
+
"coverage": "bun test --coverage",
|
|
55
|
+
"check": "bun lint && bun typecheck",
|
|
56
|
+
"verify": "bun migrate && bun fix && bun typecheck && bun coverage",
|
|
57
|
+
"pack:smoke": "bun scripts/pack-smoke.ts",
|
|
56
58
|
"release": "sh -c 'npm version \"$1\" -m \"chore(release): %s\" && git push --follow-tags' --"
|
|
57
59
|
},
|
|
58
60
|
"peerDependencies": {
|
|
@@ -63,14 +65,14 @@
|
|
|
63
65
|
"typebox": "*"
|
|
64
66
|
},
|
|
65
67
|
"devDependencies": {
|
|
66
|
-
"@biomejs/biome": "^2.4.
|
|
67
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
68
|
-
"@earendil-works/pi-ai": "^0.
|
|
69
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
70
|
-
"@earendil-works/pi-tui": "^0.
|
|
68
|
+
"@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",
|
|
71
73
|
"@types/bun": "^1.3.14",
|
|
72
74
|
"@types/node": "^25.9.1",
|
|
73
|
-
"typebox": "^1.1.
|
|
75
|
+
"typebox": "^1.1.39",
|
|
74
76
|
"typescript": "^6.0.3"
|
|
75
77
|
}
|
|
76
78
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fsPromises from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type AgentDiscoveryResult,
|
|
6
|
+
type AgentDiscoveryScopeResult,
|
|
7
|
+
type AgentScope,
|
|
8
|
+
type AgentSource,
|
|
9
|
+
discoverAgentsAsync,
|
|
10
|
+
emptyScopeResult,
|
|
11
|
+
getUserAgentsDir,
|
|
12
|
+
readMarkdownDirEntriesAsync,
|
|
13
|
+
} from "./agents.js";
|
|
14
|
+
|
|
15
|
+
interface AgentDiscoveryScopeSnapshot {
|
|
16
|
+
markdownFiles: string[];
|
|
17
|
+
fileHashes: Record<string, string | null>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type AgentDiscoverySnapshots = Record<AgentSource, AgentDiscoveryScopeSnapshot>;
|
|
21
|
+
|
|
22
|
+
export type AgentDiscoveryCacheEntry = AgentDiscoveryResult & {
|
|
23
|
+
ts: number;
|
|
24
|
+
snapshots?: AgentDiscoverySnapshots;
|
|
25
|
+
};
|
|
26
|
+
export type AgentDiscoveryCache = Map<string, AgentDiscoveryCacheEntry>;
|
|
27
|
+
export const AGENT_DISCOVERY_CACHE_TTL_MS = 300_000;
|
|
28
|
+
const sharedAgentDiscoveryCache: AgentDiscoveryCache = new Map();
|
|
29
|
+
const AGENT_SOURCES = ["user", "project"] as const;
|
|
30
|
+
|
|
31
|
+
interface CacheOperationContext {
|
|
32
|
+
cwd: string;
|
|
33
|
+
cache: AgentDiscoveryCache;
|
|
34
|
+
ts: number;
|
|
35
|
+
cacheTtlMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resetAgentDiscoveryCache(): void {
|
|
39
|
+
sharedAgentDiscoveryCache.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cacheKey(cwd: string, scope: AgentScope): string {
|
|
43
|
+
return `${path.resolve(cwd)}\0${scope}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getFreshCacheEntry(
|
|
47
|
+
ctx: CacheOperationContext,
|
|
48
|
+
key: string,
|
|
49
|
+
): AgentDiscoveryCacheEntry | undefined {
|
|
50
|
+
const entry = ctx.cache.get(key);
|
|
51
|
+
return entry && ctx.ts - entry.ts <= ctx.cacheTtlMs ? entry : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function emptySnapshot(): AgentDiscoveryScopeSnapshot {
|
|
55
|
+
return { markdownFiles: [], fileHashes: {} };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cloneScopeSnapshot(
|
|
59
|
+
snapshot: AgentDiscoveryScopeSnapshot | undefined,
|
|
60
|
+
): AgentDiscoveryScopeSnapshot {
|
|
61
|
+
if (!snapshot) return emptySnapshot();
|
|
62
|
+
return {
|
|
63
|
+
markdownFiles: [...snapshot.markdownFiles],
|
|
64
|
+
fileHashes: { ...snapshot.fileHashes },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function equalStringSets(a: string[], b: string[]): boolean {
|
|
69
|
+
if (a.length !== b.length) return false;
|
|
70
|
+
const set = new Set(a);
|
|
71
|
+
return b.every((value) => set.has(value));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function snapshotsEqual(
|
|
75
|
+
left: AgentDiscoveryScopeSnapshot,
|
|
76
|
+
right: AgentDiscoveryScopeSnapshot,
|
|
77
|
+
): boolean {
|
|
78
|
+
if (!equalStringSets(left.markdownFiles, right.markdownFiles)) return false;
|
|
79
|
+
return left.markdownFiles.every(
|
|
80
|
+
(fileName) => left.fileHashes[fileName] === right.fileHashes[fileName],
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scopeAgentsMatchListing(
|
|
85
|
+
scopeResult: AgentDiscoveryScopeResult,
|
|
86
|
+
source: AgentSource,
|
|
87
|
+
dir: string | null,
|
|
88
|
+
): boolean {
|
|
89
|
+
if (!dir)
|
|
90
|
+
return (
|
|
91
|
+
scopeResult.agents.length === 0 && scopeResult.markdownFiles.length === 0
|
|
92
|
+
);
|
|
93
|
+
const listedFiles = new Set(scopeResult.markdownFiles);
|
|
94
|
+
const resolvedDir = path.resolve(dir);
|
|
95
|
+
return scopeResult.agents.every((agent) => {
|
|
96
|
+
const fileName = path.basename(agent.filePath);
|
|
97
|
+
return (
|
|
98
|
+
agent.source === source &&
|
|
99
|
+
listedFiles.has(fileName) &&
|
|
100
|
+
path.resolve(path.dirname(agent.filePath)) === resolvedDir
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function hashMarkdownFileAsync(
|
|
106
|
+
dir: string,
|
|
107
|
+
fileName: string,
|
|
108
|
+
): Promise<string | null> {
|
|
109
|
+
try {
|
|
110
|
+
return createHash("sha256")
|
|
111
|
+
.update(await fsPromises.readFile(path.join(dir, fileName)))
|
|
112
|
+
.digest("hex");
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function buildScopeSnapshotAsync(
|
|
119
|
+
dir: string | null,
|
|
120
|
+
): Promise<AgentDiscoveryScopeSnapshot> {
|
|
121
|
+
if (!dir) return emptySnapshot();
|
|
122
|
+
const entries = await readMarkdownDirEntriesAsync(dir);
|
|
123
|
+
const markdownFiles = entries.map((entry) => entry.name);
|
|
124
|
+
const hashPairs = await Promise.all(
|
|
125
|
+
markdownFiles.map(
|
|
126
|
+
async (fileName) =>
|
|
127
|
+
[fileName, await hashMarkdownFileAsync(dir, fileName)] as const,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
return { markdownFiles, fileHashes: Object.fromEntries(hashPairs) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function buildCacheSnapshotsAsync(
|
|
134
|
+
discovery: AgentDiscoveryResult,
|
|
135
|
+
): Promise<AgentDiscoverySnapshots> {
|
|
136
|
+
const [user, project] = await Promise.all([
|
|
137
|
+
buildScopeSnapshotAsync(getUserAgentsDir()),
|
|
138
|
+
buildScopeSnapshotAsync(discovery.projectAgentsDir),
|
|
139
|
+
]);
|
|
140
|
+
return { user, project };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function canTrustDerivedScopeAsync(
|
|
144
|
+
source: AgentSource,
|
|
145
|
+
bothEntry: AgentDiscoveryCacheEntry,
|
|
146
|
+
): Promise<boolean> {
|
|
147
|
+
const dir =
|
|
148
|
+
source === "user" ? getUserAgentsDir() : bothEntry.projectAgentsDir;
|
|
149
|
+
const scopeResult = bothEntry.scopes[source];
|
|
150
|
+
const cachedSnapshot = bothEntry.snapshots?.[source];
|
|
151
|
+
if (!cachedSnapshot) return false;
|
|
152
|
+
if (!scopeAgentsMatchListing(scopeResult, source, dir)) return false;
|
|
153
|
+
if (!equalStringSets(scopeResult.markdownFiles, cachedSnapshot.markdownFiles))
|
|
154
|
+
return false;
|
|
155
|
+
return snapshotsEqual(cachedSnapshot, await buildScopeSnapshotAsync(dir));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildSourceRecord<T>(
|
|
159
|
+
source: AgentSource,
|
|
160
|
+
value: T,
|
|
161
|
+
empty: () => T,
|
|
162
|
+
): Record<AgentSource, T> {
|
|
163
|
+
const record: Record<AgentSource, T> = { user: empty(), project: empty() };
|
|
164
|
+
record[source] = value;
|
|
165
|
+
return record;
|
|
166
|
+
}
|
|
167
|
+
function createDerivedCacheEntry(
|
|
168
|
+
bothEntry: AgentDiscoveryCacheEntry,
|
|
169
|
+
source: AgentSource,
|
|
170
|
+
): AgentDiscoveryCacheEntry {
|
|
171
|
+
const { agents, markdownFiles } = bothEntry.scopes[source];
|
|
172
|
+
const clonedScope: AgentDiscoveryScopeResult = {
|
|
173
|
+
agents: [...agents],
|
|
174
|
+
markdownFiles: [...markdownFiles],
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
agents: clonedScope.agents,
|
|
178
|
+
projectAgentsDir: bothEntry.projectAgentsDir,
|
|
179
|
+
scopes: buildSourceRecord(source, clonedScope, emptyScopeResult),
|
|
180
|
+
ts: bothEntry.ts,
|
|
181
|
+
snapshots: buildSourceRecord(
|
|
182
|
+
source,
|
|
183
|
+
cloneScopeSnapshot(bothEntry.snapshots?.[source]),
|
|
184
|
+
emptySnapshot,
|
|
185
|
+
),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function discoverAndCacheAsync(
|
|
190
|
+
ctx: CacheOperationContext,
|
|
191
|
+
scope: AgentScope,
|
|
192
|
+
): Promise<AgentDiscoveryCacheEntry> {
|
|
193
|
+
const discovery = await discoverAgentsAsync(ctx.cwd, scope);
|
|
194
|
+
const entry: AgentDiscoveryCacheEntry = {
|
|
195
|
+
...discovery,
|
|
196
|
+
ts: ctx.ts,
|
|
197
|
+
snapshots: await buildCacheSnapshotsAsync(discovery),
|
|
198
|
+
};
|
|
199
|
+
ctx.cache.set(cacheKey(ctx.cwd, scope), entry);
|
|
200
|
+
if (scope === "both") {
|
|
201
|
+
await primeScopedCacheEntriesAsync(ctx, entry);
|
|
202
|
+
}
|
|
203
|
+
return entry;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function deriveOrDiscoverScopedEntryAsync(
|
|
207
|
+
ctx: CacheOperationContext,
|
|
208
|
+
source: AgentSource,
|
|
209
|
+
bothEntry: AgentDiscoveryCacheEntry,
|
|
210
|
+
): Promise<AgentDiscoveryCacheEntry> {
|
|
211
|
+
if (await canTrustDerivedScopeAsync(source, bothEntry)) {
|
|
212
|
+
const entry = createDerivedCacheEntry(bothEntry, source);
|
|
213
|
+
ctx.cache.set(cacheKey(ctx.cwd, source), entry);
|
|
214
|
+
return entry;
|
|
215
|
+
}
|
|
216
|
+
return discoverAndCacheAsync(ctx, source);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function primeScopedCacheEntriesAsync(
|
|
220
|
+
ctx: CacheOperationContext,
|
|
221
|
+
bothEntry: AgentDiscoveryCacheEntry,
|
|
222
|
+
): 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
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function getCachedAgentDiscovery(
|
|
231
|
+
cwd: string,
|
|
232
|
+
scope: AgentScope,
|
|
233
|
+
cache: AgentDiscoveryCache = sharedAgentDiscoveryCache,
|
|
234
|
+
cacheTtlMs = AGENT_DISCOVERY_CACHE_TTL_MS,
|
|
235
|
+
): Promise<AgentDiscoveryCacheEntry> {
|
|
236
|
+
const ctx: CacheOperationContext = { cwd, cache, ts: Date.now(), cacheTtlMs };
|
|
237
|
+
const key = cacheKey(cwd, scope);
|
|
238
|
+
const entry = getFreshCacheEntry(ctx, key);
|
|
239
|
+
if (entry) return entry;
|
|
240
|
+
if (scope !== "both") {
|
|
241
|
+
const bothEntry = getFreshCacheEntry(ctx, cacheKey(cwd, "both"));
|
|
242
|
+
if (bothEntry) {
|
|
243
|
+
return deriveOrDiscoverScopedEntryAsync(ctx, scope, bothEntry);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return discoverAndCacheAsync(ctx, scope);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function getCachedAgentCompletions(
|
|
250
|
+
prefix: string,
|
|
251
|
+
cwd = process.cwd(),
|
|
252
|
+
cache: AgentDiscoveryCache = sharedAgentDiscoveryCache,
|
|
253
|
+
cacheTtlMs = AGENT_DISCOVERY_CACHE_TTL_MS,
|
|
254
|
+
): Promise<{ value: string; label: string }[]> {
|
|
255
|
+
return (await getCachedAgentDiscovery(cwd, "both", cache, cacheTtlMs)).agents
|
|
256
|
+
.filter((agent) => agent.name.startsWith(prefix))
|
|
257
|
+
.map((agent) => ({ value: agent.name, label: agent.name }));
|
|
258
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import * as fsPromises from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export type AgentSource = "user" | "project";
|
|
7
|
+
export type AgentScope = AgentSource | "both";
|
|
8
|
+
|
|
9
|
+
const THINKING_LEVELS = [
|
|
10
|
+
"off",
|
|
11
|
+
"minimal",
|
|
12
|
+
"low",
|
|
13
|
+
"medium",
|
|
14
|
+
"high",
|
|
15
|
+
"xhigh",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
19
|
+
|
|
20
|
+
export interface AgentConfig {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
tools?: string[];
|
|
24
|
+
skills?: string[];
|
|
25
|
+
thinking?: ThinkingLevel;
|
|
26
|
+
systemPrompt: string;
|
|
27
|
+
source: AgentSource;
|
|
28
|
+
filePath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AgentDiscoveryScopeResult {
|
|
32
|
+
agents: AgentConfig[];
|
|
33
|
+
markdownFiles: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AgentDiscoveryResult {
|
|
37
|
+
agents: AgentConfig[];
|
|
38
|
+
projectAgentsDir: string | null;
|
|
39
|
+
scopes: Record<AgentSource, AgentDiscoveryScopeResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mergeAgentLists(
|
|
43
|
+
userResult: AgentDiscoveryScopeResult,
|
|
44
|
+
projectResult: AgentDiscoveryScopeResult,
|
|
45
|
+
projectAgentsDir: string | null,
|
|
46
|
+
): AgentDiscoveryResult {
|
|
47
|
+
const agentMap = new Map<string, AgentConfig>();
|
|
48
|
+
for (const agent of userResult.agents) agentMap.set(agent.name, agent);
|
|
49
|
+
for (const agent of projectResult.agents) agentMap.set(agent.name, agent);
|
|
50
|
+
return {
|
|
51
|
+
agents: Array.from(agentMap.values()),
|
|
52
|
+
projectAgentsDir,
|
|
53
|
+
scopes: {
|
|
54
|
+
user: userResult,
|
|
55
|
+
project: projectResult,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseCommaList(raw: unknown): string[] | undefined {
|
|
61
|
+
if (typeof raw !== "string") return undefined;
|
|
62
|
+
const items = raw
|
|
63
|
+
.split(",")
|
|
64
|
+
.map((s) => s.trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
return items.length > 0 ? items : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseThinkingLevel(raw: unknown): ThinkingLevel | undefined {
|
|
70
|
+
if (typeof raw !== "string") return undefined;
|
|
71
|
+
const normalized = raw.trim().toLowerCase();
|
|
72
|
+
return (THINKING_LEVELS as readonly string[]).includes(normalized)
|
|
73
|
+
? (normalized as ThinkingLevel)
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseAgentConfig(
|
|
78
|
+
content: string,
|
|
79
|
+
source: AgentSource,
|
|
80
|
+
filePath: string,
|
|
81
|
+
): AgentConfig | null {
|
|
82
|
+
let parsed: ReturnType<typeof parseFrontmatter<Record<string, unknown>>>;
|
|
83
|
+
try {
|
|
84
|
+
parsed = parseFrontmatter<Record<string, unknown>>(content);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const { frontmatter, body } = parsed;
|
|
89
|
+
if (
|
|
90
|
+
typeof frontmatter !== "object" ||
|
|
91
|
+
frontmatter === null ||
|
|
92
|
+
Array.isArray(frontmatter)
|
|
93
|
+
)
|
|
94
|
+
return null;
|
|
95
|
+
const {
|
|
96
|
+
name,
|
|
97
|
+
description,
|
|
98
|
+
tools: rawTools,
|
|
99
|
+
skills: rawSkills,
|
|
100
|
+
thinking: rawThinking,
|
|
101
|
+
} = frontmatter;
|
|
102
|
+
if (typeof name !== "string" || typeof description !== "string") return null;
|
|
103
|
+
if (rawTools != null && typeof rawTools !== "string") return null;
|
|
104
|
+
if (rawSkills != null && typeof rawSkills !== "string") return null;
|
|
105
|
+
if (rawThinking != null && typeof rawThinking !== "string") return null;
|
|
106
|
+
const tools = parseCommaList(rawTools);
|
|
107
|
+
const skills = Object.hasOwn(frontmatter, "skills")
|
|
108
|
+
? (parseCommaList(rawSkills) ?? [])
|
|
109
|
+
: undefined;
|
|
110
|
+
const thinking = parseThinkingLevel(rawThinking);
|
|
111
|
+
return {
|
|
112
|
+
name,
|
|
113
|
+
description,
|
|
114
|
+
tools,
|
|
115
|
+
skills,
|
|
116
|
+
thinking,
|
|
117
|
+
systemPrompt: body,
|
|
118
|
+
source,
|
|
119
|
+
filePath,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function loadAgentEntryAsync(
|
|
124
|
+
dir: string,
|
|
125
|
+
entryName: string,
|
|
126
|
+
source: AgentSource,
|
|
127
|
+
): Promise<AgentConfig | null> {
|
|
128
|
+
const filePath = path.join(dir, entryName);
|
|
129
|
+
let content: string;
|
|
130
|
+
try {
|
|
131
|
+
content = await fsPromises.readFile(filePath, "utf-8");
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return parseAgentConfig(content, source, filePath);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function emptyScopeResult(): AgentDiscoveryScopeResult {
|
|
139
|
+
return { agents: [], markdownFiles: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function loadAgentsFromDirAsync(
|
|
143
|
+
dir: string,
|
|
144
|
+
source: AgentSource,
|
|
145
|
+
): Promise<AgentDiscoveryScopeResult> {
|
|
146
|
+
const markdownEntries = await readMarkdownDirEntriesAsync(dir);
|
|
147
|
+
const markdownFiles = markdownEntries.map((entry) => entry.name);
|
|
148
|
+
const parsedAgents = await Promise.all(
|
|
149
|
+
markdownEntries.map((entry) =>
|
|
150
|
+
loadAgentEntryAsync(dir, entry.name, source),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
const agents = parsedAgents.filter(
|
|
154
|
+
(agent): agent is AgentConfig => agent !== null,
|
|
155
|
+
);
|
|
156
|
+
return { agents, markdownFiles };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isMarkdownDirent(entry: Dirent): boolean {
|
|
160
|
+
return (
|
|
161
|
+
entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink())
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function readMarkdownDirEntriesAsync(
|
|
166
|
+
dir: string | null,
|
|
167
|
+
): Promise<Dirent[]> {
|
|
168
|
+
if (!dir) return [];
|
|
169
|
+
try {
|
|
170
|
+
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
171
|
+
return entries.filter(isMarkdownDirent);
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getUserAgentsDir(): string {
|
|
178
|
+
return path.join(getAgentDir(), "agents");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function isDirectoryAsync(p: string): Promise<boolean> {
|
|
182
|
+
try {
|
|
183
|
+
return (await fsPromises.stat(p)).isDirectory();
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function findNearestProjectAgentsDirAsync(
|
|
190
|
+
cwd: string,
|
|
191
|
+
): Promise<string | null> {
|
|
192
|
+
let currentDir = cwd;
|
|
193
|
+
while (true) {
|
|
194
|
+
const candidate = path.join(currentDir, ".pi", "agents");
|
|
195
|
+
if (await isDirectoryAsync(candidate)) return candidate;
|
|
196
|
+
const parentDir = path.dirname(currentDir);
|
|
197
|
+
if (parentDir === currentDir) return null;
|
|
198
|
+
currentDir = parentDir;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function discoverAgentsAsync(
|
|
203
|
+
cwd: string,
|
|
204
|
+
scope: AgentScope,
|
|
205
|
+
): Promise<AgentDiscoveryResult> {
|
|
206
|
+
const userDir = getUserAgentsDir();
|
|
207
|
+
const projectAgentsDir = await findNearestProjectAgentsDirAsync(cwd);
|
|
208
|
+
const [userDiscovery, projectDiscovery] = await Promise.all([
|
|
209
|
+
scope === "project"
|
|
210
|
+
? emptyScopeResult()
|
|
211
|
+
: loadAgentsFromDirAsync(userDir, "user"),
|
|
212
|
+
scope === "user" || !projectAgentsDir
|
|
213
|
+
? emptyScopeResult()
|
|
214
|
+
: loadAgentsFromDirAsync(projectAgentsDir, "project"),
|
|
215
|
+
]);
|
|
216
|
+
return mergeAgentLists(userDiscovery, projectDiscovery, projectAgentsDir);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function formatAgentList(
|
|
220
|
+
agents: AgentConfig[],
|
|
221
|
+
maxItems: number,
|
|
222
|
+
): { text: string; remaining: number } {
|
|
223
|
+
if (agents.length === 0) return { text: "none", remaining: 0 };
|
|
224
|
+
const listed = agents.slice(0, maxItems);
|
|
225
|
+
const remaining = agents.length - listed.length;
|
|
226
|
+
return {
|
|
227
|
+
text: listed
|
|
228
|
+
.map((a) => `${a.name} (${a.source}): ${a.description}`)
|
|
229
|
+
.join("; "),
|
|
230
|
+
remaining,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { makeToolPreview } from "../output/normalize.js";
|
|
2
|
+
import type { ToolActivity } from "../shared/types.js";
|
|
3
|
+
|
|
4
|
+
// Extracts results[0] from details; null-safe for malformed input.
|
|
5
|
+
function tryFirstResult(details: unknown): Record<string, unknown> | null {
|
|
6
|
+
try {
|
|
7
|
+
if (typeof details !== "object" || details === null) return null;
|
|
8
|
+
const results = (details as Record<string, unknown>).results;
|
|
9
|
+
if (!Array.isArray(results) || results.length === 0) return null;
|
|
10
|
+
const nested = results[0];
|
|
11
|
+
if (typeof nested !== "object" || nested === null) return null;
|
|
12
|
+
return nested as Record<string, unknown>;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Malformed details (null result) falls back to { toolName }.
|
|
19
|
+
function parseToolActivity(
|
|
20
|
+
toolName: string,
|
|
21
|
+
partialResult: { content?: unknown; details?: unknown },
|
|
22
|
+
): ToolActivity {
|
|
23
|
+
const nestedRecord = tryFirstResult(partialResult.details);
|
|
24
|
+
if (!nestedRecord) return { toolName, inputSummary: toolName };
|
|
25
|
+
const isSubagent = toolName === "subagent";
|
|
26
|
+
const activity: ToolActivity = { toolName };
|
|
27
|
+
const agent = typeof nestedRecord.agent === "string" && nestedRecord.agent;
|
|
28
|
+
activity.inputSummary =
|
|
29
|
+
isSubagent && agent ? makeToolPreview(toolName, nestedRecord) : toolName;
|
|
30
|
+
if (
|
|
31
|
+
typeof nestedRecord.instanceName === "string" &&
|
|
32
|
+
nestedRecord.instanceName
|
|
33
|
+
) {
|
|
34
|
+
activity.instanceName = nestedRecord.instanceName;
|
|
35
|
+
}
|
|
36
|
+
const progress = nestedRecord.progress;
|
|
37
|
+
if (typeof progress === "object" && progress !== null) {
|
|
38
|
+
const activeToolActivity = (progress as Record<string, unknown>)
|
|
39
|
+
.activeToolActivity;
|
|
40
|
+
if (
|
|
41
|
+
typeof activeToolActivity === "object" &&
|
|
42
|
+
activeToolActivity !== null &&
|
|
43
|
+
typeof (activeToolActivity as Record<string, unknown>).toolName ===
|
|
44
|
+
"string"
|
|
45
|
+
) {
|
|
46
|
+
const childActivity = activeToolActivity as ToolActivity;
|
|
47
|
+
activity.child = childActivity;
|
|
48
|
+
// Subagent delegates inputSummary to its own agent name, not child
|
|
49
|
+
if (
|
|
50
|
+
!isSubagent &&
|
|
51
|
+
typeof childActivity.inputSummary === "string" &&
|
|
52
|
+
childActivity.inputSummary
|
|
53
|
+
) {
|
|
54
|
+
activity.inputSummary = childActivity.inputSummary;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return activity;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ChildKnownEvent =
|
|
62
|
+
| { type: "message_end"; message: unknown }
|
|
63
|
+
| { type: "tool_result_end"; message: unknown }
|
|
64
|
+
| { type: "agent_end"; messages?: unknown; stopReason?: string }
|
|
65
|
+
| {
|
|
66
|
+
type: "tool_execution_update";
|
|
67
|
+
toolName: string;
|
|
68
|
+
partialResult: { content?: unknown; details?: unknown };
|
|
69
|
+
toolActivity: ToolActivity;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type ChildEventParseResult =
|
|
73
|
+
| { kind: "known"; event: ChildKnownEvent }
|
|
74
|
+
| { kind: "unknown"; event: unknown }
|
|
75
|
+
| { kind: "invalid"; line: string };
|
|
76
|
+
|
|
77
|
+
export const TOOL_EXECUTION_UPDATE_EVENT = "tool_execution_update" as const;
|
|
78
|
+
|
|
79
|
+
const KNOWN_TYPES = new Set([
|
|
80
|
+
"message_end",
|
|
81
|
+
"tool_result_end",
|
|
82
|
+
"agent_end",
|
|
83
|
+
TOOL_EXECUTION_UPDATE_EVENT,
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
export function parseChildEventLine(line: string): ChildEventParseResult {
|
|
87
|
+
if (typeof line !== "string" || !line.trim())
|
|
88
|
+
return { kind: "invalid", line };
|
|
89
|
+
let event: unknown;
|
|
90
|
+
try {
|
|
91
|
+
event = JSON.parse(line);
|
|
92
|
+
} catch {
|
|
93
|
+
return { kind: "invalid", line };
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
typeof event === "object" &&
|
|
97
|
+
event !== null &&
|
|
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)
|
|
101
|
+
) {
|
|
102
|
+
const record = event as Record<string, unknown>;
|
|
103
|
+
if (record.type === TOOL_EXECUTION_UPDATE_EVENT) {
|
|
104
|
+
if (
|
|
105
|
+
typeof record.toolName !== "string" ||
|
|
106
|
+
typeof record.partialResult !== "object" ||
|
|
107
|
+
record.partialResult === null
|
|
108
|
+
) {
|
|
109
|
+
return { kind: "unknown", event };
|
|
110
|
+
}
|
|
111
|
+
record.toolActivity = parseToolActivity(
|
|
112
|
+
record.toolName as string,
|
|
113
|
+
record.partialResult as { content?: unknown; details?: unknown },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return { kind: "known", event: event as ChildKnownEvent };
|
|
117
|
+
}
|
|
118
|
+
return { kind: "unknown", event };
|
|
119
|
+
}
|