@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.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.
@@ -6,7 +6,7 @@ import { existsSync, readdirSync, readFileSync } 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 type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
9
+ import type { AgentConfig, IsolationMode, MemoryScope, ThinkingLevel } from "./types.js";
10
10
 
11
11
  /**
12
12
  * Scan for custom agent .md files from multiple locations.
@@ -20,20 +20,23 @@ import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
20
20
  * authority; .agents/agents is an additional read location.
21
21
  * Any name is allowed — names matching defaults (e.g. "Explore") override them.
22
22
  */
23
- export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
23
+ export function loadCustomAgents(cwd: string, strict = false): Map<string, AgentConfig> {
24
24
  const globalDir = join(getAgentDir(), "agents");
25
25
  const workspaceProjectDir = join(cwd, ".agents", "agents");
26
26
  const projectDir = join(cwd, ".pi", "agents");
27
27
 
28
28
  const agents = new Map<string, AgentConfig>();
29
- loadFromDir(globalDir, agents, "global"); // lowest priority
30
- loadFromDir(workspaceProjectDir, agents, "project"); // shared workspace
31
- loadFromDir(projectDir, agents, "project"); // highest priority (overwrites)
29
+ loadFromDir(globalDir, agents, "global", strict); // lowest priority
30
+ loadFromDir(workspaceProjectDir, agents, "project", strict); // shared workspace
31
+ loadFromDir(projectDir, agents, "project", strict); // highest priority (overwrites)
32
+
33
+ warnedLastLoad = warnedThisLoad;
34
+ warnedThisLoad = new Set();
32
35
  return agents;
33
36
  }
34
37
 
35
38
  /** Load agent configs from a directory into the map. */
36
- function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global"): void {
39
+ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global", strict: boolean): void {
37
40
  if (!existsSync(dir)) return;
38
41
 
39
42
  let files: string[];
@@ -45,15 +48,15 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
45
48
 
46
49
  for (const file of files) {
47
50
  const name = basename(file, ".md");
51
+ const path = join(dir, file);
48
52
 
49
- let content: string;
50
- try {
51
- content = readFileSync(join(dir, file), "utf-8");
52
- } catch {
53
+ const parsed = readAgentFile(path, strict);
54
+ if (!parsed) {
55
+ warnSkippedOverride(name, agents);
53
56
  continue;
54
57
  }
55
-
56
- const { frontmatter: fm, body } = parseFrontmatter<Record<string, unknown>>(content);
58
+ const { frontmatter: fm, body } = parsed;
59
+ warnUnknownKeys(path, fm);
57
60
 
58
61
  const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
59
62
 
@@ -67,25 +70,121 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
67
70
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
68
71
  excludeExtensions: csvListOptional(fm.exclude_extensions),
69
72
  skills: inheritField(fm.skills ?? fm.inherit_skills),
73
+ scopedModels: scopedModelsField(fm.scoped_models ?? fm.inherit_scoped_models),
70
74
  model: str(fm.model),
71
75
  thinking: str(fm.thinking) as ThinkingLevel | undefined,
72
76
  maxTurns: nonNegativeInt(fm.max_turns),
73
77
  persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
74
78
  outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
75
79
  sessionDir: str(fm.session_dir),
80
+ allowedSubagents: parseAllowedSubagents(fm.allowed_subagents),
81
+ maxSubagentDepth: nonNegativeInt(fm.max_subagent_depth),
76
82
  systemPrompt: body.trim(),
77
83
  promptMode: fm.prompt_mode === "append" ? "append" : "replace",
78
84
  inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
79
- runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
80
85
  isolated: fm.isolated != null ? fm.isolated === true : undefined,
81
86
  memory: parseMemory(fm.memory),
82
- isolation: fm.isolation === "worktree" ? "worktree" : undefined,
87
+ isolation: parseIsolation(fm.isolation),
83
88
  enabled: fm.enabled !== false, // default true; explicitly false disables
84
89
  source,
90
+ sourcePath: path,
85
91
  });
86
92
  }
87
93
  }
88
94
 
