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:
parent
f6f1f9aa27
commit
5d510ca6aa
6 changed files with 143 additions and 9 deletions
|
|
@ -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
47
scripts/dev.js
Normal 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)
|
||||
})
|
||||
}
|
||||
58
scripts/watch-resources.js
Normal file
58
scripts/watch-resources.js
Normal 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`)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue