import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { buildCodexLarkCliContext } from './lark-cli.js'; import { emitCodexEvent, emitStatus } from './codex-runner-events.js'; import { detectFeishuSkillKeys } from './feishu-skills.js'; const activeRuns = new Map(); const NON_ASCII_PATH_PATTERN = /[^\u0000-\u007F]/; const CODEXMOBILE_REPLY_INSTRUCTION = [ 'CodexMobile iOS/PWA 回复要求:最终回复必须简短、直接。', '除非用户明确要求,最终只写结果、关键链接、必要下一步;不要复述内部过程、命令日志或长篇验证细节。' ].join('\n'); async function ensureAsciiWorkingDirectory(projectPath) { if (process.platform !== 'win32' || !NON_ASCII_PATH_PATTERN.test(projectPath)) { return projectPath; } const resolved = path.resolve(projectPath); const driveRoot = path.parse(resolved).root || 'C:\\'; const aliasRoot = path.join(driveRoot, 'codex_project_aliases'); const aliasName = crypto.createHash('sha1').update(resolved.toLowerCase()).digest('hex'); const aliasPath = path.join(aliasRoot, aliasName); await fs.mkdir(aliasRoot, { recursive: true }); try { const stats = await fs.lstat(aliasPath); if (stats.isDirectory() || stats.isSymbolicLink()) { return aliasPath; } await fs.rm(aliasPath, { recursive: true, force: true }); } catch (error) { if (error.code !== 'ENOENT') { throw error; } } await fs.symlink(resolved, aliasPath, 'junction'); return aliasPath; } function mapPermissionMode(permissionMode) { if (permissionMode === 'bypassPermissions') { return { sandboxMode: 'danger-full-access', approvalPolicy: 'never' }; } if (permissionMode === 'acceptEdits') { return { sandboxMode: 'workspace-write', approvalPolicy: 'never' }; } return { sandboxMode: 'workspace-write', approvalPolicy: 'never' }; } function normalizeReasoningEffort(reasoningEffort) { const value = String(reasoningEffort || '').trim(); return ['minimal', 'low', 'medium', 'high', 'xhigh'].includes(value) ? value : undefined; } function isSpawnPermissionError(error) { return error?.code === 'EPERM' && String(error?.syscall || '').startsWith('spawn'); } function userFacingCodexError(error) { const message = String(error?.message || 'Codex task failed'); if (process.platform === 'win32' && isSpawnPermissionError(error)) { return [ 'Codex 执行器启动被 Windows 拒绝(spawn EPERM)。', '通常是后台服务从受限环境启动导致的,请重启正式服务后再试。' ].join(' '); } return message; } function codexErrorDiagnostics(error) { return { message: error?.message || '', code: error?.code || '', errno: error?.errno || '', syscall: error?.syscall || '', path: error?.path || '', spawnargs: Array.isArray(error?.spawnargs) ? error.spawnargs : [], cwd: process.cwd(), execPath: process.execPath, pathLength: String(process.env.Path || process.env.PATH || '').length }; } export async function runCodexTurn({ sessionId, draftSessionId, projectPath, message, model, reasoningEffort, permissionMode, turnId: providedTurnId }, emit) { const { Codex } = await import('@openai/codex-sdk'); const workingDirectory = await ensureAsciiWorkingDirectory(projectPath); const { sandboxMode, approvalPolicy } = mapPermissionMode(permissionMode); const feishuSkillKeys = detectFeishuSkillKeys(message); const normalizedReasoningEffort = normalizeReasoningEffort(reasoningEffort); const modelReasoningEffort = feishuSkillKeys.length && normalizedReasoningEffort === 'xhigh' ? 'low' : normalizedReasoningEffort; const larkCliContext = await buildCodexLarkCliContext(message).catch((error) => { console.warn('[lark-cli] Codex context disabled:', error.message); return { enabled: false, env: { ...process.env }, instruction: '' }; }); const abortController = new AbortController(); const turnId = providedTurnId || crypto.randomUUID(); const state = { hadAssistantText: false, failed: false, usage: null }; const run = { thread: null, abortController, turnId, sessionId: sessionId || draftSessionId || null, previousSessionId: draftSessionId || sessionId || null, startedAt: new Date().toISOString(), status: 'running' }; let currentSessionId = sessionId || null; let previousSessionId = draftSessionId || sessionId || null; let thread = null; try { if (larkCliContext.enabled && larkCliContext.env) { larkCliContext.env.CODEXMOBILE_TURN_ID = turnId; larkCliContext.env.CODEXMOBILE_SESSION_ID = sessionId || draftSessionId || ''; } const codex = new Codex({ env: larkCliContext.env || { ...process.env } }); const threadOptions = { workingDirectory, skipGitRepoCheck: true, sandboxMode, approvalPolicy, model, modelReasoningEffort, ...(larkCliContext.enabled ? { networkAccessEnabled: true } : {}) }; thread = sessionId ? codex.resumeThread(sessionId, threadOptions) : codex.startThread(threadOptions); currentSessionId = thread.id || sessionId || `codex-${Date.now()}`; run.thread = thread; run.sessionId = currentSessionId; activeRuns.set(turnId, run); emit({ type: 'chat-started', sessionId: currentSessionId, previousSessionId, turnId, projectPath, startedAt: new Date().toISOString() }); emitStatus(emit, { sessionId: currentSessionId, turnId, kind: 'reasoning', status: 'running', label: '正在思考' }); const codexInput = [message, CODEXMOBILE_REPLY_INSTRUCTION, larkCliContext.enabled ? larkCliContext.instruction : ''] .filter(Boolean) .join('\n\n'); const streamedTurn = await thread.runStreamed(codexInput, { signal: abortController.signal }); for await (const event of streamedTurn.events) { const threadId = event.thread_id || event.id || event.payload?.id; if (event.type === 'thread.started' && threadId) { const fromSessionId = previousSessionId || currentSessionId; if (threadId !== currentSessionId) { currentSessionId = threadId; run.sessionId = threadId; } previousSessionId = fromSessionId; run.previousSessionId = fromSessionId; emit({ type: 'thread-started', sessionId: threadId, previousSessionId: fromSessionId, turnId, projectPath, startedAt: new Date().toISOString() }); emitStatus(emit, { sessionId: threadId, turnId, kind: 'reasoning', status: 'running', label: '正在思考' }); continue; } if (run.status === 'aborted') { break; } emitCodexEvent(event, currentSessionId, turnId, emit, state); } if (!state.failed) { emit({ type: 'chat-complete', sessionId: currentSessionId, previousSessionId, turnId, usage: state.usage, hadAssistantText: state.hadAssistantText, completedAt: new Date().toISOString() }); } } catch (error) { const wasAborted = error?.name === 'AbortError' || String(error?.message || '').toLowerCase().includes('aborted') || activeRuns.get(turnId)?.status === 'aborted'; const userError = userFacingCodexError(error); emit({ type: wasAborted ? 'chat-aborted' : 'chat-error', sessionId: currentSessionId, turnId, error: wasAborted ? null : userError }); if (!wasAborted) { console.error('[codex] Chat error:', codexErrorDiagnostics(error)); emitStatus(emit, { sessionId: currentSessionId, turnId, kind: 'turn', status: 'failed', label: '任务失败', detail: userError }); } } finally { if (activeRuns.has(turnId)) { const activeRun = activeRuns.get(turnId); activeRun.status = activeRun.status === 'aborted' ? 'aborted' : 'completed'; activeRuns.delete(turnId); } } return currentSessionId; } function runMatchesIdentifier(run, identifier) { return ( Boolean(identifier) && (run.turnId === identifier || run.sessionId === identifier || run.previousSessionId === identifier) ); } export function abortCodexTurn(identifier) { const id = String(identifier || '').trim(); const runs = [...activeRuns.values()].filter( (run) => run.status === 'running' && runMatchesIdentifier(run, id) ); if (!runs.length) { return false; } for (const run of runs) { run.status = 'aborted'; run.abortController.abort(); } return true; } export function getActiveRuns() { return [...activeRuns.values()] .filter((run) => run.status === 'running') .map((run) => ({ sessionId: run.sessionId, previousSessionId: run.previousSessionId, startedAt: run.startedAt, status: run.status, turnId: run.turnId })); }