@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 +22 -7
- package/agents.ts +140 -14
- package/config.ts +84 -9
- package/extension.ts +11 -1
- package/host.ts +243 -78
- package/manual.ts +40 -6
- package/package.json +1 -1
- package/task-resolution.ts +29 -3
- package/types.ts +9 -1
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
152
|
-
defines its name, description,
|
|
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
|
|
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
|
|
260
|
-
thinking: VALID_THINKING.has(
|
|
261
|
-
? (
|
|
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.
|
|
334
|
+
// to parent-model inheritance. Other values are stored trimmed.
|
|
331
335
|
model:
|
|
332
|
-
|
|
336
|
+
model && model.toLowerCase() === "inherit"
|
|
333
337
|
? undefined
|
|
334
|
-
:
|
|
335
|
-
thinking: VALID_THINKING.has(
|
|
336
|
-
? (
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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 (
|
|
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
|
-
|
|
134
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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<
|
|
517
|
-
// Provider-key normalization (trim + lowercase) lives in `config.ts` —
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
696
|
+
const bestEffortPaths = new Set<string>();
|
|
697
|
+
for (const { source, required } of requested) {
|
|
591
698
|
const userPath = installedPaths.get(source);
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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 =
|
|
761
|
+
const requestedExtensions = getSubagentProviderExtensionSourcesForProvider(
|
|
650
762
|
options.modelProvider,
|
|
651
763
|
);
|
|
652
764
|
|
|
653
|
-
// Resolve provider extensions before deciding whether to use the cache.
|
|
654
|
-
//
|
|
655
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
912
|
+
extensionPaths.filter((root) =>
|
|
781
913
|
extensionErrors.some((error) =>
|
|
782
914
|
isPathWithinDirectory(root, error.path),
|
|
783
915
|
),
|
|
784
916
|
),
|
|
785
917
|
),
|
|
786
918
|
);
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
package/task-resolution.ts
CHANGED
|
@@ -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
|
|
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 ??
|
|
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
|
|
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 ────────────────
|