import { spawn } from "node:child_process";
import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileExists } from "./http-utils.mjs";
import { lookupAttachment } from "./attachment-utils.mjs";
const defaultModel = "gpt-5.5";
const defaultReasoningEffort = "low";
const miniThresholdPercent = Number(process.env.NBTANGINONE_MINI_THRESHOLD_PERCENT || process.env.CODEXINONE_MINI_THRESHOLD_PERCENT || 15);
const maxAttachmentBytes = 10 * 1024 * 1024;
const maxAttachmentCount = 3;
const maxPersistedSessionImages = 20;
const sessionsCacheTtlMs = Number(process.env.NBTANGINONE_SESSIONS_CACHE_TTL_MS || process.env.CODEXINONE_SESSIONS_CACHE_TTL_MS || 15000);
const hostStatusCacheTtlMs = Number(process.env.NBTANGINONE_HOST_STATUS_CACHE_TTL_MS || process.env.CODEXINONE_HOST_STATUS_CACHE_TTL_MS || 15000);
const attachmentExtensionByMime = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
};
function buildPromptWithHandoff(prompt, handoffSummary) {
const cleanPrompt = String(prompt || "").trim();
const cleanSummary = String(handoffSummary || "").trim();
if (!cleanSummary) {
return cleanPrompt;
}
return "[handoff]\n请继承以下同项目上下文,继续处理用户请求。\n" + cleanSummary + "\n[/handoff]\n\n当前用户请求:\n" + cleanPrompt;
}
export class CodexHostRuntime {
constructor(options = {}) {
this.codexHome = options.codexHome || path.join(os.homedir(), ".codex");
this.sessionsRoot = path.join(this.codexHome, "sessions");
this.stateFilePath = path.join(this.codexHome, "nbtanginone-state.json");
this.legacyStateFilePath = path.join(this.codexHome, "codexinone-state.json");
this.usageFilePath = path.join(this.codexHome, "nbtanginone-usage.json");
this.legacyUsageFilePath = path.join(this.codexHome, "codexinone-usage.json");
this.attachmentsRoot = path.join(this.codexHome, "codexinone-attachments");
this.attachmentsIndexPath = path.join(this.attachmentsRoot, "index.json");
this.currentProjectRoot = options.currentProjectRoot || process.cwd();
this.configPath = options.configPath || process.env.HOST_ADAPTER_CONFIG || path.join(process.cwd(), "host-adapter.config.json");
this.hostListenLabel = options.hostListenLabel || "host-adapter";
this.runs = new Map();
this.attachments = new Map();
this.sessionsCache = { value: null, expiresAt: 0, promise: null };
this.hostStatusCache = { value: null, expiresAt: 0, promise: null };
}
formatCodexResetLabel(unixSeconds) {
if (!Number.isFinite(unixSeconds)) {
return "resets n/a";
}
const resetAt = new Date(unixSeconds * 1000);
if (!Number.isFinite(resetAt.getTime())) {
return "resets n/a";
}
const now = new Date();
const sameDay =
now.getFullYear() === resetAt.getFullYear() &&
now.getMonth() === resetAt.getMonth() &&
now.getDate() === resetAt.getDate();
const timeText = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(resetAt);
if (sameDay) {
return `resets ${timeText}`;
}
const dateText = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
}).format(resetAt);
return `resets ${timeText} on ${dateText}`;
}
formatCodexWindowSummary(label, snapshot) {
if (!snapshot) {
return `${label}: n/a`;
}
const usedPercent = Number(snapshot.usedPercent);
const leftPercent = Number.isFinite(usedPercent)
? Math.max(0, Math.min(100, Math.round(100 - usedPercent)))
: null;
const leftText = leftPercent == null ? "n/a" : `${leftPercent}% left`;
return `${label}: ${leftText} (${this.formatCodexResetLabel(snapshot.resetsAt)})`;
}
parseUsageSnapshot(payload) {
const snapshot = payload?.rateLimitsByLimitId?.codex || payload?.rateLimits || null;
if (!snapshot) {
return null;
}
const leftPercent = (windowSnapshot) => {
const usedPercent = Number(windowSnapshot?.usedPercent);
return Number.isFinite(usedPercent) ? Math.max(0, Math.min(100, Math.round(100 - usedPercent))) : null;
};
const fiveHourRemainingPercent = leftPercent(snapshot.primary);
const weeklyRemainingPercent = leftPercent(snapshot.secondary);
const finiteValues = [fiveHourRemainingPercent, weeklyRemainingPercent].filter((value) => Number.isFinite(value));
const effectiveRemainingPercent = finiteValues.length ? Math.min(...finiteValues) : null;
const primaryLabel = snapshot?.primary?.windowDurationMins === 300 ? "5h limit" : "Primary limit";
const secondaryLabel = snapshot?.secondary?.windowDurationMins === 10080 ? "Weekly limit" : "Secondary limit";
const summaryParts = [this.formatCodexWindowSummary(primaryLabel, snapshot.primary)];
if (snapshot.secondary) {
summaryParts.push(this.formatCodexWindowSummary(secondaryLabel, snapshot.secondary));
}
const selectedModel = defaultModel;
return {
updatedAt: new Date().toISOString(),
fiveHourRemainingPercent,
weeklyRemainingPercent,
fiveHourResetsAt: snapshot?.primary?.resetsAt || null,
weeklyResetsAt: snapshot?.secondary?.resetsAt || null,
effectiveRemainingPercent,
selectedModel,
thresholdPercent: miniThresholdPercent,
summary: `cd: ${summaryParts.join(" ; ")}`,
};
}
async readUsageSnapshot() {
const usageFilePath = (await fileExists(fs, this.usageFilePath))
? this.usageFilePath
: this.legacyUsageFilePath;
if (!(await fileExists(fs, usageFilePath))) {
return null;
}
try {
const parsed = JSON.parse(await fs.readFile(usageFilePath, "utf8"));
const usage = parsed?.codex || null;
if (!usage || typeof usage !== "object") {
return null;
}
return usage;
} catch {
return null;
}
}
async writeUsageSnapshot(usage) {
const tempPath = `${this.usageFilePath}.${process.pid}.tmp`;
await fs.mkdir(path.dirname(this.usageFilePath), { recursive: true });
await fs.writeFile(tempPath, JSON.stringify({ codex: usage }, null, 2));
await fs.rename(tempPath, this.usageFilePath);
}
async fetchCodexRateLimits() {
return await new Promise((resolve) => {
const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
cwd: this.currentProjectRoot,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdoutBuffer = "";
let resolved = false;
const request = (id, method, params) => {
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
};
const finish = (value) => {
if (resolved) return;
resolved = true;
try {
child.kill("SIGTERM");
} catch {}
resolve(value);
};
child.stdout.on("data", (chunk) => {
stdoutBuffer += chunk.toString();
const lines = stdoutBuffer.split(/\r?\n/);
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const message = JSON.parse(trimmed);
if (message?.id === 2) {
finish(message.result || null);
return;
}
} catch {}
}
});
child.on("error", () => finish(null));
child.on("close", () => finish(null));
request(1, "initialize", {
clientInfo: { name: "nbtangInOne", version: "1.0.0" },
capabilities: null,
});
request(2, "account/rateLimits/read", null);
setTimeout(() => finish(null), 10000).unref?.();
});
}
async refreshUsageSnapshot() {
const payload = await this.fetchCodexRateLimits();
const usage = this.parseUsageSnapshot(payload);
if (!usage) {
return await this.readUsageSnapshot();
}
await this.writeUsageSnapshot(usage);
return usage;
}
async getModelSelection() {
let usage = await this.readUsageSnapshot();
if (!usage) {
usage = await this.refreshUsageSnapshot();
}
const effectiveRemainingPercent = Number(usage?.effectiveRemainingPercent);
const selectedModel = defaultModel;
return { selectedModel, usage: usage ? { ...usage, selectedModel, thresholdPercent: miniThresholdPercent } : null };
}
async loadAttachmentsIndex() {
if (!(await fileExists(fs, this.attachmentsIndexPath))) {
return { entries: [] };
}
try {
const parsed = JSON.parse(await fs.readFile(this.attachmentsIndexPath, "utf8"));
const entries = Array.isArray(parsed.entries)
? parsed.entries
.filter((item) => item && typeof item === "object" && typeof item.id === "string")
.map((item) => ({
id: item.id,
sessionId: typeof item.sessionId === "string" ? item.sessionId : "",
messageKey: typeof item.messageKey === "string" ? item.messageKey : "",
prompt: typeof item.prompt === "string" ? item.prompt : "",
createdAt: typeof item.createdAt === "string" ? item.createdAt : "",
fileName: typeof item.fileName === "string" ? item.fileName : "",
mimeType: typeof item.mimeType === "string" ? item.mimeType : "",
size: Number.isFinite(item.size) ? item.size : 0,
filePath: typeof item.filePath === "string" ? item.filePath : "",
}))
: [];
return { entries };
} catch {
return { entries: [] };
}
}
async saveAttachmentsIndex(state) {
await this.ensureAttachmentsRoot();
const entries = Array.isArray(state.entries) ? state.entries : [];
await fs.writeFile(this.attachmentsIndexPath, JSON.stringify({ entries }, null, 2));
}
async loadAdapterConfig() {
if (!(await fileExists(fs, this.configPath))) {
return {
hostId: "",
hostName: "",
projects: [],
includeSessionDiscoveredProjects: true,
};
}
try {
const parsed = JSON.parse(await fs.readFile(this.configPath, "utf8"));
return {
hostId: typeof parsed.hostId === "string" ? parsed.hostId : "",
hostName: typeof parsed.hostName === "string" ? parsed.hostName : "",
includeSessionDiscoveredProjects: parsed.includeSessionDiscoveredProjects !== false,
projects: Array.isArray(parsed.projects)
? parsed.projects
.filter((item) => item && typeof item === "object" && typeof item.cwd === "string")
.map((item) => ({
id: typeof item.id === "string" && item.id ? item.id : this.toProjectId(item.cwd),
name: typeof item.name === "string" && item.name ? item.name : path.basename(item.cwd) || item.cwd,
cwd: item.cwd,
}))
: [],
};
} catch {
return {
hostId: "",
hostName: "",
projects: [],
includeSessionDiscoveredProjects: true,
};
}
}
async loadAppState() {
const stateFilePath = (await fileExists(fs, this.stateFilePath))
? this.stateFilePath
: this.legacyStateFilePath;
if (!(await fileExists(fs, stateFilePath))) {
return { hiddenSessions: [], projects: [] };
}
try {
const text = await fs.readFile(stateFilePath, "utf8");
const parsed = JSON.parse(text);
const hiddenSessions = Array.isArray(parsed.hiddenSessions)
? parsed.hiddenSessions
.filter((item) => item && typeof item === "object" && typeof item.id === "string")
.map((item) => ({
id: item.id,
projectId: typeof item.projectId === "string" ? item.projectId : "",
projectName: typeof item.projectName === "string" ? item.projectName : "",
cwd: typeof item.cwd === "string" ? item.cwd : "",
title: typeof item.title === "string" ? item.title : "",
lastActivity: typeof item.lastActivity === "string" ? item.lastActivity : "",
hiddenAt: typeof item.hiddenAt === "string" ? item.hiddenAt : "",
}))
: [];
const legacyHiddenIds = Array.isArray(parsed.hiddenSessionIds)
? parsed.hiddenSessionIds.filter((item) => typeof item === "string")
: [];
return {
hiddenSessions: [
...hiddenSessions,
...legacyHiddenIds
.filter((id) => !hiddenSessions.some((item) => item.id === id))
.map((id) => ({
id,
projectId: "",
projectName: "",
cwd: "",
title: "",
lastActivity: "",
hiddenAt: "",
})),
],
projects: Array.isArray(parsed.projects)
? parsed.projects
.filter((item) => item && typeof item === "object" && typeof item.cwd === "string")
.map((item) => ({
id: typeof item.id === "string" && item.id ? item.id : this.toProjectId(item.cwd),
name: typeof item.name === "string" && item.name ? item.name : path.basename(item.cwd) || item.cwd,
cwd: typeof item.cwd === "string" ? item.cwd : "",
addedAt: typeof item.addedAt === "string" ? item.addedAt : "",
}))
: [],
};
} catch {
return { hiddenSessions: [], projects: [] };
}
}
async saveAppState(state) {
const hiddenSessions = Array.isArray(state.hiddenSessions)
? state.hiddenSessions
.filter((item) => item && typeof item === "object" && typeof item.id === "string")
.map((item) => ({
id: item.id,
projectId: typeof item.projectId === "string" ? item.projectId : "",
projectName: typeof item.projectName === "string" ? item.projectName : "",
cwd: typeof item.cwd === "string" ? item.cwd : "",
title: typeof item.title === "string" ? item.title : "",
lastActivity: typeof item.lastActivity === "string" ? item.lastActivity : "",
hiddenAt: typeof item.hiddenAt === "string" ? item.hiddenAt : "",
}))
: [];
const normalizedState = {
hiddenSessions: Array.from(new Map(hiddenSessions.map((item) => [item.id, item])).values()).sort((a, b) =>
a.id.localeCompare(b.id)
),
projects: Array.isArray(state.projects)
? Array.from(
new Map(
state.projects
.filter((item) => item && typeof item === "object" && typeof item.cwd === "string")
.map((item) => [
this.toProjectId(item.cwd),
{
id: typeof item.id === "string" && item.id ? item.id : this.toProjectId(item.cwd),
name: typeof item.name === "string" && item.name ? item.name : path.basename(item.cwd) || item.cwd,
cwd: item.cwd,
addedAt: typeof item.addedAt === "string" ? item.addedAt : "",
},
])
).values()
)
: [],
};
await fs.mkdir(path.dirname(this.stateFilePath), { recursive: true });
await fs.writeFile(this.stateFilePath, JSON.stringify(normalizedState, null, 2));
}
async hideSession(session) {
const [state, projects] = await Promise.all([this.loadAppState(), this.loadProjects()]);
const project = projects.find((item) => item.cwd === session.cwd);
const hiddenSession = {
id: session.id,
projectId: project?.id || this.toProjectId(session.cwd || ""),
projectName: project?.name || path.basename(session.cwd || "") || session.cwd || "",
cwd: session.cwd || "",
title: session.summary || "",
lastActivity: session.lastActivity || "",
hiddenAt: new Date().toISOString(),
};
const existingIndex = state.hiddenSessions.findIndex((item) => item.id === session.id);
if (existingIndex >= 0) {
state.hiddenSessions[existingIndex] = {
...state.hiddenSessions[existingIndex],
...hiddenSession,
};
} else {
state.hiddenSessions.push(hiddenSession);
}
await this.saveAppState(state);
this.invalidateCaches();
}
async walkFiles(rootDir) {
const files = [];
async function visit(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await visit(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(fullPath);
}
}
}
if (await fileExists(fs, rootDir)) {
await visit(rootDir);
}
return files.sort();
}
safeJsonParse(line) {
try {
return JSON.parse(line);
} catch {
return null;
}
}
async findSessionFile(sessionId) {
if (!sessionId) return null;
try {
const today = new Date();
for (let offset = 0; offset <= 3; offset++) {
const d = new Date(today);
d.setDate(d.getDate() - offset);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const dateDir = path.join(this.sessionsRoot, String(yyyy), mm, dd);
if (!(await fileExists(fs, dateDir))) continue;
const entries = await fs.readdir(dateDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
const filePath = path.join(dateDir, entry.name);
try {
const raw = await fs.readFile(filePath, "utf8");
const firstLine = raw.split("\n")[0];
const meta = this.safeJsonParse(firstLine);
if (meta?.type === "session_meta" && meta?.payload?.id === sessionId) {
return filePath;
}
} catch {}
}
}
} catch {}
return null;
}
async stripHandoffFromSession(sessionId) {
if (!sessionId) return;
let sessionFile;
try {
sessionFile = await this.findSessionFile(sessionId);
if (!sessionFile) return;
const content = await fs.readFile(sessionFile, "utf8");
const lines = content.split("\n");
let modified = false;
const newLines = lines.map((line) => {
if (!line.trim()) return line;
const entry = this.safeJsonParse(line);
if (!entry) return line;
const contentArr = entry?.payload?.content;
if (!Array.isArray(contentArr)) return line;
let entryModified = false;
const newContent = contentArr.map((part) => {
if (part?.type === "input_text" && typeof part.text === "string" && /\[handoff\]/i.test(part.text)) {
const newText = part.text
.replace(/\[handoff\][\s\S]*?\[\/handoff\]\s*\n*\s*当前用户请求:\s*/gi, "")
.trimStart();
if (newText !== part.text) {
entryModified = true;
return { ...part, text: newText };
}
}
return part;
});
if (entryModified) {
modified = true;
return JSON.stringify({ ...entry, payload: { ...entry.payload, content: newContent } });
}
return line;
});
if (modified) {
await fs.writeFile(sessionFile, newLines.join("\n"));
console.log(`[runtime] stripped handoff from session file sessionId=${sessionId} file=${sessionFile}`);
}
} catch (err) {
console.error(`[runtime] stripHandoffFromSession error sessionId=${sessionId}:`, err.message);
}
}
extractTextContent(content) {
if (!Array.isArray(content)) {
return "";
}
return content
.map((part) => {
if (!part || typeof part !== "object") return "";
return typeof part.text === "string" ? part.text : "";
})
.filter(Boolean)
.join("\n")
.trim();
}
isMeaningfulText(value) {
if (!value) return false;
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed.includes("<environment_context>") && trimmed.includes("</environment_context>")) {
const stripped = trimmed.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, "").trim();
return Boolean(stripped);
}
return true;
}
summarizeText(value) {
const trimmed = value.trim().replace(/\s+/g, " ");
return trimmed.length > 140 ? `${trimmed.slice(0, 137)}...` : trimmed;
}
normalizeUserMessageText(value) {
return String(value || "")
.trimStart()
.replace(/^\[handoff\][\s\S]*?\[\/handoff\]\s*/i, "")
.replace(/^当前用户请求:\s*/i, "")
.replace(/^Current user request:\s*/i, "")
.replace(/<image[\s\S]*?<\/image>\s*/g, "")
.trim();
}
isInternalUserBootstrapText(value) {
const normalized = this.normalizeUserMessageText(value);
if (!normalized) return true;
if (/^# AGENTS\.md instructions for\b/.test(normalized)) {
return true;
}
if (/^<turn_aborted>[\s\S]*<\/turn_aborted>$/.test(normalized)) {
return true;
}
return false;
}
async parseSessionFile(filePath) {
const text = await fs.readFile(filePath, "utf8");
const lines = text.split("\n").filter(Boolean);
let meta = null;
let lastTimestamp = "";
let summary = "";
const messages = [];
for (const line of lines) {
const entry = this.safeJsonParse(line);
if (!entry) continue;
lastTimestamp = entry.timestamp || lastTimestamp;
if (entry.type === "session_meta" && entry.payload) {
meta = entry.payload;
continue;
}
if (entry.type === "response_item" && entry.payload?.type === "message") {
const role = entry.payload.role;
const textValue = this.extractTextContent(entry.payload.content);
if ((role === "user" || role === "assistant") && this.isMeaningfulText(textValue)) {
const item = {
role,
text: textValue,
timestamp: entry.timestamp || "",
};
messages.push(item);
if (!summary && role === "user" && !this.isInternalUserBootstrapText(textValue)) {
summary = this.summarizeText(this.normalizeUserMessageText(textValue));
}
}
}
}
if (!meta?.id) {
return null;
}
let cwd = meta.cwd || "";
if (cwd) {
try {
cwd = await fs.realpath(cwd);
} catch {
cwd = meta.cwd || "";
}
}
if (!summary) {
const assistantMessage = messages.find((message) => message.role === "assistant");
if (assistantMessage) {
summary = this.summarizeText(assistantMessage.text);
}
}
return {
id: meta.id,
cwd,
model: meta.model_slug || meta.model || "default",
source: meta.source || "unknown",
startedAt: meta.timestamp || "",
lastActivity: lastTimestamp || meta.timestamp || "",
summary,
filePath,
messages,
messageCount: messages.length,
isEmpty: messages.length === 0 || !summary,
};
}
async loadAllSessions() {
const files = await this.walkFiles(this.sessionsRoot);
const sessions = [];
for (const filePath of files) {
const parsed = await this.parseSessionFile(filePath);
if (parsed && !parsed.isEmpty) {
sessions.push(parsed);
}
}
sessions.sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)));
return sessions;
}
async loadSessions(options = {}) {
const useCache = options.useCache !== false;
const now = Date.now();
if (useCache && this.sessionsCache.value && this.sessionsCache.expiresAt > now) {
return this.sessionsCache.value;
}
if (useCache && this.sessionsCache.promise) {
return this.sessionsCache.promise;
}
const loadPromise = Promise.all([this.loadAllSessions(), this.loadAppState()])
.then(([sessions, state]) => {
const hiddenIds = new Set(state.hiddenSessions.map((item) => item.id));
const visibleSessions = sessions.filter((session) => !hiddenIds.has(session.id));
this.sessionsCache.value = visibleSessions;
this.sessionsCache.expiresAt = Date.now() + sessionsCacheTtlMs;
return visibleSessions;
})
.finally(() => {
this.sessionsCache.promise = null;
});
this.sessionsCache.promise = loadPromise;
const sessions = await loadPromise;
if (!useCache) {
this.sessionsCache.expiresAt = 0;
}
return sessions;
}
invalidateCaches() {
this.sessionsCache.expiresAt = 0;
this.hostStatusCache.expiresAt = 0;
}
async readCodexVersion() {
return new Promise((resolve) => {
const child = spawn("codex", ["-V"], { stdio: ["ignore", "pipe", "ignore"] });
let data = "";
child.stdout.on("data", (chunk) => {
data += chunk.toString();
});
child.on("close", () => resolve(data.trim() || "unknown"));
child.on("error", () => resolve("unavailable"));
});
}
async loadHealthStatus(options = {}) {
const config = await this.loadAdapterConfig();
const activeRuns = Array.from(this.runs.values()).filter((run) => run.status === "running").length;
const cachedStatus = this.hostStatusCache.value;
let usage = options.forceRefresh === true ? await this.refreshUsageSnapshot() : await this.readUsageSnapshot();
if (usage && (usage.fiveHourResetsAt == null || usage.weeklyResetsAt == null)) {
usage = await this.refreshUsageSnapshot();
}
return {
ok: true,
id: config.hostId || os.hostname(),
hostname: config.hostName || os.hostname(),
sessionCount: cachedStatus?.sessionCount ?? null,
activeRuns,
adapterLabel: this.hostListenLabel,
currentProjectRoot: this.currentProjectRoot,
usage,
checkedAt: new Date().toISOString(),
};
}
toProjectId(cwd) {
return Buffer.from(cwd).toString("base64url");
}
sessionToListItem(session) {
return {
id: session.id,
cwd: session.cwd,
model: session.model,
source: session.source,
startedAt: session.startedAt,
lastActivity: session.lastActivity,
summary: session.summary,
filePath: session.filePath,
messageCount: session.messageCount,
recentMessages: session.messages.slice(-6),
};
}
async loadProjects() {
const [sessions, config, state] = await Promise.all([this.loadSessions(), this.loadAdapterConfig(), this.loadAppState()]);
const projectMap = new Map();
for (const configuredProject of config.projects) {
let cwd = configuredProject.cwd;
try {
cwd = await fs.realpath(configuredProject.cwd);
} catch {
cwd = configuredProject.cwd;
}
projectMap.set(configuredProject.id, {
id: configuredProject.id,
name: configuredProject.name,
cwd,
sessionCount: 0,
lastActivity: "",
});
}
for (const savedProject of state.projects || []) {
let cwd = savedProject.cwd;
try {
cwd = await fs.realpath(savedProject.cwd);
} catch {}
const projectId = typeof savedProject.id === "string" && savedProject.id ? savedProject.id : this.toProjectId(cwd);
if (!projectMap.has(projectId) && !Array.from(projectMap.values()).some((item) => item.cwd === cwd)) {
projectMap.set(projectId, {
id: projectId,
name: savedProject.name || path.basename(cwd) || cwd,
cwd,
sessionCount: 0,
lastActivity: "",
});
}
}
if (config.includeSessionDiscoveredProjects !== false) {
for (const session of sessions) {
if (!session.cwd) continue;
const existing = Array.from(projectMap.values()).find((item) => item.cwd === session.cwd);
if (!existing) {
const projectId = this.toProjectId(session.cwd);
projectMap.set(projectId, {
id: projectId,
name: path.basename(session.cwd) || session.cwd,
cwd: session.cwd,
sessionCount: 0,
lastActivity: session.lastActivity,
});
}
}
}
const currentProjectId = this.toProjectId(this.currentProjectRoot);
if (!projectMap.has(currentProjectId) && !Array.from(projectMap.values()).some((item) => item.cwd === this.currentProjectRoot)) {
projectMap.set(currentProjectId, {
id: currentProjectId,
name: path.basename(this.currentProjectRoot) || this.currentProjectRoot,
cwd: this.currentProjectRoot,
sessionCount: 0,
lastActivity: "",
});
}
for (const session of sessions) {
const project = Array.from(projectMap.values()).find((item) => item.cwd === session.cwd);
if (!project) continue;
project.sessionCount += 1;
if (String(session.lastActivity) > String(project.lastActivity)) {
project.lastActivity = session.lastActivity;
}
}
return Array.from(projectMap.values()).sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)));
}
getAllowedProjectRoots() {
return Array.from(new Set([os.homedir(), "/mnt"].filter(Boolean).map((item) => path.resolve(item))));
}
isAllowedProjectPath(targetPath) {
const resolved = path.resolve(targetPath);
return this.getAllowedProjectRoots().some((root) => resolved === root || resolved.startsWith(root + path.sep));
}
async registerProject({ cwd, createIfMissing = false, name = "" } = {}) {
const rawCwd = typeof cwd === "string" ? cwd.trim() : "";
if (!rawCwd) {
const error = new Error("Project path is required");
error.statusCode = 400;
throw error;
}
if (!path.isAbsolute(rawCwd)) {
const error = new Error("Project path must be absolute");
error.statusCode = 400;
throw error;
}
const normalizedPath = path.resolve(rawCwd);
if (!this.isAllowedProjectPath(normalizedPath)) {
const error = new Error("Project path is outside allowed roots");
error.statusCode = 403;
throw error;
}
if (createIfMissing) {
await fs.mkdir(normalizedPath, { recursive: true });
}
let stats;
try {
stats = await fs.stat(normalizedPath);
} catch {
const error = new Error("Project path does not exist");
error.statusCode = 404;
throw error;
}
if (!stats.isDirectory()) {
const error = new Error("Project path must be a directory");
error.statusCode = 400;
throw error;
}
let resolvedCwd = normalizedPath;
try {
resolvedCwd = await fs.realpath(normalizedPath);
} catch {}
const project = {
id: this.toProjectId(resolvedCwd),
name: typeof name === "string" && name.trim() ? name.trim() : path.basename(resolvedCwd) || resolvedCwd,
cwd: resolvedCwd,
addedAt: new Date().toISOString(),
};
const state = await this.loadAppState();
state.projects = [
project,
...(Array.isArray(state.projects) ? state.projects.filter((item) => item.id !== project.id && item.cwd !== project.cwd) : []),
];
await this.saveAppState(state);
this.invalidateCaches();
return {
id: project.id,
name: project.name,
cwd: project.cwd,
sessionCount: 0,
lastActivity: "",
};
}
async loadHostStatus(options = {}) {
const useCache = options.useCache !== false;
const now = Date.now();
if (useCache && this.hostStatusCache.value && this.hostStatusCache.expiresAt > now) {
return {
...this.hostStatusCache.value,
activeRuns: Array.from(this.runs.values()).filter((run) => run.status === "running").length,
};
}
if (useCache && this.hostStatusCache.promise) {
const cached = await this.hostStatusCache.promise;
return {
...cached,
activeRuns: Array.from(this.runs.values()).filter((run) => run.status === "running").length,
};
}
const loadPromise = Promise.all([this.loadAdapterConfig(), this.readCodexVersion(), this.loadSessions({ useCache })])
.then(([config, version, sessions]) => {
const status = {
id: config.hostId || os.hostname(),
hostname: config.hostName || os.hostname(),
codexVersion: version,
codexHome: this.codexHome,
sessionsRoot: this.sessionsRoot,
sessionCount: sessions.length,
activeRuns: Array.from(this.runs.values()).filter((run) => run.status === "running").length,
currentProjectRoot: this.currentProjectRoot,
adapterConfigPath: this.configPath,
adapterLabel: this.hostListenLabel,
};
this.hostStatusCache.value = status;
this.hostStatusCache.expiresAt = Date.now() + hostStatusCacheTtlMs;
return status;
})
.finally(() => {
this.hostStatusCache.promise = null;
});
this.hostStatusCache.promise = loadPromise;
const status = await loadPromise;
if (!useCache) {
this.hostStatusCache.expiresAt = 0;
}
return status;
}
createRun(command, args, cwd, sessionId = null) {
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
const run = {
id: runId,
status: "running",
startedAt: new Date().toISOString(),
cwd,
sessionId,
command,
args,
events: [],
nextEventSeq: 1,
clients: new Set(),
child,
threadId: null,
finishedAt: null,
};
this.runs.set(runId, run);
const publish = (event) => {
const eventWithSeq = { seq: run.nextEventSeq++, ...event };
run.events.push(eventWithSeq);
if (run.events.length > 500) {
run.events.shift();
}
const payload = `data: ${JSON.stringify(eventWithSeq)}\n\n`;
for (const client of run.clients) {
client.write(payload);
}
};
const parseStdoutLine = (line) => {
const trimmed = line.trim();
if (!trimmed) return;
const parsed = this.safeJsonParse(trimmed);
if (parsed) {
if (parsed.type === "thread.started" && parsed.thread_id) {
run.threadId = parsed.thread_id;
}
publish({ stream: "stdout", ...parsed });
return;
}
publish({ stream: "stdout", type: "raw", text: trimmed });
};
let stdoutBuffer = "";
child.stdout.on("data", (chunk) => {
stdoutBuffer += chunk.toString();
const parts = stdoutBuffer.split("\n");
stdoutBuffer = parts.pop() || "";
for (const line of parts) parseStdoutLine(line);
});
let stderrBuffer = "";
child.stderr.on("data", (chunk) => {
stderrBuffer += chunk.toString();
const parts = stderrBuffer.split("\n");
stderrBuffer = parts.pop() || "";
for (const line of parts) {
const trimmed = line.trim();
if (trimmed) publish({ stream: "stderr", type: "raw", text: trimmed });
}
});
child.on("close", (code, signal) => {
run.status = code === 0 ? "completed" : "failed";
run.finishedAt = new Date().toISOString();
this.invalidateCaches();
publish({
type: "run.completed",
code,
signal,
status: run.status,
threadId: run.threadId || null,
});
});
child.on("error", (error) => {
run.status = "failed";
run.finishedAt = new Date().toISOString();
this.invalidateCaches();
publish({
type: "run.error",
message: error.message,
});
});
return run;
}
getRun(runId) {
return this.runs.get(runId) || null;
}
getRunSummary(runId) {
const run = this.getRun(runId);
if (!run) return null;
return {
id: run.id,
status: run.status,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
cwd: run.cwd,
sessionId: run.sessionId,
threadId: run.threadId,
command: run.command,
args: run.args,
};
}
async ensureAttachmentsRoot() {
await fs.mkdir(this.attachmentsRoot, { recursive: true });
}
async ensureSessionAttachmentDir(sessionId) {
const dir = path.join(this.attachmentsRoot, sessionId);
await fs.mkdir(dir, { recursive: true });
return dir;
}
normalizeAttachmentName(fileName = "", mimeType = "") {
const baseName = path.basename(String(fileName || "image")).replace(/[^a-zA-Z0-9._-]/g, "_") || "image";
const extension = attachmentExtensionByMime[mimeType] || path.extname(baseName) || ".bin";
const stem = baseName.replace(/\.[^.]+$/, "") || "image";
return `${stem}${extension}`;
}
async createAttachment({ fileName, mimeType, contentBase64 }) {
if (!mimeType || !attachmentExtensionByMime[mimeType]) {
const error = new Error("Unsupported image type");
error.statusCode = 400;
throw error;
}
if (!contentBase64 || typeof contentBase64 !== "string") {
const error = new Error("Attachment content is required");
error.statusCode = 400;
throw error;
}
const buffer = Buffer.from(contentBase64, "base64");
if (!buffer.length) {
const error = new Error("Attachment content is empty");
error.statusCode = 400;
throw error;
}
if (buffer.length > maxAttachmentBytes) {
const error = new Error("Attachment exceeds 10MB limit");
error.statusCode = 400;
throw error;
}
await this.ensureAttachmentsRoot();
const id = crypto.randomUUID();
const normalizedName = this.normalizeAttachmentName(fileName, mimeType);
const filePath = path.join(this.attachmentsRoot, `${id}-${normalizedName}`);
await fs.writeFile(filePath, buffer);
const attachment = {
id,
fileName: normalizedName,
mimeType,
size: buffer.length,
filePath,
createdAt: new Date().toISOString(),
};
this.attachments.set(id, attachment);
console.log(
`[runtime] attachment stored attachmentId=${attachment.id} filePath=${attachment.filePath} mimeType=${attachment.mimeType} size=${attachment.size}`
);
return {
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
size: attachment.size,
createdAt: attachment.createdAt,
};
}
async removeAttachment(id) {
const attachment = this.attachments.get(id);
if (attachment) {
this.attachments.delete(id);
await fs.rm(attachment.filePath, { force: true });
console.log(`[runtime] staged attachment removed attachmentId=${id} filePath=${attachment.filePath}`);
return true;
}
const index = await this.loadAttachmentsIndex();
const entry = index.entries.find((item) => item.id === id);
if (!entry) return false;
index.entries = index.entries.filter((item) => item.id !== id);
await fs.rm(entry.filePath, { force: true });
await this.saveAttachmentsIndex(index);
console.log(`[runtime] persisted attachment removed attachmentId=${id} filePath=${entry.filePath}`);
return true;
}
async resolveAttachments(attachmentIds) {
const ids = Array.isArray(attachmentIds) ? attachmentIds.filter((item) => typeof item === "string" && item) : [];
if (ids.length > maxAttachmentCount) {
const error = new Error("Too many attachments");
error.statusCode = 400;
throw error;
}
const resolved = [];
for (const id of ids) {
const staged = this.attachments.get(id);
if (staged) { resolved.push(staged); continue; }
const persisted = await this.getPersistedAttachment(id);
if (!persisted) {
const error = new Error("Attachment not found or expired");
error.statusCode = 404;
throw error;
}
resolved.push(persisted);
}
return resolved;
}
async cleanupFiles(filePaths) {
await Promise.all(
(Array.isArray(filePaths) ? filePaths : []).map(async (filePath) => {
try {
await fs.rm(filePath, { force: true });
console.log(`[runtime] attachment cleanup removed filePath=${filePath}`);
} catch (error) {
console.warn(`[runtime] attachment cleanup failed filePath=${filePath} error=${error instanceof Error ? error.message : String(error)}`);
}
})
);
}
async archiveRunAttachments({ sessionId, prompt, createdAt, attachments }) {
if (!sessionId || !attachments.length) return;
const index = await this.loadAttachmentsIndex();
const sessionDir = await this.ensureSessionAttachmentDir(sessionId);
const messageKey = crypto.randomUUID();
const archivedEntries = [];
for (const attachment of attachments) {
const normalizedName = this.normalizeAttachmentName(attachment.fileName, attachment.mimeType);
const targetPath = path.join(sessionDir, `${attachment.id}-${normalizedName}`);
if (attachment.filePath !== targetPath) {
await fs.rename(attachment.filePath, targetPath);
}
archivedEntries.push({
id: attachment.id,
sessionId,
messageKey,
prompt,
createdAt,
fileName: normalizedName,
mimeType: attachment.mimeType,
size: attachment.size,
filePath: targetPath,
});
console.log(
`[runtime] attachment archived attachmentId=${attachment.id} sessionId=${sessionId} messageKey=${messageKey} filePath=${targetPath}`
);
}
index.entries.push(...archivedEntries);
const sessionEntries = index.entries
.filter((item) => item.sessionId === sessionId)
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
if (sessionEntries.length > maxPersistedSessionImages) {
const overflow = sessionEntries.slice(0, sessionEntries.length - maxPersistedSessionImages);
const overflowIds = new Set(overflow.map((item) => item.id));
for (const item of overflow) {
await fs.rm(item.filePath, { force: true });
console.log(`[runtime] attachment evicted attachmentId=${item.id} sessionId=${sessionId} filePath=${item.filePath}`);
}
index.entries = index.entries.filter((item) => !overflowIds.has(item.id));
}
await this.saveAttachmentsIndex(index);
}
async getSessionAttachmentGroups(sessionId, messages) {
const index = await this.loadAttachmentsIndex();
const sessionEntries = index.entries
.filter((item) => item.sessionId === sessionId)
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
const groups = new Map();
for (const entry of sessionEntries) {
const current = groups.get(entry.messageKey) || {
messageKey: entry.messageKey,
prompt: entry.prompt,
createdAt: entry.createdAt,
attachments: [],
};
current.attachments.push({
id: entry.id,
fileName: entry.fileName,
mimeType: entry.mimeType,
size: entry.size,
createdAt: entry.createdAt,
});
groups.set(entry.messageKey, current);
}
const orderedGroups = Array.from(groups.values()).sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
let groupIndex = 0;
const enrichedMessages = (messages || []).map((message) => {
if (message.role !== "user") {
return message;
}
const clone = { ...message, attachments: [] };
const group = orderedGroups[groupIndex];
if (group && group.prompt === this.normalizeUserMessageText(message.text)) {
clone.attachments = group.attachments;
clone.messageKey = group.messageKey;
groupIndex += 1;
}
return clone;
});
return enrichedMessages;
}
async getPersistedAttachment(id) {
return lookupAttachment(id, this.attachments, this.runs.values(), () => this.loadAttachmentsIndex());
}
async startRun({ mode, projectId, sessionId, prompt, attachmentIds = [], cwd: cwdParam, handoffSummary = "" }) {
const projects = await this.loadProjects();
let project = projects.find((item) => item.id === projectId);
if (!project && cwdParam) {
project = projects.find((item) => item.cwd === cwdParam)
|| { id: null, cwd: cwdParam, name: path.basename(cwdParam) || cwdParam };
}
if (!project) {
const error = new Error("Project not found");
error.statusCode = 404;
throw error;
}
if (!prompt || typeof prompt !== "string") {
const error = new Error("Prompt is required");
error.statusCode = 400;
throw error;
}
const attachments = await this.resolveAttachments(attachmentIds);
const imageArgs = attachments.flatMap((attachment) => ["--image", attachment.filePath]);
const { selectedModel, usage } = await this.getModelSelection();
const promptWithHandoff = buildPromptWithHandoff(prompt, handoffSummary);
console.log(
`[runtime] preparing run mode=${mode} projectId=${projectId} sessionId=${sessionId || ""} model=${selectedModel} usageRemaining=${usage?.effectiveRemainingPercent ?? "n/a"} attachmentCount=${attachments.length} attachmentIds=${attachments.map((item) => item.id).join(",")}`
);
let args;
if (mode === "resume") {
if (!sessionId) {
const error = new Error("Session is required for resume");
error.statusCode = 400;
throw error;
}
args = [
"-C",
project.cwd,
"exec",
"resume",
"-m",
selectedModel,
"-c",
`model_reasoning_effort="${defaultReasoningEffort}"`,
"--all",
"--json",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
...imageArgs,
"--",
sessionId,
promptWithHandoff,
];
} else {
args = [
"-C",
project.cwd,
"exec",
"-m",
selectedModel,
"-c",
`model_reasoning_effort="${defaultReasoningEffort}"`,
"--json",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
...imageArgs,
"--",
promptWithHandoff,
];
}
const run = this.createRun("codex", args, project.cwd, sessionId || null);
for (const attachment of attachments) {
this.attachments.delete(attachment.id);
}
console.log(
`[runtime] run created runId=${run.id} command=${["codex", ...args].join(" ")}`
);
run.pendingAttachments = attachments.map((attachment) => ({ ...attachment }));
run.prompt = prompt;
run.handoffSummary = handoffSummary || "";
const cleanup = async () => {
const targetSessionId = run.threadId || sessionId || null;
const pending = run.pendingAttachments || [];
if (run.status === "completed" && targetSessionId && pending.length) {
await this.archiveRunAttachments({
sessionId: targetSessionId,
prompt,
createdAt: run.startedAt,
attachments: pending,
});
} else {
await this.cleanupFiles(pending.map((attachment) => attachment.filePath));
}
run.pendingAttachments = null;
if (run.status === "completed" && targetSessionId && run.handoffSummary) {
await this.stripHandoffFromSession(targetSessionId);
}
};
run.child.once("close", () => {
this.invalidateCaches();
void cleanup();
void this.refreshUsageSnapshot();
});
run.child.once("error", () => {
this.invalidateCaches();
void cleanup();
void this.refreshUsageSnapshot();
});
return {
runId: run.id,
cwd: project.cwd,
command: ["codex", ...args].join(" "),
model: selectedModel,
usage,
};
}
}