@herbertgao/pi-subagents 0.17.0 → 0.18.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +12 -9
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +14 -1
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
@@ -13,12 +13,20 @@ import type {
13
13
  ThinkingLevel,
14
14
  } from "./types.js"
15
15
 
16
- interface WarningState {
17
- previous: Set<string>
18
- current: Set<string>
19
- }
20
-
21
- const warningHistoryByCwd = new Map<string, Set<string>>()
16
+ /**
17
+ * The one thing a declared `name:` may not contain, matching Claude Code
18
+ * exactly: it reserves `:` for plugin-scoped identifiers (`my-plugin:reviewer`)
19
+ * and refuses to load a file whose name uses one.
20
+ *
21
+ * Nothing else is rejected. Claude Code's docs describe names as "lowercase
22
+ * letters and hyphens", but that is guidance — the only stated load failure is
23
+ * the colon, so `name: Code Reviewer` must work here too. (The stricter
24
+ * letters/digits/underscore/hyphen regex in Claude Code applies to the Agent
25
+ * tool's spawn-time `name` parameter, which is a different field.) Mixed case
26
+ * has to be allowed regardless: the built-in types `Explore` and `Plan` use it,
27
+ * and a file must be able to override one.
28
+ */
29
+ const RESERVED_IN_TYPE = ":"
22
30
 
23
31
  /**
24
32
  * Scan for custom agent .md files from multiple locations.
@@ -31,6 +39,12 @@ const warningHistoryByCwd = new Map<string, Set<string>>()
31
39
  * between the two project locations, .pi/agents wins — .pi stays the project
32
40
  * authority; .agents/agents is an additional read location.
33
41
  * Any name is allowed — names matching defaults (e.g. "Explore") override them.
42
+ *
43
+ * An agent's type comes from its frontmatter `name:`, falling back to the
44
+ * filename — Claude Code's rule, where "the filename doesn't have to match".
45
+ * Because the type is now declared rather than derived from a unique path, two
46
+ * files can claim the same one; the later load wins, as it always has for a
47
+ * filename clash, and `warnSkippedOverride` reports the substitution.
34
48
  */
