singularity-forge/src/resource-loader.ts
Mikael Hugo b046bc1687 chore: clean up remaining sf-2 stale-name code comments
Final sweep after the prompt + script + README sweep for stale repo
references. These are pure code comments, not active behavior, but they
mislead readers about what repo this code lives in:

- src/resource-loader.ts: "sf-2 repo's working tree" → "sf-run repo's"
- src/web/safe-import-meta-resolve.ts: example URL hostname
- src/resources/extensions/sf/schemas/parsers.ts: dropped "sf-2 /" prefix
- src/resources/extensions/sf/schemas/validate.ts: same
- scripts/parallel-monitor.mjs: comment about "sf-2 repo itself"

Tests intentionally not touched — the test fixtures use @sf-build as a
generic scope name to exercise the symlink-merge logic, and the test
tmpdir prefixes (sf-2821-, sf-2945-) are just numeric tags from issue
numbers, not repo refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:14:22 +02:00

1035 lines
34 KiB
TypeScript

import {
DefaultResourceLoader,
sortExtensionPaths,
} from "@singularity-forge/pi-coding-agent";
if (process.env.SF_DEBUG_EXTENSIONS)
process.stderr.write("[sf-debug] resource-loader.ts loaded\n");
import { createHash } from "node:crypto";
import {
chmodSync,
closeSync,
copyFileSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, relative, resolve } from "node:path";
import { discoverExtensionEntryPaths } from "./extension-discovery.js";
import {
ensureRegistryEntries,
isExtensionEnabled,
loadRegistry,
readManifestFromEntryPath,
} from "./extension-registry.js";
import { compareSemver } from "./update-check.js";
// Resolve resources directory — prefer dist/resources/ (stable, set at build time)
// over src/resources/ (live working tree, changes with git branch).
//
// Why this matters: with `npm link`, src/resources/ points into the sf-run repo's
// working tree. Switching branches there changes src/resources/ for ALL projects
// that use sf — causing stale/broken extensions to be synced to ~/.sf/agent/.
// dist/resources/ is populated by the build step (`npm run copy-resources`) and
// reflects the built state, not the currently checked-out branch.
const packageRoot = resolve(import.meta.dirname, "..");
const distResources = join(packageRoot, "dist", "resources");
const srcResources = join(packageRoot, "src", "resources");
// Use dist/resources only if it has the full expected structure.
// A partial build (tsc without copy-resources) creates dist/resources/extensions/
// but not agents/ or skills/, causing initResources to sync from an incomplete source.
const resourcesDir =
existsSync(distResources) && existsSync(join(distResources, "agents"))
? distResources
: srcResources;
const bundledExtensionsDir = join(resourcesDir, "extensions");
const resourceVersionManifestName = "managed-resources.json";
interface ManagedResourceManifest {
sfVersion: string;
syncedAt?: number;
/** Content fingerprint of bundled resources — detects same-version content changes. */
contentHash?: string;
/**
* Root-level files installed in extensions/ by this SF version.
* Used on the next upgrade to detect and prune files that were removed or
* moved into a subdirectory, preventing orphaned non-extension files from
* causing extension load errors.
*/
installedExtensionRootFiles?: string[];
/**
* Subdirectory extension names installed in extensions/ by this SF version.
* Used on the next upgrade to detect and prune subdirectory extensions that
* were removed from the bundle.
*/
installedExtensionDirs?: string[];
}
export { discoverExtensionEntryPaths } from "./extension-discovery.js";
export function getExtensionKey(
entryPath: string,
extensionsDir: string,
): string {
const relPath = relative(extensionsDir, entryPath);
return relPath.split(/[\\/]/)[0].replace(/\.(?:ts|js)$/, "");
}
function getManagedResourceManifestPath(agentDir: string): string {
return join(agentDir, resourceVersionManifestName);
}
function getBundledSfVersion(): string {
// Prefer SF_VERSION env var (set once by loader.ts) to avoid re-reading package.json
if (process.env.SF_VERSION && process.env.SF_VERSION !== "0.0.0") {
return process.env.SF_VERSION;
}
try {
const pkg = JSON.parse(
readFileSync(join(packageRoot, "package.json"), "utf-8"),
);
return typeof pkg?.version === "string" ? pkg.version : "0.0.0";
} catch {
return "0.0.0";
}
}
function writeManagedResourceManifest(agentDir: string): void {
// Record root-level files and subdirectory extension names currently in the
// bundled extensions source so that future upgrades can detect and prune any
// that get removed or moved.
let installedExtensionRootFiles: string[] = [];
let installedExtensionDirs: string[] = [];
try {
if (existsSync(bundledExtensionsDir)) {
const entries = readdirSync(bundledExtensionsDir, {
withFileTypes: true,
});
installedExtensionRootFiles = entries
.filter((e) => e.isFile())
.map((e) => e.name);
installedExtensionDirs = entries
.filter((e) => e.isDirectory())
.filter((e) => {
// Track directories that are actual extensions — identified by an
// index.js/index.ts entry point OR an extension-manifest.json (e.g.
// remote-questions which uses mod.ts instead of index.ts).
const dirPath = join(bundledExtensionsDir, e.name);
return (
existsSync(join(dirPath, "index.js")) ||
existsSync(join(dirPath, "index.ts")) ||
existsSync(join(dirPath, "extension-manifest.json"))
);
})
.map((e) => e.name);
}
} catch {
/* non-fatal */
}
const manifest: ManagedResourceManifest = {
sfVersion: getBundledSfVersion(),
syncedAt: Date.now(),
contentHash: computeResourceFingerprint(),
installedExtensionRootFiles,
installedExtensionDirs,
};
writeFileSync(
getManagedResourceManifestPath(agentDir),
JSON.stringify(manifest),
);
}
export function readManagedResourceVersion(agentDir: string): string | null {
try {
const manifest = JSON.parse(
readFileSync(getManagedResourceManifestPath(agentDir), "utf-8"),
) as ManagedResourceManifest;
return typeof manifest?.sfVersion === "string" ? manifest.sfVersion : null;
} catch {
return null;
}
}
function readManagedResourceManifest(
agentDir: string,
): ManagedResourceManifest | null {
try {
return JSON.parse(
readFileSync(getManagedResourceManifestPath(agentDir), "utf-8"),
) as ManagedResourceManifest;
} catch {
return null;
}
}
/**
* Computes a content fingerprint of a resources directory (defaults to the
* bundled resourcesDir).
*
* Walks all files under `rootDir` and builds an aggregate fingerprint from
* `${relativePath}:${mtime}:${size}` for each one. This is orders of magnitude
* faster than full content hashing for large resource trees (1,700+ files)
* while still reliably detecting changes during development (npm link) and
* after SF version upgrades.
*
* Cost is ~1-5ms even for large trees — negligible at startup.
*
* Exported for unit tests and for callers that want to check a different
* directory (e.g. pre-install verification).
*/
export function computeResourceFingerprint(
rootDir: string = resourcesDir,
): string {
const entries: string[] = [];
collectFileEntries(rootDir, rootDir, entries);
entries.sort();
return createHash("sha256")
.update(entries.join("\n"))
.digest("hex")
.slice(0, 16);
}
function collectFileEntries(dir: string, root: string, out: string[]): void {
if (!existsSync(dir)) return;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
collectFileEntries(fullPath, root, out);
} else {
const rel = relative(root, fullPath);
// Use mtime and size for the fingerprint instead of full content hashing (#3471).
// This is orders of magnitude faster for large resource trees (1700+ files)
// while still reliably detecting dev-workflow changes and upgrades.
let fingerprint: string;
try {
const stats = lstatSync(fullPath);
fingerprint = `${stats.mtimeMs}:${stats.size}`;
} catch {
// Unreadable file — fall back to a stable marker so the entry still
// contributes to the aggregate hash and future reads will re-hash.
fingerprint = "unreadable";
}
out.push(`${rel}:${fingerprint}`);
}
}
}
export function getNewerManagedResourceVersion(
agentDir: string,
currentVersion: string,
): string | null {
const managedVersion = readManagedResourceVersion(agentDir);
if (!managedVersion) {
return null;
}
return compareSemver(managedVersion, currentVersion) > 0
? managedVersion
: null;
}
/**
* Recursively makes all files and directories under dirPath owner-writable.
*
* Files copied from the Nix store inherit read-only modes (0444/0555).
* Calling this before cpSync prevents overwrite failures on subsequent upgrades,
* and calling it after ensures the next run can overwrite the copies too.
*
* Preserves existing permission bits (including executability) and only adds
* owner-write (and for directories, owner-exec) without widening group/other
* permissions.
*/
function makeTreeWritable(dirPath: string): void {
if (!existsSync(dirPath)) return;
// Use lstatSync to avoid following symlinks into immutable filesystems
// (e.g., Nix store on NixOS/nix-darwin). Symlinks don't carry their own
// permissions and their targets may be read-only by design (#1298).
const stats = lstatSync(dirPath);
if (stats.isSymbolicLink()) return;
// Skip node_modules entirely — it's too large to crawl and should already
// have correct permissions from the package manager or cpSync.
if (basename(dirPath) === "node_modules") return;
const isDir = stats.isDirectory();
const currentMode = stats.mode & 0o777;
// Ensure owner-write; for directories also ensure owner-exec so they remain traversable.
let newMode = currentMode | 0o200;
if (isDir) {
newMode |= 0o100;
}
if (newMode !== currentMode) {
try {
chmodSync(dirPath, newMode);
} catch {
// Non-fatal — may fail on read-only filesystems or insufficient permissions
}
}
if (isDir) {
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
const entryPath = join(dirPath, entry.name);
makeTreeWritable(entryPath);
}
}
}
/**
* Syncs a single bundled resource directory into the agent directory.
*
* 1. Makes the destination writable (handles Nix store read-only copies).
* 2. Removes destination subdirs that exist in source to clear stale files,
* while preserving user-created directories.
* 3. Copies source into destination.
* 4. Makes the result writable for the next upgrade cycle.
*/
export function syncResourceDir(srcDir: string, destDir: string): void {
if (!existsSync(srcDir)) return;
// In dev mode (syncing from src/resources/ in the monorepo), use a symlink
// instead of cpSync. This makes fresh agentDir initialization (used by every
// integration test) nearly instantaneous.
const isDevSync = srcDir.includes(join("src", "resources"));
if (isDevSync) {
reconcileSymlink(destDir, srcDir);
return;
}
makeTreeWritable(destDir);
pruneStaleSiblingFiles(srcDir, destDir);
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const target = join(destDir, entry.name);
if (existsSync(target))
rmSync(target, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 50,
});
}
}
try {
cpSync(srcDir, destDir, { recursive: true, force: true });
} catch {
// Fallback for Windows paths with non-ASCII characters where cpSync
// fails with the \\?\ extended-length prefix (#1178).
copyDirRecursive(srcDir, destDir);
}
makeTreeWritable(destDir);
}
function pruneStaleSiblingFiles(srcDir: string, destDir: string): void {
if (!existsSync(destDir)) return;
const sourceFiles = new Set(
readdirSync(srcDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name),
);
for (const entry of readdirSync(destDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (sourceFiles.has(entry.name)) continue;
const sourceJsName = entry.name.replace(/\.ts$/, ".js");
const sourceTsName = entry.name.replace(/\.js$/, ".ts");
if (sourceFiles.has(sourceJsName) || sourceFiles.has(sourceTsName)) {
rmSync(join(destDir, entry.name), { force: true });
}
}
}
/**
* Recursive directory copy using copyFileSync — workaround for cpSync failures
* on Windows paths containing non-ASCII characters (#1178).
*/
function copyDirRecursive(src: string, dest: string): void {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src, { withFileTypes: true })) {
const srcPath = join(src, entry.name);
const destPath = join(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath);
} else {
copyFileSync(srcPath, destPath);
}
}
}
/**
* Creates (or updates) a symlink at agentDir/node_modules pointing to SF's
* own node_modules directory.
*
* Native ESM `import()` ignores NODE_PATH — it resolves packages by walking
* up the directory tree from the importing file. Extension files synced to
* ~/.sf/agent/extensions/ have no ancestor node_modules, so imports of
* @singularity-forge/* packages fail. The symlink makes Node's standard resolution find
* them without requiring every call site to use jiti.
*
* Layout differences by install method:
* - Source/monorepo: packageRoot/node_modules has everything → simple symlink
* - npm/bun global: deps hoisted to dirname(packageRoot), including @singularity-forge/* → simple symlink
* - pnpm global: external deps hoisted, but @singularity-forge/* stays in packageRoot/node_modules
* → merged directory with symlinks from both roots (#3529, #3564)
*/
function ensureNodeModulesSymlink(agentDir: string): void {
const agentNodeModules = join(agentDir, "node_modules");
const internalNodeModules = join(packageRoot, "node_modules");
const hoistedNodeModules = dirname(packageRoot);
const isGlobalInstall = basename(hoistedNodeModules) === "node_modules";
if (!isGlobalInstall) {
// Source/monorepo: internal node_modules has everything
reconcileSymlink(agentNodeModules, internalNodeModules);
return;
}
// Global install: check if workspace scopes (@singularity-forge/*) are hoisted.
// npm/bun hoist everything; pnpm keeps workspace packages internal.
if (!hasMissingWorkspaceScopes(hoistedNodeModules, internalNodeModules)) {
// Everything is hoisted — simple symlink to parent node_modules
reconcileSymlink(agentNodeModules, hoistedNodeModules);
return;
}
// pnpm-style layout: create a real directory merging both roots
reconcileMergedNodeModules(
agentNodeModules,
hoistedNodeModules,
internalNodeModules,
);
}
/** Check if any @singularity-forge* scopes exist in internal but not in hoisted node_modules */
function hasMissingWorkspaceScopes(hoisted: string, internal: string): boolean {
if (!existsSync(internal)) return false;
try {
for (const entry of readdirSync(internal, { withFileTypes: true })) {
if (
entry.isDirectory() &&
entry.name.startsWith("@singularity-forge") &&
!existsSync(join(hoisted, entry.name))
) {
return true;
}
}
} catch {
/* non-fatal */
}
return false;
}
/** Ensure a symlink at `link` points to `target`, fixing stale/wrong entries */
function reconcileSymlink(link: string, target: string): void {
try {
const stat = lstatSync(link);
if (stat.isSymbolicLink()) {
const existing = readlinkSync(link);
if (existing === target && existsSync(link)) return; // correct and target exists
unlinkSync(link);
} else {
// Real directory (or merged dir from previous pnpm fix) — remove it
rmSync(link, { recursive: true, force: true });
}
} catch {
// lstatSync throws if path doesn't exist — fine, we'll create below
}
try {
symlinkSync(target, link, "junction");
} catch (err) {
console.error(
`[forge] WARN: Failed to symlink ${link}${target}: ${err instanceof Error ? err.message : err}`,
);
}
}
/**
* Create a real node_modules directory containing symlinks from both the
* hoisted root (external deps) and internal root (@singularity-forge/* workspace packages).
* Used for pnpm global installs where @singularity-forge/* isn't hoisted.
*/
function reconcileMergedNodeModules(
agentNodeModules: string,
hoisted: string,
internal: string,
): void {
// Fast path: if already merged for this packageRoot + same directory contents, skip.
// The fingerprint includes entry names from both roots so `pnpm add/remove` triggers rebuild.
const marker = join(agentNodeModules, ".sf-merged");
const fingerprint = mergedFingerprint(hoisted, internal);
try {
if (
existsSync(marker) &&
readFileSync(marker, "utf-8").trim() === fingerprint
)
return;
} catch {
/* rebuild */
}
// Remove any existing symlink or stale merged directory
try {
const stat = lstatSync(agentNodeModules);
if (stat.isSymbolicLink()) {
unlinkSync(agentNodeModules);
} else {
rmSync(agentNodeModules, { recursive: true, force: true });
}
} catch {
/* doesn't exist */
}
mkdirSync(agentNodeModules, { recursive: true });
let linkedCount = 0;
// Symlink entries from the hoisted node_modules (external deps)
try {
for (const entry of readdirSync(hoisted, { withFileTypes: true })) {
// Skip the sf-run package itself and dotfiles
if (entry.name === basename(packageRoot)) continue;
if (entry.name.startsWith(".")) continue;
try {
symlinkSync(
join(hoisted, entry.name),
join(agentNodeModules, entry.name),
"junction",
);
linkedCount++;
} catch {
/* skip individual */
}
}
} catch (err) {
console.error(
`[forge] WARN: Failed to read hoisted node_modules at ${hoisted}: ${err instanceof Error ? err.message : err}`,
);
}
// Overlay internal node_modules entries that weren't hoisted.
// This covers @singularity-forge/* workspace packages AND optional deps like
// @anthropic-ai/claude-agent-sdk that npm keeps internal.
try {
for (const entry of readdirSync(internal, { withFileTypes: true })) {
if (entry.name.startsWith(".")) continue;
const link = join(agentNodeModules, entry.name);
// Replace hoisted symlink with internal version (internal takes precedence)
try {
lstatSync(link);
unlinkSync(link);
} catch {
/* didn't exist — will create below */
}
try {
symlinkSync(join(internal, entry.name), link, "junction");
linkedCount++;
} catch {
/* skip individual */
}
}
} catch (err) {
console.error(
`[forge] WARN: Failed to read internal node_modules at ${internal}: ${err instanceof Error ? err.message : err}`,
);
}
// Only stamp marker if we actually linked something — avoids caching a broken state
if (linkedCount > 0) {
try {
writeFileSync(marker, fingerprint);
} catch {
/* non-fatal */
}
}
}
/** Build a cache fingerprint from packageRoot + sorted entry names of both directories */
function mergedFingerprint(hoisted: string, internal: string): string {
try {
const h = readdirSync(hoisted).sort().join(",");
const i = readdirSync(internal).sort().join(",");
return `${packageRoot}\n${h}\n${i}`;
} catch {
return packageRoot; // fallback: at least invalidate on version change
}
}
/**
* Verify that all files recorded in the manifest still exist on disk.
* If any file is missing, the manifest is stale and a full resync is needed.
* This catches cases where files were deleted manually or by git operations
* after the manifest was written (#sf-moody5qi-8gbwp2).
*/
function verifyManifestFilesExist(
manifest: ManagedResourceManifest,
agentDir: string,
): boolean {
const extensionsDir = join(agentDir, "extensions");
// Check root files
if (manifest.installedExtensionRootFiles) {
for (const file of manifest.installedExtensionRootFiles) {
if (!existsSync(join(extensionsDir, file))) {
return false;
}
}
}
// Check subdirectory extensions
if (manifest.installedExtensionDirs) {
for (const dir of manifest.installedExtensionDirs) {
if (!existsSync(join(extensionsDir, dir))) {
return false;
}
}
}
return true;
}
/**
* Prune root-level extension files that were installed by a previous SF version
* but have since been removed or relocated to a subdirectory.
*
* Two strategies:
* 1. Manifest-based (preferred): the manifest records which root files were installed
* last time; any that are no longer in the current bundle are deleted.
* 2. Known-stale fallback: for upgrades from versions before manifest tracking,
* explicitly delete files known to have been moved (e.g. env-utils.js → sf/).
*/
function pruneRemovedBundledExtensions(
manifest: ManagedResourceManifest | null,
agentDir: string,
): void {
const extensionsDir = join(agentDir, "extensions");
if (!existsSync(extensionsDir)) return;
// Current bundled root-level files (what the new version provides)
const currentSourceFiles = new Set<string>();
// Current bundled subdirectory extensions
const currentSourceDirs = new Set<string>();
try {
if (existsSync(bundledExtensionsDir)) {
for (const e of readdirSync(bundledExtensionsDir, {
withFileTypes: true,
})) {
if (e.isFile()) currentSourceFiles.add(e.name);
if (e.isDirectory()) currentSourceDirs.add(e.name);
}
}
} catch {
/* non-fatal */
}
const removeFileIfStale = (fileName: string) => {
if (currentSourceFiles.has(fileName)) return; // still in bundle, not stale
const stale = join(extensionsDir, fileName);
try {
if (existsSync(stale)) rmSync(stale, { force: true });
} catch {
/* non-fatal */
}
};
const removeDirIfStale = (dirName: string) => {
if (currentSourceDirs.has(dirName)) return; // still in bundle, not stale
const stale = join(extensionsDir, dirName);
try {
if (existsSync(stale)) rmSync(stale, { recursive: true, force: true });
} catch {
/* non-fatal */
}
};
if (manifest?.installedExtensionRootFiles) {
// Manifest-based: remove previously-installed root files that are no longer bundled
for (const prevFile of manifest.installedExtensionRootFiles) {
removeFileIfStale(prevFile);
}
}
if (manifest?.installedExtensionDirs) {
// Manifest-based: remove previously-installed subdirectory extensions that are no longer bundled
for (const prevDir of manifest.installedExtensionDirs) {
removeDirIfStale(prevDir);
}
}
// Sweep-based: also remove any installed extension subdirectory not in the current bundle,
// even if it was never tracked in the manifest (e.g. installed by a pre-manifest version).
try {
if (existsSync(extensionsDir)) {
for (const e of readdirSync(extensionsDir, { withFileTypes: true })) {
if (e.isDirectory()) removeDirIfStale(e.name);
}
}
} catch {
/* non-fatal */
}
// Always remove known stale files regardless of manifest state.
// These were installed by pre-manifest versions so they may not appear in
// installedExtensionRootFiles even when a manifest exists.
// env-utils.js was moved from extensions/ root → sf/ in v2.39.x (#1634)
removeFileIfStale("env-utils.js");
}
/**
* Syncs all bundled resources to agentDir (~/.sf/agent/) on every launch.
*
* - extensions/ → ~/.sf/agent/extensions/ (overwrite when version changes)
* - agents/ → ~/.sf/agent/agents/ (overwrite when version changes)
* - SF-WORKFLOW.md → ~/.sf/agent/SF-WORKFLOW.md (fallback for env var miss)
*
* Skills are NOT synced here. They are installed by the user via the
* skills.sh CLI (`npx skills add <repo>`) into ~/.agents/skills/ — the
* industry-standard Agent Skills ecosystem directory.
*
* Skips the copy when the managed-resources.json version matches the current
* SF version, avoiding ~128ms of synchronous cpSync on every startup.
* After `npm update -g @glittercowboy/sf`, versions will differ and the
* copy runs once to land the new resources.
*
* Inspectable: `ls ~/.sf/agent/extensions/`
*/
export function initResources(agentDir: string): void {
mkdirSync(agentDir, { recursive: true });
const currentVersion = getBundledSfVersion();
const manifest = readManagedResourceManifest(agentDir);
const extensionsDir = join(agentDir, "extensions");
// Always prune root-level extension files that were removed from the bundle.
// This is cheap (a few existence checks + at most one rmSync) and must run
// unconditionally so that stale files left by a previous version are cleaned
// up even when the version/hash match causes the full sync to be skipped.
pruneRemovedBundledExtensions(manifest, agentDir);
pruneStaleSiblingFiles(bundledExtensionsDir, extensionsDir);
// Ensure ~/.sf/agent/node_modules symlinks to SF's node_modules on EVERY
// launch, not just during resource syncs. A stale/broken symlink makes ALL
// extensions fail to resolve @singularity-forge/* packages, rendering SF non-functional.
ensureNodeModulesSymlink(agentDir);
// Migrate legacy skills on every launch (not gated by manifest) so that
// partial-failure retries don't wait for a version bump.
migrateSkillsToEcosystemDir(agentDir);
// Skip the full copy when both version AND content fingerprint match.
// Version-only checks miss same-version content changes (npm link dev workflow,
// hotfixes within a release). The content hash catches those at ~1ms cost.
if (manifest && manifest.sfVersion === currentVersion) {
// Version matches — check content fingerprint for same-version staleness.
const currentHash = computeResourceFingerprint();
const hasStaleExtensionFiles = hasStaleCompiledExtensionSiblings(
extensionsDir,
bundledExtensionsDir,
);
// Also verify files listed in manifest actually exist on disk (#sf-moody5qi-8gbwp2)
const manifestFilesExist = verifyManifestFilesExist(manifest, agentDir);
if (
manifest.contentHash &&
manifest.contentHash === currentHash &&
!hasStaleExtensionFiles &&
manifestFilesExist
) {
return;
}
}
// Sync bundled resources — overwrite so updates land on next launch.
syncResourceDir(bundledExtensionsDir, join(agentDir, "extensions"));
syncResourceDir(join(resourcesDir, "agents"), join(agentDir, "agents"));
// Skills are no longer force-synced here. Users install skills via the
// skills.sh CLI (`npx skills add <repo>`) into ~/.agents/skills/ which
// is the industry-standard Agent Skills ecosystem directory.
//
// Migration from the legacy ~/.sf/agent/skills/ directory is handled
// above the manifest check so it runs on every launch (including retries
// after partial copy failures).
const workflowSrc = join(resourcesDir, "SF-WORKFLOW.md");
if (existsSync(workflowSrc)) {
try {
copyFileSync(workflowSrc, join(agentDir, "SF-WORKFLOW.md"));
} catch {
/* non-fatal */
}
}
// Ensure all newly copied files are owner-writable so the next run can
// overwrite them (covers extensions, agents, and skills in one walk).
// Fast now because makeTreeWritable skips node_modules recursion (#3471).
makeTreeWritable(agentDir);
writeManagedResourceManifest(agentDir);
ensureRegistryEntries(join(agentDir, "extensions"));
}
// ─── Legacy Skill Migration ──────────────────────────────────────────────────────
/**
* One-time migration: copy user-customized skills from the old
* ~/.sf/agent/skills/ directory into ~/.agents/skills/.
*
* The migration is conservative:
* - Only skill directories containing a SKILL.md are considered.
* - Copies, does not move — the old directory stays intact so downgrading
* to a pre-migration SF version still works.
* - Collision-safe — if a skill name already exists in the target, the
* existing ecosystem skill wins (user may have already installed a newer
* version via skills.sh).
* - Writes a `.migrated-to-agents` marker inside the legacy directory so
* the migration runs at most once.
*/
function migrateSkillsToEcosystemDir(agentDir: string): void {
const legacyDir = join(agentDir, "skills");
const markerPath = join(legacyDir, ".migrated-to-agents");
// Already migrated or no legacy dir — nothing to do
if (!existsSync(legacyDir)) return;
// Atomic marker check — 'wx' fails if file already exists, preventing races
// when two SF processes start simultaneously.
let markerFd: number;
try {
markerFd = openSync(markerPath, "wx");
} catch {
return; // marker already exists (another process won the race, or already migrated)
}
try {
const ecosystemDir = join(homedir(), ".agents", "skills");
mkdirSync(ecosystemDir, { recursive: true });
const entries = readdirSync(legacyDir, { withFileTypes: true });
let migrated = 0;
let candidates = 0;
for (const entry of entries) {
// Handle both real directories and symlinks pointing to directories
const isDir = entry.isDirectory();
const isSymlink = entry.isSymbolicLink();
if (!isDir && !isSymlink) continue;
const sourcePath = join(legacyDir, entry.name);
// For symlinks, verify the target is a directory
if (isSymlink) {
try {
const stat = statSync(sourcePath);
if (!stat.isDirectory()) continue;
} catch {
continue; // broken symlink — skip
}
}
const skillMd = join(sourcePath, "SKILL.md");
if (!existsSync(skillMd)) continue;
const target = join(ecosystemDir, entry.name);
if (existsSync(target)) continue; // ecosystem version wins
candidates++;
try {
if (isSymlink) {
// Recreate the symlink in the ecosystem directory using an absolute
// target. Relative symlinks would resolve from the new parent dir
// (~/.agents/skills/) instead of the original (~/.sf/agent/skills/),
// pointing to the wrong location.
const rawTarget = readlinkSync(sourcePath);
const absTarget = resolve(dirname(sourcePath), rawTarget);
symlinkSync(absTarget, target);
} else {
cpSync(sourcePath, target, { recursive: true });
}
migrated++;
} catch {
// non-fatal — skip this skill
}
}
// If any skills failed to copy, remove the marker so migration retries
// on the next launch. This keeps the legacy dir as fallback until every
// skill has been successfully migrated.
if (migrated < candidates) {
try {
closeSync(markerFd);
markerFd = -1;
} catch {
/* non-fatal */
}
try {
unlinkSync(markerPath);
} catch {
/* non-fatal */
}
return;
}
// Write migration info to the marker
try {
writeFileSync(
markerFd,
`Migrated ${migrated} skill(s) to ${ecosystemDir} on ${new Date().toISOString()}\n`,
);
} catch {
/* non-fatal */
}
} catch {
// can't create ecosystem dir or read legacy dir — close fd first (required on Windows
// where unlinkSync fails on open handles), then remove marker so we retry next launch
try {
closeSync(markerFd);
markerFd = -1;
} catch {
/* non-fatal */
}
try {
unlinkSync(markerPath);
} catch {
/* non-fatal */
}
} finally {
if (markerFd !== -1) {
try {
closeSync(markerFd);
} catch {
/* non-fatal */
}
}
}
}
export function hasStaleCompiledExtensionSiblings(
extensionsDir: string,
sourceDir: string = bundledExtensionsDir,
): boolean {
if (!existsSync(extensionsDir)) return false;
const sourceFiles = existsSync(sourceDir)
? new Set(
readdirSync(sourceDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name),
)
: new Set<string>();
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".js")) continue;
const siblingName = entry.name.endsWith(".ts")
? entry.name.replace(/\.ts$/, ".js")
: entry.name.replace(/\.js$/, ".ts");
if (!existsSync(join(extensionsDir, siblingName))) continue;
if (sourceFiles.has(entry.name) && sourceFiles.has(siblingName)) continue;
if (sourceFiles.has(entry.name) || sourceFiles.has(siblingName)) {
return true;
}
}
return false;
}
/**
* Constructs a DefaultResourceLoader that loads extensions from both
* ~/.sf/agent/extensions/ (SF's default) and ~/.pi/agent/extensions/ (pi's default).
* This allows users to use extensions from either location.
*/
// Cache bundled extension keys at module load — avoids re-scanning the extensions
// directory in buildResourceLoader() (already scanned by loader.ts for env var).
let _bundledExtensionKeys: Set<string> | null = null;
function getBundledExtensionKeys(): Set<string> {
if (!_bundledExtensionKeys) {
_bundledExtensionKeys = new Set(
discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) =>
getExtensionKey(entryPath, bundledExtensionsDir),
),
);
}
return _bundledExtensionKeys;
}
/**
* Optional overrides passed through to DefaultResourceLoader. Print mode
* needs these — it used to construct DefaultResourceLoader directly, which
* bypassed buildResourceLoader's extensionPathsTransform (= the SF registry
* filter) and let disabled bundled extensions like `ollama` leak through and
* conflict with community replacements such as `@0xkobold/pi-ollama`.
*/
export interface BuildResourceLoaderOptions {
additionalExtensionPaths?: string[];
appendSystemPrompt?: string;
}
export function buildResourceLoader(
agentDir: string,
options: BuildResourceLoaderOptions = {},
): DefaultResourceLoader {
const registry = loadRegistry();
const piAgentDir = join(homedir(), ".pi", "agent");
const piExtensionsDir = join(piAgentDir, "extensions");
const piLegacyExtensionsDir = join(homedir(), ".pi", "extensions");
const bundledKeys = getBundledExtensionKeys();
const discoverPiExtensions = (dir: string): string[] =>
discoverExtensionEntryPaths(dir)
.filter((entryPath) => !bundledKeys.has(getExtensionKey(entryPath, dir)))
.filter((entryPath) => {
const manifest = readManifestFromEntryPath(entryPath);
if (!manifest) return true;
return isExtensionEnabled(registry, manifest.id);
});
const piExtensionPaths = [
...discoverPiExtensions(piExtensionsDir),
...discoverPiExtensions(piLegacyExtensionsDir),
];
// Print-mode callers pass their own additional extension paths (e.g. --extension
// flags). Non-print mode uses the implicit pi-extensions discovery above.
const additionalExtensionPaths =
options.additionalExtensionPaths &&
options.additionalExtensionPaths.length > 0
? options.additionalExtensionPaths
: piExtensionPaths;
return new DefaultResourceLoader({
agentDir,
additionalExtensionPaths,
appendSystemPrompt: options.appendSystemPrompt,
bundledExtensionKeys: bundledKeys,
extensionPathsTransform: (paths: string[]) => {
// Filter community + bundled extensions through the SF registry so
// explicitly-disabled entries (e.g. bundled `ollama` superseded by
// `@0xkobold/pi-ollama`) never reach the runtime and trigger command
// conflicts.
const filteredPaths = paths.filter((entryPath) => {
const manifest = readManifestFromEntryPath(entryPath);
if (!manifest) return true; // no manifest = always load
return isExtensionEnabled(registry, manifest.id);
});
// Sort in topological dependency order
const { sortedPaths, warnings } = sortExtensionPaths(filteredPaths);
return {
paths: sortedPaths,
diagnostics: warnings.map((w) => w.message),
};
},
} as ConstructorParameters<typeof DefaultResourceLoader>[0]);
}