From 78177af8bc7ff844e5d60a3439feac5f936fbe20 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 16 Mar 2026 15:24:18 +0100 Subject: [PATCH] feat(coding-agent): added symlink resolution and path alias handling for consistent directory behavior - Added symlink and path alias resolution to session directory handling for consistent behavior across aliased home and temp directories. - Improved status line path display to strip display roots using canonical path resolution, correctly handling symlink-equivalent directory aliases. - Added support for quoted paths in grep, ast_grep, and find tools to properly handle directory names with spaces. - Improved ast_grep error messaging when no matches found with parse errors to suggest narrowing path/glob or setting language. - Extracted path utility functions (resolveEquivalentPath, normalizePathForComparison, pathIsWithin, relativePathWithinRoot) to shared utils package. - Added comprehensive test coverage for symlink alias resolution in status line path rendering and session directory handling. --- packages/coding-agent/CHANGELOG.md | 6 ++ .../modes/components/status-line/segments.ts | 18 ++-- .../src/session/session-manager.ts | 50 ++++------- .../session-manager/file-operations.test.ts | 30 +++++++ .../test/status-line-path.test.ts | 90 +++++++++++++++++++ packages/utils/src/dirs.ts | 29 ++++++ 6 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 packages/coding-agent/test/status-line-path.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b0e27efbc..8438f0fc3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added support for quoted paths in grep, ast_grep, and find tools to handle directory names with spaces @@ -8,8 +9,13 @@ ### Changed +- Improved status line path display to strip display roots using canonical path resolution, correctly handling symlink aliases to home and Projects directories - Improved error messaging in ast_grep when no matches are found with parse errors, now suggests narrowing `path`/`glob` or setting `lang` to resolve mis-scoped queries +### Fixed + +- Fixed session directory resolution to correctly handle symlink-equivalent paths, ensuring aliased home and temp directories resolve to the same session storage location as their real targets + ## [13.12.7] - 2026-03-16 ### Changed diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 4e5d396e7..6bffb9ef2 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -1,7 +1,8 @@ import * as os from "node:os"; +import * as path from "node:path"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { TERMINAL } from "@oh-my-pi/pi-tui"; -import { formatDuration, formatNumber, getProjectDir } from "@oh-my-pi/pi-utils"; +import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils"; import { theme } from "../../../modes/theme/theme"; import { shortenPath } from "../../../tools/render-utils"; import { getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds"; @@ -17,6 +18,14 @@ function withIcon(icon: string, text: string): string { return icon ? `${icon} ${text}` : text; } +function stripDisplayRoot(pwd: string): string { + for (const root of ["/work", path.join(os.homedir(), "Projects")]) { + const relative = relativePathWithinRoot(root, pwd); + if (relative) return relative; + } + return pwd; +} + function normalizePremiumRequests(value: number): number { return Math.round((value + Number.EPSILON) * 100) / 100; } @@ -87,13 +96,12 @@ const pathSegment: StatusLineSegment = { let pwd = getProjectDir(); + if (opts.stripWorkPrefix !== false) { + pwd = stripDisplayRoot(pwd); + } if (opts.abbreviate !== false) { pwd = shortenPath(pwd); } - if (opts.stripWorkPrefix !== false) { - if (pwd.startsWith("/work/")) pwd = pwd.slice(6); - else if (pwd.startsWith("~/Projects/")) pwd = pwd.slice(11); - } const maxLen = opts.maxLength ?? 40; if (pwd.length > maxLen) { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 63d3e55ed..89168a5eb 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -21,6 +21,8 @@ import { isEnoent, logger, parseJsonlLenient, + pathIsWithin, + resolveEquivalentPath, Snowflake, toError, } from "@oh-my-pi/pi-utils"; @@ -375,16 +377,6 @@ function encodeLegacyAbsoluteSessionDirName(cwd: string): string { return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } -function pathIsWithin(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function encodeRelativeSessionDirName(prefix: string, root: string, cwd: string): string { - const relative = path.relative(root, cwd).replace(/[/\\:]/g, "-"); - return relative ? `${prefix}-${relative}` : prefix; -} - /** * Migrate old `---*--` session dirs to the new `-*` format. * Runs once on first access, best-effort. @@ -633,33 +625,29 @@ export function buildSessionContext( return { messages, thinkingLevel, serviceTier, models, injectedTtsrRules, mode, modeData }; } -/** - * Encode a cwd into a safe directory name for session storage. - * Home-relative paths use single-dash format: `/Users/x/Projects/pi` → `-Projects-pi` - * Temp-root paths use `-tmp-` prefixes: `/tmp/foo` → `-tmp-foo` - * Other absolute paths keep the legacy double-dash format for compatibility. - */ -function encodeSessionDirName(cwd: string): string { - const resolvedCwd = path.resolve(cwd); - const home = path.resolve(os.homedir()); - if (pathIsWithin(home, resolvedCwd)) { - return encodeRelativeSessionDirName("-", home, resolvedCwd); - } - const tempRoot = path.resolve(os.tmpdir()); - if (pathIsWithin(tempRoot, resolvedCwd)) { - return encodeRelativeSessionDirName("-tmp", tempRoot, resolvedCwd); - } - return encodeLegacyAbsoluteSessionDirName(resolvedCwd); -} - /** * Compute the default session directory for a cwd. - * Encodes cwd into a safe directory name under ~/.omp/agent/sessions/. + * Classifies cwd by canonical location so symlink/alias paths resolve to the + * same home-relative or temp-root directory names as their real targets. */ function getDefaultSessionDir(cwd: string, storage: SessionStorage): string { const resolvedCwd = path.resolve(cwd); + const canonicalCwd = resolveEquivalentPath(resolvedCwd); + const home = resolveEquivalentPath(os.homedir()); + const tempRoot = resolveEquivalentPath(os.tmpdir()); + let encodedDirName: string; + if (pathIsWithin(home, canonicalCwd)) { + const relative = path.relative(home, canonicalCwd).replace(/[/\\:]/g, "-"); + encodedDirName = relative ? `-${relative}` : "-"; + } else if (pathIsWithin(tempRoot, canonicalCwd)) { + const relative = path.relative(tempRoot, canonicalCwd).replace(/[/\\:]/g, "-"); + encodedDirName = relative ? `-tmp-${relative}` : "-tmp"; + } else { + encodedDirName = encodeLegacyAbsoluteSessionDirName(canonicalCwd); + } + migrateHomeSessionDirs(); - const sessionDir = path.join(getSessionsDir(), encodeSessionDirName(resolvedCwd)); + const sessionDir = path.join(getSessionsDir(), encodedDirName); migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir); storage.ensureDirSync(sessionDir); return sessionDir; diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index e099ff49a..9273a03a1 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -232,6 +232,36 @@ describe("SessionManager temp cwd session dirs", () => { fs.rmSync(testAgentDir, { recursive: true, force: true }); }); + it("stores symlink-equivalent home cwd sessions under home-relative directories", () => { + if (process.platform === "win32") return; + + const projectsRoot = path.join(os.homedir(), "Projects"); + fs.mkdirSync(projectsRoot, { recursive: true }); + const realProjectDir = fs.mkdtempSync(path.join(projectsRoot, "omp-session-home-")); + const nestedDir = path.join(realProjectDir, "nested"); + const aliasRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omp-session-home-alias-")); + const homeAlias = path.join(aliasRoot, "home-link"); + + try { + fs.mkdirSync(nestedDir, { recursive: true }); + fs.symlinkSync(os.homedir(), homeAlias, "dir"); + + const aliasedCwd = path.join(homeAlias, "Projects", path.basename(realProjectDir), "nested"); + const session = SessionManager.create(aliasedCwd); + const sessionFile = session.getSessionFile(); + if (!sessionFile) throw new Error("Expected session file path"); + + const expectedDir = path.join( + getSessionsDir(), + `-${path.relative(os.homedir(), fs.realpathSync(aliasedCwd)).replace(/[/\\:]/g, "-")}`, + ); + expect(path.dirname(sessionFile)).toBe(expectedDir); + } finally { + fs.rmSync(aliasRoot, { recursive: true, force: true }); + fs.rmSync(realProjectDir, { recursive: true, force: true }); + } + }); + it("stores temp-root cwd sessions under -tmp-prefixed directories", () => { const tempCwd = path.join(testAgentDir, `temp-cwd-${Snowflake.next()}`); fs.mkdirSync(tempCwd, { recursive: true }); diff --git a/packages/coding-agent/test/status-line-path.test.ts b/packages/coding-agent/test/status-line-path.test.ts new file mode 100644 index 000000000..77fd25f63 --- /dev/null +++ b/packages/coding-agent/test/status-line-path.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeAll, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; +import type { SegmentContext } from "../src/modes/components/status-line/segments"; +import { renderSegment } from "../src/modes/components/status-line/segments"; + +import { initTheme } from "../src/modes/theme/theme"; + +const originalProjectDir = getProjectDir(); +beforeAll(async () => { + await initTheme(); +}); + +function createPathContext(): SegmentContext { + return { + session: { + state: {}, + isFastModeEnabled: () => false, + modelRegistry: { isUsingOAuth: () => false }, + sessionManager: undefined, + } as unknown as SegmentContext["session"], + width: 120, + options: { + path: { + abbreviate: false, + maxLength: 120, + stripWorkPrefix: true, + }, + }, + planMode: null, + usageStats: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + premiumRequests: 0, + cost: 0, + tokensPerSecond: null, + }, + contextPercent: 0, + contextWindow: 0, + autoCompactEnabled: false, + subagentCount: 0, + sessionStartTime: Date.now(), + git: { + branch: null, + status: null, + pr: null, + }, + }; +} + +afterEach(() => { + setProjectDir(originalProjectDir); +}); + +describe("status line path segment", () => { + it("strips the Projects root for symlink-equivalent aliases", () => { + if (process.platform === "win32") return; + + const projectsRoot = path.join(os.homedir(), "Projects"); + fs.mkdirSync(projectsRoot, { recursive: true }); + + const realProjectDir = fs.mkdtempSync(path.join(projectsRoot, "omp-status-line-")); + const nestedDir = path.join(realProjectDir, "nested"); + const aliasRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omp-status-line-alias-")); + const homeAlias = path.join(aliasRoot, "home-link"); + + try { + fs.mkdirSync(nestedDir, { recursive: true }); + fs.symlinkSync(os.homedir(), homeAlias, "dir"); + + const aliasedDir = path.join(homeAlias, "Projects", path.basename(realProjectDir), "nested"); + setProjectDir(aliasedDir); + + const rendered = renderSegment("path", createPathContext()); + const expectedRelative = `${path.basename(realProjectDir)}${path.sep}nested`; + + expect(rendered.visible).toBe(true); + expect(rendered.content).toContain(expectedRelative); + expect(rendered.content).not.toContain("home-link"); + expect(rendered.content).not.toContain(`${path.sep}Projects${path.sep}`); + } finally { + fs.rmSync(aliasRoot, { recursive: true, force: true }); + fs.rmSync(realProjectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 87f086f26..63b38ee84 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -47,6 +47,35 @@ function standardizeMacOSPath(p: string): string { return p; } +export function resolveEquivalentPath(inputPath: string): string { + const resolvedPath = path.resolve(inputPath); + try { + return fs.realpathSync(resolvedPath); + } catch { + return resolvedPath; + } +} + +export function normalizePathForComparison(inputPath: string): string { + const resolvedPath = resolveEquivalentPath(inputPath); + return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath; +} + +export function pathIsWithin(root: string, candidate: string): boolean { + const normalizedRoot = normalizePathForComparison(root); + const normalizedCandidate = normalizePathForComparison(candidate); + const relative = path.relative(normalizedRoot, normalizedCandidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function relativePathWithinRoot(root: string, candidate: string): string | null { + if (!pathIsWithin(root, candidate)) return null; + const normalizedRoot = normalizePathForComparison(root); + const normalizedCandidate = normalizePathForComparison(candidate); + const relative = path.relative(normalizedRoot, normalizedCandidate); + return relative || null; +} + let projectDir = standardizeMacOSPath(process.cwd()); /** Get the project directory. */