From e86d3dbe0215a230aa9758815693c1dde29dc2b0 Mon Sep 17 00:00:00 2001 From: kuqili <55538808+kuqili@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:05:34 +0800 Subject: [PATCH] fix: filter session-start injection by cwd/project to prevent cross-project contamination (#1054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: filter session-start injection by cwd/project to prevent cross-project contamination The SessionStart hook previously selected the most recent session file purely by timestamp, ignoring the current working directory. This caused Claude to receive a previous project's session context when switching between projects, leading to incorrect file reads and project analysis. session-end.js already writes **Project:** and **Worktree:** header fields into each session file. This commit adds selectMatchingSession() which uses those fields with the following priority: 1. Exact worktree (cwd) match — most recent 2. Same project name match — most recent 3. Fallback to overall most recent (preserves backward compatibility) No new dependencies. Gracefully falls back to original behavior when no matching session exists. * fix: address review feedback — eliminate duplicate I/O, add null guards, improve docstrings - Return { session, content, matchReason } from selectMatchingSession() to avoid reading the same file twice (coderabbitai, greptile P2) - Add empty array guard: return null when sessions.length === 0 (coderabbitai) - Stop mutating input objects — no more session._matchReason (coderabbitai) - Add null check on result before accessing properties (coderabbitai) - Only log "selected" after confirming content is readable (cubic-dev-ai P3) - Add full JSDoc with @param/@returns (docstring coverage) * fix: track fallback session object to prevent session/content mismatch When sessions[0] is unreadable, fallbackContent came from a later session (e.g. sessions[1]) while the returned session object still pointed to sessions[0]. This caused misleading logs and injected content from the wrong session — the exact problem this PR fixes. Now tracks fallbackSession alongside fallbackContent so the returned pair is always consistent. Addresses greptile-apps P1 review feedback. * fix: normalize worktree paths to handle symlinks and case differences On macOS /var is a symlink to /private/var, and on Windows paths may differ in casing (C:\repo vs c:\repo). Use fs.realpathSync() to resolve both sides before comparison so worktree matching is reliable across symlinked and case-insensitive filesystems. cwd is normalized once outside the loop to avoid repeated syscalls. Addresses coderabbitai Major review feedback. --------- Co-authored-by: kuqili --- scripts/hooks/session-start.js | 126 +++++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 7 deletions(-) diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index a9943080..c95881ff 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -13,6 +13,7 @@ const { getSessionsDir, getSessionSearchDirs, getLearnedSkillsDir, + getProjectName, findFiles, ensureDir, readFile, @@ -23,6 +24,25 @@ const { getPackageManager, getSelectionPrompt } = require('../lib/package-manage const { listAliases } = require('../lib/session-aliases'); const { detectProjectType } = require('../lib/project-detect'); const path = require('path'); +const fs = require('fs'); + +/** + * Resolve a filesystem path to its canonical (real) form. + * + * Handles symlinks and, on case-insensitive filesystems (macOS, Windows), + * normalizes casing so that path comparisons are reliable. + * Falls back to the original path if resolution fails (e.g. path no longer exists). + * + * @param {string} p - The path to normalize. + * @returns {string} The canonical path, or the original if resolution fails. + */ +function normalizePath(p) { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} function dedupeRecentSessions(searchDirs) { const recentSessionsByName = new Map(); @@ -53,6 +73,87 @@ function dedupeRecentSessions(searchDirs) { .sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex); } +/** + * Select the best matching session for the current working directory. + * + * Session files written by session-end.js contain header fields like: + * **Project:** my-project + * **Worktree:** /path/to/project + * + * This function reads each session file once, caching its content, and + * returns both the selected session object and its already-read content + * to avoid duplicate I/O in the caller. + * + * Priority (highest to lowest): + * 1. Exact worktree (cwd) match — most recent + * 2. Same project name match — most recent + * 3. Fallback to overall most recent (original behavior) + * + * Sessions are already sorted newest-first, so the first match in each + * category wins. + * + * @param {Array} sessions - Deduplicated session list, sorted newest-first. + * @param {string} cwd - Current working directory (process.cwd()). + * @param {string} currentProject - Current project name from getProjectName(). + * @returns {{ session: Object, content: string, matchReason: string } | null} + * The best matching session with its cached content and match reason, + * or null if the sessions array is empty or all files are unreadable. + */ +function selectMatchingSession(sessions, cwd, currentProject) { + if (sessions.length === 0) return null; + + // Normalize cwd once outside the loop to avoid repeated syscalls + const normalizedCwd = normalizePath(cwd); + + let projectMatch = null; + let projectMatchContent = null; + let fallbackSession = null; + let fallbackContent = null; + + for (const session of sessions) { + const content = readFile(session.path); + if (!content) continue; + + // Cache first readable session+content pair for fallback + if (!fallbackSession) { + fallbackSession = session; + fallbackContent = content; + } + + // Extract **Worktree:** field + const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m); + const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : ''; + + // Exact worktree match — best possible, return immediately + // Normalize both paths to handle symlinks and case-insensitive filesystems + if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) { + return { session, content, matchReason: 'worktree' }; + } + + // Project name match — keep searching for a worktree match + if (!projectMatch && currentProject) { + const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m); + const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : ''; + if (sessionProject && sessionProject === currentProject) { + projectMatch = session; + projectMatchContent = content; + } + } + } + + if (projectMatch) { + return { session: projectMatch, content: projectMatchContent, matchReason: 'project' }; + } + + // Fallback: most recent readable session (original behavior) + if (fallbackSession) { + return { session: fallbackSession, content: fallbackContent, matchReason: 'recency-fallback' }; + } + + log('[SessionStart] All session files were unreadable'); + return null; +} + async function main() { const sessionsDir = getSessionsDir(); const learnedDir = getLearnedSkillsDir(); @@ -66,15 +167,26 @@ async function main() { const recentSessions = dedupeRecentSessions(getSessionSearchDirs()); if (recentSessions.length > 0) { - const latest = recentSessions[0]; log(`[SessionStart] Found ${recentSessions.length} recent session(s)`); - log(`[SessionStart] Latest: ${latest.path}`); - // Read and inject the latest session content into Claude's context - const content = stripAnsi(readFile(latest.path)); - if (content && !content.includes('[Session context goes here]')) { - // Only inject if the session has actual content (not the blank template) - additionalContextParts.push(`Previous session summary:\n${content}`); + // Prefer a session that matches the current working directory or project. + // Session files contain **Project:** and **Worktree:** header fields written + // by session-end.js, so we can match against them. + const cwd = process.cwd(); + const currentProject = getProjectName() || ''; + + const result = selectMatchingSession(recentSessions, cwd, currentProject); + + if (result) { + log(`[SessionStart] Selected: ${result.session.path} (match: ${result.matchReason})`); + + // Use the already-read content from selectMatchingSession (no duplicate I/O) + const content = stripAnsi(result.content); + if (content && !content.includes('[Session context goes here]')) { + additionalContextParts.push(`Previous session summary:\n${content}`); + } + } else { + log('[SessionStart] No matching session found'); } }