@bermudi/pi-delegate 0.1.9 → 0.1.10

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/README.md CHANGED
@@ -47,9 +47,17 @@ The other built-ins are:
47
47
  default. Set `workspace: "shared"` when a reviewer needs a persistent
48
48
  `sessionId`.
49
49
 
50
- Fresh built-ins inherit the parent's exact model object and thinking level.
51
- Task-level overrides win; settings can provide unconditional overrides or exact
52
- parent-model overrides under `delegate.agentOverridesByParentModel`.
50
+ A same-named Markdown file can override any built-in (first definition wins
51
+ across `.pi/agents/`, `~/.pi/agent/agents/`, `~/.agents/`, `.claude/agents/`,
52
+ `~/.claude/agents/`). A prompt-only override keeps the built-in's tools and
53
+ workspace — `scout` stays read-only and `reviewer` stays scratch unless the
54
+ file explicitly sets `tools` or `workspace`. Fresh built-ins inherit the
55
+ parent's exact model object and thinking level; an explicit `model`/`thinking`
56
+ in the Markdown file replaces that inheritance. Task fields always win, and for
57
+ `scout`/`coder`/`reviewer` settings overrides (`settings.json`
58
+ `delegate.agentOverrides` / `delegate.agentOverridesByParentModel`) win over the
59
+ Markdown file, while `default` ignores settings and uses only an explicit
60
+ Markdown `model`/`thinking` when present.
53
61
 
54
62
  ### Disposable scratch workspace
55
63
 
@@ -142,15 +150,22 @@ over an installed extension.
142
150
  model by default.
143
151
  - **Default subagent** — The reserved built-in `agent: "default"` profile. It
144
152
  mirrors the live parent's model, thinking level, delegatable native tools, and
145
- base system prompt while preserving delegate's extension/context isolation.
153
+ base system prompt while preserving delegate's extension/context isolation;
154
+ a `default.md` Markdown file can override its prompt/tools/model/thinking
155
+ (first definition wins — a prompt-only file keeps the parent-mirrored tools
156
+ and thinking/model inheritance).
146
157
  - **Custom agent** — A subagent profile defined by the parent, either inline in
147
158
  a delegate task (`systemPrompt`, `tools`, and `thinking`) or persisted as a
148
159
  Markdown file. The subagent inherits the parent model by default; `model` is a
149
160
  rare override. Markdown agents are examples of custom agents.
150
161
  - **Named agent** / **Markdown agent** — A reusable custom agent persisted as a
151
- Markdown file in `.pi/agents/*.md` or `~/.pi/agent/agents/*.md`. The frontmatter
152
- defines its name, description, model, tools, and thinking level; the
153
- Markdown body is its system prompt.
162
+ Markdown file in `.pi/agents/*.md`, `~/.pi/agent/agents/*.md`, `~/.agents/*.md`,
163
+ `.claude/agents/*.md`, or `~/.claude/agents/*.md` (first definition wins). The frontmatter defines its name, description,
164
+ model, tools, and thinking level; the Markdown body is its system prompt. A
165
+ same-named file for a built-in (`default`/`scout`/`coder`/`reviewer`)
166
+ overrides that built-in; a prompt-only override keeps the built-in's tools
167
+ and workspace, and an explicit `model`/`thinking` replaces parent inheritance
168
+ (for `default` settings are ignored, for others settings win over the file).
154
169
  - **Ad-hoc subagent** — A subagent created from inline task fields instead of a
155
170
  named Markdown agent profile. In current output this is labeled `ad-hoc`.
156
171
  - **Inline task** — The task object itself when its configuration is supplied
