@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/README.md +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
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
|
+
}
|