@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
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution and verification of the provider-scoped subagent extension
|
|
3
|
+
* allowlist.
|
|
4
|
+
*
|
|
5
|
+
* Subagents run with `noExtensions: true`. This module owns the single, narrow
|
|
6
|
+
* exception: a per-provider list of user-scope packages that may be injected as
|
|
7
|
+
* `additionalExtensionPaths` (today, remote compaction for `openai-codex`).
|
|
8
|
+
* Because those packages become executable code inside a subagent, every source
|
|
9
|
+
* is put through the same gauntlet before it is handed to the resource loader:
|
|
10
|
+
*
|
|
11
|
+
* 1. resolved in the **user scope only** — a project-local package is
|
|
12
|
+
* untrusted input and never becomes a subagent extension;
|
|
13
|
+
* 2. its canonical target must stay inside a trusted install root and outside
|
|
14
|
+
* the project;
|
|
15
|
+
* 3. a Git source must be checked out at the configured repository and, if
|
|
16
|
+
* pinned, at the configured commit;
|
|
17
|
+
* 4. an npm source with a version specifier must satisfy that range.
|
|
18
|
+
*
|
|
19
|
+
* **Provenance decides failure semantics.** A source the user listed in
|
|
20
|
+
* `delegate.json` is required and fails closed. A shipped default the user
|
|
21
|
+
* never mentioned is best-effort: missing, unverifiable, or broken, it is
|
|
22
|
+
* dropped *silently* and the subagent runs extension-free on Pi's native
|
|
23
|
+
* compaction. Silence there is deliberate — see `getProviderExtensionPaths`.
|
|
24
|
+
*
|
|
25
|
+
* All parsing of source strings goes through `pi-package-source.ts`, which
|
|
26
|
+
* wraps Pi's own parser. This module deliberately contains no Git URL grammar,
|
|
27
|
+
* no npm spec regex, and no `npm:`/`git:` prefix matching of its own.
|
|
28
|
+
*/
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
33
|
+
import {
|
|
34
|
+
DefaultPackageManager,
|
|
35
|
+
SettingsManager,
|
|
36
|
+
} from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { getSubagentProviderExtensionSourcesForProvider } from "./config.ts";
|
|
38
|
+
import { canonicalPath, isPathWithinDirectory, isPathWithinDirectoryLexical } from "./trusted-paths.ts";
|
|
39
|
+
import {
|
|
40
|
+
parseGitOriginIdentity,
|
|
41
|
+
parsePackageSource,
|
|
42
|
+
PiPackageSourceError,
|
|
43
|
+
sameRepository,
|
|
44
|
+
type GitPackageSource,
|
|
45
|
+
type NpmPackageSource,
|
|
46
|
+
} from "./pi-package-source.ts";
|
|
47
|
+
|
|
48
|
+
/** Result of resolving a provider's allowlisted extension sources. */
|
|
49
|
+
export interface ProviderExtensionResolution {
|
|
50
|
+
/** User-scope package roots to inject as subagent extension paths. */
|
|
51
|
+
paths: string[];
|
|
52
|
+
/** Roots originating from shipped best-effort defaults; these may degrade
|
|
53
|
+
* silently — see the drop-site comments for why silence is the design. */
|
|
54
|
+
bestEffortPaths: Set<string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// When a shipped best-effort default (today npm:@bermudi/pi-codex) is absent —
|
|
58
|
+
// the normal state for most users — Pi's getInstalledPath can synchronously
|
|
59
|
+
// spawn `npm root -g` to check its legacy global fallback. Doing that once per
|
|
60
|
+
// task in a fan-out blocks the event loop N times and serializes the fan-out.
|
|
61
|
+
// Cache the *absence* per dispatch so only the first task pays the cost; the
|
|
62
|
+
// rest skip the lookup entirely. Absence is stable within a dispatch because
|
|
63
|
+
// nothing here ever installs.
|
|
64
|
+
const missingBestEffortSourceCache = new Set<string>();
|
|
65
|
+
|
|
66
|
+
/** Drop the per-dispatch absence cache. Called from host-deps invalidation. */
|
|
67
|
+
export function clearMissingProviderExtensionCache(): void {
|
|
68
|
+
missingBestEffortSourceCache.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a managed package's canonical target remains in a user install root.
|
|
73
|
+
*
|
|
74
|
+
* Pi's managed user installs live below `agentDir`; its legacy npm fallback
|
|
75
|
+
* lives below a global `node_modules` directory. Deriving the latter from the
|
|
76
|
+
* returned lexical path avoids invoking npm merely to validate a path, while
|
|
77
|
+
* still rejecting a package-directory symlink whose canonical target escapes
|
|
78
|
+
* that install root.
|
|
79
|
+
*/
|
|
80
|
+
function isTrustedManagedTarget(agentDir: string, userPath: string): boolean {
|
|
81
|
+
const resolvedUserPath = canonicalPath(userPath);
|
|
82
|
+
if (isPathWithinDirectory(agentDir, resolvedUserPath)) return true;
|
|
83
|
+
|
|
84
|
+
const lexicalPath = resolve(userPath);
|
|
85
|
+
const nodeModulesMarker = `${sep}node_modules${sep}`;
|
|
86
|
+
const markerIndex = lexicalPath.lastIndexOf(nodeModulesMarker);
|
|
87
|
+
if (markerIndex < 0) return false;
|
|
88
|
+
const installRoot = lexicalPath.slice(
|
|
89
|
+
0,
|
|
90
|
+
markerIndex + nodeModulesMarker.length - 1,
|
|
91
|
+
);
|
|
92
|
+
return isPathWithinDirectory(installRoot, resolvedUserPath);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Find the project boundary used by the extension trust check.
|
|
97
|
+
*
|
|
98
|
+
* `cwd` is allowed to be a package directory inside a larger checkout. Checking
|
|
99
|
+
* only that exact directory makes a user-scope symlink into a sibling project
|
|
100
|
+
* directory look safe. Git is the authoritative boundary when available;
|
|
101
|
+
* marker directories provide a conservative fallback for projects that are not
|
|
102
|
+
* Git worktrees. Returning undefined is intentional: callers then require a
|
|
103
|
+
* canonical managed target to remain under the trusted user agent directory.
|
|
104
|
+
*/
|
|
105
|
+
function findExtensionProjectRoot(cwd: string): string | undefined {
|
|
106
|
+
const root = gitOutput(cwd, ["rev-parse", "--show-toplevel"]);
|
|
107
|
+
if (root) return canonicalPath(root);
|
|
108
|
+
|
|
109
|
+
let directory = canonicalPath(cwd);
|
|
110
|
+
if (directory === undefined) return undefined;
|
|
111
|
+
for (;;) {
|
|
112
|
+
if (
|
|
113
|
+
existsSync(join(directory, ".pi", "settings.json")) ||
|
|
114
|
+
existsSync(join(directory, ".pi", "agents")) ||
|
|
115
|
+
(directory !== homedir() &&
|
|
116
|
+
existsSync(join(directory, ".claude", "agents")))
|
|
117
|
+
) {
|
|
118
|
+
return directory;
|
|
119
|
+
}
|
|
120
|
+
const parent = dirname(directory);
|
|
121
|
+
if (parent === directory) break;
|
|
122
|
+
directory = parent;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Run git in `cwd`, returning trimmed stdout or "" if the command fails. */
|
|
128
|
+
function gitOutput(cwd: string, args: string[]): string {
|
|
129
|
+
try {
|
|
130
|
+
return execFileSync("git", args, {
|
|
131
|
+
cwd,
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
134
|
+
timeout: 5000,
|
|
135
|
+
}).trim();
|
|
136
|
+
} catch {
|
|
137
|
+
// Not every path is a Git worktree, and a missing ref is a normal answer
|
|
138
|
+
// here. A timeout or non-zero exit is also treated as "no answer" so an
|
|
139
|
+
// unresponsive git never hangs delegation. Callers treat "" as "no
|
|
140
|
+
// answer" and fail closed where that matters.
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Reject a user installation whose checkout or ref differs from the configured
|
|
147
|
+
* source.
|
|
148
|
+
*
|
|
149
|
+
* Pi derives a Git package's install directory from the source's host/path, so
|
|
150
|
+
* a matching directory alone proves nothing about the checkout inside it: a
|
|
151
|
+
* source pinned to a tag and the same source unpinned share one directory.
|
|
152
|
+
* Reading the checkout's own origin and HEAD is what makes the pin mean
|
|
153
|
+
* something.
|
|
154
|
+
*/
|
|
155
|
+
function verifyGitCheckout(
|
|
156
|
+
packageManager: DefaultPackageManager,
|
|
157
|
+
configured: GitPackageSource,
|
|
158
|
+
installedPath: string,
|
|
159
|
+
): void {
|
|
160
|
+
try {
|
|
161
|
+
const origin = parseGitOriginIdentity(
|
|
162
|
+
packageManager,
|
|
163
|
+
gitOutput(installedPath, ["config", "--get", "remote.origin.url"]),
|
|
164
|
+
);
|
|
165
|
+
if (!origin || !sameRepository(origin, configured)) {
|
|
166
|
+
throw new Error("checkout has a different origin");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!configured.ref) return;
|
|
170
|
+
const head = gitOutput(installedPath, ["rev-parse", "--verify", "HEAD"]);
|
|
171
|
+
const target = gitOutput(installedPath, [
|
|
172
|
+
"rev-parse",
|
|
173
|
+
"--verify",
|
|
174
|
+
"--end-of-options",
|
|
175
|
+
`${configured.ref}^{commit}`,
|
|
176
|
+
]);
|
|
177
|
+
if (!head || head !== target) {
|
|
178
|
+
throw new Error("checkout is at a different commit");
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"A configured provider extension is not checked out at its configured Git source or ref; delegation stopped.",
|
|
183
|
+
{ cause: error },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Apply Pi's own npm range semantics to the installed package, without
|
|
190
|
+
* duplicating semver logic and without installing or updating anything during
|
|
191
|
+
* delegation.
|
|
192
|
+
*/
|
|
193
|
+
async function verifyNpmVersion(
|
|
194
|
+
configured: NpmPackageSource,
|
|
195
|
+
installedPath: string,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
// An unconstrained source (`npm:pkg`) pins nothing, so there is nothing to
|
|
198
|
+
// verify beyond the path checks every source already passed.
|
|
199
|
+
if (configured.version === undefined) return;
|
|
200
|
+
if (configured.range === undefined) {
|
|
201
|
+
// Pi treats npm tags such as `@latest` as unconstrained when checking an
|
|
202
|
+
// installed package. They are registry aliases, not verifiable local
|
|
203
|
+
// version constraints, so fail closed instead of accepting any stale
|
|
204
|
+
// package that happens to sit at the same install path.
|
|
205
|
+
throw new Error(
|
|
206
|
+
"A configured provider extension uses an npm tag rather than a verifiable semver range; delegation stopped.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let matches: boolean;
|
|
211
|
+
try {
|
|
212
|
+
matches = await configured.satisfiedBy(installedPath);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
"A configured provider extension version does not match the installed user-scope package; delegation stopped.",
|
|
216
|
+
{ cause: error },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (!matches) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
"A configured provider extension version does not match the installed user-scope package; delegation stopped.",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Full verification of one resolved source. Throws on any failure; the caller
|
|
228
|
+
* decides whether that is fatal (user-configured) or a silent drop
|
|
229
|
+
* (best-effort default).
|
|
230
|
+
*/
|
|
231
|
+
async function verifyInstalledSource(
|
|
232
|
+
packageManager: DefaultPackageManager,
|
|
233
|
+
source: string,
|
|
234
|
+
installedPath: string,
|
|
235
|
+
trust: { agentDir: string; projectRoot: string | undefined },
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const configured = parseSourceForVerification(packageManager, source);
|
|
238
|
+
const resolvedUserPath = canonicalPath(installedPath);
|
|
239
|
+
|
|
240
|
+
// A local source is allowed only when it resolves under the user agent
|
|
241
|
+
// directory. This closes the absolute/`..` path escape that a package
|
|
242
|
+
// manager's user-scope lookup otherwise permits.
|
|
243
|
+
if (
|
|
244
|
+
configured.type === "local" &&
|
|
245
|
+
!isPathWithinDirectory(trust.agentDir, resolvedUserPath)
|
|
246
|
+
) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
"A configured provider extension resolves outside the user agent directory; project-local extension paths are not allowed.",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// `cwd` may be a nested package directory. Validate against the repository
|
|
253
|
+
// (or project-marker) root, not just that exact directory, so a symlink from
|
|
254
|
+
// a user-scope managed package into a sibling such as /repo/extensions is
|
|
255
|
+
// never turned into executable subagent code. This check deliberately runs
|
|
256
|
+
// for local sources too: placing the user agent directory inside a project
|
|
257
|
+
// must not turn project code into a trusted extension.
|
|
258
|
+
if (
|
|
259
|
+
trust.projectRoot &&
|
|
260
|
+
isPathWithinDirectory(trust.projectRoot, resolvedUserPath)
|
|
261
|
+
) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
"A configured provider extension resolves inside the project directory; project-local extension paths are not allowed.",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Managed sources must remain inside a canonical user install root as well.
|
|
268
|
+
// This is the conservative fallback when no project boundary is
|
|
269
|
+
// discoverable, and it also protects legacy global npm installs from a
|
|
270
|
+
// package-directory symlink that escapes their node_modules root.
|
|
271
|
+
if (
|
|
272
|
+
configured.type !== "local" &&
|
|
273
|
+
!isTrustedManagedTarget(trust.agentDir, installedPath)
|
|
274
|
+
) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
"A configured provider extension cannot be verified as a trusted user installation; project-local extension paths are not allowed.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (configured.type === "git") {
|
|
281
|
+
verifyGitCheckout(packageManager, configured, installedPath);
|
|
282
|
+
}
|
|
283
|
+
if (configured.type === "npm") {
|
|
284
|
+
await verifyNpmVersion(configured, installedPath);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Parse a configured source, re-reporting a broken parser contract in terms of
|
|
290
|
+
* the verification it defeated.
|
|
291
|
+
*
|
|
292
|
+
* An unverifiable source is treated exactly like a failed verification, but the
|
|
293
|
+
* user should read the failure as "this Git source could not be verified", not
|
|
294
|
+
* as an unrelated internal error. The original diagnosis survives as `cause`,
|
|
295
|
+
* so a Pi-version mismatch stays distinguishable from a bad checkout.
|
|
296
|
+
*/
|
|
297
|
+
function parseSourceForVerification(
|
|
298
|
+
packageManager: DefaultPackageManager,
|
|
299
|
+
source: string,
|
|
300
|
+
) {
|
|
301
|
+
try {
|
|
302
|
+
return parsePackageSource(packageManager, source);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof PiPackageSourceError && error.sourceType === "git") {
|
|
305
|
+
throw new Error(
|
|
306
|
+
"A configured provider extension is not checked out at its configured Git source or ref; delegation stopped.",
|
|
307
|
+
{ cause: error },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (error instanceof PiPackageSourceError && error.sourceType === "npm") {
|
|
311
|
+
throw new Error(
|
|
312
|
+
"A configured provider extension version does not match the installed user-scope package; delegation stopped.",
|
|
313
|
+
{ cause: error },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Resolve and verify every allowlisted extension source for `provider`.
|
|
322
|
+
*
|
|
323
|
+
* Provider-key normalization (trim + lowercase) lives in `config.ts` — the
|
|
324
|
+
* sources getter is the single owner of that logic, so this module never
|
|
325
|
+
* re-implements it. Provenance (required vs best-effort) is decided there too,
|
|
326
|
+
* by config presence: user-listed sources fail closed; shipped defaults degrade
|
|
327
|
+
* silently, because the extension-free path is Pi's normal operation, not a
|
|
328
|
+
* warning condition.
|
|
329
|
+
*/
|
|
330
|
+
export async function getProviderExtensionPaths(
|
|
331
|
+
provider: string | undefined,
|
|
332
|
+
cwd: string,
|
|
333
|
+
agentDir: string,
|
|
334
|
+
): Promise<ProviderExtensionResolution> {
|
|
335
|
+
const requested = getSubagentProviderExtensionSourcesForProvider(provider);
|
|
336
|
+
if (!requested.length) {
|
|
337
|
+
return { paths: [], bestEffortPaths: new Set<string>() };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const packageManager = new DefaultPackageManager({
|
|
341
|
+
cwd,
|
|
342
|
+
agentDir,
|
|
343
|
+
// Package lookup is a user-scope trust boundary. Pi's legacy npm fallback
|
|
344
|
+
// may execute the configured npmCommand to discover the global npm root,
|
|
345
|
+
// so project settings must never participate.
|
|
346
|
+
settingsManager: SettingsManager.create(cwd, agentDir, {
|
|
347
|
+
projectTrusted: false,
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
const trust = { agentDir, projectRoot: findExtensionProjectRoot(cwd) };
|
|
351
|
+
|
|
352
|
+
const installedPaths = new Map<string, string>();
|
|
353
|
+
const missing: string[] = [];
|
|
354
|
+
for (const { source, required } of requested) {
|
|
355
|
+
const userPath = resolveUserScopePath(
|
|
356
|
+
packageManager,
|
|
357
|
+
source,
|
|
358
|
+
required,
|
|
359
|
+
agentDir,
|
|
360
|
+
);
|
|
361
|
+
if (!userPath) {
|
|
362
|
+
if (required) missing.push(source);
|
|
363
|
+
// A best-effort default that is not installed is skipped silently: for
|
|
364
|
+
// most users the package was never installed at all, and its absence is
|
|
365
|
+
// the normal, correct state — not something to warn about.
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
installedPaths.set(source, userPath);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (missing.length > 0) {
|
|
372
|
+
const providerName = provider?.trim() || "the selected provider";
|
|
373
|
+
const sourceLabel = missing.length === 1 ? "source" : "sources";
|
|
374
|
+
throw new Error(
|
|
375
|
+
`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.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const paths = new Set<string>();
|
|
380
|
+
const bestEffortPaths = new Set<string>();
|
|
381
|
+
for (const { source, required } of requested) {
|
|
382
|
+
const userPath = installedPaths.get(source);
|
|
383
|
+
// Best-effort defaults that were not installed never reached the map.
|
|
384
|
+
if (!userPath) continue;
|
|
385
|
+
try {
|
|
386
|
+
await verifyInstalledSource(packageManager, source, userPath, trust);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (required) throw error;
|
|
389
|
+
// A best-effort default that cannot be verified is skipped, not loaded
|
|
390
|
+
// and not fatal. Silent by design: an installed-but-broken package also
|
|
391
|
+
// fails in the parent's own extension inventory, where Pi surfaces it;
|
|
392
|
+
// this path only mirrors a signal the user has already seen.
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
paths.add(userPath);
|
|
396
|
+
if (!required) bestEffortPaths.add(userPath);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { paths: [...paths], bestEffortPaths };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Look up a source in the user scope, short-circuiting the repeated cost of
|
|
404
|
+
* discovering that an optional default is simply not installed.
|
|
405
|
+
*
|
|
406
|
+
* Deliberately resolves only the user scope: project-local packages are
|
|
407
|
+
* untrusted input and must never become executable subagent extensions.
|
|
408
|
+
*/
|
|
409
|
+
function resolveUserScopePath(
|
|
410
|
+
packageManager: DefaultPackageManager,
|
|
411
|
+
source: string,
|
|
412
|
+
required: boolean,
|
|
413
|
+
agentDir: string,
|
|
414
|
+
): string | undefined {
|
|
415
|
+
// A required source is always looked up: its absence is fatal, so paying the
|
|
416
|
+
// lookup once per task is irrelevant next to stopping the dispatch.
|
|
417
|
+
if (required) return packageManager.getInstalledPath(source, "user");
|
|
418
|
+
|
|
419
|
+
const missingKey = `${agentDir}\0${source}`;
|
|
420
|
+
if (missingBestEffortSourceCache.has(missingKey)) return undefined;
|
|
421
|
+
const userPath = packageManager.getInstalledPath(source, "user");
|
|
422
|
+
if (!userPath) missingBestEffortSourceCache.add(missingKey);
|
|
423
|
+
return userPath;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Split allowlisted extension roots into the ones that must abort delegation
|
|
428
|
+
* and the ones that may be dropped and retried without.
|
|
429
|
+
*
|
|
430
|
+
* A root "failed" if it produced a load error or produced no loaded extension
|
|
431
|
+
* at all — a package can resolve successfully while exposing only skills or
|
|
432
|
+
* prompts, and a malformed manifest can expose nothing loadable. An error that
|
|
433
|
+
* no best-effort root claims stays fatal, including one that no supplied root
|
|
434
|
+
* claims at all.
|
|
435
|
+
*
|
|
436
|
+
* Pure, so the classification is testable without a resource loader.
|
|
437
|
+
*/
|
|
438
|
+
export function partitionExtensionLoadFailures(input: {
|
|
439
|
+
extensionPaths: readonly string[];
|
|
440
|
+
loadedExtensionPaths: readonly string[];
|
|
441
|
+
extensionErrors: ReadonlyArray<{ path: string }>;
|
|
442
|
+
bestEffortRoots: ReadonlySet<string>;
|
|
443
|
+
}): { fatalCount: number; droppableRoots: string[] } {
|
|
444
|
+
const { extensionPaths, loadedExtensionPaths, extensionErrors } = input;
|
|
445
|
+
const bestEffortRoots = [...input.bestEffortRoots];
|
|
446
|
+
|
|
447
|
+
const failedRoots = new Set(
|
|
448
|
+
extensionPaths.filter(
|
|
449
|
+
(root) =>
|
|
450
|
+
!loadedExtensionPaths.some((extensionPath) =>
|
|
451
|
+
isPathWithinDirectoryLexical(root, extensionPath),
|
|
452
|
+
) ||
|
|
453
|
+
extensionErrors.some((error) =>
|
|
454
|
+
isPathWithinDirectoryLexical(root, error.path),
|
|
455
|
+
),
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
const fatalErrors = extensionErrors.filter(
|
|
459
|
+
(error) =>
|
|
460
|
+
!bestEffortRoots.some((root) =>
|
|
461
|
+
isPathWithinDirectoryLexical(root, error.path),
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
const fatalRoots = [...failedRoots].filter(
|
|
465
|
+
(root) => !input.bestEffortRoots.has(root),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
fatalCount: Math.max(fatalRoots.length, fatalErrors.length),
|
|
470
|
+
droppableRoots: [...failedRoots].filter((root) =>
|
|
471
|
+
input.bestEffortRoots.has(root),
|
|
472
|
+
),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* UI notifier for the provider-extension-loaded notice, primed from
|
|
478
|
+
* extension.ts `execute` (the only place the real, ui-bearing ctx exists —
|
|
479
|
+
* host-dep construction itself has no UI context). Consumed defensively:
|
|
480
|
+
* a throw means the ctx went stale (headless run, torn-down TUI) and simply
|
|
481
|
+
* un-primes the notifier so the next live execute re-primes it.
|
|
482
|
+
*/
|
|
483
|
+
let providerExtensionNotifier: ((message: string) => void) | undefined;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Prime the UI notifier used for the best-effort extension-loaded notice.
|
|
487
|
+
* Idempotent and cheap; called at the top of every delegate execute. Pass
|
|
488
|
+
* `undefined` to un-prime (tests) — an un-primed notifier makes the notice a
|
|
489
|
+
* no-op, which is also the default state in headless/test runs.
|
|
490
|
+
*/
|
|
491
|
+
export function registerProviderExtensionNotifier(
|
|
492
|
+
notify: ((message: string) => void) | undefined,
|
|
493
|
+
): void {
|
|
494
|
+
providerExtensionNotifier = notify;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** provider+root pairs already noticed this process. */
|
|
498
|
+
const noticedProviderExtensionRoots = new Set<string>();
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Announce that a shipped best-effort provider integration actually loaded
|
|
502
|
+
* for delegated subagents. This is the deliberate inverse of the silent
|
|
503
|
+
* drop: absence of an optional integration is normal and never mentioned,
|
|
504
|
+
* but a default that IS active changes subagent behavior (remote compaction
|
|
505
|
+
* on codex models) invisibly — so it gets one info notice per process per
|
|
506
|
+
* provider+root, not one per dispatch. User-configured sources never get
|
|
507
|
+
* here: the user installed them knowingly and they fail closed.
|
|
508
|
+
*/
|
|
509
|
+
export function noticeProviderExtensionLoaded(
|
|
510
|
+
provider: string | undefined,
|
|
511
|
+
root: string,
|
|
512
|
+
): void {
|
|
513
|
+
const providerName = provider?.trim() || "provider";
|
|
514
|
+
const key = `${providerName.toLowerCase()}\0${root}`;
|
|
515
|
+
if (noticedProviderExtensionRoots.has(key)) return;
|
|
516
|
+
const notify = providerExtensionNotifier;
|
|
517
|
+
if (!notify) return;
|
|
518
|
+
const label = basename(root) || root;
|
|
519
|
+
try {
|
|
520
|
+
notify(`⚡ ${label} integration active for ${providerName} subagents`);
|
|
521
|
+
noticedProviderExtensionRoots.add(key);
|
|
522
|
+
} catch {
|
|
523
|
+
// Cosmetic notice on a stale ctx — fail open (status.ts precedent for
|
|
524
|
+
// cached-ctx notify): drop the notifier, keep the key un-noticed so a
|
|
525
|
+
// live ctx can still surface it later. Delegation is unaffected.
|
|
526
|
+
providerExtensionNotifier = undefined;
|
|
527
|
+
}
|
|
528
|
+
}
|