diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ddf672d38..0af338b96 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -35,6 +35,8 @@ - Fixed `tui.select.pageUp` and `tui.select.pageDown` being ignored in slash command and file autocomplete, `/settings`, and the model, scoped models, login, fork, `/mcp`, and llama.cpp selectors. In fullscreen mode, PageUp/PageDown now move an open list or autocomplete menu instead of scrolling the transcript, and a page key bound to Ctrl+V pages autocomplete instead of pasting an image ([#7629](https://github.com/earendil-works/pi/issues/7629), based on [#7680](https://github.com/earendil-works/pi/pull/7680) by [@muyiyr](https://github.com/muyiyr)) - Fixed tool output and `!` command output showing the parameters of control sequences with a `<`, `=` or `>` prefix as text, for example `35;59;1M` from a mouse report `ESC[<35;59;1M` or `0;276;0c` from a terminal reply `ESC[>0;276;0c`. Text after the keypad-mode sequences `ESC=` and `ESC>` is still kept ([#11377](https://github.com/earendil-works/pi/issues/11377)) - Fixed `pi -e npm:` staying on the version from the first download. On each start and `/reload`, pi now reinstalls an `-e` npm package without an exact version when the registry has a newer matching version. `pi -e git:@` likewise stayed on its first checkout and is now fetched and reset to the branch; a commit-hash ref that is already checked out is not fetched. Offline mode and failed checks keep the cached copy ([#11375](https://github.com/earendil-works/pi/issues/11375), [#11590](https://github.com/earendil-works/pi/pull/11590)) +- Fixed Windows paths being compared case-sensitively. When the working directory and home directory differed only in drive-letter casing (`c:\Users\me` vs `C:\Users\me`), skills in `~/.agents/skills` loaded twice, as project and user skills, and were reported as name collisions. The same mismatch also re-asked for saved project trust, hid sessions in a custom session directory from `--continue` and `/resume`, and stopped `pi remove` from matching configured local packages ([#10488](https://github.com/earendil-works/pi/issues/10488)) +- Fixed paths next to the home directory being shown with a broken `~` prefix, for example `/home/me2/project` shown as `~2/project` when home is `/home/me` ## [1.4.0] - 2026-10-21 diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index c1d7655dc..81c4264cf 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -1,6 +1,6 @@ import { constants, copyFileSync, existsSync, mkdirSync } from "node:fs"; -import { basename, join, parse, resolve } from "node:path"; -import { resolvePath } from "../utils/paths.ts"; +import { basename, join, parse } from "node:path"; +import { pathsEqual, resolvePath } from "../utils/paths.ts"; import type { AgentSession } from "./agent-session.ts"; import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts"; import type { @@ -368,7 +368,7 @@ export class AgentSessionRuntime { } let destinationPath = join(sessionDir, basename(resolvedPath)); - const sourceAlreadyStored = resolve(destinationPath) === resolvedPath; + const sourceAlreadyStored = pathsEqual(destinationPath, resolvedPath); if (!sourceAlreadyStored) { const { name, ext } = parse(destinationPath); let suffix = 1; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index cbc1100fc..235a8632e 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -11,7 +11,7 @@ import type { Provider } from "@earendil-works/pi-ai"; import type { KeyId } from "@earendil-works/pi-tui"; import type { createJiti } from "jiti"; import { CONFIG_DIR_NAME, getAgentDir, isBunBinary, isBundledNode } from "../../config.ts"; -import { resolvePath } from "../../utils/paths.ts"; +import { pathsEqual, resolvePath } from "../../utils/paths.ts"; import { createEventBus, type EventBus } from "../event-bus.ts"; import type { ExecOptions } from "../exec.ts"; import { execCommand } from "../exec.ts"; @@ -146,7 +146,7 @@ export function clearExtensionCache(): void { function useExtensionCacheCwd(cwd: string): ExtensionCacheToken { const resolvedCwd = resolvePath(cwd); - if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) { + if (extensionCacheCwd !== undefined && !pathsEqual(extensionCacheCwd, resolvedCwd)) { clearExtensionCache(); } extensionCacheCwd = resolvedCwd; diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index f45678ccc..1a0c96932 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -43,16 +43,18 @@ import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; import { canonicalizePath, + getPathComparisonKey, isLocalPath, markPathIgnoredByCloudSync, normalizePath, + pathsEqual, resolvePath, } from "../utils/paths.ts"; import { stripBom } from "../utils/text.ts"; import { isStdoutTakenOver } from "./output-guard.ts"; import { type PiManifest, readPiManifest } from "./pi-manifest.ts"; import type { PackageSource, SettingsManager } from "./settings-manager.ts"; -import { BUILTIN_PATH_PREFIX } from "./source-info.ts"; +import { BUILTIN_PATH_PREFIX, getResourcePathKey } from "./source-info.ts"; const NETWORK_TIMEOUT_MS = 10000; const UPDATE_CHECK_CONCURRENCY = 4; @@ -1522,7 +1524,7 @@ export class DefaultPackageManager implements PackageManager { if (parsed.type === "git") { return `git:${parsed.host}/${parsed.path}`; } - return `local:${this.resolvePath(parsed.path)}`; + return `local:${getPathComparisonKey(this.resolvePath(parsed.path))}`; } private getSourceMatchKeyForSettings(source: string, scope: SourceScope): string { @@ -1534,7 +1536,7 @@ export class DefaultPackageManager implements PackageManager { return `git:${parsed.host}/${parsed.path}`; } const baseDir = this.getBaseDirForScope(scope); - return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`; + return `local:${getPathComparisonKey(this.resolvePathFromBase(parsed.path, baseDir))}`; } private buildNoMatchingPackageMessage(source: string, configuredPackages: PackageSource[]): string { @@ -1839,9 +1841,9 @@ export class DefaultPackageManager implements PackageManager { } if (scope) { const baseDir = this.getBaseDirForScope(scope); - return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`; + return `local:${getPathComparisonKey(this.resolvePathFromBase(parsed.path, baseDir))}`; } - return `local:${this.resolvePath(parsed.path)}`; + return `local:${getPathComparisonKey(this.resolvePath(parsed.path))}`; } /** @@ -2591,7 +2593,7 @@ export class DefaultPackageManager implements PackageManager { const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); const projectTrusted = this.settingsManager.isProjectTrusted(); const projectAgentsSkillDirs = projectTrusted - ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir)) + ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => !pathsEqual(dir, userAgentsSkillsDir)) : []; const addResources = ( @@ -2780,9 +2782,9 @@ export class DefaultPackageManager implements PackageManager { const seen = new Set(); return resolved.filter((entry) => { - const canonicalPath = canonicalizePath(entry.path); - if (seen.has(canonicalPath)) return false; - seen.add(canonicalPath); + const pathKey = getResourcePathKey(entry.path); + if (seen.has(pathKey)) return false; + seen.add(pathKey); return true; }); }; diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 5957abf27..bb0740e49 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -8,7 +8,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts"; export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; -import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; +import { canonicalizePath, isLocalPath, isPathWithin, pathsEqual, resolvePath } from "../utils/paths.ts"; import { stripBom } from "../utils/text.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; import { @@ -39,6 +39,7 @@ import { loadSkills } from "./skills.ts"; import { BUILTIN_PATH_PREFIX, createSourceInfo, + getResourcePathKey, getSyntheticPathSource, isSyntheticPath, type SourceInfo, @@ -246,12 +247,12 @@ function findShadowedContextFile(cwd: string): string | undefined { const mainRepoRoot = dirname(commonGitDir); // False for an ordinary repo, where the two are the same dir, and for a sibling // worktree (`git worktree add ../feat`), whose main repo is not an ancestor. - if (!worktreeRoot.startsWith(`${mainRepoRoot}${sep}`)) return undefined; + if (!isPathWithin(mainRepoRoot, worktreeRoot) || pathsEqual(worktreeRoot, mainRepoRoot)) return undefined; // dirname of the common git dir is the main worktree root only when that dir is // itself checked out from the same repo. In a bare layout (`proj/.bare` + // `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a // submodule's gitdir has no `commondir`, so it lands under `.git/modules`. - if (canonicalizePath(join(mainRepoRoot, ".git")) !== commonGitDir) return undefined; + if (!pathsEqual(canonicalizePath(join(mainRepoRoot, ".git")), commonGitDir)) return undefined; const worktreeContextFile = loadContextFileFromDir(worktreeRoot); return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined; } @@ -284,7 +285,7 @@ export function loadProjectContextFiles(options: { const isShadowed = shadowedContextFile !== undefined && contextFile !== null && - join(canonicalizePath(currentDir), basename(contextFile.path)) === shadowedContextFile; + pathsEqual(join(canonicalizePath(currentDir), basename(contextFile.path)), shadowedContextFile); if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) { ancestorContextFiles.unshift(contextFile); seenPaths.add(contextFile.path); @@ -750,6 +751,10 @@ export class DefaultResourceLoader implements ResourceLoader { return isSyntheticPath(path) ? path : resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true }); } + private getExtensionPathKey(path: string): string { + return getResourcePathKey(this.resolveExtensionLoadPath(path)); + } + /** Load extension paths: files from disk and `builtin:` paths from the built-in extensions. */ private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise { const isBuiltinPath = (path: string) => path.startsWith(BUILTIN_PATH_PREFIX); @@ -792,26 +797,26 @@ export class DefaultResourceLoader implements ResourceLoader { const preloadedByPath = new Map( preloaded .filter((extension) => !extension.path.startsWith(" [extension.resolvedPath, extension]), + .map((extension) => [getResourcePathKey(extension.resolvedPath), extension]), ); const failedPreloadPaths = new Set( - (preTrustExtensions?.errors ?? []).map((error) => this.resolveExtensionLoadPath(error.path)), + (preTrustExtensions?.errors ?? []).map((error) => this.getExtensionPathKey(error.path)), ); const remainingPaths = extensionPaths.filter((path) => { - const resolvedPath = this.resolveExtensionLoadPath(path); - return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath); + const pathKey = this.getExtensionPathKey(path); + return !preloadedByPath.has(pathKey) && !failedPreloadPaths.has(pathKey); }); const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime); const loadedByPath = new Map(preloadedByPath); for (const extension of remainingExtensions.extensions) { - loadedByPath.set(extension.resolvedPath, extension); + loadedByPath.set(getResourcePathKey(extension.resolvedPath), extension); } const inlineExtensions = preTrustExtensions ? { extensions: preloaded.filter((extension) => extension.path.startsWith(" loadedByPath.get(this.resolveExtensionLoadPath(path))) + .map((path) => loadedByPath.get(this.getExtensionPathKey(path))) .filter((extension): extension is Extension => extension !== undefined); orderedExtensions.push(...inlineExtensions.extensions); @@ -980,11 +985,7 @@ export class DefaultResourceLoader implements ResourceLoader { const normalizedResourcePath = resolve(resourcePath); if (extraSourceInfos) { for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) { - const normalizedSourcePath = resolve(sourcePath); - if ( - normalizedResourcePath === normalizedSourcePath || - normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`) - ) { + if (isPathWithin(sourcePath, normalizedResourcePath)) { return { ...sourceInfo, path: resourcePath }; } } @@ -997,11 +998,7 @@ export class DefaultResourceLoader implements ResourceLoader { } for (const [sourcePath, metadata] of metadataByPath.entries()) { - const normalizedSourcePath = resolve(sourcePath); - if ( - normalizedResourcePath === normalizedSourcePath || - normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`) - ) { + if (isPathWithin(sourcePath, normalizedResourcePath)) { return createSourceInfo(resourcePath, metadata); } } @@ -1062,9 +1059,9 @@ export class DefaultResourceLoader implements ResourceLoader { for (const p of [...primary, ...additional]) { const resolved = this.resolveResourcePath(p); - const canonicalPath = canonicalizePath(resolved); - if (seen.has(canonicalPath)) continue; - seen.add(canonicalPath); + const pathKey = getResourcePathKey(resolved); + if (seen.has(pathKey)) continue; + seen.add(pathKey); merged.push(resolved); } diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 89431f2a3..822aad816 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -30,7 +30,7 @@ import { basename, join, resolve } from "path"; import { createInterface } from "readline"; import { StringDecoder } from "string_decoder"; import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; -import { normalizePath, resolvePath } from "../utils/paths.ts"; +import { normalizePath, pathsEqual, resolvePath } from "../utils/paths.ts"; import { type BashExecutionMessage, type CustomMessage, @@ -742,7 +742,7 @@ function getSessionHeaderCwd(header: SessionHeader): string | undefined { } function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean { - return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd; + return cwd !== undefined && cwd !== "" && pathsEqual(resolvePath(cwd), resolvedCwd); } /** Exported for testing */ @@ -1146,7 +1146,7 @@ export class SessionManager { } usesDefaultSessionDir(): boolean { - return this.sessionDir === getDefaultSessionDirPath(this.cwd); + return pathsEqual(this.sessionDir, getDefaultSessionDirPath(this.cwd)); } getSessionId(): string { @@ -1802,7 +1802,7 @@ export class SessionManager { */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd)); const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); @@ -1888,7 +1888,7 @@ export class SessionManager { */ static findById(cwd: string, id: string, sessionDir?: string): string | undefined { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd)); const resolvedCwd = resolvePath(cwd); try { @@ -1919,7 +1919,7 @@ export class SessionManager { signal?: AbortSignal, ): Promise { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd)); const resolvedCwd = resolvePath(cwd); const includeSession = (session: SessionInfo) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd); const progress: SessionListProgress | undefined = onProgress diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 3fbf2b926..7440c4684 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -3,9 +3,9 @@ import ignore from "ignore"; import { basename, dirname, join, relative, resolve, sep } from "path"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { parseFrontmatter } from "../utils/frontmatter.ts"; -import { canonicalizePath, resolvePath } from "../utils/paths.ts"; +import { isPathWithin, resolvePath } from "../utils/paths.ts"; import type { ResourceDiagnostic } from "./diagnostics.ts"; -import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; +import { createSyntheticSourceInfo, getResourcePathKey, type SourceInfo } from "./source-info.ts"; /** Max name length per spec */ const MAX_NAME_LENGTH = 64; @@ -427,7 +427,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { allDiagnostics.push(...result.diagnostics); for (const skill of result.skills) { // Resolve symlinks to detect duplicate files - const realPath = canonicalizePath(skill.filePath); + const realPath = getResourcePathKey(skill.filePath); // Skip silently if we've already loaded this exact file (via symlink) if (realPathSet.has(realPath)) { @@ -462,19 +462,10 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { const userSkillsDir = join(resolvedAgentDir, "skills"); const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"); - const isUnderPath = (target: string, root: string): boolean => { - const normalizedRoot = resolve(root); - if (target === normalizedRoot) { - return true; - } - const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; - return target.startsWith(prefix); - }; - const getSource = (resolvedPath: string): "user" | "project" | "path" => { if (!includeDefaults) { - if (isUnderPath(resolvedPath, userSkillsDir)) return "user"; - if (isUnderPath(resolvedPath, projectSkillsDir)) return "project"; + if (isPathWithin(userSkillsDir, resolvedPath)) return "user"; + if (isPathWithin(projectSkillsDir, resolvedPath)) return "project"; } return "path"; }; diff --git a/packages/coding-agent/src/core/source-info.ts b/packages/coding-agent/src/core/source-info.ts index e73a0471d..716e8c069 100644 --- a/packages/coding-agent/src/core/source-info.ts +++ b/packages/coding-agent/src/core/source-info.ts @@ -1,3 +1,4 @@ +import { canonicalizePath, getPathComparisonKey } from "../utils/paths.ts"; import type { PathMetadata } from "./package-manager.ts"; export type SourceScope = "user" | "project" | "temporary"; @@ -41,6 +42,11 @@ export function isSyntheticPath(path: string): boolean { return path.startsWith(BUILTIN_PATH_PREFIX) || path.startsWith("<"); } +/** Key for deduplicating resource paths: synthetic paths as-is, file paths via realpath and platform casing. */ +export function getResourcePathKey(path: string): string { + return isSyntheticPath(path) ? path : getPathComparisonKey(canonicalizePath(path)); +} + export function createSourceInfo(path: string, metadata: PathMetadata): SourceInfo { return { path, diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index c0fca375d..1bde70bc3 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -1,20 +1,15 @@ -import * as os from "node:os"; import { pathToFileURL } from "node:url"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.ts"; import { stripAnsi } from "../../utils/ansi.ts"; -import { resolvePath } from "../../utils/paths.ts"; +import { resolvePath, shortenHomePath } from "../../utils/paths.ts"; import { sanitizeBinaryOutput } from "../../utils/shell.ts"; import type { ToolRenderContext } from "../extensions/types.ts"; export function shortenPath(path: unknown): string { if (typeof path !== "string") return ""; - const home = os.homedir(); - if (path.startsWith(home)) { - return `~${path.slice(home.length)}`; - } - return path; + return shortenHomePath(path); } export function linkPath(styledText: string, rawPath: string, cwd: string): string { diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 3396c9a39..f0721efef 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME } from "../config.ts"; -import { canonicalizePath, resolvePath } from "../utils/paths.ts"; +import { canonicalizePath, pathsEqual, resolvePath } from "../utils/paths.ts"; import { stripBom } from "../utils/text.ts"; export type ProjectTrustDecision = boolean | null; @@ -42,12 +42,19 @@ function normalizeCwd(cwd: string): string { return canonicalizePath(resolvePath(cwd)); } +/** Trust file keys naming `path`. Usually one; several on Windows when saved with different casing. */ +function findTrustFileKeys(data: TrustFile, path: string): string[] { + return Object.keys(data).filter((key) => pathsEqual(key, path)); +} + function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null { let currentDir = normalizeCwd(cwd); while (true) { - const value = data[currentDir]; - if (value === true || value === false) { - return { path: currentDir, decision: value }; + for (const key of findTrustFileKeys(data, currentDir)) { + const value = data[key]; + if (value === true || value === false) { + return { path: key, decision: value }; + } } const parentDir = dirname(currentDir); @@ -195,7 +202,7 @@ export function hasTrustRequiringProjectResources(cwd: string): boolean { while (true) { const agentsSkillsDir = join(currentDir, ".agents", "skills"); - if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) { + if (!pathsEqual(agentsSkillsDir, userAgentsSkillsDir) && existsSync(agentsSkillsDir)) { return true; } @@ -234,9 +241,10 @@ export class ProjectTrustStore { const data = readTrustFile(this.trustPath); for (const { path, decision } of decisions) { const key = normalizeCwd(path); - if (decision === null) { - delete data[key]; - } else { + for (const existingKey of findTrustFileKeys(data, key)) { + delete data[existingKey]; + } + if (decision !== null) { data[key] = decision; } } diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index a8054886e..913b51179 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -2,7 +2,6 @@ * TUI component for managing package resources (enable/disable) */ -import { homedir } from "node:os"; import { basename, dirname, join, relative } from "node:path"; import { type Component, @@ -18,8 +17,8 @@ import { import { CONFIG_DIR_NAME } from "../../../config.ts"; import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts"; import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts"; -import { BUILTIN_PATH_PREFIX } from "../../../core/source-info.ts"; -import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.ts"; +import { BUILTIN_PATH_PREFIX, getResourcePathKey } from "../../../core/source-info.ts"; +import { isLocalPath, resolvePath, shortenHomePath } from "../../../utils/paths.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; @@ -65,19 +64,7 @@ interface ResourceGroup { } function formatBaseDir(baseDir: string): string { - const homeDir = homedir(); - let displayPath: string; - - if (baseDir === homeDir) { - displayPath = "~"; - } else if (baseDir.startsWith(homeDir)) { - // Replace home prefix with ~, normalize separators for display - const rest = baseDir.slice(homeDir.length); - displayPath = `~${rest.replace(/\\/g, "/")}`; - } else { - displayPath = baseDir.replace(/\\/g, "/"); - } - + const displayPath = shortenHomePath(baseDir).replace(/\\/g, "/"); return displayPath.endsWith("/") ? displayPath : `${displayPath}/`; } @@ -848,7 +835,7 @@ class ResourceList implements Component, Focusable { } private getResourceItemKey(item: ResourceItem): string { - return `${item.resourceType}:${canonicalizePath(item.path)}`; + return `${item.resourceType}:${getResourcePathKey(item.path)}`; } private getItemScope(item: ResourceItem): SettingsScope { diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index baa66e991..4d6d6c09f 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -1,7 +1,6 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { unlink } from "node:fs/promises"; -import * as os from "node:os"; import { type Component, Container, @@ -15,7 +14,7 @@ import { } from "@earendil-works/pi-tui"; import { KeybindingsManager } from "../../../core/keybindings.ts"; import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.ts"; -import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.ts"; +import { canonicalizePath, getPathComparisonKey, shortenHomePath } from "../../../utils/paths.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint, keyText } from "./keybinding-hints.ts"; @@ -23,15 +22,6 @@ import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } type SessionScope = "current" | "all"; -function shortenPath(path: string): string { - const home = os.homedir(); - if (!path) return path; - if (path.startsWith(home)) { - return `~${path.slice(home.length)}`; - } - return path; -} - function formatSessionDate(date: Date): string { const now = new Date(); const diffMs = now.getTime() - date.getTime(); @@ -48,9 +38,9 @@ function formatSessionDate(date: Date): string { return `${Math.floor(diffDays / 365)}y`; } -function canonicalizePath(path: string | undefined): string | undefined { - if (!path) return path; - return _canonicalizePath(path); +/** Comparison key for a session file path; follows symlinks and ignores case on Windows. */ +function getSessionPathKey(path: string): string { + return getPathComparisonKey(canonicalizePath(path)); } class SessionSelectorHeader implements Component { @@ -210,16 +200,16 @@ function buildSessionTree(sessions: SessionInfo[]): SessionTreeNode[] { const byPath = new Map(); for (const session of sessions) { - const sessionPath = canonicalizePath(session.path) ?? session.path; + const sessionPath = getSessionPathKey(session.path); byPath.set(sessionPath, { session, children: [], latestActivity: session.modified.getTime() }); } const roots: SessionTreeNode[] = []; for (const session of sessions) { - const sessionPath = canonicalizePath(session.path) ?? session.path; + const sessionPath = getSessionPathKey(session.path); const node = byPath.get(sessionPath)!; - const parentPath = canonicalizePath(session.parentSessionPath); + const parentPath = session.parentSessionPath ? getSessionPathKey(session.parentSessionPath) : undefined; if (parentPath && byPath.has(parentPath)) { byPath.get(parentPath)!.children.push(node); @@ -296,7 +286,7 @@ class SessionList implements Component, Focusable { private keybindings: KeybindingsManager; private showPath = false; private confirmingDeletePath: string | null = null; - private currentSessionCanonicalPath?: string; + private currentSessionPathKey?: string; public onSelect?: (sessionPath: string) => void; public onCancel?: () => void; public onExit: () => void = () => {}; @@ -335,7 +325,7 @@ class SessionList implements Component, Focusable { this.sortMode = sortMode; this.nameFilter = nameFilter; this.keybindings = keybindings; - this.currentSessionCanonicalPath = canonicalizePath(currentSessionFilePath); + this.currentSessionPathKey = currentSessionFilePath ? getSessionPathKey(currentSessionFilePath) : undefined; this.filterSessions(""); // Handle Enter in search input - select current item @@ -413,8 +403,8 @@ class SessionList implements Component, Focusable { } private isCurrentSessionPath(path: string): boolean { - if (!this.currentSessionCanonicalPath) return false; - return (canonicalizePath(path) ?? path) === this.currentSessionCanonicalPath; + if (!this.currentSessionPathKey) return false; + return getSessionPathKey(path) === this.currentSessionPathKey; } invalidate(): void {} @@ -474,10 +464,10 @@ class SessionList implements Component, Focusable { const msgCount = String(session.messageCount); let rightPart = `${msgCount} ${age}`; if (this.showCwd && session.cwd) { - rightPart = `${shortenPath(session.cwd)} ${rightPart}`; + rightPart = `${shortenHomePath(session.cwd)} ${rightPart}`; } if (this.showPath) { - rightPart = `${shortenPath(session.path)} ${rightPart}`; + rightPart = `${shortenHomePath(session.path)} ${rightPart}`; } // Cursor diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 73255479b..5bcdd846d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -14,6 +14,7 @@ import { } from "@earendil-works/pi-tui"; import type { SessionTreeNode } from "../../../core/session-manager.ts"; import { formatReadLineRange } from "../../../core/tools/render-utils.ts"; +import { shortenHomePath } from "../../../utils/paths.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { formatKeyText, keyHint } from "./keybinding-hints.ts"; @@ -945,11 +946,7 @@ class TreeList implements Component { } private formatToolCall(name: string, args: Record): string { - const shortenPath = (p: string): string => { - const home = process.env.HOME || process.env.USERPROFILE || ""; - if (home && p.startsWith(home)) return `~${p.slice(home.length)}`; - return p; - }; + const shortenPath = (p: string): string => shortenHomePath(p, process.env.HOME || process.env.USERPROFILE || ""); switch (name) { case "read": { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 2af951437..fb93341fa 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -127,7 +127,7 @@ import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../. import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; import { parseGitUrl } from "../../utils/git.ts"; import { ensurePngTranscoder } from "../../utils/image-convert.ts"; -import { getCwdRelativePath } from "../../utils/paths.ts"; +import { getCwdRelativePath, shortenHomePath } from "../../utils/paths.ts"; import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts"; @@ -1474,15 +1474,7 @@ export class InteractiveMode { // ========================================================================= private formatDisplayPath(p: string): string { - const home = os.homedir(); - let result = p; - - // Replace home directory with ~ - if (result.startsWith(home)) { - result = `~${result.slice(home.length)}`; - } - - return result; + return shortenHomePath(p); } private formatExtensionDisplayPath(path: string): string { diff --git a/packages/coding-agent/src/utils/paths.ts b/packages/coding-agent/src/utils/paths.ts index 6a5a0cfb8..cf77b5bff 100644 --- a/packages/coding-agent/src/utils/paths.ts +++ b/packages/coding-agent/src/utils/paths.ts @@ -1,6 +1,6 @@ import { realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path"; +import { isAbsolute, join, resolve as nodeResolvePath, posix, relative, sep, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnProcessSync } from "./child-process.ts"; @@ -33,6 +33,50 @@ export function canonicalizePath(path: string): string { } } +/** + * Key for comparing filesystem paths: the resolved absolute path, case-folded + * on Windows because Windows paths are case-insensitive (`c:\Users` and + * `C:\Users` are the same directory). NTFS can enable per-directory case + * sensitivity, but it is rarely used outside WSL interop, so it is ignored + * here (https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity). + * Does not touch the filesystem; call `canonicalizePath` first when symlinks + * must compare equal. Use only for comparison, never for display or + * filesystem access. + */ +export function getPathComparisonKey(path: string): string { + const pathApi = getPlatformPathApi(); + const resolvedPath = pathApi.resolve(path); + return pathApi === win32 ? resolvedPath.toLowerCase() : resolvedPath; +} + +/** `node:path` rules for the current platform, read at call time so tests can simulate Windows. */ +function getPlatformPathApi(): typeof win32 { + return process.platform === "win32" ? win32 : posix; +} + +/** True when both paths name the same location (see `getPathComparisonKey`). */ +export function pathsEqual(left: string, right: string): boolean { + return getPathComparisonKey(left) === getPathComparisonKey(right); +} + +/** True when `child` is `parent` or inside it (see `getPathComparisonKey`). */ +export function isPathWithin(parent: string, child: string): boolean { + const parentKey = getPathComparisonKey(parent); + const childKey = getPathComparisonKey(child); + if (childKey === parentKey) return true; + // Filesystem roots (`/`, `C:\`) already end with a separator. + const pathSep = getPlatformPathApi().sep; + return childKey.startsWith(parentKey.endsWith(pathSep) ? parentKey : `${parentKey}${pathSep}`); +} + +/** Replace a leading home directory with `~` for display. Relative paths are returned unchanged. */ +export function shortenHomePath(path: string, homeDir: string = homedir()): string { + const pathApi = getPlatformPathApi(); + if (!homeDir || !pathApi.isAbsolute(path) || !isPathWithin(homeDir, path)) return path; + const relativePath = pathApi.relative(homeDir, path); + return relativePath ? `~${pathApi.sep}${relativePath}` : "~"; +} + export function getFileRevision(path: string): string | undefined { try { const stats = statSync(path, { bigint: true }); diff --git a/packages/coding-agent/test/paths.test.ts b/packages/coding-agent/test/paths.test.ts index fbdb4f56f..b57238c13 100644 --- a/packages/coding-agent/test/paths.test.ts +++ b/packages/coding-agent/test/paths.test.ts @@ -6,10 +6,14 @@ import { afterEach, describe, expect, it } from "vitest"; import { canonicalizePath, getCwdRelativePath, + getPathComparisonKey, isLocalPath, + isPathWithin, normalizePath, normalizeWindowsShellPath, + pathsEqual, resolvePath, + shortenHomePath, } from "../src/utils/paths.ts"; let tempDir: string; @@ -26,6 +30,64 @@ function createTempDir(): string { return tempDir; } +describe("path comparison", () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + + afterEach(() => { + if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform); + }); + + function simulatePlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: platform }); + } + + // Regression test for https://github.com/earendil-works/pi/issues/10488 + it("treats drive letters and path components case-insensitively on Windows", () => { + simulatePlatform("win32"); + expect(getPathComparisonKey("c:\\Users\\me\\.agents\\skills")).toBe( + getPathComparisonKey("C:\\Users\\Me\\.agents\\skills"), + ); + expect(pathsEqual("c:\\Users\\me", "C:/Users/Me/")).toBe(true); + expect(pathsEqual("C:\\Users\\me", "D:\\Users\\me")).toBe(false); + expect(isPathWithin("C:\\Users\\Me", "c:\\users\\me\\project")).toBe(true); + expect(isPathWithin("C:\\Users\\Me", "C:\\Users\\Me2")).toBe(false); + expect(isPathWithin("C:\\", "c:\\Users")).toBe(true); + expect(isPathWithin("\\\\server\\share", "\\\\SERVER\\Share\\folder")).toBe(true); + }); + + it("keeps casing on case-sensitive platforms", () => { + simulatePlatform("linux"); + expect(pathsEqual("/home/me", "/home/Me")).toBe(false); + expect(isPathWithin("/home/me", "/home/Me/project")).toBe(false); + }); + + it("isPathWithin matches whole path segments", () => { + simulatePlatform("linux"); + expect(isPathWithin("/home/me", "/home/me")).toBe(true); + expect(isPathWithin("/home/me", "/home/me/project")).toBe(true); + expect(isPathWithin("/home/me/", "/home/me/project")).toBe(true); + expect(isPathWithin("/home/me", "/home/me2")).toBe(false); + expect(isPathWithin("/", "/home")).toBe(true); + }); + + it("shortenHomePath replaces only a leading home directory", () => { + simulatePlatform("linux"); + expect(shortenHomePath("/home/me", "/home/me")).toBe("~"); + expect(shortenHomePath("/home/me/project", "/home/me")).toBe("~/project"); + expect(shortenHomePath("/home/me2/project", "/home/me")).toBe("/home/me2/project"); + expect(shortenHomePath("/home/Me/project", "/home/me")).toBe("/home/Me/project"); + expect(shortenHomePath("/etc/passwd", "/")).toBe("~/etc/passwd"); + expect(shortenHomePath("home/me/project", "/home/me")).toBe("home/me/project"); + expect(shortenHomePath("/home/me/project", "")).toBe("/home/me/project"); + }); + + it("shortenHomePath ignores casing and keeps separators on Windows", () => { + simulatePlatform("win32"); + expect(shortenHomePath("c:\\users\\me\\project", "C:\\Users\\Me")).toBe("~\\project"); + expect(shortenHomePath("\\\\server\\share\\folder", "\\\\server\\share")).toBe("~\\folder"); + }); +}); + describe("canonicalizePath", () => { it("returns the real path for a regular file", () => { const dir = createTempDir(); diff --git a/packages/coding-agent/test/source-info.test.ts b/packages/coding-agent/test/source-info.test.ts new file mode 100644 index 000000000..35e535704 --- /dev/null +++ b/packages/coding-agent/test/source-info.test.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getResourcePathKey } from "../src/core/source-info.ts"; + +describe("getResourcePathKey", () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + + afterEach(() => { + if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform); + }); + + // Regression test for https://github.com/earendil-works/pi/issues/10488 + it("folds file path casing on Windows but keeps synthetic identifiers unchanged", () => { + Object.defineProperty(process, "platform", { value: "win32" }); + expect(getResourcePathKey("C:\\Users\\Me\\ext.ts")).toBe(getResourcePathKey("c:\\users\\me\\ext.ts")); + expect(getResourcePathKey("builtin:MCP")).toBe("builtin:MCP"); + expect(getResourcePathKey("builtin:MCP")).not.toBe(getResourcePathKey("builtin:mcp")); + expect(getResourcePathKey("")).toBe(""); + }); +}); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index 01500bac2..36d6f89ec 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -64,4 +64,45 @@ describe("ProjectTrustStore", () => { } } }); + + // Regression test for https://github.com/earendil-works/pi/issues/10488 + it("ignores the user .agents/skills dir when HOME differs only by case on Windows", () => { + const originalHome = process.env.HOME; + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + const mixedCaseHome = join(tempDir, "Home"); + const projectDir = join(mixedCaseHome, "project"); + mkdirSync(join(mixedCaseHome, ".agents", "skills"), { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + process.env.HOME = mixedCaseHome.toLowerCase(); + Object.defineProperty(process, "platform", { value: "win32" }); + try { + expect(hasTrustRequiringProjectResources(projectDir)).toBe(false); + } finally { + if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + } + }); + + // Regression test for https://github.com/earendil-works/pi/issues/10488 + it("matches saved decisions regardless of casing on Windows", () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + const projectDir = join(tempDir, "Project-Mixed"); + mkdirSync(projectDir, { recursive: true }); + const store = new ProjectTrustStore(agentDir); + store.set(projectDir, true); + Object.defineProperty(process, "platform", { value: "win32" }); + try { + expect(store.get(projectDir.toLowerCase())).toBe(true); + store.set(projectDir.toLowerCase(), false); + const saved = JSON.parse(readFileSync(join(agentDir, "trust.json"), "utf-8")) as Record; + expect(Object.values(saved)).toEqual([false]); + expect(store.get(projectDir)).toBe(false); + } finally { + if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform); + } + }); });