Skip to content

Commit 49e7a34

Browse files
committed
fix(session-log): detect ghost sessions from /clear to resolve correct log
After /clear, Claude Code creates a new session (new .jsonl file) but never updates ~/.claude/sessions/<pid>.json — the metadata retains the old sessionId. The ancestor-PID resolver would confidently return the stale log, preventing fallthrough to mtime-based tiers. Fix: after tier-1 matches a log that isn't the newest by mtime, check whether the newer file's sessionId is registered in any metadata file. If not, it's a "ghost" session from /clear — prefer it. If it IS registered, it belongs to a concurrent session — keep the PID result. Fixes #643
1 parent 84a0b43 commit 49e7a34

2 files changed

Lines changed: 127 additions & 3 deletions

File tree

apps/hook/server/session-log.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,87 @@ describe("resolveSessionLogByAncestorPids", () => {
822822
cleanup();
823823
}
824824
});
825+
826+
test("prefers newer ghost session over stale metadata match (after /clear)", () => {
827+
const { sessionsDir, projectsDir, cleanup } = makeTempDirs("ghost-clear");
828+
try {
829+
const cwd = "/tmp/fake-project-ghost";
830+
const oldSessionId = "old-session-before-clear";
831+
const newSessionId = "new-session-after-clear";
832+
833+
// Metadata still points to old session (stale after /clear)
834+
writeSessionMeta(sessionsDir, 400, { sessionId: oldSessionId, cwd });
835+
836+
// Both logs exist; new one is more recently modified
837+
writeSessionLog(projectsDir, cwd, oldSessionId, buildLog(
838+
userPrompt("hello"),
839+
assistantText("msg_old", "Doing well, thanks!")
840+
));
841+
842+
// Small delay to ensure mtime ordering
843+
const newLog = writeSessionLog(projectsDir, cwd, newSessionId, buildLog(
844+
userPrompt("Why is the sky blue?"),
845+
assistantText("msg_new", "Rayleigh scattering")
846+
));
847+
848+
// Touch the new file to guarantee it's newer
849+
const { utimesSync } = require("node:fs");
850+
utimesSync(newLog, new Date(), new Date());
851+
852+
const result = resolveSessionLogByAncestorPids({
853+
startPid: 400,
854+
getParentPid: () => null,
855+
sessionsDir,
856+
projectsDir,
857+
});
858+
859+
// Should prefer the ghost session (newer, unregistered)
860+
expect(result).toBe(newLog);
861+
} finally {
862+
cleanup();
863+
}
864+
});
865+
866+
test("keeps PID-based result when newer log belongs to a concurrent session", () => {
867+
const { sessionsDir, projectsDir, cleanup } = makeTempDirs("concurrent");
868+
try {
869+
const cwd = "/tmp/fake-project-concurrent";
870+
const sessionA = "session-terminal-1";
871+
const sessionB = "session-terminal-2";
872+
873+
// Both sessions have their own metadata (different PIDs)
874+
writeSessionMeta(sessionsDir, 400, { sessionId: sessionA, cwd });
875+
writeSessionMeta(sessionsDir, 500, { sessionId: sessionB, cwd });
876+
877+
// Terminal 1's log
878+
const logA = writeSessionLog(projectsDir, cwd, sessionA, buildLog(
879+
userPrompt("hello from terminal 1"),
880+
assistantText("msg_a", "Response in terminal 1")
881+
));
882+
883+
// Terminal 2's log (more recently modified)
884+
const logB = writeSessionLog(projectsDir, cwd, sessionB, buildLog(
885+
userPrompt("hello from terminal 2"),
886+
assistantText("msg_b", "Response in terminal 2")
887+
));
888+
889+
const { utimesSync } = require("node:fs");
890+
utimesSync(logB, new Date(), new Date());
891+
892+
// From terminal 1's process tree, should get terminal 1's log
893+
const result = resolveSessionLogByAncestorPids({
894+
startPid: 400,
895+
getParentPid: () => null,
896+
sessionsDir,
897+
projectsDir,
898+
});
899+
900+
// Should keep the PID-based result (session B is registered, not a ghost)
901+
expect(result).toBe(logA);
902+
} finally {
903+
cleanup();
904+
}
905+
});
825906
});
826907

827908
describe("resolveSessionLogByCwdScan", () => {

apps/hook/server/session-log.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { readdirSync, statSync, readFileSync } from "node:fs";
1818
import { spawnSync } from "node:child_process";
19-
import { join, dirname } from "node:path";
19+
import { join, dirname, basename } from "node:path";
2020
import { homedir } from "node:os";
2121

2222
const DEFAULT_SESSIONS_DIR = join(homedir(), ".claude", "sessions");
@@ -292,10 +292,43 @@ export function getAncestorPids(
292292
return chain;
293293
}
294294

295+
/**
296+
* Check if a sessionId is referenced by any metadata file in the sessions dir.
297+
* Used to distinguish "ghost" sessions (created by /clear but never registered
298+
* in metadata) from legitimate concurrent sessions (which have their own PID's
299+
* metadata file).
300+
*/
301+
export function isSessionRegistered(
302+
sessionId: string,
303+
sessionsDir: string
304+
): boolean {
305+
try {
306+
const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
307+
for (const f of files) {
308+
try {
309+
const meta: SessionMetadata = JSON.parse(
310+
readFileSync(join(sessionsDir, f), "utf-8")
311+
);
312+
if (meta?.sessionId === sessionId) return true;
313+
} catch {
314+
// Malformed file — skip
315+
}
316+
}
317+
} catch {
318+
// sessionsDir unreadable
319+
}
320+
return false;
321+
}
322+
295323
/**
296324
* Resolve a session log path by walking up the PID chain, checking
297325
* `~/.claude/sessions/<pid>.json` at each hop for a session metadata match.
298-
* Deterministic — no mtime guessing, no cwd matching.
326+
*
327+
* When the matched log is not the most recently modified file in the project
328+
* directory, checks whether the newer file is a "ghost" session — one created
329+
* by /clear that was never registered in any metadata file. If so, prefers the
330+
* ghost (it's the current session). If the newer file belongs to a registered
331+
* concurrent session, keeps the PID-based result.
299332
*/
300333
export function resolveSessionLogByAncestorPids(
301334
opts: {
@@ -321,7 +354,17 @@ export function resolveSessionLogByAncestorPids(
321354

322355
const candidates = findSessionLogsForCwd(meta.cwd, opts.projectsDir);
323356
const match = candidates.find((p) => p.includes(meta.sessionId));
324-
if (match) return match;
357+
if (match) {
358+
// Check for stale metadata: if a newer log exists that has no
359+
// registered metadata, it's a ghost session from /clear — prefer it.
360+
if (candidates[0] !== match) {
361+
const newestSessionId = basename(candidates[0], ".jsonl");
362+
if (!isSessionRegistered(newestSessionId, sessionsDir)) {
363+
return candidates[0];
364+
}
365+
}
366+
return match;
367+
}
325368
}
326369
return null;
327370
}

0 commit comments

Comments
 (0)