feat(extensions): wire up topological sort and unified registry filtering (#3152)

- Add extension-manifest.ts and extension-sort.ts to pi-coding-agent
  with manifest reading and Kahn's BFS topological sort algorithm
- Add extensionPathsTransform hook to DefaultResourceLoader that runs
  between path merging and loadExtensions() — enables pre-load
  filtering and reordering without modifying pi internals
- Wire GSD's buildResourceLoader() to provide a transform that:
  1. Filters ALL extensions (including community) through the GSD registry
  2. Sorts in topological dependency order via sortExtensionPaths()
- Mark discoverAndLoadExtensions() as @deprecated (dead code path)
- Add 16 tests covering manifest reading, dependency sorting, cycles,
  missing deps, and non-array deps

Previously, dependencies.extensions in manifests was decorative (sort
existed but was never called), and gsd extensions disable only worked
for bundled extensions. Community extensions in ~/.gsd/agent/extensions/
bypassed the registry entirely.
This commit is contained in:
Jeremy McSpadden 2026-03-31 12:54:48 -05:00 committed by GitHub
parent f0059a5498
commit e0d130e682
11 changed files with 606 additions and 2 deletions

View file

@ -0,0 +1,138 @@
# Extension Loading: Dependency Sort + Unified Enable/Disable
## Context
GSD-2 has a well-structured extension system with three discovery paths (bundled, global/community, project-local) that are **already wired up** through pi's `DefaultPackageManager.addAutoDiscoveredResources()`. However, two critical gaps remain:
1. `sortExtensionPaths()` (topological dependency sort) is implemented but **never called**`dependencies.extensions` in manifests is decorative
2. The GSD extension registry (enable/disable) only applies to **bundled** extensions — community extensions bypass it entirely
### Architecture (Current Flow)
```
GSD loader.ts
→ discoverExtensionEntryPaths(bundledExtDir)
→ filter by GSD registry (isExtensionEnabled)
→ set GSD_BUNDLED_EXTENSION_PATHS env var
DefaultResourceLoader.reload()
→ packageManager.resolve()
→ addAutoDiscoveredResources()
→ project: cwd/.gsd/extensions/ (CONFIG_DIR_NAME = ".gsd")
→ global: ~/.gsd/agent/extensions/ (includes synced bundled)
→ loadExtensions(mergedPaths) ← NO sort, NO registry check on community
```
### Key Files
| File | Role |
|------|------|
| `src/loader.ts` (lines 146-161) | GSD startup — bundled discovery + registry filter |
| `src/extension-sort.ts` | Topological sort (Kahn's BFS) — EXISTS but NEVER CALLED |
| `src/extension-registry.ts` | Registry I/O, enable/disable, tier checks |
| `src/resource-loader.ts` (lines 589-607) | `buildResourceLoader()` — constructs DefaultResourceLoader |
| `packages/pi-coding-agent/src/core/resource-loader.ts` (lines 311-395) | `reload()` — merges paths, calls `loadExtensions()` |
| `packages/pi-coding-agent/src/core/package-manager.ts` (lines 1585-1700) | `addAutoDiscoveredResources()` — auto-discovers from .gsd/ dirs |
| `packages/pi-coding-agent/src/core/extensions/loader.ts` (lines 945-1002) | `discoverAndLoadExtensions()` — DEAD CODE, never invoked |
---
## Plan
### Task 1: Wire topological sort into extension loading
**What:** Call `sortExtensionPaths()` on the merged extension paths before passing them to `loadExtensions()`.
**Where:** `packages/pi-coding-agent/src/core/resource-loader.ts` ~line 381-385
**Before:**
```typescript
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
```
**After:**
```typescript
import { sortExtensionPaths } from '../../../src/extension-sort.js';
const { sortedPaths, warnings } = sortExtensionPaths(extensionPaths);
for (const w of warnings) {
// emit as diagnostic, not hard error
}
const extensionsResult = await loadExtensions(sortedPaths, this.cwd, this.eventBus);
```
**Consideration:** `sortExtensionPaths` lives in `src/` (GSD side), not in `packages/pi-coding-agent/`. Need to either:
- (a) Move it into pi-coding-agent as a shared utility, OR
- (b) Import it cross-package (already done for other GSD→pi imports), OR
- (c) Call it on the GSD side before paths reach pi — harder since auto-discovered paths are added inside pi's package manager
Option (a) is cleanest — the sort logic only depends on `readManifestFromEntryPath` which is also in `src/extension-registry.ts` but could be duplicated or shared.
### Task 2: Apply GSD registry to community extensions
**What:** When `buildResourceLoader()` in `src/resource-loader.ts` constructs the DefaultResourceLoader, also discover and filter community extensions from `~/.gsd/agent/extensions/` through the GSD registry — same as it already does for `~/.pi/agent/extensions/` paths.
**Where:** `src/resource-loader.ts``buildResourceLoader()` (lines 589-607)
**Current code already filters pi extensions:**
```typescript
const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir)
.filter((entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)))
.filter((entryPath) => {
const manifest = readManifestFromEntryPath(entryPath)
if (!manifest) return true
return isExtensionEnabled(registry, manifest.id)
})
```
**Add similar filtering for community extensions in agentDir:**
- Discover extensions in `~/.gsd/agent/extensions/` that are NOT bundled
- Filter through `isExtensionEnabled(registry, manifest.id)`
- Pass as disabled (via override patterns or pre-filtering) to the resource loader
**Alternative approach:** Hook into `addAutoDiscoveredResources` or the `addResource` call to check the GSD registry. This might be cleaner since the auto-discovery already happens inside pi's package manager.
### Task 3: Emit sort warnings as diagnostics
**What:** Surface dependency warnings (missing deps, cycles) through GSD's diagnostic system so users see them.
**Where:** Wherever the sort is invoked from Task 1.
**Format:**
```
⚠ Extension 'gsd-watch' declares dependency 'gsd' which is not installed — loading anyway
⚠ Extensions 'foo' and 'bar' form a dependency cycle — loading in alphabetical order
```
### Task 4: Clean up dead code
**What:** The `discoverAndLoadExtensions()` function in `packages/pi-coding-agent/src/core/extensions/loader.ts` (lines 945-1002) is exported but never invoked. The project-local trust model inside it (`getUntrustedExtensionPaths`) also never runs.
**Options:**
- (a) Remove it entirely — it's dead
- (b) Mark deprecated — in case upstream pi uses it
- (c) Leave it — lowest risk
Recommend (b) for now — add `@deprecated` JSDoc so it doesn't grow new callers.
### Task 5: Tests
- **Sort integration test:** Create two extensions where A depends on B. Verify B loads before A after sort.
- **Registry community test:** Drop a community extension in `~/.gsd/agent/extensions/`, run `gsd extensions disable <id>`, verify it doesn't load.
- **Conflict test:** Same extension ID in project-local and global — verify project-local wins.
- **Missing dep test:** Extension declares dependency on non-existent extension — verify warning emitted, extension still loads.
- **Cycle test:** Two extensions that depend on each other — verify warning, both load.
---
## Follow-up PR (separate)
**Subagent extension forwarding:** Update `src/resources/extensions/subagent/index.ts` to forward ALL extension paths (not just bundled) to child processes. May need a second env var like `GSD_COMMUNITY_EXTENSION_PATHS` or consolidate into `GSD_EXTENSION_PATHS`.
---
## Open Questions
1. **Where should `sortExtensionPaths` live?** Currently in `src/` (GSD side). Needs to be callable from pi's resource-loader. Options: move to pi, keep and import cross-package, or duplicate.
2. **Should community extensions respect the same registry as bundled?** Or should they have their own enable/disable mechanism? Current plan unifies them.
3. **Project-local trust:** The TOFU model in the dead `discoverAndLoadExtensions()` never runs. Should `addAutoDiscoveredResources` also gate project-local extensions behind trust? Or is `.gsd/extensions/` in your own project always trusted?

View file

@ -0,0 +1,77 @@
// GSD-2 — Extension Manifest Tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readManifest, readManifestFromEntryPath } from "./extension-manifest.js";
describe("readManifest", () => {
it("returns null for missing directory", () => {
assert.equal(readManifest("/nonexistent/path"), null);
});
it("returns null for directory without manifest", () => {
const dir = mkdtempSync(join(tmpdir(), "ext-manifest-"));
assert.equal(readManifest(dir), null);
});
it("returns null for invalid JSON", () => {
const dir = mkdtempSync(join(tmpdir(), "ext-manifest-"));
writeFileSync(join(dir, "extension-manifest.json"), "not json{{{", "utf-8");
assert.equal(readManifest(dir), null);
});
it("returns null for manifest missing required fields", () => {
const dir = mkdtempSync(join(tmpdir(), "ext-manifest-"));
writeFileSync(
join(dir, "extension-manifest.json"),
JSON.stringify({ id: "test", name: "test" }),
);
assert.equal(readManifest(dir), null);
});
it("returns valid manifest", () => {
const dir = mkdtempSync(join(tmpdir(), "ext-manifest-"));
const manifest = {
id: "test-ext",
name: "Test Extension",
version: "1.0.0",
tier: "bundled",
requires: { platform: ">=2.29.0" },
};
writeFileSync(join(dir, "extension-manifest.json"), JSON.stringify(manifest));
const result = readManifest(dir);
assert.equal(result?.id, "test-ext");
assert.equal(result?.tier, "bundled");
});
});
describe("readManifestFromEntryPath", () => {
it("reads manifest from parent of entry path", () => {
const dir = mkdtempSync(join(tmpdir(), "ext-manifest-"));
const extDir = join(dir, "my-ext");
mkdirSync(extDir);
writeFileSync(
join(extDir, "extension-manifest.json"),
JSON.stringify({
id: "my-ext",
name: "My Extension",
version: "1.0.0",
tier: "community",
}),
);
writeFileSync(join(extDir, "index.ts"), "");
const result = readManifestFromEntryPath(join(extDir, "index.ts"));
assert.equal(result?.id, "my-ext");
assert.equal(result?.tier, "community");
});
it("returns null when entry path parent has no manifest", () => {
const dir = mkdtempSync(join(tmpdir(), "ext-manifest-"));
assert.equal(readManifestFromEntryPath(join(dir, "index.ts")), null);
});
});

View file

@ -0,0 +1,62 @@
// GSD-2 — Extension Manifest: Types and reading for extension-manifest.json
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
// ─── Types ──────────────────────────────────────────────────────────────────
export interface ExtensionManifest {
id: string;
name: string;
version: string;
description: string;
tier: "core" | "bundled" | "community";
requires: { platform: string };
provides?: {
tools?: string[];
commands?: string[];
hooks?: string[];
shortcuts?: string[];
};
dependencies?: {
extensions?: string[];
runtime?: string[];
};
}
// ─── Validation ─────────────────────────────────────────────────────────────
function isManifest(data: unknown): data is ExtensionManifest {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.id === "string" &&
typeof obj.name === "string" &&
typeof obj.version === "string" &&
typeof obj.tier === "string"
);
}
// ─── Reading ────────────────────────────────────────────────────────────────
/** Read extension-manifest.json from a directory. Returns null if missing or invalid. */
export function readManifest(extensionDir: string): ExtensionManifest | null {
const manifestPath = join(extensionDir, "extension-manifest.json");
if (!existsSync(manifestPath)) return null;
try {
const raw = JSON.parse(readFileSync(manifestPath, "utf-8"));
return isManifest(raw) ? raw : null;
} catch {
return null;
}
}
/**
* Given an entry path (e.g. `.../extensions/browser-tools/index.ts`),
* resolve the parent directory and read its manifest.
*/
export function readManifestFromEntryPath(entryPath: string): ExtensionManifest | null {
const dir = dirname(entryPath);
return readManifest(dir);
}

View file

@ -0,0 +1,134 @@
// GSD-2 — Extension Sort Tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { sortExtensionPaths } from "./extension-sort.js";
function createExtDir(base: string, id: string, deps?: string[]): string {
const dir = join(base, id);
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "extension-manifest.json"),
JSON.stringify({
id,
name: id,
version: "1.0.0",
tier: "bundled",
requires: { platform: ">=2.29.0" },
...(deps ? { dependencies: { extensions: deps } } : {}),
}),
);
writeFileSync(join(dir, "index.ts"), `export default function() {}`);
return join(dir, "index.ts");
}
describe("sortExtensionPaths", () => {
it("returns empty for empty input", () => {
const result = sortExtensionPaths([]);
assert.deepEqual(result.sortedPaths, []);
assert.deepEqual(result.warnings, []);
});
it("sorts independent extensions alphabetically", () => {
const base = mkdtempSync(join(tmpdir(), "ext-sort-"));
const pathC = createExtDir(base, "charlie");
const pathA = createExtDir(base, "alpha");
const pathB = createExtDir(base, "bravo");
const result = sortExtensionPaths([pathC, pathA, pathB]);
assert.deepEqual(result.sortedPaths, [pathA, pathB, pathC]);
assert.equal(result.warnings.length, 0);
});
it("sorts dependencies before dependents", () => {
const base = mkdtempSync(join(tmpdir(), "ext-sort-"));
const pathBase = createExtDir(base, "base-ext");
const pathDependent = createExtDir(base, "dependent-ext", ["base-ext"]);
// Pass dependent first — sort should reorder
const result = sortExtensionPaths([pathDependent, pathBase]);
assert.deepEqual(result.sortedPaths, [pathBase, pathDependent]);
assert.equal(result.warnings.length, 0);
});
it("handles deep dependency chains", () => {
const base = mkdtempSync(join(tmpdir(), "ext-sort-"));
const pathA = createExtDir(base, "a");
const pathB = createExtDir(base, "b", ["a"]);
const pathC = createExtDir(base, "c", ["b"]);
const result = sortExtensionPaths([pathC, pathB, pathA]);
assert.deepEqual(result.sortedPaths, [pathA, pathB, pathC]);
assert.equal(result.warnings.length, 0);
});
it("warns about missing dependencies but still loads", () => {
const base = mkdtempSync(join(tmpdir(), "ext-sort-"));
const pathExt = createExtDir(base, "my-ext", ["nonexistent"]);
const result = sortExtensionPaths([pathExt]);
assert.equal(result.sortedPaths.length, 1);
assert.equal(result.sortedPaths[0], pathExt);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0].message, /nonexistent.*not installed/);
});
it("warns about cycles but still loads both", () => {
const base = mkdtempSync(join(tmpdir(), "ext-sort-"));
const pathA = createExtDir(base, "cycle-a", ["cycle-b"]);
const pathB = createExtDir(base, "cycle-b", ["cycle-a"]);
const result = sortExtensionPaths([pathA, pathB]);
assert.equal(result.sortedPaths.length, 2);
assert.ok(result.warnings.length > 0);
assert.ok(result.warnings.some((w) => w.message.includes("cycle")));
});
it("silently ignores self-dependencies", () => {
const base = mkdtempSync(join(tmpdir(), "ext-sort-"));
const pathExt = createExtDir(base, "self-dep", ["self-dep"]);
const result = sortExtensionPaths([pathExt]);
assert.deepEqual(result.sortedPaths, [pathExt]);
assert.equal(result.warnings.length, 0);
});
it("prepends extensions without manifests", () => {
const base = mkdtempSync(join(tmpdir(), "ext-sort-"));
const noManifestDir = join(base, "no-manifest");
mkdirSync(noManifestDir, { recursive: true });
writeFileSync(join(noManifestDir, "index.ts"), `export default function() {}`);
const noManifestPath = join(noManifestDir, "index.ts");
const pathWithManifest = createExtDir(base, "with-manifest");
const result = sortExtensionPaths([pathWithManifest, noManifestPath]);
assert.equal(result.sortedPaths[0], noManifestPath);
assert.equal(result.sortedPaths[1], pathWithManifest);
});
it("handles non-array dependencies gracefully", () => {
const base = mkdtempSync(join(tmpdir(), "ext-sort-"));
const dir = join(base, "bad-deps");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "extension-manifest.json"),
JSON.stringify({
id: "bad-deps",
name: "bad-deps",
version: "1.0.0",
tier: "bundled",
dependencies: { extensions: "not-an-array" },
}),
);
writeFileSync(join(dir, "index.ts"), `export default function() {}`);
const result = sortExtensionPaths([join(dir, "index.ts")]);
assert.equal(result.sortedPaths.length, 1);
assert.equal(result.warnings.length, 0);
});
});

