@bermudi/pi-delegate 0.1.10 → 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/delegate.ts +3 -2
- package/dispatch.ts +26 -27
- package/extension.ts +2 -4
- package/format.ts +2 -2
- package/host-cache.ts +70 -0
- package/host.ts +164 -811
- package/lifecycle.ts +653 -508
- package/manual.ts +0 -4
- package/package.json +1 -1
- package/pi-package-source.ts +293 -0
- package/provider-extensions.ts +528 -0
- package/quiescence.ts +262 -0
- package/runner.ts +32 -140
- package/schema.ts +77 -153
- package/task-resolution.ts +34 -16
- package/ticket-format.ts +323 -0
- package/tickets.ts +79 -248
- package/tools.ts +12 -0
- package/trusted-paths.ts +71 -0
- package/types.ts +4 -16
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)
|
|
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 {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
//
|
|
950
|
-
//
|
|
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 (
|
|
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
|
-
|
|
1003
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1049
|
-
hostDepsInflight.clear();
|
|
1050
|
-
missingBestEffortNpmCache.clear();
|
|
403
|
+
invalidateHostDepsCache();
|
|
1051
404
|
return;
|
|
1052
405
|
}
|
|
1053
406
|
for (const deps of hostDepsCache.values()) {
|