Skip to content

Commit 24eea2f

Browse files
kuqilikuqili
andauthored
fix: filter session-start injection by cwd/project to prevent cross-project contamination (affaan-m#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>
1 parent d0b9399 commit 24eea2f

1 file changed

Lines changed: 119 additions & 7 deletions

File tree

scripts/hooks/session-start.js

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const {
1313
getSessionsDir,
1414
getSessionSearchDirs,
1515
getLearnedSkillsDir,
16+
getProjectName,
1617
findFiles,
1718
ensureDir,
1819
readFile,
@@ -23,6 +24,25 @@ const { getPackageManager, getSelectionPrompt } = require('../lib/package-manage
2324
const { listAliases } = require('../lib/session-aliases');
2425
const { detectProjectType } = require('../lib/project-detect');
2526
const path = require('path');
27+
const fs = require('fs');
28+
29+
/**
30+
* Resolve a filesystem path to its canonical (real) form.
31+
*
32+
* Handles symlinks and, on case-insensitive filesystems (macOS, Windows),
33+
* normalizes casing so that path comparisons are reliable.
34+
* Falls back to the original path if resolution fails (e.g. path no longer exists).
35+
*
36+
* @param {string} p - The path to normalize.
37+
* @returns {string} The canonical path, or the original if resolution fails.
38+
*/
39+
function normalizePath(p) {
40+
try {
41+
return fs.realpathSync(p);
42+
} catch {
43+
return p;
44+
}
45+
}
2646

2747
function dedupeRecentSessions(searchDirs) {
2848
const recentSessionsByName = new Map();
@@ -53,6 +73,87 @@ function dedupeRecentSessions(searchDirs) {
5373
.sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex);
5474
}
5575

76+
/**
77+
* Select the best matching session for the current working directory.
78+
*
79+
* Session files written by session-end.js contain header fields like:
80+
* **Project:** my-project
81+
* **Worktree:** /path/to/project
82+
*
83+
* This function reads each session file once, caching its content, and
84+
* returns both the selected session object and its already-read content
85+
* to avoid duplicate I/O in the caller.
86+
*
87+
* Priority (highest to lowest):
88+
* 1. Exact worktree (cwd) match — most recent
89+
* 2. Same project name match — most recent
90+
* 3. Fallback to overall most recent (original behavior)
91+
*
92+
* Sessions are already sorted newest-first, so the first match in each
93+
* category wins.
94+
*
95+
* @param {Array<Object>} sessions - Deduplicated session list, sorted newest-first.
96+
* @param {string} cwd - Current working directory (process.cwd()).
97+
* @param {string} currentProject - Current project name from getProjectName().
98+
* @returns {{ session: Object, content: string, matchReason: string } | null}
99+
* The best matching session with its cached content and match reason,
100+
* or null if the sessions array is empty or all files are unreadable.
101+
*/
102+
function selectMatchingSession(sessions, cwd, currentProject) {
103+
if (sessions.length === 0) return null;
104+
105+
// Normalize cwd once outside the loop to avoid repeated syscalls
106+
const normalizedCwd = normalizePath(cwd);
107+
108+
let projectMatch = null;
109+
let projectMatchContent = null;
110+
let fallbackSession = null;
111+
let fallbackContent = null;
112+
113+
for (const session of sessions) {
114+
const content = readFile(session.path);
115+
if (!content) continue;
116+
117+
// Cache first readable session+content pair for fallback
118+
if (!fallbackSession) {
119+
fallbackSession = session;
120+
fallbackContent = content;
121+
}
122+
123+
// Extract **Worktree:** field
124+
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
125+
const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : '';
126+
127+
// Exact worktree match — best possible, return immediately
128+
// Normalize both paths to handle symlinks and case-insensitive filesystems
129+
if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) {
130+
return { session, content, matchReason: 'worktree' };
131+
}
132+
133+
// Project name match — keep searching for a worktree match
134+
if (!projectMatch && currentProject) {
135+
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
136+
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
137+
if (sessionProject && sessionProject === currentProject) {
138+
projectMatch = session;
139+
projectMatchContent = content;
140+
}
141+
}
142+
}
143+
144+
if (projectMatch) {
145+
return { session: projectMatch, content: projectMatchContent, matchReason: 'project' };
146+
}
147+
148+
// Fallback: most recent readable session (original behavior)
149+
if (fallbackSession) {
150+
return { session: fallbackSession, content: fallbackContent, matchReason: 'recency-fallback' };
151+
}
152+
153+
log('[SessionStart] All session files were unreadable');
154+
return null;
155+
}
156+
56157
async function main() {
57158
const sessionsDir = getSessionsDir();
58159
const learnedDir = getLearnedSkillsDir();
@@ -66,15 +167,26 @@ async function main() {
66167
const recentSessions = dedupeRecentSessions(getSessionSearchDirs());
67168

68169
if (recentSessions.length > 0) {
69-
const latest = recentSessions[0];
70170
log(`[SessionStart] Found ${recentSessions.length} recent session(s)`);
71-
log(`[SessionStart] Latest: ${latest.path}`);
72171

73-
// Read and inject the latest session content into Claude's context
74-
const content = stripAnsi(readFile(latest.path));
75-
if (content && !content.includes('[Session context goes here]')) {
76-
// Only inject if the session has actual content (not the blank template)
77-
additionalContextParts.push(`Previous session summary:\n${content}`);
172+
// Prefer a session that matches the current working directory or project.
173+
// Session files contain **Project:** and **Worktree:** header fields written
174+
// by session-end.js, so we can match against them.
175+
const cwd = process.cwd();
176+
const currentProject = getProjectName() || '';
177+
178+
const result = selectMatchingSession(recentSessions, cwd, currentProject);
179+
180+
if (result) {
181+
log(`[SessionStart] Selected: ${result.session.path} (match: ${result.matchReason})`);
182+
183+
// Use the already-read content from selectMatchingSession (no duplicate I/O)
184+
const content = stripAnsi(result.content);
185+
if (content && !content.includes('[Session context goes here]')) {
186+
additionalContextParts.push(`Previous session summary:\n${content}`);
187+
}
188+
} else {
189+
log('[SessionStart] No matching session found');
78190
}
79191
}
80192

0 commit comments

Comments
 (0)