View file

@ -0,0 +1,137 @@
// GSD-2 — Extension Sort: Topological dependency ordering
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { readManifestFromEntryPath } from "./extension-manifest.js";
export interface SortWarning {
declaringId: string;
missingId: string;
message: string;
}
export interface SortResult {
sortedPaths: string[];
warnings: SortWarning[];
}
/**
* Sort extension entry paths in topological dependency-first order using Kahn's BFS algorithm.
*
* - Extensions without manifests are prepended in input order.
* - Missing dependencies produce a structured warning but do not block loading.
* - Cycles produce warnings; cycle participants are appended alphabetically.
* - Self-dependencies are silently ignored.
*/
export function sortExtensionPaths(paths: string[]): SortResult {
const warnings: SortWarning[] = [];
const pathsWithoutId: string[] = [];
const idToPath = new Map<string, string>();
// Step 1: Build ID map
for (const p of paths) {
const manifest = readManifestFromEntryPath(p);
if (!manifest) {
pathsWithoutId.push(p);
} else {
idToPath.set(manifest.id, p);
}
}
// Step 2: Build graph — inDegree and dependents adjacency
const inDegree = new Map<string, number>();
const dependents = new Map<string, string[]>(); // dep → [ids that depend on dep]
for (const id of idToPath.keys()) {
if (!inDegree.has(id)) inDegree.set(id, 0);
if (!dependents.has(id)) dependents.set(id, []);
}
for (const [id, entryPath] of idToPath) {
const manifest = readManifestFromEntryPath(entryPath);
const rawDeps = manifest?.dependencies?.extensions ?? [];
const deps = Array.isArray(rawDeps) ? rawDeps : [];
for (const depId of deps) {
// Silently ignore self-deps
if (depId === id) continue;
if (!idToPath.has(depId)) {
// Missing dependency — warn and skip edge
warnings.push({
declaringId: id,
missingId: depId,
message: `Extension '${id}' declares dependency '${depId}' which is not installed — loading anyway`,
});
continue;
}
// Valid edge: id depends on depId → increment inDegree[id], add id to dependents[depId]
inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
const depDependents = dependents.get(depId) ?? [];
depDependents.push(id);
dependents.set(depId, depDependents);
}
}
// Step 3: Kahn's algorithm — start with nodes that have inDegree 0
const sorted: string[] = [];
// Ready queue: IDs with inDegree 0, maintained in alphabetical order
const ready: string[] = [...idToPath.keys()]
.filter((id) => inDegree.get(id) === 0)
.sort();
while (ready.length > 0) {
const id = ready.shift()!;
sorted.push(idToPath.get(id)!);
const deps = dependents.get(id) ?? [];
for (const depId of deps) {
const newDegree = (inDegree.get(depId) ?? 0) - 1;
inDegree.set(depId, newDegree);
if (newDegree === 0) {
// Insert into ready queue maintaining alphabetical order
const insertIdx = ready.findIndex((r) => r > depId);
if (insertIdx === -1) {
ready.push(depId);
} else {
ready.splice(insertIdx, 0, depId);
}
}
}
}
// Step 4: Cycle handling — any remaining IDs with inDegree > 0
const cycleIds = [...idToPath.keys()]
.filter((id) => (inDegree.get(id) ?? 0) > 0)
.sort();
if (cycleIds.length > 0) {
const cycleSet = new Set(cycleIds);
for (const id of cycleIds) {
const entryPath = idToPath.get(id)!;
const manifest = readManifestFromEntryPath(entryPath);
const rawDeps = manifest?.dependencies?.extensions ?? [];
const deps = Array.isArray(rawDeps) ? rawDeps : [];
for (const depId of deps) {
if (depId === id) continue;
if (!cycleSet.has(depId)) continue;
// Both id and depId are in cycle — emit warning
warnings.push({
declaringId: id,
missingId: depId,
message: `Extension '${id}' and '${depId}' form a dependency cycle — loading both anyway (alphabetical order)`,
});
}
sorted.push(entryPath);
}
}
return {
sortedPaths: [...pathsWithoutId, ...sorted],
warnings,
};
}

