@bermudi/pi-delegate 0.1.0

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 ADDED
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Shared, lazily-cached construction of the heavy pi-coding-agent deps that
3
+ * `createAgentSession` needs but the extension's `ExtensionContext` does not
4
+ * expose directly.
5
+ *
6
+ * `DefaultResourceLoader.reload()` is the one expensive step (~1.2s cold — it
7
+ * scans for skills, prompts, agents.md files, system prompts). It is a
8
+ * read-only cache for the parts we care about: skills, AGENTS.md/context files,
9
+ * and the system prompt. `_buildRuntime` reads `resourceLoader.getExtensions()`
10
+ * and the prompt/skill getters.
11
+ *
12
+ * **Extensions are disabled for subagents by default** (`noExtensions: true`).
13
+ * Subagents are headless workers spawned by the parent's delegate tool — they
14
+ * must not run the parent's interactive extensions (custom UI, slash commands,
15
+ * hooks that call `pi.appendEntry()`/`pi.sendMessage()`). A narrow,
16
+ * provider-scoped allowlist is injected as `additionalExtensionPaths` for
17
+ * safety-critical integrations. Those extension-bearing dependencies are
18
+ * deliberately built per session: `AgentSession._buildRuntime` hands the
19
+ * loader's `extensionsResult.runtime` to a new `ExtensionRunner`, whose
20
+ * `bindCore()` overwrites mutable methods on that runtime (`sendMessage`,
21
+ * `appendEntry`, `setSessionName`, …). Sharing that loader would redirect one
22
+ * session's extension calls into another session. Only extension-free deps are
23
+ * cached and shared.
24
+ *
25
+ * The `modelRuntime` / `settingsManager` / `resourceLoader` are pi-delegate-
26
+ * owned siblings reading the same on-disk files under `~/.pi/agent` as the
27
+ * parent. Since pi 0.80.8, `createAgentSession` takes a single `modelRuntime`
28
+ * (the unified model + auth runtime) in place of the removed `authStorage` /
29
+ * `modelRegistry` options, so the runtime is built here from
30
+ * `~/.pi/agent/{auth,models}.json`. Runtime-only providers are then copied from
31
+ * the parent's registry into this child runtime; extensions remain disabled
32
+ * unless explicitly allowlisted. The parent's `ctx.modelRegistry` is also
33
+ * threaded by the caller for model selection in task-resolution.
34
+ */
35
+ import { execFileSync } from "node:child_process";
36
+ import { homedir } from "node:os";
37
+ import { existsSync, realpathSync } from "node:fs";
38
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
39
+ import {
40
+ DefaultPackageManager,
41
+ DefaultResourceLoader,
42
+ ModelRuntime,
43
+ SettingsManager,
44
+ getAgentDir,
45
+ type ProviderConfig,
46
+ type ResourceLoader,
47
+ } from "@earendil-works/pi-coding-agent";
48
+ import {
49
+ getSubagentProviderExtensionMap,
50
+ getSubagentProviderExtensionsForProvider,
51
+ } from "./config.ts";
52
+
53
+ export interface HostDeps {
54
+ modelRuntime: ModelRuntime;
55
+ settingsManager: SettingsManager;
56
+ resourceLoader: ResourceLoader;
57
+ }
58
+
59
+ export interface HostDepsOptions {
60
+ /** Working directory for project-local resource discovery. */
61
+ cwd: string;
62
+ /** Global config directory. Defaults to `~/.pi/agent`. */
63
+ agentDir?: string;
64
+ /**
65
+ * Provider registrations owned by the parent extension runtime. Subagents
66
+ * intentionally load no extensions, so runtime-only providers (for example
67
+ * Kilo) must be registered explicitly for their auth/config to resolve.
68
+ */
69
+ providerConfigs?: ReadonlyArray<readonly [string, ProviderConfig]>;
70
+ /**
71
+ * Model provider of the current task (e.g. `openai-codex`). Used to apply
72
+ * provider-scoped extension loading for subagents. The default behavior still
73
+ * keeps extension loading disabled for safety. Allowlisted extensions must be
74
+ * installed in the user scope; project-local installations are rejected.
75
+ */
76
+ modelProvider?: string;
77
+ /**
78
+ * Custom system prompt for a named agent. When set, it overrides the default
79
+ * system prompt the resource loader would otherwise discover. Extension-free
80
+ * host deps are cached per (agentDir + cwd + systemPrompt): the expensive
81
+ * `reload()` (skills, AGENTS.md discovery) runs once per distinct combo, then
82
+ * is reused across concurrent subagents. Provider-configured or
83
+ * allowlisted-extension tasks always receive fresh host deps. For ad-hoc
84
+ * tasks (no named agent) pass undefined to use the discovered prompt.
85
+ */
86
+ systemPrompt?: string;
87
+ }
88
+
89
+ const hostDepsCache = new Map<string, HostDeps>();
90
+ /** In-flight builds, so concurrent calls for the same key share one reload(). */
91
+ const hostDepsInflight = new Map<string, Promise<HostDeps>>();
92
+
93
+ function canonicalPath(candidate: string): string {
94
+ try {
95
+ return realpathSync(candidate);
96
+ } catch {
97
+ // The caller normally passes an existing installed path. Keep a lexical
98
+ // fallback for a race where it disappears between lookup and validation.
99
+ return resolve(candidate);
100
+ }
101
+ }
102
+
103
+ function isPathWithinDirectory(directory: string, candidate: string): boolean {
104
+ const relativePath = relative(
105
+ canonicalPath(directory),
106
+ canonicalPath(candidate),
107
+ );
108
+ return (
109
+ relativePath === "" ||
110
+ (relativePath !== ".." &&
111
+ !relativePath.startsWith(`..${sep}`) &&
112
+ !isAbsolute(relativePath))
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Whether a managed package's canonical target remains in a user install root.
118
+ *
119
+ * Pi's managed user installs live below `agentDir`; its legacy npm fallback
120
+ * lives below a global `node_modules` directory. Deriving the latter from the
121
+ * returned lexical path avoids invoking npm merely to validate a path, while
122
+ * still rejecting a package-directory symlink whose canonical target escapes
123
+ * that install root.
124
+ */
125
+ function isTrustedManagedTarget(agentDir: string, userPath: string): boolean {
126
+ const resolvedUserPath = canonicalPath(userPath);
127
+ if (isPathWithinDirectory(agentDir, resolvedUserPath)) return true;
128
+
129
+ const lexicalPath = resolve(userPath);
130
+ const nodeModulesMarker = `${sep}node_modules${sep}`;
131
+ const markerIndex = lexicalPath.lastIndexOf(nodeModulesMarker);
132
+ if (markerIndex < 0) return false;
133
+ const nodeModulesEnd = markerIndex + nodeModulesMarker.length - 1;
134
+ const installRoot = lexicalPath.slice(0, nodeModulesEnd);
135
+ return isPathWithinDirectory(installRoot, resolvedUserPath);
136
+ }
137
+
138
+ /**
139
+ * Find the project boundary used by the extension trust check.
140
+ *
141
+ * `cwd` is allowed to be a package directory inside a larger checkout. Checking
142
+ * only that exact directory makes a user-scope symlink into a sibling project
143
+ * directory look safe. Git is the authoritative boundary when available;
144
+ * marker directories provide a conservative fallback for projects that are not
145
+ * Git worktrees. Returning undefined is intentional: callers then require a
146
+ * canonical managed target to remain under the trusted user agent directory.
147
+ */
148
+ function findExtensionProjectRoot(cwd: string): string | undefined {
149
+ try {
150
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
151
+ cwd,
152
+ encoding: "utf8",
153
+ stdio: ["ignore", "pipe", "ignore"],
154
+ }).trim();
155
+ if (root) return canonicalPath(root);
156
+ } catch {
157
+ // Not every task cwd belongs to a Git worktree. Use project markers below.
158
+ }
159
+
160
+ let directory = canonicalPath(cwd);
161
+ while (true) {
162
+ if (
163
+ existsSync(join(directory, ".pi", "settings.json")) ||
164
+ existsSync(join(directory, ".pi", "agents")) ||
165
+ (directory !== homedir() &&
166
+ existsSync(join(directory, ".claude", "agents")))
167
+ ) {
168
+ return directory;
169
+ }
170
+ const parent = dirname(directory);
171
+ if (parent === directory) break;
172
+ directory = parent;
173
+ }
174
+ return undefined;
175
+ }
176
+
177
+ /** Match the package-manager prefixes that are not local filesystem paths. */
178
+ function isLocalExtensionSource(source: string): boolean {
179
+ const trimmed = source.trim().toLowerCase();
180
+ return !["npm:", "git:", "github:", "http:", "https:", "ssh:"].some(
181
+ (prefix) => trimmed.startsWith(prefix),
182
+ );
183
+ }
184
+
185
+ /** Whether an npm source carries a version, range, or tag after its package name. */
186
+ function hasNpmVersionSpecifier(source: string): boolean {
187
+ const trimmed = source.trim();
188
+ if (!trimmed.toLowerCase().startsWith("npm:")) return false;
189
+ const spec = trimmed.slice("npm:".length).trim();
190
+ const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
191
+ return Boolean(match?.[2]?.trim());
192
+ }
193
+
194
+ /** Normalize the host/path identity emitted by Pi or read from Git. */
195
+ function normalizeGitRepositoryIdentity(
196
+ host: string,
197
+ repoPath: string,
198
+ ): { host: string; path: string } | undefined {
199
+ const path = repoPath.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
200
+ if (!host || !path) return undefined;
201
+ return { host: host.toLowerCase(), path };
202
+ }
203
+
204
+ /** Parse the repository identity used by Pi's Git source parser. */
205
+ function parseGitRepositoryIdentity(
206
+ source: string,
207
+ ): { host: string; path: string } | undefined {
208
+ let candidate = source.trim();
209
+ if (candidate.toLowerCase().startsWith("git:")) {
210
+ candidate = candidate.slice("git:".length).trim();
211
+ }
212
+
213
+ let host: string;
214
+ let repoPath: string;
215
+ if (candidate.startsWith("git@")) {
216
+ const colon = candidate.indexOf(":");
217
+ if (colon < 0) return undefined;
218
+ host = candidate.slice("git@".length, colon);
219
+ repoPath = candidate.slice(colon + 1);
220
+ } else if (/^[a-z][a-z\d+.-]*:\/\//i.test(candidate)) {
221
+ try {
222
+ const url = new URL(candidate);
223
+ host = url.host;
224
+ repoPath = url.pathname;
225
+ } catch {
226
+ return undefined;
227
+ }
228
+ } else {
229
+ const slash = candidate.indexOf("/");
230
+ if (slash < 0) return undefined;
231
+ host = candidate.slice(0, slash);
232
+ repoPath = candidate.slice(slash + 1);
233
+ }
234
+
235
+ // `@` is Pi's ref separator for the source forms that can also appear in
236
+ // an origin URL. Do not treat `#` as a ref separator here: Pi can preserve
237
+ // it as a literal path in generic/SCP forms, while URL parsing above already
238
+ // removes a real URL fragment from `pathname`. Configured-source ref
239
+ // semantics come from Pi's parsed source below.
240
+ const refSeparator = repoPath.indexOf("@");
241
+ if (refSeparator >= 0) repoPath = repoPath.slice(0, refSeparator);
242
+ return normalizeGitRepositoryIdentity(host, repoPath);
243
+ }
244
+
245
+ function getGitRepositoryIdentity(
246
+ source: string,
247
+ ): { host: string; path: string } | undefined {
248
+ const trimmed = source.trim();
249
+ if (
250
+ isLocalExtensionSource(trimmed) ||
251
+ trimmed.toLowerCase().startsWith("npm:")
252
+ ) {
253
+ return undefined;
254
+ }
255
+ return parseGitRepositoryIdentity(trimmed);
256
+ }
257
+
258
+ /**
259
+ * Read the configured Git identity and ref from Pi's own parsed package
260
+ * source. The package manager's parser is private upstream, but
261
+ * getInstalledPath() uses that same parser; keeping this call on the same seam
262
+ * prevents validation from inventing a second (and subtly different) Git URL
263
+ * grammar.
264
+ *
265
+ * In particular, Pi leaves `#release` in the parsed path for generic and SCP
266
+ * sources, while hosted providers may interpret it as a ref. The parsed
267
+ * host/path is therefore authoritative; never normalize the configured source
268
+ * with a blanket `#` rule.
269
+ */
270
+ function getConfiguredGitSource(
271
+ packageManager: DefaultPackageManager,
272
+ source: string,
273
+ ): { host: string; path: string; ref?: string } | undefined {
274
+ if (getGitRepositoryIdentity(source) === undefined) return undefined;
275
+
276
+ const internals =
277
+ packageManager as unknown as Partial<PackageManagerConstraintInternals>;
278
+ if (typeof internals.parseSource !== "function") {
279
+ throw new Error(
280
+ "This Pi version cannot verify a configured Git provider extension ref; delegation stopped.",
281
+ );
282
+ }
283
+
284
+ let parsed: unknown;
285
+ try {
286
+ parsed = internals.parseSource.call(packageManager, source);
287
+ } catch (error) {
288
+ throw new Error(
289
+ "A configured provider extension Git source could not be parsed; delegation stopped.",
290
+ { cause: error },
291
+ );
292
+ }
293
+
294
+ if (typeof parsed !== "object" || parsed === null) {
295
+ throw new Error(
296
+ "A configured provider extension Git source could not be verified; delegation stopped.",
297
+ );
298
+ }
299
+ const parsedSource = parsed as {
300
+ type?: unknown;
301
+ host?: unknown;
302
+ path?: unknown;
303
+ ref?: unknown;
304
+ };
305
+ if (parsedSource.type !== "git") {
306
+ throw new Error(
307
+ "A configured provider extension was not parsed as a Git source; delegation stopped.",
308
+ );
309
+ }
310
+ if (
311
+ typeof parsedSource.host !== "string" ||
312
+ typeof parsedSource.path !== "string"
313
+ ) {
314
+ throw new Error(
315
+ "A configured provider extension Git identity could not be verified; delegation stopped.",
316
+ );
317
+ }
318
+ const repository = normalizeGitRepositoryIdentity(
319
+ parsedSource.host,
320
+ parsedSource.path,
321
+ );
322
+ if (!repository) {
323
+ throw new Error(
324
+ "A configured provider extension Git identity could not be verified; delegation stopped.",
325
+ );
326
+ }
327
+ if (parsedSource.ref === undefined) return repository;
328
+ if (typeof parsedSource.ref !== "string" || parsedSource.ref.length === 0) {
329
+ throw new Error(
330
+ "A configured provider extension Git ref could not be verified; delegation stopped.",
331
+ );
332
+ }
333
+ return { ...repository, ref: parsedSource.ref };
334
+ }
335
+
336
+ function gitOutput(installedPath: string, args: string[]): string {
337
+ return execFileSync("git", args, {
338
+ cwd: installedPath,
339
+ encoding: "utf8",
340
+ stdio: ["ignore", "pipe", "ignore"],
341
+ }).trim();
342
+ }
343
+
344
+ /** Reject a user installation whose checkout or ref differs from the source. */
345
+ function assertConfiguredGitInstallation(
346
+ packageManager: DefaultPackageManager,
347
+ source: string,
348
+ installedPath: string,
349
+ ): void {
350
+ try {
351
+ const configuredRepository = getConfiguredGitSource(packageManager, source);
352
+ if (!configuredRepository) return;
353
+
354
+ const installedRepository = parseGitRepositoryIdentity(
355
+ gitOutput(installedPath, ["config", "--get", "remote.origin.url"]),
356
+ );
357
+ if (
358
+ !installedRepository ||
359
+ installedRepository.host !== configuredRepository.host ||
360
+ installedRepository.path !== configuredRepository.path
361
+ ) {
362
+ throw new Error("checkout has a different origin");
363
+ }
364
+
365
+ const ref = configuredRepository.ref;
366
+ if (!ref) return;
367
+ const head = gitOutput(installedPath, ["rev-parse", "--verify", "HEAD"]);
368
+ const target = gitOutput(installedPath, [
369
+ "rev-parse",
370
+ "--verify",
371
+ "--end-of-options",
372
+ `${ref}^{commit}`,
373
+ ]);
374
+ if (!head || head !== target) {
375
+ throw new Error("checkout is at a different commit");
376
+ }
377
+ } catch (error) {
378
+ throw new Error(
379
+ "A configured provider extension is not checked out at its configured Git source or ref; delegation stopped.",
380
+ { cause: error },
381
+ );
382
+ }
383
+ }
384
+
385
+ type PackageManagerConstraintInternals = {
386
+ parseSource(source: string): unknown;
387
+ installedNpmMatchesConfiguredVersion(
388
+ source: unknown,
389
+ installedPath: string,
390
+ ): Promise<boolean>;
391
+ };
392
+
393
+ type ParsedNpmSource = {
394
+ type?: unknown;
395
+ range?: unknown;
396
+ };
397
+
398
+ /**
399
+ * Ask Pi's package manager to apply its own npm range semantics without
400
+ * duplicating semver logic or installing/updating anything during delegation.
401
+ * These methods are private upstream implementation details, so fail closed if
402
+ * a future Pi release removes or renames them rather than executing an
403
+ * unverified installation.
404
+ */
405
+ async function assertConfiguredNpmVersion(
406
+ packageManager: DefaultPackageManager,
407
+ source: string,
408
+ installedPath: string,
409
+ ): Promise<void> {
410
+ const internals =
411
+ packageManager as unknown as Partial<PackageManagerConstraintInternals>;
412
+ if (
413
+ typeof internals.parseSource !== "function" ||
414
+ typeof internals.installedNpmMatchesConfiguredVersion !== "function"
415
+ ) {
416
+ throw new Error(
417
+ "This Pi version cannot verify a configured provider extension version; delegation stopped.",
418
+ );
419
+ }
420
+
421
+ let parsed: unknown;
422
+ try {
423
+ parsed = internals.parseSource.call(packageManager, source);
424
+ } catch (error) {
425
+ throw new Error(
426
+ "A configured provider extension version could not be parsed; delegation stopped.",
427
+ { cause: error },
428
+ );
429
+ }
430
+
431
+ const parsedNpm =
432
+ typeof parsed === "object" && parsed !== null
433
+ ? (parsed as ParsedNpmSource)
434
+ : undefined;
435
+ if (parsedNpm?.type !== "npm" || typeof parsedNpm.range !== "string") {
436
+ // Pi treats npm tags such as `@latest` as an unconstrained source when
437
+ // checking an installed package. They are registry aliases, not
438
+ // verifiable local version constraints, so fail closed instead of
439
+ // accepting any stale package at the same install path.
440
+ throw new Error(
441
+ "A configured provider extension uses an npm tag rather than a verifiable semver range; delegation stopped.",
442
+ );
443
+ }
444
+
445
+ try {
446
+ const matches = await internals.installedNpmMatchesConfiguredVersion.call(
447
+ packageManager,
448
+ parsed,
449
+ installedPath,
450
+ );
451
+ if (!matches) {
452
+ throw new Error(
453
+ "installed package does not satisfy the configured version",
454
+ );
455
+ }
456
+ } catch (error) {
457
+ throw new Error(
458
+ "A configured provider extension version does not match the installed user-scope package; delegation stopped.",
459
+ { cause: error },
460
+ );
461
+ }
462
+ }
463
+
464
+ async function getProviderExtensionPaths(
465
+ provider: string | undefined,
466
+ cwd: string,
467
+ agentDir: string,
468
+ packageLookupSettingsManager: SettingsManager,
469
+ ): Promise<string[]> {
470
+ // Provider-key normalization (trim + lowercase) lives in `config.ts` —
471
+ // `getSubagentProviderExtensionsForProvider` is the single owner of that
472
+ // logic, so this module never re-implements it.
473
+ const requested = getSubagentProviderExtensionsForProvider(provider);
474
+ if (!requested.length) return [];
475
+
476
+ const packageManager = new DefaultPackageManager({
477
+ cwd,
478
+ agentDir,
479
+ settingsManager: packageLookupSettingsManager,
480
+ });
481
+ const projectRoot = findExtensionProjectRoot(cwd);
482
+
483
+ const validateInstalledPath = (source: string, userPath: string): void => {
484
+ const resolvedUserPath = canonicalPath(userPath);
485
+ const localSource = isLocalExtensionSource(source);
486
+
487
+ // A local source is allowed only when it resolves under the user agent
488
+ // directory. This closes the absolute/`..` path escape that a package
489
+ // manager's user-scope lookup otherwise permits.
490
+ if (localSource && !isPathWithinDirectory(agentDir, resolvedUserPath)) {
491
+ throw new Error(
492
+ "A configured provider extension resolves outside the user agent directory; project-local extension paths are not allowed.",
493
+ );
494
+ }
495
+
496
+ // `cwd` may be a nested package directory. Validate against the repository
497
+ // (or project-marker) root, not just that exact directory, so a symlink from
498
+ // a user-scope managed package into a sibling such as /repo/extensions is
499
+ // never turned into executable subagent code. This check deliberately runs
500
+ // for local sources too: placing the user agent directory inside a project
501
+ // must not turn project code into a trusted extension.
502
+ if (projectRoot && isPathWithinDirectory(projectRoot, resolvedUserPath)) {
503
+ throw new Error(
504
+ "A configured provider extension resolves inside the project directory; project-local extension paths are not allowed.",
505
+ );
506
+ }
507
+
508
+ // Managed sources must remain inside a canonical user install root as well.
509
+ // This is the conservative fallback when no project boundary is
510
+ // discoverable, and it also protects legacy global npm installs from a
511
+ // package-directory symlink that escapes their node_modules root.
512
+ if (!localSource && !isTrustedManagedTarget(agentDir, userPath)) {
513
+ throw new Error(
514
+ "A configured provider extension cannot be verified as a trusted user installation; project-local extension paths are not allowed.",
515
+ );
516
+ }
517
+
518
+ assertConfiguredGitInstallation(packageManager, source, userPath);
519
+ };
520
+
521
+ const installedPaths = new Map<string, string>();
522
+ const missing: string[] = [];
523
+ for (const source of requested) {
524
+ // Deliberately resolve only the user scope. Project-local packages are
525
+ // untrusted input and must never become executable subagent extensions.
526
+ const userPath = packageManager.getInstalledPath(source, "user");
527
+ if (!userPath) {
528
+ missing.push(source);
529
+ continue;
530
+ }
531
+ installedPaths.set(source, userPath);
532
+ }
533
+
534
+ if (missing.length > 0) {
535
+ const providerName = provider?.trim() || "the selected provider";
536
+ const sourceLabel = missing.length === 1 ? "source" : "sources";
537
+ throw new Error(
538
+ `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.`,
539
+ );
540
+ }
541
+
542
+ const paths = new Set<string>();
543
+ for (const source of requested) {
544
+ const userPath = installedPaths.get(source);
545
+ // Every requested source was collected above; this guard keeps the map
546
+ // boundary explicit if that invariant changes later.
547
+ if (!userPath) {
548
+ throw new Error(
549
+ "A configured provider extension disappeared before validation; delegation stopped.",
550
+ );
551
+ }
552
+ validateInstalledPath(source, userPath);
553
+ if (hasNpmVersionSpecifier(source)) {
554
+ await assertConfiguredNpmVersion(packageManager, source, userPath);
555
+ }
556
+ paths.add(userPath);
557
+ }
558
+
559
+ return [...paths];
560
+ }
561
+
562
+ /**
563
+ * Test-only flag: when set, every newly-built settingsManager reports a small
564
+ * retry base delay so retry integration tests don't sleep real seconds. Set
565
+ * via `_setHostRetryBaseMsForTesting`. Module-scoped so it also covers hosts
566
+ * built after the flag is set (the first run in a test).
567
+ */
568
+ let testRetryBaseMs: number | undefined;
569
+
570
+ /**
571
+ * Test-only override for the ModelRuntime factory. When set, `getHostDeps`
572
+ * uses it instead of `ModelRuntime.create` — so integration tests can feed
573
+ * subagents a pre-authenticated runtime (e.g. the parent session's
574
+ * `modelRuntime`) and stub auth. Since pi 0.80.8, subagents build their own
575
+ * `modelRuntime`; the explicit provider-config seam keeps runtime-only
576
+ * providers available without loading extensions in the child.
577
+ */
578
+ let testModelRuntimeFactory: (() => Promise<ModelRuntime>) | undefined;
579
+
580
+ /**
581
+ * Lazily build the host deps for a task. Extension-free, provider-independent
582
+ * deps are cached by (agentDir, cwd, systemPrompt); provider registrations and
583
+ * allowlisted extensions get a private dependency graph for every session.
584
+ *
585
+ * The first cached call pays the `resourceLoader.reload()` cost (~1.2s). An
586
+ * extension-bearing call intentionally pays that cost again: sharing its
587
+ * ResourceLoader would share Pi's mutable extension runtime and let
588
+ * `ExtensionRunner.bindCore()` redirect one session's extension callbacks into
589
+ * another session.
590
+ */
591
+ export async function getHostDeps(options: HostDepsOptions): Promise<HostDeps> {
592
+ const agentDir = options.agentDir ?? getAgentDir();
593
+ const providerExtensions = getSubagentProviderExtensionMap();
594
+ const providerExtensionSignature = JSON.stringify(
595
+ Object.entries(providerExtensions)
596
+ .sort(([a], [b]) => a.localeCompare(b))
597
+ .map(([provider, entries]) => [provider, [...entries]] as const),
598
+ );
599
+ const providerConfigs = options.providerConfigs ?? [];
600
+ const requestedExtensions = getSubagentProviderExtensionsForProvider(
601
+ options.modelProvider,
602
+ );
603
+
604
+ // Resolve provider extensions before deciding whether to use the cache. This
605
+ // fails closed for missing sources while keeping package lookup's user-only
606
+ // settings isolated from the project-aware session settings built below.
607
+ let additionalExtensionPaths: string[] = [];
608
+ if (requestedExtensions.length > 0) {
609
+ // Package lookup is a user-scope trust boundary. Pi's legacy npm fallback
610
+ // may execute the configured npmCommand to discover the global npm root,
611
+ // so project settings must not participate even though the normal resource
612
+ // loader below remains project-aware.
613
+ const packageLookupSettingsManager = SettingsManager.create(
614
+ options.cwd,
615
+ agentDir,
616
+ { projectTrusted: false },
617
+ );
618
+ additionalExtensionPaths = await getProviderExtensionPaths(
619
+ options.modelProvider,
620
+ options.cwd,
621
+ agentDir,
622
+ packageLookupSettingsManager,
623
+ );
624
+ }
625
+
626
+ // Provider configs may contain functions (custom stream/OAuth handlers), so a
627
+ // stringified value cannot safely identify them. Do not cache any call that
628
+ // registers one; each call gets the exact config object supplied by its
629
+ // caller. The same rule is used for extension-bearing calls because their
630
+ // extension runtime is mutable and session-owned.
631
+ const cacheable =
632
+ providerConfigs.length === 0 && additionalExtensionPaths.length === 0;
633
+ const key = JSON.stringify({
634
+ agentDir,
635
+ cwd: options.cwd,
636
+ systemPrompt:
637
+ options.systemPrompt === undefined
638
+ ? { source: "discovered" }
639
+ : { source: "explicit", value: options.systemPrompt },
640
+ modelProvider: options.modelProvider ?? "",
641
+ providerExtensionSignature,
642
+ });
643
+
644
+ if (cacheable) {
645
+ const cached = hostDepsCache.get(key);
646
+ if (cached) return cached;
647
+
648
+ // Another call is already building this key — await its promise.
649
+ const inflight = hostDepsInflight.get(key);
650
+ if (inflight) return inflight;
651
+ }
652
+
653
+ const build = async (): Promise<HostDeps> => {
654
+ // Canonical model/auth runtime — the 0.80.8+ successor to the separate
655
+ // `authStorage` + `modelRegistry` options. It is shared only for the
656
+ // cacheable, extension-free path; provider-specific sessions get their own
657
+ // runtime so all stateful host dependencies have the same ownership.
658
+ // Reads the same ~/.pi/agent/{auth,models}.json the parent uses, so stored
659
+ // credentials stay consistent. Runtime-only provider registrations are
660
+ // layered on just below.
661
+ // In tests, `_setModelRuntimeFactoryForTesting` can substitute a runtime.
662
+ const modelRuntime = testModelRuntimeFactory
663
+ ? await testModelRuntimeFactory()
664
+ : await ModelRuntime.create({
665
+ authPath: join(agentDir, "auth.json"),
666
+ modelsPath: join(agentDir, "models.json"),
667
+ // Subagents receive an explicit model (resolved by the parent), so they
668
+ // never need remote model-catalog discovery. Skipping the network
669
+ // availability refresh makes the first call per cwd faster and
670
+ // offline-safe; auth (getAuth) still reads auth.json directly.
671
+ allowModelNetwork: false,
672
+ });
673
+ for (const [providerId, config] of providerConfigs) {
674
+ modelRuntime.registerProvider(providerId, config);
675
+ }
676
+
677
+ const resolvedSettingsManager = SettingsManager.create(
678
+ options.cwd,
679
+ agentDir,
680
+ );
681
+ if (testRetryBaseMs !== undefined) {
682
+ installFastRetry(resolvedSettingsManager, testRetryBaseMs);
683
+ }
684
+ const resourceLoader = new DefaultResourceLoader({
685
+ cwd: options.cwd,
686
+ agentDir,
687
+ settingsManager: resolvedSettingsManager,
688
+ // Subagents are headless workers — they must not load the parent's
689
+ // interactive extension inventory. The only paths supplied here are the
690
+ // explicitly allowlisted, user-scoped provider extensions.
691
+ noExtensions: true,
692
+ ...(additionalExtensionPaths.length ? { additionalExtensionPaths } : {}),
693
+ // When a named agent supplies a custom prompt, it becomes the loader's
694
+ // customPrompt — overriding the default system prompt AgentSession would
695
+ // otherwise build. `systemPrompt` (the source) wins over file discovery.
696
+ ...(options.systemPrompt !== undefined
697
+ ? { systemPrompt: options.systemPrompt }
698
+ : {}),
699
+ });
700
+ await resourceLoader.reload();
701
+
702
+ const extensionsResult = resourceLoader.getExtensions();
703
+ const extensionErrors = extensionsResult.errors;
704
+ const loadedExtensionPaths = extensionsResult.extensions.map(
705
+ (extension) => extension.resolvedPath || extension.path,
706
+ );
707
+ // A package can resolve successfully while exposing only skills/prompts,
708
+ // or a malformed manifest can expose no loadable extension at all. Treat
709
+ // that as a failed provider integration rather than silently delegating
710
+ // without the safety-critical behavior the allowlist requested.
711
+ const missingExtensionRoots = additionalExtensionPaths.filter(
712
+ (root) =>
713
+ !loadedExtensionPaths.some((extensionPath) =>
714
+ isPathWithinDirectory(root, extensionPath),
715
+ ),
716
+ );
717
+ if (extensionErrors.length > 0 || missingExtensionRoots.length > 0) {
718
+ const failedRoots = new Set(
719
+ missingExtensionRoots.concat(
720
+ additionalExtensionPaths.filter((root) =>
721
+ extensionErrors.some((error) =>
722
+ isPathWithinDirectory(root, error.path),
723
+ ),
724
+ ),
725
+ ),
726
+ );
727
+ const failureCount = Math.max(failedRoots.size, extensionErrors.length);
728
+ const providerName =
729
+ options.modelProvider?.trim() || "the selected provider";
730
+ throw new Error(
731
+ `Failed to load ${failureCount} allowlisted provider extension(s) for ${providerName}; delegation stopped instead of running without the required integration.`,
732
+ );
733
+ }
734
+
735
+ return {
736
+ modelRuntime,
737
+ settingsManager: resolvedSettingsManager,
738
+ resourceLoader,
739
+ };
740
+ };
741
+
742
+ if (!cacheable) return build();
743
+
744
+ const promise = build().then((deps) => {
745
+ hostDepsCache.set(key, deps);
746
+ return deps;
747
+ });
748
+ hostDepsInflight.set(key, promise);
749
+ try {
750
+ return await promise;
751
+ } finally {
752
+ // Clear the in-flight marker whether it succeeded or threw; the cache holds
753
+ // the result on success, and a failure leaves nothing for a retry to reuse.
754
+ hostDepsInflight.delete(key);
755
+ }
756
+ }
757
+
758
+ /** Patch a settingsManager to report a fixed (small) retry base delay. */
759
+ function installFastRetry(sm: SettingsManager, baseDelayMs: number): void {
760
+ sm.getRetrySettings = (() => ({
761
+ enabled: true,
762
+ maxRetries: 3,
763
+ baseDelayMs,
764
+ })) as never;
765
+ }
766
+
767
+ /** Test-only: clear the cache so a fresh (cwd, prompt) gets re-built. */
768
+ export function _resetHostDepsCacheForTesting(): void {
769
+ hostDepsCache.clear();
770
+ hostDepsInflight.clear();
771
+ }
772
+
773
+ /**
774
+ * Test-only: substitute the ModelRuntime factory. Pass a factory returning a
775
+ * pre-authenticated runtime (e.g. the parent session's `modelRuntime`) so
776
+ * subagents reuse it and a test can stub auth; pass `undefined` to restore the
777
+ * real `ModelRuntime.create` path. Clears the deps cache either way so the
778
+ * next build respects the change.
779
+ */
780
+ export function _setModelRuntimeFactoryForTesting(
781
+ factory: (() => Promise<ModelRuntime>) | undefined,
782
+ ): void {
783
+ testModelRuntimeFactory = factory;
784
+ hostDepsCache.clear();
785
+ hostDepsInflight.clear();
786
+ }
787
+
788
+ /**
789
+ * Test-only: shrink the retry backoff on every cached + future settingsManager
790
+ * so retry integration tests don't sleep real seconds. AgentSession owns retry
791
+ * (strip-and-continue with exponential backoff), and the only knob is the
792
+ * shared settingsManager that `createAgentSession` reads — so this is the
793
+ * single chokepoint for making retry fast in tests.
794
+ *
795
+ * Pass `undefined` to restore: this clears the cache (existing patched managers
796
+ * can't be un-patched in place, so they're dropped and rebuilt fresh on next
797
+ * use). Tests that reuse a cwd across beforeEach/afterEach cycles must pair
798
+ * this with `_resetHostDepsCacheForTesting()` to guarantee an unpatched rebuild.
799
+ */
800
+ export function _setHostRetryBaseMsForTesting(
801
+ baseDelayMs: number | undefined,
802
+ ): void {
803
+ testRetryBaseMs = baseDelayMs;
804
+ if (baseDelayMs === undefined) {
805
+ // Restore: drop patched managers. The `testRetryBaseMs` flag (cleared above)
806
+ // ensures newly-built ones come back unpatched.
807
+ hostDepsCache.clear();
808
+ hostDepsInflight.clear();
809
+ return;
810
+ }
811
+ for (const deps of hostDepsCache.values()) {
812
+ installFastRetry(deps.settingsManager, baseDelayMs);
813
+ }
814
+ }