@bermudi/pi-delegate 0.1.9 → 0.1.11

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/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
- * provider-specific integrations (best-effort for shipped defaults). Those
19
+ * provider-specific integrations (best-effort for shipped defaults); resolving
20
+ * and verifying that allowlist lives in `provider-extensions.ts`. Those
20
21
  * extension-bearing dependencies are
21
22
  * deliberately built per session: `AgentSession._buildRuntime` hands the
22
23
  * loader's `extensionsResult.runtime` to a new `ExtensionRunner`, whose
@@ -35,20 +36,9 @@
35
36
  * unless explicitly allowlisted. The parent's `ctx.modelRegistry` is also
36
37
  * threaded by the caller for model selection in task-resolution.
37
38
  */
38
- import { execFileSync } from "node:child_process";
39
39
  import { homedir } from "node:os";
40
- import { existsSync, realpathSync } from "node:fs";
40
+ import { join, relative, resolve } from "node:path";
41
41
  import {
42
- basename,
43
- dirname,
44
- isAbsolute,
45
- join,
46
- relative,
47
- resolve,
48
- sep,
49
- } from "node:path";
50
- import {
51
- DefaultPackageManager,
52
42
  DefaultResourceLoader,
53
43
  ModelRuntime,
54
44
  SettingsManager,
@@ -56,10 +46,14 @@ import {
56
46
  type ProviderConfig,
57
47
  type ResourceLoader,
58
48
  } from "@earendil-works/pi-coding-agent";
49
+ import { getSubagentProviderExtensionMap } from "./config.ts";
50
+ import { GenerationCache } from "./host-cache.ts";
59
51
  import {
60
- getSubagentProviderExtensionMap,
61
- getSubagentProviderExtensionSourcesForProvider,
62
- } from "./config.ts";
52
+ clearMissingProviderExtensionCache,
53
+ getProviderExtensionPaths,
54
+ noticeProviderExtensionLoaded,
55
+ partitionExtensionLoadFailures,
56
+ } from "./provider-extensions.ts";
63
57
 
