@herbertgao/pi-subagents 0.19.0 → 0.22.0-local.1
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/CHANGELOG.md +8 -0
- package/docs/packages.md +211 -0
- package/docs/rpc.md +61 -63
- package/docs/workflows.md +24 -16
- package/package.json +6 -5
- package/src/agent-file-toggle.ts +51 -14
- package/src/agent-manager.ts +123 -55
- package/src/agent-runner.ts +144 -13
- package/src/agent-source-badge.ts +78 -0
- package/src/completion-outbox.ts +122 -0
- package/src/custom-agents.ts +171 -79
- package/src/index.ts +597 -296
- package/src/package-resources.ts +724 -0
- package/src/schedule.ts +1 -1
- package/src/settings.ts +66 -0
- package/src/types.ts +26 -4
- package/src/ui/agent-widget.ts +2 -0
- package/src/ui/conversation-viewer.ts +354 -32
- package/src/ui/fleet-list.ts +33 -15
- package/src/workflow/host.ts +6 -0
- package/src/workflow/saved.ts +48 -2
package/src/custom-agents.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
name,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|