Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 104 additions & 38 deletions session.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,145 @@
/**
* Session ID resolution for mcp-server-nerf.
*
* Resolves the Claude Code session ID from the environment, filesystem
* artifacts, or generates a stable fallback.
* Resolves the Claude Code session ID from the environment, the project
* transcript directory, or generates a fallback identifier of last resort.
*/

import { readdirSync } from "node:fs";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";
import { log } from "./logger.ts";

const PROJECTS_DIR = join(homedir(), ".claude", "projects");

/**
* UUID-shaped basename (case-insensitive). Claude Code transcript filenames
* are lowercase v4 UUIDs in current builds, but the `i` flag costs nothing
* and protects against a hypothetical future generator that uses uppercase.
*/
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
* Resolve the session ID from the Claude Code environment.
*
* Strategy:
* 1. Check CLAUDE_SESSION_ID env var (set by some Claude Code configurations)
* 2. Scan /tmp for claude session artifacts (transcript/output files)
* 3. Fallback: generate a stable ID from PID + process start time
* Strategy, in order:
* 1. Explicit `override` from tool params.
* 2. `CLAUDE_SESSION_ID` env var (set by some Claude Code configurations).
* 3. Most-recently-modified transcript in this project's transcript dir.
* Claude Code writes per-project transcripts at
* ~/.claude/projects/<slug>/<session_uuid>.jsonl
* where <slug> = process.cwd() with `/` replaced by `-`. Scoping to the
* calling CC's own project dir disambiguates correctly when the user has
* multiple concurrent CC sessions in different projects.
* 4. Fallback: stable ID from `md5(pid-ppid)`. Logged at `warn` level so the
* fleet sees this gap if it fires.
*
* Returns the resolved session ID. Never throws.
*
* `projectsDir` and `cwd` are exposed for testability; production callers pass
* neither and get the real Claude Code paths.
*/
export function resolveSessionId(override?: string): string {
// 0. Explicit override from tool params
export function resolveSessionId(
override?: string,
projectsDir: string = PROJECTS_DIR,
cwd: string = process.cwd(),
): string {
if (override) {
log.debug("state_change", { what: "session", to: override }, "Resolved via explicit override");
return override;
}

// 1. Direct env var
const envId = process.env.CLAUDE_SESSION_ID;
if (envId) {
log.debug("state_change", { what: "session", to: envId }, "Resolved via CLAUDE_SESSION_ID env var");
return envId;
}

// 2. Scan /tmp for session artifacts
const scanned = scanForSessionArtifacts();
if (scanned) {
log.debug("state_change", { what: "session", to: scanned }, "Resolved via artifact scan");
return scanned;
const fromTranscripts = resolveFromTranscripts(projectsDir, cwd);
if (fromTranscripts) {
log.debug("state_change", { what: "session", to: fromTranscripts }, "Resolved via newest transcript");
return fromTranscripts;
}

// 3. Fallback: stable ID from PID + timestamp
const stableId = generateStableId();
log.debug("state_change", { what: "session", to: stableId }, "Resolved via stable ID fallback");
log.warn(
"session_resolution",
{ cause: "fallback_used", to: stableId },
"Resolved via md5(pid-ppid) fallback — no transcript found in project's transcript dir",
);
return stableId;
}

/**
* Scan /tmp for Claude session artifacts and extract a session ID.
* Looks for files matching claude-session-* or similar patterns.
* Derive the per-project transcript directory name from a project root.
* Claude Code uses the absolute path with `/` replaced by `-`.
*
* /home/bakerb/sandbox/github/foo → -home-bakerb-sandbox-github-foo
*
* Exported for testing.
*/
export function projectSlug(cwd: string): string {
return cwd.replace(/\//g, "-");
}

/**
* Find the newest UUID-named `.jsonl` transcript in this project's transcript
* directory. Scans only `<projectsDir>/<slug>/`, where `slug` is derived from
* `cwd` (defaults to `process.cwd()`).
*
* Returns the basename (without `.jsonl`) of the newest match, or null when
* the directory is missing/empty/unreadable. Exported for testing.
*/
function scanForSessionArtifacts(): string | null {
export function resolveFromTranscripts(
projectsDir: string = PROJECTS_DIR,
cwd: string = process.cwd(),
): string | null {
const slug = projectSlug(cwd);
const projectDir = join(projectsDir, slug);

if (!existsSync(projectDir)) {
return null;
}

let newest: { sessionId: string; mtimeMs: number } | null = null;

let entries: string[];
try {
const entries = readdirSync("/tmp");
// Look for nerf config files first (nerf-<session_id>.json)
for (const entry of entries) {
const match = entry.match(/^nerf-([a-f0-9-]+)\.json$/);
if (match) {
return match[1];
}
}
// Look for claude session markers
for (const entry of entries) {
const match = entry.match(/^claude-session-([a-f0-9-]+)/);
if (match) {
return match[1];
entries = readdirSync(projectDir);
} catch (err) {
// Directory existed (existsSync passed) but was unreadable — permissions,
// symlink loop, FS error. Log so an operator diagnosing a fallback can
// see the actual cause, not just "no transcript found."
log.debug(
"session_resolution",
{ projectDir, err: String(err) },
"readdirSync failed on existing project dir",
);
return null;
}

for (const entry of entries) {
if (!entry.endsWith(".jsonl")) continue;
const sessionId = entry.slice(0, -".jsonl".length);
if (!SESSION_UUID_RE.test(sessionId)) continue;
try {
const mtimeMs = statSync(join(projectDir, entry)).mtimeMs;
if (!newest || mtimeMs > newest.mtimeMs) {
newest = { sessionId, mtimeMs };
}
} catch {
// Unreadable file — skip silently
}
} catch {
// /tmp not readable — fall through
}
return null;

return newest?.sessionId ?? null;
}

/**
* Generate a stable ID from process characteristics.
* Uses PID and a fixed seed so the ID is deterministic within a process.
* Generate a stable ID from process characteristics. Last-resort fallback —
* never collides with the UUID shape used by real Claude Code sessions, so
* this ID cannot be confused for a transcript-derived one downstream.
*/
function generateStableId(): string {
const seed = `${process.pid}-${process.ppid ?? 0}`;
Expand Down
162 changes: 162 additions & 0 deletions tests/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Regression tests for session.ts — session ID resolution.
*
* Covers issue #24: the previous /tmp self-debris scan returned a fake
* 12-char-hex md5(pid-ppid) ID forever once written. The new resolver reads
* from the per-project transcript dir under ~/.claude/projects/ — Claude Code
* writes those files itself, so the source of truth cannot be self-poisoned.
*/

import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolveSessionId, resolveFromTranscripts, projectSlug } from "../session.ts";

const SESSION_A = "991053c7-f9e4-4840-b906-d0252650793e";
const SESSION_B = "44c9b97f-ac26-4fe8-be6f-45466b0b265e";
const SESSION_C = "5044048d-1cf7-4497-b9d7-66b78bc9d188";

function writeTranscript(projectsDir: string, project: string, sessionId: string, mtimeMs: number): void {
const projDir = join(projectsDir, project);
mkdirSync(projDir, { recursive: true });
const path = join(projDir, `${sessionId}.jsonl`);
writeFileSync(path, "");
const seconds = mtimeMs / 1000;
utimesSync(path, seconds, seconds);
}

describe("projectSlug", () => {
test("replaces all forward slashes with dashes", () => {
expect(projectSlug("/home/bakerb/sandbox/github/foo")).toBe("-home-bakerb-sandbox-github-foo");
});

test("handles single-component absolute path", () => {
expect(projectSlug("/foo")).toBe("-foo");
});

test("preserves dashes already in the path", () => {
expect(projectSlug("/home/user/my-project")).toBe("-home-user-my-project");
});
});

describe("resolveFromTranscripts (issue #24)", () => {
let projectsDir: string;
const CWD = "/home/test/project-a";
const SLUG = "-home-test-project-a";

beforeEach(() => {
projectsDir = mkdtempSync(join(tmpdir(), "nerf-session-projects-"));
});

afterEach(() => {
try { rmSync(projectsDir, { recursive: true, force: true }); } catch { /* ignore */ }
});

test("returns null when project dir does not exist for this cwd", () => {
expect(resolveFromTranscripts(projectsDir, CWD)).toBeNull();
});

test("returns null when project dir is empty", () => {
mkdirSync(join(projectsDir, SLUG), { recursive: true });
expect(resolveFromTranscripts(projectsDir, CWD)).toBeNull();
});

test("returns the session ID from a single transcript", () => {
writeTranscript(projectsDir, SLUG, SESSION_A, Date.now());
expect(resolveFromTranscripts(projectsDir, CWD)).toBe(SESSION_A);
});

test("returns newest by mtime within this project", () => {
const now = Date.now();
writeTranscript(projectsDir, SLUG, SESSION_A, now - 30_000);
writeTranscript(projectsDir, SLUG, SESSION_B, now);
writeTranscript(projectsDir, SLUG, SESSION_C, now - 60_000);
expect(resolveFromTranscripts(projectsDir, CWD)).toBe(SESSION_B);
});

test("ignores transcripts in other project directories (multi-session isolation)", () => {
// Critical regression check: when the user has concurrent CC sessions
// in different projects, the resolver MUST scope to its own cwd, not pick
// up the freshest transcript across the entire fleet.
const now = Date.now();
writeTranscript(projectsDir, SLUG, SESSION_A, now - 60_000);
writeTranscript(projectsDir, "-home-test-other-project", SESSION_B, now); // newer but wrong project
expect(resolveFromTranscripts(projectsDir, CWD)).toBe(SESSION_A);
});

test("ignores non-UUID-shaped basenames (debris)", () => {
// Old fallback IDs were 12-char hex like 81457c8d97e2 — must not be picked up
// even if a stray file ends up in a project dir.
writeTranscript(projectsDir, SLUG, "81457c8d97e2", Date.now());
expect(resolveFromTranscripts(projectsDir, CWD)).toBeNull();
});

test("ignores non-jsonl files", () => {
const projDir = join(projectsDir, SLUG);
mkdirSync(projDir, { recursive: true });
writeFileSync(join(projDir, `${SESSION_A}.json`), "");
writeFileSync(join(projDir, `${SESSION_A}.txt`), "");
expect(resolveFromTranscripts(projectsDir, CWD)).toBeNull();
});

test("UUID-shaped match wins over debris with newer mtime", () => {
const now = Date.now();
writeTranscript(projectsDir, SLUG, SESSION_A, now - 60_000);
writeTranscript(projectsDir, SLUG, "abcd1234ef56", now); // newer debris, ignored
expect(resolveFromTranscripts(projectsDir, CWD)).toBe(SESSION_A);
});
});

describe("resolveSessionId (issue #24)", () => {
let savedEnv: string | undefined;

beforeEach(() => {
savedEnv = process.env.CLAUDE_SESSION_ID;
delete process.env.CLAUDE_SESSION_ID;
});

afterEach(() => {
if (savedEnv === undefined) {
delete process.env.CLAUDE_SESSION_ID;
} else {
process.env.CLAUDE_SESSION_ID = savedEnv;
}
});

test("explicit override beats env var", () => {
process.env.CLAUDE_SESSION_ID = SESSION_B;
expect(resolveSessionId(SESSION_A)).toBe(SESSION_A);
});

test("env var beats transcript scan", () => {
process.env.CLAUDE_SESSION_ID = SESSION_A;
expect(resolveSessionId()).toBe(SESSION_A);
});

test("fallback fires with 12-char hex ID when no transcript exists for this cwd", () => {
// Force the resolver into the fallback path with an empty projectsDir +
// a cwd that has no transcripts. This is the path that USED to be the
// only thing that fired (the bug); now it must be a true last resort.
const emptyProjectsDir = mkdtempSync(join(tmpdir(), "nerf-fallback-empty-"));
try {
const id = resolveSessionId(undefined, emptyProjectsDir, "/home/test/no-transcripts");
expect(id).toMatch(/^[0-9a-f]{12}$/);
} finally {
try { rmSync(emptyProjectsDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});

test("transcript scan beats fallback when a transcript is present", () => {
const projectsDir = mkdtempSync(join(tmpdir(), "nerf-resolve-"));
const cwd = "/home/test/project-x";
const slug = "-home-test-project-x";
try {
writeTranscript(projectsDir, slug, SESSION_A, Date.now());
const id = resolveSessionId(undefined, projectsDir, cwd);
expect(id).toBe(SESSION_A);
} finally {
try { rmSync(projectsDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
});
Loading