From 9f0723a7be8e7b1b27e05deae2bc7c964bedbc79 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 19 Apr 2026 08:53:27 +0200 Subject: [PATCH] preferences + mcp-client: resolve from main worktree and add global MCP config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes surfaced from a real sf headless auto run in dr-repo. 1. Project preferences now resolve from the MAIN worktree, not the current linked worktree. SF's auto-mode creates a git worktree per milestone (`.sf/worktrees/M003/`). The old code called `projectPreferencesPath()` which used `process.cwd()` — the milestone worktree — so a pref change on main (service_tier, dynamic_routing, model config) never reached an in-flight milestone until the branch merged main. Observed concretely when disabling dynamic_routing had no effect until we merged main into the milestone branch. New `projectPrefsRoot()` detects a linked worktree by reading `.git` (a FILE in worktrees, pointing to `/main/.git/worktrees/NAME`), follows the `commondir` pointer back to the main `.git` dir, and walks up one level. Falls back to cwd silently for non-worktree setups. 2. MCP server config now also loads from global paths (`~/.sf/mcp.json`, `~/.sf/agent/mcp.json`) in addition to the existing project-level (`.mcp.json`, `.sf/mcp.json`). First-hit wins, so project configs can still shadow or augment a globally- registered server by name. This lets the user register unauth'd servers like the DeepWiki remote MCP once and have every SF project pick it up without per-project `.mcp.json`. Co-Authored-By: Claude Sonnet 4.6 --- src/resources/extensions/mcp-client/index.ts | 7 ++++ src/resources/extensions/sf/preferences.ts | 44 ++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/resources/extensions/mcp-client/index.ts b/src/resources/extensions/mcp-client/index.ts index 2ed6b7729..f29a46175 100644 --- a/src/resources/extensions/mcp-client/index.ts +++ b/src/resources/extensions/mcp-client/index.ts @@ -24,6 +24,7 @@ import { Client } from "@modelcontextprotocol/sdk/client"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { readFileSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { buildHttpTransportOpts } from "./auth.js"; import type { McpHttpAuthConfig } from "./auth.js"; @@ -66,9 +67,15 @@ function readConfigs(): McpServerConfig[] { const servers: McpServerConfig[] = []; const seen = new Set(); + // Search order matters: first hit wins (seen-guard below), so put + // project-local configs first — a project can override or shadow a + // globally-registered server by re-declaring the same name. + const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); const configPaths = [ join(process.cwd(), ".mcp.json"), join(process.cwd(), ".sf", "mcp.json"), + join(sfHome, "mcp.json"), // global: ~/.sf/mcp.json + join(sfHome, "agent", "mcp.json"), // global: ~/.sf/agent/mcp.json (legacy alt) ]; for (const configPath of configPaths) { diff --git a/src/resources/extensions/sf/preferences.ts b/src/resources/extensions/sf/preferences.ts index 8210bf3ef..bc0672af1 100644 --- a/src/resources/extensions/sf/preferences.ts +++ b/src/resources/extensions/sf/preferences.ts @@ -12,7 +12,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, dirname, resolve } from "node:path"; import { sfRoot } from "./paths.js"; import { parse as parseYaml } from "yaml"; @@ -110,8 +110,46 @@ function legacyGlobalPreferencesPath(): string { return join(homedir(), ".pi", "agent", "sf-preferences.md"); } +/** + * Resolve the "project root" for preferences. When SF is running inside a + * git worktree (e.g. `.sf/worktrees/M003/`), project-level prefs should + * come from the MAIN worktree's `.sf/PREFERENCES.md`, not the milestone + * branch's frozen copy. Otherwise a pref change on main never reaches an + * in-flight milestone, and we saw this in practice: updating PREFERENCES + * on main had no effect until the milestone branch merged main. + * + * Strategy: read `.git` in the current worktree. If it's a file of the + * form `gitdir: `, we're in a linked worktree — resolve the + * commondir and walk up one level to reach the main worktree. Fall back + * to cwd silently for non-worktree setups (bare clones, submodules, etc. + * where the current logic is already correct). + */ +function projectPrefsRoot(): string { + const cwd = process.cwd(); + try { + const gitPath = join(cwd, ".git"); + if (!existsSync(gitPath)) return cwd; + const stat = readFileSync(gitPath, "utf-8"); + // Linked worktrees have `.git` as a FILE: "gitdir: /abs/path/to/main/.git/worktrees/NAME" + const m = /^gitdir:\s*(.+)$/m.exec(stat.trim()); + if (!m) return cwd; + const gitdir = m[1].trim(); + // gitdir looks like /.../.git/worktrees/NAME — commondir is the /.../.git/ part; + // the main worktree is the directory containing that .git dir. + // Use the `commondir` pointer file inside the linked worktree's gitdir when present. + const commondirFile = join(gitdir, "commondir"); + if (existsSync(commondirFile)) { + const rel = readFileSync(commondirFile, "utf-8").trim(); + const commondir = resolve(gitdir, rel); // usually "../.." → /.../.git + const mainRoot = dirname(commondir); // /.../ (main worktree root) + if (existsSync(join(mainRoot, ".sf"))) return mainRoot; + } + } catch { /* non-fatal — fall back to cwd */ } + return cwd; +} + function projectPreferencesPath(): string { - return join(sfRoot(process.cwd()), "preferences.md"); + return join(sfRoot(projectPrefsRoot()), "preferences.md"); } // Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake. // Check uppercase as a fallback so those files aren't silently ignored. @@ -119,7 +157,7 @@ function globalPreferencesPathUppercase(): string { return join(sfHome(), "PREFERENCES.md"); } function projectPreferencesPathUppercase(): string { - return join(sfRoot(process.cwd()), "PREFERENCES.md"); + return join(sfRoot(projectPrefsRoot()), "PREFERENCES.md"); } export function getGlobalSFPreferencesPath(): string {