import os from "node:os"; import path from "node:path"; import { existsSync, readFileSync } from "node:fs"; import { spawn } from "node:child_process"; import type { CodexCliInfo, CodexModelOption, RuntimeSession } from "./types.js"; import { buildCodexExecCommand, buildCodexTakeoverCommand, resolveCodexInvocation } from "./runtime.js"; const fallbackModels = ["gpt-5.4", "gpt-5.2", "gpt-5.2-codex"]; const CODEX_INFO_CACHE_TTL_MS = 5 * 60 * 1000; let codexCliInfoCache: { value: CodexCliInfo; fetchedAt: number } | null = null; const stripQuotes = (value: string): string => value.replace(/^['"]|['"]$/g, ""); const quoteForDisplay = (value: string): string => `"${value.replace(/"/g, '\\"')}"`; const createConfigModelOption = (model: string, isDefault = false): CodexModelOption => ({ model, displayName: model, description: "从本机 Codex 配置读取到的模型。", defaultReasoningEffort: "medium", supportedReasoningEfforts: [], supportsPersonality: true, isDefault, hidden: false, source: "config" }); const mergeModelOptions = ( preferred: CodexModelOption[], fallback: CodexModelOption[], defaultModel: string ): CodexModelOption[] => { const merged = new Map(); for (const item of [...fallback, ...preferred]) { merged.set(item.model, { ...item, isDefault: item.model === defaultModel }); } if (!merged.has(defaultModel)) { merged.set(defaultModel, createConfigModelOption(defaultModel, true)); } return [...merged.values()].sort((left, right) => { if (left.model === defaultModel) { return -1; } if (right.model === defaultModel) { return 1; } if (left.isDefault && !right.isDefault) { return -1; } if (!left.isDefault && right.isDefault) { return 1; } return left.model.localeCompare(right.model); }); }; const readCodexCliInfoFromConfig = (): CodexCliInfo => { const configPath = path.join(os.homedir(), ".codex", "config.toml"); const info: CodexCliInfo = { defaultModel: "gpt-5.4", activeProfile: "", provider: "", contextWindow: null, autoCompactTokenLimit: null, suggestedModels: [...fallbackModels], availableModels: fallbackModels.map((model, index) => createConfigModelOption(model, index === 0)) }; if (!existsSync(configPath)) { return info; } const raw = readFileSync(configPath, "utf8"); let currentSection = ""; const discoveredModels = new Set(fallbackModels); for (const originalLine of raw.split(/\r?\n/)) { const line = originalLine.replace(/\s+#.*$/, "").trim(); if (!line) { continue; } const sectionMatch = line.match(/^\[(.+)\]$/); if (sectionMatch) { currentSection = sectionMatch[1]; continue; } const kvMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/); if (!kvMatch) { continue; } const key = kvMatch[1]; const value = stripQuotes(kvMatch[2].trim()); if (!currentSection) { if (key === "model") { info.defaultModel = value; discoveredModels.add(value); } if (key === "profile") { info.activeProfile = value; } if (key === "model_provider") { info.provider = value; } if (key === "model_context_window") { const parsed = Number(value); info.contextWindow = Number.isFinite(parsed) ? parsed : info.contextWindow; } if (key === "model_auto_compact_token_limit") { const parsed = Number(value); info.autoCompactTokenLimit = Number.isFinite(parsed) ? parsed : info.autoCompactTokenLimit; } } if (key === "model") { discoveredModels.add(value); } if (currentSection === "notice.model_migrations") { discoveredModels.add(stripQuotes(key)); discoveredModels.add(value); } } info.availableModels = mergeModelOptions( [], Array.from(new Set([info.defaultModel, ...discoveredModels])).map((model) => createConfigModelOption(model, model === info.defaultModel)), info.defaultModel ); info.suggestedModels = info.availableModels.map((item) => item.model); return info; }; type AppServerRpcResponse = | { id: number; result?: unknown; error?: { code?: number; message?: string } } | { method: string; params?: unknown }; const parseJsonLines = (buffer: string): AppServerRpcResponse[] => buffer .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => { try { return JSON.parse(line) as AppServerRpcResponse; } catch { return null; } }) .filter((item): item is AppServerRpcResponse => item !== null); const readModelsFromAppServer = async (): Promise => new Promise((resolve, reject) => { const invocation = resolveCodexInvocation(); const child = spawn(invocation.command, [...invocation.prefixArgs, "app-server"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); let stdout = ""; let stderr = ""; let settled = false; let modelListRequested = false; const timeoutHandle = setTimeout(() => { finish(new Error(stderr.trim() || "读取 Codex app-server 模型列表超时")); }, 15000); const finish = (value: CodexModelOption[] | Error) => { if (settled) { return; } settled = true; clearTimeout(timeoutHandle); child.kill(); if (value instanceof Error) { reject(value); } else { resolve(value); } }; child.stdout.on("data", (chunk) => { stdout += chunk.toString(); const messages = parseJsonLines(stdout); const initializeAck = messages.find((message) => "id" in message && message.id === 1); if (initializeAck && !modelListRequested) { modelListRequested = true; child.stdin.write( `${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "model/list", params: { limit: 100, includeHidden: false } })}\n` ); } const modelList = messages.find((message) => "id" in message && message.id === 2); if (modelList && "result" in modelList) { const data = Array.isArray((modelList.result as { data?: unknown[] })?.data) ? ((modelList.result as { data: Array<{ model?: string; displayName?: string; description?: string; defaultReasoningEffort?: string; supportedReasoningEfforts?: Array<{ reasoningEffort?: string; description?: string }>; supportsPersonality?: boolean; isDefault?: boolean; hidden?: boolean; }>; }).data || []) : []; const models: CodexModelOption[] = []; for (const item of data) { const model = typeof item.model === "string" ? item.model : ""; if (!model) { continue; } models.push({ model, displayName: typeof item.displayName === "string" && item.displayName ? item.displayName : model, description: typeof item.description === "string" ? item.description : "", defaultReasoningEffort: typeof item.defaultReasoningEffort === "string" && item.defaultReasoningEffort ? item.defaultReasoningEffort : "medium", supportedReasoningEfforts: Array.isArray(item.supportedReasoningEfforts) ? item.supportedReasoningEfforts .map((effort) => ({ reasoningEffort: typeof effort?.reasoningEffort === "string" && effort.reasoningEffort ? effort.reasoningEffort : "", description: typeof effort?.description === "string" ? effort.description : "" })) .filter((effort) => effort.reasoningEffort) : [], supportsPersonality: Boolean(item.supportsPersonality), isDefault: Boolean(item.isDefault), hidden: Boolean(item.hidden), source: "app-server" }); } finish(models); } if (modelList && "error" in modelList && modelList.error) { finish(new Error(modelList.error.message || "Codex app-server model/list 失败")); } }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", (error) => finish(error)); child.on("exit", (code) => { if (!settled && code !== 0) { finish(new Error(stderr.trim() || `Codex app-server 退出码 ${code}`)); } }); child.stdin.write( `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "agents-team-workbench", title: "Agents Team Workbench", version: "0.1.0" }, capabilities: { experimentalApi: true } } })}\n` ); }); export const getCodexCliInfo = (): CodexCliInfo => codexCliInfoCache?.value || readCodexCliInfoFromConfig(); export const refreshCodexCliInfo = async (force = false): Promise => { if (!force && codexCliInfoCache && Date.now() - codexCliInfoCache.fetchedAt < CODEX_INFO_CACHE_TTL_MS) { return codexCliInfoCache.value; } const base = readCodexCliInfoFromConfig(); try { const appServerModels = await readModelsFromAppServer(); const availableModels = mergeModelOptions(appServerModels, base.availableModels, base.defaultModel); const merged: CodexCliInfo = { ...base, availableModels, suggestedModels: availableModels.map((item) => item.model) }; codexCliInfoCache = { value: merged, fetchedAt: Date.now() }; return merged; } catch { codexCliInfoCache = { value: base, fetchedAt: Date.now() }; return base; } }; export const applyCodexConfigToSession = ( session: RuntimeSession, codex: CodexCliInfo, projectPath: string ): RuntimeSession => { const effectiveModel = session.modelSource === "session-override" && session.model ? session.model : codex.defaultModel; const resumeCommand = session.lastThreadId ? `codex resume ${quoteForDisplay(session.lastThreadId)} -m ${quoteForDisplay(effectiveModel)} -C ${quoteForDisplay(projectPath)}` : session.resumeCommand; const identityState = session.lastThreadId ? "resume_ready" : session.status === "blocked" || session.status === "offline" ? "needs_rebuild" : "fresh"; return { ...session, identityState, model: effectiveModel, lastCommand: buildCodexExecCommand(projectPath, `以 ${session.roleLabel} 身份接手当前项目上下文`, effectiveModel), takeoverCommand: buildCodexTakeoverCommand(projectPath, effectiveModel), resumeCommand }; };