diff --git a/package.json b/package.json index 80ec250a3..7fb168bbd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/dev.js b/scripts/dev.js new file mode 100644 index 000000000..dc87dce60 --- /dev/null +++ b/scripts/dev.js @@ -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) + }) +} diff --git a/scripts/watch-resources.js b/scripts/watch-resources.js new file mode 100644 index 000000000..900afae51 --- /dev/null +++ b/scripts/watch-resources.js @@ -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`) diff --git a/src/loader.ts b/src/loader.ts index 674b9533e..9a0d03c87 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -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 diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 89a97627a..c7c00331c 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -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 { diff --git a/src/resources/extensions/gsd/prompt-loader.ts b/src/resources/extensions/gsd/prompt-loader.ts index 64e86ca08..9b83607b3 100644 --- a/src/resources/extensions/gsd/prompt-loader.ts +++ b/src/resources/extensions/gsd/prompt-loader.ts @@ -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(); + /** * 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 { - 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