From 461cd47026827bfcac01bb109605348ec32ddffd Mon Sep 17 00:00:00 2001 From: kuqili Date: Tue, 31 Mar 2026 20:10:42 +0800 Subject: [PATCH 1/4] fix: filter session-start injection by cwd/project to prevent cross-project contamination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/hooks/session-start.js | 66 +++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index a994308027..2e4e28054d 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, @@ -53,6 +54,56 @@ 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 + * + * 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. + */ +function selectMatchingSession(sessions, cwd, currentProject) { + let projectMatch = null; + + for (const session of sessions) { + const content = readFile(session.path); + if (!content) continue; + + // Extract **Worktree:** field + const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m); + const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : ''; + + // Exact worktree match — best possible, return immediately + if (sessionWorktree && sessionWorktree === cwd) { + session._matchReason = 'worktree'; + return session; + } + + // 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; + projectMatch._matchReason = 'project'; + } + } + } + + if (projectMatch) return projectMatch; + + // Fallback: most recent session (original behavior) + sessions[0]._matchReason = 'recency-fallback'; + return sessions[0]; +} + async function main() { const sessionsDir = getSessionsDir(); const learnedDir = getLearnedSkillsDir(); @@ -66,12 +117,19 @@ 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)); + // 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 selected = selectMatchingSession(recentSessions, cwd, currentProject); + log(`[SessionStart] Selected: ${selected.path} (match: ${selected._matchReason})`); + + // Read and inject the selected session content into Claude's context + const content = stripAnsi(readFile(selected.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}`); From c9d5fdb4054e5c1c08534f12817e5299ef5bf154 Mon Sep 17 00:00:00 2001 From: kuqili Date: Tue, 31 Mar 2026 20:25:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20eliminate=20duplicate=20I/O,=20add=20null=20guards,?= =?UTF-8?q?=20improve=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- scripts/hooks/session-start.js | 55 +++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 2e4e28054d..b5701ce87d 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -61,6 +61,10 @@ function dedupeRecentSessions(searchDirs) { * **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 @@ -68,22 +72,35 @@ function dedupeRecentSessions(searchDirs) { * * 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; + let projectMatch = null; + let projectMatchContent = null; + let fallbackContent = null; for (const session of sessions) { const content = readFile(session.path); if (!content) continue; + // Cache first readable content for fallback + if (!fallbackContent) 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 if (sessionWorktree && sessionWorktree === cwd) { - session._matchReason = 'worktree'; - return session; + return { session, content, matchReason: 'worktree' }; } // Project name match — keep searching for a worktree match @@ -92,16 +109,22 @@ function selectMatchingSession(sessions, cwd, currentProject) { const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : ''; if (sessionProject && sessionProject === currentProject) { projectMatch = session; - projectMatch._matchReason = 'project'; + projectMatchContent = content; } } } - if (projectMatch) return projectMatch; + if (projectMatch) { + return { session: projectMatch, content: projectMatchContent, matchReason: 'project' }; + } - // Fallback: most recent session (original behavior) - sessions[0]._matchReason = 'recency-fallback'; - return sessions[0]; + // Fallback: most recent session with readable content (original behavior) + if (fallbackContent) { + return { session: sessions[0], content: fallbackContent, matchReason: 'recency-fallback' }; + } + + log('[SessionStart] All session files were unreadable'); + return null; } async function main() { @@ -125,14 +148,18 @@ async function main() { const cwd = process.cwd(); const currentProject = getProjectName() || ''; - const selected = selectMatchingSession(recentSessions, cwd, currentProject); - log(`[SessionStart] Selected: ${selected.path} (match: ${selected._matchReason})`); + const result = selectMatchingSession(recentSessions, cwd, currentProject); - // Read and inject the selected session content into Claude's context - const content = stripAnsi(readFile(selected.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}`); + 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'); } } From 40f3294b759b28f8ef8101fff5530851fe011780 Mon Sep 17 00:00:00 2001 From: kuqili Date: Tue, 31 Mar 2026 20:35:43 +0800 Subject: [PATCH 3/4] fix: track fallback session object to prevent session/content mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/hooks/session-start.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index b5701ce87d..8e48bfa854 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -85,14 +85,18 @@ function selectMatchingSession(sessions, cwd, currentProject) { 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 content for fallback - if (!fallbackContent) fallbackContent = content; + // Cache first readable session+content pair for fallback + if (!fallbackSession) { + fallbackSession = session; + fallbackContent = content; + } // Extract **Worktree:** field const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m); @@ -118,9 +122,9 @@ function selectMatchingSession(sessions, cwd, currentProject) { return { session: projectMatch, content: projectMatchContent, matchReason: 'project' }; } - // Fallback: most recent session with readable content (original behavior) - if (fallbackContent) { - return { session: sessions[0], content: fallbackContent, matchReason: 'recency-fallback' }; + // Fallback: most recent readable session (original behavior) + if (fallbackSession) { + return { session: fallbackSession, content: fallbackContent, matchReason: 'recency-fallback' }; } log('[SessionStart] All session files were unreadable'); From f610b38638e638b8f188532711ea389ae0ac7414 Mon Sep 17 00:00:00 2001 From: kuqili Date: Tue, 31 Mar 2026 20:48:55 +0800 Subject: [PATCH 4/4] 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. --- scripts/hooks/session-start.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 8e48bfa854..c95881ff95 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -24,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(); @@ -83,6 +102,9 @@ function dedupeRecentSessions(searchDirs) { 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; @@ -103,7 +125,8 @@ function selectMatchingSession(sessions, cwd, currentProject) { const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : ''; // Exact worktree match — best possible, return immediately - if (sessionWorktree && sessionWorktree === cwd) { + // Normalize both paths to handle symlinks and case-insensitive filesystems + if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) { return { session, content, matchReason: 'worktree' }; }