338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
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<string, CodexModelOption>();
|
|
|
|
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<string>(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<CodexModelOption[]> =>
|
|
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<CodexCliInfo> => {
|
|
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
|
|
};
|
|
};
|