From ff875951d8b9b72b66c9578fe0bd2a7c6e5fecd3 Mon Sep 17 00:00:00 2001 From: bbsngg <37531019+bbsngg@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:59:18 -0700 Subject: [PATCH] Support non-ASCII project paths (Claude session listing + Codex header) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent fixes for projects whose filesystem path contains non-ASCII characters (e.g. a Chinese path). Fix 1 — Claude sessions could not be opened. dr-claw located session jsonl by joining ~/.claude/projects/, but the Claude CLI names those directories with a different, lossy scheme (every non-alphanumeric char -> '-'). For non-ASCII paths the two never matched, so getSessions() hit ENOENT and /session/ rendered blank. Rather than replicate the CLI's undocumented encoding (which would break on any CLI change and need a config/DB migration), discover the directories by the cwd recorded inside their jsonl files. New resolveClaudeProjectDirs() builds a reverse index (real path -> dir names) and is used by every consumer that previously joined the directory by name: getSessions / getSessionMessages / deleteSession / renameSession / deleteTrashedProject / reconcileClaudeSessionIndex (server/projects.js), getClaudeProjectDirs (server/project-token-usage.js), and the session token-usage route (server/index.js, which also drops a buggy inline encoder missing '.'). reconcileClaudeSessionIndex retries with a fresh index when the CLI just created a new directory; the index is invalidated together with the existing projectDirectoryCache. encodeProjectPath, the config file, the database and the frontend are unchanged; no migration. Fix 2 — Codex crashed with "failed to convert header to a str for header name 'x-codex-turn-metadata'". codex-cli embeds the workspace path verbatim into an HTTP header, and header values must be ASCII/ISO-8859-1 (codex-cli 0.139.0 is latest, so no version bump fixes it). codex-cli keeps the -C/--cd value verbatim and does not canonicalize symlinks, so we run Codex inside an ASCII-only symlink under ~/.dr-claw/codex-cwd/- that points at the real project dir. New resolveCodexWorkingDirectory() (server/utils/codexWorkingDir.js): ASCII paths pass through unchanged (zero behaviour change); non-ASCII paths get a stable, idempotent ASCII symlink; any failure falls back to the real path. queryCodex hands the SDK the shadow path but keeps the real path for session indexing and stage tags. normalizeComparablePath now resolves symlinks (best-effort realpath) so Codex sessions recorded under the shadow cwd still associate to the real project in getCodexSessions. Tests: server/__tests__/claude-dir-resolve.test.mjs and server/__tests__/codex-nonascii-path.test.mjs cover non-ASCII resolution, ASCII no-regression, multi-dir merge, cache invalidation, reconcile retry, Codex ASCII passthrough, symlink mapping, idempotence, and end-to-end session association. Full suite: 104 passing. Co-Authored-By: Claude Fable 5 --- server/__tests__/claude-dir-resolve.test.mjs | 214 ++++++++++++++ server/__tests__/codex-nonascii-path.test.mjs | 119 ++++++++ server/index.js | 42 +-- server/openai-codex.js | 7 +- server/project-token-usage.js | 16 +- server/projects.js | 278 ++++++++++++++---- server/utils/codexWorkingDir.js | 98 ++++++ 7 files changed, 681 insertions(+), 93 deletions(-) create mode 100644 server/__tests__/claude-dir-resolve.test.mjs create mode 100644 server/__tests__/codex-nonascii-path.test.mjs create mode 100644 server/utils/codexWorkingDir.js diff --git a/server/__tests__/claude-dir-resolve.test.mjs b/server/__tests__/claude-dir-resolve.test.mjs new file mode 100644 index 00000000..1a49c3c1 --- /dev/null +++ b/server/__tests__/claude-dir-resolve.test.mjs @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalDatabasePath = process.env.DATABASE_PATH; + +let tempRoot = null; + +async function loadTestModules() { + vi.resetModules(); + const projects = await import('../projects.js'); + const database = await import('../database/db.js'); + await database.initializeDatabase(); + return { projects, database }; +} + +// Mirrors how the Claude CLI currently derives ~/.claude/projects dir names. +// The resolver under test must NOT depend on this rule — it discovers +// directories through the cwd recorded in their jsonl files. +function cliEncode(projectPath) { + return projectPath.replace(/[^a-zA-Z0-9]/g, '-'); +} + +async function writeClaudeSessionFile({ dirName, sessionId, cwd, userMessage = 'Hello' }) { + const projectDir = path.join(tempRoot, '.claude', 'projects', dirName); + await mkdir(projectDir, { recursive: true }); + + const userUuid = `user-${sessionId}`; + const lines = [ + { + sessionId, + type: 'user', + uuid: userUuid, + parentUuid: null, + cwd, + timestamp: '2026-06-09T10:00:00.000Z', + message: { role: 'user', content: userMessage }, + }, + { + sessionId, + type: 'assistant', + uuid: `assistant-${sessionId}`, + parentUuid: userUuid, + cwd, + timestamp: '2026-06-09T10:00:05.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] }, + }, + ].map((entry) => JSON.stringify(entry)).join('\n'); + + const sessionFile = path.join(projectDir, `${sessionId}.jsonl`); + await writeFile(sessionFile, `${lines}\n`, 'utf8'); + return sessionFile; +} + +async function writeProjectConfig(config) { + const claudeDir = path.join(tempRoot, '.claude'); + await mkdir(claudeDir, { recursive: true }); + await writeFile(path.join(claudeDir, 'project-config.json'), JSON.stringify(config, null, 2), 'utf8'); +} + +describe('Claude CLI session directory resolution', () => { + beforeEach(async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-dir-resolve-')); + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db'); + }); + + afterEach(async () => { + vi.resetModules(); + + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + + if (originalDatabasePath === undefined) delete process.env.DATABASE_PATH; + else process.env.DATABASE_PATH = originalDatabasePath; + + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + it('lists sessions for a project whose path contains non-ASCII characters', async () => { + const projectPath = path.join(tempRoot, '项目', '子目录', 'demo'); + const cliDirName = cliEncode(projectPath); + // dr-claw's own encoding keeps the Chinese characters, so the project name + // never matches the CLI directory name. + const sessionId = 'a1b2c3d4-0000-4000-8000-aaaaaaaaaaaa'; + + await writeClaudeSessionFile({ dirName: cliDirName, sessionId, cwd: projectPath }); + + const { projects } = await loadTestModules(); + const projectName = projects.encodeProjectPath(projectPath); + expect(projectName).not.toBe(cliDirName); + + await writeProjectConfig({ + [projectName]: { manuallyAdded: true, originalPath: projectPath }, + }); + + const result = await projects.getSessions(projectName, 5, 0); + expect(result.total).toBe(1); + expect(result.sessions[0].id).toBe(sessionId); + }); + + it('reads session messages across resolved directories', async () => { + const projectPath = path.join(tempRoot, '项目', 'demo'); + const sessionId = 'a1b2c3d4-0000-4000-8000-bbbbbbbbbbbb'; + + await writeClaudeSessionFile({ + dirName: cliEncode(projectPath), + sessionId, + cwd: projectPath, + userMessage: 'Find my messages', + }); + + const { projects } = await loadTestModules(); + const projectName = projects.encodeProjectPath(projectPath); + await writeProjectConfig({ + [projectName]: { manuallyAdded: true, originalPath: projectPath }, + }); + + const messages = await projects.getSessionMessages(projectName, sessionId); + expect(messages.length).toBe(2); + expect(messages[0].message.content).toBe('Find my messages'); + }); + + it('still lists sessions for directory-named (ASCII) projects', async () => { + const projectPath = path.join(tempRoot, 'workspace', 'plain-project'); + const dirName = cliEncode(projectPath); + const sessionId = '11111111-2222-4333-8444-555555555555'; + + await writeClaudeSessionFile({ dirName, sessionId, cwd: projectPath }); + + const { projects } = await loadTestModules(); + // Auto-discovered projects are named after the CLI directory itself. + const result = await projects.getSessions(dirName, 5, 0); + expect(result.total).toBe(1); + expect(result.sessions[0].id).toBe(sessionId); + }); + + it('merges sessions from multiple directories that resolve to the same path', async () => { + const projectPath = path.join(tempRoot, '项目', 'demo'); + const sessionA = 'aaaaaaaa-1111-4111-8111-111111111111'; + const sessionB = 'bbbbbbbb-2222-4222-8222-222222222222'; + + // Two historic encodings of the same cwd (e.g. the CLI changed its naming rule) + await writeClaudeSessionFile({ dirName: cliEncode(projectPath), sessionId: sessionA, cwd: projectPath }); + await writeClaudeSessionFile({ dirName: `${cliEncode(projectPath)}-legacy`, sessionId: sessionB, cwd: projectPath }); + + const { projects } = await loadTestModules(); + const projectName = projects.encodeProjectPath(projectPath); + await writeProjectConfig({ + [projectName]: { manuallyAdded: true, originalPath: projectPath }, + }); + + const result = await projects.getSessions(projectName, 10, 0); + const ids = result.sessions.map((session) => session.id).sort(); + expect(ids).toEqual([sessionA, sessionB]); + }); + + it('discovers a directory created after the index was built once the cache is cleared', async () => { + const projectPath = path.join(tempRoot, '项目', 'fresh-project'); + const sessionId = 'cccccccc-3333-4333-8333-333333333333'; + + const { projects } = await loadTestModules(); + const projectName = projects.encodeProjectPath(projectPath); + await writeProjectConfig({ + [projectName]: { manuallyAdded: true, originalPath: projectPath }, + }); + + // First lookup: no CLI directory exists yet, builds an index without it + const before = await projects.getSessions(projectName, 5, 0); + expect(before.total).toBe(0); + + await writeClaudeSessionFile({ dirName: cliEncode(projectPath), sessionId, cwd: projectPath }); + + // The file watcher calls this when ~/.claude/projects changes + projects.clearProjectDirectoryCache(); + + const after = await projects.getSessions(projectName, 5, 0); + expect(after.total).toBe(1); + expect(after.sessions[0].id).toBe(sessionId); + }); + + it('reconciles a targeted session even when the directory is newer than the index', async () => { + const projectPath = path.join(tempRoot, '项目', 'reconcile-project'); + const sessionId = 'dddddddd-4444-4444-8444-444444444444'; + + const { projects, database } = await loadTestModules(); + const projectName = projects.encodeProjectPath(projectPath); + await writeProjectConfig({ + [projectName]: { manuallyAdded: true, originalPath: projectPath }, + }); + + // Build the index before the CLI creates the directory (stale-index scenario) + await projects.getSessions(projectName, 5, 0); + + await writeClaudeSessionFile({ dirName: cliEncode(projectPath), sessionId, cwd: projectPath }); + + // reconcileClaudeSessionIndex must retry with a fresh index instead of giving up + const result = await projects.reconcileClaudeSessionIndex(projectName, sessionId); + expect(result.session?.id).toBe(sessionId); + + const indexed = database.sessionDb.getSessionById(sessionId); + expect(indexed?.provider).toBe('claude'); + }); +}); diff --git a/server/__tests__/codex-nonascii-path.test.mjs b/server/__tests__/codex-nonascii-path.test.mjs new file mode 100644 index 00000000..b1f078f4 --- /dev/null +++ b/server/__tests__/codex-nonascii-path.test.mjs @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile, readlink, stat } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalDatabasePath = process.env.DATABASE_PATH; + +let tempRoot = null; + +async function loadModules() { + vi.resetModules(); + const codexWorkingDir = await import('../utils/codexWorkingDir.js'); + const projects = await import('../projects.js'); + const database = await import('../database/db.js'); + await database.initializeDatabase(); + return { codexWorkingDir, projects, database }; +} + +function hasNonAscii(p) { + return /[^\x00-\x7F]/.test(p); +} + +async function writeCodexRollout({ sessionId, cwd, userMessage = 'Hello', timestamp = '2026-06-09T11:00:00.000Z' }) { + const sessionFile = path.join( + tempRoot, '.codex', 'sessions', '2026', '06', '09', + `rollout-2026-06-09T11-00-00-${sessionId}.jsonl`, + ); + await mkdir(path.dirname(sessionFile), { recursive: true }); + const lines = [ + { timestamp, type: 'session_meta', payload: { id: sessionId, timestamp, cwd, model: 'gpt-5.5' } }, + { timestamp, type: 'event_msg', payload: { type: 'user_message', message: userMessage } }, + { timestamp, type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Hi' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + await writeFile(sessionFile, `${lines}\n`, 'utf8'); + return sessionFile; +} + +describe('Codex non-ASCII project path handling', () => { + beforeEach(async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-codex-nonascii-')); + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db'); + }); + + afterEach(async () => { + vi.resetModules(); + if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = originalUserProfile; + if (originalDatabasePath === undefined) delete process.env.DATABASE_PATH; else process.env.DATABASE_PATH = originalDatabasePath; + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + it('returns ASCII project paths unchanged without creating a symlink', async () => { + const { codexWorkingDir } = await loadModules(); + const asciiPath = path.join(tempRoot, 'workspace', 'plain-project'); + await mkdir(asciiPath, { recursive: true }); + + const result = await codexWorkingDir.resolveCodexWorkingDirectory(asciiPath); + expect(result).toBe(asciiPath); + + const shadowRoot = path.join(tempRoot, '.dr-claw', 'codex-cwd'); + await expect(stat(shadowRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('maps a non-ASCII project path to an ASCII symlink pointing at the real dir', async () => { + const { codexWorkingDir } = await loadModules(); + const realPath = path.join(tempRoot, '项目', '子目录', 'demo'); + await mkdir(realPath, { recursive: true }); + + const result = await codexWorkingDir.resolveCodexWorkingDirectory(realPath); + + expect(result).not.toBe(realPath); + expect(hasNonAscii(result)).toBe(false); + expect(codexWorkingDir.pathIsHeaderSafe(result)).toBe(true); + + const target = await readlink(result); + expect(path.resolve(path.dirname(result), target)).toBe(path.resolve(realPath)); + + // A marker created through the symlink lands in the real directory + await writeFile(path.join(result, 'marker.txt'), 'ok', 'utf8'); + await expect(stat(path.join(realPath, 'marker.txt'))).resolves.toBeTruthy(); + }); + + it('is idempotent and stable for the same path', async () => { + const { codexWorkingDir } = await loadModules(); + const realPath = path.join(tempRoot, '项目', 'demo'); + await mkdir(realPath, { recursive: true }); + + const first = await codexWorkingDir.resolveCodexWorkingDirectory(realPath); + const second = await codexWorkingDir.resolveCodexWorkingDirectory(realPath); + expect(second).toBe(first); + }); + + it('associates a Codex session recorded under the shadow symlink with the real project', async () => { + const { codexWorkingDir, projects } = await loadModules(); + const realPath = path.join(tempRoot, '项目', '子目录', 'demo'); + await mkdir(realPath, { recursive: true }); + + // The shadow symlink Codex would run inside + const shadowPath = await codexWorkingDir.resolveCodexWorkingDirectory(realPath); + expect(shadowPath).not.toBe(realPath); + + // Codex records the rollout with cwd = the shadow (symlink) path + const sessionId = 'a1b2c3d4-0000-4000-8000-cccccccccccc'; + await writeCodexRollout({ sessionId, cwd: shadowPath, userMessage: 'Inspect the project' }); + + // dr-claw queries by the REAL project path; realpath matching must connect them + const sessions = await projects.getCodexSessions(realPath, { limit: 10 }); + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe(sessionId); + expect(sessions[0].provider).toBe('codex'); + }); +}); diff --git a/server/index.js b/server/index.js index dfe400a7..ae2a0cda 100755 --- a/server/index.js +++ b/server/index.js @@ -42,7 +42,7 @@ import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; -import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; +import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, resolveClaudeProjectDirs, clearProjectDirectoryCache } from './projects.js'; import { getProjectTokenUsageSummary } from './project-token-usage.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getCursorSessionStartTime, getActiveCursorSessions } from './cursor-cli.js'; @@ -2835,29 +2835,31 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica return res.status(500).json({ error: 'Failed to determine project path' }); } - // Construct the JSONL file path - // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl - // The encoding replaces /, spaces, ~, and _ with - - const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-'); - const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); + // Locate the session JSONL among the CLI session directories for this project + const projectDirs = await resolveClaudeProjectDirs(projectName, projectPath); - const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); + let fileContent = null; + for (const projectDir of projectDirs) { + const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); - // Constrain to projectDir - const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); - if (rel.startsWith('..') || path.isAbsolute(rel)) { - return res.status(400).json({ error: 'Invalid path' }); - } + // Constrain to projectDir + const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + return res.status(400).json({ error: 'Invalid path' }); + } - // Read and parse the JSONL file - let fileContent; - try { - fileContent = await fsPromises.readFile(jsonlPath, 'utf8'); - } catch (error) { - if (error.code === 'ENOENT') { - return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); + try { + fileContent = await fsPromises.readFile(jsonlPath, 'utf8'); + break; + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; // Re-throw other errors to be caught by outer try-catch + } } - throw error; // Re-throw other errors to be caught by outer try-catch + } + + if (fileContent === null) { + return res.status(404).json({ error: 'Session file not found' }); } const lines = fileContent.trim().split('\n'); diff --git a/server/openai-codex.js b/server/openai-codex.js index 1fa818c8..549133ab 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -24,6 +24,7 @@ import { classifyError, classifySDKError } from '../shared/errorClassifier.js'; import { buildTempAttachmentFilename } from './utils/imageAttachmentFiles.js'; import { buildCodexRealtimeTokenBudget } from './utils/sessionTokenUsage.js'; import { expandSkillCommand } from './utils/skillExpander.js'; +import { resolveCodexWorkingDirectory } from './utils/codexWorkingDir.js'; import { CODEX_MODELS } from '../shared/modelConstants.js'; import { BTW_SYSTEM_PROMPT, buildBtwUserMessage } from './utils/btw.js'; @@ -367,6 +368,10 @@ export async function queryCodex(command, options = {}, ws) { } = options; const workingDirectory = cwd || projectPath || process.cwd(); + // Codex embeds this path verbatim into an HTTP header; non-ASCII paths crash + // the request. Hand the SDK an ASCII symlink while keeping the real path for + // dr-claw's own indexing/tags. ASCII paths are returned unchanged. + const codexWorkingDirectory = await resolveCodexWorkingDirectory(workingDirectory); const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode); let codex; @@ -393,7 +398,7 @@ export async function queryCodex(command, options = {}, ws) { // Thread options with sandbox and approval settings const threadOptions = { - workingDirectory, + workingDirectory: codexWorkingDirectory, skipGitRepoCheck: true, sandboxMode, approvalPolicy, diff --git a/server/project-token-usage.js b/server/project-token-usage.js index 3a77618e..a7433ae7 100644 --- a/server/project-token-usage.js +++ b/server/project-token-usage.js @@ -4,7 +4,7 @@ import os from 'os'; import path from 'path'; import readline from 'readline'; -import { encodeProjectPath, getCodexSessions, getGeminiSessions } from './projects.js'; +import { getCodexSessions, getGeminiSessions, resolveClaudeProjectDirs } from './projects.js'; const CACHE_TTL_MS = 5_000; @@ -80,15 +80,17 @@ function remapCurrentProjectPathToLegacy(projectPath) { ); } -function getClaudeProjectDirs(projectRef) { - const projectDirs = new Set(); +async function getClaudeProjectDirs(projectRef) { + const projectDirs = new Set( + await resolveClaudeProjectDirs(projectRef?.name || null, projectRef?.fullPath || null), + ); if (projectRef?.fullPath) { - projectDirs.add(path.join(os.homedir(), '.claude', 'projects', encodeProjectPath(projectRef.fullPath))); - const legacyProjectPath = remapCurrentProjectPathToLegacy(projectRef.fullPath); if (legacyProjectPath) { - projectDirs.add(path.join(os.homedir(), '.claude', 'projects', encodeProjectPath(legacyProjectPath))); + for (const projectDir of await resolveClaudeProjectDirs(null, legacyProjectPath)) { + projectDirs.add(projectDir); + } } } @@ -144,7 +146,7 @@ function getClaudeUsageSnapshot(entry) { async function summarizeClaudeProject(projectRef, bounds) { const totals = createEmptyUsageTotals(); - const projectDirs = getClaudeProjectDirs(projectRef); + const projectDirs = await getClaudeProjectDirs(projectRef); const jsonlFiles = ( await Promise.all(projectDirs.map((projectDir) => collectJsonlFiles(projectDir))) ).flat(); diff --git a/server/projects.js b/server/projects.js index 776e0ff7..3862b022 100755 --- a/server/projects.js +++ b/server/projects.js @@ -545,9 +545,18 @@ async function detectTaskMasterFolder(projectPath) { // Cache for extracted project directories const projectDirectoryCache = new Map(); +// Reverse index of Claude CLI session directories: real project path -> dir names +// under ~/.claude/projects. Built lazily, invalidated together with the directory cache. +let claudeDirIndexPromise = null; + +function invalidateClaudeDirIndex() { + claudeDirIndexPromise = null; +} + // Clear cache when needed (called when project files change) function clearProjectDirectoryCache() { projectDirectoryCache.clear(); + invalidateClaudeDirIndex(); } // Load project configuration file @@ -955,6 +964,65 @@ async function extractProjectDirectory(projectName) { } } +async function buildClaudeDirIndex() { + const claudeProjectsRoot = path.join(os.homedir(), '.claude', 'projects'); + const index = new Map(); + + let entries; + try { + entries = await fs.readdir(claudeProjectsRoot, { withFileTypes: true }); + } catch (_) { + return index; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const resolvedPath = await extractProjectDirectory(entry.name).catch(() => null); + if (!resolvedPath) { + continue; + } + const key = path.resolve(resolvedPath); + const dirNames = index.get(key) || []; + dirNames.push(entry.name); + index.set(key, dirNames); + } + + return index; +} + +// Resolve the Claude CLI session directories for a project. The CLI encodes the +// cwd into a directory name under ~/.claude/projects with its own lossy scheme +// that can change between CLI versions, so instead of recomputing that encoding +// we discover directories by the cwd recorded inside their jsonl files. Returns +// absolute paths; may be empty (no sessions yet) or contain several directories +// (historic encodings of the same path). +async function resolveClaudeProjectDirs(projectName, projectPath = null) { + const claudeProjectsRoot = path.join(os.homedir(), '.claude', 'projects'); + const dirNames = new Set(); + + // Projects discovered by scanning ~/.claude/projects are named after the + // directory itself, so a same-name directory always belongs to the project. + if (projectName && await pathExists(path.join(claudeProjectsRoot, projectName))) { + dirNames.add(projectName); + } + + const resolvedPath = projectPath + || (projectName ? await extractProjectDirectory(projectName).catch(() => null) : null); + if (resolvedPath) { + if (!claudeDirIndexPromise) { + claudeDirIndexPromise = buildClaudeDirIndex(); + } + const index = await claudeDirIndexPromise; + for (const dirName of index.get(path.resolve(resolvedPath)) || []) { + dirNames.add(dirName); + } + } + + return [...dirNames].map((dirName) => path.join(claudeProjectsRoot, dirName)); +} + async function mapWithConcurrency(items, concurrency, mapper) { const results = new Array(items.length); let nextIndex = 0; @@ -1153,24 +1221,38 @@ async function reconcileIndexedSessionFromSource(projectName, provider, parsedSe }); } +async function findClaudeSessionFile(projectDirs, sessionId) { + for (const projectDir of projectDirs) { + const candidate = path.join(projectDir, `${sessionId}.jsonl`); + if (await pathExists(candidate)) { + return candidate; + } + } + return null; +} + async function reconcileClaudeSessionIndex(projectName, targetSessionId = null) { if (targetSessionId) { - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - const sessionFile = path.join(projectDir, `${targetSessionId}.jsonl`); const { sessionDb } = await import('./database/db.js'); + const projectPath = await extractProjectDirectory(projectName).catch(() => null); - try { - await fs.access(sessionFile); - } catch (error) { - if (error?.code === 'ENOENT') { - return { sessions: [], hasMore: false, total: 0, session: null }; - } - throw error; + let projectDirs = await resolveClaudeProjectDirs(projectName, projectPath); + let sessionFile = await findClaudeSessionFile(projectDirs, targetSessionId); + + if (!sessionFile) { + // The CLI may have just created a brand-new directory for this project + // that isn't in the reverse index yet — rebuild once before giving up. + invalidateClaudeDirIndex(); + projectDirs = await resolveClaudeProjectDirs(projectName, projectPath); + sessionFile = await findClaudeSessionFile(projectDirs, targetSessionId); + } + + if (!sessionFile) { + return { sessions: [], hasMore: false, total: 0, session: null }; } const dbSessions = sessionDb.getSessionsByProject(projectName); const dbSessionMap = new Map(dbSessions.filter((session) => session.provider === 'claude').map((session) => [session.id, session])); - const projectPath = await extractProjectDirectory(projectName).catch(() => null); const result = await parseJsonlSessions(sessionFile, projectName, dbSessionMap); const session = (result.sessions || []).find((item) => item.id === targetSessionId) || null; @@ -1476,43 +1558,55 @@ async function getTrashedProjects(userId = null) { } async function getSessions(projectName, limit = 5, offset = 0, userId = null) { - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); const { sessionDb } = await import('./database/db.js'); try { - // Check if the project directory exists before trying to read it - try { - await fs.access(projectDir); - } catch (err) { - if (err.code === 'ENOENT') { - // No Claude sessions for this project yet, which is fine for manual projects - return { sessions: [], hasMore: false, total: 0 }; + const projectPath = await extractProjectDirectory(projectName).catch(() => null); + const projectDirs = await resolveClaudeProjectDirs(projectName, projectPath); + + // Collect session files from every directory the CLI may have used for this project + const jsonlFilePaths = []; + for (const projectDir of projectDirs) { + let files; + try { + files = await fs.readdir(projectDir); + } catch (err) { + if (err.code === 'ENOENT') { + continue; + } + throw err; + } + for (const file of files) { + if (file.endsWith('.jsonl') && !file.startsWith('agent-')) { + jsonlFilePaths.push(path.join(projectDir, file)); + } } - throw err; } - const files = await fs.readdir(projectDir); - const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); - - if (jsonlFiles.length === 0) { + if (jsonlFilePaths.length === 0) { + // No Claude sessions for this project yet, which is fine for manual projects return { sessions: [], hasMore: false, total: 0 }; } // Fetch indexed sessions from database - filter by userId? // Usually sessions inherit project ownership, but we store it anyway. - const dbSessions = sessionDb.getSessionsByProject(projectName); + // Placeholder rows written at spawn time are keyed by dr-claw's own encoding + // of the project path, which can differ from the name of a directory-derived + // project — query both keys. + const dbSessions = [...sessionDb.getSessionsByProject(projectName)]; + if (projectPath) { + const altProjectName = encodeProjectPath(projectPath); + if (altProjectName !== projectName) { + dbSessions.push(...sessionDb.getSessionsByProject(altProjectName)); + } + } const dbSessionMap = new Map(dbSessions.filter(s => s.provider === 'claude').map(s => [s.id, s])); - const projectPath = await extractProjectDirectory(projectName).catch(() => null); - - // ... (rest of getSessions remains mostly same, but ensures it uses the DB map correctly) - // Sort files by modification time (newest first) const filesWithStats = await Promise.all( - jsonlFiles.map(async (file) => { - const filePath = path.join(projectDir, file); + jsonlFilePaths.map(async (filePath) => { const stats = await fs.stat(filePath); - return { file, mtime: stats.mtime }; + return { filePath, mtime: stats.mtime }; }) ); filesWithStats.sort((a, b) => b.mtime - a.mtime); @@ -1522,9 +1616,8 @@ async function getSessions(projectName, limit = 5, offset = 0, userId = null) { const uuidToSessionMap = new Map(); // Collect all sessions and entries from all files - for (const { file } of filesWithStats) { - const jsonlFile = path.join(projectDir, file); - const result = await parseJsonlSessions(jsonlFile, projectName, dbSessionMap); + for (const { filePath } of filesWithStats) { + const result = await parseJsonlSessions(filePath, projectName, dbSessionMap); result.sessions.forEach(session => { if (!allSessions.has(session.id)) { @@ -2279,15 +2372,38 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = } } - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); + const projectDirs = await resolveClaudeProjectDirs(projectName); try { - const files = await fs.readdir(projectDir); + const jsonlFilePaths = []; // agent-*.jsonl files contain subagent tool history, handled separately below - const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); - const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-')); + const agentFileMap = new Map(); - if (jsonlFiles.length === 0) { + for (const projectDir of projectDirs) { + let files; + try { + files = await fs.readdir(projectDir); + } catch (err) { + if (err.code === 'ENOENT') { + continue; + } + throw err; + } + for (const file of files) { + if (!file.endsWith('.jsonl')) { + continue; + } + if (file.startsWith('agent-')) { + if (!agentFileMap.has(file)) { + agentFileMap.set(file, path.join(projectDir, file)); + } + } else { + jsonlFilePaths.push(path.join(projectDir, file)); + } + } + } + + if (jsonlFilePaths.length === 0) { return { messages: [], total: 0, hasMore: false }; } @@ -2295,8 +2411,7 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = const agentToolsCache = new Map(); // Process all JSONL files to find messages for this session - for (const file of jsonlFiles) { - const jsonlFile = path.join(projectDir, file); + for (const jsonlFile of jsonlFilePaths) { const fileStream = fsSync.createReadStream(jsonlFile); const rl = readline.createInterface({ input: fileStream, @@ -2326,9 +2441,8 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = } for (const agentId of agentIds) { - const agentFileName = `agent-${agentId}.jsonl`; - if (agentFiles.includes(agentFileName)) { - const agentFilePath = path.join(projectDir, agentFileName); + const agentFilePath = agentFileMap.get(`agent-${agentId}.jsonl`); + if (agentFilePath) { const tools = await parseAgentTools(agentFilePath); agentToolsCache.set(agentId, tools); } @@ -2523,17 +2637,31 @@ async function deleteSession(projectName, sessionId, provider = 'claude') { throw new Error(`Nano session ${sessionId} not found in file system or index`); } - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); + const projectDirs = await resolveClaudeProjectDirs(projectName); try { - const files = await fs.readdir(projectDir); - const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); + const jsonlFiles = []; + for (const projectDir of projectDirs) { + let files; + try { + files = await fs.readdir(projectDir); + } catch (err) { + if (err.code === 'ENOENT') { + continue; + } + throw err; + } + for (const file of files) { + if (file.endsWith('.jsonl')) { + jsonlFiles.push(path.join(projectDir, file)); + } + } + } let matchedFiles = 0; let removedEntries = 0; - for (const file of jsonlFiles) { - const jsonlFile = path.join(projectDir, file); + for (const jsonlFile of jsonlFiles) { const content = await fs.readFile(jsonlFile, 'utf8'); const lines = content.split('\n').filter(line => line.trim()); let fileRemovedEntries = 0; @@ -2579,7 +2707,7 @@ async function deleteSession(projectName, sessionId, provider = 'claude') { } catch (error) { if (error?.code === 'ENOENT' && indexedSession?.provider === 'claude') { sessionDb.deleteSession(sessionId); - console.log(`[Claude] Deleted session ${sessionId} from index only; project directory missing: ${projectDir}`); + console.log(`[Claude] Deleted session ${sessionId} from index only; session file missing for project ${projectName}`); return true; } console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error); @@ -2812,8 +2940,11 @@ async function deleteTrashedProject(projectName, mode = 'logical', userId = null } try { - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - await fs.rm(projectDir, { recursive: true, force: true }); + const projectDirs = await resolveClaudeProjectDirs(projectName, trashMeta.originalPath || null); + for (const projectDir of projectDirs) { + await fs.rm(projectDir, { recursive: true, force: true }); + } + invalidateClaudeDirIndex(); } catch (err) { console.warn(`Failed to delete Claude project dir for ${projectName}:`, err.message); } @@ -3595,7 +3726,20 @@ async function normalizeComparablePath(inputPath) { } const resolved = path.resolve(normalized); - return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + + // Resolve symlinks so paths compare by canonical identity. Codex sessions for + // non-ASCII projects are recorded under an ASCII shadow symlink (see + // utils/codexWorkingDir.js); realpath maps that back to the real project dir + // so the session still associates correctly. Best-effort: paths that don't + // exist (e.g. deleted projects) fall back to the lexically resolved path. + let canonical = resolved; + try { + canonical = await fs.realpath(resolved); + } catch (_) { + // keep `resolved` + } + + return process.platform === 'win32' ? canonical.toLowerCase() : canonical; } async function findCodexJsonlFiles(dir) { @@ -4286,27 +4430,30 @@ async function renameSession(projectName, sessionId, newSummary, provider = 'cla } // 3. Handle Claude sessions (JSONL) else { - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); + const projectDirs = await resolveClaudeProjectDirs(projectName); try { - // Check if project directory exists first - try { - await fs.access(projectDir); - } catch (e) { - console.error(`[Claude] Project directory not found: ${projectDir}`); - throw new Error(`Claude project directory not found: ${projectName}`); + const jsonlFiles = []; + for (const projectDir of projectDirs) { + let files; + try { + files = await fs.readdir(projectDir); + } catch (e) { + continue; + } + for (const file of files) { + if (file.endsWith('.jsonl') && !file.startsWith('agent-')) { + jsonlFiles.push(path.join(projectDir, file)); + } + } } - const files = await fs.readdir(projectDir); - const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); - if (jsonlFiles.length === 0) { throw new Error('No session files found for this project'); } // Check all JSONL files to find which one contains the session - for (const file of jsonlFiles) { - const jsonlFile = path.join(projectDir, file); + for (const jsonlFile of jsonlFiles) { const content = await fs.readFile(jsonlFile, 'utf8'); const lines = content.split('\n').filter(line => line.trim()); @@ -4363,6 +4510,7 @@ export { loadProjectConfig, saveProjectConfig, extractProjectDirectory, + resolveClaudeProjectDirs, clearProjectDirectoryCache, getCodexSessions, getGeminiSessions, diff --git a/server/utils/codexWorkingDir.js b/server/utils/codexWorkingDir.js new file mode 100644 index 00000000..9e96800e --- /dev/null +++ b/server/utils/codexWorkingDir.js @@ -0,0 +1,98 @@ +/** + * ASCII-safe working directory for the Codex CLI. + * + * HTTP header values must be ISO-8859-1 / ASCII. codex-cli embeds the workspace + * path verbatim into the `x-codex-turn-metadata` request header, so a project + * whose filesystem path contains non-ASCII characters (e.g. a Chinese path like + * `/Users/you/Documents/项目/demo`) makes the Codex backend reject the + * request with: + * + * UTF-8 encoding error: failed to convert header to a str for header name + * 'x-codex-turn-metadata' ... + * + * and the stream disconnects before completion ("Reconnecting... 5/5"). + * + * Codex keeps the `-C/--cd` working-directory value verbatim — it does NOT + * canonicalize symlinks (verified: the `` and `` it renders + * keep the symlink path). So we run Codex inside an ASCII-only symlink that points + * at the real project directory. The header stays ASCII while file operations + * still resolve to the real directory through the symlink. + * + * dr-claw keeps using the REAL project path for its own indexing/tags; only the + * path handed to the Codex SDK is swapped. `normalizeComparablePath` resolves + * symlinks so sessions Codex records under the symlink cwd still associate to the + * real project. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; + +// Anything outside printable ASCII would break the HTTP header. +export function pathIsHeaderSafe(p) { + return typeof p === 'string' && !/[^\x00-\x7F]/.test(p); +} + +// Prefer a stable home location; fall back to the temp dir if HOME itself is +// non-ASCII (which would defeat the purpose). +function getShadowRoot() { + const homeShadow = path.join(os.homedir(), '.dr-claw', 'codex-cwd'); + if (pathIsHeaderSafe(homeShadow)) { + return homeShadow; + } + return path.join(os.tmpdir(), 'dr-claw-codex-cwd'); +} + +function asciiSlug(name) { + const slug = (name || '').replace(/[^a-zA-Z0-9._-]/g, ''); + return slug || 'project'; +} + +/** + * Returns a working-directory path safe to send to the Codex CLI. + * - ASCII paths are returned unchanged (no symlink, zero behaviour change). + * - Non-ASCII paths get an ASCII symlink under the shadow root; the symlink path + * is returned. On any failure the real path is returned (no worse than today). + */ +export async function resolveCodexWorkingDirectory(realPath) { + if (!realPath || pathIsHeaderSafe(realPath)) { + return realPath; + } + + const resolvedReal = path.resolve(realPath); + const shadowRoot = getShadowRoot(); + const hash = crypto.createHash('sha1').update(resolvedReal).digest('hex').slice(0, 12); + const linkPath = path.join(shadowRoot, `${asciiSlug(path.basename(resolvedReal))}-${hash}`); + + // If even the shadow root can't be made ASCII, there's nothing we can do. + if (!pathIsHeaderSafe(linkPath)) { + return realPath; + } + + try { + await fs.mkdir(shadowRoot, { recursive: true }); + + // Reuse an existing, correct symlink; replace anything stale. + try { + const current = await fs.readlink(linkPath); + if (path.resolve(shadowRoot, current) === resolvedReal) { + return linkPath; + } + await fs.rm(linkPath, { force: true }); + } catch (err) { + if (err.code !== 'ENOENT') { + await fs.rm(linkPath, { force: true }).catch(() => {}); + } + } + + await fs.symlink(resolvedReal, linkPath); + return linkPath; + } catch (err) { + if (err.code === 'EEXIST') { + return linkPath; + } + console.warn('[codex] Failed to create ASCII shadow working dir, using real path:', err.message); + return realPath; + } +}