@oai404iao/pi-subagent 0.2.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/src/agents.ts ADDED
@@ -0,0 +1,174 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
+ import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
5
+ import { assertSupportedToolReferences } from "./tool-policy.ts";
6
+ import type { AgentDefinition, AgentScope, AgentSource } from "./types.ts";
7
+
8
+ const MAX_AGENT_FILE_BYTES = 256 * 1024;
9
+ const AGENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
10
+ const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
11
+
12
+ export interface AgentDiscoveryOptions {
13
+ cwd: string;
14
+ scope: AgentScope;
15
+ projectTrusted: boolean;
16
+ bundledDir: string;
17
+ agentDir?: string;
18
+ includeBundled?: boolean;
19
+ excludeUserAgentNames?: ReadonlySet<string>;
20
+ }
21
+
22
+ export interface AgentDiscoveryResult {
23
+ agents: AgentDefinition[];
24
+ diagnostics: string[];
25
+ projectAgentsDir?: string;
26
+ }
27
+
28
+ function isDirectory(path: string): boolean {
29
+ try {
30
+ return statSync(path).isDirectory();
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function findNearestProjectAgentsDir(cwd: string): string | undefined {
37
+ let current = resolve(cwd);
38
+ while (true) {
39
+ const candidate = join(current, CONFIG_DIR_NAME, "agents");
40
+ if (isDirectory(candidate)) return candidate;
41
+ const parent = dirname(current);
42
+ if (parent === current) return undefined;
43
+ current = parent;
44
+ }
45
+ }
46
+
47
+ function optionalString(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
49
+ }
50
+
51
+ function parseTools(value: unknown, filePath: string): string[] | undefined {
52
+ const raw = optionalString(value);
53
+ if (!raw || raw === "*" || raw.toLowerCase() === "all") return undefined;
54
+ if (raw.toLowerCase() === "none") return [];
55
+ const tools = [...new Set(raw.split(",").map((tool) => tool.trim()).filter(Boolean))];
56
+ if (tools.length === 0) throw new Error(`${filePath}: tools must name at least one tool, "all", or "none"`);
57
+ assertSupportedToolReferences(tools, `${filePath}: tools`);
58
+ return tools;
59
+ }
60
+
61
+ function parseThinking(value: unknown, filePath: string): ThinkingLevel | undefined {
62
+ const thinking = optionalString(value);
63
+ if (!thinking) return undefined;
64
+ if (!THINKING_LEVELS.has(thinking as ThinkingLevel)) {
65
+ throw new Error(`${filePath}: unsupported thinking level "${thinking}"`);
66
+ }
67
+ return thinking as ThinkingLevel;
68
+ }
69
+
70
+ function loadAgentFile(filePath: string, source: AgentSource): AgentDefinition {
71
+ const stats = statSync(filePath);
72
+ if (stats.size > MAX_AGENT_FILE_BYTES) {
73
+ throw new Error(`${filePath}: agent definition exceeds ${MAX_AGENT_FILE_BYTES} bytes`);
74
+ }
75
+ const content = readFileSync(filePath, "utf8");
76
+ const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
77
+ const name = optionalString(frontmatter.name);
78
+ const description = optionalString(frontmatter.description);
79
+ if (!name || !AGENT_NAME_PATTERN.test(name)) {
80
+ throw new Error(`${filePath}: name must match ${AGENT_NAME_PATTERN}`);
81
+ }
82
+ if (!description) throw new Error(`${filePath}: description is required`);
83
+ if (!body.trim()) throw new Error(`${filePath}: agent system prompt is empty`);
84
+
85
+ return {
86
+ name,
87
+ description,
88
+ tools: parseTools(frontmatter.tools, filePath),
89
+ model: optionalString(frontmatter.model),
90
+ thinking: parseThinking(frontmatter.thinking, filePath),
91
+ systemPrompt: body.trim(),
92
+ source,
93
+ filePath,
94
+ };
95
+ }
96
+
97
+ function loadDirectory(
98
+ dir: string,
99
+ source: AgentSource,
100
+ excludeNames?: ReadonlySet<string>,
101
+ ): { agents: AgentDefinition[]; diagnostics: string[] } {
102
+ if (!isDirectory(dir)) return { agents: [], diagnostics: [] };
103
+ const agents: AgentDefinition[] = [];
104
+ const diagnostics: string[] = [];
105
+ let entries;
106
+ try {
107
+ entries = readdirSync(dir, { withFileTypes: true });
108
+ } catch (error) {
109
+ return {
110
+ agents,
111
+ diagnostics: [`${dir}: ${error instanceof Error ? error.message : String(error)}`],
112
+ };
113
+ }
114
+
115
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
116
+ if (!entry.name.endsWith(".md") || (!entry.isFile() && !entry.isSymbolicLink())) continue;
117
+ if (excludeNames?.has(entry.name)) continue;
118
+ const filePath = join(dir, entry.name);
119
+ try {
120
+ agents.push(loadAgentFile(filePath, source));
121
+ } catch (error) {
122
+ diagnostics.push(error instanceof Error ? error.message : String(error));
123
+ }
124
+ }
125
+ return { agents, diagnostics };
126
+ }
127
+
128
+ export function discoverAgents(options: AgentDiscoveryOptions): AgentDiscoveryResult {
129
+ const projectAgentsDir =
130
+ options.scope !== "user" && options.projectTrusted
131
+ ? findNearestProjectAgentsDir(options.cwd)
132
+ : undefined;
133
+ const sources: Array<{ dir: string; source: AgentSource }> = [];
134
+ if (options.includeBundled !== false) {
135
+ sources.push({ dir: options.bundledDir, source: "bundled" });
136
+ }
137
+ if (options.scope !== "project") {
138
+ sources.push({ dir: join(options.agentDir ?? getAgentDir(), "agents"), source: "user" });
139
+ }
140
+ if (options.scope !== "user" && options.projectTrusted && projectAgentsDir) {
141
+ sources.push({ dir: projectAgentsDir, source: "project" });
142
+ }
143
+
144
+ const diagnostics: string[] = [];
145
+ if (options.scope !== "user" && !options.projectTrusted) {
146
+ diagnostics.push("Project-local agents were not loaded because the project is not trusted.");
147
+ }
148
+
149
+ const byName = new Map<string, AgentDefinition>();
150
+ for (const item of sources) {
151
+ const loaded = loadDirectory(
152
+ item.dir,
153
+ item.source,
154
+ item.source === "user" ? options.excludeUserAgentNames : undefined,
155
+ );
156
+ diagnostics.push(...loaded.diagnostics);
157
+ for (const agent of loaded.agents) byName.set(agent.name, agent);
158
+ }
159
+
160
+ return {
161
+ agents: [...byName.values()],
162
+ diagnostics,
163
+ ...(projectAgentsDir ? { projectAgentsDir } : {}),
164
+ };
165
+ }
166
+
167
+ export function formatAgentCatalog(agents: AgentDefinition[]): string {
168
+ if (agents.length === 0) return "(no agents)";
169
+ return agents.map((agent) => `${agent.name} (${agent.source}) — ${agent.description}`).join("\n");
170
+ }
171
+
172
+ export function hasBundledAgents(dir: string): boolean {
173
+ return existsSync(dir) && isDirectory(dir);
174
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { foldDescriptor } from "./descriptor.ts";
3
+ import type { SessionView } from "./providers.ts";
4
+ import type { CatalogDiagnostic, SubagentDescriptor } from "./types.ts";
5
+
6
+ export interface PersistedDescriptor {
7
+ id: string;
8
+ sessionFile: string;
9
+ descriptor: SubagentDescriptor;
10
+ }
11
+
12
+ export interface PersistedCatalog {
13
+ descriptors: PersistedDescriptor[];
14
+ diagnostics: CatalogDiagnostic[];
15
+ }
16
+
17
+ async function mapWithConcurrency<T>(
18
+ items: readonly T[],
19
+ limit: number,
20
+ visit: (item: T) => Promise<void>,
21
+ ): Promise<void> {
22
+ let next = 0;
23
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
24
+ while (true) {
25
+ const index = next++;
26
+ if (index >= items.length) return;
27
+ await visit(items[index]);
28
+ }
29
+ });
30
+ await Promise.all(workers);
31
+ }
32
+
33
+ export async function readPersistedCatalog(session: SessionView): Promise<PersistedCatalog> {
34
+ const descriptors: PersistedDescriptor[] = [];
35
+ const diagnostics: CatalogDiagnostic[] = [];
36
+ const sessions = await SessionManager.list(session.getCwd(), session.getSessionDir());
37
+
38
+ await mapWithConcurrency(sessions, 8, async (info) => {
39
+ try {
40
+ const manager = SessionManager.open(info.path, session.getSessionDir(), session.getCwd());
41
+ const folded = foldDescriptor(manager.getEntries());
42
+ if (folded.kind === "valid") {
43
+ const headerParent = manager.getHeader()?.parentSession;
44
+ if (headerParent !== folded.descriptor.parentSessionFile) {
45
+ diagnostics.push({
46
+ kind: "diagnostic",
47
+ id: manager.getSessionId(),
48
+ reason: "corrupt",
49
+ sessionFile: info.path,
50
+ ...(headerParent ? { parentSessionFile: headerParent } : {}),
51
+ message: "descriptor parentSessionFile does not match the child session header",
52
+ });
53
+ return;
54
+ }
55
+ descriptors.push({
56
+ id: manager.getSessionId(),
57
+ sessionFile: info.path,
58
+ descriptor: folded.descriptor,
59
+ });
60
+ } else if (folded.kind === "corrupt") {
61
+ const headerParent = manager.getHeader()?.parentSession;
62
+ diagnostics.push({
63
+ kind: "diagnostic",
64
+ id: manager.getSessionId(),
65
+ reason: "corrupt",
66
+ sessionFile: info.path,
67
+ ...(headerParent ? { parentSessionFile: headerParent } : {}),
68
+ message: folded.message,
69
+ });
70
+ }
71
+ } catch (error) {
72
+ diagnostics.push({
73
+ kind: "diagnostic",
74
+ id: info.id,
75
+ reason: "unavailable",
76
+ sessionFile: info.path,
77
+ ...(info.parentSessionPath ? { parentSessionFile: info.parentSessionPath } : {}),
78
+ message: error instanceof Error ? error.message : String(error),
79
+ });
80
+ }
81
+ });
82
+
83
+ descriptors.sort(
84
+ (left, right) =>
85
+ left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) || left.id.localeCompare(right.id),
86
+ );
87
+ diagnostics.sort((left, right) => left.id.localeCompare(right.id));
88
+ return { descriptors, diagnostics };
89
+ }
package/src/config.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import type { AgentScope, ReportDelivery, SubagentSettings } from "./types.ts";
5
+
6
+ export const CONFIG_FILE_NAME = "subagent.json";
7
+
8
+ export const DEFAULT_SETTINGS: Readonly<SubagentSettings> = {
9
+ agentScope: "user",
10
+ syncBundledAgents: false,
11
+ maxDepth: 3,
12
+ enableRunInBackground: true,
13
+ defaultBackground: true,
14
+ reportDelivery: "wakeup",
15
+ inheritExtensions: false,
16
+ maxOutputBytes: 50 * 1024,
17
+ };
18
+
19
+ const CONFIG_KEYS = new Set([
20
+ "$schema",
21
+ "agentScope",
22
+ "syncBundledAgents",
23
+ "maxDepth",
24
+ "enableRunInBackground",
25
+ "defaultBackground",
26
+ "reportDelivery",
27
+ "inheritExtensions",
28
+ "maxOutputBytes",
29
+ ]);
30
+
31
+ interface LoadSettingsOptions {
32
+ cwd: string;
33
+ projectTrusted: boolean;
34
+ agentDir?: string;
35
+ }
36
+
37
+ export interface LoadedSettings {
38
+ settings: SubagentSettings;
39
+ sources: string[];
40
+ }
41
+
42
+ type ConfigRecord = Record<string, unknown>;
43
+
44
+ function asRecord(value: unknown, filePath: string): ConfigRecord {
45
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
46
+ throw new Error(`${filePath}: configuration must be a JSON object`);
47
+ }
48
+ return value as ConfigRecord;
49
+ }
50
+
51
+ function readConfig(filePath: string): ConfigRecord | undefined {
52
+ if (!existsSync(filePath)) return undefined;
53
+ let parsed: unknown;
54
+ try {
55
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
56
+ } catch (error) {
57
+ throw new Error(`${filePath}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
58
+ }
59
+ const record = asRecord(parsed, filePath);
60
+ for (const key of Object.keys(record)) {
61
+ if (!CONFIG_KEYS.has(key)) throw new Error(`${filePath}: unknown setting "${key}"`);
62
+ }
63
+ return record;
64
+ }
65
+
66
+ function findNearestProjectConfig(cwd: string): string | undefined {
67
+ let current = resolve(cwd);
68
+ while (true) {
69
+ const candidate = join(current, CONFIG_DIR_NAME, CONFIG_FILE_NAME);
70
+ if (existsSync(candidate)) return candidate;
71
+ const parent = dirname(current);
72
+ if (parent === current) return undefined;
73
+ current = parent;
74
+ }
75
+ }
76
+
77
+ function parseAgentScope(value: unknown, source: string): AgentScope {
78
+ if (value === "user" || value === "project" || value === "both") return value;
79
+ throw new Error(`${source}: agentScope must be "user", "project", or "both"`);
80
+ }
81
+
82
+ function parseReportDelivery(value: unknown, source: string): ReportDelivery {
83
+ if (value === "wakeup" || value === "quiet") return value;
84
+ throw new Error(`${source}: reportDelivery must be "wakeup" or "quiet"`);
85
+ }
86
+
87
+ function parseBoolean(value: unknown, key: string, source: string): boolean {
88
+ if (typeof value === "boolean") return value;
89
+ throw new Error(`${source}: ${key} must be a boolean`);
90
+ }
91
+
92
+ function parseInteger(
93
+ value: unknown,
94
+ key: string,
95
+ source: string,
96
+ options: { minimum: number; maximum: number },
97
+ ): number {
98
+ if (
99
+ typeof value !== "number" ||
100
+ !Number.isSafeInteger(value) ||
101
+ value < options.minimum ||
102
+ value > options.maximum
103
+ ) {
104
+ throw new Error(
105
+ `${source}: ${key} must be a safe integer between ${options.minimum} and ${options.maximum}`,
106
+ );
107
+ }
108
+ return value;
109
+ }
110
+
111
+ function applyConfig(
112
+ settings: SubagentSettings,
113
+ config: ConfigRecord,
114
+ source: string,
115
+ options: { allowSyncBundledAgents: boolean },
116
+ ): SubagentSettings {
117
+ if (config.syncBundledAgents !== undefined && !options.allowSyncBundledAgents) {
118
+ throw new Error(`${source}: syncBundledAgents may be configured only in the user-level subagent.json`);
119
+ }
120
+ return {
121
+ agentScope:
122
+ config.agentScope === undefined ? settings.agentScope : parseAgentScope(config.agentScope, source),
123
+ syncBundledAgents:
124
+ config.syncBundledAgents === undefined
125
+ ? settings.syncBundledAgents
126
+ : parseBoolean(config.syncBundledAgents, "syncBundledAgents", source),
127
+ maxDepth:
128
+ config.maxDepth === undefined
129
+ ? settings.maxDepth
130
+ : parseInteger(config.maxDepth, "maxDepth", source, {
131
+ minimum: 0,
132
+ maximum: Number.MAX_SAFE_INTEGER,
133
+ }),
134
+ enableRunInBackground:
135
+ config.enableRunInBackground === undefined
136
+ ? settings.enableRunInBackground
137
+ : parseBoolean(config.enableRunInBackground, "enableRunInBackground", source),
138
+ defaultBackground:
139
+ config.defaultBackground === undefined
140
+ ? settings.defaultBackground
141
+ : parseBoolean(config.defaultBackground, "defaultBackground", source),
142
+ reportDelivery:
143
+ config.reportDelivery === undefined
144
+ ? settings.reportDelivery
145
+ : parseReportDelivery(config.reportDelivery, source),
146
+ inheritExtensions:
147
+ config.inheritExtensions === undefined
148
+ ? settings.inheritExtensions
149
+ : parseBoolean(config.inheritExtensions, "inheritExtensions", source),
150
+ maxOutputBytes:
151
+ config.maxOutputBytes === undefined
152
+ ? settings.maxOutputBytes
153
+ : parseInteger(config.maxOutputBytes, "maxOutputBytes", source, {
154
+ minimum: 1024,
155
+ maximum: 1024 * 1024,
156
+ }),
157
+ };
158
+ }
159
+
160
+ export function loadSettings(options: LoadSettingsOptions): LoadedSettings {
161
+ let settings: SubagentSettings = { ...DEFAULT_SETTINGS };
162
+ const sources: string[] = [];
163
+ const userPath = join(options.agentDir ?? getAgentDir(), CONFIG_FILE_NAME);
164
+ const userConfig = readConfig(userPath);
165
+ if (userConfig) {
166
+ settings = applyConfig(settings, userConfig, userPath, { allowSyncBundledAgents: true });
167
+ sources.push(userPath);
168
+ }
169
+
170
+ if (options.projectTrusted) {
171
+ const projectPath = findNearestProjectConfig(options.cwd);
172
+ if (projectPath) {
173
+ const projectConfig = readConfig(projectPath);
174
+ if (projectConfig) {
175
+ settings = applyConfig(settings, projectConfig, projectPath, { allowSyncBundledAgents: false });
176
+ sources.push(projectPath);
177
+ }
178
+ }
179
+ }
180
+
181
+ return { settings, sources };
182
+ }