import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { z } from "zod"; import type { CanvasSnapshot } from "../canvas/types.js"; import { logger } from "../utils/logger.js"; import { errorMessage, field, type JsonRecord } from "../utils/value.js"; import { CodexAppClient, CodexReportedError } from "./codex-client.js"; import { codexEventHistory } from "./codex-event-history.js"; import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js"; import type { CodexReasoningEffort, CodexSkillMetadata, CodexSkillSelector, CodexSkillsListEntry } from "./codex-protocol.js"; import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js"; type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; skill?: CodexSkillSelector; messageText?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void }; type CodexSkillDraftInput = { model?: string; effort?: CodexReasoningEffort } & ({ source: "conversation"; threadId: string } | { source: "canvas"; snapshot: CanvasSnapshot }); const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const skillDraftSchema = z.object({ name: z.string().trim().min(1).max(64).regex(skillNamePattern), displayName: z.string().trim().max(64), description: z.string().trim().min(1).max(1024).refine((value) => !/[<>]/.test(value)), instructions: z.string().trim().min(1).max(20000), shortDescription: z.string().trim().max(64).refine((value) => !value || value.length >= 25), defaultPrompt: z.string().trim().max(1024), }).strict().superRefine((draft, context) => { if (draft.defaultPrompt && !mentionsSkill(draft.defaultPrompt, draft.name)) context.addIssue({ code: "custom", path: ["defaultPrompt"], message: `默认提示词必须包含 $${draft.name}` }); }); const SKILL_DRAFT_OUTPUT_SCHEMA: JsonRecord = { type: "object", additionalProperties: false, required: ["name", "displayName", "description", "instructions", "shortDescription", "defaultPrompt"], properties: { name: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", minLength: 1, maxLength: 64 }, displayName: { type: "string", maxLength: 64 }, description: { type: "string", pattern: "^[^<>]+$", minLength: 1, maxLength: 1024 }, instructions: { type: "string", minLength: 1, maxLength: 20000 }, shortDescription: { anyOf: [{ type: "string", maxLength: 0 }, { type: "string", minLength: 25, maxLength: 64 }] }, defaultPrompt: { type: "string", maxLength: 1024 }, }, }; export type AgentSkillDraft = z.infer; export class CodexSkillLookupError extends Error { override name = "CodexSkillLookupError"; constructor(message: string, readonly statusCode: 400 | 404 | 409) { super(message); } } let codexQueue: Promise = Promise.resolve(); let codexApp: CodexAppClient | null = null; let codexAppStart: Promise | null = null; /** 仅表示最近主动加载/选择的线程;运行中的 turn 身份由 CodexAppClient 自己维护。 */ let loadedThreadId = ""; export { summarizeCodexThread } from "./codex-history.js"; /** 将 Codex turn 加入串行队列并等待执行完成。 */ export async function runCodexTurn(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) { if (!prompt.trim()) return; codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, lifecycleEmit, attachments, options)); await codexQueue; } /** 从当前对话或指定网页画布生成可编辑草稿,不写入 Skill 文件。 */ export async function generateCodexSkillDraft(emit: AgentEmit, cwd: string, input: CodexSkillDraftInput): Promise { const queued = codexQueue.catch(() => undefined).then(() => generateCodexSkillDraftNow(emit, cwd, input)); codexQueue = queued; return await queued; } /** 中断当前线程正在执行的 Codex turn。 */ export async function interruptCodexTurn(threadId?: string) { if (!codexApp) return false; return await codexApp.interruptCurrentTurn(threadId); } /** 回复当前 app-server 的待处理权限请求。 */ export async function resolveCodexApproval(requestId: string, decision: string) { return Boolean(codexApp?.resolveApproval(requestId, decision)); } /** 创建新的 Codex 线程并记录当前线程 ID。 */ export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) { const app = await getCodexApp(emit); const thread = await app.startThread(cwd, permissionMode, preheat); loadedThreadId = String(field(thread, "id") || ""); return thread; } /** 恢复指定 Codex 线程并返回聊天历史。 */ export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) { const app = await getCodexApp(emit); const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true, preheat); const history = await loadCodexHistory(emit, threadId, cwd); const supplementalItems = await codexEventHistory.readThread(threadId); return { thread, messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady }; } /** 查询当前工作空间中的 Codex 线程。 */ export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) { const app = await getCodexApp(emit); const result = await app.listThreads({ limit: options.limit || 40, sortKey: "updated_at", sortDirection: "desc", sourceKinds: ["cli", "vscode", "appServer", "exec"], cwd: options.cwd, ...(options.searchTerm ? { searchTerm: options.searchTerm } : {}), }); const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : []; return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null }; } /** 查询当前账号可用于新任务的 Codex 模型。 */ export async function listCodexModels(emit: AgentEmit) { return await (await getCodexApp(emit)).listModels(); } /** 查询当前工作空间的原生 Skill 列表。 */ export async function listCodexSkills(emit: AgentEmit, cwd: string, forceReload = false): Promise { const result = await (await getCodexApp(emit)).listSkills(cwd, forceReload); return result.data.find((entry) => samePath(entry.cwd, cwd)) || { cwd, skills: [], errors: [] }; } /** 从原生 Skill 列表中解析并校验浏览器提交的选择器。 */ export async function resolveCodexSkill(emit: AgentEmit, cwd: string, selector: CodexSkillSelector, requireEnabled = false): Promise { const name = String(selector?.name || ""); const requestedPath = String(selector?.path || ""); if (!name || !requestedPath || !path.isAbsolute(requestedPath)) throw new CodexSkillLookupError("Skill 选择无效", 400); const { skills } = await listCodexSkills(emit, cwd, true); const skill = skills.find((item) => item.name === name && samePath(item.path, requestedPath)); if (!skill) throw new CodexSkillLookupError("找不到指定 Skill,请刷新列表后重试", 404); if (requireEnabled && !skill.enabled) throw new CodexSkillLookupError("该 Skill 已停用,请先启用后再使用", 409); return skill; } /** 修改经过原生列表校验的 Skill 启用状态。 */ export async function configureCodexSkill(emit: AgentEmit, cwd: string, selector: CodexSkillSelector, enabled: boolean) { const skill = await resolveCodexSkill(emit, cwd, selector); const result = await (await getCodexApp(emit)).setSkillEnabled(skill.path, enabled); return { ...result, skill: { ...skill, enabled: result.effectiveEnabled } }; } /** 读取指定 Codex 线程及其聊天历史。 */ export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { const app = await getCodexApp(emit); const history = await loadCodexHistory(emit, threadId, cwd); const supplementalItems = await codexEventHistory.readThread(threadId); return { thread: summarizeCodexThread(history.thread), messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady }; } /** 归档指定 Codex 线程。 */ export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { const app = await getCodexApp(emit); try { await loadCodexThread(emit, threadId, cwd, false); } catch (error) { if (!isRecoverableThreadError(error)) throw error; await resumeLoadedThread(app, threadId, cwd, "request", false); } await app.archiveThread(threadId); app.clearPlanUpdates(threadId); await codexEventHistory.removeThread(threadId); if (loadedThreadId === threadId) loadedThreadId = ""; } /** 判断线程异常是否允许自动新建线程后重试。 */ export function isRecoverableThreadError(error: unknown) { return /thread not loaded|no rollout found/i.test(errorMessage(error)); } /** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */ async function runCodexTurnNow(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) { let files: string[] = []; try { options.onStart?.(); files = await writeAttachmentFiles(attachments); const app = await getCodexApp(options.appEmit || lifecycleEmit); let threadId = await ensureCodexThread(app, options, lifecycleEmit); options.onThread?.(threadId); try { await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText); } catch (error) { if (!isRecoverableThreadError(error)) throw error; lifecycleEmit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` }); loadedThreadId = ""; threadId = await ensureCodexThread(app, { cwd: options.cwd }, lifecycleEmit); options.onThread?.(threadId); await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText); } } catch (error) { logger.error("Codex turn failed", error); if (!(error instanceof CodexReportedError)) lifecycleEmit("agent_error", { message: errorMessage(error) }); } finally { options.onFinish?.(); await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined))); } } /** 恢复请求线程或创建新的 Codex 线程。 */ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) { if (options.threadId) { if (options.threadId === loadedThreadId) return loadedThreadId; try { await resumeLoadedThread(app, options.threadId, options.cwd, options.permissionMode || "request", true); return loadedThreadId; } catch (error) { if (!isRecoverableThreadError(error)) throw error; emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` }); loadedThreadId = ""; } } if (!loadedThreadId) { const thread = await app.startThread(options.cwd, options.permissionMode || "request"); loadedThreadId = String(field(thread, "id") || ""); } return loadedThreadId; } /** 从 app-server 读取线程并校验工作空间。 */ async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) { const app = await getCodexApp(emit); const result = await app.readThread(threadId, includeTurns); const thread = field(result, "thread") || {}; assertThreadWorkspace(thread, cwd); return thread; } async function generateCodexSkillDraftNow(emit: AgentEmit, cwd: string, input: CodexSkillDraftInput) { const app = await getCodexApp(emit); let threadId = ""; try { const thread = input.source === "conversation" ? await app.forkSkillDraftThread(input.threadId, cwd) : await app.startSkillDraftThread(cwd); threadId = String(field(thread, "id") || ""); const raw = await app.generateSkillDraft(threadId, skillDraftPrompt(input), SKILL_DRAFT_OUTPUT_SCHEMA, input.model, input.effort); let value: unknown; try { value = JSON.parse(raw); } catch { throw new Error("Codex 返回的 Skill 草稿不是有效 JSON"); } const parsed = skillDraftSchema.safeParse(value); if (!parsed.success) throw new Error("Codex 返回的 Skill 草稿格式不正确"); assertDraftHasNoSensitiveValues(parsed.data, input.source === "canvas" ? canvasPrivateValues(input.snapshot) : []); return parsed.data; } finally { if (threadId) await app.closeSkillDraftThread(threadId).catch((error) => logger.warn("Failed to release Skill draft thread", { threadId, error })); } } function skillDraftPrompt(input: CodexSkillDraftInput) { const source = input.source === "conversation" ? "从这个临时分支继承的完整对话中,识别已经实际完成且值得复用的稳定流程。不要总结本条提炼请求,也不要保留一次性的结论、错误排查过程或工具日志。" : `从下面经过清理的画布快照中,识别节点、连线和生成步骤所表达的可复用流程。不要把画布节点 ID 写进执行说明。\n\n画布快照:\n${JSON.stringify(canvasSkillSource(input.snapshot))}`; return [ "请生成一个可编辑的 Codex Skill 草稿。", source, "要求:", "- name 使用不超过 64 个字符的小写字母、数字和连字符,优先使用简短的动词短语。", "- description 同时说明能力和触发场景;所有何时使用的信息都写在这里。", "- instructions 只写另一个 Codex 真正需要的、可复用的命令式步骤、约束和输出要求,不写 YAML frontmatter。", "- shortDescription 写 25–64 个字符的人类可读短说明;没有合适内容时返回空字符串。", "- defaultPrompt 必须包含与 name 完全一致的 $skill-name 调用标记;没有合适内容时返回空字符串。", "- displayName 使用简洁的人类可读名称。", "- 不得输出 Token、API Key、密码、凭证、本地路径、媒体 URL、敏感 URL、临时错误、调试日志或一次性结果。", "只按 outputSchema 返回对象。", ].join("\n"); } const MAX_CANVAS_SKILL_NODES = 300; const MAX_CANVAS_SKILL_CONNECTIONS = 600; const MAX_CANVAS_SKILL_NODE_CHARS = 100_000; const MAX_CANVAS_SKILL_SOURCE_CHARS = 120_000; /** 只保留理解画布流程所需的信息,避免把媒体、外部地址、坐标和本地凭证送入草稿线程。 */ export function canvasSkillSource(snapshot: CanvasSnapshot): JsonRecord { const allNodes = prioritizedCanvasNodes(snapshot); const nodes = allNodes.slice(0, MAX_CANVAS_SKILL_NODES); const nodeRefs = new Map(allNodes.map((node, index) => [node.id, `node-${index + 1}`])); const title = cleanCanvasString(snapshot.title, nodeRefs); const source: JsonRecord & { nodes: JsonRecord[]; connections: Array<{ from: string; to: string }> } = { ...(title ? { title } : {}), nodes: [], connections: [], }; let truncated = nodes.length < allNodes.length; nodes.forEach((node, index) => { const metadata = { ...(node.metadata || {}) }; if (node.type !== "text") delete metadata.content; const cleanMetadata = sanitizeCanvasValue(metadata, nodeRefs); const nodeTitle = cleanCanvasString(node.title, nodeRefs); const summary = { ref: `node-${index + 1}`, type: node.type, ...(nodeTitle ? { title: nodeTitle } : {}) }; const candidate = { ...summary, ...(cleanMetadata && Object.keys(cleanMetadata as JsonRecord).length ? { metadata: cleanMetadata } : {}) }; if (canvasSourceFits({ ...source, nodes: [...source.nodes, candidate] }, MAX_CANVAS_SKILL_NODE_CHARS)) source.nodes.push(candidate); else if (canvasSourceFits({ ...source, nodes: [...source.nodes, summary] }, MAX_CANVAS_SKILL_NODE_CHARS)) (source.nodes.push(summary), truncated = true); else truncated = true; }); const includedRefs = new Set(source.nodes.map((node) => String(node.ref || ""))); const selectedNodeRefs = (snapshot.selectedNodeIds || []).flatMap((id) => nodeRefs.get(id) || []).filter((ref) => includedRefs.has(ref)); if (selectedNodeRefs.length) source.selectedNodeRefs = selectedNodeRefs; const connections = (snapshot.connections || []).flatMap(({ fromNodeId, toNodeId }) => { const from = nodeRefs.get(fromNodeId); const to = nodeRefs.get(toNodeId); return from && to && includedRefs.has(from) && includedRefs.has(to) ? [{ from, to }] : []; }); if (connections.length > MAX_CANVAS_SKILL_CONNECTIONS) truncated = true; connections.slice(0, MAX_CANVAS_SKILL_CONNECTIONS).forEach((connection) => { if (canvasSourceFits({ ...source, connections: [...source.connections, connection] }, MAX_CANVAS_SKILL_SOURCE_CHARS - 32)) source.connections.push(connection); else truncated = true; }); if (truncated) source.truncated = true; return source; } const sensitiveCanvasKey = /api.?key|token|secret|password|authorization|credential|storage.?key|(?:local|file).?path/i; const transientCanvasKey = /^(?:status|progress|errorDetails|taskId|createdAt|updatedAt|startedAt|completedAt)$/i; const canvasNodeReferenceKey = /^.*(?:Node|Group|Parent|Child|Root|Source|Target|PrimaryImage)Ids?$/i; const directLocalPath = /^(?:file:(?:\/\/)?|[a-z]:[\\/]|\\\\|\/(?!\/)(?=[^\s`'"“”<>]+\/))/i; const fileUrl = /\bfile:(?:\/\/)?[^\s`'"“”<>]+/gi; const inlineLocalPath = /(?]+|(?]+\/)[^\s`'"“”<>]+/giu; const credentialAssignment = /(?:api[_ -]?key|access[_ -]?(?:key|token)|connect[_ -]?token|token|secret|password|authorization|credential)\s*(?:[:=:]|为|是)\s*(?:bearer\s+)?[`'"“]?[A-Za-z0-9_./+\-=]{8,}/gi; const bearerToken = /\bbearer\s+[A-Za-z0-9._~+/=\-]{8,}/gi; const jwtToken = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; const knownApiToken = /\b(?:sk-[A-Za-z0-9_-]{12,}|(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16})\b/g; const transientIdentifier = /\b(?:task|job|request|generation|node)[_-](?:\d{4,}|[A-Fa-f0-9]{8,}|(?=[A-Za-z0-9_-]{12,}\b)(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]+)\b/gi; const webUrl = /\bhttps?:\/\/[^\s<>{}\[\]`'"“”]+/gi; function sanitizeCanvasValue(value: unknown, nodeRefs: Map, depth = 0): unknown { if (value === null || typeof value === "boolean" || typeof value === "number") return value; if (typeof value === "string") return cleanCanvasString(value, nodeRefs); if (depth >= 6) return undefined; if (Array.isArray(value)) return value.slice(0, 300).map((item) => sanitizeCanvasValue(item, nodeRefs, depth + 1)).filter((item) => item !== undefined); if (!value || typeof value !== "object") return undefined; const result: JsonRecord = {}; Object.entries(value as JsonRecord).forEach(([key, item]) => { if (sensitiveCanvasKey.test(key) || transientCanvasKey.test(key)) return; if (canvasNodeReferenceKey.test(key)) { const refs = (Array.isArray(item) ? item : [item]).flatMap((id) => typeof id === "string" ? nodeRefs.get(id) || [] : []); if (refs.length) result[key.replace(/Ids$/i, "Refs").replace(/Id$/i, "Ref")] = Array.isArray(item) ? [...new Set(refs)] : refs[0]; return; } const clean = sanitizeCanvasValue(item, nodeRefs, depth + 1); if (clean !== undefined) result[replaceCanvasNodeRefs(key, nodeRefs)] = clean; }); return result; } function cleanCanvasString(value: unknown, nodeRefs: Map) { const valueText = typeof value === "string" ? value.trim() : ""; if (!valueText || /^(?:data:|blob:|https?:\/\/|file:)/i.test(valueText) || directLocalPath.test(valueText)) return undefined; const text = replaceCanvasNodeRefs(valueText, nodeRefs) .replace(/\b(?:data:|blob:)[^\s`'"“”<>]+/gi, "[媒体地址已移除]") .replace(fileUrl, "[本地路径已移除]") .replace(webUrl, "[外部地址已移除]") .replace(inlineLocalPath, "[本地路径已移除]") .replace(credentialAssignment, "[敏感凭证已移除]") .replace(bearerToken, "[敏感凭证已移除]") .replace(jwtToken, "[敏感凭证已移除]") .replace(knownApiToken, "[敏感凭证已移除]") .trim(); return text.length > 12000 ? `${text.slice(0, 12000)}\n[内容已截断]` : text || undefined; } function replaceCanvasNodeRefs(value: string, nodeRefs: Map) { const nodeIds = [...nodeRefs.keys()].filter(Boolean).sort((left, right) => right.length - left.length); const nodeIdPattern = nodeIds.length ? new RegExp(nodeIds.map((id) => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g") : undefined; return nodeIdPattern ? value.replace(nodeIdPattern, (id) => nodeRefs.get(id) || id) : value; } function prioritizedCanvasNodes(snapshot: CanvasSnapshot) { const nodes = snapshot.nodes || []; const selectedIds = new Set(snapshot.selectedNodeIds || []); const relatedIds = new Set(); (snapshot.connections || []).forEach(({ fromNodeId, toNodeId }) => { if (selectedIds.has(fromNodeId)) relatedIds.add(toNodeId); if (selectedIds.has(toNodeId)) relatedIds.add(fromNodeId); }); return [ ...nodes.filter((node) => selectedIds.has(node.id)), ...nodes.filter((node) => !selectedIds.has(node.id) && relatedIds.has(node.id)), ...nodes.filter((node) => !selectedIds.has(node.id) && !relatedIds.has(node.id)), ]; } function canvasSourceFits(source: JsonRecord, limit: number) { return JSON.stringify(source).length <= limit; } function canvasPrivateValues(snapshot: CanvasSnapshot) { return [snapshot.projectId, snapshot.clientId, ...(snapshot.nodes || []).map((node) => node.id), ...(snapshot.connections || []).map((connection) => connection.id)] .filter((value): value is string => typeof value === "string" && value.length >= 6); } function patternMatches(pattern: RegExp, text: string) { pattern.lastIndex = 0; return pattern.test(text); } function mentionsSkill(prompt: string, name: string) { return new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(prompt); } export function assertDraftHasNoSensitiveValues(draft: AgentSkillDraft, privateValues: string[]) { const text = Object.values(draft).join("\n"); const localPath = /(?:^|[\s`'"“”((\[{,:;:])(?:file:(?:\/\/)?|[a-z]:[\\/]|\\\\|\/(?!\/))/im; const externalUrl = (text.match(webUrl) || []).length > 0; const hasPrivateValue = privateValues.some((value) => text.includes(value)); if (localPath.test(text) || /\b(?:data:|blob:)/i.test(text) || patternMatches(credentialAssignment, text) || patternMatches(bearerToken, text) || patternMatches(jwtToken, text) || patternMatches(knownApiToken, text) || patternMatches(transientIdentifier, text) || externalUrl || hasPrivateValue) { throw new Error("生成的 Skill 草稿包含外部地址、本地路径、敏感凭证或一次性标识,已拒绝返回"); } } /** 读取线程历史,并显式标记 Codex 是否已经物化 turns。 */ async function loadCodexHistory(emit: AgentEmit, threadId: string, cwd?: string) { try { return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true }; } catch (error) { if (/not materialized yet.*includeTurns/i.test(errorMessage(error))) return { thread: await loadCodexThread(emit, threadId, cwd, false), historyReady: false }; if (!isRecoverableThreadError(error)) throw error; const app = await getCodexApp(emit); const thread = await resumeLoadedThread(app, threadId, cwd, "request", false); try { return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true }; } catch (historyError) { if (/not materialized yet.*includeTurns/i.test(errorMessage(historyError))) return { thread, historyReady: false }; throw historyError; } } } /** 恢复线程并统一校验工作空间与进程内活动线程。 */ async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true, preheat = false) { const thread = await app.resumeThread(threadId, cwd, permissionMode, preheat); assertThreadWorkspace(thread, cwd); if (updateLoaded) loadedThreadId = String(field(thread, "id") || threadId); return thread; } /** 获取已启动的 Codex app-server 客户端。 */ async function getCodexApp(emit: AgentEmit) { if (codexApp) return codexApp; codexAppStart ||= CodexAppClient.start(emit, () => { codexApp = null; loadedThreadId = ""; }); try { codexApp = await codexAppStart; return codexApp; } finally { codexAppStart = null; } } /** 校验线程是否属于指定工作空间。 */ function assertThreadWorkspace(thread: unknown, cwd?: string) { if (!cwd || threadInWorkspace(thread, cwd)) return; throw new Error("该 Codex 会话不属于当前画布工作空间"); } /** 判断线程工作目录是否与当前工作空间一致。 */ function threadInWorkspace(thread: unknown, cwd: string) { const threadCwd = String(field(thread, "cwd") || ""); return Boolean(threadCwd && samePath(threadCwd, cwd)); } /** 比较跨平台绝对路径;Windows 路径不区分大小写。 */ function samePath(left: string, right: string) { const normalize = (value: string) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value); return normalize(left) === normalize(right); } /** 将图片附件写入临时文件供 Codex 读取。 */ async function writeAttachmentFiles(attachments: AgentAttachment[]) { return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile)); } /** 将单个 Data URL 图片附件写入临时文件。 */ async function writeAttachmentFile(item: AgentAttachment) { const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || []; if (!data) throw new Error(`图片附件无效:${item.name || "未命名图片"}`); const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`); await fs.writeFile(file, Buffer.from(data, "base64")); return file; } /** 根据图片 MIME 类型返回临时文件扩展名。 */ function imageExt(type = "") { if (type.includes("png")) return "png"; if (type.includes("webp")) return "webp"; return "jpg"; }