@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1444 -2
  3. package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
  4. package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
  5. package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
  6. package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
  7. package/dist/index.ts +9874 -0
  8. package/dist/index.ts.map +7 -0
  9. package/package.json +82 -4
  10. package/src/abortable.ts +43 -0
  11. package/src/agent-color.ts +161 -0
  12. package/src/agent-file-toggle.ts +298 -0
  13. package/src/agent-manager.ts +2431 -0
  14. package/src/agent-runner.ts +1703 -0
  15. package/src/agent-source-badge.ts +75 -0
  16. package/src/agent-types.ts +346 -0
  17. package/src/child-context.ts +33 -0
  18. package/src/clock.ts +25 -0
  19. package/src/completion-outbox.ts +144 -0
  20. package/src/context.ts +58 -0
  21. package/src/cross-extension-rpc.ts +200 -0
  22. package/src/custom-agents.ts +441 -0
  23. package/src/default-agents.ts +126 -0
  24. package/src/detached-results.ts +150 -0
  25. package/src/enabled-models.ts +181 -0
  26. package/src/enterprise-local.ts +67 -0
  27. package/src/env.ts +33 -0
  28. package/src/error-message.ts +11 -0
  29. package/src/git-path.ts +66 -0
  30. package/src/group-join.ts +142 -0
  31. package/src/home-paths.ts +27 -0
  32. package/src/index.ts +5047 -0
  33. package/src/invocation-config.ts +158 -0
  34. package/src/memory.ts +179 -0
  35. package/src/mention-clone.ts +200 -0
  36. package/src/mention.ts +141 -0
  37. package/src/model-call.ts +83 -0
  38. package/src/model-resolver.ts +333 -0
  39. package/src/model-scope.ts +71 -0
  40. package/src/model-selection.ts +170 -0
  41. package/src/model-tiers.ts +176 -0
  42. package/src/nested-tools.ts +512 -0
  43. package/src/notification-result.ts +118 -0
  44. package/src/output-file.ts +186 -0
  45. package/src/package-resources.ts +639 -0
  46. package/src/plan-mode-children.ts +176 -0
  47. package/src/process-budget.ts +346 -0
  48. package/src/prompts.ts +161 -0
  49. package/src/quota-admission.ts +121 -0
  50. package/src/resource-loader.ts +65 -0
  51. package/src/schedule-store.ts +153 -0
  52. package/src/schedule.ts +514 -0
  53. package/src/settings.ts +851 -0
  54. package/src/skill-loader.ts +288 -0
  55. package/src/stale-ctx.ts +16 -0
  56. package/src/startup-error.ts +29 -0
  57. package/src/status-note.ts +113 -0
  58. package/src/structured-output.ts +130 -0
  59. package/src/types.ts +487 -0
  60. package/src/ui/agent-mention.ts +219 -0
  61. package/src/ui/agent-row.ts +245 -0
  62. package/src/ui/agent-widget.ts +722 -0
  63. package/src/ui/conversation-viewer.ts +837 -0
  64. package/src/ui/fallback-flag.ts +114 -0
  65. package/src/ui/fleet-list.ts +1020 -0
  66. package/src/ui/fleet-state.ts +154 -0
  67. package/src/ui/notification-view.ts +139 -0
  68. package/src/ui/schedule-menu.ts +105 -0
  69. package/src/ui/select-item.ts +45 -0
  70. package/src/ui/viewer-keys.ts +39 -0
  71. package/src/ui/workflow-card.ts +459 -0
  72. package/src/ui/workflow-dialog.ts +1138 -0
  73. package/src/ui/workflow-menu.ts +193 -0
  74. package/src/usage.ts +167 -0
  75. package/src/workflow/collisions.ts +123 -0
  76. package/src/workflow/entry.ts +47 -0
  77. package/src/workflow/host.ts +398 -0
  78. package/src/workflow/journal.ts +176 -0
  79. package/src/workflow/json-schema.ts +129 -0
  80. package/src/workflow/meta.ts +329 -0
  81. package/src/workflow/progress.ts +554 -0
  82. package/src/workflow/runtime.ts +1249 -0
  83. package/src/workflow/saved.ts +259 -0
  84. package/src/workflow/task.ts +317 -0
  85. package/src/workflow/tool-description.ts +200 -0
  86. package/src/workflow/worker-source.ts +903 -0
  87. package/src/worktree.ts +520 -0
  88. package/src/xml.ts +13 -0