95
+ /**
96
+ * Read and parse one agent file, or warn and return undefined for the caller to
97
+ * skip. One bad file must not take the whole extension down with it — an
98
+ * unparseable `.md` used to abort activation, so pi exited before the TUI.
99
+ *
100
+ * The path is as much of the fix as the recovery: a bare YAML error ("line 2,
101
+ * column 14") is unactionable when agents come from three directories at once,
102
+ * and the only other symptom is `Unknown agent type`, which reads like a typo.
103
+ *
104
+ * Under `strict` the same failure rethrows, still naming the path, so callers
105
+ * that opted into failing closed stop rather than run a substituted agent.
106
+ */
107
+ function readAgentFile(path: string, strict: boolean): { frontmatter: Record<string, unknown>; body: string } | undefined {
108
+ try {
109
+ return parseFrontmatter<Record<string, unknown>>(readFileSync(path, "utf-8"));
110
+ } catch (err) {
111
+ const reason = err instanceof Error ? err.message : String(err);
112
+ if (strict) throw new Error(`${path}: ${reason}`);
113
+ warnIfNew(`Skipping agent file ${path}: ${reason}`);
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * A skipped file that was overriding an already-loaded agent leaves the name
120
+ * pointing at a *different* file — its own prompt, model and tools. Nothing
121
+ * downstream can flag that: unlike an unknown type, the `Agent` call succeeds.
122
+ */
123
+ function warnSkippedOverride(name: string, agents: Map<string, AgentConfig>): void {
124
+ const surviving = agents.get(name);
125
+ // Nothing shadowed, or what it shadowed is disabled: dispatch refuses the type
126
+ // either way (see resolveEnabledTypeIn), so there is no substitution to report.
127
+ if (!surviving?.sourcePath || surviving.enabled === false) return;
128
+ warnIfNew(`Agent "${name}" now loads from ${surviving.sourcePath} instead`);
129
+ }
130
+
131
+ /**
132
+ * Frontmatter keys this loader reads. The parser below is field-by-field, so a
133
+ * key outside this set is a silent no-op: a typo, or a field from another tool
134
+ * (`run_in_background`, `join_mode`, `name:`) that reads as configuration and
135
+ * does nothing. Reported once per file per key at load — the same channel as a
136
+ * malformed file — rather than per spawn, where it would be noise.
137
+ */
138
+ const KNOWN_FRONTMATTER_KEYS: ReadonlySet<string> = new Set([
139
+ "description",
140
+ "display_name",
141
+ "tools",
142
+ "disallowed_tools",
143
+ "extensions",
144
+ "inherit_extensions",
145
+ "exclude_extensions",
146
+ "skills",
147
+ "inherit_skills",
148
+ "scoped_models",
149
+ "inherit_scoped_models",
150
+ "model",
151
+ "thinking",
152
+ "max_turns",
153
+ "persist_session",
154
+ "output_transcript",
155
+ "session_dir",
156
+ "allowed_subagents",
157
+ "max_subagent_depth",
158
+ "prompt_mode",
159
+ "inherit_context",
160
+ "isolated",
161
+ "memory",
162
+ "isolation",
163
+ "enabled",
164
+ ]);
165
+
166
+ function warnUnknownKeys(path: string, frontmatter: Record<string, unknown>): void {
167
+ for (const key of Object.keys(frontmatter)) {
168
+ if (KNOWN_FRONTMATTER_KEYS.has(key)) continue;
169
+ warnIfNew(`Ignoring unknown frontmatter key "${key}" in ${path}`);
170
+ }
171
+ }
172
+
173
+ let warnedLastLoad = new Set<string>();
174
+ let warnedThisLoad = new Set<string>();
175
+
176
+ /**
177
+ * Agents reload on activation and again on every `Agent` call, so an unchanged
178
+ * problem would re-warn all session — over a painted TUI, since pi does not
179
+ * redirect console output. Compare against the previous load rather than every
180
+ * load ever, so a file that is fixed and then broken again still reports.
181
+ */
182
+ function warnIfNew(message: string): void {
183
+ warnedThisLoad.add(message);
184
+ if (warnedLastLoad.has(message)) return;
185
+ console.warn(`[pi-subagents] ${message}`);
186
+ }
187
+
89
188
  // ---- Field parsers ----
90
189
  // All follow the same convention: omitted → default, "none"/empty → nothing, value → exact.
91
190
 
@@ -110,6 +209,22 @@ function parseCsvField(val: unknown): string[] | undefined {
110
209
  return items.length > 0 ? items : undefined;
111
210
  }
112
211
 
212
+ /**
213
+ * Parse the nested-delegation allowlist. Single field, default-off:
214
+ * omitted/empty/"none"/`false` → undefined (no nested tools); "all"/"*"/`true`
215
+ * → "all" (any enabled agent); csv → only the listed types.
216
+ *
217
+ * Booleans are accepted because `extensions:`/`skills:` take them and users
218
+ * generalize: without this, YAML's `true` stringifies into an agent type
219
+ * literally named "true", so the tools appear and every spawn is refused.
220
+ */
221
+ function parseAllowedSubagents(val: unknown): "all" | string[] | undefined {
222
+ if (typeof val === "boolean") return val ? "all" : undefined;
223
+ const items = parseCsvField(val);
224
+ if (!items) return undefined;
225
+ return items.some(i => i === "*" || i.toLowerCase() === "all") ? "all" : items;
226
+ }
227
+
113
228
  /**
114
229
  * Parse a comma-separated list field with defaults.
115
230
  * omitted → defaults; "none"/empty → []; csv → listed items.
@@ -155,6 +270,24 @@ function parseMemory(val: unknown): MemoryScope | undefined {
155
270
  return undefined;
156
271
  }
157
272
 
273
+ /**
274
+ * Parse the `isolation` frontmatter field.
275
+ *
276
+ * `off` is kept as a value rather than folded into `undefined` because the two
277
+ * do not mean the same thing here: agent config outranks tool-call params, so
278
+ * `off` vetoes a caller's `worktree` while an absent field lets it through.
279
+ *
280
+ * pi's frontmatter parser is not YAML 1.1 — bare `off` and `no` arrive as
281
+ * strings and only `false` becomes a boolean — so all three spellings are
282
+ * accepted rather than leaving an author's intent silently dropped. Anything
283
+ * else stays `undefined`, as before.
284
+ */
285
+ function parseIsolation(val: unknown): IsolationMode | undefined {
286
+ if (val === "worktree") return "worktree";
287
+ if (val === "off" || val === "none" || val === "no" || val === false) return "off";
288
+ return undefined;
289
+ }
290
+
158
291
  /**
159
292
  * Parse an inherit field (extensions, skills).
160
293
  * omitted/true → true (inherit all); false/"none"/empty → false; csv → listed names.
@@ -165,3 +298,17 @@ function inheritField(val: unknown): true | string[] | false {
165
298
  const items = csvList(val, []);
166
299
  return items.length > 0 ? items : false;
167
300
  }
301
+
302
+ /**
303
+ * Parse scoped_models (alias inherit_scoped_models), the child session's
304
+ * model scope. omitted/true/"inherit" → true (inherit the parent session's
305
+ * scope); false/"none"/empty → false; csv → explicit provider/modelId list.
306
+ * Unlike inheritField, the literal "inherit" is accepted as a readable alias,
307
+ * but only as the sole value — mixed `inherit, foo` stays an explicit list.
308
+ */
309
+ function scopedModelsField(val: unknown): true | string[] | false {
310
+ // `inherit` must be the sole value: a scalar or a single-item YAML list.
311
+ const sole = Array.isArray(val) ? (val.length === 1 ? val[0] : undefined) : val;
312
+ if (typeof sole === "string" && sole.trim().toLowerCase() === "inherit") return true;
313
+ return inheritField(val);
314
+ }
@@ -16,7 +16,7 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
16
16
  displayName: "Agent",
17
17
  description: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
18
18
  // builtinToolNames omitted — means "all available tools" (resolved at lookup time)
19
- // inheritContext / runInBackground / isolated omitted — strategy fields, callers decide per-call.
19
+ // inheritContext / isolated omitted — strategy fields, callers decide per-call.
20
20
  // Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
21
21
  extensions: true,
22
22
  skills: true,