@bermudi/pi-delegate 0.1.8 → 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/config.ts CHANGED
@@ -129,10 +129,17 @@ function normalizeProviderExtensions(
129
129
  // Provider-scoped opt-in extension map for subagents. Keep this aligned with
130
130
  // the currently shipped codex remote-compaction integration. `delegate.json`
131
131
  // `providerExtensions` replaces a provider's entries (it does not append); an
132
- // empty array is ignored so the default persists.
133
- const DEFAULT_PROVIDER_EXTENSIONS: Record<string, readonly string[]> = {
134
- "openai-codex": ["git:github.com/bermudi/manaflow-pi-codex"],
135
- };
132
+ // empty array is ignored so the default persists. Provenance is classification
133
+ // by config presence: every source the user lists is required and fails
134
+ // closed when missing, unverifiable, or broken — including an exact re-listing
135
+ // of a shipped default. Shipped defaults (providers the user never mentioned)
136
+ // are best-effort: they degrade silently to extension-free subagents on Pi's
137
+ // native compaction, because absence of an optional integration is not a
138
+ // warning condition — that is Pi's normal operation.
139
+ const DEFAULT_PROVIDER_EXTENSIONS: Record<string, readonly string[]> =
140
+ Object.assign(Object.create(null) as Record<string, readonly string[]>, {
141
+ "openai-codex": ["npm:@bermudi/pi-codex"],
142
+ });
136
143
 
137
144
  function resolveProviderExtensions(
138
145
  raw: unknown,
@@ -155,7 +162,7 @@ const DEFAULT_DELEGATE_CONFIG: DelegateConfig = {
155
162
  wholeTaskMaxRetries: 3,
156
163
  wholeTaskBaseDelayMs: 1_000,
157
164
  },
158
- providerExtensions: DEFAULT_PROVIDER_EXTENSIONS,
165
+ providerExtensions: {},
159
166
  telemetry: {
160
167
  enabled: true,
161
168
  },
@@ -174,7 +181,15 @@ let __delegateConfig: DelegateConfig = {
174
181
  };
175
182
  let stallTimeoutOverrideForTesting: number | undefined;
176
183
 
177
- /** Read delegate config from disk. Returns defaults if file missing or corrupt. */
184
+ /** Read delegate config from disk. Returns defaults if file missing or corrupt.
185
+ *
186
+ * The returned `providerExtensions` is the *user-only* view — exactly what the
187
+ * file said, defaults excluded. `getSubagentProviderExtensionMap()` is the
188
+ * merged (defaults + user) view, and
189
+ * `getSubagentProviderExtensionSourcesForProvider()` is the provenance-tagged
190
+ * view. Keeping the raw user map here is what lets the sources getter
191
+ * distinguish "the user listed this" from "this is a shipped default" by
192
+ * config presence rather than string identity. */
178
193
  export function loadDelegateConfig(): DelegateConfig {
179
194
  try {
180
195
  const raw = fs.readFileSync(DELEGATE_CONFIG_PATH, "utf-8");
@@ -191,7 +206,9 @@ export function loadDelegateConfig(): DelegateConfig {
191
206
  ...(parsed.concurrency ?? {}),
192
207
  },
193
208
  retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
194
- providerExtensions: resolveProviderExtensions(parsed.providerExtensions),
209
+ providerExtensions: normalizeProviderExtensions(
210
+ parsed.providerExtensions,
211
+ ),
195
212
  telemetry: normalizeTelemetryConfig(parsed.telemetry),
196
213
  output: { ...DEFAULT_DELEGATE_CONFIG.output, ...(parsed.output ?? {}) },
197
214
  } as DelegateConfig;
@@ -255,7 +272,7 @@ export function _setDelegateConfigForTesting(
255
272
  ...DEFAULT_DELEGATE_CONFIG.retry,
256
273
  ...(config.retry ?? {}),
257
274
  },
258
- providerExtensions: resolveProviderExtensions(config.providerExtensions),
275
+ providerExtensions: normalizeProviderExtensions(config.providerExtensions),
259
276
  telemetry: normalizeTelemetryConfig(config.telemetry),
260
277
  output: {
261
278
  ...DEFAULT_DELEGATE_CONFIG.output,
@@ -266,7 +283,11 @@ export function _setDelegateConfigForTesting(
266
283
  }
267
284
 
268
285
  /**
269
- * Get the configured provider-scoped extension allowlist for subagents.
286
+ * Get the configured provider-scoped extension allowlist for subagents:
287
+ * the merged view (shipped defaults + user config, user entries replacing a
288
+ * provider's defaults). The stored `config.providerExtensions` itself is the
289
+ * user-only view; the merge happens here so provenance survives until a
290
+ * consumer asks for it (`getSubagentProviderExtensionSourcesForProvider`).
270
291
  * Explicit configs are normalized here too, so callers using the injected
271
292
  * config form get the same case-insensitive and replace-per-provider
272
293
  * semantics as the file-backed singleton.
@@ -295,6 +316,60 @@ export function getSubagentProviderExtensionsForProvider(
295
316
  : [];
296
317
  }
297
318
 
319
+ /** A provider-extension source together with how it entered the config. */
320
+ export interface ProviderExtensionSource {
321
+ /** The normalized source string, as the package manager consumes it. */
322
+ readonly source: string;
323
+ /**
324
+ * Whether the user configured this source themselves (required) or it is a
325
+ * shipped default for a provider the user never mentioned (best-effort).
326
+ * Required sources fail closed when missing, unverifiable, or broken;
327
+ * best-effort defaults degrade silently to extension-free subagents on
328
+ * Pi's native compaction.
329
+ */
330
+ readonly required: boolean;
331
+ }
332
+
333
+ /**
334
+ * Get the provenance-tagged extension sources for a provider's subagents.
335
+ * Classification is by config presence, never by string identity: everything
336
+ * the user lists in `providerExtensions` is `required: true` — including an
337
+ * exact re-listing of a shipped default, because typing it into the config
338
+ * expresses intent. Providers the user never configured fall back to the
339
+ * shipped defaults, tagged `required: false` (best-effort).
340
+ */
341
+ export function getSubagentProviderExtensionSourcesForProvider(
342
+ provider: string | undefined,
343
+ config: DelegateConfig = __delegateConfig,
344
+ ): readonly ProviderExtensionSource[] {
345
+ const normalized = provider?.trim().toLowerCase();
346
+ if (!normalized) return [];
347
+ // Normalize the injected config map so an unnormalized key like " Custom-Provider "
348
+ // is handled, matching getSubagentProviderExtensionMap / getSubagentProviderExtensionsForProvider.
349
+ const rawUserMap = config.providerExtensions;
350
+ const userMap = rawUserMap
351
+ ? normalizeProviderExtensions(rawUserMap)
352
+ : (Object.create(null) as Record<string, readonly string[]>);
353
+ if (Object.prototype.hasOwnProperty.call(userMap, normalized)) {
354
+ return (userMap[normalized] ?? []).map((source) => ({
355
+ source,
356
+ required: true,
357
+ }));
358
+ }
359
+ if (
360
+ !Object.prototype.hasOwnProperty.call(
361
+ DEFAULT_PROVIDER_EXTENSIONS,
362
+ normalized,
363
+ )
364
+ ) {
365
+ return [];
366
+ }
367
+ return (DEFAULT_PROVIDER_EXTENSIONS[normalized] ?? []).map((source) => ({
368
+ source,
369
+ required: false,
370
+ }));
371
+ }
372
+
298
373
  // ── Config Getters ───────────────────────────────────────────────────────
299
374
 
300
375
  /**
package/extension.ts CHANGED
@@ -18,7 +18,10 @@ import {
18
18
  } from "./dispatch.ts";
19
19
  import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
20
20
  import { hostCompatError } from "./host-compat.ts";
21
- import { invalidateHostDepsCache } from "./host.ts";
21
+ import {
22
+ invalidateHostDepsCache,
23
+ registerProviderExtensionNotifier,
24
+ } from "./host.ts";
22
25
  import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
23
26
  import { closeAllPooledAgents } from "./pool.ts";
24
27
  import {
@@ -122,6 +125,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
122
125
  prepareArguments: normalizeDelegateArguments,
123
126
 
124
127
  async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
128
+ // Prime the UI notice for best-effort provider extensions that load for
129
+ // subagents (host.ts consumes it where the fact is discovered). Every
130
+ // execute re-primes so a stale ctx never sticks.
131
+ registerProviderExtensionNotifier((message) =>
132
+ ctx.ui.notify(message, "info"),
133
+ );
125
134
  const parentModelId = ctx.model?.id;
126
135
  const tasks = params.tasks ?? [];
127
136
  const parentSessionFile = (
@@ -347,6 +356,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
347
356
  // captured pi) to touch. The cancelled completion path still writes one
348
357
  // final aggregate after late task results arrive, but never delivers UI.
349
358
  clearDelegateStatusContext();
359
+ registerProviderExtensionNotifier(undefined);
350
360
  // A replacement session starts on its own leaf; stale tracking would make
351
361
  // every ticket look cross-leaf (or, worse, look same-leaf by accident).
352
362
  resetLeafTracking();
package/host.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  * must not run the parent's interactive extensions (custom UI, slash commands,
17
17
  * hooks that call `pi.appendEntry()`/`pi.sendMessage()`). A narrow,
18
18
  * provider-scoped allowlist is injected as `additionalExtensionPaths` for
19
- * safety-critical integrations. Those extension-bearing dependencies are
19
+ * provider-specific integrations (best-effort for shipped defaults). Those
20
+ * extension-bearing dependencies are
20
21
  * deliberately built per session: `AgentSession._buildRuntime` hands the
21
22
  * loader's `extensionsResult.runtime` to a new `ExtensionRunner`, whose
22
23
  * `bindCore()` overwrites mutable methods on that runtime (`sendMessage`,
@@ -37,7 +38,15 @@
37
38
  import { execFileSync } from "node:child_process";
38
39
  import { homedir } from "node:os";
39
40
  import { existsSync, realpathSync } from "node:fs";
40
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
41
+ import {
42
+ basename,
43
+ dirname,
44
+ isAbsolute,
45
+ join,
46
+ relative,
47
+ resolve,
48
+ sep,
49
+ } from "node:path";
41
50
  import {
42
51
  DefaultPackageManager,
43
52
  DefaultResourceLoader,
@@ -49,7 +58,7 @@ import {
49
58
  } from "@earendil-works/pi-coding-agent";
50
59
  import {
51
60
  getSubagentProviderExtensionMap,
52
- getSubagentProviderExtensionsForProvider,
61
+ getSubagentProviderExtensionSourcesForProvider,
53
62
  } from "./config.ts";
54
63
 
55
64
  export interface HostDeps {
@@ -94,6 +103,14 @@ const hostDepsInflight = new Map<string, Promise<HostDeps>>();
94
103
  /** Prevent a pre-invalidation build from repopulating or clearing newer state. */
95
104
  let hostDepsCacheGeneration = 0;
96
105
 
106
+ // When the shipped best-effort default (npm:@bermudi/pi-codex) is absent — the
107
+ // normal state for most users — Pi's DefaultPackageManager.getInstalledPath
108
+ // would synchronously spawn `npm root -g` to check the legacy global fallback.
109
+ // Doing that once per task in a fan-out blocks the event loop N times and
110
+ // serializes the fan-out. Cache the *absence* per dispatch so only the first
111
+ // task in a fan-out pays the cost; the rest skip the lookup entirely.
112
+ const missingBestEffortNpmCache = new Set<string>();
113
+
97
114
  function canonicalPath(candidate: string): string {
98
115
  try {
99
116
  return realpathSync(candidate);
@@ -508,17 +525,84 @@ async function assertConfiguredNpmVersion(
508
525
  }
509
526
  }
510
527
 
528
+ /** Result of resolving a provider's allowlisted extension sources. */
529
+ interface ProviderExtensionResolution {
530
+ /** User-scope package roots to inject as subagent extension paths. */
531
+ paths: string[];
532
+ /** Roots originating from shipped best-effort defaults; these may degrade
533
+ * silently — see the drop-site comments for why silence is the design. */
534
+ bestEffortPaths: Set<string>;
535
+ }
536
+
537
+ /**
538
+ * UI notifier for the provider-extension-loaded notice, primed from
539
+ * extension.ts `execute` (the only place the real, ui-bearing ctx exists —
540
+ * host-dep construction itself has no UI context). Consumed defensively:
541
+ * a throw means the ctx went stale (headless run, torn-down TUI) and simply
542
+ * un-primes the notifier so the next live execute re-primes it.
543
+ */
544
+ let providerExtensionNotifier: ((message: string) => void) | undefined;
545
+
546
+ /**
547
+ * Prime the UI notifier used for the best-effort extension-loaded notice.
548
+ * Idempotent and cheap; called at the top of every delegate execute. Pass
549
+ * `undefined` to un-prime (tests) — an un-primed notifier makes the notice a
550
+ * no-op, which is also the default state in headless/test runs.
551
+ */
552
+ export function registerProviderExtensionNotifier(
553
+ notify: ((message: string) => void) | undefined,
554
+ ): void {
555
+ providerExtensionNotifier = notify;
556
+ }
557
+
558
+ /** provider+root pairs already noticed this process. */
559
+ const noticedProviderExtensionRoots = new Set<string>();
560
+
561
+ /**
562
+ * Announce that a shipped best-effort provider integration actually loaded
563
+ * for delegated subagents. This is the deliberate inverse of the silent
564
+ * drop: absence of an optional integration is normal and never mentioned,
565
+ * but a default that IS active changes subagent behavior (remote compaction
566
+ * on codex models) invisibly — so it gets one info notice per process per
567
+ * provider+root, not one per dispatch. User-configured sources never get
568
+ * here: the user installed them knowingly and they fail closed.
569
+ */
570
+ function noticeProviderExtensionLoaded(
571
+ provider: string | undefined,
572
+ root: string,
573
+ ): void {
574
+ const providerName = provider?.trim() || "provider";
575
+ const key = `${providerName.toLowerCase()}\0${root}`;
576
+ if (noticedProviderExtensionRoots.has(key)) return;
577
+ const notify = providerExtensionNotifier;
578
+ if (!notify) return;
579
+ const label = basename(root) || root;
580
+ try {
581
+ notify(`⚡ ${label} integration active for ${providerName} subagents`);
582
+ noticedProviderExtensionRoots.add(key);
583
+ } catch {
584
+ // Cosmetic notice on a stale ctx — fail open (status.ts precedent for
585
+ // cached-ctx notify): drop the notifier, keep the key un-noticed so a
586
+ // live ctx can still surface it later. Delegation is unaffected.
587
+ providerExtensionNotifier = undefined;
588
+ }
589
+ }
590
+
511
591
  async function getProviderExtensionPaths(
512
592
  provider: string | undefined,
513
593
  cwd: string,
514
594
  agentDir: string,
515
595
  packageLookupSettingsManager: SettingsManager,
516
- ): Promise<string[]> {
517
- // Provider-key normalization (trim + lowercase) lives in `config.ts` —
518
- // `getSubagentProviderExtensionsForProvider` is the single owner of that
519
- // logic, so this module never re-implements it.
520
- const requested = getSubagentProviderExtensionsForProvider(provider);
521
- if (!requested.length) return [];
596
+ ): Promise<ProviderExtensionResolution> {
597
+ // Provider-key normalization (trim + lowercase) lives in `config.ts` — the
598
+ // sources getter is the single owner of that logic, so this module never
599
+ // re-implements it. Provenance (required vs best-effort) is decided there
600
+ // too, by config presence: user-listed sources fail closed; shipped
601
+ // defaults degrade silently — the extension-free path is Pi's normal
602
+ // operation, not a warning condition.
603
+ const requested = getSubagentProviderExtensionSourcesForProvider(provider);
604
+ if (!requested.length)
605
+ return { paths: [], bestEffortPaths: new Set<string>() };
522
606
 
523
607
  const packageManager = new DefaultPackageManager({
524
608
  cwd,
@@ -567,12 +651,34 @@ async function getProviderExtensionPaths(
567
651
 
568
652
  const installedPaths = new Map<string, string>();
569
653
  const missing: string[] = [];
570
- for (const source of requested) {
654
+ for (const { source, required } of requested) {
655
+ // For best-effort npm defaults that are absent — the normal state for the
656
+ // shipped optional integration — avoid Pi's legacy global npm fallback
657
+ // (`npm root -g`) on every task in a fan-out. The first task in a dispatch
658
+ // still pays the cost to discover the absence, but subsequent tasks with
659
+ // the same agentDir+source skip the synchronous spawn entirely. The cache
660
+ // is per-dispatch and cleared on invalidateHostDepsCache.
661
+ if (!required && source.trim().toLowerCase().startsWith("npm:")) {
662
+ const missingKey = `${agentDir}\0${source}`;
663
+ if (missingBestEffortNpmCache.has(missingKey)) {
664
+ continue;
665
+ }
666
+ const userPath = packageManager.getInstalledPath(source, "user");
667
+ if (!userPath) {
668
+ missingBestEffortNpmCache.add(missingKey);
669
+ continue;
670
+ }
671
+ installedPaths.set(source, userPath);
672
+ continue;
673
+ }
571
674
  // Deliberately resolve only the user scope. Project-local packages are
572
675
  // untrusted input and must never become executable subagent extensions.
573
676
  const userPath = packageManager.getInstalledPath(source, "user");
574
677
  if (!userPath) {
575
- missing.push(source);
678
+ if (required) missing.push(source);
679
+ // A best-effort default that is not installed is skipped silently: for
680
+ // most users the package was never installed at all, and its absence is
681
+ // the normal, correct state — not something to warn about.
576
682
  continue;
577
683
  }
578
684
  installedPaths.set(source, userPath);
@@ -587,23 +693,29 @@ async function getProviderExtensionPaths(
587
693
  }
588
694
 
589
695
  const paths = new Set<string>();
590
- for (const source of requested) {
696
+ const bestEffortPaths = new Set<string>();
697
+ for (const { source, required } of requested) {
591
698
  const userPath = installedPaths.get(source);
592
- // Every requested source was collected above; this guard keeps the map
593
- // boundary explicit if that invariant changes later.
594
- if (!userPath) {
595
- throw new Error(
596
- "A configured provider extension disappeared before validation; delegation stopped.",
597
- );
598
- }
599
- validateInstalledPath(source, userPath);
600
- if (hasNpmVersionSpecifier(source)) {
601
- await assertConfiguredNpmVersion(packageManager, source, userPath);
699
+ // Best-effort defaults that were not installed never reached the map.
700
+ if (!userPath) continue;
701
+ try {
702
+ validateInstalledPath(source, userPath);
703
+ if (hasNpmVersionSpecifier(source)) {
704
+ await assertConfiguredNpmVersion(packageManager, source, userPath);
705
+ }
706
+ } catch (error) {
707
+ if (required) throw error;
708
+ // A best-effort default that cannot be verified is skipped, not loaded
709
+ // and not fatal. Silent by design: an installed-but-broken package also
710
+ // fails in the parent's own extension inventory, where Pi surfaces it;
711
+ // this path only mirrors a signal the user has already seen.
712
+ continue;
602
713
  }
603
714
  paths.add(userPath);
715
+ if (!required) bestEffortPaths.add(userPath);
604
716
  }
605
717
 
606
- return [...paths];
718
+ return { paths: [...paths], bestEffortPaths };
607
719
  }
608
720
 
609
721
  /**
@@ -646,14 +758,16 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
646
758
  .map(([provider, entries]) => [provider, [...entries]] as const),
647
759
  );
648
760
  const providerConfigs = options.providerConfigs ?? [];
649
- const requestedExtensions = getSubagentProviderExtensionsForProvider(
761
+ const requestedExtensions = getSubagentProviderExtensionSourcesForProvider(
650
762
  options.modelProvider,
651
763
  );
652
764
 
653
- // Resolve provider extensions before deciding whether to use the cache. This
654
- // fails closed for missing sources while keeping both package lookup and child
655
- // resource loading isolated from executable project settings.
765
+ // Resolve provider extensions before deciding whether to use the cache.
766
+ // User-configured sources fail closed when missing; shipped defaults are
767
+ // best-effort and silently drop instead. Both package lookup and child
768
+ // resource loading stay isolated from executable project settings.
656
769
  let additionalExtensionPaths: string[] = [];
770
+ let bestEffortExtensionRoots = new Set<string>();
657
771
  if (requestedExtensions.length > 0) {
658
772
  // Package lookup is a user-scope trust boundary. Pi's legacy npm fallback
659
773
  // may execute the configured npmCommand to discover the global npm root,
@@ -663,12 +777,14 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
663
777
  agentDir,
664
778
  { projectTrusted: false },
665
779
  );
666
- additionalExtensionPaths = await getProviderExtensionPaths(
780
+ const resolution = await getProviderExtensionPaths(
667
781
  options.modelProvider,
668
782
  options.cwd,
669
783
  agentDir,
670
784
  packageLookupSettingsManager,
671
785
  );
786
+ additionalExtensionPaths = resolution.paths;
787
+ bestEffortExtensionRoots = resolution.bestEffortPaths;
672
788
  }
673
789
 
674
790
  // Provider configs may contain functions (custom stream/OAuth handlers), so a
@@ -731,65 +847,111 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
731
847
  if (testRetryBaseMs !== undefined) {
732
848
  installFastRetry(resolvedSettingsManager, testRetryBaseMs);
733
849
  }
734
- const resourceLoader = new DefaultResourceLoader({
735
- cwd: options.cwd,
736
- agentDir,
737
- settingsManager: resolvedSettingsManager,
738
- // Subagents are headless workers — they must not load the parent's
739
- // interactive extension inventory. The only paths supplied here are the
740
- // explicitly allowlisted, user-scoped provider extensions.
741
- noExtensions: true,
742
- // Global AGENTS.md files describe the parent harness, not the delegated
743
- // task. Keep cwd/ancestor project context discovery, but remove Pi's
744
- // global file and the legacy ~/.agents equivalent. This override also
745
- // handles symlinked global files because it compares discovered paths.
746
- agentsFilesOverride: ({ agentsFiles }) => ({
747
- agentsFiles: agentsFiles.filter(
748
- ({ path: contextPath }) =>
749
- !isExcludedGlobalContextFile(contextPath, agentDir),
750
- ),
751
- }),
752
- ...(additionalExtensionPaths.length ? { additionalExtensionPaths } : {}),
753
- // When a named agent supplies a custom prompt, it becomes the loader's
754
- // customPrompt overriding the default system prompt AgentSession would
755
- // otherwise build. `systemPrompt` (the source) wins over file discovery.
756
- ...(options.systemPrompt !== undefined
757
- ? { systemPrompt: options.systemPrompt }
758
- : {}),
759
- });
760
- await resourceLoader.reload();
761
-
762
- const extensionsResult = resourceLoader.getExtensions();
763
- const extensionErrors = extensionsResult.errors;
764
- const loadedExtensionPaths = extensionsResult.extensions.map(
765
- (extension) => extension.resolvedPath || extension.path,
766
- );
767
- // A package can resolve successfully while exposing only skills/prompts,
768
- // or a malformed manifest can expose no loadable extension at all. Treat
769
- // that as a failed provider integration rather than silently delegating
770
- // without the safety-critical behavior the allowlist requested.
771
- const missingExtensionRoots = additionalExtensionPaths.filter(
772
- (root) =>
773
- !loadedExtensionPaths.some((extensionPath) =>
774
- isPathWithinDirectory(root, extensionPath),
775
- ),
776
- );
777
- if (extensionErrors.length > 0 || missingExtensionRoots.length > 0) {
850
+ // Best-effort default sources that fail to load are dropped and the loader
851
+ // is rebuilt without them, so the subagent runs extension-free on Pi's
852
+ // native compaction — silently, per the drop-site rationale above.
853
+ // User-configured sources still fail closed below.
854
+ //
855
+ // Loop invariant: pi's ResourceLoader.reload() never *throws* for an
856
+ // extension's own failure its loader wraps module import AND factory
857
+ // invocation in try/catch and returns them as `extensionsResult.errors`
858
+ // (verified in pi 0.80.x, core/extensions/loader.ts). A reload() throw is
859
+ // therefore environmental (settings reload, package resolution) and not
860
+ // attributable to any supplied root; letting it propagate is correct even
861
+ // when best-effort roots are present.
862
+ let extensionPaths = additionalExtensionPaths;
863
+ let resourceLoader: DefaultResourceLoader;
864
+ for (;;) {
865
+ resourceLoader = new DefaultResourceLoader({
866
+ cwd: options.cwd,
867
+ agentDir,
868
+ settingsManager: resolvedSettingsManager,
869
+ // Subagents are headless workers they must not load the parent's
870
+ // interactive extension inventory. The only paths supplied here are
871
+ // the explicitly allowlisted, user-scoped provider extensions.
872
+ noExtensions: true,
873
+ // Global AGENTS.md files describe the parent harness, not the
874
+ // delegated task. Keep cwd/ancestor project context discovery, but
875
+ // remove Pi's global file and the legacy ~/.agents equivalent. This
876
+ // override also handles symlinked global files because it compares
877
+ // discovered paths.
878
+ agentsFilesOverride: ({ agentsFiles }) => ({
879
+ agentsFiles: agentsFiles.filter(
880
+ ({ path: contextPath }) =>
881
+ !isExcludedGlobalContextFile(contextPath, agentDir),
882
+ ),
883
+ }),
884
+ ...(extensionPaths.length
885
+ ? { additionalExtensionPaths: extensionPaths }
886
+ : {}),
887
+ // When a named agent supplies a custom prompt, it becomes the loader's
888
+ // customPrompt — overriding the default system prompt AgentSession
889
+ // would otherwise build. `systemPrompt` (the source) wins over file
890
+ // discovery.
891
+ ...(options.systemPrompt !== undefined
892
+ ? { systemPrompt: options.systemPrompt }
893
+ : {}),
894
+ });
895
+ await resourceLoader.reload();
896
+
897
+ const extensionsResult = resourceLoader.getExtensions();
898
+ const extensionErrors = extensionsResult.errors;
899
+ const loadedExtensionPaths = extensionsResult.extensions.map(
900
+ (extension) => extension.resolvedPath || extension.path,
901
+ );
902
+ // A package can resolve successfully while exposing only skills/prompts,
903
+ // or a malformed manifest can expose no loadable extension at all.
904
+ const missingExtensionRoots = extensionPaths.filter(
905
+ (root) =>
906
+ !loadedExtensionPaths.some((extensionPath) =>
907
+ isPathWithinDirectory(root, extensionPath),
908
+ ),
909
+ );
778
910
  const failedRoots = new Set(
779
911
  missingExtensionRoots.concat(
780
- additionalExtensionPaths.filter((root) =>
912
+ extensionPaths.filter((root) =>
781
913
  extensionErrors.some((error) =>
782
914
  isPathWithinDirectory(root, error.path),
783
915
  ),
784
916
  ),
785
917
  ),
786
918
  );
787
- const failureCount = Math.max(failedRoots.size, extensionErrors.length);
788
- const providerName =
789
- options.modelProvider?.trim() || "the selected provider";
790
- throw new Error(
791
- `Failed to load ${failureCount} allowlisted provider extension(s) for ${providerName}; delegation stopped instead of running without the required integration.`,
919
+ // An error inside a best-effort root is attributable to that root; any
920
+ // other error (including one no supplied root claims) stays fatal.
921
+ const fatalErrors = extensionErrors.filter(
922
+ (error) =>
923
+ ![...bestEffortExtensionRoots].some((root) =>
924
+ isPathWithinDirectory(root, error.path),
925
+ ),
926
+ );
927
+ const fatalRoots = [...failedRoots].filter(
928
+ (root) => !bestEffortExtensionRoots.has(root),
929
+ );
930
+ if (fatalErrors.length > 0 || fatalRoots.length > 0) {
931
+ const failureCount = Math.max(fatalRoots.length, fatalErrors.length);
932
+ const providerName =
933
+ options.modelProvider?.trim() || "the selected provider";
934
+ throw new Error(
935
+ `Failed to load ${failureCount} allowlisted provider extension(s) for ${providerName}; delegation stopped instead of running without the required integration.`,
936
+ );
937
+ }
938
+ const droppableRoots = [...failedRoots].filter((root) =>
939
+ bestEffortExtensionRoots.has(root),
792
940
  );
941
+ if (droppableRoots.length === 0) break;
942
+ extensionPaths = extensionPaths.filter(
943
+ (root) => !droppableRoots.includes(root),
944
+ );
945
+ }
946
+
947
+ // Positive visibility for the invisible-by-design path: surviving
948
+ // best-effort roots each produced at least one loaded extension, and
949
+ // that changes subagent behavior without any user action — the one
950
+ // state worth a notice. Once per process per provider+root.
951
+ for (const root of extensionPaths) {
952
+ if (bestEffortExtensionRoots.has(root)) {
953
+ noticeProviderExtensionLoaded(options.modelProvider, root);
954
+ }
793
955
  }
794
956
 
795
957
  return {
@@ -840,6 +1002,7 @@ export function invalidateHostDepsCache(): void {
840
1002
  hostDepsCacheGeneration++;
841
1003
  hostDepsCache.clear();
842
1004
  hostDepsInflight.clear();
1005
+ missingBestEffortNpmCache.clear();
843
1006
  }
844
1007
 
845
1008
  /** Test-only alias retained for existing test setup. */
@@ -860,6 +1023,7 @@ export function _setModelRuntimeFactoryForTesting(
860
1023
  testModelRuntimeFactory = factory;
861
1024
  hostDepsCache.clear();
862
1025
  hostDepsInflight.clear();
1026
+ missingBestEffortNpmCache.clear();
863
1027
  }
864
1028
 
865
1029
  /**
@@ -883,6 +1047,7 @@ export function _setHostRetryBaseMsForTesting(
883
1047
  // ensures newly-built ones come back unpatched.
884
1048
  hostDepsCache.clear();
885
1049
  hostDepsInflight.clear();
1050
+ missingBestEffortNpmCache.clear();
886
1051
  return;
887
1052
  }
888
1053
  for (const deps of hostDepsCache.values()) {
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.8",
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 ────────────────