mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-01 06:33:27 +08:00
fix: filter session-start injection by cwd/project to prevent cross-project contamination (#1054)
* 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 <kuqili@tencent.com>
This commit is contained in:
@@ -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<Object>} 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user