From c1d1d3e5dba90e8ae5c642d4ab9e0c8de912b563 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 16:02:45 -0500 Subject: [PATCH 1/6] group gsd model picker by provider --- .../extensions/gsd/commands/handlers/core.ts | 77 ++++++++----- .../gsd/tests/core-overlay-fallback.test.ts | 101 ++++++++++++++++++ 2 files changed, 153 insertions(+), 25 deletions(-) diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index ae8da6c60..9b608f166 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -194,6 +194,56 @@ function sortModelsForSelection(models: Model[], currentModel: Model | }); } +function buildProviderModelGroups( + models: Model[], + currentModel: Model | undefined, +): Map[]> { + const byProvider = new Map[]>(); + + for (const model of sortModelsForSelection(models, currentModel)) { + let group = byProvider.get(model.provider); + if (!group) { + group = []; + byProvider.set(model.provider, group); + } + group.push(model); + } + return byProvider; +} + +async function selectModelByProvider( + title: string, + models: Model[], + ctx: ExtensionCommandContext, + currentModel: Model | undefined, +): Promise | undefined> { + const byProvider = buildProviderModelGroups(models, currentModel); + const providerOptions = Array.from(byProvider.entries()).map(([provider, group]) => + `${provider} (${group.length} model${group.length === 1 ? "" : "s"})`, + ); + providerOptions.push("(cancel)"); + + const providerChoice = await ctx.ui.select(`${title} — choose provider:`, providerOptions); + if (!providerChoice || typeof providerChoice !== "string" || providerChoice === "(cancel)") return undefined; + + const providerName = providerChoice.replace(/ \(\d+ models?\)$/, ""); + const providerModels = byProvider.get(providerName); + if (!providerModels || providerModels.length === 0) return undefined; + + const optionToModel = new Map>(); + const modelOptions = providerModels.map((model) => { + const isCurrent = currentModel && model.provider === currentModel.provider && model.id === currentModel.id; + const label = `${isCurrent ? "* " : ""}${model.id}`; + optionToModel.set(label, model); + return label; + }); + modelOptions.push("(cancel)"); + + const modelChoice = await ctx.ui.select(`${title} — ${providerName}:`, modelOptions); + if (!modelChoice || typeof modelChoice !== "string" || modelChoice === "(cancel)") return undefined; + return optionToModel.get(modelChoice); +} + async function resolveRequestedModel( query: string, ctx: ExtensionCommandContext, @@ -211,19 +261,7 @@ async function resolveRequestedModel( if (partialMatches.length === 1) return partialMatches[0]; if (partialMatches.length === 0 || !ctx.hasUI) return undefined; - - const sorted = sortModelsForSelection(partialMatches, ctx.model); - const optionToModel = new Map>(); - const options = sorted.map((model) => { - const label = `${model.provider}/${model.id}`; - optionToModel.set(label, model); - return label; - }); - options.push("(cancel)"); - - const choice = await ctx.ui.select(`Multiple models match "${query}" — choose one:`, options); - if (!choice || typeof choice !== "string" || choice === "(cancel)") return undefined; - return optionToModel.get(choice); + return selectModelByProvider(`Multiple models match "${query}"`, partialMatches, ctx, ctx.model); } async function handleModel(trimmedArgs: string, ctx: ExtensionCommandContext, pi: ExtensionAPI | undefined): Promise { @@ -247,18 +285,7 @@ async function handleModel(trimmedArgs: string, ctx: ExtensionCommandContext, pi return; } - const optionToModel = new Map>(); - const options = sortModelsForSelection(availableModels, ctx.model).map((model) => { - const isCurrent = ctx.model && model.provider === ctx.model.provider && model.id === ctx.model.id; - const label = `${isCurrent ? "* " : ""}${model.provider}/${model.id}`; - optionToModel.set(label, model); - return label; - }); - options.push("(cancel)"); - - const choice = await ctx.ui.select("Select session model:", options); - if (!choice || typeof choice !== "string" || choice === "(cancel)") return; - targetModel = optionToModel.get(choice); + targetModel = await selectModelByProvider("Select session model:", availableModels, ctx, ctx.model); } else { targetModel = await resolveRequestedModel(trimmed, ctx); } diff --git a/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts b/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts index a6c2dc6d9..9a7a21d16 100644 --- a/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts +++ b/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts @@ -74,3 +74,104 @@ test("model command resolves and persists exact provider-qualified selection", a assert.deepEqual(applied, selectedModel); assert.match(notices[0]!.message, /openai\/gpt-5\.4/); }); + +test("interactive model picker chooses provider first, then model", async () => { + const selectedModel = { provider: "openai", id: "gpt-5.4" }; + let applied: typeof selectedModel | null = null; + const selects: Array<{ title: string; options: string[] }> = []; + const notices: Array<{ message: string; type?: string }> = []; + + const ctx = { + hasUI: true, + model: { provider: "anthropic", id: "claude-sonnet-4-6" }, + modelRegistry: { + getAvailable: () => [ + { provider: "openai", id: "gpt-5.4" }, + { provider: "anthropic", id: "claude-opus-4-6" }, + { provider: "openai", id: "gpt-5.3-mini" }, + { provider: "anthropic", id: "claude-sonnet-4-6" }, + ], + }, + ui: { + select: async (title: string, options: string[]) => { + selects.push({ title, options }); + return selects.length === 1 ? "openai (2 models)" : "gpt-5.4"; + }, + notify: (message: string, type?: string) => { + notices.push({ message, type }); + }, + }, + } as any; + + const pi = { + setModel: async (model: typeof selectedModel) => { + applied = model; + return true; + }, + } as any; + + const handled = await handleCoreCommand("model", ctx, pi); + assert.equal(handled, true); + assert.deepEqual(selects, [ + { + title: "Select session model: — choose provider:", + options: ["anthropic (2 models)", "openai (2 models)", "(cancel)"], + }, + { + title: "Select session model: — openai:", + options: ["gpt-5.3-mini", "gpt-5.4", "(cancel)"], + }, + ]); + assert.deepEqual(applied, selectedModel); + assert.match(notices[0]!.message, /openai\/gpt-5\.4/); +}); + +test("ambiguous typed model selection chooses provider first, then model", async () => { + const selectedModel = { provider: "github-copilot", id: "gpt-5" }; + let applied: typeof selectedModel | null = null; + const selects: Array<{ title: string; options: string[] }> = []; + const notices: Array<{ message: string; type?: string }> = []; + + const ctx = { + hasUI: true, + model: { provider: "anthropic", id: "claude-sonnet-4-6" }, + modelRegistry: { + getAvailable: () => [ + { provider: "openai", id: "gpt-5" }, + { provider: "github-copilot", id: "gpt-5" }, + { provider: "openai", id: "gpt-5-mini" }, + ], + }, + ui: { + select: async (title: string, options: string[]) => { + selects.push({ title, options }); + return selects.length === 1 ? "github-copilot (1 model)" : "gpt-5"; + }, + notify: (message: string, type?: string) => { + notices.push({ message, type }); + }, + }, + } as any; + + const pi = { + setModel: async (model: typeof selectedModel) => { + applied = model; + return true; + }, + } as any; + + const handled = await handleCoreCommand("model gpt", ctx, pi); + assert.equal(handled, true); + assert.deepEqual(selects, [ + { + title: "Multiple models match \"gpt\" — choose provider:", + options: ["github-copilot (1 model)", "openai (2 models)", "(cancel)"], + }, + { + title: "Multiple models match \"gpt\" — github-copilot:", + options: ["gpt-5", "(cancel)"], + }, + ]); + assert.deepEqual(applied, selectedModel); + assert.match(notices[0]!.message, /github-copilot\/gpt-5/); +}); From c19830b7024f82200964bf2708a84be7676d3865 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 17:45:28 -0500 Subject: [PATCH 2/6] Fix workflow MCP auto-discovery for Claude Code --- src/resources/extensions/gsd/auto.ts | 30 ++++++++++++- src/resources/extensions/gsd/auto/session.ts | 6 +++ .../gsd/tests/auto-project-root-env.test.ts | 29 +++++++++++++ .../extensions/gsd/tests/workflow-mcp.test.ts | 40 +++++++++++++++++- src/resources/extensions/gsd/workflow-mcp.ts | 42 +++++++++++++++---- 5 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/auto-project-root-env.test.ts diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 54f8b4da5..d5a20a264 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -241,6 +241,29 @@ const s = new AutoSession(); /** Throttle STATE.md rebuilds — at most once per 30 seconds */ const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; +function captureProjectRootEnv(projectRoot: string): void { + if (!s.projectRootEnvCaptured) { + s.hadProjectRootEnv = Object.prototype.hasOwnProperty.call(process.env, "GSD_PROJECT_ROOT"); + s.previousProjectRootEnv = process.env.GSD_PROJECT_ROOT ?? null; + s.projectRootEnvCaptured = true; + } + process.env.GSD_PROJECT_ROOT = projectRoot; +} + +function restoreProjectRootEnv(): void { + if (!s.projectRootEnvCaptured) return; + + if (s.hadProjectRootEnv && s.previousProjectRootEnv !== null) { + process.env.GSD_PROJECT_ROOT = s.previousProjectRootEnv; + } else { + delete process.env.GSD_PROJECT_ROOT; + } + + s.previousProjectRootEnv = null; + s.hadProjectRootEnv = false; + s.projectRootEnvCaptured = false; +} + export function shouldUseWorktreeIsolation(): boolean { const prefs = loadEffectiveGSDPreferences()?.preferences?.git; if (prefs?.isolation === "worktree") return true; @@ -542,6 +565,7 @@ function handleLostSessionLock( s.active = false; s.paused = false; clearUnitTimeout(); + restoreProjectRootEnv(); deregisterSigtermHandler(); clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences); const base = lockBase(); @@ -577,6 +601,7 @@ function cleanupAfterLoopExit(ctx: ExtensionContext): void { s.currentUnit = null; s.active = false; clearUnitTimeout(); + restoreProjectRootEnv(); // Clear crash lock and release session lock so the next `/gsd next` does // not see a stale lock with the current PID and treat it as a "remote" @@ -846,6 +871,7 @@ export async function stopAuto( ctx?.ui.setStatus("gsd-auto", undefined); ctx?.ui.setWidget("gsd-progress", undefined); ctx?.ui.setFooter(undefined); + restoreProjectRootEnv(); // Reset all session state in one call s.reset(); @@ -934,6 +960,7 @@ export async function pauseAuto( s.active = false; s.paused = true; + restoreProjectRootEnv(); s.pendingVerificationRetry = null; s.verificationRetryCount.clear(); ctx?.ui.setStatus("gsd-auto", "paused"); @@ -1305,6 +1332,7 @@ export async function startAuto( ); logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress"); + captureProjectRootEnv(s.originalBasePath || s.basePath); await autoLoop(ctx, pi, s, buildLoopDeps()); cleanupAfterLoopExit(ctx); return; @@ -1329,6 +1357,7 @@ export async function startAuto( ); if (!ready) return; + captureProjectRootEnv(s.originalBasePath || s.basePath); try { syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath)); } catch (err) { @@ -1569,4 +1598,3 @@ export { buildLoopRemediationSteps, } from "./auto-recovery.js"; export { resolveExpectedArtifactPath } from "./auto-artifact-paths.js"; - diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index 5f822a51f..dbf8cd0b9 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -84,6 +84,9 @@ export class AutoSession { // ── Paths ──────────────────────────────────────────────────────────────── basePath = ""; originalBasePath = ""; + previousProjectRootEnv: string | null = null; + hadProjectRootEnv = false; + projectRootEnvCaptured = false; gitService: GitServiceImpl | null = null; // ── Dispatch counters ──────────────────────────────────────────────────── @@ -192,6 +195,9 @@ export class AutoSession { // Paths this.basePath = ""; this.originalBasePath = ""; + this.previousProjectRootEnv = null; + this.hadProjectRootEnv = false; + this.projectRootEnvCaptured = false; this.gitService = null; // Dispatch diff --git a/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts b/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts new file mode 100644 index 000000000..98f6a11e2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const sourcePath = join(import.meta.dirname, "..", "auto.ts"); +const source = readFileSync(sourcePath, "utf-8"); + +test("auto-mode captures GSD_PROJECT_ROOT before entering the dispatch loop", () => { + const captureDeclIdx = source.indexOf("function captureProjectRootEnv(projectRoot: string): void {"); + assert.ok(captureDeclIdx > -1, "auto.ts should define captureProjectRootEnv()"); + + const resumeCallIdx = source.indexOf("captureProjectRootEnv(s.originalBasePath || s.basePath);"); + assert.ok(resumeCallIdx > -1, "auto.ts should capture GSD_PROJECT_ROOT before resume autoLoop"); + + const firstAutoLoopIdx = source.indexOf("await autoLoop(ctx, pi, s, buildLoopDeps());"); + assert.ok(firstAutoLoopIdx > -1, "auto.ts should invoke autoLoop()"); + assert.ok( + resumeCallIdx < firstAutoLoopIdx, + "auto.ts must set GSD_PROJECT_ROOT before the first autoLoop() call", + ); +}); + +test("auto-mode restores GSD_PROJECT_ROOT when execution stops or pauses", () => { + assert.match(source, /function restoreProjectRootEnv\(\): void \{/); + assert.match(source, /cleanupAfterLoopExit\(ctx: ExtensionContext\): void \{[\s\S]*restoreProjectRootEnv\(\);/); + assert.match(source, /export async function pauseAuto\([\s\S]*restoreProjectRootEnv\(\);/); + assert.match(source, /\} finally \{[\s\S]*restoreProjectRootEnv\(\);[\s\S]*s\.reset\(\);/); +}); diff --git a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts index 97cb3b3c1..cceac52aa 100644 --- a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts @@ -1,7 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { @@ -70,6 +71,43 @@ test("buildWorkflowMcpServers mirrors explicit launch config", () => { }); }); +test("detectWorkflowMcpLaunchConfig resolves the bundled server from GSD_PROJECT_ROOT", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "gsd-workflow-root-")); + const worktreeRoot = mkdtempSync(join(tmpdir(), "gsd-workflow-worktree-")); + const cliPath = join(repoRoot, "packages", "mcp-server", "dist", "cli.js"); + + mkdirSync(join(repoRoot, "packages", "mcp-server", "dist"), { recursive: true }); + writeFileSync(cliPath, "#!/usr/bin/env node\n", "utf-8"); + + const launch = detectWorkflowMcpLaunchConfig(worktreeRoot, { + GSD_PROJECT_ROOT: repoRoot, + }); + + assert.deepEqual(launch, { + name: "gsd-workflow", + command: process.execPath, + args: [cliPath], + cwd: repoRoot, + env: { + GSD_PERSIST_WRITE_GATE_STATE: "1", + GSD_WORKFLOW_PROJECT_ROOT: repoRoot, + }, + }); +}); + +test("detectWorkflowMcpLaunchConfig resolves the bundled server relative to the installed GSD package", () => { + const launch = detectWorkflowMcpLaunchConfig("/tmp/project", { + GSD_BIN_PATH: "/tmp/gsd-loader.js", + }); + + assert.equal(launch?.command, process.execPath); + assert.equal(launch?.cwd, "/tmp/project"); + assert.equal(launch?.env?.GSD_CLI_PATH, "/tmp/gsd-loader.js"); + assert.equal(launch?.env?.GSD_WORKFLOW_PROJECT_ROOT, "/tmp/project"); + assert.equal(typeof launch?.args?.[0], "string"); + assert.match(launch?.args?.[0] ?? "", /packages[\/\\]mcp-server[\/\\]dist[\/\\]cli\.js$/); +}); + test("usesWorkflowMcpTransport matches local externalCli providers", () => { assert.equal(usesWorkflowMcpTransport("externalCli", "local://claude-code"), true); assert.equal(usesWorkflowMcpTransport("externalCli", "https://api.example.com"), false); diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index a4b5047cc..ead4ea8b5 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -1,6 +1,7 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; export interface WorkflowMcpLaunchConfig { name: string; @@ -66,6 +67,15 @@ function lookupCommand(command: string, platform: NodeJS.Platform = process.plat } } +function getBundledWorkflowMcpCliPath(env: NodeJS.ProcessEnv): string | null { + if (!env.GSD_BIN_PATH?.trim() && !env.GSD_CLI_PATH?.trim()) return null; + + const bundledCli = resolve( + fileURLToPath(new URL("../../../../packages/mcp-server/dist/cli.js", import.meta.url)), + ); + return existsSync(bundledCli) ? bundledCli : null; +} + export function detectWorkflowMcpLaunchConfig( projectRoot = process.cwd(), env: NodeJS.ProcessEnv = process.env, @@ -75,16 +85,19 @@ export function detectWorkflowMcpLaunchConfig( const explicitArgs = parseJsonEnv(env, "GSD_WORKFLOW_MCP_ARGS"); const explicitEnv = parseJsonEnv>(env, "GSD_WORKFLOW_MCP_ENV"); const explicitCwd = env.GSD_WORKFLOW_MCP_CWD?.trim(); + const gsdCliPath = env.GSD_CLI_PATH?.trim() || env.GSD_BIN_PATH?.trim(); const workflowProjectRoot = explicitEnv?.GSD_WORKFLOW_PROJECT_ROOT?.trim() || env.GSD_WORKFLOW_PROJECT_ROOT?.trim() || + env.GSD_PROJECT_ROOT?.trim() || explicitCwd || projectRoot; + const resolvedWorkflowProjectRoot = resolve(workflowProjectRoot); if (explicitCommand) { const launchEnv = { ...(explicitEnv ?? {}), - ...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}), + ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), GSD_PERSIST_WRITE_GATE_STATE: "1", GSD_WORKFLOW_PROJECT_ROOT: resolve(workflowProjectRoot), }; @@ -97,17 +110,32 @@ export function detectWorkflowMcpLaunchConfig( }; } - const distCli = resolve(projectRoot, "packages", "mcp-server", "dist", "cli.js"); + const bundledCli = getBundledWorkflowMcpCliPath(env); + if (bundledCli) { + return { + name, + command: process.execPath, + args: [bundledCli], + cwd: resolvedWorkflowProjectRoot, + env: { + ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), + GSD_PERSIST_WRITE_GATE_STATE: "1", + GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot, + }, + }; + } + + const distCli = resolve(resolvedWorkflowProjectRoot, "packages", "mcp-server", "dist", "cli.js"); if (existsSync(distCli)) { return { name, command: process.execPath, args: [distCli], - cwd: projectRoot, + cwd: resolvedWorkflowProjectRoot, env: { - ...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}), + ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), GSD_PERSIST_WRITE_GATE_STATE: "1", - GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot), + GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot, }, }; } @@ -118,9 +146,9 @@ export function detectWorkflowMcpLaunchConfig( name, command: binPath, env: { - ...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}), + ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), GSD_PERSIST_WRITE_GATE_STATE: "1", - GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot), + GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot, }, }; } From b573af48282ee247953badfe70546b4fcf963824 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 17:50:01 -0500 Subject: [PATCH 3/6] Fix workflow MCP bundled CLI lookup in tests --- src/resources/extensions/gsd/workflow-mcp.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index ead4ea8b5..ffeee0b6f 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -70,10 +70,16 @@ function lookupCommand(command: string, platform: NodeJS.Platform = process.plat function getBundledWorkflowMcpCliPath(env: NodeJS.ProcessEnv): string | null { if (!env.GSD_BIN_PATH?.trim() && !env.GSD_CLI_PATH?.trim()) return null; - const bundledCli = resolve( - fileURLToPath(new URL("../../../../packages/mcp-server/dist/cli.js", import.meta.url)), - ); - return existsSync(bundledCli) ? bundledCli : null; + const candidates = [ + resolve(fileURLToPath(new URL("../../../../packages/mcp-server/dist/cli.js", import.meta.url))), + resolve(fileURLToPath(new URL("../../../../../packages/mcp-server/dist/cli.js", import.meta.url))), + ]; + + for (const bundledCli of candidates) { + if (existsSync(bundledCli)) return bundledCli; + } + + return null; } export function detectWorkflowMcpLaunchConfig( From c6ff8b026d94cafca663f16f243240014018e2a0 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 18:00:24 -0500 Subject: [PATCH 4/6] Fix MCP server packaging in clean builds --- package.json | 3 ++- packages/mcp-server/tsconfig.json | 2 +- scripts/ensure-workspace-builds.cjs | 1 + scripts/validate-pack.js | 7 +++++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 949928fb7..90b1f5a05 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,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-resources && npm run copy-themes && npm run copy-export-html && node scripts/build-web-if-stale.cjs", + "build:mcp-server": "npm run build -w @gsd-build/mcp-server", + "build": "npm run build:pi && npm run build:mcp-server && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html && node scripts/build-web-if-stale.cjs", "stage:web-host": "node scripts/stage-web-standalone.cjs", "build:web-host": "npm --prefix web run build && npm run stage:web-host", "copy-resources": "node scripts/copy-resources.cjs", diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json index 779b48aca..1e62e4af6 100644 --- a/packages/mcp-server/tsconfig.json +++ b/packages/mcp-server/tsconfig.json @@ -20,5 +20,5 @@ "rootDir": "./src" }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts", "src/**/*.test.ts"] } diff --git a/scripts/ensure-workspace-builds.cjs b/scripts/ensure-workspace-builds.cjs index 10a6638e4..daecd8689 100644 --- a/scripts/ensure-workspace-builds.cjs +++ b/scripts/ensure-workspace-builds.cjs @@ -97,6 +97,7 @@ if (require.main === module) { 'pi-ai', 'pi-agent-core', 'pi-coding-agent', + 'mcp-server', ] const stale = detectStalePackages(root, WORKSPACE_PACKAGES) diff --git a/scripts/validate-pack.js b/scripts/validate-pack.js index 3ecd195ca..a29cd1f76 100644 --- a/scripts/validate-pack.js +++ b/scripts/validate-pack.js @@ -65,6 +65,7 @@ try { const requiredFiles = [ 'dist/loader.js', 'packages/pi-coding-agent/dist/index.js', + 'packages/mcp-server/dist/cli.js', 'scripts/link-workspace-packages.cjs', 'dist/web/standalone/server.js', ]; @@ -133,6 +134,12 @@ try { // --- Run the binary to confirm end-to-end resolution --- console.log('==> Running installed binary (gsd -v)...'); const loaderPath = join(installedRoot, 'dist', 'loader.js'); + const bundledWorkflowMcpCliPath = join(installedRoot, 'packages', 'mcp-server', 'dist', 'cli.js'); + if (!existsSync(bundledWorkflowMcpCliPath)) { + console.log('ERROR: Bundled workflow MCP CLI missing after install.'); + console.log(` Expected: ${bundledWorkflowMcpCliPath}`); + process.exit(1); + } try { const versionOutput = execSync(`node "${loaderPath}" -v`, { cwd: installDir, From bb2fdf0145fcb31962d13b0896992157b5bd1345 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 18:29:38 -0500 Subject: [PATCH 5/6] Build and link bundled RPC client --- package.json | 3 ++- scripts/ensure-workspace-builds.cjs | 1 + scripts/link-workspace-packages.cjs | 39 +++++++++++++++++------------ scripts/validate-pack.js | 16 +++++++----- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 90b1f5a05..e43d754ce 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,10 @@ "build:pi-agent-core": "npm run build -w @gsd/pi-agent-core", "build:pi-coding-agent": "npm run build -w @gsd/pi-coding-agent", "build:native-pkg": "npm run build -w @gsd/native", + "build:rpc-client": "npm run build -w @gsd-build/rpc-client", "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:mcp-server": "npm run build -w @gsd-build/mcp-server", - "build": "npm run build:pi && npm run build:mcp-server && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html && node scripts/build-web-if-stale.cjs", + "build": "npm run build:pi && npm run build:rpc-client && npm run build:mcp-server && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html && node scripts/build-web-if-stale.cjs", "stage:web-host": "node scripts/stage-web-standalone.cjs", "build:web-host": "npm --prefix web run build && npm run stage:web-host", "copy-resources": "node scripts/copy-resources.cjs", diff --git a/scripts/ensure-workspace-builds.cjs b/scripts/ensure-workspace-builds.cjs index daecd8689..60636feb6 100644 --- a/scripts/ensure-workspace-builds.cjs +++ b/scripts/ensure-workspace-builds.cjs @@ -97,6 +97,7 @@ if (require.main === module) { 'pi-ai', 'pi-agent-core', 'pi-coding-agent', + 'rpc-client', 'mcp-server', ] diff --git a/scripts/link-workspace-packages.cjs b/scripts/link-workspace-packages.cjs index f1faf9875..7c203a19f 100644 --- a/scripts/link-workspace-packages.cjs +++ b/scripts/link-workspace-packages.cjs @@ -2,7 +2,8 @@ /** * link-workspace-packages.cjs * - * Creates node_modules/@gsd/* symlinks pointing to packages/* directories. + * Creates node_modules/@gsd/* and node_modules/@gsd-build/* symlinks pointing + * to shipped packages/* directories. * * During development, npm workspaces creates these automatically. But in the * published tarball, workspace packages are shipped under packages/ (via the @@ -20,27 +21,33 @@ const { resolve, join } = require('path') const root = resolve(__dirname, '..') const packagesDir = join(root, 'packages') -const nodeModulesGsd = join(root, 'node_modules', '@gsd') - -// Map directory names to package names -const packageMap = { - 'native': 'native', - 'pi-agent-core': 'pi-agent-core', - 'pi-ai': 'pi-ai', - 'pi-coding-agent': 'pi-coding-agent', - 'pi-tui': 'pi-tui', +const scopeDirs = { + '@gsd': join(root, 'node_modules', '@gsd'), + '@gsd-build': join(root, 'node_modules', '@gsd-build'), } -// Ensure @gsd scope directory exists -if (!existsSync(nodeModulesGsd)) { - mkdirSync(nodeModulesGsd, { recursive: true }) +// Map directory names to scoped package names +const packageMap = { + 'native': { scope: '@gsd', name: 'native' }, + 'pi-agent-core': { scope: '@gsd', name: 'pi-agent-core' }, + 'pi-ai': { scope: '@gsd', name: 'pi-ai' }, + 'pi-coding-agent': { scope: '@gsd', name: 'pi-coding-agent' }, + 'pi-tui': { scope: '@gsd', name: 'pi-tui' }, + 'rpc-client': { scope: '@gsd-build', name: 'rpc-client' }, +} + +for (const scopeDir of Object.values(scopeDirs)) { + if (!existsSync(scopeDir)) { + mkdirSync(scopeDir, { recursive: true }) + } } let linked = 0 let copied = 0 -for (const [dir, name] of Object.entries(packageMap)) { +for (const [dir, pkg] of Object.entries(packageMap)) { const source = join(packagesDir, dir) - const target = join(nodeModulesGsd, name) + const scopeDir = scopeDirs[pkg.scope] + const target = join(scopeDir, pkg.name) if (!existsSync(source)) continue @@ -50,7 +57,7 @@ for (const [dir, name] of Object.entries(packageMap)) { const stat = lstatSync(target) if (stat.isSymbolicLink()) { const linkTarget = readlinkSync(target) - if (resolve(join(nodeModulesGsd, linkTarget)) === source || linkTarget === source) { + if (resolve(join(scopeDir, linkTarget)) === source || linkTarget === source) { continue // Already correct } unlinkSync(target) // Wrong target, relink diff --git a/scripts/validate-pack.js b/scripts/validate-pack.js index a29cd1f76..e4bbe6277 100644 --- a/scripts/validate-pack.js +++ b/scripts/validate-pack.js @@ -65,6 +65,7 @@ try { const requiredFiles = [ 'dist/loader.js', 'packages/pi-coding-agent/dist/index.js', + 'packages/rpc-client/dist/index.js', 'packages/mcp-server/dist/cli.js', 'scripts/link-workspace-packages.cjs', 'dist/web/standalone/server.js', @@ -110,16 +111,19 @@ try { // node_modules/@gsd/ is never populated, causing ERR_MODULE_NOT_FOUND at runtime. console.log('==> Verifying @gsd/* workspace package resolution...'); const installedRoot = join(installDir, 'node_modules', 'gsd-pi'); - const criticalPkgs = ['pi-coding-agent']; + const criticalPackages = [ + { scope: '@gsd', name: 'pi-coding-agent' }, + { scope: '@gsd-build', name: 'rpc-client' }, + ]; let resolutionFailed = false; - for (const pkg of criticalPkgs) { - const pkgPath = join(installedRoot, 'node_modules', '@gsd', pkg); - const fallbackPath = join(installedRoot, 'packages', pkg); + for (const pkg of criticalPackages) { + const pkgPath = join(installedRoot, 'node_modules', pkg.scope, pkg.name); + const fallbackPath = join(installedRoot, 'packages', pkg.name); if (!existsSync(pkgPath)) { if (existsSync(fallbackPath)) { - console.log(` MISSING symlink/copy: node_modules/@gsd/${pkg} (packages/${pkg} exists — postinstall may not have run)`); + console.log(` MISSING symlink/copy: node_modules/${pkg.scope}/${pkg.name} (packages/${pkg.name} exists — postinstall may not have run)`); } else { - console.log(` MISSING: node_modules/@gsd/${pkg} (packages/${pkg} also absent — package is broken)`); + console.log(` MISSING: node_modules/${pkg.scope}/${pkg.name} (packages/${pkg.name} also absent — package is broken)`); } resolutionFailed = true; } From 26afe8b2a0f3c883720af79733a684be31333f72 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 18:41:46 -0500 Subject: [PATCH 6/6] Prefer project MCP CLI over bundled fallback --- src/resources/extensions/gsd/workflow-mcp.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index ffeee0b6f..7ec90e174 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -116,12 +116,12 @@ export function detectWorkflowMcpLaunchConfig( }; } - const bundledCli = getBundledWorkflowMcpCliPath(env); - if (bundledCli) { + const distCli = resolve(resolvedWorkflowProjectRoot, "packages", "mcp-server", "dist", "cli.js"); + if (existsSync(distCli)) { return { name, command: process.execPath, - args: [bundledCli], + args: [distCli], cwd: resolvedWorkflowProjectRoot, env: { ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), @@ -131,12 +131,12 @@ export function detectWorkflowMcpLaunchConfig( }; } - const distCli = resolve(resolvedWorkflowProjectRoot, "packages", "mcp-server", "dist", "cli.js"); - if (existsSync(distCli)) { + const bundledCli = getBundledWorkflowMcpCliPath(env); + if (bundledCli) { return { name, command: process.execPath, - args: [distCli], + args: [bundledCli], cwd: resolvedWorkflowProjectRoot, env: { ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}),