64
58
  export interface HostDeps {
65
59
  modelRuntime: ModelRuntime;
@@ -97,42 +91,7 @@ export interface HostDepsOptions {
97
91
  systemPrompt?: string;
98
92
  }
99
93
 
100
- const hostDepsCache = new Map<string, HostDeps>();
101
- /** In-flight builds, so concurrent calls for the same key share one reload(). */
102
- const hostDepsInflight = new Map<string, Promise<HostDeps>>();
103
- /** Prevent a pre-invalidation build from repopulating or clearing newer state. */
104
- let hostDepsCacheGeneration = 0;
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
-
114
- function canonicalPath(candidate: string): string {
115
- try {
116
- return realpathSync(candidate);
117
- } catch {
118
- // The caller normally passes an existing installed path. Keep a lexical
119
- // fallback for a race where it disappears between lookup and validation.
120
- return resolve(candidate);
121
- }
122
- }
123
-
124
- function isPathWithinDirectory(directory: string, candidate: string): boolean {
125
- const relativePath = relative(
126
- canonicalPath(directory),
127
- canonicalPath(candidate),
128
- );
129
- return (
130
- relativePath === "" ||
131
- (relativePath !== ".." &&
132
- !relativePath.startsWith(`..${sep}`) &&
133
- !isAbsolute(relativePath))
134
- );
135
- }
94
+ const hostDepsCache = new GenerationCache<HostDeps>();
136
95
 
137
96
  const CONTEXT_FILE_NAMES = new Set([
138
97
  "agents.override.md",
@@ -161,563 +120,6 @@ function isExcludedGlobalContextFile(
161
120
  });
162
121
  }
163
122
 
164
- /**
165
- * Whether a managed package's canonical target remains in a user install root.
166
- *
167
- * Pi's managed user installs live below `agentDir`; its legacy npm fallback
168
- * lives below a global `node_modules` directory. Deriving the latter from the
169
- * returned lexical path avoids invoking npm merely to validate a path, while
170
- * still rejecting a package-directory symlink whose canonical target escapes
171
- * that install root.
172
- */
173
- function isTrustedManagedTarget(agentDir: string, userPath: string): boolean {
174
- const resolvedUserPath = canonicalPath(userPath);
175
- if (isPathWithinDirectory(agentDir, resolvedUserPath)) return true;
176
-
177
- const lexicalPath = resolve(userPath);
178
- const nodeModulesMarker = `${sep}node_modules${sep}`;
179
- const markerIndex = lexicalPath.lastIndexOf(nodeModulesMarker);
180
- if (markerIndex < 0) return false;
181
- const nodeModulesEnd = markerIndex + nodeModulesMarker.length - 1;
182
- const installRoot = lexicalPath.slice(0, nodeModulesEnd);
183
- return isPathWithinDirectory(installRoot, resolvedUserPath);
184
- }
185
-
186
- /**
187
- * Find the project boundary used by the extension trust check.
188
- *
189
- * `cwd` is allowed to be a package directory inside a larger checkout. Checking
190
- * only that exact directory makes a user-scope symlink into a sibling project
191
- * directory look safe. Git is the authoritative boundary when available;
192
- * marker directories provide a conservative fallback for projects that are not
193
- * Git worktrees. Returning undefined is intentional: callers then require a
194
- * canonical managed target to remain under the trusted user agent directory.
195
- */
196
- function findExtensionProjectRoot(cwd: string): string | undefined {
197
- try {
198
- const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
199
- cwd,
200
- encoding: "utf8",
201
- stdio: ["ignore", "pipe", "ignore"],
202
- }).trim();
203
- if (root) return canonicalPath(root);
204
- } catch {
205
- // Not every task cwd belongs to a Git worktree. Use project markers below.
206
- }
207
-
208
- let directory = canonicalPath(cwd);
209
- while (true) {
210
- if (
211
- existsSync(join(directory, ".pi", "settings.json")) ||
212
- existsSync(join(directory, ".pi", "agents")) ||
213
- (directory !== homedir() &&
214
- existsSync(join(directory, ".claude", "agents")))
215
- ) {
216
- return directory;
217
- }
218
- const parent = dirname(directory);
219
- if (parent === directory) break;
220
- directory = parent;
221
- }
222
- return undefined;
223
- }
224
-
225
- /** Match the package-manager prefixes that are not local filesystem paths. */
226
- function isLocalExtensionSource(source: string): boolean {
227
- const trimmed = source.trim().toLowerCase();
228
- return !["npm:", "git:", "github:", "http:", "https:", "ssh:"].some(
229
- (prefix) => trimmed.startsWith(prefix),
230
- );
231
- }
232
-
233
- /** Whether an npm source carries a version, range, or tag after its package name. */
234
- function hasNpmVersionSpecifier(source: string): boolean {
235
- const trimmed = source.trim();
236
- if (!trimmed.toLowerCase().startsWith("npm:")) return false;
237
- const spec = trimmed.slice("npm:".length).trim();
238
- const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
239
- return Boolean(match?.[2]?.trim());
240
- }
241
-
242
- /** Normalize the host/path identity emitted by Pi or read from Git. */
243
- function normalizeGitRepositoryIdentity(
244
- host: string,
245
- repoPath: string,
246
- ): { host: string; path: string } | undefined {
247
- const path = repoPath.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
248
- if (!host || !path) return undefined;
249
- return { host: host.toLowerCase(), path };
250
- }
251
-
252
- /** Parse the repository identity used by Pi's Git source parser. */
253
- function parseGitRepositoryIdentity(
254
- source: string,
255
- ): { host: string; path: string } | undefined {
256
- let candidate = source.trim();
257
- if (candidate.toLowerCase().startsWith("git:")) {
258
- candidate = candidate.slice("git:".length).trim();
259
- }
260
-
261
- let host: string;
262
- let repoPath: string;
263
- if (candidate.startsWith("git@")) {
264
- const colon = candidate.indexOf(":");
265
- if (colon < 0) return undefined;
266
- host = candidate.slice("git@".length, colon);
267
- repoPath = candidate.slice(colon + 1);
268
- } else if (/^[a-z][a-z\d+.-]*:\/\//i.test(candidate)) {
269
- try {
270
- const url = new URL(candidate);
271
- host = url.host;
272
- repoPath = url.pathname;
273
- } catch {
274
- return undefined;
275
- }
276
- } else {
277
- const slash = candidate.indexOf("/");
278
- if (slash < 0) return undefined;
279
- host = candidate.slice(0, slash);
280
- repoPath = candidate.slice(slash + 1);
281
- }
282
-
283
- // `@` is Pi's ref separator for the source forms that can also appear in
284
- // an origin URL. Do not treat `#` as a ref separator here: Pi can preserve
285
- // it as a literal path in generic/SCP forms, while URL parsing above already
286
- // removes a real URL fragment from `pathname`. Configured-source ref
287
- // semantics come from Pi's parsed source below.
288
- const refSeparator = repoPath.indexOf("@");
289
- if (refSeparator >= 0) repoPath = repoPath.slice(0, refSeparator);
290
- return normalizeGitRepositoryIdentity(host, repoPath);
291
- }
292
-
293
- function getGitRepositoryIdentity(
294
- source: string,
295
- ): { host: string; path: string } | undefined {
296
- const trimmed = source.trim();
297
- if (
298
- isLocalExtensionSource(trimmed) ||
299
- trimmed.toLowerCase().startsWith("npm:")
300
- ) {
301
- return undefined;
302
- }
303
- return parseGitRepositoryIdentity(trimmed);
304
- }
305
-
306
- /**
307
- * Read the configured Git identity and ref from Pi's own parsed package
308
- * source. The package manager's parser is private upstream, but
309
- * getInstalledPath() uses that same parser; keeping this call on the same seam
310
- * prevents validation from inventing a second (and subtly different) Git URL
311
- * grammar.
312
- *
313
- * In particular, Pi leaves `#release` in the parsed path for generic and SCP
314
- * sources, while hosted providers may interpret it as a ref. The parsed
315
- * host/path is therefore authoritative; never normalize the configured source
316
- * with a blanket `#` rule.
317
- */
318
- function getConfiguredGitSource(
319
- packageManager: DefaultPackageManager,
320
- source: string,
321
- ): { host: string; path: string; ref?: string } | undefined {
322
- if (getGitRepositoryIdentity(source) === undefined) return undefined;
323
-
324
- const internals =
325
- packageManager as unknown as Partial<PackageManagerConstraintInternals>;
326
- if (typeof internals.parseSource !== "function") {
327
- throw new Error(
328
- "This Pi version cannot verify a configured Git provider extension ref; delegation stopped.",
329
- );
330
- }
331
-
332
- let parsed: unknown;
333
- try {
334
- parsed = internals.parseSource.call(packageManager, source);
335
- } catch (error) {
336
- throw new Error(
337
- "A configured provider extension Git source could not be parsed; delegation stopped.",
338
- { cause: error },
339
- );
340
- }
341
-
342
- if (typeof parsed !== "object" || parsed === null) {
343
- throw new Error(
344
- "A configured provider extension Git source could not be verified; delegation stopped.",
345
- );
346
- }
347
- const parsedSource = parsed as {
348
- type?: unknown;
349
- host?: unknown;
350
- path?: unknown;
351
- ref?: unknown;
352
- pinned?: unknown;
353
- };
354
- if (parsedSource.type !== "git") {
355
- throw new Error(
356
- "A configured provider extension was not parsed as a Git source; delegation stopped.",
357
- );
358
- }
359
- if (
360
- typeof parsedSource.host !== "string" ||
361
- typeof parsedSource.path !== "string"
362
- ) {
363
- throw new Error(
364
- "A configured provider extension Git identity could not be verified; delegation stopped.",
365
- );
366
- }
367
- const repository = normalizeGitRepositoryIdentity(
368
- parsedSource.host,
369
- parsedSource.path,
370
- );
371
- if (!repository) {
372
- throw new Error(
373
- "A configured provider extension Git identity could not be verified; delegation stopped.",
374
- );
375
- }
376
- // `ref` and `pinned` are a security contract from Pi's private parser. If a
377
- // host upgrade drops either field, never reinterpret a pinned source as an
378
- // unpinned checkout and silently skip commit validation.
379
- if (typeof parsedSource.pinned !== "boolean") {
380
- throw new Error(
381
- "A configured provider extension Git pin state could not be verified; delegation stopped.",
382
- );
383
- }
384
- if (!parsedSource.pinned) {
385
- if (parsedSource.ref !== undefined) {
386
- throw new Error(
387
- "A configured provider extension Git ref could not be verified; delegation stopped.",
388
- );
389
- }
390
- return repository;
391
- }
392
- if (typeof parsedSource.ref !== "string" || parsedSource.ref.length === 0) {
393
- throw new Error(
394
- "A configured provider extension Git ref could not be verified; delegation stopped.",
395
- );
396
- }
397
- return { ...repository, ref: parsedSource.ref };
398
- }
399
-
400
- function gitOutput(installedPath: string, args: string[]): string {
401
- return execFileSync("git", args, {
402
- cwd: installedPath,
403
- encoding: "utf8",
404
- stdio: ["ignore", "pipe", "ignore"],
405
- }).trim();
406
- }
407
-
408
- /** Reject a user installation whose checkout or ref differs from the source. */
409
- function assertConfiguredGitInstallation(
410
- packageManager: DefaultPackageManager,
411
- source: string,
412
- installedPath: string,
413
- ): void {
414
- try {
415
- const configuredRepository = getConfiguredGitSource(packageManager, source);
416
- if (!configuredRepository) return;
417
-
418
- const installedRepository = parseGitRepositoryIdentity(
419
- gitOutput(installedPath, ["config", "--get", "remote.origin.url"]),
420
- );
421
- if (
422
- !installedRepository ||
423
- installedRepository.host !== configuredRepository.host ||
424
- installedRepository.path !== configuredRepository.path
425
- ) {
426
- throw new Error("checkout has a different origin");
427
- }
428
-
429
- const ref = configuredRepository.ref;
430
- if (!ref) return;
431
- const head = gitOutput(installedPath, ["rev-parse", "--verify", "HEAD"]);
432
- const target = gitOutput(installedPath, [
433
- "rev-parse",
434
- "--verify",
435
- "--end-of-options",
436
- `${ref}^{commit}`,
437
- ]);
438
- if (!head || head !== target) {
439
- throw new Error("checkout is at a different commit");
440
- }
441
- } catch (error) {
442
- throw new Error(
443
- "A configured provider extension is not checked out at its configured Git source or ref; delegation stopped.",
444
- { cause: error },
445
- );
446
- }
447
- }
448
-
449
- type PackageManagerConstraintInternals = {
450
- parseSource(source: string): unknown;
451
- installedNpmMatchesConfiguredVersion(
452
- source: unknown,
453
- installedPath: string,
454
- ): Promise<boolean>;
455
- };
456
-
457
- type ParsedNpmSource = {
458
- type?: unknown;
459
- range?: unknown;
460
- };
461
-
462
- /**
463
- * Ask Pi's package manager to apply its own npm range semantics without
464
- * duplicating semver logic or installing/updating anything during delegation.
465
- * These methods are private upstream implementation details, so fail closed if
466
- * a future Pi release removes or renames them rather than executing an
467
- * unverified installation.
468
- */
469
- async function assertConfiguredNpmVersion(
470
- packageManager: DefaultPackageManager,
471
- source: string,
472
- installedPath: string,
473
- ): Promise<void> {
474
- const internals =
475
- packageManager as unknown as Partial<PackageManagerConstraintInternals>;
476
- if (
477
- typeof internals.parseSource !== "function" ||
478
- typeof internals.installedNpmMatchesConfiguredVersion !== "function"
479
- ) {
480
- throw new Error(
481
- "This Pi version cannot verify a configured provider extension version; delegation stopped.",
482
- );
483
- }
484
-
485
- let parsed: unknown;
486
- try {
487
- parsed = internals.parseSource.call(packageManager, source);
488
- } catch (error) {
489
- throw new Error(
490
- "A configured provider extension version could not be parsed; delegation stopped.",
491
- { cause: error },
492
- );
493
- }
494
-
495
- const parsedNpm =
496
- typeof parsed === "object" && parsed !== null
497
- ? (parsed as ParsedNpmSource)
498
- : undefined;
499
- if (parsedNpm?.type !== "npm" || typeof parsedNpm.range !== "string") {
500
- // Pi treats npm tags such as `@latest` as an unconstrained source when
501
- // checking an installed package. They are registry aliases, not
502
- // verifiable local version constraints, so fail closed instead of
503
- // accepting any stale package at the same install path.
504
- throw new Error(
505
- "A configured provider extension uses an npm tag rather than a verifiable semver range; delegation stopped.",
506
- );
507
- }
508
-
509
- try {
510
- const matches = await internals.installedNpmMatchesConfiguredVersion.call(
511
- packageManager,
512
- parsed,
513
- installedPath,
514
- );
515
- if (!matches) {
516
- throw new Error(
517
- "installed package does not satisfy the configured version",
518
- );
519
- }
520
- } catch (error) {
521
- throw new Error(
522
- "A configured provider extension version does not match the installed user-scope package; delegation stopped.",
523
- { cause: error },
524
- );
525
- }
526
- }
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
-
591
- async function getProviderExtensionPaths(
592
- provider: string | undefined,
593
- cwd: string,
594
- agentDir: string,
595
- packageLookupSettingsManager: SettingsManager,
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>() };
606
-
607
- const packageManager = new DefaultPackageManager({
608
- cwd,
609
- agentDir,
610
- settingsManager: packageLookupSettingsManager,
611
- });
612
- const projectRoot = findExtensionProjectRoot(cwd);
613
-
614
- const validateInstalledPath = (source: string, userPath: string): void => {
615
- const resolvedUserPath = canonicalPath(userPath);
616
- const localSource = isLocalExtensionSource(source);
617
-
618
- // A local source is allowed only when it resolves under the user agent
619
- // directory. This closes the absolute/`..` path escape that a package
620
- // manager's user-scope lookup otherwise permits.
621
- if (localSource && !isPathWithinDirectory(agentDir, resolvedUserPath)) {
622
- throw new Error(
623
- "A configured provider extension resolves outside the user agent directory; project-local extension paths are not allowed.",
624
- );
625
- }
626
-
627
- // `cwd` may be a nested package directory. Validate against the repository
628
- // (or project-marker) root, not just that exact directory, so a symlink from
629
- // a user-scope managed package into a sibling such as /repo/extensions is
630
- // never turned into executable subagent code. This check deliberately runs
631
- // for local sources too: placing the user agent directory inside a project
632
- // must not turn project code into a trusted extension.
633
- if (projectRoot && isPathWithinDirectory(projectRoot, resolvedUserPath)) {
634
- throw new Error(
635
- "A configured provider extension resolves inside the project directory; project-local extension paths are not allowed.",
636
- );
637
- }
638
-
639
- // Managed sources must remain inside a canonical user install root as well.
640
- // This is the conservative fallback when no project boundary is
641
- // discoverable, and it also protects legacy global npm installs from a
642
- // package-directory symlink that escapes their node_modules root.
643
- if (!localSource && !isTrustedManagedTarget(agentDir, userPath)) {
644
- throw new Error(
645
- "A configured provider extension cannot be verified as a trusted user installation; project-local extension paths are not allowed.",
646
- );
647
- }
648
-
649
- assertConfiguredGitInstallation(packageManager, source, userPath);
650
- };
651
-
652
- const installedPaths = new Map<string, string>();
653
- const missing: string[] = [];
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
- }
674
- // Deliberately resolve only the user scope. Project-local packages are
675
- // untrusted input and must never become executable subagent extensions.
676
- const userPath = packageManager.getInstalledPath(source, "user");
677
- if (!userPath) {
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.
682
- continue;
683
- }
684
- installedPaths.set(source, userPath);
685
- }
686
-
687
- if (missing.length > 0) {
688
- const providerName = provider?.trim() || "the selected provider";
689
- const sourceLabel = missing.length === 1 ? "source" : "sources";
690
- throw new Error(
691
- `Provider extension(s) for ${providerName} are not installed in the user scope (${missing.length} configured ${sourceLabel}). Install the configured sources with Pi before delegating; project-local installations are not allowed.`,
692
- );
693
- }
694
-
695
- const paths = new Set<string>();
696
- const bestEffortPaths = new Set<string>();
697
- for (const { source, required } of requested) {
698
- const userPath = installedPaths.get(source);
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;
713
- }
714
- paths.add(userPath);
715
- if (!required) bestEffortPaths.add(userPath);
716
- }
717
-
718
- return { paths: [...paths], bestEffortPaths };
719
- }
720
-
721
123
  /**
722
124
  * Test-only flag: when set, every newly-built settingsManager reports a small
723
125
  * retry base delay so retry integration tests don't sleep real seconds. Set
@@ -751,51 +153,54 @@ let testModelRuntimeFactory: (() => Promise<ModelRuntime>) | undefined;
751
153
  */
