Spaces:
Running
Running
| 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 | |
| })); | |
| } | |