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 8e4ad76a..3b83e75c 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; + } +}