View file

@ -2,6 +2,10 @@
* Extension system for lifecycle events and custom tools. * Extension system for lifecycle events and custom tools.
*/ */
export type { ExtensionManifest } from "./extension-manifest.js";
export { readManifest, readManifestFromEntryPath } from "./extension-manifest.js";
export type { SortResult, SortWarning } from "./extension-sort.js";
export { sortExtensionPaths } from "./extension-sort.js";
export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands.js"; export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands.js";
export { export {
createExtensionRuntime, createExtensionRuntime,

View file

@ -941,6 +941,11 @@ function discoverExtensionsInDir(dir: string): string[] {
/** /**
* Discover and load extensions from standard locations. * Discover and load extensions from standard locations.
*
* @deprecated Use DefaultResourceLoader.reload() instead this function is
* not called in the GSD loading flow. Extension discovery happens through
* DefaultPackageManager.resolve() addAutoDiscoveredResources(). Kept for
* backwards compatibility with direct pi-coding-agent consumers.
*/ */
export async function discoverAndLoadExtensions( export async function discoverAndLoadExtensions(
configuredPaths: string[], configuredPaths: string[],

View file

@ -29,6 +29,7 @@ export {
type ExecResult, type ExecResult,
type Extension, type Extension,
type ExtensionAPI, type ExtensionAPI,
type ExtensionManifest,
type ExtensionCommandContext, type ExtensionCommandContext,
type ExtensionContext, type ExtensionContext,
type ExtensionError, type ExtensionError,
@ -53,6 +54,11 @@ export {
type SessionSwitchEvent, type SessionSwitchEvent,
type SessionTreeEvent, type SessionTreeEvent,
type ToolCallEvent, type ToolCallEvent,
readManifest,
readManifestFromEntryPath,
type SortResult,
type SortWarning,
sortExtensionPaths,
type ToolDefinition, type ToolDefinition,
type ToolRenderResultOptions, type ToolRenderResultOptions,
type ToolResultEvent, type ToolResultEvent,

View file

@ -129,6 +129,12 @@ export interface DefaultResourceLoaderOptions {
appendSystemPrompt?: string; appendSystemPrompt?: string;
/** Names of bundled extensions (used to identify built-in extensions in conflict detection). */ /** Names of bundled extensions (used to identify built-in extensions in conflict detection). */
bundledExtensionNames?: Set<string>; bundledExtensionNames?: Set<string>;
/**
* Transform extension paths before loading. Receives the merged list of all
* discovered extension paths and returns a (possibly reordered/filtered) list.
* Use this to apply dependency sorting or registry-based filtering.
*/
extensionPathsTransform?: (paths: string[]) => { paths: string[]; diagnostics?: string[] };
extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
skills: Skill[]; skills: Skill[];
@ -167,6 +173,7 @@ export class DefaultResourceLoader implements ResourceLoader {
private systemPromptSource?: string; private systemPromptSource?: string;
private appendSystemPromptSource?: string; private appendSystemPromptSource?: string;
private bundledExtensionNames: Set<string>; private bundledExtensionNames: Set<string>;
private extensionPathsTransform?: (paths: string[]) => { paths: string[]; diagnostics?: string[] };
private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
skills: Skill[]; skills: Skill[];
@ -223,6 +230,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.systemPromptSource = options.systemPrompt; this.systemPromptSource = options.systemPrompt;
this.appendSystemPromptSource = options.appendSystemPrompt; this.appendSystemPromptSource = options.appendSystemPrompt;
this.bundledExtensionNames = options.bundledExtensionNames ?? new Set(); this.bundledExtensionNames = options.bundledExtensionNames ?? new Set();
this.extensionPathsTransform = options.extensionPathsTransform;
this.extensionsOverride = options.extensionsOverride; this.extensionsOverride = options.extensionsOverride;
this.skillsOverride = options.skillsOverride; this.skillsOverride = options.skillsOverride;
this.promptsOverride = options.promptsOverride; this.promptsOverride = options.promptsOverride;
@ -378,10 +386,21 @@ export class DefaultResourceLoader implements ResourceLoader {
const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts); const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts);
const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes); const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes);
const extensionPaths = this.noExtensions let extensionPaths = this.noExtensions
? cliEnabledExtensions ? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions); : this.mergePaths(cliEnabledExtensions, enabledExtensions);
// Apply path transform (dependency sorting, registry filtering) if provided
if (this.extensionPathsTransform) {
const transformed = this.extensionPathsTransform(extensionPaths);
extensionPaths = transformed.paths;
if (transformed.diagnostics?.length) {
for (const msg of transformed.diagnostics) {
process.stderr.write(`[extensions] ${msg}\n`);
}
}
}
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.extensions.push(...inlineExtensions.extensions);

View file

@ -68,6 +68,7 @@ export type {
Extension, Extension,
ExtensionActions, ExtensionActions,
ExtensionAPI, ExtensionAPI,
ExtensionManifest,
ExtensionCommandContext, ExtensionCommandContext,
ExtensionCommandContextActions, ExtensionCommandContextActions,
ExtensionContext, ExtensionContext,
@ -119,6 +120,8 @@ export type {
ToolCallEvent, ToolCallEvent,
ToolDefinition, ToolDefinition,
ToolInfo, ToolInfo,
SortResult,
SortWarning,
ToolRenderResultOptions, ToolRenderResultOptions,
ToolResultEvent, ToolResultEvent,
TurnEndEvent, TurnEndEvent,
@ -137,6 +140,9 @@ export {
importExtensionModule, importExtensionModule,
isToolCallEventType, isToolCallEventType,
isToolResultEventType, isToolResultEventType,
readManifest,
readManifestFromEntryPath,
sortExtensionPaths,
wrapRegisteredTool, wrapRegisteredTool,
wrapRegisteredTools, wrapRegisteredTools,
wrapToolsWithExtensions, wrapToolsWithExtensions,

View file

@ -1,4 +1,4 @@
import { DefaultResourceLoader } from '@gsd/pi-coding-agent' import { DefaultResourceLoader, sortExtensionPaths } from '@gsd/pi-coding-agent'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readlinkSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readlinkSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
@ -603,5 +603,21 @@ export function buildResourceLoader(agentDir: string): DefaultResourceLoader {
agentDir, agentDir,
additionalExtensionPaths: piExtensionPaths, additionalExtensionPaths: piExtensionPaths,
bundledExtensionNames: bundledKeys, bundledExtensionNames: bundledKeys,
extensionPathsTransform: (paths: string[]) => {
// 1. Filter community extensions through the GSD registry
const filteredPaths = paths.filter((entryPath) => {
const manifest = readManifestFromEntryPath(entryPath)
if (!manifest) return true // no manifest = always load
return isExtensionEnabled(registry, manifest.id)
})
// 2. Sort in topological dependency order
const { sortedPaths, warnings } = sortExtensionPaths(filteredPaths)
return {
paths: sortedPaths,
diagnostics: warnings.map((w) => w.message),
}
},
} as ConstructorParameters<typeof DefaultResourceLoader>[0]) } as ConstructorParameters<typeof DefaultResourceLoader>[0])
} }