package/agents.ts CHANGED
@@ -115,7 +115,7 @@ export function parseFrontmatter(
115
115
 
116
116
  // ── Agent Discovery ───────────────────────────────────────────────────────
117
117
 
118
- /** Built-in profiles are always available and cannot vary with Markdown files. */
118
+ /** Built-in profiles are always available but can be overridden by a same-named Markdown file. */
119
119
  export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
120
120
  [DEFAULT_AGENT_NAME]: {
121
121
  name: DEFAULT_AGENT_NAME,
@@ -253,12 +253,14 @@ export function loadAgentFile(filePath: string): AgentConfig | null {
253
253
  }
254
254
  const { data, body } = parseFrontmatter(content, filePath);
255
255
  if (!data.name || !data.description) return null;
256
+ const model = data.model?.trim() || undefined;
257
+ const thinking = data.thinking?.trim();
256
258
  return {
257
259
  name: data.name,
258
260
  description: data.description,
259
- model: data.model,
260
- thinking: VALID_THINKING.has(data.thinking ?? "")
261
- ? (data.thinking as ThinkingLevel)
261
+ model,
262
+ thinking: VALID_THINKING.has(thinking ?? "")
263
+ ? (thinking as ThinkingLevel)
262
264
  : "off",
263
265
  // Omitted/blank `tools:` → inherit the full agent set (`*`), matching
264
266
  // CC/OpenCode/Devin. A previous version rejected empty tools; that was
@@ -295,6 +297,8 @@ export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
295
297
  }
296
298
  const { data, body } = parseFrontmatter(content, filePath);
297
299
  if (!data.name || !data.description) return null;
300
+ const model = data.model?.trim();
301
+ const thinking = data.thinking?.trim();
298
302
 
299
303
  // Track whether the user wrote an explicit `tools:` allowlist. Omitted or
300
304
  // blank means "inherit the full default set" (`*`), and only in that case
@@ -327,13 +331,13 @@ export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
327
331
  name: data.name,
328
332
  description: data.description,
329
333
  // `inherit` is Claude's "use parent" default — drop it so we fall through
330
- // to parent-model inheritance. Any other value passes through verbatim.
334
+ // to parent-model inheritance. Other values are stored trimmed.
331
335
  model:
332
- data.model && data.model.toLowerCase() === "inherit"
336
+ model && model.toLowerCase() === "inherit"
333
337
  ? undefined
334
- : data.model,
335
- thinking: VALID_THINKING.has(data.thinking ?? "")
336
- ? (data.thinking as ThinkingLevel)
338
+ : model || undefined,
339
+ thinking: VALID_THINKING.has(thinking ?? "")
340
+ ? (thinking as ThinkingLevel)
337
341
  : "off",
338
342
  tools,
339
343
  systemPrompt: body,
@@ -382,6 +386,7 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
382
386
  const agents = new Map<string, AgentConfig>(
383
387
  Object.entries(BUILTIN_AGENT_CONFIGS),
384
388
  );
389
+ const overriddenBuiltins = new Set<string>();
385
390
  const loadDir = (
386
391
  { dir, scope }: { dir: string; scope: AgentConfig["scope"] },
387
392
  loader: (fp: string) => AgentConfig | null,
@@ -396,13 +401,134 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
396
401
  if (!e.name.endsWith(".md") || e.name.endsWith(".chain.md")) continue;
397
402
  const filePath = path.join(dir, e.name);
398
403
  const cfg = loader(filePath);
399
- if (cfg && isBuiltinAgentName(cfg.name)) {
400
- console.warn(
401
- `[delegate] ignoring agent profile '${cfg.name}' from ${filePath}: the name is reserved for a built-in delegate profile.`,
402
- );
404
+ if (!cfg) continue;
405
+ if (isBuiltinAgentName(cfg.name)) {
406
+ const existing = agents.get(cfg.name);
407
+ if (existing?.builtin && !overriddenBuiltins.has(cfg.name)) {
408
+ // Markdown can override a built-in: first definition wins, replacing
409
+ // the default config. This lets users customize systemPrompt, tools,
410
+ // etc. via a Markdown file. Preserve builtin workspace when the file
411
+ // does not specify one (Markdown profiles have no workspace
412
+ // frontmatter today), and preserve builtin tools when the file omits
413
+ // the tools key so a prompt-only override does not silently escalate
414
+ // privileges (e.g. scout staying read-only).
415
+ // `disallowedTools` is only meaningful for Claude profiles (which
416
+ // actually implement the denylist); for native Pi loaders it is
417
+ // ignored, so it must not be treated as an explicit tools change.
418
+ let rawTools: string | undefined;
419
+ let rawWorkspace: string | undefined;
420
+ let rawDisallowedTools: string | undefined;
421
+ let rawModel: string | undefined;
422
+ let rawThinking: string | undefined;
423
+ try {
424
+ const content = fs.readFileSync(filePath, "utf-8");
425
+ const { data } = parseFrontmatter(content, filePath);
426
+ rawTools = data.tools;
427
+ rawWorkspace = (data as Record<string, string>).workspace;
428
+ rawDisallowedTools = (data as Record<string, string>)
429
+ .disallowedTools;
430
+ rawModel = data.model;
431
+ rawThinking = data.thinking;
432
+ } catch {
433
+ // ignore, keep parsed tools/workspace
434
+ }
435
+ const hasExplicitAllowlist =
436
+ rawTools !== undefined && rawTools.trim() !== "";
437
+ const hasDenylist =
438
+ scope === "claude" &&
439
+ rawDisallowedTools !== undefined &&
440
+ rawDisallowedTools.trim() !== "";
441
+ const hasExplicitTools = hasExplicitAllowlist || hasDenylist;
442
+ const hasExplicitWorkspace =
443
+ rawWorkspace === "shared" || rawWorkspace === "scratch";
444
+ const hasInvalidWorkspace =
445
+ rawWorkspace !== undefined &&
446
+ rawWorkspace.trim() !== "" &&
447
+ !hasExplicitWorkspace;
448
+ const hasExplicitModel =
449
+ rawModel !== undefined &&
450
+ rawModel.trim() !== "" &&
451
+ rawModel.trim().toLowerCase() !== "inherit";
452
+ const hasExplicitThinking =
453
+ rawThinking !== undefined &&
454
+ rawThinking.trim() !== "" &&
455
+ VALID_THINKING.has(rawThinking.trim());
456
+ if (
457
+ cfg.name === DEFAULT_AGENT_NAME &&
458
+ !hasExplicitAllowlist &&
459
+ hasDenylist
460
+ ) {
461
+ // For `default`, a deny-only override must be applied to the
462
+ // parent's actual tools at resolution, not to the static
463
+ // DEFAULT_TOOLS at discovery. Materializing against DEFAULT_TOOLS
464
+ // would grant write/edit when the parent is read-only.
465
+ const denied = new Set(
466
+ (rawDisallowedTools ?? "")
467
+ .split(",")
468
+ .map((s) => s.trim())
469
+ .filter(Boolean)
470
+ .map(
471
+ (n) =>
472
+ (CLAUDE_TOOL_ALIASES as Record<string, string>)[
473
+ n.toLowerCase()
474
+ ] ?? null,
475
+ )
476
+ .filter((n): n is string => n !== null),
477
+ );
478
+ cfg.deniedTools = [...denied];
479
+ cfg.explicitTools = false;
480
+ // Keep tools display as the built-in default; resolution will
481
+ // filter parentNativeTools instead.
482
+ cfg.tools = existing.tools;
483
+ } else if (!hasExplicitAllowlist && hasDenylist && existing.tools) {
484
+ // No explicit allowlist but a Claude denylist is present – apply
485
+ // the denylist to the built-in's own toolset, not the generic
486
+ // full set, to avoid turning a denylist into an escalation
487
+ // (e.g. scout `disallowedTools: Bash` should stay read-only).
488
+ const denied = new Set(
489
+ (rawDisallowedTools ?? "")
490
+ .split(",")
491
+ .map((s) => s.trim())
492
+ .filter(Boolean)
493
+ .map(
494
+ (n) =>
495
+ (CLAUDE_TOOL_ALIASES as Record<string, string>)[
496
+ n.toLowerCase()
497
+ ] ?? null,
498
+ )
499
+ .filter((n): n is string => n !== null),
500
+ );
501
+ cfg.tools = existing.tools.filter((t) => !denied.has(t));
502
+ cfg.explicitTools = true;
503
+ } else {
504
+ if (!hasExplicitTools && existing.tools) {
505
+ cfg.tools = existing.tools;
506
+ }
507
+ cfg.explicitTools = hasExplicitTools;
508
+ }
509
+ cfg.explicitModel = hasExplicitModel;
510
+ cfg.explicitThinking = hasExplicitThinking;
511
+ // Preserve built-in semantics for model/thinking/workspace handling
512
+ // in task-resolution – the overridden profile is still a built-in
513
+ // by name, just with a custom prompt/tools.
514
+ cfg.builtin = true;
515
+ if (!rawWorkspace?.trim() && existing.workspace) {
516
+ cfg.workspace = existing.workspace;
517
+ } else if (hasExplicitWorkspace) {
518
+ cfg.workspace = rawWorkspace as AgentConfig["workspace"];
519
+ } else if (hasInvalidWorkspace) {
520
+ console.warn(
521
+ `[delegate] invalid workspace '${rawWorkspace}' in ${filePath}; preserving built-in '${existing.workspace}'. Expected "shared" or "scratch".`,
522
+ );
523
+ if (existing.workspace) cfg.workspace = existing.workspace;
524
+ }
525
+ cfg.scope = scope;
526
+ agents.set(cfg.name, cfg);
527
+ overriddenBuiltins.add(cfg.name);
528
+ }
403
529
  continue;
404
530
  }
405
- if (cfg && !agents.has(cfg.name)) {
531
+ if (!agents.has(cfg.name)) {
406
532
  cfg.scope = scope;
407
533
  agents.set(cfg.name, cfg);
408
534
  }
package/manual.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import {
2
+ BUILTIN_AGENT_NAMES,
3
+ DEFAULT_AGENT_NAME,
2
4
  DEFAULT_TOOLS,
3
5
  OUTPUT_SPILL_THRESHOLD_CHARS,
4
6
  OUTPUT_SPILL_TAIL_CHARS,
@@ -7,6 +9,7 @@ import { getMaxAsyncTickets, getMaxConcurrent } from "./config.ts";
7
9
  import type { TSchema } from "@sinclair/typebox";
8
10
  import { delegateArgumentsSchema, delegateTaskSchema } from "./schema.ts";
9
11
  import type { AgentConfig } from "./types.ts";
12
+ import { BUILTIN_AGENT_CONFIGS } from "./agents.ts";
10
13
 
11
14
  function schemaType(schema: TSchema): string {
12
15
  if (Array.isArray(schema.enum)) {
@@ -39,7 +42,41 @@ function schemaTable(properties: Record<string, TSchema>): string {
39
42
  export function getSubagentManualMarkdown(
40
43
  agents: Map<string, AgentConfig>,
41
44
  ): string {
42
- const entries = [...agents].filter(([, a]) => !a.builtin);
45
+ const builtinNames = new Set<string>(
46
+ BUILTIN_AGENT_NAMES as readonly string[],
47
+ );
48
+ const entries = [...agents].filter(
49
+ ([name, a]) => !a.builtin && !builtinNames.has(name),
50
+ );
51
+ const builtinLines = (BUILTIN_AGENT_NAMES as readonly string[]).map(
52
+ (name) => {
53
+ const cfg = agents.get(name) ?? BUILTIN_AGENT_CONFIGS[name]!;
54
+ const isDefault = name === DEFAULT_AGENT_NAME;
55
+ // `default` normally mirrors the parent's native tools; only show a
56
+ // fixed list when the file explicitly overrode them. This avoids
57
+ // advertising `read, write, edit, bash` when runtime will actually use
58
+ // the parent's active set. A deny-only `default` (deniedTools with no
59
+ // explicit allowlist) filters the parent at runtime and must surface.
60
+ let toolsPart: string;
61
+ if (isDefault && cfg.deniedTools?.length && !cfg.explicitTools) {
62
+ toolsPart = ` Tools: parent tools minus \`${cfg.deniedTools.join(", ")}\`.`;
63
+ } else {
64
+ const showTools = !isDefault || !!cfg.explicitTools;
65
+ toolsPart = showTools ? ` Tools: \`${cfg.tools.join(", ")}\`.` : "";
66
+ }
67
+ const modelPart =
68
+ cfg.explicitModel && cfg.model ? ` Model: \`${cfg.model}\`.` : "";
69
+ const thinkingPart =
70
+ cfg.explicitThinking && cfg.thinking
71
+ ? ` Thinking: \`${cfg.thinking}\`.`
72
+ : "";
73
+ const workspace =
74
+ cfg.workspace === "scratch"
75
+ ? `Defaults to a disposable scratch workspace; set \`workspace: "shared"\` for a persistent ${name} with \`sessionId\`.`
76
+ : "Shared workspace.";
77
+ return `- **${name}**: ${cfg.description}${toolsPart}${modelPart}${thinkingPart} ${workspace}`;
78
+ },
79
+ );
43
80
  const agentList = entries.length
44
81
  ? entries
45
82
  .map(([n, a]) => {
@@ -97,12 +134,9 @@ export function getSubagentManualMarkdown(
97
134
  "",
98
135
  "## Built-in Agents",
99
136
  "",
100
- "- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base prompt. It uses the shared workspace.",
101
- "- **scout**: investigates without modifying files. Tools: `read`, `grep`, `find`, `ls`. Shared workspace.",
102
- "- **coder**: implements and verifies changes. Tools: `read`, `write`, `edit`, `bash`. Shared workspace.",
103
- '- **reviewer**: reviews the current snapshot and reports findings. Tools: `read`, `bash`. Defaults to a disposable scratch workspace; set `workspace: "shared"` for a persistent reviewer with `sessionId`.',
137
+ ...builtinLines,
104
138
  "",
105
- "Fresh built-ins inherit the parent's exact model object and thinking level unless task fields or settings override them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
139
+ "Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, settings overrides (`settings.json` `delegate.agentOverrides` and `delegate.agentOverridesByParentModel`) win over the Markdown file; `default` ignores settings and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays scratch unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
106
140
  "",
107
141
  "## Available Custom Agents",
108
142
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -274,11 +274,29 @@ export function resolveTasks(
274
274
  // "continue with only sessionId" works without re-supplying tools.
275
275
  // Explicit overrides that don't match get rejected by acquireAgentSession.
276
276
  if (t.sessionAction !== "close" && t.sessionAction !== "list") {
277
+ // For `default` a deny-only override (no explicit allowlist) is not
278
+ // materialized at discovery; apply its denylist to the parent's actual
279
+ // tools here so a read-only parent stays read-only.
280
+ let effectiveParentTools = parentNativeTools;
281
+ if (
282
+ isDefaultAgent &&
283
+ agent?.deniedTools?.length &&
284
+ !agent?.explicitTools
285
+ ) {
286
+ const denied = new Set(agent.deniedTools);
287
+ effectiveParentTools = parentNativeTools.filter(
288
+ (t) => !denied.has(t),
289
+ );
290
+ }
277
291
  tools = resolveToolGroups(
278
292
  t.tools ??
279
293
  parentModelOverride?.tools ??
280
294
  agentOverride?.tools ??
281
- (isDefaultAgent ? parentNativeTools : undefined) ??
295
+ (isDefaultAgent
296
+ ? agent?.explicitTools
297
+ ? agent.tools
298
+ : effectiveParentTools
299
+ : undefined) ??
282
300
  (isBuiltinAgent ? agent?.tools : undefined) ??
283
301
  agent?.tools ??
284
302
  (isPoolHit ? pooledConfig?.tools : undefined) ??
@@ -369,10 +387,16 @@ export function resolveTasks(
369
387
  // task and settings.json model overrides, but deliberately ignore the
370
388
  // legacy delegate.json agent model map so they inherit the parent unless
371
389
  // an explicit modern override wins.
390
+ // Overridden built-ins can still provide an explicit `model` in their
391
+ // Markdown frontmatter – when `explicitModel` is set, honor it instead
392
+ // of silently ignoring it (which would contradict the Markdown contract).
372
393
  const modelSpec = isDefaultAgent
373
- ? t.model
394
+ ? (t.model ?? (agent?.explicitModel ? agent.model : undefined))
374
395
  : isBuiltinAgent
375
- ? (t.model ?? parentModelOverride?.model ?? agentOverride?.model)
396
+ ? (t.model ??
397
+ parentModelOverride?.model ??
398
+ agentOverride?.model ??
399
+ (agent?.explicitModel ? agent.model : undefined))
376
400
  : resolveModelSpec({
377
401
  taskModel:
378
402
  t.model ?? parentModelOverride?.model ?? agentOverride?.model,
@@ -449,6 +473,7 @@ export function resolveTasks(
449
473
  ? (t.thinking ??
450
474
  parentModelOverride?.thinking ??
451
475
  agentOverride?.thinking ??
476
+ (agent?.explicitThinking ? agent.thinking : undefined) ??
452
477
  modelSuffix ??
453
478
  parentDefaults.thinking ??
454
479
  (isPoolHit ? pooledConfig?.thinking : undefined) ??
@@ -456,6 +481,7 @@ export function resolveTasks(
456
481
  : (t.thinking ??
457
482
  parentModelOverride?.thinking ??
458
483
  agentOverride?.thinking ??
484
+ (agent?.explicitThinking ? agent.thinking : undefined) ??
459
485
  (isPoolHit ? pooledConfig?.thinking : undefined) ??
460
486
  modelSuffix ??
461
487
  parentDefaults.thinking ??
package/types.ts CHANGED
@@ -23,12 +23,20 @@ export interface AgentConfig {
23
23
  thinking?: ThinkingLevel;
24
24
  tools: string[];
25
25
  systemPrompt: string;
26
- /** Built-in profiles are immutable and cannot be shadowed by Markdown. */
26
+ /** Built-in profiles can be overridden by a same-named Markdown file. */
27
27
  builtin?: boolean;
28
28
  /** Default workspace for a built-in profile. Custom agents use shared. */
29
29
  workspace?: WorkspaceMode;
30
30
  /** Origin of the profile. `claude` denotes imported .claude/agents files. */
31
31
  scope?: "project" | "global" | "claude";
32
+ /** Whether `tools` was explicitly set in the Markdown frontmatter (vs inherited default). */
33
+ explicitTools?: boolean;
34
+ /** Whether `model` was explicitly set in the Markdown frontmatter. */
35
+ explicitModel?: boolean;
36
+ /** Whether `thinking` was explicitly set in the Markdown frontmatter. */
37
+ explicitThinking?: boolean;
38
+ /** Denylist applied to a built-in `default` override with no explicit allowlist – materialized against parentNativeTools at resolution. */
39
+ deniedTools?: string[];
32
40
  }
33
41
 
34
42
  // ── Tool parameter types — derived from the TypeBox schema ────────────────