2026-03-12 21:55:17 -06:00
|
|
|
import { DefaultResourceLoader } from '@gsd/pi-coding-agent'
|
2026-03-18 11:06:09 -06:00
|
|
|
import { createHash } from 'node:crypto'
|
2026-03-11 15:09:30 -04:00
|
|
|
import { homedir } from 'node:os'
|
2026-03-18 21:15:33 -04:00
|
|
|
import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
2026-03-12 20:44:01 -07:00
|
|
|
import { dirname, join, relative, resolve } from 'node:path'
|
2026-03-10 22:28:37 -06:00
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-03-15 00:58:18 -04:00
|
|
|
import { compareSemver } from './update-check.js'
|
2026-03-17 17:14:04 -06:00
|
|
|
import { discoverExtensionEntryPaths } from './extension-discovery.js'
|
2026-03-18 14:12:19 -06:00
|
|
|
import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegistryEntries } from './extension-registry.js'
|
2026-03-10 22:28:37 -06:00
|
|
|
|
fix: read resources from dist/ to prevent branch-drift in npm-link setups (#314)
* fix: read resources from dist/ to prevent branch-drift in npm-link setups
initResources() reads extensions, prompts, skills, and agents from
src/resources/ — which points into the live working tree when gsd is
installed via npm link. Switching branches in the gsd repo changes
src/resources/ for ALL projects using gsd, causing stale or broken
extensions to be synced to ~/.gsd/agent/ on next launch.
Fix: the build step now copies src/resources/ to dist/resources/.
At runtime, resource-loader.ts and loader.ts prefer dist/resources/
(stable, set at build time) over src/resources/ (live working tree).
Fallback to src/resources/ is preserved for setups without a build.
Also adds npm run dev watch-resources watcher that syncs src/resources/
to dist/resources/ on file changes, running alongside tsc --watch.
* fix: cache prompt templates per session to prevent cross-session invalidation
When two gsd sessions run concurrently, the second session's
initResources() overwrites ~/.gsd/agent/ templates on disk. The first
session then reads a newer template that expects variables its in-memory
code doesn't know about, causing 'template declares {{X}} but no value
was provided' crashes that hang auto-mode indefinitely.
Fix: cache each template on first read. A running session uses the
template versions from when it first loaded them, immune to later
disk overwrites by other sessions.
2026-03-14 18:47:03 +01:00
|
|
|
// 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 gsd-2 repo's
|
|
|
|
|
// working tree. Switching branches there changes src/resources/ for ALL projects
|
|
|
|
|
// that use gsd — causing stale/broken extensions to be synced to ~/.gsd/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(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
|
|
const distResources = join(packageRoot, 'dist', 'resources')
|
|
|
|
|
const srcResources = join(packageRoot, 'src', 'resources')
|
2026-03-19 12:24:39 -04:00
|
|
|
const resourcesDir = (existsSync(distResources) && existsSync(join(distResources, 'agents')))
|
|
|
|
|
? distResources
|
|
|
|
|
: srcResources
|
2026-03-10 22:28:37 -06:00
|
|
|
const bundledExtensionsDir = join(resourcesDir, 'extensions')
|
2026-03-15 00:58:18 -04:00
|
|
|
const resourceVersionManifestName = 'managed-resources.json'
|
|
|
|
|
|
|
|
|
|
interface ManagedResourceManifest {
|
|
|
|
|
gsdVersion: string
|
fix: auto-mode worktree path and resource sync bugs (#557)
* fix(auto): add missing import for resolveSkillDiscoveryMode
Used at line 687 but not imported, causing "resolveSkillDiscoveryMode is
not defined" crash on auto-mode startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auto): add workingDirectory to all auto-mode prompt templates
Six prompt templates (reassess-roadmap, complete-milestone, replan-slice,
run-uat, research-milestone, plan-milestone) were missing the working
directory directive. Without it, the LLM infers the main repo path from
system context and cd's there instead of staying in the worktree. This
causes artifacts to be written to the wrong location, preventing the
dispatch loop from detecting completion and triggering infinite
re-dispatches of the same unit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auto): detect mid-session resource updates and stop gracefully
Templates are read from disk on each dispatch but extension code is
loaded once at startup. If resources are re-synced mid-session (via
/gsd:update, npm update, or dev copy-resources), templates may expect
variables the in-memory code doesn't provide, causing a crash.
Add a syncedAt timestamp to managed-resources.json. Auto-mode captures
this at startup and checks before each dispatch. If resources changed,
it stops with a clear message instead of crashing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add workingDirectory to prompt template test fixtures
Tests that load prompt templates via loadPromptFromWorktree now pass the
workingDirectory variable, matching the updated templates that include
the {{workingDirectory}} directive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:26:55 -06:00
|
|
|
syncedAt?: number
|
2026-03-18 11:06:09 -06:00
|
|
|
/** Content fingerprint of bundled resources — detects same-version content changes. */
|
|
|
|
|
contentHash?: string
|
2026-03-15 00:58:18 -04:00
|
|
|
}
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-03-17 17:14:04 -06:00
|
|
|
export { discoverExtensionEntryPaths } from './extension-discovery.js'
|
2026-03-12 20:44:01 -07:00
|
|
|
|
2026-03-19 08:38:50 -05:00
|
|
|
export function getExtensionKey(entryPath: string, extensionsDir: string): string {
|
2026-03-12 20:44:01 -07:00
|
|
|
const relPath = relative(extensionsDir, entryPath)
|
2026-03-19 08:38:50 -05:00
|
|
|
return relPath.split(/[\\/]/)[0].replace(/\.(?:ts|js)$/, '')
|
2026-03-12 20:44:01 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-15 00:58:18 -04:00
|
|
|
function getManagedResourceManifestPath(agentDir: string): string {
|
|
|
|
|
return join(agentDir, resourceVersionManifestName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getBundledGsdVersion(): string {
|
2026-03-17 22:57:13 -05:00
|
|
|
// Prefer GSD_VERSION env var (set once by loader.ts) to avoid re-reading package.json
|
|
|
|
|
if (process.env.GSD_VERSION && process.env.GSD_VERSION !== '0.0.0') {
|
|
|
|
|
return process.env.GSD_VERSION
|
|
|
|
|
}
|
2026-03-15 00:58:18 -04:00
|
|
|
try {
|
|
|
|
|
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'))
|
|
|
|
|
return typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
|
|
|
|
|
} catch {
|
2026-03-17 22:57:13 -05:00
|
|
|
return '0.0.0'
|
2026-03-15 00:58:18 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeManagedResourceManifest(agentDir: string): void {
|
2026-03-18 11:06:09 -06:00
|
|
|
const manifest: ManagedResourceManifest = {
|
|
|
|
|
gsdVersion: getBundledGsdVersion(),
|
|
|
|
|
syncedAt: Date.now(),
|
|
|
|
|
contentHash: computeResourceFingerprint(),
|
|
|
|
|
}
|
2026-03-15 00:58:18 -04:00
|
|
|
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?.gsdVersion === 'string' ? manifest.gsdVersion : null
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 11:06:09 -06:00
|
|
|
function readManagedResourceManifest(agentDir: string): ManagedResourceManifest | null {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Computes a lightweight content fingerprint of the bundled resources directory.
|
|
|
|
|
*
|
|
|
|
|
* Walks all files under resourcesDir and hashes their relative paths + sizes.
|
|
|
|
|
* This catches same-version content changes (npm link dev workflow, hotfixes
|
|
|
|
|
* within a release) without the cost of reading every file's contents.
|
|
|
|
|
*
|
|
|
|
|
* ~1ms for a typical resources tree (~100 files) — just stat calls, no reads.
|
|
|
|
|
*/
|
|
|
|
|
function computeResourceFingerprint(): string {
|
|
|
|
|
const entries: string[] = []
|
|
|
|
|
collectFileEntries(resourcesDir, resourcesDir, 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)
|
|
|
|
|
const size = statSync(fullPath).size
|
|
|
|
|
out.push(`${rel}:${size}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix: auto-mode worktree path and resource sync bugs (#557)
* fix(auto): add missing import for resolveSkillDiscoveryMode
Used at line 687 but not imported, causing "resolveSkillDiscoveryMode is
not defined" crash on auto-mode startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auto): add workingDirectory to all auto-mode prompt templates
Six prompt templates (reassess-roadmap, complete-milestone, replan-slice,
run-uat, research-milestone, plan-milestone) were missing the working
directory directive. Without it, the LLM infers the main repo path from
system context and cd's there instead of staying in the worktree. This
causes artifacts to be written to the wrong location, preventing the
dispatch loop from detecting completion and triggering infinite
re-dispatches of the same unit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auto): detect mid-session resource updates and stop gracefully
Templates are read from disk on each dispatch but extension code is
loaded once at startup. If resources are re-synced mid-session (via
/gsd:update, npm update, or dev copy-resources), templates may expect
variables the in-memory code doesn't provide, causing a crash.
Add a syncedAt timestamp to managed-resources.json. Auto-mode captures
this at startup and checks before each dispatch. If resources changed,
it stops with a clear message instead of crashing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add workingDirectory to prompt template test fixtures
Tests that load prompt templates via loadPromptFromWorktree now pass the
workingDirectory variable, matching the updated templates that include
the {{workingDirectory}} directive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:26:55 -06:00
|
|
|
|
2026-03-15 00:58:18 -04:00
|
|
|
export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null {
|
|
|
|
|
const managedVersion = readManagedResourceVersion(agentDir)
|
|
|
|
|
if (!managedVersion) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 01:45:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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.
|
2026-03-16 20:29:09 -06:00
|
|
|
*
|
|
|
|
|
* Preserves existing permission bits (including executability) and only adds
|
|
|
|
|
* owner-write (and for directories, owner-exec) without widening group/other
|
|
|
|
|
* permissions.
|
2026-03-17 01:45:38 +00:00
|
|
|
*/
|
|
|
|
|
function makeTreeWritable(dirPath: string): void {
|
|
|
|
|
if (!existsSync(dirPath)) return
|
2026-03-16 20:29:09 -06:00
|
|
|
|
2026-03-18 21:15:33 -04:00
|
|
|
// 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
|
|
|
|
|
|
2026-03-16 20:29:09 -06:00
|
|
|
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) {
|
2026-03-18 21:15:33 -04:00
|
|
|
try {
|
|
|
|
|
chmodSync(dirPath, newMode)
|
|
|
|
|
} catch {
|
|
|
|
|
// Non-fatal — may fail on read-only filesystems or insufficient permissions
|
|
|
|
|
}
|
2026-03-16 20:29:09 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isDir) {
|
|
|
|
|
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
|
|
|
const entryPath = join(dirPath, entry.name)
|
2026-03-17 01:45:38 +00:00
|
|
|
makeTreeWritable(entryPath)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 18:37:59 -06:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
function syncResourceDir(srcDir: string, destDir: string): void {
|
|
|
|
|
makeTreeWritable(destDir)
|
|
|
|
|
if (existsSync(srcDir)) {
|
2026-03-19 08:38:50 -05:00
|
|
|
pruneStaleSiblingFiles(srcDir, destDir)
|
2026-03-17 18:37:59 -06:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-18 12:13:37 -04:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-17 18:37:59 -06:00
|
|
|
makeTreeWritable(destDir)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:38:50 -05:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 12:13:37 -04:00
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 22:28:37 -06:00
|
|
|
/**
|
|
|
|
|
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
|
|
|
|
|
*
|
2026-03-15 14:33:43 -05:00
|
|
|
* - extensions/ → ~/.gsd/agent/extensions/ (overwrite when version changes)
|
|
|
|
|
* - agents/ → ~/.gsd/agent/agents/ (overwrite when version changes)
|
|
|
|
|
* - skills/ → ~/.gsd/agent/skills/ (overwrite when version changes)
|
2026-03-20 01:50:08 +03:00
|
|
|
* - GSD-WORKFLOW.md → ~/.gsd/agent/GSD-WORKFLOW.md (fallback for env var miss)
|
2026-03-10 22:28:37 -06:00
|
|
|
*
|
2026-03-15 14:33:43 -05:00
|
|
|
* Skips the copy when the managed-resources.json version matches the current
|
|
|
|
|
* GSD version, avoiding ~128ms of synchronous cpSync on every startup.
|
|
|
|
|
* After `npm update -g @glittercowboy/gsd`, versions will differ and the
|
|
|
|
|
* copy runs once to land the new resources.
|
2026-03-10 22:28:37 -06:00
|
|
|
*
|
|
|
|
|
* Inspectable: `ls ~/.gsd/agent/extensions/`
|
|
|
|
|
*/
|
|
|
|
|
export function initResources(agentDir: string): void {
|
|
|
|
|
mkdirSync(agentDir, { recursive: true })
|
|
|
|
|
|
2026-03-18 11:06:09 -06:00
|
|
|
// 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.
|
2026-03-17 22:57:13 -05:00
|
|
|
const currentVersion = getBundledGsdVersion()
|
2026-03-18 11:06:09 -06:00
|
|
|
const manifest = readManagedResourceManifest(agentDir)
|
|
|
|
|
if (manifest && manifest.gsdVersion === currentVersion) {
|
|
|
|
|
// Version matches — check content fingerprint for same-version staleness.
|
|
|
|
|
const currentHash = computeResourceFingerprint()
|
2026-03-19 08:38:50 -05:00
|
|
|
const hasStaleExtensionFiles = hasStaleCompiledExtensionSiblings(join(agentDir, 'extensions'))
|
|
|
|
|
if (manifest.contentHash && manifest.contentHash === currentHash && !hasStaleExtensionFiles) {
|
2026-03-18 11:06:09 -06:00
|
|
|
return
|
|
|
|
|
}
|
2026-03-17 22:57:13 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 18:37:59 -06:00
|
|
|
syncResourceDir(bundledExtensionsDir, join(agentDir, 'extensions'))
|
|
|
|
|
syncResourceDir(join(resourcesDir, 'agents'), join(agentDir, 'agents'))
|
|
|
|
|
syncResourceDir(join(resourcesDir, 'skills'), join(agentDir, 'skills'))
|
2026-03-15 00:58:18 -04:00
|
|
|
|
2026-03-20 01:50:08 +03:00
|
|
|
// Sync GSD-WORKFLOW.md to agentDir as a fallback for when GSD_WORKFLOW_PATH
|
|
|
|
|
// env var is not set (e.g. fork/dev builds, alternative entry points).
|
|
|
|
|
const workflowSrc = join(resourcesDir, 'GSD-WORKFLOW.md')
|
|
|
|
|
if (existsSync(workflowSrc)) {
|
|
|
|
|
try { copyFileSync(workflowSrc, join(agentDir, 'GSD-WORKFLOW.md')) } catch { /* non-fatal */ }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 18:28:42 -06:00
|
|
|
// Ensure all newly copied files are owner-writable so the next run can
|
|
|
|
|
// overwrite them (covers extensions, agents, and skills in one walk).
|
|
|
|
|
makeTreeWritable(agentDir)
|
|
|
|
|
|
2026-03-15 00:58:18 -04:00
|
|
|
writeManagedResourceManifest(agentDir)
|
2026-03-18 14:12:19 -06:00
|
|
|
ensureRegistryEntries(join(agentDir, 'extensions'))
|
2026-03-10 22:28:37 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 08:38:50 -05:00
|
|
|
export function hasStaleCompiledExtensionSiblings(extensionsDir: string): boolean {
|
|
|
|
|
if (!existsSync(extensionsDir)) return false
|
|
|
|
|
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
|
|
|
|
|
if (!entry.isFile() || !entry.name.endsWith('.ts')) continue
|
|
|
|
|
const jsName = entry.name.replace(/\.ts$/, '.js')
|
|
|
|
|
if (existsSync(join(extensionsDir, jsName))) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 22:28:37 -06:00
|
|
|
/**
|
2026-03-11 15:09:30 -04:00
|
|
|
* Constructs a DefaultResourceLoader that loads extensions from both
|
|
|
|
|
* ~/.gsd/agent/extensions/ (GSD's default) and ~/.pi/agent/extensions/ (pi's default).
|
|
|
|
|
* This allows users to use extensions from either location.
|
2026-03-10 22:28:37 -06:00
|
|
|
*/
|
2026-03-17 22:57:13 -05:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 22:28:37 -06:00
|
|
|
export function buildResourceLoader(agentDir: string): DefaultResourceLoader {
|
2026-03-18 14:12:19 -06:00
|
|
|
const registry = loadRegistry()
|
2026-03-11 15:09:30 -04:00
|
|
|
const piAgentDir = join(homedir(), '.pi', 'agent')
|
|
|
|
|
const piExtensionsDir = join(piAgentDir, 'extensions')
|
2026-03-17 22:57:13 -05:00
|
|
|
const bundledKeys = getBundledExtensionKeys()
|
2026-03-18 14:12:19 -06:00
|
|
|
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)
|
|
|
|
|
})
|
2026-03-12 20:44:01 -07:00
|
|
|
|
2026-03-11 15:09:30 -04:00
|
|
|
return new DefaultResourceLoader({
|
|
|
|
|
agentDir,
|
2026-03-12 20:44:01 -07:00
|
|
|
additionalExtensionPaths: piExtensionPaths,
|
2026-03-11 15:09:30 -04:00
|
|
|
})
|
2026-03-10 22:28:37 -06:00
|
|
|
}
|