@herbertgao/pi-subagents 0.19.0 → 0.22.0-local.2

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.
@@ -1,11 +1,17 @@
1
1
  /**
2
- * custom-agents.ts — Load user-defined agents from project (.pi/agents/, plus the shared .agents/agents/ workspace) and global ($PI_CODING_AGENT_DIR/agents/, default ~/.pi/agent/agents/) locations.
2
+ * custom-agents.ts — Load user-defined agents from project (.pi/agents/, plus the shared .agents/agents/ workspace) and global ($PI_CODING_AGENT_DIR/agents/, default ~/.pi/agent/agents/) locations, plus any installed pi package that declares them (see package-resources.ts).
3
3
  */
4
4
 
5
- import { existsSync, readdirSync, readFileSync } from "node:fs"
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
6
6
  import { basename, join } from "node:path"
7
7
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"
8
8
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js"
9
+ import {
10
+ isPackageResourceExcluded,
11
+ MAX_PACKAGE_RESOURCE_BYTES,
12
+ packageAgentDirs,
13
+ packageAgentFiles,
14
+ } from "./package-resources.js"
9
15
  import type {
10
16
  AgentConfig,
11
17
  IsolationMode,
@@ -28,18 +34,45 @@ import type {
28
34
  */
29
35
  const RESERVED_IN_TYPE = ":"
30
36
 
37
+ /** Where an agent file was found. Widens `AgentConfig.source` for the loader. */
38
+ type AgentSource = "project" | "global" | "package"
39
+
40
+ /** Answers whether a `!` entry in some package manifest excludes this path. */
41
+ type Excluder = (path: string) => boolean
42
+
43
+ /**
44
+ * Overrides for the package tier. The gate and project-trust state normally come
45
+ * from the session values in package-resources.ts, so these exist for tests and
46
+ * for a caller that has to pin a scan it already performed.
47
+ */
48
+ export interface LoadCustomAgentsOptions {
49
+ /** Explicit package directories, bypassing discovery. */
50
+ packageDirs?: string[]
51
+ /** Explicit package files, bypassing discovery. */
52
+ packageFiles?: string[]
53
+ /** Explicit `!` exclusion predicate, bypassing discovery. */
54
+ packageExcluded?: Excluder
55
+ }
56
+
31
57
  /**
32
58
  * Scan for custom agent .md files from multiple locations.
33
59
  * Discovery hierarchy (higher priority wins):
34
60
  * 1. Project: <cwd>/.pi/agents/*.md (authoritative — also where /agents writes)
35
61
  * 2. Workspace: <cwd>/.agents/agents/*.md (shared cross-tool .agents workspace, read-only)
36
62
  * 3. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
63
+ * 4. Package: paths declared by an installed pi package (read-only)
37
64
  *
38
65
  * Project-level agents override global ones with the same name. On a name clash
39
66
  * between the two project locations, .pi/agents wins — .pi stays the project
40
67
  * authority; .agents/agents is an additional read location.
41
68
  * Any name is allowed — names matching defaults (e.g. "Explore") override them.
42
69
  *
70
+ * Package agents load *first*, so everything the user or project wrote outranks
71
+ * them. That is what makes them safe to adopt: a package can offer a `reviewer`,
72
+ * and a `.pi/agents/reviewer.md` silently takes it back. It mirrors pi's own
73
+ * resource precedence, where package-provided skills rank below every user and
74
+ * project one, and Claude Code's, where a plugin agent is the lowest tier.
75
+ *
43
76
  * An agent's type comes from its frontmatter `name:`, falling back to the
44
77
  * filename — Claude Code's rule, where "the filename doesn't have to match".
45
78
  * Because the type is now declared rather than derived from a unique path, two
@@ -49,13 +82,25 @@ const RESERVED_IN_TYPE = ":"
49
82
  export function loadCustomAgents(
50
83
  cwd: string,
51
84
  strict = false,
85
+ opts: LoadCustomAgentsOptions = {},
52
86
  ): Map<string, AgentConfig> {
53
87
  const globalDir = join(getAgentDir(), "agents")
54
88
  const workspaceProjectDir = join(cwd, ".agents", "agents")
55
89
  const projectDir = join(cwd, ".pi", "agents")
56
90
 
91
+ const pkgDirs = opts.packageDirs ?? packageAgentDirs(cwd)
92
+ const pkgFiles = opts.packageFiles ?? packageAgentFiles(cwd)
93
+ // `!./agents/wip.md` alongside `./agents` excludes one file out of a directory
94
+ // that is only enumerated here, so the check has to run per file.
95
+ const excluded: Excluder =
96
+ opts.packageExcluded ??
97
+ ((path) => isPackageResourceExcluded(path, cwd, "agents"))
98
+
57
99
  const agents = new Map<string, AgentConfig>()
58
- loadFromDir(globalDir, agents, "global", strict) // lowest priority
100
+ for (const dir of pkgDirs)
101
+ loadFromDir(dir, agents, "package", strict, excluded) // lowest priority
102
+ for (const file of pkgFiles) loadOneFile(file, agents, "package", strict)
103
+ loadFromDir(globalDir, agents, "global", strict)
59
104
  loadFromDir(workspaceProjectDir, agents, "project", strict) // shared workspace
60
105
  loadFromDir(projectDir, agents, "project", strict) // highest priority (overwrites)
61
106
 
@@ -64,12 +109,13 @@ export function loadCustomAgents(
64
109
  return agents
65
110
  }
66
111
 
67
- /** Load agent configs from a directory into the map. */
112
+ /** Load agent configs from a directory into the map, skipping any `excluded` path. */
68
113
  function loadFromDir(
69
114
  dir: string,
70
115
  agents: Map<string, AgentConfig>,
71
- source: "project" | "global",
116
+ source: AgentSource,
72
117
  strict: boolean,
118
+ excluded?: Excluder,
73
119
  ): void {
74
120
  if (!existsSync(dir)) return
75
121
 
@@ -81,82 +127,128 @@ function loadFromDir(
81
127
  }
82
128
 
83
129
  for (const file of files) {
84
- const filenameType = basename(file, ".md")
85
-
86
130
  const path = join(dir, file)
131
+ if (excluded?.(path)) continue
132
+ loadOneFile(path, agents, source, strict)
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Load one agent file into the map.
138
+ *
139
+ * Split out of `loadFromDir` because a package manifest may name an individual
140
+ * `.md` rather than a directory, and because the size guard below has to apply
141
+ * to both paths.
142
+ */
143
+ function loadOneFile(
144
+ path: string,
145
+ agents: Map<string, AgentConfig>,
146
+ source: AgentSource,
147
+ strict: boolean,
148
+ ): void {
149
+ const filenameType = basename(path, ".md")
87
150
 
88
- const parsed = readAgentFile(path, strict)
89
- if (!parsed) {
90
- warnSkippedOverride(filenameType, agents)
91
- continue
92
- }
93
- const { frontmatter: fm, body } = parsed
94
-
95
- // Claude Code's rule: `name:` IS the agent type, and the filename need not
96
- // match. Absent, the filename stands in — Claude Code requires the field,
97
- // but most files here predate it and must keep loading.
98
- const declared = str(fm.name)?.trim()
99
- if (declared?.includes(RESERVED_IN_TYPE)) {
100
- // Refusing beats silently substituting: the file would otherwise load
101
- // under its filename, so `Agent({subagent_type})` would succeed against
102
- // an agent whose declared identity nothing honoured.
103
- warnIfNew(
104
- `Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for ` +
105
- "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
106
- )
107
- // No `warnSkippedOverride`: this file would have registered under its
108
- // *declared* name, which nothing else can hold (a colon keeps it out of
109
- // the registry), so it shadowed nothing. Passing the filename instead
110
- // would report a substitution of an unrelated agent that never happened.
111
- continue
112
- }
113
- // `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise
114
- // register the agent under the empty type — unspawnable, and it takes the
115
- // filename-derived one down with it.
116
- const name = declared || filenameType
117
-
118
- const { builtinToolNames, extSelectors } = parseToolsField(fm.tools)
119
-
120
- agents.set(name, {
121
- name,
122
- // Only `display_name` now: `name` is the type, and `getConfig` already
123
- // falls back to the type when no label is set — so a Claude Code file
124
- // with `name: code-reviewer` still badges as "code-reviewer".
125
- displayName: str(fm.display_name),
126
- color: str(fm.color),
127
- description: str(fm.description) ?? name,
128
- builtinToolNames,
129
- extSelectors,
130
- disallowedTools: csvListOptional(fm.disallowed_tools),
131
- extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
132
- excludeExtensions: csvListOptional(fm.exclude_extensions),
133
- skills: inheritField(fm.skills ?? fm.inherit_skills),
134
- model: str(fm.model),
135
- thinking: str(fm.thinking) as ThinkingLevel | undefined,
136
- maxTurns: nonNegativeInt(fm.max_turns),
137
- persistSession:
138
- fm.persist_session != null ? fm.persist_session === true : undefined,
139
- outputTranscript:
140
- fm.output_transcript != null
141
- ? fm.output_transcript !== false
142
- : undefined,
143
- sessionDir: str(fm.session_dir),
144
- allowedSubagents: parseAllowedSubagents(fm.allowed_subagents),
145
- systemPrompt: body.trim(),
146
- promptMode: fm.prompt_mode === "append" ? "append" : "replace",
147
- inheritContext:
148
- fm.inherit_context != null ? fm.inherit_context === true : undefined,
149
- runInBackground:
150
- fm.run_in_background != null
151
- ? fm.run_in_background === true
152
- : undefined,
153
- isolated: fm.isolated != null ? fm.isolated === true : undefined,
154
- memory: parseMemory(fm.memory),
155
- isolation: parseIsolation(fm.isolation),
156
- enabled: fm.enabled !== false, // default true; explicitly false disables
157
- source,
158
- sourcePath: path,
159
- })
151
+ // Package files are third-party content read on every `Agent` call, so a
152
+ // pathological one is skipped rather than pulled into the session. Claude
153
+ // Code applies the same 1 MB ceiling to plugin-provided agents. Local files
154
+ // are the user's own and stay unbounded, as they always have been.
155
+ if (source === "package" && !withinSizeLimit(path)) {
156
+ warnIfNew(
157
+ `Skipping package agent ${path}: not a regular file, or larger than ${MAX_PACKAGE_RESOURCE_BYTES} bytes.`,
158
+ )
159
+ return
160
+ }
161
+
162
+ // `strict` is deliberately not passed for a package file. Failing activation
163
+ // over a broken agent file is right for a checked-in `.pi/agents/` — the point
164
+ // is that a repo's own file cannot silently fall through to a same-named one
165
+ // elsewhere. A file inside a third-party package is not the user's to fix, and
166
+ // one bad `.md` in any installed package would otherwise stop pi from
167
+ // starting at all. Warn and skip, as for a non-strict local file.
168
+ const parsed = readAgentFile(path, strict && source !== "package")
169
+ if (!parsed) {
170
+ warnSkippedOverride(filenameType, agents)
171
+ return
172
+ }
173
+ const { frontmatter: fm, body } = parsed
174
+
175
+ // Claude Code's rule: `name:` IS the agent type, and the filename need not
176
+ // match. Absent, the filename stands in — Claude Code requires the field,
177
+ // but most files here predate it and must keep loading.
178
+ const declared = str(fm.name)?.trim()
179
+ if (declared?.includes(RESERVED_IN_TYPE)) {
180
+ // Refusing beats silently substituting: the file would otherwise load
181
+ // under its filename, so `Agent({subagent_type})` would succeed against
182
+ // an agent whose declared identity nothing honoured.
183
+ warnIfNew(
184
+ `Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for ` +
185
+ "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
186
+ )
187
+ // No `warnSkippedOverride`: this file would have registered under its
188
+ // *declared* name, which nothing else can hold (a colon keeps it out of
189
+ // the registry), so it shadowed nothing. Passing the filename instead
190
+ // would report a substitution of an unrelated agent that never happened.
191
+ return
192
+ }
193
+ // `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise
194
+ // register the agent under the empty type — unspawnable, and it takes the
195
+ // filename-derived one down with it.
196
+ const name = declared || filenameType
197
+
198
+ const { builtinToolNames, extSelectors } = parseToolsField(fm.tools)
199
+
200
+ agents.set(name, {
201
+ name,
202
+ // Only `display_name` now: `name` is the type, and `getConfig` already
203
+ // falls back to the type when no label is set — so a Claude Code file
204
+ // with `name: code-reviewer` still badges as "code-reviewer".
205
+ displayName: str(fm.display_name),
206
+ color: str(fm.color),
207
+ description: str(fm.description) ?? name,
208
+ builtinToolNames,
209
+ extSelectors,
210
+ disallowedTools: csvListOptional(fm.disallowed_tools),
211
+ extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
212
+ excludeExtensions: csvListOptional(fm.exclude_extensions),
213
+ skills: inheritField(fm.skills ?? fm.inherit_skills),
214
+ model: str(fm.model),
215
+ thinking: str(fm.thinking) as ThinkingLevel | undefined,
216
+ maxTurns: nonNegativeInt(fm.max_turns),
217
+ persistSession:
218
+ fm.persist_session != null ? fm.persist_session === true : undefined,
219
+ outputTranscript:
220
+ fm.output_transcript != null ? fm.output_transcript !== false : undefined,
221
+ sessionDir: str(fm.session_dir),
222
+ allowedSubagents: parseAllowedSubagents(fm.allowed_subagents),
223
+ systemPrompt: body.trim(),
224
+ promptMode: fm.prompt_mode === "append" ? "append" : "replace",
225
+ inheritContext:
226
+ fm.inherit_context != null ? fm.inherit_context === true : undefined,
227
+ runInBackground:
228
+ fm.run_in_background != null ? fm.run_in_background === true : undefined,
229
+ isolated: fm.isolated != null ? fm.isolated === true : undefined,
230
+ memory: parseMemory(fm.memory),
231
+ isolation: parseIsolation(fm.isolation),
232
+ enabled: fm.enabled !== false, // default true; explicitly false disables
233
+ source,
234
+ sourcePath: path,
235
+ })
236
+ }
237
+
238
+ /**
239
+ * True when `path` is a regular file within the package-resource size ceiling.
240
+ *
241
+ * Only applied to package-provided files: they arrive from a third party and are
242
+ * re-read on every `Agent` call, so a 500 MB `.md` — or a fifo that never ends —
243
+ * must not be reachable from a spawn. A stat failure counts as "not within",
244
+ * because the alternative is reading something we could not describe.
245
+ */
246
+ function withinSizeLimit(path: string): boolean {
247
+ try {
248
+ const stat = statSync(path)
249
+ return stat.isFile() && stat.size <= MAX_PACKAGE_RESOURCE_BYTES
250
+ } catch {
251
+ return false
160
252
  }
161
253
  }
162
254