diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 52f466861..77e016579 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -158,8 +158,7 @@ import { findSessionLogsForCwd, getRecentRenderedMessages, resolveDroidSessionLogForCwd, - resolveSessionLogByAncestorPids, - resolveSessionLogByCwdScan, + resolveClaudeSessionLog, type RenderedMessage, } from "./session-log"; import { findCodexRolloutByThreadId, getLatestCodexPlan, getRecentCodexMessages } from "./codex-session"; @@ -1448,19 +1447,8 @@ if (args[0] === "sessions") { } else { // Claude Code path: resolve session log // - // Strategy (most precise → least precise): - // 1. Ancestor-PID session metadata: walk up the process tree checking - // ~/.claude/sessions/.json at each hop. When invoked from a slash - // command's `!` bang, the direct parent is a bash subshell — Claude's - // session file is a few hops up. Deterministic when it matches. - // 2. Cwd-scan of session metadata: read every ~/.claude/sessions/*.json, - // filter by cwd, pick the most recent startedAt. Better than mtime - // guessing because it uses session-level metadata. - // 3. CWD slug match (mtime-based): legacy behavior — picks the most - // recently modified jsonl in the project dir. Fragile when multiple - // sessions exist for the same project. - // 4. Ancestor directory walk: handles the case where the user `cd`'d - // deeper into a subdirectory after session start. + // Prefer precise session metadata. Heuristic cwd/ancestor fallbacks are + // only safe when no metadata identifies the invoking session. if (process.env.PLANNOTATOR_DEBUG) { console.error(`[DEBUG] Project root: ${projectRoot}`); @@ -1489,19 +1477,16 @@ if (args[0] === "sessions") { } } - // 1. Walk ancestor PIDs for a matching session metadata file - const ancestorLog = resolveSessionLogByAncestorPids(); - tryLogCandidates("Ancestor PID session metadata", () => ancestorLog ? [ancestorLog] : []); - - // 2. Scan all session metadata files for one whose cwd matches - const cwdScanLog = resolveSessionLogByCwdScan({ cwd: projectRoot }); - tryLogCandidates("Cwd-scan session metadata", () => cwdScanLog ? [cwdScanLog] : []); - - // 3. Fall back to CWD slug match (mtime-based) - tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot)); - - // 4. Fall back to ancestor directory walk - tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot)); + const resolution = resolveClaudeSessionLog({ cwd: projectRoot }); + if (resolution.status === "identified") { + tryLogCandidates( + `Claude session metadata (${resolution.source})`, + () => resolution.logPath ? [resolution.logPath] : [], + ); + } else if (resolution.status === "unavailable") { + tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot)); + tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot)); + } } if (!lastMessage) { diff --git a/apps/hook/server/session-log.test.ts b/apps/hook/server/session-log.test.ts index 458a1562f..99006a636 100644 --- a/apps/hook/server/session-log.test.ts +++ b/apps/hook/server/session-log.test.ts @@ -18,6 +18,7 @@ import { resolveActiveBranchIndices, findDroidSessionLogsForCwd, resolveDroidSessionLogForCwd, + findClaudeSessionLogById, projectSlugFromCwd, findSessionLogsByAncestorWalk, findSessionLogsForCwd, @@ -25,11 +26,12 @@ import { normalizeCwdForCompare, parseProcessTableCsv, parseProcessTablePs, + resolveClaudeSessionLog, resolveSessionLogByAncestorPids, resolveSessionLogByCwdScan, type SessionLogEntry, } from "./session-log"; -import { mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync, rmSync, utimesSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -1210,6 +1212,583 @@ function writeSessionLog( return path; } +describe("findClaudeSessionLogById", () => { + test("finds a unique session log under a different project slug", () => { + const { projectsDir, cleanup } = makeTempDirs("session-id-cross-slug"); + try { + const launchDir = join(projectsDir, "launch-slug"); + mkdirSync(launchDir, { recursive: true }); + const logPath = join(launchDir, "session-a.jsonl"); + writeFileSync(logPath, "{}\n"); + + expect(findClaudeSessionLogById("session-a", projectsDir)).toBe(logPath); + } finally { + cleanup(); + } + }); + + test("accepts a dotted session id basename", () => { + const { projectsDir, cleanup } = makeTempDirs("session-id-dotted"); + try { + const dir = join(projectsDir, "project-slug"); + mkdirSync(dir, { recursive: true }); + const logPath = join(dir, "session.v1.jsonl"); + writeFileSync(logPath, "{}\n"); + + expect(findClaudeSessionLogById("session.v1", projectsDir)).toBe(logPath); + } finally { + cleanup(); + } + }); + + test.each(["../escape", "", "bad\\session", "bad/session", "bad\0session"])( + "rejects malformed session id %p", + (sessionId) => { + const { projectsDir, cleanup } = makeTempDirs("session-id-malformed"); + try { + expect(findClaudeSessionLogById(sessionId, projectsDir)).toBeNull(); + } finally { + cleanup(); + } + }, + ); + + test("returns null when the session log is missing", () => { + const { projectsDir, cleanup } = makeTempDirs("session-id-missing"); + try { + expect(findClaudeSessionLogById("missing-session", projectsDir)).toBeNull(); + } finally { + cleanup(); + } + }); + + test("returns null when the matching session log is unreadable", () => { + if (process.platform === "win32") return; + const { projectsDir, cleanup } = makeTempDirs("session-id-unreadable"); + try { + const dir = join(projectsDir, "project-slug"); + mkdirSync(dir, { recursive: true }); + const logPath = join(dir, "unreadable-session.jsonl"); + writeFileSync(logPath, "{}\n"); + chmodSync(logPath, 0o000); + + expect( + findClaudeSessionLogById("unreadable-session", projectsDir), + ).toBeNull(); + } finally { + cleanup(); + } + }); + + test("returns null when the session id appears under multiple project slugs", () => { + const { projectsDir, cleanup } = makeTempDirs("session-id-duplicate"); + try { + for (const slug of ["first-slug", "second-slug"]) { + const dir = join(projectsDir, slug); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "duplicate-session.jsonl"), "{}\n"); + } + + expect( + findClaudeSessionLogById("duplicate-session", projectsDir), + ).toBeNull(); + } finally { + cleanup(); + } + }); +}); + +describe("resolveClaudeSessionLog", () => { + test("fails closed on non-string ancestor metadata session id", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-ancestor-non-string-id"); + try { + const cwd = "/tmp/non-string-ancestor"; + writeFileSync(join(sessionsDir, "400.json"), JSON.stringify({ + pid: 400, + sessionId: 123, + cwd, + startedAt: Date.now(), + })); + + expect(() => resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).not.toThrow(); + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ status: "blocked", source: "ancestor-pid" }); + } finally { + cleanup(); + } + }); + + test("fails closed on non-string cwd metadata session id", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-cwd-non-string-id"); + try { + const cwd = "/tmp/non-string-cwd"; + writeFileSync(join(sessionsDir, "400.json"), JSON.stringify({ + pid: 400, + sessionId: { value: "session-a" }, + cwd, + startedAt: Date.now(), + })); + + expect(() => resolveClaudeSessionLog({ + startPid: 999, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).not.toThrow(); + expect(resolveClaudeSessionLog({ + startPid: 999, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ status: "blocked", source: "cwd-metadata" }); + } finally { + cleanup(); + } + }); + + test("blocks malformed ancestor metadata", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-ancestor-malformed"); + try { + writeFileSync(join(sessionsDir, "400.json"), "not json"); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd: "/tmp/ancestor-malformed", + sessionsDir, + projectsDir, + })).toEqual({ status: "blocked", source: "ancestor-pid" }); + } finally { + cleanup(); + } + }); + + test("blocks unreadable ancestor metadata", () => { + if (process.platform === "win32") return; + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-ancestor-unreadable"); + try { + const metaPath = join(sessionsDir, "400.json"); + writeFileSync(metaPath, JSON.stringify({ + pid: 400, + sessionId: "session-a", + cwd: "/tmp/ancestor-unreadable", + startedAt: Date.now(), + })); + chmodSync(metaPath, 0o000); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd: "/tmp/ancestor-unreadable", + sessionsDir, + projectsDir, + })).toEqual({ status: "blocked", source: "ancestor-pid" }); + } finally { + cleanup(); + } + }); + + test("blocks matching-cwd metadata with invalid ordering", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-cwd-invalid-order"); + try { + const cwd = "/tmp/cwd-invalid-order"; + writeFileSync(join(sessionsDir, "400.json"), JSON.stringify({ + pid: 400, + sessionId: "session-a", + cwd, + startedAt: "not-a-number", + })); + + expect(resolveClaudeSessionLog({ + startPid: 999, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ status: "blocked", source: "cwd-metadata" }); + } finally { + cleanup(); + } + }); + + test("ignores invalid metadata for another cwd", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-cwd-unrelated-invalid"); + try { + writeFileSync(join(sessionsDir, "400.json"), JSON.stringify({ + pid: "invalid", + sessionId: { value: "session-a" }, + cwd: "/tmp/another-project", + startedAt: "invalid", + })); + + expect(resolveClaudeSessionLog({ + startPid: 999, + getParentPid: () => null, + cwd: "/tmp/target-project", + sessionsDir, + projectsDir, + })).toEqual({ status: "unavailable" }); + } finally { + cleanup(); + } + }); + + test("resolves ancestor metadata across project slugs", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-ancestor-cross-slug"); + try { + const cwd = "/tmp/moved-worktree"; + writeSessionMeta(sessionsDir, 400, { sessionId: "session-a", cwd }); + const launchDir = join(projectsDir, "launch-slug"); + mkdirSync(launchDir, { recursive: true }); + const logPath = join(launchDir, "session-a.jsonl"); + writeFileSync(logPath, "{}\n"); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "session-a", + logPath, + source: "ancestor-pid", + }); + } finally { + cleanup(); + } + }); + + test("treats the newest cwd metadata record as authoritative", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-cwd-newest"); + try { + const cwd = "/tmp/cwd-authoritative"; + writeSessionMeta(sessionsDir, 111, { + sessionId: "old-session", + cwd, + startedAt: 1_000, + }); + writeSessionMeta(sessionsDir, 222, { + sessionId: "new-session", + cwd, + startedAt: 2_000, + }); + writeSessionLog(projectsDir, cwd, "old-session"); + const newLog = writeSessionLog(projectsDir, cwd, "new-session"); + + expect(resolveClaudeSessionLog({ + startPid: 999, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "new-session", + logPath: newLog, + source: "cwd-metadata", + }); + } finally { + cleanup(); + } + }); + + test("returns identified with no path when the newest cwd transcript is missing", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-cwd-missing"); + try { + const cwd = "/tmp/cwd-missing"; + writeSessionMeta(sessionsDir, 111, { + sessionId: "old-session", + cwd, + startedAt: 1_000, + }); + writeSessionMeta(sessionsDir, 222, { + sessionId: "new-session-missing", + cwd, + startedAt: 2_000, + }); + writeSessionLog(projectsDir, cwd, "old-session"); + writeSessionLog(projectsDir, cwd, "unrelated-newer-session"); + + expect(resolveClaudeSessionLog({ + startPid: 999, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "new-session-missing", + logPath: null, + source: "cwd-metadata", + }); + } finally { + cleanup(); + } + }); + + test("keeps moved-worktree identity instead of selecting another slug", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-moved-worktree"); + try { + const cwd = "/tmp/worktree-after-move"; + writeSessionMeta(sessionsDir, 400, { + sessionId: "session-a", + cwd, + }); + + const launchDir = join(projectsDir, "launch-slug"); + const otherDir = join(projectsDir, "other-slug"); + mkdirSync(launchDir, { recursive: true }); + mkdirSync(otherDir, { recursive: true }); + const sessionA = join(launchDir, "session-a.jsonl"); + const olderSibling = join(launchDir, "older-unregistered.jsonl"); + const sessionX = join(otherDir, "session-x.jsonl"); + writeFileSync(sessionA, "{}\n"); + writeFileSync(olderSibling, "{}\n"); + writeFileSync(sessionX, "{}\n"); + const now = Date.now() / 1000; + utimesSync(olderSibling, now - 20, now - 20); + utimesSync(sessionA, now - 10, now - 10); + utimesSync(sessionX, now, now); + + const options = { + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + }; + expect(resolveClaudeSessionLog(options)).toEqual({ + status: "identified", + sessionId: "session-a", + logPath: sessionA, + source: "ancestor-pid", + }); + + rmSync(sessionA); + expect(resolveClaudeSessionLog(options)).toEqual({ + status: "identified", + sessionId: "session-a", + logPath: null, + source: "ancestor-pid", + }); + } finally { + cleanup(); + } + }); + + test("does not replace an exact match with a registered concurrent sibling", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-concurrent"); + try { + const cwd = "/tmp/detailed-concurrent"; + writeSessionMeta(sessionsDir, 400, { sessionId: "session-a", cwd }); + writeSessionMeta(sessionsDir, 500, { sessionId: "session-b", cwd }); + const sessionA = writeSessionLog(projectsDir, cwd, "session-a"); + const sessionB = writeSessionLog(projectsDir, cwd, "session-b"); + const now = Date.now() / 1000; + utimesSync(sessionA, now - 10, now - 10); + utimesSync(sessionB, now, now); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "session-a", + logPath: sessionA, + source: "ancestor-pid", + }); + } finally { + cleanup(); + } + }); + + test("selects a /clear ghost behind a newer registered concurrent sibling", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-clear-behind-concurrent"); + try { + const cwd = "/tmp/detailed-clear-behind-concurrent"; + writeSessionMeta(sessionsDir, 400, { sessionId: "session-a", cwd }); + writeSessionMeta(sessionsDir, 500, { sessionId: "session-b", cwd }); + const sessionA = writeSessionLog(projectsDir, cwd, "session-a"); + const ghost = writeSessionLog(projectsDir, cwd, "session-ghost"); + const sessionB = writeSessionLog(projectsDir, cwd, "session-b"); + const now = Date.now() / 1000; + utimesSync(sessionA, now - 20, now - 20); + utimesSync(ghost, now - 10, now - 10); + utimesSync(sessionB, now, now); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "session-ghost", + logPath: ghost, + source: "ancestor-pid", + }); + } finally { + cleanup(); + } + }); + + test("preserves a newer same-directory unregistered /clear transcript", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-clear"); + try { + const cwd = "/tmp/detailed-clear"; + writeSessionMeta(sessionsDir, 400, { sessionId: "session-a", cwd }); + const sessionA = writeSessionLog(projectsDir, cwd, "session-a"); + const ghost = writeSessionLog(projectsDir, cwd, "session-after-clear"); + const now = Date.now() / 1000; + utimesSync(sessionA, now - 10, now - 10); + utimesSync(ghost, now, now); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "session-after-clear", + logPath: ghost, + source: "ancestor-pid", + }); + } finally { + cleanup(); + } + }); + + test("does not treat an equal-mtime sibling as a /clear transcript", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-clear-equal-mtime"); + try { + const cwd = "/tmp/detailed-clear-equal-mtime"; + writeSessionMeta(sessionsDir, 400, { sessionId: "session-a", cwd }); + const ghost = writeSessionLog(projectsDir, cwd, "000-session-after-clear"); + const sessionA = writeSessionLog(projectsDir, cwd, "session-a"); + const sameTime = Date.now() / 1000; + utimesSync(ghost, sameTime, sameTime); + utimesSync(sessionA, sameTime, sameTime); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "session-a", + logPath: sessionA, + source: "ancestor-pid", + }); + } finally { + cleanup(); + } + }); + + test("retains the precise match when registration metadata is malformed", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-clear-malformed"); + try { + const cwd = "/tmp/detailed-clear-malformed"; + writeSessionMeta(sessionsDir, 400, { sessionId: "session-a", cwd }); + writeFileSync(join(sessionsDir, "malformed.json"), "not json"); + const sessionA = writeSessionLog(projectsDir, cwd, "session-a"); + const sibling = writeSessionLog(projectsDir, cwd, "session-b"); + const now = Date.now() / 1000; + utimesSync(sessionA, now - 10, now - 10); + utimesSync(sibling, now, now); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "session-a", + logPath: sessionA, + source: "ancestor-pid", + }); + } finally { + cleanup(); + } + }); + + test("retains the precise match when registration metadata is unreadable", () => { + if (process.platform === "win32") return; + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-clear-unreadable"); + try { + const cwd = "/tmp/detailed-clear-unreadable"; + writeSessionMeta(sessionsDir, 400, { sessionId: "session-a", cwd }); + const unreadableMeta = join(sessionsDir, "500.json"); + writeFileSync(unreadableMeta, JSON.stringify({ + pid: 500, + sessionId: "session-b", + cwd, + startedAt: Date.now(), + })); + chmodSync(unreadableMeta, 0o000); + const sessionA = writeSessionLog(projectsDir, cwd, "session-a"); + const sibling = writeSessionLog(projectsDir, cwd, "session-b"); + const now = Date.now() / 1000; + utimesSync(sessionA, now - 10, now - 10); + utimesSync(sibling, now, now); + + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd, + sessionsDir, + projectsDir, + })).toEqual({ + status: "identified", + sessionId: "session-a", + logPath: sessionA, + source: "ancestor-pid", + }); + } finally { + cleanup(); + } + }); + + test("returns unavailable when no precise metadata identifies a session", () => { + const { sessionsDir, projectsDir, cleanup } = makeTempDirs("detailed-unavailable"); + try { + expect(resolveClaudeSessionLog({ + startPid: 400, + getParentPid: () => null, + cwd: "/tmp/no-metadata", + sessionsDir, + projectsDir, + })).toEqual({ status: "unavailable" }); + } finally { + cleanup(); + } + }); +}); + describe("resolveSessionLogByAncestorPids", () => { test("returns null when no ancestor PID has session metadata", () => { const { sessionsDir, projectsDir, cleanup } = makeTempDirs("no-ancestor"); @@ -1445,7 +2024,7 @@ describe("resolveSessionLogByCwdScan", () => { } }); - test("falls through to older session if newest has no matching jsonl", () => { + test("does not fall through when the newest session has no matching jsonl", () => { const { sessionsDir, projectsDir, cleanup } = makeTempDirs("fallthrough"); try { const cwd = "/tmp/fallthrough-project"; @@ -1459,7 +2038,7 @@ describe("resolveSessionLogByCwdScan", () => { cwd, startedAt: 2_000, }); - const oldLog = writeSessionLog(projectsDir, cwd, "old-session"); + writeSessionLog(projectsDir, cwd, "old-session"); // Note: no jsonl for new-session-no-log const result = resolveSessionLogByCwdScan({ @@ -1467,7 +2046,7 @@ describe("resolveSessionLogByCwdScan", () => { sessionsDir, projectsDir, }); - expect(result).toBe(oldLog); + expect(result).toBeNull(); } finally { cleanup(); } @@ -1619,3 +2198,42 @@ describe("resolveSessionLogByCwdScan (cross-platform cwd matching)", () => { } }); }); + +describe("annotate-last Claude session resolution", () => { + test("uses one detailed resolution and reserves heuristics for unavailable metadata", () => { + const source = readFileSync(join(import.meta.dir, "index.ts"), "utf8"); + const start = source.indexOf( + '} else if (args[0] === "annotate-last" || args[0] === "last") {', + ); + const end = source.indexOf('} else if (args[0] === "opencode-plan") {', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + + const annotateLastBlock = source.slice(start, end); + expect(annotateLastBlock.match(/resolveClaudeSessionLog/g)).toHaveLength(1); + expect(annotateLastBlock).toContain( + 'if (resolution.status === "identified")', + ); + expect(annotateLastBlock).not.toContain("resolveSessionLogByAncestorPids"); + expect(annotateLastBlock).not.toContain("resolveSessionLogByCwdScan"); + + const unavailableGuard = + '} else if (resolution.status === "unavailable") {'; + expect(annotateLastBlock).toContain(unavailableGuard); + const identifiedBranch = annotateLastBlock.slice( + annotateLastBlock.indexOf('if (resolution.status === "identified")'), + annotateLastBlock.indexOf(unavailableGuard), + ); + expect(identifiedBranch).not.toContain("findSessionLogsForCwd"); + expect(identifiedBranch).not.toContain("findSessionLogsByAncestorWalk"); + expect(identifiedBranch).not.toContain("resolution.sessionId"); + expect(identifiedBranch).toContain( + "Claude session metadata (${resolution.source})", + ); + const unavailableBranch = annotateLastBlock.slice( + annotateLastBlock.indexOf(unavailableGuard), + ); + expect(unavailableBranch).toContain("findSessionLogsForCwd"); + expect(unavailableBranch).toContain("findSessionLogsByAncestorWalk"); + }); +}); diff --git a/apps/hook/server/session-log.ts b/apps/hook/server/session-log.ts index e209e462f..144f80601 100644 --- a/apps/hook/server/session-log.ts +++ b/apps/hook/server/session-log.ts @@ -15,7 +15,13 @@ * sees rendered in chat. */ -import { readdirSync, statSync, readFileSync } from "node:fs"; +import { + accessSync, + constants as fsConstants, + readdirSync, + statSync, + readFileSync, +} from "node:fs"; import { spawnSync } from "node:child_process"; import { join, dirname, basename } from "node:path"; import { homedir } from "node:os"; @@ -152,6 +158,54 @@ export function findSessionLogsForCwd(cwd: string, projectsDirOverride?: string) return []; } +/** + * Find a Claude session log by its exact session id across project slugs. + * Returns null unless exactly one first-level project directory contains the + * corresponding regular file. + */ +export function findClaudeSessionLogById( + sessionId: string, + projectsDirOverride?: string, +): string | null { + if ( + typeof sessionId !== "string" || + !sessionId || + sessionId.includes("/") || + sessionId.includes("\\") || + sessionId.includes("\0") || + basename(sessionId) !== sessionId + ) { + return null; + } + + const projectsDir = projectsDirOverride ?? DEFAULT_PROJECTS_DIR; + let projectDirs: string[]; + try { + projectDirs = readdirSync(projectsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + return null; + } + + const matches: string[] = []; + for (const projectDir of projectDirs) { + const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`); + try { + if (statSync(candidate).isFile()) { + accessSync(candidate, fsConstants.R_OK); + matches.push(candidate); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") return null; + } + if (matches.length > 1) return null; + } + + return matches[0] ?? null; +} + /** * Find Droid/Factory session log candidates for a given working directory. * Returns all .jsonl paths sorted by mtime (most recent first). @@ -220,19 +274,73 @@ export interface SessionMetadata { startedAt: number; } -/** - * Read a Claude Code session metadata file for a given PID. - * Returns null if the file doesn't exist or can't be parsed. - */ -function readSessionMetadata( +export type ClaudeSessionLogResolution = + | { status: "unavailable" } + | { status: "blocked"; source: "ancestor-pid" | "cwd-metadata" } + | { + status: "identified"; + sessionId: string; + logPath: string | null; + source: "ancestor-pid" | "cwd-metadata"; + }; + +export interface ClaudeSessionLogResolutionOptions { + startPid?: number; + cwd?: string; + sessionsDir?: string; + projectsDir?: string; + getParentPid?: (pid: number) => number | null; + maxHops?: number; +} + +function parseSessionMetadata(value: unknown): SessionMetadata | null { + if (!value || typeof value !== "object") return null; + const meta = value as Record; + if ( + typeof meta.pid !== "number" || + !Number.isFinite(meta.pid) || + typeof meta.sessionId !== "string" || + !meta.sessionId || + typeof meta.cwd !== "string" || + !meta.cwd || + typeof meta.startedAt !== "number" || + !Number.isFinite(meta.startedAt) + ) { + return null; + } + return { + pid: meta.pid, + sessionId: meta.sessionId, + cwd: meta.cwd, + startedAt: meta.startedAt, + }; +} + +type SessionMetadataReadResult = + | { status: "absent" } + | { status: "invalid" } + | { status: "valid"; metadata: SessionMetadata }; + +function readSessionMetadataDetailed( pid: number, - sessionsDir: string -): SessionMetadata | null { + sessionsDir: string, +): SessionMetadataReadResult { const metaPath = join(sessionsDir, `${pid}.json`); + let content: string; try { - return JSON.parse(readFileSync(metaPath, "utf-8")); + content = readFileSync(metaPath, "utf-8"); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + ? { status: "absent" } + : { status: "invalid" }; + } + try { + const metadata = parseSessionMetadata(JSON.parse(content)); + return metadata + ? { status: "valid", metadata } + : { status: "invalid" }; } catch { - return null; + return { status: "invalid" }; } } @@ -360,49 +468,57 @@ export function getAncestorPids( * in metadata) from legitimate concurrent sessions (which have their own PID's * metadata file). */ -export function isSessionRegistered( +type SessionRegistrationStatus = "registered" | "unregistered" | "unknown"; + +function resolveSessionRegistration( sessionId: string, - sessionsDir: string -): boolean { + sessionsDir: string, +): SessionRegistrationStatus { + let files: string[]; try { - const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")); - for (const f of files) { - try { - const meta: SessionMetadata = JSON.parse( - readFileSync(join(sessionsDir, f), "utf-8") - ); - if (meta?.sessionId === sessionId) return true; - } catch { - // Malformed file — skip - } - } + files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")); } catch { - // sessionsDir unreadable + return "unknown"; + } + + for (const f of files) { + let meta: SessionMetadata | null; + try { + meta = parseSessionMetadata( + JSON.parse(readFileSync(join(sessionsDir, f), "utf-8")), + ); + } catch { + return "unknown"; + } + if (!meta) return "unknown"; + if (meta.sessionId === sessionId) { + return "registered"; + } } - return false; + + return "unregistered"; +} + +export function isSessionRegistered( + sessionId: string, + sessionsDir: string, +): boolean { + return resolveSessionRegistration(sessionId, sessionsDir) === "registered"; } /** - * Resolve a session log path by walking up the PID chain, checking - * `~/.claude/sessions/.json` at each hop for a session metadata match. + * Resolve a session by walking up the PID chain, checking + * `~/.claude/sessions/.json` at each hop for session metadata. * - * When the matched log is not the most recently modified file in the project - * directory, checks whether the newer file is a "ghost" session — one created - * by /clear that was never registered in any metadata file. If so, prefers the - * ghost (it's the current session). If the newer file belongs to a registered - * concurrent session, keeps the PID-based result. + * The session id is resolved across project slugs because a worktree can move + * after Claude records its launch cwd. When the exact log has a newer sibling, + * an unregistered sibling is a "ghost" session created by /clear and wins. */ -export function resolveSessionLogByAncestorPids( - opts: { - startPid?: number; - sessionsDir?: string; - projectsDir?: string; - getParentPid?: (pid: number) => number | null; - maxHops?: number; - } = {} -): string | null { +function resolveSessionLogByAncestorPidsDetailed( + opts: ClaudeSessionLogResolutionOptions = {}, +): ClaudeSessionLogResolution { const startPid = opts.startPid ?? process.ppid; - if (!startPid) return null; + if (!startPid) return { status: "unavailable" }; const sessionsDir = opts.sessionsDir ?? DEFAULT_SESSIONS_DIR; // Fresh closure per call: each resolver invocation gets its own snapshot, // so the process table can't go stale between unrelated lookups. @@ -411,24 +527,73 @@ export function resolveSessionLogByAncestorPids( const pids = getAncestorPids(startPid, maxHops, getParent); for (const pid of pids) { - const meta = readSessionMetadata(pid, sessionsDir); - if (!meta?.sessionId || !meta?.cwd) continue; - - const candidates = findSessionLogsForCwd(meta.cwd, opts.projectsDir); - const match = candidates.find((p) => p.includes(meta.sessionId)); - if (match) { - // Check for stale metadata: if a newer log exists that has no - // registered metadata, it's a ghost session from /clear — prefer it. - if (candidates[0] !== match) { - const newestSessionId = basename(candidates[0], ".jsonl"); - if (!isSessionRegistered(newestSessionId, sessionsDir)) { - return candidates[0]; + const metadata = readSessionMetadataDetailed(pid, sessionsDir); + if (metadata.status === "absent") continue; + if (metadata.status === "invalid") { + return { status: "blocked", source: "ancestor-pid" }; + } + const meta = metadata.metadata; + + const match = findClaudeSessionLogById(meta.sessionId, opts.projectsDir); + if (!match) { + return { + status: "identified", + sessionId: meta.sessionId, + logPath: null, + source: "ancestor-pid", + }; + } + const preciseMatch: ClaudeSessionLogResolution = { + status: "identified", + sessionId: meta.sessionId, + logPath: match, + source: "ancestor-pid", + }; + + // Check for stale metadata: if a newer sibling log has no registered + // metadata, it's a ghost session from /clear — prefer it. + const candidates = findSessionLogs(dirname(match)); + let matchMtime: number; + try { + matchMtime = statSync(match).mtimeMs; + } catch { + return preciseMatch; + } + for (const candidate of candidates) { + if (candidate === match) break; + try { + if (statSync(candidate).mtimeMs <= matchMtime) break; + const candidateSessionId = basename(candidate, ".jsonl"); + const registration = resolveSessionRegistration( + candidateSessionId, + sessionsDir, + ); + if (registration === "unknown") { + return preciseMatch; + } + if (registration === "unregistered") { + return { + status: "identified", + sessionId: candidateSessionId, + logPath: candidate, + source: "ancestor-pid", + }; } + } catch { + return preciseMatch; } - return match; } + + return preciseMatch; } - return null; + return { status: "unavailable" }; +} + +export function resolveSessionLogByAncestorPids( + opts: ClaudeSessionLogResolutionOptions = {}, +): string | null { + const resolution = resolveSessionLogByAncestorPidsDetailed(opts); + return resolution.status === "identified" ? resolution.logPath : null; } /** @@ -440,13 +605,9 @@ export function resolveSessionLogByAncestorPids( * session-level metadata rather than file modification time, which can be * touched by unrelated processes or resumed sessions. */ -export function resolveSessionLogByCwdScan( - opts: { - cwd?: string; - sessionsDir?: string; - projectsDir?: string; - } = {} -): string | null { +function resolveSessionLogByCwdScanDetailed( + opts: ClaudeSessionLogResolutionOptions = {}, +): ClaudeSessionLogResolution { const cwd = opts.cwd ?? process.cwd(); const sessionsDir = opts.sessionsDir ?? DEFAULT_SESSIONS_DIR; @@ -454,37 +615,60 @@ export function resolveSessionLogByCwdScan( try { files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")); } catch { - return null; + return { status: "unavailable" }; } const normalizedTarget = normalizeCwdForCompare(cwd); const candidates: SessionMetadata[] = []; for (const f of files) { + let value: unknown; try { - const meta: SessionMetadata = JSON.parse( - readFileSync(join(sessionsDir, f), "utf-8") - ); - if ( - meta?.sessionId && - meta?.cwd && - normalizeCwdForCompare(meta.cwd) === normalizedTarget - ) { - candidates.push(meta); - } + value = JSON.parse(readFileSync(join(sessionsDir, f), "utf-8")); } catch { - // Malformed metadata file — skip + // Cannot associate unreadable or malformed metadata with this cwd. + continue; } + if (!value || typeof value !== "object") continue; + const rawCwd = (value as Record).cwd; + if ( + typeof rawCwd !== "string" || + normalizeCwdForCompare(rawCwd) !== normalizedTarget + ) { + continue; + } + const meta = parseSessionMetadata(value); + if (!meta) return { status: "blocked", source: "cwd-metadata" }; + candidates.push(meta); } - // Newest sessions first — pick the most recently started session that has a matching jsonl + // The newest matching metadata record is authoritative even if its log is + // missing. Falling through could select a different concurrent session. candidates.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)); - const logs = findSessionLogsForCwd(cwd, opts.projectsDir); - for (const meta of candidates) { - const match = logs.find((p) => p.includes(meta.sessionId)); - if (match) return match; - } - return null; + const meta = candidates[0]; + if (!meta) return { status: "unavailable" }; + + return { + status: "identified", + sessionId: meta.sessionId, + logPath: findClaudeSessionLogById(meta.sessionId, opts.projectsDir), + source: "cwd-metadata", + }; +} + +export function resolveSessionLogByCwdScan( + opts: ClaudeSessionLogResolutionOptions = {}, +): string | null { + const resolution = resolveSessionLogByCwdScanDetailed(opts); + return resolution.status === "identified" ? resolution.logPath : null; +} + +export function resolveClaudeSessionLog( + opts: ClaudeSessionLogResolutionOptions = {}, +): ClaudeSessionLogResolution { + const ancestor = resolveSessionLogByAncestorPidsDetailed(opts); + if (ancestor.status !== "unavailable") return ancestor; + return resolveSessionLogByCwdScanDetailed(opts); } /**