Add Claude project-dir resolver matching SDK encoding

Claude's Agent SDK encodes a cwd into ~/.claude/projects/<dir> by
realpath + NFC normalizing the path, replacing every non-alphanumeric
character with "-", and (when the encoded result exceeds 200 chars)
appending a base-36 hash. paseo's existing sanitizer only replaces
five characters and skips realpath, so it misses the SDK's directory
whenever the cwd has a symlink (macOS /var → /private/var), spaces,
parens, NFD unicode, or exceeds the length cap.

This adds the resolver alongside the existing logic; wiring it into
agent.ts to replace resolveHistoryPath is the next step. Tests assert
parity by writing a session file at our computed path and asking the
SDK's getSessionInfo({ dir }) to find it — if both encoders agree,
the SDK finds it.
This commit is contained in:
Mohamed Boudra
2026-05-19 13:42:24 +07:00
parent b2cdbdaed8
commit fdecd75f94
2 changed files with 161 additions and 0 deletions
@@ -0,0 +1,109 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { claudeProjectDir } from "./project-dir.js";
// Parity oracle: the Claude SDK's getSessionInfo({ dir }) canonicalizes the
// given dir with the SDK's own encoder and looks for `<sessionId>.jsonl` under
// that path. If we write a session file at the path our function computes and
// the SDK finds it, our encoding matches theirs for that input.
const workspaceRoot = join(homedir(), ".paseo-claude-parity-tests");
const tmpWorkspaceRoot = join(tmpdir(), "paseo-claude-parity");
const createdSessionFiles: string[] = [];
interface ParityCase {
label: string;
build(): Promise<string>;
}
const cases: ParityCase[] = [
{
label: "plain path under home",
build: () => ensureDir(join(workspaceRoot, "plain")),
},
{
label: "path with spaces",
build: () => ensureDir(join(workspaceRoot, "with spaces and stuff")),
},
{
label: "path with parens and other shell-meta",
build: () => ensureDir(join(workspaceRoot, "proj (work) [v2]")),
},
{
label: "path with combining-mark unicode (NFD input)",
build: () => ensureDir(join(workspaceRoot, "cafe\u0301-nfd")),
},
{
label: "path under macOS tmpdir (realpath delta)",
build: () => ensureDir(join(tmpWorkspaceRoot, "var-folders-case")),
},
{
label: "user-defined symlink",
build: async () => {
const real = await ensureDir(join(workspaceRoot, "real-target"));
const link = join(workspaceRoot, "via-symlink");
if (!existsSync(link)) {
await symlink(real, link);
}
return link;
},
},
{
label: "deep path that triggers truncation + hash",
build: async () => {
// ~60 segments × 6 chars = 360+ char path, well past the 200-char cap.
let p = workspaceRoot;
for (let i = 0; i < 60; i++) p = join(p, `seg${i.toString().padStart(3, "0")}`);
return ensureDir(p);
},
},
{
label: "non-existent path (canonicalize fallback)",
// realpath will throw; canonicalize falls back to NFC of input. SDK does
// the same, so paths should still match.
build: async () => join(workspaceRoot, "this-path-was-never-created-" + randomUUID()),
},
];
describe("claudeProjectDir parity with Claude Agent SDK", () => {
beforeAll(async () => {
await mkdir(workspaceRoot, { recursive: true });
await mkdir(tmpWorkspaceRoot, { recursive: true });
});
afterAll(async () => {
for (const file of createdSessionFiles) {
await rm(file, { force: true });
}
await rm(workspaceRoot, { recursive: true, force: true });
await rm(tmpWorkspaceRoot, { recursive: true, force: true });
});
for (const c of cases) {
test(c.label, async () => {
const cwd = await c.build();
const sessionId = randomUUID();
const ourDir = await claudeProjectDir(cwd);
await mkdir(ourDir, { recursive: true });
const sessionFile = join(ourDir, `${sessionId}.jsonl`);
await writeFile(sessionFile, '{"type":"summary","summary":"parity"}\n');
createdSessionFiles.push(sessionFile);
const info = await getSessionInfo(sessionId, { dir: cwd });
expect(info?.sessionId).toBe(sessionId);
});
}
});
async function ensureDir(path: string): Promise<string> {
await mkdir(path, { recursive: true });
return path;
}
@@ -0,0 +1,52 @@
import { realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
// Verbatim port of the Claude Agent SDK's project-directory encoding so
// paseo computes the same `~/.claude/projects/<dir>` path the SDK does.
// The SDK ships only as a precompiled bundle; grep the JS source at
// node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs for `function f1`,
// `function i4`, `function tB`, `S0=200`.
const PROJECT_DIR_LENGTH_CAP = 200;
export interface ClaudeProjectDirOptions {
configDir?: string;
}
export async function claudeProjectDir(
cwd: string,
options?: ClaudeProjectDirOptions,
): Promise<string> {
const canonical = await canonicalize(cwd);
const projectsRoot = join(resolveConfigDir(options), "projects");
return join(projectsRoot, encode(canonical));
}
async function canonicalize(input: string): Promise<string> {
try {
return (await realpath(input)).normalize("NFC");
} catch {
return input.normalize("NFC");
}
}
function encode(input: string): string {
const replaced = input.replace(/[^a-zA-Z0-9]/g, "-");
if (replaced.length <= PROJECT_DIR_LENGTH_CAP) {
return replaced;
}
return `${replaced.slice(0, PROJECT_DIR_LENGTH_CAP)}-${hashSuffix(input)}`;
}
function hashSuffix(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
}
function resolveConfigDir(options?: ClaudeProjectDirOptions): string {
return options?.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
}