pi-subagents-lite 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/LICENSE +21 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/agent-discovery.ts +412 -0
- package/src/agent-manager.ts +545 -0
- package/src/agent-runner.ts +435 -0
- package/src/agent-types.ts +140 -0
- package/src/context.ts +13 -0
- package/src/default-agents.ts +67 -0
- package/src/index.ts +1356 -0
- package/src/model-precedence.ts +71 -0
- package/src/model-selector.ts +271 -0
- package/src/output-file.ts +176 -0
- package/src/prompts.ts +61 -0
- package/src/result-viewer.ts +218 -0
- package/src/skill-loader.ts +104 -0
- package/src/types.ts +96 -0
- package/src/ui/agent-widget.ts +666 -0
- package/src/usage.ts +39 -0
- package/src/utils.ts +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Paramonov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# pi-subagents-lite
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-subagents-lite)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
Lightweight sub-agents for [pi](https://pi.dev). A focused fork of [pi-subagents](https://github.com/tintinweb/pi-subagents) with reduced surface area — spawn specialized agents with isolated sessions, tools, and models.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Agent tool** — spawn foreground or background sub-agents with `Agent({ prompt, description, agent, run_in_background, ... })`
|
|
11
|
+
- **Auto-delivered results** — background agents notify you on completion, no polling needed
|
|
12
|
+
- **steer_subagent** — inject messages into running agents mid-execution
|
|
13
|
+
- **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter
|
|
14
|
+
- **Turn limits** — soft limit with wrap-up warning, then hard abort
|
|
15
|
+
- **Per-model concurrency** — configurable slot limits per model
|
|
16
|
+
- **Stealth tools** — minimal prompt footprint (`.description`), no promptSnippet/guidelines
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Global
|
|
22
|
+
pi install npm:pi-subagents-lite
|
|
23
|
+
|
|
24
|
+
# Project-local
|
|
25
|
+
pi install -l npm:pi-subagents-lite
|
|
26
|
+
|
|
27
|
+
# Try without installing
|
|
28
|
+
pi -e npm:pi-subagents-lite
|
|
29
|
+
|
|
30
|
+
# From git
|
|
31
|
+
pi install git:github.com/AlexParamonov/pi-subagents-lite
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// Spawn a foreground agent
|
|
38
|
+
Agent({
|
|
39
|
+
agent: "Explore",
|
|
40
|
+
prompt: "Find all files that handle authentication",
|
|
41
|
+
description: "Find auth files",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Spawn a background agent (result auto-delivered)
|
|
45
|
+
Agent({
|
|
46
|
+
agent: "Explore",
|
|
47
|
+
prompt: "Find all files that handle authentication",
|
|
48
|
+
description: "Find auth files",
|
|
49
|
+
run_in_background: true,
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Custom Agent Types
|
|
54
|
+
|
|
55
|
+
Define agents in `.pi/agents/<name>.md` with YAML frontmatter:
|
|
56
|
+
|
|
57
|
+
```markdown
|
|
58
|
+
---
|
|
59
|
+
description: Review code for security issues
|
|
60
|
+
tools: [read, bash, grep, find]
|
|
61
|
+
extensions: false
|
|
62
|
+
skills: false
|
|
63
|
+
max_turns: 5
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
You are a security review specialist. Analyze code for vulnerabilities,
|
|
67
|
+
focusing on injection flaws, auth bypasses, and insecure defaults.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
- `/agents` — Management menu: model settings, concurrency, running agents, agent types, agent briefing
|
|
73
|
+
- `/steer` — Steer a running agent
|
|
74
|
+
|
|
75
|
+
## Requirements
|
|
76
|
+
|
|
77
|
+
- Node.js >= 18
|
|
78
|
+
- pi >= 0.74.0
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-subagents-lite",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Lightweight sub-agents for pi — spawn specialized agents with isolated sessions, tools, and models.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"subagent",
|
|
10
|
+
"agent"
|
|
11
|
+
],
|
|
12
|
+
"author": "AlexParamonov",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/AlexParamonov/pi-subagents-lite.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/AlexParamonov/pi-subagents-lite#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/AlexParamonov/pi-subagents-lite/issues"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@earendil-works/pi-ai": ">=0.74.0",
|
|
27
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
28
|
+
"@earendil-works/pi-tui": ">=0.74.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@sinclair/typebox": "^0.34.49"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src/",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./src/index.ts"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"typescript": "^6.0.3",
|
|
50
|
+
"vitest": "^4.1.7"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-discovery.ts — Agent file discovery, parsing, and config merging.
|
|
3
|
+
*
|
|
4
|
+
* Extended from subagent-lazy/agent-discovery.ts with full frontmatter support.
|
|
5
|
+
*
|
|
6
|
+
* Scans:
|
|
7
|
+
* ~/.pi/agent/agents/*.md (user agents)
|
|
8
|
+
* <project>/.pi/agents/*.md (project agents)
|
|
9
|
+
*
|
|
10
|
+
* Parses YAML frontmatter, extracts all fields, produces AgentConfig objects.
|
|
11
|
+
* Merges with per-field precedence: default < user < project.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import type { AgentConfig, ThinkingLevel } from "./types.js";
|
|
17
|
+
|
|
18
|
+
/* ------------------------------------------------------------------ */
|
|
19
|
+
/* Validation helpers */
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
|
|
22
|
+
const VALID_THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
23
|
+
"off", "minimal", "low", "medium", "high", "xhigh",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
/** Validate and narrow a raw thinking value to ThinkingLevel. */
|
|
27
|
+
function validateThinking(raw: string | undefined): ThinkingLevel | undefined {
|
|
28
|
+
if (raw === undefined) return undefined;
|
|
29
|
+
return VALID_THINKING_LEVELS.includes(raw as ThinkingLevel) ? (raw as ThinkingLevel) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* ------------------------------------------------------------------ */
|
|
33
|
+
/* Types */
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
|
|
36
|
+
/** Raw agent config as parsed from .md frontmatter. */
|
|
37
|
+
export interface AgentConfigFromMd {
|
|
38
|
+
name?: string;
|
|
39
|
+
display_name?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
tools?: string[];
|
|
42
|
+
extensions?: boolean | string[];
|
|
43
|
+
skills?: boolean | string[];
|
|
44
|
+
model?: string;
|
|
45
|
+
thinking?: ThinkingLevel;
|
|
46
|
+
max_turns?: number;
|
|
47
|
+
disallowed_tools?: string[];
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
systemPrompt: string;
|
|
50
|
+
source: "user" | "project";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------------------------ */
|
|
54
|
+
/* Simple frontmatter parser */
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Naive YAML frontmatter splitter.
|
|
59
|
+
*
|
|
60
|
+
* Handles triple-dash delimited frontmatter blocks. Does NOT parse nested
|
|
61
|
+
* YAML structures or complex types — only flat key: value pairs and
|
|
62
|
+
* YAML array syntax (lines starting with "- ").
|
|
63
|
+
*
|
|
64
|
+
* Returns { frontmatter: Record<string, unknown>, body: string }.
|
|
65
|
+
*/
|
|
66
|
+
function parseFrontmatter(
|
|
67
|
+
content: string,
|
|
68
|
+
): { frontmatter: Record<string, unknown>; body: string } {
|
|
69
|
+
if (!content) {
|
|
70
|
+
return { frontmatter: {}, body: "" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check for triple-dash delimited frontmatter
|
|
74
|
+
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
|
|
75
|
+
return { frontmatter: {}, body: content };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Find closing ---
|
|
79
|
+
const endIdx = content.indexOf("\n---\n", 4);
|
|
80
|
+
if (endIdx === -1) {
|
|
81
|
+
return { frontmatter: {}, body: content };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fmRaw = content.slice(4, endIdx);
|
|
85
|
+
const body = content.slice(endIdx + 5).trim();
|
|
86
|
+
|
|
87
|
+
const frontmatter: Record<string, unknown> = {};
|
|
88
|
+
let currentKey: string | null = null;
|
|
89
|
+
let currentValues: string[] | null = null;
|
|
90
|
+
|
|
91
|
+
for (const line of fmRaw.split("\n")) {
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
|
|
94
|
+
// Skip empty lines
|
|
95
|
+
if (!trimmed) continue;
|
|
96
|
+
|
|
97
|
+
// Array item (continuation of previous key)
|
|
98
|
+
if (trimmed.startsWith("- ")) {
|
|
99
|
+
if (currentKey) {
|
|
100
|
+
if (!currentValues) currentValues = [];
|
|
101
|
+
currentValues.push(trimmed.slice(2).trim());
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Flush previous array before processing a new key
|
|
107
|
+
if (currentKey && currentValues) {
|
|
108
|
+
frontmatter[currentKey] = currentValues;
|
|
109
|
+
currentValues = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const colonIdx = trimmed.indexOf(":");
|
|
113
|
+
if (colonIdx === -1) {
|
|
114
|
+
currentKey = trimmed;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
currentKey = trimmed.slice(0, colonIdx).trim();
|
|
119
|
+
const rawValue = trimmed.slice(colonIdx + 1).trim();
|
|
120
|
+
|
|
121
|
+
if (!rawValue) {
|
|
122
|
+
// Might be followed by array items
|
|
123
|
+
currentValues = [];
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Strip surrounding quotes if present (YAML convention)
|
|
128
|
+
frontmatter[currentKey] = rawValue.replace(/^['"]|['"]$/g, '');
|
|
129
|
+
currentValues = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Flush trailing array items
|
|
133
|
+
if (currentKey && currentValues) {
|
|
134
|
+
frontmatter[currentKey] = currentValues;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { frontmatter, body };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ------------------------------------------------------------------ */
|
|
141
|
+
/* parseExtensions */
|
|
142
|
+
/* ------------------------------------------------------------------ */
|
|
143
|
+
|
|
144
|
+
/** Split comma-separated string, trim whitespace, and remove empty entries. */
|
|
145
|
+
function splitCommaList(value: string): string[] {
|
|
146
|
+
return value
|
|
147
|
+
.split(",")
|
|
148
|
+
.map((s) => s.trim())
|
|
149
|
+
.filter((s) => s.length > 0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse the extensions/skills field from frontmatter.
|
|
154
|
+
*
|
|
155
|
+
* - false / "false" / "none" → false
|
|
156
|
+
* - true / "true" / "all" → true
|
|
157
|
+
* - Comma-separated string → string[]
|
|
158
|
+
* - undefined → undefined
|
|
159
|
+
*/
|
|
160
|
+
export function parseExtensions(
|
|
161
|
+
raw: unknown,
|
|
162
|
+
): boolean | string[] | undefined {
|
|
163
|
+
if (raw === false || raw === "false" || raw === "none") {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (raw === true || raw === "true" || raw === "all") {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
170
|
+
return splitCommaList(raw);
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(raw)) {
|
|
173
|
+
return raw.map(String);
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ------------------------------------------------------------------ */
|
|
179
|
+
/* Frontmatter value helpers */
|
|
180
|
+
/* ------------------------------------------------------------------ */
|
|
181
|
+
|
|
182
|
+
/** Extract a non-empty string value from frontmatter. */
|
|
183
|
+
export function parseString(
|
|
184
|
+
frontmatter: Record<string, unknown>,
|
|
185
|
+
key: string,
|
|
186
|
+
): string | undefined {
|
|
187
|
+
const v = frontmatter[key];
|
|
188
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Extract a string array from frontmatter (array or comma-separated string). */
|
|
192
|
+
export function parseStringArray(
|
|
193
|
+
frontmatter: Record<string, unknown>,
|
|
194
|
+
key: string,
|
|
195
|
+
): string[] | undefined {
|
|
196
|
+
const v = frontmatter[key];
|
|
197
|
+
if (Array.isArray(v)) {
|
|
198
|
+
return v.map(String);
|
|
199
|
+
}
|
|
200
|
+
if (typeof v === "string" && v.length > 0) {
|
|
201
|
+
return splitCommaList(v);
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build an object containing only the entries whose value is not undefined.
|
|
208
|
+
* Used to transform AgentConfigFromMd fields into a Partial<AgentConfig>
|
|
209
|
+
* without 14 repetitive `if (x !== undefined)` blocks.
|
|
210
|
+
*/
|
|
211
|
+
function compactDefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
|
212
|
+
return Object.fromEntries(
|
|
213
|
+
Object.entries(obj).filter(([_, v]) => v !== undefined),
|
|
214
|
+
) as Partial<T>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ------------------------------------------------------------------ */
|
|
218
|
+
/* parseAgentFile */
|
|
219
|
+
/* ------------------------------------------------------------------ */
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse a single agent .md file into AgentConfigFromMd.
|
|
223
|
+
*
|
|
224
|
+
* @param content - Raw file content
|
|
225
|
+
* @param filename - Filename (for context, currently unused)
|
|
226
|
+
* @param source - Source designation ("user" or "project")
|
|
227
|
+
* @returns Parsed agent config with frontmatter and body
|
|
228
|
+
*/
|
|
229
|
+
export function parseAgentFile(
|
|
230
|
+
content: string,
|
|
231
|
+
_filename: string,
|
|
232
|
+
source: "user" | "project",
|
|
233
|
+
): AgentConfigFromMd {
|
|
234
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
235
|
+
|
|
236
|
+
const extensions = parseExtensions(frontmatter.extensions);
|
|
237
|
+
const skills = parseExtensions(frontmatter.skills);
|
|
238
|
+
|
|
239
|
+
// enabled field
|
|
240
|
+
const enabledRaw = frontmatter.enabled;
|
|
241
|
+
let enabled: boolean | undefined;
|
|
242
|
+
if (enabledRaw === "false" || enabledRaw === false) {
|
|
243
|
+
enabled = false;
|
|
244
|
+
} else if (enabledRaw === "true" || enabledRaw === true) {
|
|
245
|
+
enabled = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// max_turns field
|
|
249
|
+
const maxTurnsRaw = frontmatter.max_turns;
|
|
250
|
+
let maxTurns: number | undefined;
|
|
251
|
+
if (typeof maxTurnsRaw === "number") {
|
|
252
|
+
maxTurns = maxTurnsRaw;
|
|
253
|
+
} else if (typeof maxTurnsRaw === "string" && maxTurnsRaw.length > 0) {
|
|
254
|
+
const parsed = Number(maxTurnsRaw);
|
|
255
|
+
if (!Number.isNaN(parsed)) {
|
|
256
|
+
maxTurns = parsed;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
name: parseString(frontmatter, "name"),
|
|
262
|
+
display_name: parseString(frontmatter, "display_name"),
|
|
263
|
+
description: parseString(frontmatter, "description"),
|
|
264
|
+
tools: parseStringArray(frontmatter, "tools"),
|
|
265
|
+
extensions,
|
|
266
|
+
skills,
|
|
267
|
+
model: parseString(frontmatter, "model"),
|
|
268
|
+
thinking: validateThinking(parseString(frontmatter, "thinking")),
|
|
269
|
+
max_turns: maxTurns,
|
|
270
|
+
disallowed_tools: parseStringArray(frontmatter, "disallowed_tools"),
|
|
271
|
+
enabled,
|
|
272
|
+
systemPrompt: body,
|
|
273
|
+
source: source,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ------------------------------------------------------------------ */
|
|
278
|
+
/* scanAgentFilesInDir */
|
|
279
|
+
/* ------------------------------------------------------------------ */
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Scan a directory for .md files and parse them into AgentConfigFromMd[].
|
|
283
|
+
* Returns empty array if directory doesn't exist.
|
|
284
|
+
*/
|
|
285
|
+
export async function scanAgentFilesInDir(
|
|
286
|
+
dirPath: string,
|
|
287
|
+
source: "user" | "project" = "user",
|
|
288
|
+
): Promise<AgentConfigFromMd[]> {
|
|
289
|
+
try {
|
|
290
|
+
await fs.promises.access(dirPath);
|
|
291
|
+
} catch {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
296
|
+
const mdFiles = entries.filter(
|
|
297
|
+
(e) => e.isFile() && e.name.endsWith(".md"),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const agents: AgentConfigFromMd[] = [];
|
|
301
|
+
for (const entry of mdFiles) {
|
|
302
|
+
const filePath = path.join(dirPath, entry.name);
|
|
303
|
+
try {
|
|
304
|
+
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
305
|
+
const info = parseAgentFile(content, entry.name, source);
|
|
306
|
+
if (info.name) {
|
|
307
|
+
agents.push(info);
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Skip files that can't be read
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return agents;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ------------------------------------------------------------------ */
|
|
317
|
+
/* mergeAgents */
|
|
318
|
+
/* ------------------------------------------------------------------ */
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Merge default agents with user and project overrides.
|
|
322
|
+
*
|
|
323
|
+
* Per-field merge precedence (highest to lowest):
|
|
324
|
+
* 1. project agents
|
|
325
|
+
* 2. user agents
|
|
326
|
+
* 3. default agents
|
|
327
|
+
*
|
|
328
|
+
* For each field, if a higher-precedence layer sets the field (not undefined),
|
|
329
|
+
* it wins. Otherwise, the lower layer's value is preserved.
|
|
330
|
+
*
|
|
331
|
+
* @param defaults - Map of default agent configs
|
|
332
|
+
* @param userAgents - User-defined agent configs
|
|
333
|
+
* @param projectAgents - Project-specific agent configs
|
|
334
|
+
* @returns Merged Map<string, AgentConfig> keyed by agent name
|
|
335
|
+
*/
|
|
336
|
+
export function mergeAgents(
|
|
337
|
+
defaults: Map<string, AgentConfig>,
|
|
338
|
+
userAgents: AgentConfigFromMd[],
|
|
339
|
+
projectAgents: AgentConfigFromMd[],
|
|
340
|
+
): Map<string, AgentConfig> {
|
|
341
|
+
const result = new Map<string, AgentConfig>();
|
|
342
|
+
|
|
343
|
+
// Start with defaults
|
|
344
|
+
for (const [name, config] of defaults) {
|
|
345
|
+
result.set(name, { ...config });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Apply user overrides (middle priority), then project (highest priority)
|
|
349
|
+
mergeAgentOverrides(result, userAgents);
|
|
350
|
+
mergeAgentOverrides(result, projectAgents);
|
|
351
|
+
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Apply a list of agent configs onto the result map.
|
|
357
|
+
* Existing agents are merged per-field; new agents are built from scratch.
|
|
358
|
+
*/
|
|
359
|
+
function mergeAgentOverrides(
|
|
360
|
+
result: Map<string, AgentConfig>,
|
|
361
|
+
agents: AgentConfigFromMd[],
|
|
362
|
+
): void {
|
|
363
|
+
for (const md of agents) {
|
|
364
|
+
if (!md.name) continue;
|
|
365
|
+
const existing = result.get(md.name);
|
|
366
|
+
if (existing) {
|
|
367
|
+
result.set(md.name, { ...existing, ...fromMd(md) });
|
|
368
|
+
} else {
|
|
369
|
+
result.set(md.name, { ...BASE_DEFAULTS, ...fromMd(md) });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Translate AgentConfigFromMd fields to a Partial<AgentConfig> containing
|
|
376
|
+
* only fields that are explicitly set in the frontmatter (not undefined).
|
|
377
|
+
*
|
|
378
|
+
* When merging into an existing AgentConfig, spread this result after the
|
|
379
|
+
* existing config so frontmatter fields override defaults while undefined
|
|
380
|
+
* fields fall through to the existing values.
|
|
381
|
+
*/
|
|
382
|
+
function fromMd(md: AgentConfigFromMd): Partial<AgentConfig> {
|
|
383
|
+
const obj: Record<string, unknown> = {
|
|
384
|
+
name: md.name,
|
|
385
|
+
displayName: md.display_name,
|
|
386
|
+
description: md.description,
|
|
387
|
+
builtinToolNames: md.tools,
|
|
388
|
+
extensions: md.extensions,
|
|
389
|
+
skills: md.skills,
|
|
390
|
+
model: md.model,
|
|
391
|
+
thinking: md.thinking,
|
|
392
|
+
maxTurns: md.max_turns,
|
|
393
|
+
disallowedTools: md.disallowed_tools,
|
|
394
|
+
enabled: md.enabled,
|
|
395
|
+
systemPrompt: md.systemPrompt,
|
|
396
|
+
source: md.source === "project" ? "project" : "global",
|
|
397
|
+
};
|
|
398
|
+
return compactDefined(obj) as Partial<AgentConfig>;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Defaults used when creating a new AgentConfig from a .md file that has
|
|
403
|
+
* no existing default to merge into. Satisfies all required AgentConfig
|
|
404
|
+
* fields.
|
|
405
|
+
*/
|
|
406
|
+
const BASE_DEFAULTS: AgentConfig = {
|
|
407
|
+
name: "unknown",
|
|
408
|
+
description: "",
|
|
409
|
+
extensions: true,
|
|
410
|
+
skills: true,
|
|
411
|
+
systemPrompt: "",
|
|
412
|
+
};
|