@@ -0,0 +1,639 @@
1
+ /**
2
+ * package-resources.ts — Discover agent and workflow files that installed pi
3
+ * packages declare in their `package.json`.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Pi already ships four resource types inside packages — `extensions`, `skills`,
8
+ * `prompts`, `themes` — resolved from a `pi` manifest key or, when there is no
9
+ * `pi` key at all, from convention directories. Subagents are *our* concept, so
10
+ * pi knows nothing about them and a package author's only option was to copy
11
+ * `.md` files into `.pi/agents/` on every container start (#109).
12
+ *
13
+ * ## The contract
14
+ *
15
+ * Two-sided, deliberately:
16
+ *
17
+ * 1. The package author opts in by naming the paths in `package.json`.
18
+ * 2. The user opts in by running `pi install`, which is already the trust
19
+ * decision for that package — pi executes its `extensions/` and injects its
20
+ * `skills/` into the system prompt with no further prompt. A declared `.md`
21
+ * agent is strictly less privileged than either, so it rides the boundary
22
+ * the user already crossed rather than inventing a second one.
23
+ *
24
+ * There is deliberately **no convention-directory fallback**. Pi scans `skills/`
25
+ * only when a package has no `pi` key whatsoever; we never scan an undeclared
26
+ * `agents/`. A package that happens to carry an `agents/` folder for some other
27
+ * tool must not start contributing subagents to pi because it was installed for
28
+ * an unrelated reason.
29
+ *
30
+ * ## Which packages are visible
31
+ *
32
+ * Only what pi itself has configured — `settings.json -> packages[]`, global and
33
+ * project — read through pi's own `DefaultPackageManager.listConfiguredPackages()`.
34
+ * That call is synchronous, pure, and never installs anything; it also resolves
35
+ * npm / git / local install roots, pnpm global roots and legacy fallbacks, which
36
+ * is why it is worth borrowing rather than reimplementing.
37
+ *
38
+ * `node_modules` is never scanned. Being a transitive dependency of the user's
39
+ * project means nothing here — the same rule pi applies to itself.
40
+ *
41
+ * ## Accepted manifest spellings
42
+ *
43
+ * All four mean the same thing. The `pi-subagents` top-level key exists for
44
+ * cross-compatibility with `nicobailon/pi-subagents`, which reads the same two
45
+ * shapes, so a package author writes one manifest that works on either extension.
46
+ *
47
+ * "pi": { "subagents": { "agents": ["./agents"], "workflows": ["./flows"] } }
48
+ * "pi": { "subagents": ["./agents"] } // shorthand for { agents }
49
+ * "pi-subagents": { "agents": ["./agents"] }
50
+ * "pi-subagents": ["./agents"] // shorthand for { agents }
51
+ *
52
+ * Adding `pi.subagents` is safe for pi itself: its `readPiManifest` reads only
53
+ * `extensions`/`skills`/`prompts`/`themes` and ignores every other key.
54
+ */
55
+
56
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
57
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
58
+ import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
59
+ import { normalizeHomePath, resolveAgentDir } from "./home-paths.js";
60
+
61
+ /** Resource kinds a package may declare. Agents are `.md`, workflows are `.js`. */
62
+ export type PackageResourceKind = "agents" | "workflows";
63
+
64
+ /** File extension scanned for each kind, mirroring the local roots for each. */
65
+ const KIND_EXTENSION: Record<PackageResourceKind, string> = {
66
+ agents: ".md",
67
+ workflows: ".js",
68
+ };
69
+
70
+ /**
71
+ * Per-file size ceiling for package-provided resources, matching Claude Code's
72
+ * plugin-agent limit. These files come from third-party packages and are read
73
+ * eagerly on every agent load; a pathological one should be skipped, not
74
+ * `readFileSync`'d into the session.
75
+ */
76
+ export const MAX_PACKAGE_RESOURCE_BYTES = 1_048_576;
77
+
78
+ /** One installed pi package that declared something we understand. */
79
+ export interface PiPackage {
80
+ /** The settings source string, e.g. `npm:@foo/bar@1.0.0` or an absolute path. */
81
+ source: string;
82
+ /** `package.json` `name`, when the manifest could be read. */
83
+ name?: string;
84
+ /** Unscoped, lowercased short name (`@scope/foo` becomes `foo`) for allowlist matching. */
85
+ shortName?: string;
86
+ /** Which settings scope configured it. */
87
+ scope: "user" | "project";
88
+ /** Absolute path of the installed package root. */
89
+ root: string;
90
+ /**
91
+ * What its manifest declared. Carried here rather than re-read downstream:
92
+ * `listPiPackages` has to parse the manifest anyway to know whether the
93
+ * package declares anything at all, and reading it twice would leave two
94
+ * places that could disagree about what a package said.
95
+ */
96
+ declared: DeclaredEntries;
97
+ }
98
+
99
+ /**
100
+ * The gate for one resource kind, as persisted in our settings:
101
+ * `true` (default) = every declaring package, `false` = none, `string[]` =
102
+ * only packages whose short name, full name, or source matches an entry.
103
+ */
104
+ export type PackageGate = boolean | string[] | undefined;
105
+
106
+ export interface PackageDiscoveryOptions {
107
+ /** Home directory for user settings and installed resources, resolved at call time. */
108
+ homeDir?: string;
109
+ /**
110
+ * Pi's project-trust state for `cwd`, from `ctx.isProjectTrusted()`. When
111
+ * false, `.pi/settings.json` is not read — so a package configured only by an
112
+ * untrusted project is invisible to us, exactly as it is to pi. Omitted falls
113
+ * back to the session state set by {@link setProjectTrusted}.
114
+ */
115
+ projectTrusted?: boolean;
116
+ /** Gate override. Omitted falls back to the session state for the kind. */
117
+ gate?: PackageGate;
118
+ }
119
+
120
+ // ---- Session state ----
121
+ //
122
+ // Held here rather than threaded through every caller, matching how the rest of
123
+ // this extension carries session-wide settings (`setMaxSubagentDepth`,
124
+ // `setWorktreeIsolationEnabled`, ...). The alternative was an options bag
125
+ // travelling from `index.ts` through `resolveWorkflowScript`,
126
+ // `resolveWorkflowSource`, `readSavedWorkflow` and `listSavedWorkflows` purely
127
+ // so a gate could be consulted at the bottom.
128
+ //
129
+ // Both gates start `undefined`, which reads as `true`: absent settings mean
130
+ // every declaring package contributes, matching pi's own default for the
131
+ // resources it owns.
132
+
133
+ let agentsGate: PackageGate;
134
+ let workflowsGate: PackageGate;
135
+ /**
136
+ * Defaults to `false` — an extension activates before any session context
137
+ * exists, and pi's persisted trust file is not a substitute for its canonical
138
+ * answer (project trust events and `--approve`/`--noapprove` override it). Only
139
+ * `ctx.isProjectTrusted()` on `session_start` raises it, via `setProjectTrusted`.
140
+ */
141
+ let projectTrustedState = false;
142
+
143
+ /** Apply the `packageAgents` setting. Drops the cache, since roots may change. */
144
+ export function setPackageAgentsGate(gate: PackageGate): void {
145
+ agentsGate = gate;
146
+ invalidatePackageCache();
147
+ }
148
+
149
+ /** Apply the `packageWorkflows` setting. Drops the cache, since roots may change. */
150
+ export function setPackageWorkflowsGate(gate: PackageGate): void {
151
+ workflowsGate = gate;
152
+ invalidatePackageCache();
153
+ }
154
+
155
+ export function getPackageAgentsGate(): PackageGate {
156
+ return agentsGate;
157
+ }
158
+
159
+ export function getPackageWorkflowsGate(): PackageGate {
160
+ return workflowsGate;
161
+ }
162
+
163
+ /**
164
+ * Record pi's project-trust answer. Drops the cache when the answer changes,
165
+ * since a project's `packages[]` becomes visible or invisible with it. A repeat
166
+ * of the same answer is a no-op — `session_start` fires on every new, resumed
167
+ * and forked session, and it has its own reason to invalidate.
168
+ */
169
+ export function setProjectTrusted(trusted: boolean): void {
170
+ if (projectTrustedState === trusted) return;
171
+ projectTrustedState = trusted;
172
+ invalidatePackageCache();
173
+ }
174
+
175
+ /**
176
+ * Pi's project-trust answer as last recorded, `false` until one is. The loader
177
+ * for project-supplied agent files reads this, so a missing answer fails closed.
178
+ */
179
+ export function isProjectTrusted(): boolean {
180
+ return projectTrustedState;
181
+ }
182
+
183
+ /** Reset every session-scoped value. Tests only — the extension never un-loads. */
184
+ export function resetPackageState(): void {
185
+ agentsGate = undefined;
186
+ workflowsGate = undefined;
187
+ projectTrustedState = false;
188
+ invalidatePackageCache();
189
+ }
190
+
191
+ /** What a package declared, after normalising the accepted spellings. */
192
+ export interface DeclaredEntries {
193
+ agents?: string[];
194
+ workflows?: string[];
195
+ }
196
+
197
+ /** Absolute paths a declaration resolved to, split by what the loader needs. */
198
+ export interface ResolvedResourcePaths {
199
+ dirs: string[];
200
+ files: string[];
201
+ /**
202
+ * Canonical paths of `!` entries. Kept separate from `dirs`/`files` rather
203
+ * than subtracted from them because an exclusion usually names a file *inside*
204
+ * an included directory, which is not enumerated until load time.
205
+ */
206
+ excluded: ReadonlySet<string>;
207
+ }
208
+
209
+ // ---- Manifest reading ----
210
+
211
+ /** A string array, or undefined for anything else. Mirrors pi's `readPiManifest` strictness. */
212
+ function stringArray(val: unknown): string[] | undefined {
213
+ return Array.isArray(val) && val.every(e => typeof e === "string") ? (val as string[]) : undefined;
214
+ }
215
+
216
+ /**
217
+ * Normalize one accepted spelling into `{ agents, workflows }`.
218
+ * A bare array is the `agents` shorthand — the only kind that existed when the
219
+ * shorthand was worth having, and the shape the other fork's docs show.
220
+ */
221
+ function normalizeDeclaration(val: unknown): DeclaredEntries | undefined {
222
+ const asArray = stringArray(val);
223
+ if (asArray) return { agents: asArray };
224
+ if (!val || typeof val !== "object" || Array.isArray(val)) return undefined;
225
+ const obj = val as Record<string, unknown>;
226
+ const agents = stringArray(obj.agents);
227
+ const workflows = stringArray(obj.workflows);
228
+ if (!agents && !workflows) return undefined;
229
+ return { agents, workflows };
230
+ }
231
+
232
+ /**
233
+ * Read the subagent declaration out of a parsed `package.json`.
234
+ *
235
+ * `pi.subagents` wins over the top-level `pi-subagents` when a package carries
236
+ * both — the pi-namespaced key is the primary spelling here, and a package that
237
+ * writes both almost certainly means them to be identical anyway.
238
+ */
239
+ export function readSubagentManifest(pkg: unknown): DeclaredEntries | undefined {
240
+ if (!pkg || typeof pkg !== "object") return undefined;
241
+ const obj = pkg as Record<string, unknown>;
242
+ const piKey = obj.pi;
243
+ if (piKey && typeof piKey === "object" && !Array.isArray(piKey)) {
244
+ const nested = normalizeDeclaration((piKey as Record<string, unknown>).subagents);
245
+ if (nested) return nested;
246
+ }
247
+ return normalizeDeclaration(obj["pi-subagents"]);
248
+ }
249
+
250
+ /** Parse `<root>/package.json`, or undefined when it is missing or malformed. */
251
+ function readPackageJson(root: string): Record<string, unknown> | undefined {
252
+ try {
253
+ const parsed: unknown = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
254
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
255
+ } catch {
256
+ return undefined;
257
+ }
258
+ }
259
+
260
+ /** `@scope/foo` becomes `foo`, lowercased. The name a user types in an allowlist. */
261
+ export function unscopedShortName(name: string): string {
262
+ const short = name.startsWith("@") ? name.slice(name.indexOf("/") + 1) : name;
263
+ return short.toLowerCase();
264
+ }
265
+
266
+ // ---- Path resolution ----
267
+
268
+ /**
269
+ * Canonical absolute path, resolving symlinks as far as the path exists.
270
+ *
271
+ * A plain `realpathSync` is not enough: an exclusion may name a file the package
272
+ * does not ship yet, and a bare `resolve()` fallback would leave it uncanonical
273
+ * — which on macOS means `/var/...` compared against a canonical `/private/var/...`
274
+ * root, so the containment check below rejects it. Canonicalizing the nearest
275
+ * existing ancestor and re-appending the rest keeps both sides comparable.
276
+ */
277
+ function realOrSelf(path: string): string {
278
+ const abs = resolve(path);
279
+ try {
280
+ return realpathSync(abs);
281
+ } catch {
282
+ const parent = dirname(abs);
283
+ return parent === abs ? abs : join(realOrSelf(parent), basename(abs));
284
+ }
285
+ }
286
+
287
+ /**
288
+ * True when `candidate` is inside `root`. Package manifests are third-party
289
+ * input, so `"../../.ssh"` must not become a scanned directory — pi refuses the
290
+ * same shape with "Refusing to use path outside package install root".
291
+ *
292
+ * Compared on canonical paths, because `..` is not the only way out: a symlinked
293
+ * `agents/` pointing at `/etc` is textually inside the root, and `readdirSync`
294
+ * follows it. Both sides are canonicalized so a package installed *under* a
295
+ * symlink (`/var` → `/private/var` on macOS) still matches itself.
296
+ */
297
+ function isInside(root: string, candidate: string): boolean {
298
+ const rel = relative(realOrSelf(root), realOrSelf(candidate));
299
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
300
+ }
301
+
302
+ /**
303
+ * Resolve one manifest entry against the package root, returning the path only
304
+ * when it stays inside the package and exists.
305
+ *
306
+ * Globs are deliberately not supported. Pi's own manifest expands them because
307
+ * it resolves four resource types across arbitrary layouts; here the entries are
308
+ * one or two directories, and a glob would mean walking a third-party tree to
309
+ * answer. A directory or a file path covers the declared use cases.
310
+ */
311
+ function expandEntry(root: string, entry: string): string | undefined {
312
+ const target = resolve(root, entry);
313
+ if (!isInside(root, target) || !existsSync(target)) return undefined;
314
+ return target;
315
+ }
316
+
317
+ /**
318
+ * Resolve one kind's declared entries into concrete absolute paths inside the
319
+ * package: directories to scan, and individual files.
320
+ *
321
+ * Split into `{ dirs, files }` rather than a flat list because the agent loader
322
+ * takes directories (it does its own `readdirSync` and frontmatter handling),
323
+ * while a manifest entry naming a single file has to be surfaced on its own.
324
+ *
325
+ * `!entry` marks an exclusion. Exact paths only — pi's own `!` is gitignore-style
326
+ * glob negation, and globs are not supported here for the reason above.
327
+ */
328
+ export function resolveDeclaredPaths(
329
+ root: string,
330
+ entries: string[] | undefined,
331
+ kind: PackageResourceKind,
332
+ ): ResolvedResourcePaths {
333
+ const dirs: string[] = [];
334
+ const files: string[] = [];
335
+ const excluded = new Set<string>();
336
+ if (!entries) return { dirs, files, excluded };
337
+
338
+ // Exclusions are collected first and stored canonically, so `!./agents/wip.md`
339
+ // matches the same file however the loader spelled its way to it. They do not
340
+ // have to exist: a package may exclude a path it ships conditionally.
341
+ for (const entry of entries) {
342
+ if (!entry.startsWith("!")) continue;
343
+ const target = resolve(root, entry.slice(1));
344
+ if (isInside(root, target)) excluded.add(realOrSelf(target));
345
+ }
346
+
347
+ const ext = KIND_EXTENSION[kind];
348
+ for (const entry of entries) {
349
+ if (entry.startsWith("!")) continue;
350
+ const path = expandEntry(root, entry);
351
+ if (!path || excluded.has(realOrSelf(path))) continue;
352
+ let stat: ReturnType<typeof statSync>;
353
+ try {
354
+ stat = statSync(path);
355
+ } catch {
356
+ continue;
357
+ }
358
+ if (stat.isDirectory()) {
359
+ if (!dirs.includes(path)) dirs.push(path);
360
+ } else if (stat.isFile() && path.endsWith(ext) && !files.includes(path)) {
361
+ files.push(path);
362
+ }
363
+ }
364
+ return { dirs, files, excluded };
365
+ }
366
+
367
+ // ---- Package enumeration ----
368
+
369
+ interface ConfiguredPackageRow {
370
+ source: string;
371
+ scope: "user" | "project";
372
+ installedPath?: string;
373
+ }
374
+
375
+ /**
376
+ * Build a read-only view of pi's configured packages.
377
+ *
378
+ * Feature-detected rather than assumed: the peer range `>=1.1.0` is open-ended,
379
+ * so a later pi that drops or renames this API contributes no packages instead
380
+ * of failing activation.
381
+ */
382
+ function configuredPackages(cwd: string, projectTrusted: boolean, homeDir?: string): ConfiguredPackageRow[] {
383
+ try {
384
+ if (typeof DefaultPackageManager !== "function") return [];
385
+ const agentDir = resolveAgentDir(homeDir);
386
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
387
+ const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
388
+ if (typeof manager.listConfiguredPackages !== "function") return [];
389
+ if (homeDir === undefined) return manager.listConfiguredPackages();
390
+ // Pi's enumerator expands local package sources through its own ambient
391
+ // home. Reuse its public installed-path resolver with normalized inputs,
392
+ // preserving the original source for package-gate matching and diagnostics.
393
+ const rows: ConfiguredPackageRow[] = [];
394
+ for (const scope of ["user", "project"] as const) {
395
+ const settings = scope === "user" ? settingsManager.getGlobalSettings() : settingsManager.getProjectSettings();
396
+ for (const pkg of settings.packages ?? []) {
397
+ const source = typeof pkg === "string" ? pkg : pkg.source;
398
+ rows.push({ source, scope, installedPath: manager.getInstalledPath(normalizeHomePath(source, homeDir), scope) });
399
+ }
400
+ }
401
+ return rows;
402
+ } catch {
403
+ return [];
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Every installed pi package that declares subagent resources, in pi's own
409
+ * order (global entries first, then project). Duplicates by root are collapsed —
410
+ * a package listed in both scopes is one package on disk.
411
+ */
412
+ export function listPiPackages(cwd: string, projectTrusted = false, homeDir?: string): PiPackage[] {
413
+ const seen = new Set<string>();
414
+ const out: PiPackage[] = [];
415
+ for (const entry of configuredPackages(cwd, projectTrusted, homeDir)) {
416
+ const root = entry.installedPath;
417
+ if (!root || seen.has(root)) continue;
418
+ const manifest = readPackageJson(root);
419
+ const declared = manifest ? readSubagentManifest(manifest) : undefined;
420
+ if (!manifest || !declared) continue;
421
+ seen.add(root);
422
+ const name = typeof manifest.name === "string" ? manifest.name : undefined;
423
+ out.push({
424
+ source: entry.source,
425
+ name,
426
+ shortName: name ? unscopedShortName(name) : undefined,
427
+ scope: entry.scope,
428
+ root,
429
+ declared,
430
+ });
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /**
436
+ * Whether `pkg` passes the gate. `true`/omitted admits everything; an array
437
+ * matches case-insensitively against the unscoped short name, the full package
438
+ * name, or the settings source string — users reach for whichever of the three
439
+ * they happen to have in front of them.
440
+ */
441
+ export function packageAllowed(pkg: PiPackage, gate: PackageGate): boolean {
442
+ if (gate === false) return false;
443
+ if (gate === undefined || gate === true) return true;
444
+ const candidates = new Set(
445
+ [pkg.shortName, pkg.name?.toLowerCase(), pkg.source.toLowerCase()].filter(
446
+ (v): v is string => typeof v === "string",
447
+ ),
448
+ );
449
+ return gate.some(allowed => candidates.has(allowed.trim().toLowerCase()));
450
+ }
451
+
452
+ // ---- Caching ----
453
+
454
+ /** An admitted package's install root and the name to show for it. */
455
+ interface PackageOwner {
456
+ /** Normalized, so it compares against a normalized candidate path. */
457
+ root: string;
458
+ name: string;
459
+ }
460
+
461
+ /**
462
+ * `loadCustomAgents` runs on activation and again on every `Agent` call, so an
463
+ * uncached scan would re-read pi's settings and every package manifest per
464
+ * spawn. What is cached is only the *root list* — which packages declare what —
465
+ * because that changes when settings change, not when a file is edited. The
466
+ * agent files themselves are still re-read on every load, so editing a linked
467
+ * package's agent still takes effect immediately.
468
+ *
469
+ * Keyed by cwd because nested subagents load from `context.configCwd`, which is
470
+ * not necessarily the main session's directory, and by the gate because a
471
+ * settings change must not be served a stale allowlist.
472
+ */
473
+ interface CacheEntry {
474
+ agents: ResolvedResourcePaths;
475
+ workflows: ResolvedResourcePaths;
476
+ /**
477
+ * Every package the gate admitted, longest root first, so a path can be
478
+ * traced back to the package that contributed it. Recorded during the scan
479
+ * that already enumerated them rather than re-derived later: a second pass
480
+ * would re-read pi's settings and every manifest, and could disagree with
481
+ * this entry about which packages the gate let through.
482
+ */
483
+ owners: PackageOwner[];
484
+ }
485
+
486
+ /** A resolved-paths value with nothing in it. Fresh each call — callers get copies. */
487
+ function emptyPaths(): ResolvedResourcePaths {
488
+ return { dirs: [], files: [], excluded: new Set() };
489
+ }
490
+
491
+ /** A whole cache entry with nothing in it, for a gate that admits no package. */
492
+ function emptyEntry(): CacheEntry {
493
+ return { agents: emptyPaths(), workflows: emptyPaths(), owners: [] };
494
+ }
495
+ const cache = new Map<string, CacheEntry>();
496
+
497
+ /** Drop the memoized package scan. Called on `/reload` and after settings changes. */
498
+ export function invalidatePackageCache(): void {
499
+ cache.clear();
500
+ }
501
+
502
+ function scan(cwd: string, projectTrusted: boolean, gate: PackageGate, homeDir?: string): CacheEntry {
503
+ const entry = emptyEntry();
504
+ for (const pkg of listPiPackages(cwd, projectTrusted, homeDir)) {
505
+ if (!packageAllowed(pkg, gate)) continue;
506
+ // The root is normalized here, not at lookup time: the declared paths below
507
+ // go through `resolve(root, entry)`, so a root that arrived with a trailing
508
+ // separator would never prefix-match the very paths it produced.
509
+ //
510
+ // `name` is absent only when the manifest had no usable `name` string; the
511
+ // settings source (`npm:@foo/bar@1.0.0`) is the last resort, which is what
512
+ // pi itself falls back to when it labels a package-provided resource.
513
+ entry.owners.push({ root: resolve(pkg.root), name: pkg.name ?? pkg.shortName ?? pkg.source });
514
+ for (const kind of ["agents", "workflows"] as const) {
515
+ const resolvedPaths = resolveDeclaredPaths(pkg.root, pkg.declared[kind], kind);
516
+ entry[kind].dirs.push(...resolvedPaths.dirs);
517
+ entry[kind].files.push(...resolvedPaths.files);
518
+ // One flat set across packages: an exclusion is an absolute path, so it
519
+ // can only ever match inside the package that wrote it.
520
+ for (const path of resolvedPaths.excluded) (entry[kind].excluded as Set<string>).add(path);
521
+ }
522
+ }
523
+ // Longest root first, so a package installed inside another's `node_modules`
524
+ // claims its own files rather than losing them to its host.
525
+ entry.owners.sort((a, b) => b.root.length - a.root.length);
526
+ return entry;
527
+ }
528
+
529
+ function cacheEntryFor(cwd: string, kind: PackageResourceKind, opts: PackageDiscoveryOptions): CacheEntry {
530
+ const gate = opts.gate ?? (kind === "agents" ? agentsGate : workflowsGate);
531
+ // An empty allowlist matches nothing, like `false` — see `sanitizePackageGate`
532
+ // in settings.ts for why an empty array is kept rather than dropped.
533
+ if (gate === false || (Array.isArray(gate) && gate.length === 0)) return emptyEntry();
534
+ const projectTrusted = opts.projectTrusted ?? projectTrustedState;
535
+ // `\u0000` written as an escape, not a raw byte: a literal NUL makes this
536
+ // whole file read as binary to grep, ripgrep and git, which silently drops
537
+ // it from search results.
538
+ const key = `${cwd}\u0000${opts.homeDir ?? ""}\u0000${resolveAgentDir(opts.homeDir)}\u0000${projectTrusted ? 1 : 0}\u0000${JSON.stringify(gate ?? true)}`;
539
+ let hit = cache.get(key);
540
+ if (!hit) {
541
+ hit = scan(cwd, projectTrusted, gate, opts.homeDir);
542
+ cache.set(key, hit);
543
+ }
544
+ return hit;
545
+ }
546
+
547
+ function resolved(cwd: string, kind: PackageResourceKind, opts: PackageDiscoveryOptions): ResolvedResourcePaths {
548
+ return cacheEntryFor(cwd, kind, opts)[kind];
549
+ }
550
+
551
+ /**
552
+ * Name of the installed package that provided `path`, or undefined when no
553
+ * admitted package owns it. What `/agents` shows so a package agent can name its
554
+ * origin, and what a user needs in order to write a `packageAgents` allowlist.
555
+ *
556
+ * `kind` selects which gate to consult, because the two are configured
557
+ * separately: a package may be admitted for agents but not for workflows.
558
+ *
559
+ * A plain prefix test is enough. `resolveDeclaredPaths` builds every declared
560
+ * path as `resolve(root, entry)` without canonicalizing it — only the
561
+ * containment check and the exclusion set go through `realOrSelf` — so a
562
+ * `sourcePath` the loader derived from one is textually inside the root it came
563
+ * from. The `sep` anchor keeps `…/node_modules/foo` from claiming
564
+ * `…/node_modules/foo-bar`.
565
+ */
566
+ export function packageNameForPath(
567
+ path: string,
568
+ cwd: string,
569
+ kind: PackageResourceKind = "agents",
570
+ opts: PackageDiscoveryOptions = {},
571
+ ): string | undefined {
572
+ const abs = resolve(path);
573
+ return cacheEntryFor(cwd, kind, opts).owners.find(
574
+ o => abs === o.root || abs.startsWith(o.root + sep),
575
+ )?.name;
576
+ }
577
+
578
+ /**
579
+ * Directories of package-declared agent files, lowest-precedence first.
580
+ * Fed to `loadCustomAgents` ahead of the global and project roots.
581
+ */
582
+ export function packageAgentDirs(cwd: string, opts: PackageDiscoveryOptions = {}): string[] {
583
+ // A copy: the cache holds this array for the rest of the session, so a caller
584
+ // that sorted or spliced it would corrupt every later load.
585
+ return [...resolved(cwd, "agents", opts).dirs];
586
+ }
587
+
588
+ /** Individually declared agent `.md` files (a manifest entry naming a file, not a directory). */
589
+ export function packageAgentFiles(cwd: string, opts: PackageDiscoveryOptions = {}): string[] {
590
+ return [...resolved(cwd, "agents", opts).files];
591
+ }
592
+
593
+ /**
594
+ * Whether a `!` entry in some package manifest excludes this path.
595
+ *
596
+ * Checked at load time rather than subtracted during resolution, because the
597
+ * common exclusion — `["./agents", "!./agents/wip.md"]` — names a file inside a
598
+ * directory that is not enumerated until the loader reads it.
599
+ */
600
+ export function isPackageResourceExcluded(
601
+ path: string,
602
+ cwd: string,
603
+ kind: PackageResourceKind,
604
+ opts: PackageDiscoveryOptions = {},
605
+ ): boolean {
606
+ const excluded = resolved(cwd, kind, opts).excluded;
607
+ return excluded.size > 0 && excluded.has(realOrSelf(path));
608
+ }
609
+
610
+ /**
611
+ * Directories of package-declared workflow scripts, appended to
612
+ * `savedWorkflowRoots` so a name resolves there last.
613
+ */
614
+ export function packageWorkflowDirs(cwd: string, opts: PackageDiscoveryOptions = {}): string[] {
615
+ return [...resolved(cwd, "workflows", opts).dirs];
616
+ }
617
+
618
+ /**
619
+ * Individually declared workflow scripts, as `name -> absolute path`.
620
+ *
621
+ * A name lookup wants a directory, and a declared *file* has none — but standing
622
+ * its parent directory in as a root would make every sibling `.js` resolvable,
623
+ * so `workflows: ["./one.js"]` would quietly offer the package's other scripts
624
+ * too, and `["./index.js"]` would offer everything at the package root. That is
625
+ * the opposite of the rule the whole feature rests on, that a package only
626
+ * contributes what it names. An exact map keeps the declaration honest, and
627
+ * matches how a declared agent file is already handled.
628
+ *
629
+ * Later entries lose a name clash, so a package's own directory-sourced script
630
+ * is not displaced by a file entry from a package resolved after it.
631
+ */
632
+ export function packageWorkflowFiles(cwd: string, opts: PackageDiscoveryOptions = {}): Map<string, string> {
633
+ const out = new Map<string, string>();
634
+ for (const path of resolved(cwd, "workflows", opts).files) {
635
+ const name = basename(path, KIND_EXTENSION.workflows);
636
+ if (!out.has(name)) out.set(name, path);
637
+ }
638
+ return out;
639
+ }