752
154
  export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
753
155
  const agentDir = options.agentDir ?? getAgentDir();
754
- const providerExtensions = getSubagentProviderExtensionMap();
755
- const providerExtensionSignature = JSON.stringify(
756
- Object.entries(providerExtensions)
757
- .sort(([a], [b]) => a.localeCompare(b))
758
- .map(([provider, entries]) => [provider, [...entries]] as const),
759
- );
760
156
  const providerConfigs = options.providerConfigs ?? [];
761
- const requestedExtensions = getSubagentProviderExtensionSourcesForProvider(
762
- options.modelProvider,
763
- );
764
157
 
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.
769
- let additionalExtensionPaths: string[] = [];
770
- let bestEffortExtensionRoots = new Set<string>();
771
- if (requestedExtensions.length > 0) {
772
- // Package lookup is a user-scope trust boundary. Pi's legacy npm fallback
773
- // may execute the configured npmCommand to discover the global npm root,
774
- // so project settings must never participate.
775
- const packageLookupSettingsManager = SettingsManager.create(
776
- options.cwd,
777
- agentDir,
778
- { projectTrusted: false },
779
- );
780
- const resolution = await getProviderExtensionPaths(
158
+ // Resolve provider extensions before deciding whether to use the cache: the
159
+ // answer determines cacheability. User-configured sources fail closed when
160
+ // missing; shipped defaults silently drop instead.
161
+ const { paths: extensionRoots, bestEffortPaths: bestEffortRoots } =
162
+ await getProviderExtensionPaths(
781
163
  options.modelProvider,
782
164
  options.cwd,
783
165
  agentDir,
784
- packageLookupSettingsManager,
785
166
  );
786
- additionalExtensionPaths = resolution.paths;
787
- bestEffortExtensionRoots = resolution.bestEffortPaths;
788
- }
789
167
 
790
168
  // Provider configs may contain functions (custom stream/OAuth handlers), so a
791
169
  // stringified value cannot safely identify them. Do not cache any call that
792
170
  // registers one; each call gets the exact config object supplied by its
793
171
  // caller. The same rule is used for extension-bearing calls because their
794
172
  // extension runtime is mutable and session-owned.
795
- const cacheable =
796
- providerConfigs.length === 0 && additionalExtensionPaths.length === 0;
797
- const cacheGeneration = hostDepsCacheGeneration;
798
- const key = JSON.stringify({
173
+ const cacheable = providerConfigs.length === 0 && extensionRoots.length === 0;
174
+
175
+ return hostDepsCache.resolve(
176
+ hostDepsCacheKey(options, agentDir),
177
+ cacheable,
178
+ async () => {
179
+ const modelRuntime = await buildModelRuntime(agentDir, providerConfigs);
180
+ const settingsManager = SettingsManager.create(options.cwd, agentDir, {
181
+ projectTrusted: false,
182
+ });
183
+ if (testRetryBaseMs !== undefined) {
184
+ installFastRetry(settingsManager, testRetryBaseMs);
185
+ }
186
+ const resourceLoader = await loadChildResources(options, agentDir, {
187
+ settingsManager,
188
+ extensionRoots,
189
+ bestEffortRoots,
190
+ });
191
+ return { modelRuntime, settingsManager, resourceLoader };
192
+ },
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Cache identity for a set of host deps. Everything that changes what
198
+ * `reload()` discovers must appear here — including the configured extension
199
+ * allowlist, since editing it changes what a subsequent build would inject.
200
+ */
201
+ function hostDepsCacheKey(options: HostDepsOptions, agentDir: string): string {
202
+ const providerExtensions = getSubagentProviderExtensionMap();
203
+ return JSON.stringify({
799
204
  agentDir,
800
205
  cwd: options.cwd,
801
206
  systemPrompt:
@@ -803,181 +208,135 @@ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
803
208
  ? { source: "discovered" }
804
209
  : { source: "explicit", value: options.systemPrompt },
805
210
  modelProvider: options.modelProvider ?? "",
806
- providerExtensionSignature,
211
+ providerExtensionSignature: Object.entries(providerExtensions)
212
+ .sort(([a], [b]) => a.localeCompare(b))
213
+ .map(([provider, entries]) => [provider, [...entries]] as const),
807
214
  });
215
+ }
808
216
 
809
- if (cacheable) {
810
- const cached = hostDepsCache.get(key);
811
- if (cached) return cached;
812
-
813
- // Another call is already building this key await its promise.
814
- const inflight = hostDepsInflight.get(key);
815
- if (inflight) return inflight;
217
+ /**
218
+ * Canonical model/auth runtime — the 0.80.8+ successor to the separate
219
+ * `authStorage` + `modelRegistry` options. It is shared only on the cacheable,
220
+ * extension-free path; provider-specific sessions get their own runtime so all
221
+ * stateful host dependencies have the same ownership. Reads the same
222
+ * `~/.pi/agent/{auth,models}.json` the parent uses, so stored credentials stay
223
+ * consistent.
224
+ */
225
+ async function buildModelRuntime(
226
+ agentDir: string,
227
+ providerConfigs: ReadonlyArray<readonly [string, ProviderConfig]>,
228
+ ): Promise<ModelRuntime> {
229
+ // In tests, `_setModelRuntimeFactoryForTesting` can substitute a runtime.
230
+ const modelRuntime = testModelRuntimeFactory
231
+ ? await testModelRuntimeFactory()
232
+ : await ModelRuntime.create({
233
+ authPath: join(agentDir, "auth.json"),
234
+ modelsPath: join(agentDir, "models.json"),
235
+ // Subagents receive an explicit model (resolved by the parent), so they
236
+ // never need remote model-catalog discovery. Skipping the network
237
+ // availability refresh makes the first call per cwd faster and
238
+ // offline-safe; auth (getAuth) still reads auth.json directly.
239
+ allowModelNetwork: false,
240
+ });
241
+ for (const [providerId, config] of providerConfigs) {
242
+ modelRuntime.registerProvider(providerId, config);
816
243
  }
244
+ return modelRuntime;
245
+ }
817
246
 
818
- const build = async (): Promise<HostDeps> => {
819
- // Canonical model/auth runtime the 0.80.8+ successor to the separate
820
- // `authStorage` + `modelRegistry` options. It is shared only for the
821
- // cacheable, extension-free path; provider-specific sessions get their own
822
- // runtime so all stateful host dependencies have the same ownership.
823
- // Reads the same ~/.pi/agent/{auth,models}.json the parent uses, so stored
824
- // credentials stay consistent. Runtime-only provider registrations are
825
- // layered on just below.
826
- // In tests, `_setModelRuntimeFactoryForTesting` can substitute a runtime.
827
- const modelRuntime = testModelRuntimeFactory
828
- ? await testModelRuntimeFactory()
829
- : await ModelRuntime.create({
830
- authPath: join(agentDir, "auth.json"),
831
- modelsPath: join(agentDir, "models.json"),
832
- // Subagents receive an explicit model (resolved by the parent), so they
833
- // never need remote model-catalog discovery. Skipping the network
834
- // availability refresh makes the first call per cwd faster and
835
- // offline-safe; auth (getAuth) still reads auth.json directly.
836
- allowModelNetwork: false,
837
- });
838
- for (const [providerId, config] of providerConfigs) {
839
- modelRuntime.registerProvider(providerId, config);
840
- }
841
-
842
- const resolvedSettingsManager = SettingsManager.create(
843
- options.cwd,
247
+ /**
248
+ * Build the child ResourceLoader, retrying without any best-effort extension
249
+ * root that failed to load.
250
+ *
251
+ * A dropped default leaves the subagent extension-free on Pi's native
252
+ * compaction silently, per the drop-site rationale in `provider-extensions`.
253
+ * User-configured roots still fail closed.
254
+ *
255
+ * Loop invariant: pi's `ResourceLoader.reload()` never *throws* for an
256
+ * extension's own failure — its loader wraps module import AND factory
257
+ * invocation in try/catch and returns them as `extensionsResult.errors`
258
+ * (verified in pi 0.80.x, core/extensions/loader.ts). A reload() throw is
259
+ * therefore environmental (settings reload, package resolution) and not
260
+ * attributable to any supplied root; letting it propagate is correct even when
261
+ * best-effort roots are present.
262
+ */
263
+ async function loadChildResources(
264
+ options: HostDepsOptions,
265
+ agentDir: string,
266
+ deps: {
267
+ settingsManager: SettingsManager;
268
+ extensionRoots: readonly string[];
269
+ bestEffortRoots: ReadonlySet<string>;
270
+ },
271
+ ): Promise<ResourceLoader> {
272
+ let extensionPaths = [...deps.extensionRoots];
273
+ for (;;) {
274
+ const resourceLoader = new DefaultResourceLoader({
275
+ cwd: options.cwd,
844
276
  agentDir,
845
- { projectTrusted: false },
846
- );
847
- if (testRetryBaseMs !== undefined) {
848
- installFastRetry(resolvedSettingsManager, testRetryBaseMs);
849
- }
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
- );
910
- const failedRoots = new Set(
911
- missingExtensionRoots.concat(
912
- extensionPaths.filter((root) =>
913
- extensionErrors.some((error) =>
914
- isPathWithinDirectory(root, error.path),
915
- ),
916
- ),
277
+ settingsManager: deps.settingsManager,
278
+ // Subagents are headless workers — they must not load the parent's
279
+ // interactive extension inventory. The only paths supplied here are
280
+ // the explicitly allowlisted, user-scoped provider extensions.
281
+ noExtensions: true,
282
+ // Global AGENTS.md files describe the parent harness, not the
283
+ // delegated task. Keep cwd/ancestor project context discovery, but
284
+ // remove Pi's global file and the legacy ~/.agents equivalent. This
285
+ // override also handles symlinked global files because it compares
286
+ // discovered paths.
287
+ agentsFilesOverride: ({ agentsFiles }) => ({
288
+ agentsFiles: agentsFiles.filter(
289
+ ({ path: contextPath }) =>
290
+ !isExcludedGlobalContextFile(contextPath, agentDir),
917
291
  ),
292
+ }),
293
+ ...(extensionPaths.length
294
+ ? { additionalExtensionPaths: extensionPaths }
295
+ : {}),
296
+ // When a named agent supplies a custom prompt, it becomes the loader's
297
+ // customPrompt — overriding the default system prompt AgentSession
298
+ // would otherwise build. `systemPrompt` (the source) wins over file
299
+ // discovery.
300
+ ...(options.systemPrompt !== undefined
301
+ ? { systemPrompt: options.systemPrompt }
302
+ : {}),
303
+ });
304
+ await resourceLoader.reload();
305
+
306
+ const extensionsResult = resourceLoader.getExtensions();
307
+ const { fatalCount, droppableRoots } = partitionExtensionLoadFailures({
308
+ extensionPaths,
309
+ loadedExtensionPaths: extensionsResult.extensions.map(
310
+ (extension) => extension.resolvedPath || extension.path,
311
+ ),
312
+ extensionErrors: extensionsResult.errors,
313
+ bestEffortRoots: deps.bestEffortRoots,
314
+ });
315
+ if (fatalCount > 0) {
316
+ const providerName =
317
+ options.modelProvider?.trim() || "the selected provider";
318
+ throw new Error(
319
+ `Failed to load ${fatalCount} allowlisted provider extension(s) for ${providerName}; delegation stopped instead of running without the required integration.`,
918
320
  );
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),
940
- );
941
- if (droppableRoots.length === 0) break;
321
+ }
322
+
323
+ if (droppableRoots.length > 0) {
942
324
  extensionPaths = extensionPaths.filter(
943
325
  (root) => !droppableRoots.includes(root),
944
326
  );
327
+ continue;
945
328
  }
946
329
 
947
330
  // 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.
331
+ // best-effort roots each produced at least one loaded extension, and that
332
+ // changes subagent behavior without any user action — the one state worth
333
+ // a notice. Once per process per provider+root.
951
334
  for (const root of extensionPaths) {
952
- if (bestEffortExtensionRoots.has(root)) {
335
+ if (deps.bestEffortRoots.has(root)) {
953
336
  noticeProviderExtensionLoaded(options.modelProvider, root);
954
337
  }
955
338
  }
956
-
957
- return {
958
- modelRuntime,
959
- settingsManager: resolvedSettingsManager,
960
- resourceLoader,
961
- };
962
- };
963
-
964
- if (!cacheable) return build();
965
-
966
- const promise = build().then((deps) => {
967
- if (hostDepsCacheGeneration === cacheGeneration) {
968
- hostDepsCache.set(key, deps);
969
- }
970
- return deps;
971
- });
972
- hostDepsInflight.set(key, promise);
973
- try {
974
- return await promise;
975
- } finally {
976
- // An invalidation can let a newer generation install its own in-flight
977
- // build for the same key. Never let the older promise delete that marker.
978
- if (hostDepsInflight.get(key) === promise) {
979
- hostDepsInflight.delete(key);
980
- }
339
+ return resourceLoader;
981
340
  }
982
341
  }
983
342
 
@@ -999,10 +358,8 @@ function installFastRetry(sm: SettingsManager, baseDelayMs: number): void {
999
358
  * dependencies; clearing the maps never mutates live AgentSessions.
1000
359
  */
1001
360
  export function invalidateHostDepsCache(): void {
1002
- hostDepsCacheGeneration++;
1003
- hostDepsCache.clear();
1004
- hostDepsInflight.clear();
1005
- missingBestEffortNpmCache.clear();
361
+ hostDepsCache.invalidate();
362
+ clearMissingProviderExtensionCache();
1006
363
  }
1007
364
 
1008
365
  /** Test-only alias retained for existing test setup. */
@@ -1014,16 +371,14 @@ export function _resetHostDepsCacheForTesting(): void {
1014
371
  * Test-only: substitute the ModelRuntime factory. Pass a factory returning a
1015
372
  * pre-authenticated runtime (e.g. the parent session's `modelRuntime`) so
1016
373
  * subagents reuse it and a test can stub auth; pass `undefined` to restore the
1017
- * real `ModelRuntime.create` path. Clears the deps cache either way so the
374
+ * real `ModelRuntime.create` path. Invalidates the deps cache either way so the
1018
375
  * next build respects the change.
1019
376
  */
1020
377
  export function _setModelRuntimeFactoryForTesting(
1021
378
  factory: (() => Promise<ModelRuntime>) | undefined,
1022
379
  ): void {
1023
380
  testModelRuntimeFactory = factory;
1024
- hostDepsCache.clear();
1025
- hostDepsInflight.clear();
1026
- missingBestEffortNpmCache.clear();
381
+ invalidateHostDepsCache();
1027
382
  }
1028
383
 
1029
384
  /**
@@ -1045,9 +400,7 @@ export function _setHostRetryBaseMsForTesting(
1045
400
  if (baseDelayMs === undefined) {
1046
401
  // Restore: drop patched managers. The `testRetryBaseMs` flag (cleared above)
1047
402
  // ensures newly-built ones come back unpatched.
1048
- hostDepsCache.clear();
1049
- hostDepsInflight.clear();
1050
- missingBestEffortNpmCache.clear();
403
+ invalidateHostDepsCache();
1051
404
  return;
1052
405
  }
1053
406
  for (const deps of hostDepsCache.values()) {