35
49
  export function loadCustomAgents(
36
50
  cwd: string,
@@ -41,27 +55,12 @@ export function loadCustomAgents(
41
55
  const projectDir = join(cwd, ".pi", "agents")
42
56
 
43
57
  const agents = new Map<string, AgentConfig>()
44
- const skippedOverrides = new Set<string>()
45
- const warnings: WarningState = {
46
- previous: warningHistoryByCwd.get(cwd) ?? new Set(),
47
- current: new Set(),
48
- }
58
+ loadFromDir(globalDir, agents, "global", strict) // lowest priority
59
+ loadFromDir(workspaceProjectDir, agents, "project", strict) // shared workspace
60
+ loadFromDir(projectDir, agents, "project", strict) // highest priority (overwrites)
49
61
 
50
- loadFromDir(globalDir, agents, "global", strict, warnings, skippedOverrides) // lowest priority
51
- loadFromDir(
52
- workspaceProjectDir,
53
- agents,
54
- "project",
55
- strict,
56
- warnings,
57
- skippedOverrides,
58
- ) // shared workspace
59
- loadFromDir(projectDir, agents, "project", strict, warnings, skippedOverrides) // highest priority (overwrites)
60
-
61
- for (const name of skippedOverrides) {
62
- warnSkippedOverride(name, agents, warnings)
63
- }
64
- warningHistoryByCwd.set(cwd, warnings.current)
62
+ warnedLastLoad = warnedThisLoad
63
+ warnedThisLoad = new Set()
65
64
  return agents
66
65
  }
67
66
 
@@ -71,8 +70,6 @@ function loadFromDir(
71
70
  agents: Map<string, AgentConfig>,
72
71
  source: "project" | "global",
73
72
  strict: boolean,
74
- warnings: WarningState,
75
- skippedOverrides: Set<string>,
76
73
  ): void {
77
74
  if (!existsSync(dir)) return
78
75
 
@@ -84,22 +81,48 @@ function loadFromDir(
84
81
  }
85
82
 
86
83
  for (const file of files) {
87
- const name = basename(file, ".md")
84
+ const filenameType = basename(file, ".md")
85
+
88
86
  const path = join(dir, file)
89
87
 
90
- const parsed = readAgentFile(path, strict, warnings)
88
+ const parsed = readAgentFile(path, strict)
91
89
  if (!parsed) {
92
- skippedOverrides.add(name)
90
+ warnSkippedOverride(filenameType, agents)
93
91
  continue
94
92
  }
95
- skippedOverrides.delete(name)
96
93
  const { frontmatter: fm, body } = parsed
97
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
+
98
118
  const { builtinToolNames, extSelectors } = parseToolsField(fm.tools)
99
119
 
100
120
  agents.set(name, {
101
121
  name,
102
- displayName: str(fm.display_name) ?? str(fm.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),
103
126
  color: str(fm.color),
104
127
  description: str(fm.description) ?? name,
105
128
  builtinToolNames,
@@ -139,7 +162,30 @@ function loadFromDir(
139
162
 
140
163
  /**
141
164
  * Read and parse one agent file, or warn and return undefined for the caller to
142
- * skip. Under strict mode the same failure aborts startup while naming the file.
165
+ * skip. One bad file must not take the whole extension down with it an
166
+ * unparseable `.md` used to abort activation, so pi exited before the TUI.
167
+ *
168
+ * The path is as much of the fix as the recovery: a bare YAML error ("line 2,
169
+ * column 14") is unactionable when agents come from three directories at once,
170
+ * and the only other symptom is `Unknown agent type`, which reads like a typo.
171
+ *
172
+ * Under `strict` the same failure rethrows, still naming the path, so callers
173
+ * that opted into failing closed stop rather than run a substituted agent.
174
+ */
175
+ /**
176
+ * Parse an agent file's frontmatter, tolerating a leading UTF-8 BOM.
177
+ *
178
+ * Editors across the Windows/CJK world write UTF-8 with a BOM by default, and
179
+ * pi's parser did not look past one before 0.84.3: the fence never matched, so
180
+ * the frontmatter came back empty and the *whole file* — YAML and all — became
181
+ * the body. An agent authored that way silently lost every field. `tools: none`
182
+ * going missing is the sharp edge: the agent registers with the default
183
+ * toolset rather than none, which is a wider grant than its author wrote.
184
+ *
185
+ * Stripped here rather than detected per pi version, because this is the only
186
+ * place agent files are read and the BOM is a file-encoding artifact, not
187
+ * content — normalising it at the boundary keeps one behaviour across the whole
188
+ * supported peer range instead of forking on what happens to be installed.
143
189
  */
144
190
  export function parseAgentFrontmatter<T extends Record<string, unknown>>(
145
191
  content: string,
@@ -152,7 +198,6 @@ export function parseAgentFrontmatter<T extends Record<string, unknown>>(
152
198
  function readAgentFile(
153
199
  path: string,
154
200
  strict: boolean,
155
- warnings: WarningState,
156
201
  ): { frontmatter: Record<string, unknown>; body: string } | undefined {
157
202
  try {
158
203
  return parseAgentFrontmatter<Record<string, unknown>>(
@@ -161,30 +206,39 @@ function readAgentFile(
161
206
  } catch (err) {
162
207
  const reason = err instanceof Error ? err.message : String(err)
163
208
  if (strict) throw new Error(`${path}: ${reason}`)
164
- warnIfNew(`Skipping agent file ${path}: ${reason}`, warnings)
209
+ warnIfNew(`Skipping agent file ${path}: ${reason}`)
165
210
  return undefined
166
211
  }
167
212
  }
168
213
 
169
- /** Warn when a broken higher-priority file exposes an earlier definition. */
214
+ /**
215
+ * A skipped file that was overriding an already-loaded agent leaves the name
216
+ * pointing at a *different* file — its own prompt, model and tools. Nothing
217
+ * downstream can flag that: unlike an unknown type, the `Agent` call succeeds.
218
+ */
170
219
  function warnSkippedOverride(
171
220
  name: string,
172
221
  agents: Map<string, AgentConfig>,
173
- warnings: WarningState,
174
222
  ): void {
175
223
  const surviving = agents.get(name)
224
+ // Nothing shadowed, or what it shadowed is disabled: dispatch refuses the type
225
+ // either way (see resolveEnabledTypeIn), so there is no substitution to report.
176
226
  if (!surviving?.sourcePath || surviving.enabled === false) return
177
- warnIfNew(
178
- `Agent "${name}" now loads from ${surviving.sourcePath} instead`,
179
- warnings,
180
- )
227
+ warnIfNew(`Agent "${name}" now loads from ${surviving.sourcePath} instead`)
181
228
  }
182
229
 
183
- /** Warn once while an error is unchanged, but report it again after recovery. */
184
- function warnIfNew(message: string, warnings: WarningState): void {
185
- if (warnings.current.has(message)) return
186
- warnings.current.add(message)
187
- if (warnings.previous.has(message)) return
230
+ let warnedLastLoad = new Set<string>()
231
+ let warnedThisLoad = new Set<string>()
232
+
233
+ /**
234
+ * Agents reload on activation and again on every `Agent` call, so an unchanged
235
+ * problem would re-warn all session — over a painted TUI, since pi does not
236
+ * redirect console output. Compare against the previous load rather than every
237
+ * load ever, so a file that is fixed and then broken again still reports.
238
+ */
239
+ function warnIfNew(message: string): void {
240
+ warnedThisLoad.add(message)
241
+ if (warnedLastLoad.has(message)) return
188
242
  console.warn(`[pi-subagents] ${message}`)
189
243
  }
190
244