diff --git a/docs/CODEX_DESKTOP_SESSION_SYNC.md b/docs/CODEX_DESKTOP_SESSION_SYNC.md new file mode 100644 index 0000000..698a9b2 --- /dev/null +++ b/docs/CODEX_DESKTOP_SESSION_SYNC.md @@ -0,0 +1,56 @@ +## Codex Desktop Session Sync Notes + +### Background + +When a Codex session was created from the mobile/browser UI, the session could run normally, but it did not always appear in the Codex Desktop sidebar for the corresponding project. + +### Symptoms + +- A browser-created session was persisted under `~/.codex/sessions/...`. +- The same session was missing from the Codex Desktop sidebar, even though the session file existed. +- This was reproducible with Windows projects whose real path contained non-ASCII characters. + +### Findings + +1. The mobile UI uses `server/openai-codex.js` to start Codex SDK threads. +2. On Windows, non-ASCII project paths are redirected through an ASCII junction path such as `D:\codex_project_aliases\`. +3. The persisted `session_meta.payload.cwd` could therefore be written as the ASCII alias instead of the real project path. +4. Even when `cwd` was already correct, mobile-created sessions were still written with `session_meta.payload.source = "exec"`. +5. Codex Desktop sessions that were visible in the sidebar were written with `session_meta.payload.source = "vscode"`. + +### Root Cause + +There were two compatibility mismatches between mobile-created sessions and Codex Desktop sidebar indexing: + +- `session_meta.payload.cwd` could point to the temporary ASCII alias instead of the real workspace path. +- `session_meta.payload.source` was written as `exec`, while Codex Desktop-visible sessions used `vscode`. + +### Fix + +`server/openai-codex.js` now performs a post-start metadata sync for the persisted session file: + +- Wait for the session file to appear under `~/.codex/sessions`. +- Rewrite `session_meta.payload.cwd` back to the real requested project path when the persisted path matches the ASCII alias. +- Rewrite `session_meta.payload.source` to `vscode` for Codex Desktop compatibility. + +The runtime behavior of the Codex SDK is unchanged: + +- The SDK can still execute from the ASCII alias path when needed. +- Only the persisted session metadata is normalized for Codex Desktop. + +### Validation + +Use the following checks: + +1. Create a session from the mobile/browser UI. +2. Confirm that the new session file under `~/.codex/sessions/...` has: + - the real project path in `session_meta.payload.cwd` + - `session_meta.payload.source = "vscode"` +3. Refresh or restart Codex Desktop. +4. Confirm that the session appears in the sidebar under the correct project. + +### Operational Notes + +- Existing historical sessions may still need a one-time metadata migration if they were created before this fix. +- The runtime patch only affects newly created or resumed sessions after the updated server is deployed. +- The change is intentionally local to `server/openai-codex.js` and does not alter the project discovery logic elsewhere. diff --git a/docs/CODEX_DESKTOP_SESSION_SYNC.zh-CN.md b/docs/CODEX_DESKTOP_SESSION_SYNC.zh-CN.md new file mode 100644 index 0000000..4a0e32d --- /dev/null +++ b/docs/CODEX_DESKTOP_SESSION_SYNC.zh-CN.md @@ -0,0 +1,55 @@ +## Codex Desktop 会话同步说明 + +### 背景 + +当会话是从手机端或浏览器端创建时,Codex 实际可以正常运行,但这条会话不一定会出现在 Codex Desktop 对应项目的左侧线程列表里。 + +### 现象 + +- 浏览器新建的会话确实已经写入 `~/.codex/sessions/...` +- 但 Codex Desktop 左侧列表里看不到它 +- 在 Windows 非 ASCII 项目路径下,这个问题可以稳定复现 + +### 关键发现 + +1. 手机端会话是通过 `server/openai-codex.js` 调用 Codex SDK 创建的。 +2. Windows 下如果项目路径包含非 ASCII 字符,会先切到一个 ASCII junction 路径,例如 `D:\codex_project_aliases\`。 +3. 因此落盘时 `session_meta.payload.cwd` 可能被写成这个临时别名路径,而不是真实项目路径。 +4. 即使 `cwd` 本身就是对的,手机端新建的会话仍然会写成 `session_meta.payload.source = "exec"`。 +5. 在 Codex Desktop 左侧能正常显示的会话,`session_meta.payload.source` 则是 `vscode`。 + +### 根因 + +手机端新建会话和 Codex Desktop 左侧索引之间,至少存在两个兼容性差异: + +- `session_meta.payload.cwd` 可能落成 ASCII 别名路径,而不是实际工作目录 +- `session_meta.payload.source` 会被写成 `exec`,而 Codex Desktop 可见线程使用的是 `vscode` + +### 修复方式 + +现在 `server/openai-codex.js` 会在会话启动后,对落盘的会话文件做一次元数据回写: + +- 等待 `~/.codex/sessions` 里对应会话文件出现 +- 如果 `cwd` 落成了 ASCII 别名路径,就改回真实项目路径 +- 把 `session_meta.payload.source` 统一改成 `vscode`,提升 Codex Desktop 侧边栏兼容性 + +这个修复不会改变 Codex SDK 的实际运行方式: + +- 运行时仍然可以继续使用 ASCII 别名路径规避 Windows 非 ASCII 路径问题 +- 只是在落盘元数据这一层做兼容修正 + +### 验证步骤 + +1. 从手机端或浏览器端新建一个会话 +2. 检查 `~/.codex/sessions/...` 中对应文件的首条 `session_meta` +3. 确认: + - `cwd` 已经是真实项目路径 + - `source = "vscode"` +4. 刷新或重启 Codex Desktop +5. 确认左侧线程列表已经能在正确项目下看到该会话 + +### 运行经验 + +- 这次修复只保证“更新后的服务创建的新会话”自动兼容 +- 对于历史旧会话,必要时仍需要一次性元数据迁移 +- 整个修复被控制在 `server/openai-codex.js` 内部,没有扩散到项目发现逻辑或数据库层 diff --git a/upstream-overrides/claudecodeui-1.25.2/server/openai-codex.js b/upstream-overrides/claudecodeui-1.25.2/server/openai-codex.js index 863dcac..4bf2471 100644 --- a/upstream-overrides/claudecodeui-1.25.2/server/openai-codex.js +++ b/upstream-overrides/claudecodeui-1.25.2/server/openai-codex.js @@ -16,11 +16,16 @@ import { Codex } from '@openai/codex-sdk'; import crypto from 'crypto'; import { promises as fs } from 'fs'; +import os from 'os'; import path from 'path'; // Track active sessions const activeCodexSessions = new Map(); const CODEX_ONLY_HARDENED_MODE = process.env.CODEX_ONLY_HARDENED_MODE !== 'false'; +const CODEX_SESSION_SYNC_TIMEOUT_MS = 5000; +const CODEX_SESSION_SYNC_RETRY_MS = 250; +// Codex Desktop groups sidebar threads using metadata from session_meta. +const CODEX_APP_SESSION_SOURCE = 'vscode'; const NON_ASCII_PATH_PATTERN = /[^\u0000-\u007F]/; @@ -28,6 +33,19 @@ function containsNonAscii(value) { return typeof value === 'string' && NON_ASCII_PATH_PATTERN.test(value); } +function normalizeComparablePath(value) { + if (typeof value !== 'string' || !value.trim()) { + return ''; + } + + const normalized = path.normalize(value.trim()); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + async function ensureAsciiWorkingDirectory(projectPath) { if (process.platform !== 'win32' || !containsNonAscii(projectPath)) { return projectPath; @@ -60,6 +78,141 @@ async function ensureAsciiWorkingDirectory(projectPath) { return aliasPath; } +async function findCodexSessionFile(sessionId, currentDir = path.join(os.homedir(), '.codex', 'sessions')) { + let entries; + + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return null; + } + throw error; + } + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + const nestedMatch = await findCodexSessionFile(sessionId, fullPath); + if (nestedMatch) { + return nestedMatch; + } + continue; + } + + if (entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.includes(sessionId)) { + return fullPath; + } + } + + return null; +} + +async function rewriteCodexSessionMetadata( + sessionFilePath, + aliasWorkingDirectory, + requestedWorkingDirectory, + desiredSource = null +) { + const fileContent = await fs.readFile(sessionFilePath, 'utf8'); + const newline = fileContent.includes('\r\n') ? '\r\n' : '\n'; + const lines = fileContent.split(/\r?\n/); + const normalizedAliasPath = normalizeComparablePath(aliasWorkingDirectory); + const normalizedRequestedPath = normalizeComparablePath(requestedWorkingDirectory); + let foundSessionMeta = false; + let changed = false; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line.trim()) { + continue; + } + + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + if (entry.type !== 'session_meta' || !entry.payload) { + continue; + } + foundSessionMeta = true; + + const persistedPath = normalizeComparablePath(entry.payload.cwd); + if ( + normalizedRequestedPath && + persistedPath !== normalizedRequestedPath && + persistedPath === normalizedAliasPath + ) { + entry.payload.cwd = requestedWorkingDirectory; + changed = true; + } + + if (desiredSource && entry.payload.source !== desiredSource) { + entry.payload.source = desiredSource; + changed = true; + } + + if (!changed) { + return { found: true, updated: false }; + } + + lines[index] = JSON.stringify(entry); + await fs.writeFile(sessionFilePath, lines.join(newline), 'utf8'); + return { found: true, updated: true }; + } + + return { found: foundSessionMeta, updated: false }; +} + +async function syncCodexSessionMetadata( + sessionId, + aliasWorkingDirectory, + requestedWorkingDirectory, + desiredSource = null +) { + if (!sessionId) { + return false; + } + + const deadline = Date.now() + CODEX_SESSION_SYNC_TIMEOUT_MS; + while (Date.now() <= deadline) { + const sessionFilePath = await findCodexSessionFile(sessionId); + if (!sessionFilePath) { + await sleep(CODEX_SESSION_SYNC_RETRY_MS); + continue; + } + + try { + const syncResult = await rewriteCodexSessionMetadata( + sessionFilePath, + aliasWorkingDirectory, + requestedWorkingDirectory, + desiredSource + ); + + if (syncResult.updated) { + console.log( + '[Codex] Synced persisted session metadata:', + requestedWorkingDirectory, + desiredSource || '(source unchanged)', + 'for', + sessionId + ); + } + return syncResult.found; + } catch (error) { + console.warn('[Codex] Failed to sync persisted session metadata for', sessionId, error); + return false; + } + } + + console.warn('[Codex] Timed out waiting for persisted session metadata for', sessionId); + return false; +} + /** * Transform Codex SDK event to WebSocket message format * @param {object} event - SDK event @@ -250,6 +403,7 @@ export async function queryCodex(command, options = {}, ws) { let thread; let currentSessionId = sessionId; const abortController = new AbortController(); + let sessionMetadataSyncPromise = null; try { // Initialize Codex SDK @@ -273,6 +427,12 @@ export async function queryCodex(command, options = {}, ws) { // Get the thread ID currentSessionId = thread.id || sessionId || `codex-${Date.now()}`; + sessionMetadataSyncPromise = syncCodexSessionMetadata( + currentSessionId, + workingDirectory, + requestedWorkingDirectory, + CODEX_APP_SESSION_SOURCE + ); // Track the session activeCodexSessions.set(currentSessionId, { @@ -352,6 +512,14 @@ export async function queryCodex(command, options = {}, ws) { } } finally { + if (sessionMetadataSyncPromise) { + try { + await sessionMetadataSyncPromise; + } catch (error) { + console.warn('[Codex] Session metadata sync promise rejected for', currentSessionId, error); + } + } + // Update session status if (currentSessionId) { const session = activeCodexSessions.get(currentSessionId);