@bermudi/pi-delegate 0.1.8 → 0.1.9
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/config.ts +84 -9
- package/extension.ts +11 -1
- package/host.ts +243 -78
- package/package.json +1 -1
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()) {
|