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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user