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.
This commit is contained in:
deseltrus 2026-03-14 18:47:03 +01:00 committed by GitHub
parent f6f1f9aa27
commit 5d510ca6aa
6 changed files with 143 additions and 9 deletions

View file

@ -43,7 +43,8 @@
"build:pi-coding-agent": "npm run build -w @gsd/pi-coding-agent",
"build:native-pkg": "npm run build -w @gsd/native",
"build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent",
"build": "npm run build:pi && tsc && npm run copy-themes",
"build": "npm run build:pi && tsc && npm run copy-resources && npm run copy-themes",
"copy-resources": "node -e \"const{cpSync,rmSync}=require('fs');rmSync('dist/resources',{recursive:true,force:true});cpSync('src/resources','dist/resources',{recursive:true,force:true})\"",
"copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'packages/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"",
"test:unit": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
"test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*integration*.test.ts src/tests/integration/*.test.ts",
@ -52,7 +53,7 @@
"test:native": "node --test packages/native/src/__tests__/grep.test.mjs",
"build:native": "node native/scripts/build.js",
"build:native:dev": "node native/scripts/build.js --dev",
"dev": "tsc --watch",
"dev": "node scripts/dev.js",
"postinstall": "node scripts/link-workspace-packages.cjs && node scripts/postinstall.js",
"pi:install-global": "node scripts/install-pi-global.js",
"pi:uninstall-global": "node scripts/uninstall-pi-global.js",

47
scripts/dev.js Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* Dev supervisor runs tsc --watch and watch-resources.js in parallel.
*
* Both processes terminate together when either exits or when the parent
* receives SIGINT/SIGTERM. This avoids the problem with shell backgrounding
* (`&`) where the watcher can outlive tsc and orphan.
*/
import { spawn } from 'node:child_process'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '..')
const procs = [
spawn('node', [resolve(__dirname, 'watch-resources.js')], {
cwd: root, stdio: 'inherit'
}),
spawn('npx', ['tsc', '--watch'], {
cwd: root, stdio: 'inherit'
})
]
function cleanup() {
for (const p of procs) {
try { p.kill() } catch {}
}
}
// If either child exits, kill the other and exit with its code
for (const p of procs) {
p.on('exit', (code) => {
cleanup()
process.exit(code ?? 1)
})
}
// Forward signals to children
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
cleanup()
process.exit(0)
})
}

View file

@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* Watch src/resources/ and sync changes to dist/resources/.
*
* Runs alongside `tsc --watch` to ensure non-TS resources (prompts, agents,
* skills, workflow files) are kept in sync with the build output.
*
* This solves the `npm link` branch-drift problem: without dist/resources/,
* `initResources()` reads from src/resources/ which changes with git branch
* switches, causing stale extensions to be synced to ~/.gsd/agent/ for ALL
* projects using gsd.
*/
import { watch } from 'node:fs'
import { cpSync, mkdirSync, rmSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const src = resolve(__dirname, '..', 'src', 'resources')
const dest = resolve(__dirname, '..', 'dist', 'resources')
function sync() {
// Remove dest first to mirror deletions from src (prevents stale files)
rmSync(dest, { recursive: true, force: true })
mkdirSync(dest, { recursive: true })
cpSync(src, dest, { recursive: true, force: true })
}
// Initial sync
sync()
process.stderr.write(`[watch-resources] Initial sync done\n`)
// Watch for changes — recursive, debounced.
// fs.watch({ recursive: true }) is supported on macOS and Windows.
// On Linux (Node <20.13) it throws ERR_FEATURE_UNAVAILABLE_ON_PLATFORM.
// Fall back to polling on unsupported platforms.
let timer = null
const onChange = () => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
sync()
process.stderr.write(`[watch-resources] Synced at ${new Date().toLocaleTimeString()}\n`)
}, 300)
}
try {
watch(src, { recursive: true }, onChange)
} catch {
// Fallback: poll every 2s (Linux without recursive watch support)
process.stderr.write(`[watch-resources] fs.watch recursive not supported, falling back to polling\n`)
setInterval(() => {
try { sync() } catch {}
}, 2000)
}
process.stderr.write(`[watch-resources] Watching src/resources/ → dist/resources/\n`)

View file

@ -69,8 +69,12 @@ try {
process.env.GSD_BIN_PATH = process.argv[1]
// GSD_WORKFLOW_PATH — absolute path to bundled GSD-WORKFLOW.md, used by patched gsd extension
// when dispatching workflow prompts (dist/loader.js → ../src/resources/GSD-WORKFLOW.md)
const resourcesDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'resources')
// when dispatching workflow prompts. Prefers dist/resources/ (stable, set at build time)
// over src/resources/ (live working tree) — see resource-loader.ts for rationale.
const loaderPackageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const distRes = join(loaderPackageRoot, 'dist', 'resources')
const srcRes = join(loaderPackageRoot, 'src', 'resources')
const resourcesDir = existsSync(distRes) ? distRes : srcRes
process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md')
// GSD_BUNDLED_EXTENSION_PATHS — colon-joined list of all bundled extension entry point absolute

View file

@ -4,9 +4,18 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
// Resolves to the bundled src/resources/ inside the npm package at runtime:
// dist/resource-loader.js → .. → package root → src/resources/
const resourcesDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'resources')
// 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')
const resourcesDir = existsSync(distResources) ? distResources : srcResources
const bundledExtensionsDir = join(resourcesDir, 'extensions')
function isExtensionFile(name: string): boolean {

View file

@ -6,6 +6,13 @@
*
* Templates live at prompts/ relative to this module's directory.
* They use {{variableName}} syntax for substitution.
*
* Templates are cached on first read per session. This prevents a running
* session from being invalidated when another `gsd` launch overwrites
* ~/.gsd/agent/ with newer templates via initResources(). Without caching,
* the in-memory extension code (which knows variable set A) can read a
* newer template from disk (which expects variable set B), causing a
* "template declares {{X}} but no value was provided" crash mid-session.
*/
import { readFileSync } from "node:fs";
@ -14,6 +21,10 @@ import { fileURLToPath } from "node:url";
const promptsDir = join(dirname(fileURLToPath(import.meta.url)), "prompts");
// Cache templates on first read — a running session uses the template versions
// that were on disk when it first loaded them, immune to later overwrites.
const templateCache = new Map<string, string>();
/**
* Load a prompt template and substitute variables.
*
@ -21,8 +32,12 @@ const promptsDir = join(dirname(fileURLToPath(import.meta.url)), "prompts");
* @param vars - Key-value pairs to substitute for {{key}} placeholders
*/
export function loadPrompt(name: string, vars: Record<string, string> = {}): string {
const path = join(promptsDir, `${name}.md`);
let content = readFileSync(path, "utf-8");
let content = templateCache.get(name);
if (content === undefined) {
const path = join(promptsDir, `${name}.md`);
content = readFileSync(path, "utf-8");
templateCache.set(name, content);
}
// Check BEFORE substitution: find all {{varName}} placeholders the template
// declares and verify every one has a value in vars. Checking after substitution