From fdecd75f94f687d08d0d8fd7096742f3381c6ee5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 13:42:24 +0700 Subject: [PATCH] Add Claude project-dir resolver matching SDK encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude's Agent SDK encodes a cwd into ~/.claude/projects/ 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. --- .../providers/claude/project-dir.test.ts | 109 ++++++++++++++++++ .../agent/providers/claude/project-dir.ts | 52 +++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/server/src/server/agent/providers/claude/project-dir.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/project-dir.ts diff --git a/packages/server/src/server/agent/providers/claude/project-dir.test.ts b/packages/server/src/server/agent/providers/claude/project-dir.test.ts new file mode 100644 index 000000000..e90e13aad --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/project-dir.test.ts @@ -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 `.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; +} + +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 { + await mkdir(path, { recursive: true }); + return path; +} diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts new file mode 100644 index 000000000..d8cc95e64 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -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/` 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 { + const canonical = await canonicalize(cwd); + const projectsRoot = join(resolveConfigDir(options), "projects"); + return join(projectsRoot, encode(canonical)); +} + +async function canonicalize(input: string): Promise { + 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"); +}