feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空

This commit is contained in:
mac
2026-09-11 09:06:41 +08:00
parent 0446d09b3b
commit a74576c3e1
28 changed files with 3217 additions and 155 deletions
+201
View File
@@ -12,6 +12,32 @@ const EXE_NAMES = process.platform === "win32"
const WIN_PROCESS_NAMES = EXE_NAMES.filter((n) => /\.exe$/i.test(n));
let lastDiscovery = null;
const GLOBAL_DICTATION_HIDE_SCRIPT = `
set hiddenCount to 0
tell application "System Events"
set targetProcesses to every process whose name is "ChatGPT" or name is "Codex"
repeat with p in targetProcesses
tell p
repeat with w in windows
try
set titleText to title of w as text
set roleText to role of w as text
set subroleText to subrole of w as text
set windowSize to size of w
set widthValue to item 1 of windowSize as integer
set heightValue to item 2 of windowSize as integer
if titleText is "Codex" and roleText is "AXWindow" and subroleText is "AXDialog" and widthValue >= 500 and widthValue <= 900 and heightValue >= 60 and heightValue <= 120 then
set position of w to {-10000, -10000}
set hiddenCount to hiddenCount + 1
end if
end try
end repeat
end tell
end repeat
end tell
return hiddenCount
`;
function existsFile(p) {
try {
return !!p && fs.statSync(p).isFile();
@@ -443,6 +469,179 @@ function codexRunning() {
return false;
}
function execFileText(file, args, options = {}) {
return new Promise((resolve, reject) => {
execFile(file, args, {
encoding: "utf-8",
windowsHide: true,
timeout: options.timeout === undefined
? 2200
: Math.max(1, Number(options.timeout) || 1),
signal: options.signal,
env: options.env || process.env,
}, (error, stdout) => {
if (error) {
error.stdout = stdout || error.stdout || "";
reject(error);
return;
}
resolve(String(stdout || ""));
});
});
}
function isCodexProcessCommand(command) {
const text = String(command || "").replace(/\s+/g, " ").trim();
if (!text || isOwnWindowsAppCandidate(text, "")) return false;
return /(?:^|[\\/])(?:Codex|ChatGPT)\.app[\\/]/i.test(text) ||
/(?:^|[\\/\s])(?:Codex|ChatGPT)(?:\.exe)?(?:\s|$)/i.test(text) ||
/WindowsApps[\\/]OpenAI\.(?:Codex|CodexBeta|ChatGPT-Desktop)_/i.test(text);
}
function hideGlobalDictationWindows(options = {}) {
if (process.platform !== "darwin") {
return Promise.resolve({
ok: false,
status: "unsupported-platform",
hidden: 0,
error: "native global dictation window cleanup is only available on macOS",
});
}
const run = options.execFile || execFile;
return new Promise((resolve) => {
run("osascript", ["-e", GLOBAL_DICTATION_HIDE_SCRIPT], {
encoding: "utf-8",
windowsHide: true,
timeout: Math.max(500, Number(options.timeoutMs) || 5000),
signal: options.signal,
}, (error, stdout, stderr) => {
const output = String(stdout || "").trim();
const hidden = /^\d+$/.test(output) ? Number(output) : 0;
if (error) {
resolve({
ok: false,
status: "native-command-failed",
hidden,
error: String((error && error.message) || stderr || error),
});
return;
}
resolve({
ok: true,
status: hidden ? "hidden" : "not-found",
hidden,
error: "",
});
});
});
}
async function debugPortOwner(port, options = {}) {
const value = Number(port);
if (!Number.isInteger(value) || value < 1 || value > 65535) {
return { verified: false, owned: false, reason: "invalid-port", processes: [] };
}
const signal = options.signal;
const totalTimeoutMs = Math.max(1, Number(options.timeoutMs) || 5000);
const deadline = Date.now() + totalTimeoutMs;
const commandOptions = (maxMs) => {
if (signal && signal.aborted) {
throw signal.reason instanceof Error ? signal.reason : new Error("debug port owner check aborted");
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
const error = new Error("debug port owner check timed out");
error.code = "ETIMEDOUT";
throw error;
}
return {
timeout: Math.max(1, Math.min(maxMs, remaining)),
signal,
};
};
if (process.platform === "darwin") {
try {
const output = await execFileText("lsof", [
"-nP",
"-a",
`-iTCP:${value}`,
"-sTCP:LISTEN",
"-Fp",
], commandOptions(2200));
const pids = [...new Set(
output.split(/\r?\n/).filter((line) => /^p\d+$/.test(line)).map((line) => line.slice(1)),
)];
const processes = [];
for (const pid of pids) {
try {
const command = (await execFileText(
"ps",
["-p", pid, "-o", "command="],
commandOptions(2200),
)).trim();
processes.push({ pid: Number(pid), command });
} catch {}
}
return {
verified: true,
owned: processes.some((item) => isCodexProcessCommand(item.command)),
reason: processes.length ? "process-inspected" : "listener-not-found",
processes,
};
} catch (e) {
return {
verified: false,
owned: false,
reason: String((e && e.message) || e),
processes: [],
};
}
}
if (process.platform === "win32") {
const script = [
"$port=[int]$env:TD_CODEX_DEBUG_PORT",
"$rows=@()",
"Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | ForEach-Object {",
" $p=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $_.OwningProcess) -ErrorAction SilentlyContinue",
" if($p){ $rows += [pscustomobject]@{ProcessId=$p.ProcessId;Name=$p.Name;ExecutablePath=$p.ExecutablePath;CommandLine=$p.CommandLine} }",
"}",
"$rows|ConvertTo-Json -Depth 4 -Compress",
].join(";");
for (const shell of ["powershell.exe", "pwsh.exe"]) {
try {
const output = (await execFileText(shell, [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
], {
...commandOptions(3500),
env: { ...process.env, TD_CODEX_DEBUG_PORT: String(value) },
})).trim();
const parsed = output ? JSON.parse(output) : [];
const rows = Array.isArray(parsed) ? parsed : [parsed];
const processes = rows.map((row) => ({
pid: Number(row.ProcessId) || 0,
command: [row.Name, row.ExecutablePath, row.CommandLine].filter(Boolean).join(" "),
}));
return {
verified: true,
owned: processes.some((item) => isCodexProcessCommand(item.command)),
reason: processes.length ? "process-inspected" : "listener-not-found",
processes,
};
} catch {}
}
return { verified: false, owned: false, reason: "process-inspection-failed", processes: [] };
}
return { verified: false, owned: false, reason: "unsupported-platform", processes: [] };
}
function windowsProcessRows() {
if (process.platform !== "win32") return [];
const escapedNames = WIN_PROCESS_NAMES.map((name) => "'" + name.replace(/'/g, "''") + "'").join(",");
@@ -532,6 +731,8 @@ module.exports = {
discoveryReport,
discoverySummary,
codexRunning,
debugPortOwner,
hideGlobalDictationWindows,
windowsProcessDiagnostics,
killCodex,
};
+54
View File
@@ -0,0 +1,54 @@
// TD工具箱自定义协议解析。保持纯 Node 实现,便于单独测试。
const { URL } = require("url");
const SCHEME = "tdtoolbox";
function trim(value) {
return String(value || "").trim();
}
function isHttpUrl(value) {
try {
const u = new URL(value);
return u.protocol === "https:" || u.protocol === "http:";
} catch {
return false;
}
}
function parseTdToolboxImport(rawUrl) {
let url;
try {
url = new URL(String(rawUrl || ""));
} catch {
return { ok: false, error: "深链格式无效" };
}
if (url.protocol !== `${SCHEME}:`) return { ok: false, error: "深链协议无效" };
if (url.host !== "v1" || url.pathname !== "/import") return { ok: false, error: "深链接口不存在" };
const q = url.searchParams;
const input = {
resource: trim(q.get("resource")),
app: trim(q.get("app")),
name: trim(q.get("name")),
endpoint: trim(q.get("endpoint")),
apiKey: trim(q.get("apiKey")),
model: trim(q.get("model")),
source: trim(q.get("source")),
sourceGroupId: trim(q.get("sourceGroupId")),
};
if (input.resource !== "provider") return { ok: false, error: "仅支持导入 provider 资源" };
if (input.app !== "codex") return { ok: false, error: "仅支持 Codex 分组导入" };
if (input.source !== "sub2api") return { ok: false, error: "仅支持 Sub2API 来源" };
if (!input.name || input.name.length > 80) return { ok: false, error: "分组名称无效" };
if (!isHttpUrl(input.endpoint)) return { ok: false, error: "API 地址必须是 http/https URL" };
if (!input.apiKey || input.apiKey.length > 4096) return { ok: false, error: "API 密钥无效" };
if (!input.model || input.model.length > 200) return { ok: false, error: "模型无效" };
if (!/^[A-Za-z0-9_-]{1,128}$/.test(input.sourceGroupId)) return { ok: false, error: "来源分组 ID 无效" };
input.name = input.name.replace(/[\r\n\0]/g, "");
return { ok: true, input };
}
module.exports = { SCHEME, parseTdToolboxImport };
+656
View File
@@ -0,0 +1,656 @@
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const KEEP_VISIBLE_KEY = "global-dictation-keep-visible";
const GLOBAL_DICTATION_ROUTE = "/global-dictation";
const DEFAULT_HTTP_TIMEOUT_MS = 1500;
const DEFAULT_OWNER_TIMEOUT_MS = 5000;
const DEFAULT_CLOSE_SETTLE_MS = 100;
const DEFAULT_CLOSE_RETRIES = 2;
const RETRYABLE_IO_CODES = new Set(["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"]);
let codexHome = path.join(os.homedir(), ".codex");
let watchedStateFile = "";
let guardLog = () => {};
let guardBusy = false;
let endpointOwnerVerifier = null;
const cleanupFlights = new Map();
function sleepSync(ms) {
if (!ms) return;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function retryIo(fn) {
const delays = [0, 10, 25, 50, 100, 200];
let lastError;
for (const delay of delays) {
if (delay) sleepSync(delay);
try {
return fn();
} catch (e) {
lastError = e;
if (!RETRYABLE_IO_CODES.has(e && e.code)) throw e;
}
}
throw lastError;
}
function setCodexDataDir(dir) {
if (typeof dir !== "string" || !dir.trim() || !path.isAbsolute(dir.trim())) {
throw new Error("Codex data directory must be an absolute path");
}
codexHome = path.resolve(dir.trim());
}
function setEndpointOwnerVerifier(verifier) {
endpointOwnerVerifier = typeof verifier === "function" ? verifier : null;
}
function stateFileFor(home = codexHome) {
return path.join(home, ".codex-global-state.json");
}
function acquireLock(file, waitMs = 600) {
const lockFile = file + ".td-dictation.lock";
const deadline = Date.now() + Math.max(0, waitMs);
while (true) {
try {
const fd = fs.openSync(lockFile, "wx", 0o600);
return { fd, lockFile };
} catch (e) {
if (!e || e.code !== "EEXIST") throw e;
try {
const ageMs = Date.now() - fs.statSync(lockFile).mtimeMs;
if (ageMs > 30000) {
fs.rmSync(lockFile, { force: true });
continue;
}
} catch {}
if (Date.now() >= deadline) return null;
sleepSync(25);
}
}
}
function releaseLock(lock) {
if (!lock) return;
try { fs.closeSync(lock.fd); } catch {}
try { fs.rmSync(lock.lockFile, { force: true }); } catch {}
}
function writeCompleteTemp(file, content, mode, label) {
const tmp = `${file}.${process.pid}.${Date.now()}.${label}.tmp`;
const fd = fs.openSync(tmp, "wx", mode || 0o600);
try {
fs.writeFileSync(fd, content);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
try { fs.chmodSync(tmp, mode || 0o600); } catch {}
return tmp;
}
function writeBackupOnce(file, content, mode) {
const backup = file + ".td-before-global-dictation.bak";
let tmp = "";
try {
tmp = writeCompleteTemp(backup, content, mode, "backup");
fs.linkSync(tmp, backup);
} catch (e) {
if (!e || e.code !== "EEXIST") throw e;
} finally {
if (tmp) {
try { fs.rmSync(tmp, { force: true }); } catch {}
}
}
return backup;
}
function isJsonWhitespaceByte(value) {
return value === 0x20 || value === 0x09 || value === 0x0a || value === 0x0d;
}
function patchKeepVisibleFalse(file, original) {
const text = original.toString("latin1");
const pattern = new RegExp(`"${KEEP_VISIBLE_KEY}"\\s*:\\s*true`, "g");
let match;
let valueOffset = -1;
while ((match = pattern.exec(text))) {
valueOffset = match.index + match[0].lastIndexOf("true");
}
if (valueOffset < 1 || !isJsonWhitespaceByte(original[valueOffset - 1])) {
return { ok: false, changed: false, reason: "unsafe-format" };
}
const patchOffset = valueOffset - 1;
let fd;
try {
fd = retryIo(() => fs.openSync(file, "r+"));
const current = retryIo(() => fs.readFileSync(fd));
if (!current.equals(original)) {
return { ok: false, changed: false, reason: "concurrent-write" };
}
const descriptorStat = fs.fstatSync(fd);
const pathStat = fs.statSync(file);
if (descriptorStat.dev !== pathStat.dev || descriptorStat.ino !== pathStat.ino) {
return { ok: false, changed: false, reason: "concurrent-write" };
}
const patch = Buffer.from("false", "ascii");
const written = fs.writeSync(fd, patch, 0, patch.length, patchOffset);
if (written !== patch.length) throw new Error("global dictation state short write");
fs.fsyncSync(fd);
} finally {
if (fd !== undefined) {
try { fs.closeSync(fd); } catch {}
}
}
const saved = retryIo(() => fs.readFileSync(file));
try {
const parsed = JSON.parse(saved.toString("utf8"));
if (parsed && parsed[KEEP_VISIBLE_KEY] === false) {
return { ok: true, changed: true, reason: "disabled" };
}
} catch {}
return { ok: false, changed: false, reason: "concurrent-write" };
}
function sleepAsync(ms, signal) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) {
reject(signal.reason instanceof Error ? signal.reason : abortError("request aborted"));
return;
}
const timer = setTimeout(done, Math.max(0, Number(ms) || 0));
function done() {
if (signal) signal.removeEventListener("abort", aborted);
resolve();
}
function aborted() {
clearTimeout(timer);
signal.removeEventListener("abort", aborted);
reject(signal.reason instanceof Error ? signal.reason : abortError("request aborted"));
}
if (signal) signal.addEventListener("abort", aborted, { once: true });
});
}
function createFileIfMissing(file, content, mode) {
let tmp = "";
try {
tmp = writeCompleteTemp(file, content, mode, "td-dictation-create");
try {
fs.linkSync(tmp, file);
} catch (e) {
if (e && e.code === "EEXIST") return false;
throw e;
}
const written = retryIo(() => fs.readFileSync(file));
return written.equals(content);
} finally {
if (tmp) {
try { fs.rmSync(tmp, { force: true }); } catch {}
}
}
}
function rewriteStateAtomically(file, original, state, mode) {
const next = Buffer.from(JSON.stringify(state), "utf8");
if (next.equals(original)) {
return { ok: true, changed: false, reason: "already-disabled" };
}
let tmp = "";
try {
const current = retryIo(() => fs.readFileSync(file));
if (!current.equals(original)) {
return { ok: false, changed: false, reason: "concurrent-write" };
}
tmp = writeCompleteTemp(file, next, mode, "td-dictation-atomic");
const beforeRename = retryIo(() => fs.readFileSync(file));
if (!beforeRename.equals(original)) {
return { ok: false, changed: false, reason: "concurrent-write" };
}
fs.renameSync(tmp, file);
tmp = "";
const saved = retryIo(() => fs.readFileSync(file));
const parsed = JSON.parse(saved.toString("utf8"));
if (parsed && parsed[KEEP_VISIBLE_KEY] === false) {
return { ok: true, changed: true, reason: "disabled-atomic" };
}
return { ok: false, changed: false, reason: "verification-failed" };
} finally {
if (tmp) {
try { fs.rmSync(tmp, { force: true }); } catch {}
}
}
}
function disableKeepVisible(options = {}) {
const home = options.codexHome ? path.resolve(options.codexHome) : codexHome;
const file = stateFileFor(home);
let lock;
try {
lock = acquireLock(file, options.lockWaitMs);
if (!lock) return { ok: false, changed: false, reason: "locked", file };
for (let attempt = 0; attempt < 5; attempt++) {
if (!fs.existsSync(file)) {
const next = Buffer.from(JSON.stringify({ [KEEP_VISIBLE_KEY]: false }, null, 2) + "\n", "utf8");
if (createFileIfMissing(file, next, 0o600)) {
return { ok: true, changed: true, reason: "created-disabled", file };
}
continue;
}
const original = retryIo(() => fs.readFileSync(file));
let state;
try {
state = JSON.parse(original.toString("utf8"));
} catch (e) {
return { ok: false, changed: false, reason: "invalid-json", error: e.message, file };
}
if (!state || typeof state !== "object" || Array.isArray(state)) {
return { ok: false, changed: false, reason: "invalid-state", file };
}
if (state[KEEP_VISIBLE_KEY] === false) {
return { ok: true, changed: false, reason: "already-disabled", file };
}
state[KEEP_VISIBLE_KEY] = false;
const mode = fs.statSync(file).mode & 0o777;
writeBackupOnce(file, original, mode);
const patched = patchKeepVisibleFalse(file, original);
if (patched.ok) return { ...patched, file };
if (patched.reason === "unsafe-format") {
const rewritten = rewriteStateAtomically(file, original, state, mode);
if (rewritten.ok) return { ...rewritten, file };
if (rewritten.reason === "concurrent-write") continue;
return { ...rewritten, file };
}
if (patched.reason === "concurrent-write") continue;
return { ...patched, file };
}
return { ok: false, changed: false, reason: "concurrent-write", file };
} catch (e) {
return {
ok: false,
changed: false,
reason: "write-failed",
error: String((e && e.message) || e),
file,
};
} finally {
releaseLock(lock);
}
}
function decodeRoute(value) {
let route = String(value || "").trim();
for (let i = 0; i < 2 && /%[0-9a-f]{2}/i.test(route); i++) {
try { route = decodeURIComponent(route); } catch { break; }
}
if (route && !route.startsWith("/")) route = "/" + route;
return route.toLowerCase();
}
function parseCodexPageUrl(rawUrl) {
try {
const url = new URL(String(rawUrl || ""));
const pathname = url.pathname.toLowerCase();
if (
url.protocol !== "app:" ||
url.hostname !== "-" ||
(pathname !== "/index.html" && pathname !== "/index.html/")
) return null;
return url;
} catch {}
return null;
}
function routeInfo(rawUrl) {
const url = parseCodexPageUrl(rawUrl);
if (!url) return { url: null, present: false, route: "" };
for (const [key, value] of url.searchParams.entries()) {
if (key.toLowerCase() === "initialroute") {
return { url, present: true, route: decodeRoute(value) };
}
}
return { url, present: false, route: "" };
}
function initialRoute(rawUrl) {
return routeInfo(rawUrl).route;
}
function isCodexMainTarget(target) {
if (!target || String(target.type || "").toLowerCase() !== "page") return false;
const info = routeInfo(target.url);
return !!info.url && !info.present;
}
function isGlobalDictationTarget(target) {
if (!target || String(target.type || "").toLowerCase() !== "page") return false;
const info = routeInfo(target.url);
return !!info.url && info.present && info.route === GLOBAL_DICTATION_ROUTE;
}
async function fetchWithTimeout(fetchImpl, url, timeoutMs, signal) {
return runWithTimeout(
(requestSignal) => fetchImpl(url, { signal: requestSignal }),
timeoutMs,
signal,
);
}
function abortError(message, code) {
const error = new Error(message);
error.name = "AbortError";
if (code) error.code = code;
return error;
}
async function runWithTimeout(task, timeoutMs, signal) {
const controller = new AbortController();
let timer;
let abortListener;
const interrupted = new Promise((_, reject) => {
timer = setTimeout(() => {
const error = abortError("request timed out", "ETIMEDOUT");
controller.abort(error);
reject(error);
}, Math.max(1, Number(timeoutMs) || DEFAULT_HTTP_TIMEOUT_MS));
if (signal) {
abortListener = () => {
const reason = signal.reason instanceof Error
? signal.reason
: abortError(signal.reason ? String(signal.reason) : "request aborted");
controller.abort(reason);
reject(reason);
};
if (signal.aborted) abortListener();
else signal.addEventListener("abort", abortListener, { once: true });
}
});
try {
return await Promise.race([
Promise.resolve().then(() => task(controller.signal)),
interrupted,
]);
} finally {
clearTimeout(timer);
if (signal && abortListener) signal.removeEventListener("abort", abortListener);
}
}
async function listTargets(port, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
return { available: false, status: "failed", targets: [], error: "fetch unavailable" };
}
try {
const { response, targets } = await runWithTimeout(
async (requestSignal) => {
const response = await fetchImpl(`http://127.0.0.1:${Number(port)}/json`, {
signal: requestSignal,
});
const targets = response && response.ok ? await response.json() : null;
return { response, targets };
},
options.timeoutMs || DEFAULT_HTTP_TIMEOUT_MS,
options.signal,
);
if (!response || !response.ok) {
return {
available: false,
status: "port-unavailable",
targets: [],
error: response ? `HTTP ${response.status || "error"}` : "empty response",
};
}
if (!Array.isArray(targets)) {
return { available: false, status: "failed", targets: [], error: "invalid target list" };
}
return { available: true, status: "available", targets, error: "" };
} catch (e) {
const aborted = !!(options.signal && options.signal.aborted);
const timedOut = !!(e && e.code === "ETIMEDOUT");
return {
available: false,
status: aborted ? "aborted" : timedOut ? "timeout" : "port-unavailable",
targets: [],
error: String((e && e.message) || e),
};
}
}
async function runCloseGlobalDictationTargets(port, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
const listed = Array.isArray(options.targets)
? { available: true, status: "available", targets: options.targets, error: "" }
: await listTargets(port, options);
const matches = listed.targets.filter(isGlobalDictationTarget);
const result = {
ok: listed.available,
status: listed.status,
available: listed.available,
matched: matches.length,
closed: 0,
remaining: listed.available ? matches.length : null,
targetIds: matches.map((target) => target.id).filter(Boolean),
errors: [],
};
if (!listed.available || typeof fetchImpl !== "function") {
result.status = listed.status || "port-unavailable";
if (listed.error) result.errors.push(listed.error);
return result;
}
if (!matches.length) {
result.status = "not-found";
result.remaining = 0;
return result;
}
if (!listed.targets.some(isCodexMainTarget)) {
const verifier = options.endpointOwnerVerifier || endpointOwnerVerifier;
let ownership = null;
if (typeof verifier === "function") {
try {
ownership = await runWithTimeout(
(requestSignal) => verifier(Number(port), {
signal: requestSignal,
timeoutMs: options.ownerTimeoutMs || DEFAULT_OWNER_TIMEOUT_MS,
}),
options.ownerTimeoutMs || DEFAULT_OWNER_TIMEOUT_MS,
options.signal,
);
} catch (e) {
ownership = {
verified: false,
owned: false,
reason: String((e && e.message) || e),
};
}
}
const owned = ownership === true || !!(ownership && ownership.owned);
if (!owned) {
result.ok = false;
result.status = ownership && ownership.verified ? "foreign-endpoint" : "unverified-endpoint";
result.errors.push((ownership && ownership.reason) || "Codex endpoint ownership could not be verified");
return result;
}
}
const closeResults = await Promise.all(matches.map(async (target) => {
if (!target.id) {
return { closed: false, error: "global dictation target has no id" };
}
try {
const response = await fetchWithTimeout(
fetchImpl,
`http://127.0.0.1:${Number(port)}/json/close/${encodeURIComponent(target.id)}`,
options.timeoutMs || DEFAULT_HTTP_TIMEOUT_MS,
options.signal,
);
if (response && response.ok) return { closed: true, error: "" };
return { closed: false, error: `failed to close target ${target.id}` };
} catch (e) {
return { closed: false, error: String((e && e.message) || e) };
}
}));
result.closed = closeResults.filter((item) => item.closed).length;
result.errors.push(...closeResults.map((item) => item.error).filter(Boolean));
if (result.errors.length) {
result.ok = false;
result.status = options.signal && options.signal.aborted ? "aborted" : "failed";
return result;
}
const settleMs = options.settleMs === undefined
? DEFAULT_CLOSE_SETTLE_MS
: Math.max(0, Number(options.settleMs) || 0);
if (settleMs) {
try {
await sleepAsync(settleMs, options.signal);
} catch (e) {
result.ok = false;
result.status = "aborted";
result.remaining = null;
result.errors.push(String((e && e.message) || e));
return result;
}
}
const verified = await listTargets(port, options);
if (!verified.available) {
result.ok = false;
result.status = verified.status === "aborted" ? "aborted" : "verification-failed";
result.remaining = null;
if (verified.error) result.errors.push(verified.error);
return result;
}
const remaining = verified.targets.filter(isGlobalDictationTarget);
result.remaining = remaining.length;
if (remaining.length) {
result.ok = false;
result.status = "still-present";
result.targetIds = remaining.map((target) => target.id).filter(Boolean);
return result;
}
result.status = "closed";
return result;
}
async function runCloseGlobalDictationWithRetry(port, options = {}) {
const retries = options.retries === undefined
? DEFAULT_CLOSE_RETRIES
: Math.max(0, Number(options.retries) || 0);
const retryDelayMs = options.retryDelayMs === undefined
? 120
: Math.max(0, Number(options.retryDelayMs) || 0);
let totalClosed = 0;
let lastResult = null;
for (let attempt = 0; attempt <= retries; attempt++) {
const attemptOptions = attempt === 0
? options
: { ...options, targets: undefined };
lastResult = await runCloseGlobalDictationTargets(port, attemptOptions);
totalClosed += lastResult.closed || 0;
if (lastResult.status !== "still-present") {
return { ...lastResult, closed: totalClosed, attempts: attempt + 1 };
}
if (attempt < retries && retryDelayMs) {
try {
await sleepAsync(retryDelayMs, options.signal);
} catch (e) {
return {
...lastResult,
ok: false,
status: "aborted",
closed: totalClosed,
remaining: null,
attempts: attempt + 1,
errors: [...lastResult.errors, String((e && e.message) || e)],
};
}
}
}
return { ...lastResult, closed: totalClosed, attempts: retries + 1 };
}
function closeGlobalDictationTargets(port, options = {}) {
const hasCustomOptions =
!!options.fetchImpl ||
!!options.signal ||
!!options.endpointOwnerVerifier ||
Array.isArray(options.targets) ||
options.timeoutMs !== undefined ||
options.ownerTimeoutMs !== undefined ||
options.settleMs !== undefined ||
options.retries !== undefined ||
options.retryDelayMs !== undefined;
if (hasCustomOptions) return runCloseGlobalDictationWithRetry(port, options);
const key = String(Number(port));
if (cleanupFlights.has(key)) return cleanupFlights.get(key);
const flight = runCloseGlobalDictationWithRetry(port, options).finally(() => {
if (cleanupFlights.get(key) === flight) cleanupFlights.delete(key);
});
cleanupFlights.set(key, flight);
return flight;
}
function enforceGuard() {
if (guardBusy) return;
guardBusy = true;
try {
const result = disableKeepVisible();
if (result.changed) guardLog("已关闭 Codex 全局听写栏常驻显示");
else if (!result.ok && result.reason !== "locked") {
guardLog(`关闭 Codex 全局听写栏常驻显示失败:${result.error || result.reason}`);
}
} finally {
guardBusy = false;
}
}
function startGuard(options = {}) {
stopGuard();
if (options.codexHome) setCodexDataDir(options.codexHome);
guardLog = typeof options.log === "function" ? options.log : () => {};
watchedStateFile = stateFileFor();
const initial = disableKeepVisible();
fs.watchFile(watchedStateFile, { interval: options.intervalMs || 2000, persistent: false }, enforceGuard);
return initial;
}
function stopGuard() {
if (watchedStateFile) {
try { fs.unwatchFile(watchedStateFile, enforceGuard); } catch {}
}
watchedStateFile = "";
guardLog = () => {};
}
module.exports = {
KEEP_VISIBLE_KEY,
GLOBAL_DICTATION_ROUTE,
setCodexDataDir,
setEndpointOwnerVerifier,
disableKeepVisible,
startGuard,
stopGuard,
isCodexMainTarget,
isGlobalDictationTarget,
closeGlobalDictationTargets,
_initialRoute: initialRoute,
_listTargets: listTargets,
_stateFileFor: stateFileFor,
};
+7
View File
@@ -64,11 +64,18 @@ function evaluate(manifest, appVersion, bundledHotVersion, hotBase) {
const newer = cmp(manifest.contentVersion, effective) > 0;
const minOk = cmp(appVersion, manifest.minAppVersion || "0") >= 0;
const needFull = !!manifest.requiresFull || !minOk;
const fullAppVersion = manifest.fullAppVersion || "";
// 兼容协议注册等必须由完整安装包完成的场景:
// 旧版先安装热更,热更生效后再引导安装同版本完整包。
const needFullAfterHot = !!manifest.requiresFullAfterHot &&
(!fullAppVersion || cmp(appVersion, fullAppVersion) < 0);
return {
hasHot: newer,
needFull: newer && needFull, // 有新内容但当前原生版本不够 → 必须下完整包
canHot: newer && minOk && !manifest.requiresFull,
needFullAfterHot,
contentVersion: manifest.contentVersion,
fullAppVersion: fullAppVersion || null,
minAppVersion: manifest.minAppVersion || null,
notes: manifest.notes || "",
mandatory: !!manifest.mandatory,
+37
View File
@@ -964,6 +964,42 @@ async function deleteRow(id) {
notify();
}
async function clearAllSessions() {
await ensureDb();
const rows = all("SELECT id, cwd, rollout_path FROM sessions");
const scopes = Array.from(new Set(rows.map((row) => row.cwd).filter(Boolean)));
for (const row of rows) {
deletedSessionIds.add(String(row.id || ""));
if (row.rollout_path) {
dirty.delete(row.rollout_path);
seenRollouts.delete(row.rollout_path);
}
}
queue.length = 0;
const snapshot = Buffer.from(db.export());
try {
db.run("BEGIN");
db.run("DELETE FROM sessions");
// 保留 pinned=1 的长期身份/偏好事实;清掉由会话产生的普通决策与项目摘要。
db.run("DELETE FROM core_decisions WHERE COALESCE(pinned,0)<>1");
db.run("DELETE FROM core");
db.run("COMMIT");
flushDbStrict();
} catch (e) {
try { db.run("ROLLBACK"); } catch {}
try { db.close(); } catch {}
db = new SQLmod.Database(snapshot);
throw e;
}
for (const scope of scopes) {
try { fs.rmSync(archivePath(scope), { force: true }); } catch {}
try { removeProjectCore(scope); } catch (e) { log("清空会话记忆块失败:" + (e.message || e)); }
try { removeGeneratedHandoff(scope); } catch (e) { log("清空会话交接文件失败:" + (e.message || e)); }
}
notify();
return { count: rows.length, scopes: scopes.length };
}
async function deleteSession(id, cwd) {
deletedSessionIds.add(String(id || ""));
await ensureDb();
@@ -1019,6 +1055,7 @@ module.exports = {
resummarize,
deleteRow,
deleteSession,
clearAllSessions,
handoffPath,
renderProjectCore,
writeProjectCore,
+177 -9
View File
@@ -15,6 +15,7 @@ let GLOBAL_STATE = path.join(CODEX_HOME, ".codex-global-state.json");
// Codex 桌面端侧栏现在优先读这个本地目录库,而不是直接扫 state_*.sqlite。
// 只改 threads / rollout / session_index 而不同步这里,重启后侧栏仍会空。
let LOCAL_CATALOG_DB = path.join(CODEX_HOME, "sqlite", "codex-dev.db");
const STATE_DB_UPSERT_BATCH_SIZE = 200;
function setCodexDataDir(dir) {
if (typeof dir !== "string" || !path.isAbsolute(dir)) throw new Error("Codex 数据目录必须是绝对路径");
@@ -351,7 +352,12 @@ function threadValue(record, target, column) {
function threadUpdateSet(columns, target, parameterized = false) {
return columns.filter((c) => c !== "id").map((c) => {
if (c === "model_provider") return `${c}=${parameterized ? "?" : sql(target)}`;
if (c === "has_user_event") return `${c}=CASE WHEN excluded.${c}=1 THEN 1 ELSE threads.${c} END`;
if (c === "has_user_event") {
const subagent = columns.includes("thread_source")
? "LOWER(COALESCE(excluded.thread_source,''))='subagent'"
: "0";
return `${c}=CASE WHEN ${subagent} THEN 0 WHEN excluded.${c}=1 THEN 1 ELSE threads.${c} END`;
}
if (["cwd", "first_user_message", "preview", "thread_source"].includes(c)) {
return `${c}=CASE WHEN COALESCE(excluded.${c},'')<>'' THEN excluded.${c} ELSE threads.${c} END`;
}
@@ -449,13 +455,21 @@ function readRolloutRecord(file, titles) {
if (!rec.first_user_message) rec.first_user_message = firstText(p.message || p.input).trim();
}
}
if (
String(rec.thread_source || "").toLowerCase() === "subagent"
|| (rec.source && typeof rec.source === "object" && rec.source.subagent)
|| (typeof rec.source === "string" && /"subagent"\s*:/.test(rec.source))
) {
rec.thread_source = "subagent";
rec.has_user_event = 0;
}
rec.updated_at_ms = Math.max(rec.updated_at_ms, rec.created_at_ms);
rec.title = cleanThreadTitle(titles[rec.id] || "", rec.first_user_message, 80) || "(无标题对话)";
rec.preview = cleanUserVisibleText(rec.first_user_message).replace(/\s+/g, " ").trim().slice(0, 200);
return rec.id ? rec : null;
}
function buildThreadUpsertSql(records, target, cols) {
function buildThreadUpsertSql(records, target, cols, opts = {}) {
const desired = threadColumns(cols);
const updateSet = threadUpdateSet(desired, target);
const lines = ["BEGIN;"];
@@ -465,12 +479,20 @@ function buildThreadUpsertSql(records, target, cols) {
`ON CONFLICT(id) DO UPDATE SET ${updateSet.join(",")};`
);
}
// 【增强】确保数据库中所有会话的 provider 都更新为目标(兜底逻辑)
lines.push(`UPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};`);
if (opts.updateAllProviders !== false) {
// 【增强】确保数据库中所有会话的 provider 都更新为目标(兜底逻辑)
lines.push(`UPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};`);
}
lines.push("COMMIT;");
return lines.join("\n") + "\n";
}
function chunksOf(items, size) {
const chunks = [];
for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
return chunks;
}
function updateProjectState(records, log = () => {}) {
if (!fs.existsSync(GLOBAL_STATE)) return { addedProjects: 0, removedProjectless: 0 };
let state;
@@ -580,6 +602,7 @@ function rebuildLocalThreadCatalog(records, target, log = () => {}) {
const seen = new Set();
for (const r of records) {
if (!r || !r.id || seen.has(r.id) || r.archived) continue;
if (String(r.thread_source || "").toLowerCase() === "subagent") continue;
const hasText = !!(r.first_user_message || r.preview || (r.title && r.title !== "(无标题对话)"));
const hasUser = !!r.has_user_event || hasText;
if (!hasUser) continue;
@@ -715,6 +738,7 @@ async function rebuildLocalThreadCatalogAsync(records, target, log = () => {}) {
const seen = new Set();
for (const r of records) {
if (!r || !r.id || seen.has(r.id) || r.archived) continue;
if (String(r.thread_source || "").toLowerCase() === "subagent") continue;
const hasText = !!(r.first_user_message || r.preview || (r.title && r.title !== "(无标题对话)"));
if (!(r.has_user_event || hasText)) continue;
seen.add(r.id);
@@ -823,6 +847,134 @@ function forceHasUserEventSql(userEventIds) {
.join("\n");
}
function forceSubagentNoUserEventSql(columns) {
if (!columns || !columns.has("has_user_event")) return "";
const checks = [];
if (columns.has("thread_source")) checks.push("LOWER(COALESCE(thread_source,''))='subagent'");
if (columns.has("source")) checks.push("LOWER(COALESCE(source,'')) LIKE '%\"subagent\"%'");
if (!checks.length) return "";
return `UPDATE threads SET has_user_event=0 WHERE COALESCE(has_user_event,0)<>0 AND (${checks.join(" OR ")});`;
}
function backupSqlite3(dbPath, suffix) {
const backupDir = path.join(CODEX_HOME, "backups_state", "visibility-repair");
fs.mkdirSync(backupDir, { recursive: true });
const backupPath = path.join(backupDir, `${path.basename(dbPath)}.${suffix}.bak`);
const escaped = backupPath.replace(/'/g, "''");
runSqlite3(dbPath, `.timeout 5000\n.backup '${escaped}'\n`);
return backupPath;
}
// 旧版本曾把 subagent 当成顶层用户会话写入 state/catalog。这里做轻量幂等修复,
// 不扫描或改写大体积 rollout;sqlite3 可用时允许与 Codex 的 WAL 连接并发事务。
// Windows 无 sqlite3 CLI 且 Codex 正在运行时不冒险整库覆盖,等下次 Codex 退出后再修。
async function repairLegacySessionVisibility(log = () => {}) {
const stateDbNames = (() => {
try { return fs.readdirSync(CODEX_HOME).filter((name) => /^state_.*\.sqlite$/.test(name)); } catch { return []; }
})();
const suffix = ts();
const result = { stateRows: 0, catalogRows: 0, backups: [], skipped: false };
if (sqlite3Available()) {
for (const name of stateDbNames) {
const dbPath = path.join(CODEX_HOME, name);
const cols = sqliteCols(dbPath);
const clearSql = forceSubagentNoUserEventSql(cols);
if (!clearSql) continue;
const before = Number(runSqlite3(
dbPath,
`SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>0 AND (` +
`${cols.has("thread_source") ? "LOWER(COALESCE(thread_source,''))='subagent'" : "0"}` +
`${cols.has("source") ? `${cols.has("thread_source") ? " OR " : ""}LOWER(COALESCE(source,'')) LIKE '%\"subagent\"%'` : ""}` +
");\n"
).trim() || 0);
if (!before) continue;
result.backups.push(backupSqlite3(dbPath, suffix));
runSqlite3(dbPath, `.timeout 5000\nBEGIN IMMEDIATE;\n${clearSql}\nCOMMIT;\n`);
result.stateRows += before;
}
if (fs.existsSync(LOCAL_CATALOG_DB)) {
const catalogCols = new Set(
runSqlite3(LOCAL_CATALOG_DB, "PRAGMA table_info(local_thread_catalog);\n")
.split("\n").map((line) => line.split("|")[1]).filter(Boolean)
);
if (catalogCols.has("thread_source")) {
const before = Number(runSqlite3(
LOCAL_CATALOG_DB,
"SELECT COUNT(*) FROM local_thread_catalog WHERE LOWER(COALESCE(thread_source,''))='subagent';\n"
).trim() || 0);
if (before) {
result.backups.push(backupSqlite3(LOCAL_CATALOG_DB, suffix));
runSqlite3(
LOCAL_CATALOG_DB,
".timeout 5000\nBEGIN IMMEDIATE;\n" +
"DELETE FROM local_thread_catalog WHERE LOWER(COALESCE(thread_source,''))='subagent';\n" +
"UPDATE local_thread_catalog_metadata SET catalog_revision=COALESCE(catalog_revision,0)+1 WHERE id=1;\n" +
"COMMIT;\n"
);
result.catalogRows = before;
}
}
}
if (result.stateRows || result.catalogRows) {
log(`启动修复会话目录:state ${result.stateRows} 条,侧栏移除 subagent ${result.catalogRows} 条`);
}
return result;
}
if (codexRunning()) {
result.skipped = true;
result.reason = "Codex 正在运行且系统没有 sqlite3,已延后到下次启动";
log(`启动修复会话目录跳过:${result.reason}`);
return result;
}
const SQL = await loadSqlJs();
for (const name of stateDbNames) {
const dbPath = path.join(CODEX_HOME, name);
const db = new SQL.Database(fs.readFileSync(dbPath));
try {
const cols = sqlJsTableColumns(db, "threads");
const clearSql = forceSubagentNoUserEventSql(cols);
if (!clearSql) continue;
db.run(clearSql);
const changed = db.getRowsModified();
if (!changed) continue;
const backupPath = `${dbPath}.${suffix}.bak`;
fs.copyFileSync(dbPath, backupPath);
result.backups.push(backupPath);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
result.stateRows += changed;
} finally {
db.close();
}
}
if (fs.existsSync(LOCAL_CATALOG_DB)) {
const db = new SQL.Database(fs.readFileSync(LOCAL_CATALOG_DB));
try {
const cols = sqlJsTableColumns(db, "local_thread_catalog");
if (cols.has("thread_source")) {
db.run("DELETE FROM local_thread_catalog WHERE LOWER(COALESCE(thread_source,''))='subagent'");
const changed = db.getRowsModified();
if (changed) {
try { db.run("UPDATE local_thread_catalog_metadata SET catalog_revision=COALESCE(catalog_revision,0)+1 WHERE id=1"); } catch {}
const backupPath = `${LOCAL_CATALOG_DB}.${suffix}.bak`;
fs.copyFileSync(LOCAL_CATALOG_DB, backupPath);
result.backups.push(backupPath);
fs.writeFileSync(LOCAL_CATALOG_DB, Buffer.from(db.export()));
result.catalogRows = changed;
}
}
} finally {
db.close();
}
}
if (result.stateRows || result.catalogRows) {
log(`启动修复会话目录:state ${result.stateRows} 条,侧栏移除 subagent ${result.catalogRows} 条`);
}
return result;
}
function sqlJsTableColumns(db, table) {
const result = db.exec(`PRAGMA table_info("${String(table).replace(/"/g, '""')}")`);
if (!result.length) return new Set();
@@ -921,6 +1073,10 @@ function upsertStateDbSqlJs(db, records, target, userEventIds, cwdById, log = ()
db.run("UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1", [id]);
ueRows += db.getRowsModified();
}
const checks = [];
if (cols.has("thread_source")) checks.push("LOWER(COALESCE(thread_source,''))='subagent'");
if (cols.has("source")) checks.push("LOWER(COALESCE(source,'')) LIKE '%\"subagent\"%'");
if (checks.length) db.run(`UPDATE threads SET has_user_event=0 WHERE COALESCE(has_user_event,0)<>0 AND (${checks.join(" OR ")})`);
}
if (cols.has("cwd")) {
for (const [id, cwd] of Object.entries(cwdById || {})) {
@@ -986,8 +1142,6 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
continue;
}
if (!lines.length) continue;
const blob = lines.join("");
const hasUE = blob.includes('"user_message"') || blob.includes('"user_input"');
let tid = null;
let rewrote = false;
for (let i = 0; i < lines.length; i++) {
@@ -1015,10 +1169,10 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
fs.writeFileSync(file, lines.join(""), "utf-8");
changedFiles++;
}
if (tid && hasUE) userEventIds.add(tid);
const rec = readRolloutRecord(file, titles);
if (rec) {
rec.model_provider = target;
if (rec.has_user_event) userEventIds.add(rec.id);
records.push(rec);
}
}
@@ -1063,9 +1217,17 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
const beforeProvider = Number(runSqlite3(dbPath, `SELECT COUNT(*) FROM threads WHERE COALESCE(model_provider,'')<>${sql(target)};\n`).trim() || 0);
const beforeUserEvent = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>1;\n").trim() || 0);
const beforeRows = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads;\n").trim() || 0);
runSqlite3(dbPath, buildThreadUpsertSql(records, target, cols));
let batchDone = 0;
for (const batch of chunksOf(records, STATE_DB_UPSERT_BATCH_SIZE)) {
batchDone += batch.length;
onProgress({ phase: `更新数据库 ${name}`, done: batchDone, total: records.length });
runSqlite3(dbPath, buildThreadUpsertSql(batch, target, cols, { updateAllProviders: false }));
}
runSqlite3(dbPath, `BEGIN;\nUPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};\nCOMMIT;\n`);
// 兜底:强制把 rollout 已确认有用户消息的线程 has_user_event 拉起来
if (forceUeSql) runSqlite3(dbPath, "BEGIN;\n" + forceUeSql + "\nCOMMIT;\n");
const clearSubagentSql = forceSubagentNoUserEventSql(cols);
if (clearSubagentSql) runSqlite3(dbPath, "BEGIN;\n" + clearSubagentSql + "\nCOMMIT;\n");
runSqlite3(dbPath, "PRAGMA wal_checkpoint(TRUNCATE);\n");
const afterUserEvent = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>1;\n").trim() || 0);
const afterRows = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads;\n").trim() || 0);
@@ -1172,4 +1334,10 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
};
}
module.exports = { syncProviderToCurrent, codexRunning, targetProvider, setCodexDataDir };
module.exports = {
syncProviderToCurrent,
repairLegacySessionVisibility,
codexRunning,
targetProvider,
setCodexDataDir,
};
+302 -29
View File
@@ -111,6 +111,7 @@ function restoreFileState(file, state) {
function writeCodexConfigPair(configText, authText) {
const configFile = path.join(CODEX_HOME, "config.toml");
const authFile = path.join(CODEX_HOME, "auth.json");
assertManagedConfigComplete(configText);
const beforeConfig = readFileState(configFile);
const beforeAuth = readFileState(authFile);
try {
@@ -372,7 +373,7 @@ function currentRootModel() {
}
function extractProviderBlock(text, prov) {
const esc = prov.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`\\[model_providers\\.${esc}\\][\\s\\S]*?(?=\\n\\[|$)`, "m");
const re = new RegExp(`\\[model_providers\\.${esc}\\][\\s\\S]*?(?=\\n\\[|$)`);
const m = text.match(re);
return m ? m[0].trimEnd() + "\n" : "";
}
@@ -442,6 +443,46 @@ function removeTomlSection(text, header) {
function isManagedProviderId(id) {
return id === PROVIDER_ID || id === DIRECT_PROVIDER_ID;
}
function providerBlockIsComplete(text, id) {
const block = extractProviderBlock(text, id);
return !!(block && /^\s*base_url\s*=/m.test(block) && /^\s*wire_api\s*=\s*["']responses["']/m.test(block));
}
function managedCompatibilityProviderIds() {
const def = config && Array.isArray(config.groups) ? config.groups.find((g) => g && g.isDefault) : null;
const id = String((def && def.modelProvider) || "").trim();
// openai 是 Codex 内置 provider;托管的两个 ID 已由固定兼容段覆盖。
// 这里只接管安全的 TOML bare key,避免历史异常值生成非法 section。
if (!id || id === "openai" || isManagedProviderId(id) || !/^[A-Za-z0-9_-]+$/.test(id)) return [];
return [id];
}
function managedProviderBlocksAreComplete(text) {
return [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()]
.every((id) => providerBlockIsComplete(text, id));
}
function removeManagedProviderSections(text, includeCompatibility = true) {
let out = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
out = removeTomlSection(out, `model_providers.${DIRECT_PROVIDER_ID}`);
if (includeCompatibility) {
for (const id of managedCompatibilityProviderIds()) {
out = removeTomlSection(out, `model_providers.${id}`);
}
}
return out;
}
function providerBlockValue(text, id, key) {
const block = extractProviderBlock(text, id);
if (!block) return "";
const esc = String(key).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const m = block.match(new RegExp(`^\\s*${esc}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "m"));
return m ? (m[1] !== undefined ? m[1] : m[2]) : "";
}
function assertManagedConfigComplete(text) {
const provider = modelProviderFromText(text);
if (!isManagedProviderId(provider)) return;
if (managedProviderBlocksAreComplete(text)) return;
const required = [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()].join("、");
throw new Error(`拒绝写入不完整的 Codex config.toml:托管模式缺少 provider 兼容段(${required})`);
}
function configLooksManaged(text) {
const s = String(text || "");
if (!s.trim()) return false;
@@ -449,12 +490,92 @@ function configLooksManaged(text) {
return s.includes(`[model_providers.${PROVIDER_ID}]`) || s.includes(`[model_providers.${DIRECT_PROVIDER_ID}]`);
}
function codexConfigRepairPlan() {
load();
const text = readConfigToml();
const provider = modelProviderFromText(text);
if (provider === PROVIDER_ID && !managedProviderBlocksAreComplete(text)) {
return { needed: true, mode: "proxy", reason: "托管 provider 兼容段缺失或不完整" };
}
if (provider === DIRECT_PROVIDER_ID && !managedProviderBlocksAreComplete(text)) {
const direct = config.groups.find((g) => g.id === config.activeId && !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key)
|| config.groups.find((g) => !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key === readAuthKey());
return {
needed: true,
mode: "direct",
groupId: direct && direct.id,
reason: "托管 provider 兼容段缺失或不完整",
};
}
if (String(text || "").trim()) return { needed: false };
const authText = readTextIfExists(path.join(CODEX_HOME, "auth.json"));
let auth = {};
try { auth = JSON.parse(authText || "{}"); } catch {}
const key = auth.OPENAI_API_KEY || "";
if (!key) return { needed: false };
if (key === config.routerKey) {
return { needed: true, mode: "proxy", reason: "config.toml 为空但 auth.json 仍是本地路由 Key" };
}
const direct = config.groups.find((g) => !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key === key)
|| config.groups.find((g) => g.id === config.activeId && !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key);
if (direct && authLooksManaged(authText)) {
return { needed: true, mode: "direct", groupId: direct.id, reason: "config.toml 为空但 auth.json 仍是中转 Key" };
}
return { needed: false };
}
function repairCodexConfigIfNeeded() {
const plan = codexConfigRepairPlan();
if (!plan.needed) return { repaired: false };
const currentText = readConfigToml();
const currentProvider = modelProviderFromText(currentText);
if (isManagedProviderId(currentProvider) && String(currentText || "").trim()) {
const fallbackGroup = config.groups.find((g) => g.id === plan.groupId)
|| config.groups.find((g) => g.id === config.activeId);
const sourceId = providerBlockIsComplete(currentText, currentProvider)
? currentProvider
: (providerBlockIsComplete(currentText, PROVIDER_ID) ? PROVIDER_ID : DIRECT_PROVIDER_ID);
const fallbackBase = currentProvider === PROVIDER_ID
? `http://127.0.0.1:${config.port}`
: (fallbackGroup && fallbackGroup.baseUrl ? responsesProviderBase(fallbackGroup.baseUrl) : "");
const baseUrl = providerBlockValue(currentText, sourceId, "base_url") || fallbackBase;
if (!baseUrl) return { repaired: false, error: "没有可用于修复兼容 provider 的 API 地址" };
const name = providerBlockValue(currentText, sourceId, "name")
|| (fallbackGroup && fallbackGroup.name)
|| (currentProvider === PROVIDER_ID ? "本地路由" : "中转直连");
let repairedText = removeManagedProviderSections(currentText);
repairedText = repairedText.trimEnd() + "\n\n" + managedProviderBlocks(name, baseUrl);
backupConfig(currentText);
writeCodexConfigPair(repairedText, readTextForUpdate(path.join(CODEX_HOME, "auth.json")));
return {
repaired: true,
mode: currentProvider === PROVIDER_ID ? "proxy" : "direct",
reason: plan.reason,
groupId: plan.groupId,
preservedModel: currentRootModel(),
};
}
if (plan.mode === "proxy") {
applyToCodex();
return { repaired: true, mode: "proxy", reason: plan.reason };
}
if (plan.mode === "direct" && plan.groupId) {
const result = applyDirectToCodex(plan.groupId);
if (result && result.ok) return { repaired: true, mode: "direct", reason: plan.reason, groupId: plan.groupId };
return { repaired: false, error: result && result.error ? result.error : "直连分组不可用" };
}
return { repaired: false, error: "没有可用于修复的中转分组" };
}
// 当前 Codex 是否仍保留“本地路由”接管痕迹。这个状态只用于页面显示和取消按钮,
// 不把直连中转(td-direct)误显示成已启用本地路由。
function codexRouteApplied() {
try { repairCodexConfigIfNeeded(); } catch {}
const text = readConfigToml();
if (modelProviderFromText(text) === PROVIDER_ID) return true;
if (extractProviderBlock(text, PROVIDER_ID)) return true;
const provider = modelProviderFromText(text);
if (provider === PROVIDER_ID) return true;
if (provider === DIRECT_PROVIDER_ID) return false;
try {
const auth = JSON.parse(readTextIfExists(path.join(CODEX_HOME, "auth.json")) || "{}");
return auth.OPENAI_API_KEY === config.routerKey;
@@ -665,6 +786,31 @@ function catalogPath() {
return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name);
}
function catalogSyncStatePath(catalogFile) {
return `${catalogFile}.td-router-state.json`;
}
function readCatalogManagedSlugs(catalogFile) {
try {
const state = JSON.parse(fs.readFileSync(catalogSyncStatePath(catalogFile), "utf-8"));
return new Set(Array.isArray(state.managedSlugs) ? state.managedSlugs.filter((slug) => typeof slug === "string") : []);
} catch {
return new Set();
}
}
function writeCatalogManagedSlugs(catalogFile, slugs) {
safeWriteFileSync(
catalogSyncStatePath(catalogFile),
JSON.stringify({ managedSlugs: [...slugs].sort() }, null, 2),
"utf-8"
);
}
function isToolOwnedCatalog(catalogFile) {
return path.resolve(catalogFile) === path.resolve(path.join(CODEX_HOME, "cc-switch-model-catalog.json"));
}
// 国产模型的实际可用窗口通常小于 Codex 模板模型。分组未填写时给出保守值,
// 让 Codex 在上游拒绝请求前预留足够空间完成自动压缩。用户填写的值始终优先
// (≥1024 即生效;过小的值多半也是有意为之,比如想强制尽早压缩)。
@@ -754,12 +900,33 @@ function applyModelCatalog(group) {
} catch {}
safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
// 【根治「切模型丢会话」】把分组中转站 /models 返回的全部文本模型,先登记进 Codex 模型目录。
// 关键:用户是在 Codex 本体的模型选择器里切模型的,那条路径不经过本 App,App 的 applyModelCatalog
// 不会触发;若目标 slug 不在目录里,Codex 会以未知模型启动、侧栏历史还原失败 = 会话"消失"。
// 因此必须【先回填】:把该中转能用的模型全部登记为 visibility=list,用户在 Codex 里选哪个都不丢。
// 非文本模型(画图/审阅/语音等)不是对话模型,排除;任何网络/解析失败都不动既有目录(离线安全)。
const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|tts|stt|embed|rerank|review|moderation|vision-ocr)/i;
// Codex 模型目录只公开当前分组可用的同族文本模型。中转站的 /models 往往返回
// GPT、Claude、国产模型和多媒体模型的混合库存,不能把整站库存灌进选择器。
const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|speech|tts|stt|embed|rerank|review|moderation|vision-ocr|video|seedance|veo|sora|banana|kling|hailuo)/i;
function configuredCatalogModels(group) {
const values = [
group && group.model,
...(Array.isArray(group && group.models) ? group.models : []),
...(Array.isArray(group && group.routeModels) ? group.routeModels : []),
];
return Array.from(new Set(values.filter((slug) => typeof slug === "string" && slug.trim()).map((slug) => slug.trim())));
}
function allowedCatalogModels(group, providerIds) {
const available = Array.from(new Set(
(providerIds || []).filter((slug) => typeof slug === "string" && slug.trim() && !NON_TEXT_MODEL_RE.test(slug))
));
const configured = configuredCatalogModels(group).filter((slug) => !NON_TEXT_MODEL_RE.test(slug));
const rootModel = currentRootModel();
const anchor = configured[0] || rootModel || "";
if (/^gpt-/i.test(anchor)) {
const allowed = available.filter((slug) => /^gpt-/i.test(slug));
for (const slug of [...configured, rootModel]) {
if (/^gpt-/i.test(slug) && !NON_TEXT_MODEL_RE.test(slug) && !allowed.includes(slug)) allowed.push(slug);
}
return allowed;
}
return configured;
}
async function syncCatalogFromProvider(group) {
try {
if (!group || group.isDefault || !group.baseUrl || !group.key) return { ok: false, skipped: true };
@@ -770,8 +937,8 @@ async function syncCatalogFromProvider(group) {
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) }; // 网络/接口失败 → 不动既有目录
}
const textIds = ids.filter((s) => !NON_TEXT_MODEL_RE.test(s));
if (!textIds.length) return { ok: false, error: "无可登记的文本模型" };
const allowedIds = allowedCatalogModels(group, ids);
if (!allowedIds.length) return { ok: false, error: "无可登记的分组文本模型" };
const p = catalogPath();
let cat;
try {
@@ -788,7 +955,18 @@ async function syncCatalogFromProvider(group) {
let added = 0;
let revealed = 0;
let refreshed = 0;
for (const slug of textIds) {
let hidden = 0;
const allowed = new Set(allowedIds);
const managedSlugs = readCatalogManagedSlugs(p);
const hideAllStale = isToolOwnedCatalog(p);
for (const model of cat.models) {
if (!model || allowed.has(model.slug) || model.visibility !== "list") continue;
if (hideAllStale || managedSlugs.has(model.slug)) {
model.visibility = "hide";
hidden++;
}
}
for (const slug of allowedIds) {
const ex = cat.models.find((m) => m.slug === slug);
if (ex) {
if (ex.visibility !== "list") { ex.visibility = "list"; revealed++; }
@@ -812,11 +990,13 @@ async function syncCatalogFromProvider(group) {
cat.models.push(t);
added++;
}
if (added || revealed || refreshed) {
for (const slug of allowedIds) managedSlugs.add(slug);
if (added || revealed || refreshed || hidden) {
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
return { ok: true, added, revealed, refreshed, total: textIds.length };
writeCatalogManagedSlugs(p, managedSlugs);
return { ok: true, added, revealed, refreshed, hidden, total: allowedIds.length };
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) };
}
@@ -875,6 +1055,22 @@ function setRootModelInText(text, model) {
return `model = "${model}"\n${text}`;
}
function setRootTomlValueInText(text, key, value) {
const rendered = typeof value === "boolean" ? String(value) : JSON.stringify(String(value));
const lines = text.split("\n");
const firstSection = lines.findIndex((line) => /^\s*\[/.test(line));
const rootEnd = firstSection === -1 ? lines.length : firstSection;
const existingIndex = lines.slice(0, rootEnd).findIndex(
(line) => line.trim().startsWith(`${key}=`) || line.trim().startsWith(`${key} =`)
);
if (existingIndex >= 0) {
lines[existingIndex] = `${key} = ${rendered}`;
} else {
lines.splice(rootEnd, 0, `${key} = ${rendered}`);
}
return lines.join("\n");
}
// 恢复到默认(把 config.toml/auth.json 还原成客户原始配置,Codex 直连自己账号)
function restoreDirect() {
const def = config.groups.find((g) => g.isDefault);
@@ -1019,6 +1215,26 @@ function knownModelsForBase(baseUrl) {
return [];
}
// Codex Responses provider 的 base_url 语义是“后续自动追加 /responses”。
// 模型列表探测允许用户填站点根(自动试 /v1/models),因此直连与路由转发也必须
// 把站点根规范到 /v1;已填 /v1、/v2 或完整 /responses 地址时不重复改写。
function responsesProviderBase(baseUrl) {
const base = String(baseUrl || "").replace(/\/+$/, "");
try {
const u = new URL(base);
const pathname = u.pathname.replace(/\/+$/, "");
if (/\/responses$/i.test(pathname)) {
u.pathname = pathname.replace(/\/responses$/i, "");
return u.href.replace(/\/+$/, "");
}
if (/\/v\d+$/i.test(pathname)) return base;
u.pathname = pathname === "/" ? "/v1" : `${pathname}/v1`;
return u.href.replace(/\/+$/, "");
} catch {
return base;
}
}
// 拉取某个中转 API 的模型列表(试 /v1/models 再 /models)
async function fetchModels(baseUrl, key) {
if (!baseUrl) throw new Error("请先填 API 地址");
@@ -1294,7 +1510,16 @@ function handle(req, res) {
let target;
try {
target = new URL(g.baseUrl.replace(/\/$/, "") + req.url);
const openAIUpstream = g.mode !== "chat" && !isClaudeGroup(g);
// Codex 的 provider base_url 可能带 /v1;本地路由自身统一挂在根路径。
// 先去掉入口里的版本段,再拼上游 base,避免 /v1/v1/responses。
const upstreamPath = openAIUpstream && /^\/v\d+\/responses\b/i.test(req.url || "")
? req.url.replace(/^\/v\d+/i, "")
: req.url;
const upstreamBase = !openAIUpstream
? g.baseUrl.replace(/\/$/, "")
: responsesProviderBase(g.baseUrl);
target = new URL(upstreamBase + upstreamPath);
} catch {
res.writeHead(502);
res.end("分组 API 地址无效");
@@ -1367,6 +1592,7 @@ function handle(req, res) {
function start() {
load();
try { repairCodexConfigIfNeeded(); } catch {}
if (server) return { running: true, port: config.port };
return new Promise((resolve, reject) => {
server = http.createServer(handle);
@@ -1400,6 +1626,10 @@ function releaseCodexTakeover() {
text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`);
if (managedProvider) {
// 托管期间为旧会话补的 provider 别名也要一起清理;用户已手改 provider 时则完全保留。
for (const id of managedCompatibilityProviderIds()) {
text = removeTomlSection(text, `model_providers.${id}`);
}
// 根级还指向本工具,才删根级 model_provider 和根级 model;否则不碰用户配置。
text = text.replace(/^\s*model_provider\s*=.*$\n?/m, "");
text = text.replace(/^\s*model\s*=.*$\n?/m, "");
@@ -1446,6 +1676,8 @@ function isRunning() {
function status() {
load();
let repair = { repaired: false };
try { repair = repairCodexConfigIfNeeded(); } catch (e) { repair = { repaired: false, error: String((e && e.message) || e) }; }
return {
running: isRunning(),
port: config.port,
@@ -1453,6 +1685,7 @@ function status() {
address: `http://127.0.0.1:${config.port}`,
activeId: config.activeId,
activeClaudeId: config.activeClaudeId,
repair,
claudeApplied: claudeApplied(),
codexApplied: codexRouteApplied(),
groups: config.groups.map((g) => ({
@@ -1470,12 +1703,15 @@ function status() {
sttModel: g.sttModel || "",
multimodal: !!g.multimodal,
oneMillionModels: Array.isArray(g.oneMillionModels) ? g.oneMillionModels : [],
source: g.source || "",
sourceGroupId: g.sourceGroupId || "",
})),
};
}
function defaultConfigInfo() {
load();
try { repairCodexConfigIfNeeded(); } catch {}
const def = config.groups.find((g) => g.isDefault) || null;
if (def && snapshotOfficialLogin(def)) save();
const currentConfigToml = readConfigToml();
@@ -1626,6 +1862,24 @@ function upsertGroup(g) {
}
return st;
}
function importSub2apiProvider(input) {
load();
const existing = config.groups.find(
(g) => !g.isDefault && g.source === "sub2api" && String(g.sourceGroupId) === String(input.sourceGroupId)
);
const result = upsertGroup({
...(existing ? { id: existing.id } : {}),
name: input.name,
baseUrl: input.endpoint,
key: input.apiKey,
model: input.model,
mode: "responses",
source: "sub2api",
sourceGroupId: String(input.sourceGroupId),
});
return { ...result, _imported: true, _updated: !!existing };
}
function deleteGroup(id) {
load();
const g = config.groups.find((x) => x.id === id);
@@ -1745,7 +1999,26 @@ function setPort(port) {
// —— 自动配置 Codex —— 写 config.toml 的 local-router provider + auth.json 的 routerKey
function codexSnippet() {
load();
return `model_provider = "${PROVIDER_ID}"\n\n[model_providers.${PROVIDER_ID}]\nname = "本地路由"\nbase_url = "http://127.0.0.1:${config.port}"\nwire_api = "responses"\nrequires_openai_auth = true`;
const base = `http://127.0.0.1:${config.port}`;
return `model_provider = "${PROVIDER_ID}"\n\n${managedProviderBlocks("本地路由", base).trimEnd()}`;
}
function managedProviderBlocks(name, baseUrl) {
const providerName = JSON.stringify(String(name || "TD 工具箱"));
const compatibilityName = JSON.stringify(`${String(name || "TD 工具箱")}(历史会话兼容)`);
const base = JSON.stringify(String(baseUrl || "").replace(/\/+$/, ""));
const blocks = [];
for (const id of [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()]) {
blocks.push(
`[model_providers.${id}]`,
`name = ${id === PROVIDER_ID || id === DIRECT_PROVIDER_ID ? providerName : compatibilityName}`,
`base_url = ${base}`,
'wire_api = "responses"',
"requires_openai_auth = true",
""
);
}
return blocks.join("\n");
}
function applyToCodex() {
@@ -1775,9 +2048,10 @@ function applyToCodex() {
if (def && def.modelProvider && def.modelProvider !== PROVIDER_ID) {
text = removeTomlSection(text, `model_providers.${def.modelProvider}`);
}
// 替换/插入 [model_providers.local-router] 段:先删旧的(含被 bug 弄重复的),再追加干净的
const block = `[model_providers.${PROVIDER_ID}]\nname = "本地路由"\nbase_url = "http://127.0.0.1:${config.port}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
// 托管 provider 与历史默认 provider 都保留完整兼容段。历史线程可能记录任意一个 ID,
// 恢复时会按线程自己的 provider 查配置,缺一个就会一直停在 loading。
const block = managedProviderBlocks("本地路由", `http://127.0.0.1:${config.port}`);
text = removeManagedProviderSections(text);
text = text.trimEnd() + "\n\n" + block;
// auth.json 写 routerKey(保留其它字段),Codex 会把它当 Bearer 发到本地路由
@@ -1793,8 +2067,7 @@ function applyToCodex() {
}
text = ensureCatalogLinked(text);
writeCodexConfigPair(text, JSON.stringify(auth, null, 2));
// 先回填该中转站的全部文本模型进目录(异步、不阻塞、失败不动目录),
// 保证用户之后在 Codex 本体里切到任何模型都不会因"模型不在目录"而丢会话。
// 收敛为当前分组的同族文本模型,避免把中转站全量库存灌进 Codex。
if (cur2 && !cur2.isDefault) syncCatalogFromProvider(cur2).catch(() => {});
save();
return { ok: true };
@@ -1835,8 +2108,7 @@ function applyDirectToCodex(groupId) {
backupConfig(text);
// 清理其他接管痕迹:本地路由段、上次直连段、默认分组原始 provider 段
text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`);
text = removeManagedProviderSections(text);
if (def && def.modelProvider && def.modelProvider !== DIRECT_PROVIDER_ID) {
text = removeTomlSection(text, `model_providers.${def.modelProvider}`);
}
@@ -1846,9 +2118,11 @@ function applyDirectToCodex(groupId) {
} else {
text = `model_provider = "${DIRECT_PROVIDER_ID}"\n` + text;
}
// 追加 [model_providers.td-direct] 段:base_url = 分组真实中转地址(非 127.0.0.1)
const base = g.baseUrl.replace(/\/+$/, "");
const block = `[model_providers.${DIRECT_PROVIDER_ID}]\nname = "${(g.name || "中转直连").replace(/"/g, "")}"\nbase_url = "${base}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
// 追加托管与历史 provider 兼容段:base_url 都是分组真实中转地址(非 127.0.0.1)
const base = responsesProviderBase(g.baseUrl);
const block = managedProviderBlocks(g.name || "中转直连", base);
text = setRootTomlValueInText(text, "model_reasoning_effort", "high");
text = setRootTomlValueInText(text, "disable_response_storage", true);
text = text.trimEnd() + "\n\n" + block;
text = text.replace(/\n{3,}/g, "\n\n");
@@ -1865,9 +2139,7 @@ function applyDirectToCodex(groupId) {
}
text = ensureCatalogLinked(text);
writeCodexConfigPair(text, JSON.stringify(auth, null, 2));
// 先回填:把该中转站能用的全部文本模型登记进 Codex 模型目录(异步、不阻塞保存、失败不动目录)。
// 这样即便用户之后在 Codex 本体的模型选择器里切到别的模型(那条路径不经过本 App),
// 目标模型也已在目录里 → Codex 能正常启动、侧栏历史不再"消失"。
// 异步收敛当前分组模型目录;网络失败时保持现状。
syncCatalogFromProvider(g).catch(() => {});
config.activeId = g.id;
save();
@@ -2015,6 +2287,7 @@ module.exports = {
status,
defaultConfigInfo,
upsertGroup,
importSub2apiProvider,
deleteGroup,
setActive,
setPort,
+180 -5
View File
@@ -8,6 +8,9 @@ const { cleanThreadTitle, cleanUserVisibleText, stripInternalThoughtBlocks, look
const codexDataDir = require("./codex_data_dir");
let permanentDeleteBusy = false;
let clearAllBusy = false;
const DEFAULT_LIST_LIMIT = 500;
const ALL_LIST_LIMIT = 5000;
function paths() {
const codexHome = codexDataDir.get();
@@ -95,7 +98,8 @@ function peekMeta(file) {
}
const p = d.payload || {};
if (d.type === "session_meta") {
meta.id = p.session_id || p.id || "";
// 新版 subagent 的 session_id 指向父线程,id 才是当前子线程。
meta.id = p.id || p.session_id || "";
meta.cwd = p.cwd || "";
meta.ts = p.timestamp || d.timestamp || "";
meta.source = p.source || "";
@@ -363,6 +367,171 @@ async function deleteConversation(filePath) {
}
}
// 清空全部 Codex 会话:删除 rollout 正文、状态库线程关联记录、桌面侧栏目录和会话索引。
// 配置、登录态、项目、插件、自动化任务均保留;执行前由调用方保证 Codex 已完全退出。
function deleteAllThreadLinkedRows(SQL, dbPath, { requireThreads = false } = {}) {
if (!fs.existsSync(dbPath)) return { changed: false, rows: 0 };
let db;
try {
db = new SQL.Database(sqliteBufferWithCommittedWal(dbPath));
const tables = sqlJsRows(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.map((row) => row.name).filter(Boolean);
if (requireThreads && !tables.includes("threads")) return { changed: false, rows: 0 };
db.run("PRAGMA foreign_keys=ON");
db.run("PRAGMA secure_delete=ON");
db.run("BEGIN");
let rows = 0;
const deferred = [];
for (const table of tables) {
// 自动化定义不是会话正文;即使它记录了 target_thread_id,也必须保留。
if (table === "automations") continue;
const columns = sqlJsRows(db, "PRAGMA table_info(" + quoteIdent(table) + ")").map((row) => String(row.name || ""));
const fks = sqlJsRows(db, "PRAGMA foreign_key_list(" + quoteIdent(table) + ")");
const linked = columns.filter((name) => /(^|_)(thread_id|parent_thread_id|child_thread_id)$/i.test(name));
const referencesThreads = fks
.filter((fk) => String(fk.table || "").toLowerCase() === "threads")
.map((fk) => String(fk.from || ""));
const targets = Array.from(new Set(linked.concat(referencesThreads))).filter(Boolean);
if (table === "threads") {
deferred.push(table);
continue;
}
for (const column of targets) {
db.run("DELETE FROM " + quoteIdent(table) + " WHERE " + quoteIdent(column) + " IS NOT NULL");
rows += db.getRowsModified();
}
}
if (tables.includes("threads")) {
db.run("DELETE FROM " + quoteIdent("threads"));
rows += db.getRowsModified();
}
db.run("COMMIT");
if (!rows) return { changed: false, rows: 0 };
db.run("VACUUM");
const fkErrors = sqlJsRows(db, "PRAGMA foreign_key_check");
const integrity = sqlJsRows(db, "PRAGMA integrity_check");
if (fkErrors.length || !integrity.length || integrity.some((row) => String(Object.values(row)[0] || "").toLowerCase() !== "ok")) {
throw new Error("SQLite 清空会话后完整性校验失败,已停止替换");
}
replaceStateDb(dbPath, Buffer.from(db.export()));
return { changed: true, rows };
} catch (e) {
if (db) try { db.run("ROLLBACK"); } catch {}
throw e;
} finally {
if (db) try { db.close(); } catch {}
}
}
function copyIfExists(source, target) {
if (!fs.existsSync(source)) return false;
fs.mkdirSync(path.dirname(target), { recursive: true });
const stat = fs.statSync(source);
if (stat.isDirectory()) {
fs.cpSync(source, target, { recursive: true, force: true });
} else {
fs.copyFileSync(source, target);
}
return true;
}
function pruneEmptyDirs(root) {
if (!fs.existsSync(root)) return;
let entries;
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (entry.isDirectory()) pruneEmptyDirs(path.join(root, entry.name));
}
try {
if (!fs.readdirSync(root).length) fs.rmdirSync(root);
} catch {}
}
async function clearAllConversations() {
if (clearAllBusy) throw new Error("已有清空会话任务正在执行,请稍后重试");
clearAllBusy = true;
let backup = "";
const stagedDirs = [];
const copiedFiles = [];
try {
const { codexHome, sessionsDir, archivedSessionsDir, sessionIndex } = paths();
const rolloutFiles = sessionRoots().flatMap((root) => findRolloutFiles(root));
const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
backup = path.join(codexHome, "backups_state", "session-clear", timestamp);
fs.mkdirSync(backup, { recursive: true });
// 会话正文用同盘 rename 暂存,不复制 700+ 个大 JSONL;清空成功后保留备份,便于恢复。
for (const [name, source] of [["sessions", sessionsDir], ["archived_sessions", archivedSessionsDir]]) {
if (!fs.existsSync(source)) continue;
const target = path.join(backup, name);
fs.renameSync(source, target);
stagedDirs.push({ source, target });
fs.mkdirSync(source, { recursive: true });
}
if (fs.existsSync(sessionIndex)) {
copyIfExists(sessionIndex, path.join(backup, "session_index.jsonl"));
copiedFiles.push([sessionIndex, path.join(backup, "session_index.jsonl")]);
fs.writeFileSync(sessionIndex, "", "utf-8");
}
for (const name of fs.readdirSync(codexHome)) {
if (!/^state_.*\.sqlite(?:-(?:wal|shm))?$/.test(name)) continue;
const source = path.join(codexHome, name);
const target = path.join(backup, name);
copyIfExists(source, target);
copiedFiles.push([source, target]);
}
const catalogDb = path.join(codexHome, "sqlite", "codex-dev.db");
for (const suffix of ["", "-wal", "-shm"]) {
const source = catalogDb + suffix;
if (!fs.existsSync(source)) continue;
const target = path.join(backup, "codex-dev.db" + suffix);
copyIfExists(source, target);
copiedFiles.push([source, target]);
}
const indexLines = copiedFiles.some(([source]) => source === sessionIndex)
? fs.readFileSync(path.join(backup, "session_index.jsonl"), "utf-8").split("\n").filter((line) => line.trim()).length
: 0;
const SQL = await initSqlJs({
locateFile: (file) => path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."), "node_modules", "sql.js", "dist", file),
});
let stateRowsRemoved = 0;
for (const name of fs.readdirSync(codexHome).filter((item) => /^state_.*\.sqlite$/.test(item))) {
const result = deleteAllThreadLinkedRows(SQL, path.join(codexHome, name), { requireThreads: true });
stateRowsRemoved += result.rows;
}
const catalog = deleteAllThreadLinkedRows(SQL, catalogDb);
return {
ok: true,
filesRemoved: rolloutFiles.length,
indexEntriesRemoved: indexLines,
stateRowsRemoved,
catalogRowsRemoved: catalog.rows,
backup,
};
} catch (error) {
// 任一 SQLite/文件步骤失败,恢复正文目录、索引和数据库备份,避免半清空状态。
for (const [source, target] of copiedFiles) {
try {
if (fs.existsSync(target)) {
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.copyFileSync(target, source);
}
} catch {}
}
for (const { source, target } of stagedDirs.reverse()) {
try {
fs.rmSync(source, { recursive: true, force: true });
if (fs.existsSync(target)) fs.renameSync(target, source);
} catch {}
}
throw error;
} finally {
clearAllBusy = false;
}
}
function findConversationById(id) {
const target = String(id || "").trim();
if (!target || target.length > 160) return null;
@@ -469,16 +638,21 @@ function repairSkillMentionTitles(log = () => {}) {
}
function normalizeListOptions(opts) {
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false };
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false, limit: DEFAULT_LIST_LIMIT };
const requestedLimit = Number(opts && opts.limit);
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.min(Math.floor(requestedLimit), ALL_LIST_LIMIT)
: DEFAULT_LIST_LIMIT;
return {
includeSubagent: !!(opts && opts.includeSubagent),
includeArchived: !!(opts && opts.includeArchived),
limit,
};
}
// 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions)
function listConversations(opts = {}) {
const { includeSubagent, includeArchived } = normalizeListOptions(opts);
const { includeSubagent, includeArchived, limit } = normalizeListOptions(opts);
const { sessionsDir, archivedSessionsDir } = paths();
const roots = [{ dir: sessionsDir, archived: false }];
if (includeArchived) roots.push({ dir: archivedSessionsDir, archived: true });
@@ -510,7 +684,7 @@ function listConversations(opts = {}) {
}
}
items.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
return items;
return items.slice(0, limit);
}
// 解析单个会话文件为可读消息列表
@@ -534,7 +708,7 @@ function readConversation(file) {
const p = d.payload || {};
const ts = d.timestamp || "";
if (d.type === "session_meta") {
meta = { id: p.session_id || p.id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" };
meta = { id: p.id || p.session_id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" };
} else if (d.type === "event_msg" && p.type === "user_message") {
messages.push({ role: "user", text: cleanUserVisibleText(String(p.message || "")), ts });
} else if (d.type === "event_msg" && p.type === "agent_message") {
@@ -677,6 +851,7 @@ module.exports = {
backupAll,
importSessions,
deleteConversation,
clearAllConversations,
findConversationById,
repairSkillMentionTitles,
};
+8
View File
@@ -16,6 +16,7 @@ const path = require("path");
function dlog(...a) { try { console.log("[voice]", ...a); } catch {} }
const router = require("./router");
const codexDesktop = require("./codex_desktop");
const globalDictation = require("./global_dictation");
const { INJECT_SCRIPT } = require("./voice_inject");
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
const tokenUsage = require("./token_usage");
@@ -1014,6 +1015,10 @@ function closeNetworkSessions() {
// —— 连接到已带端口的 Codex ——
async function connectToTarget(port) {
const targets = await listTargets(port);
// listTargets() keeps its legacy [] fallback for the voice state machine.
// Let the cleanup module probe the endpoint itself so a failed connection
// cannot be mistaken for an available endpoint with no targets.
await globalDictation.closeGlobalDictationTargets(port);
const tgt = pickTarget(targets);
if (!tgt) return false;
closeNetworkSessions();
@@ -1120,6 +1125,7 @@ function onSessionDelete(cb) {
async function connect(cb, opts = {}) {
if (cb) onStatusCb = cb;
const port = opts.port || DEFAULT_PORT;
globalDictation.disableKeepVisible();
state.port = port;
setPhase("checking", { lastError: "", launchWarning: "", portDiagnostic: "" });
const exe = scanCodexExecutable();
@@ -1184,6 +1190,7 @@ async function connect(cb, opts = {}) {
async function disconnect() {
stopTokenSync();
closeNetworkSessions();
globalDictation.disableKeepVisible();
if (session) {
const ownedSession = session;
// 先让页面停止录音并删除已挂载按钮,再关闭 CDP;关闭后无法清理前台 DOM。
@@ -1205,6 +1212,7 @@ async function disconnect() {
} catch {}
}
setPhase("disconnected");
await globalDictation.closeGlobalDictationTargets(state.port);
return snapshot();
}