const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { spawn, spawnSync } = require('child_process');
const { WebSocketServer } = require('ws');
const { createAgentRuntime } = require('./lib/agent-runtime');
const { createCodexAppServerClient } = require('./lib/codex-app-server-client');
const { createCodexAppWorkerClient } = require('./lib/codex-app-worker-client');
const { createCodexAppRuntime } = require('./lib/codex-app-runtime');
const { createCodexRolloutStore } = require('./lib/codex-rollouts');
const { TOOLS: CCWEB_MCP_TOOLS } = require('./lib/ccweb-mcp-server');
const CCWEB_MCP_SERVER_INFO = { name: 'ccweb', version: '1.0.0' };

if (process.argv.includes('--ccweb-mcp-server')) {
  require('./lib/ccweb-mcp-server').runStdioServer();
  return;
}

if (process.argv.includes('--codex-app-worker')) {
  require('./lib/codex-app-worker');
  return;
}

function resolveAppDir() {
  const explicit = String(process.env.CC_WEB_APP_DIR || '').trim();
  if (explicit) return path.resolve(explicit);

  const execDir = process.execPath ? path.dirname(process.execPath) : '';
  if (process.versions?.bun && execDir && fs.existsSync(path.join(execDir, 'public'))) {
    return execDir;
  }

  return __dirname;
}

const APP_DIR = resolveAppDir();
const IS_BUN_SINGLE_EXECUTABLE = !!process.versions?.bun
  && process.execPath
  && !/^bun(?:\.exe)?$/i.test(path.basename(process.execPath));
const CCWEB_MCP_SERVER_ARG = '--ccweb-mcp-server';
const CODEX_APP_WORKER_ARG = '--codex-app-worker';

function ccwebMcpServerCommandSpec() {
  if (IS_BUN_SINGLE_EXECUTABLE) {
    return { command: process.execPath, args: [CCWEB_MCP_SERVER_ARG] };
  }
  return {
    command: process.execPath,
    args: [path.join(APP_DIR, 'server.js'), CCWEB_MCP_SERVER_ARG],
  };
}

// Load .env
const envPath = path.join(APP_DIR, '.env');
if (fs.existsSync(envPath)) {
  for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
    const m = line.match(/^([^#=]+)=(.*)$/);
    if (m && !process.env[m[1].trim()]) process.env[m[1].trim()] = m[2].trim();
  }
}

function readPositiveIntEnv(name, fallback, options = {}) {
  const raw = Number.parseInt(String(process.env[name] || ''), 10);
  const min = Number.isFinite(options.min) ? options.min : 1;
  const max = Number.isFinite(options.max) ? options.max : Number.MAX_SAFE_INTEGER;
  if (!Number.isFinite(raw) || raw <= 0) return fallback;
  return Math.max(min, Math.min(max, raw));
}

function normalizeCodexAppCcwebMcpTransport(value) {
  const raw = String(value || '').trim().toLowerCase();
  if (raw === 'stdio') return 'stdio';
  return 'streamable_http';
}

const PORT = parseInt(process.env.PORT) || 8002;
const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
const CODEX_PATH = process.env.CODEX_PATH || 'codex';
const INTERNAL_MCP_TOKEN = process.env.CC_WEB_INTERNAL_MCP_TOKEN || crypto.randomBytes(32).toString('hex');
const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(APP_DIR, 'config');
const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(APP_DIR, 'sessions');
const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(APP_DIR, 'public');
const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(APP_DIR, 'logs');
const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments');
const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const MAX_MESSAGE_ATTACHMENTS = 4;
const FILE_BROWSER_MAX_LIST_ENTRIES = 400;
const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024;
const COMPOSER_SUGGESTION_LIMIT = 20;
const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024;
const COMPOSER_MAX_FILE_MENTIONS = 4;
const COMPOSER_MAX_PROMPT_MENTIONS = 4;
const COMPOSER_SKILL_METADATA_MAX_BYTES = 64 * 1024;
const COMPOSER_SKILL_DEFAULT_PROMPT_PREVIEW_CHARS = 180;
const COMPOSER_SKILL_TEXT_MAX_CHARS = 240;
const SESSION_MAX_JSON_BYTES = readPositiveIntEnv('CC_WEB_SESSION_MAX_JSON_BYTES', 10 * 1024 * 1024, { min: 512 * 1024 });
const SESSION_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_LOAD_MAX_BYTES', 32 * 1024 * 1024, { min: SESSION_MAX_JSON_BYTES });
const SESSION_META_FULL_PARSE_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES', 512 * 1024, { min: 64 * 1024 });
const SESSION_META_PREVIEW_BYTES = readPositiveIntEnv('CC_WEB_SESSION_META_PREVIEW_BYTES', 128 * 1024, { min: 16 * 1024 });
const SESSION_PERSIST_MAX_MESSAGES = readPositiveIntEnv('CC_WEB_SESSION_PERSIST_MAX_MESSAGES', 180, { min: 20, max: 2000 });
const SESSION_MESSAGE_CONTENT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS', 96 * 1024, { min: 4096 });
const SESSION_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 });
const SESSION_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 });
const SESSION_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_MAX_TOOL_CALLS_PER_MESSAGE', 80, { min: 1, max: 1000 });
const SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS', 64 * 1024, { min: 4096 });
const SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS', 8 * 1024, { min: 1024 });
const SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS', 16 * 1024, { min: 1024 });
const SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE = readPositiveIntEnv('CC_WEB_SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE', 40, { min: 1, max: 1000 });
const HISTORY_PREFETCH_CHUNKS = readPositiveIntEnv('CC_WEB_HISTORY_PREFETCH_CHUNKS', 3, { min: 0, max: 20 });
const HISTORY_MAX_CHUNKS_PER_LOAD = readPositiveIntEnv('CC_WEB_HISTORY_MAX_CHUNKS_PER_LOAD', 8, { min: 1, max: 100 });
const CODEX_APP_STATE_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_BYTES', 2 * 1024 * 1024, { min: 128 * 1024 });
const CODEX_APP_STATE_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_LOAD_MAX_BYTES', 4 * 1024 * 1024, { min: CODEX_APP_STATE_MAX_BYTES });
const CODEX_APP_STATE_FULL_TEXT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', 192 * 1024, { min: 4096 });
const CODEX_APP_STATE_MAP_VALUE_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAP_VALUE_MAX_CHARS', 16 * 1024, { min: 1024 });
const CODEX_APP_STATE_MAX_MAP_ENTRIES = readPositiveIntEnv('CC_WEB_CODEX_APP_STATE_MAX_MAP_ENTRIES', 80, { min: 1, max: 1000 });
const RUN_OUTPUT_RECOVERY_MAX_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_RECOVERY_MAX_BYTES', 16 * 1024 * 1024, { min: 1024 * 1024 });
const RUN_OUTPUT_TAILER_MAX_READ_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_TAILER_MAX_READ_BYTES', 2 * 1024 * 1024, { min: 64 * 1024 });
const CROSS_CONVERSATION_MAX_CONTENT_CHARS = readPositiveIntEnv('CC_WEB_CROSS_CONVERSATION_MAX_CONTENT_CHARS', 64 * 1024, { min: 1024 });
const MCP_CREATE_CONVERSATION_MAX_HOP_COUNT = readPositiveIntEnv('CC_WEB_MCP_CREATE_CONVERSATION_MAX_HOP_COUNT', 3, { min: 1, max: 20 });
const CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS', 3, { min: 1, max: 10 });
const CODEX_TRANSIENT_RETRY_BASE_DELAY_MS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS', 2000, { min: 100, max: 60000 });
const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4000;
const CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV = 'CC_WEB_CODEX_APP_MCP_TOKEN';
const CODEX_APP_CCWEB_MCP_TRANSPORT = normalizeCodexAppCcwebMcpTransport(process.env.CC_WEB_CODEX_APP_CCWEB_MCP_TRANSPORT);
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
const CODEX_APP_PROCESS_ENV_STRIP_KEYS = [
  'CC_WEB_MCP_URL',
  'CC_WEB_MCP_TOKEN',
  CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV,
  'CC_WEB_SOURCE_SESSION_ID',
  'CC_WEB_CROSS_HOP_COUNT',
  'CODEX_THREAD_ID',
  'CODEX_CI',
];
const PROCESS_CLEAN_PATH_FALLBACK = [
  path.join(os.homedir(), '.local/bin'),
  '/usr/local/sbin',
  '/usr/local/bin',
  '/usr/sbin',
  '/usr/bin',
  '/sbin',
  '/bin',
].join(path.delimiter);

function isCodexInjectedPathEntry(value) {
  const normalized = String(value || '').replace(/\\/g, '/');
  if (!normalized) return true;
  if (normalized.includes('/.codex/tmp/arg0')) return true;
  return normalized.includes('/node_modules/@openai/codex/')
    && /\/(?:codex-path|path)$/.test(normalized);
}

function cleanProcessPathValue(value) {
  const override = String(process.env.CC_WEB_PROCESS_CLEAN_PATH || '').trim();
  const source = override || value || PROCESS_CLEAN_PATH_FALLBACK;
  const fallbackEntries = PROCESS_CLEAN_PATH_FALLBACK.split(path.delimiter).filter(Boolean);
  const output = [];
  const seen = new Set();
  const addEntry = (entry) => {
    const text = String(entry || '').trim();
    if (!text || seen.has(text) || isCodexInjectedPathEntry(text)) return;
    seen.add(text);
    output.push(text);
  };

  String(source).split(path.delimiter).forEach(addEntry);
  fallbackEntries.forEach(addEntry);
  return output.length > 0 ? output.join(path.delimiter) : PROCESS_CLEAN_PATH_FALLBACK;
}

const PERSIST_TRUNCATED_TEXT = '[cc-web: 内容过大，已截断以保护服务稳定性]';
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const TEXT_PREVIEW_EXTENSIONS = new Set([
  '.txt', '.md', '.markdown', '.json', '.jsonl', '.js', '.jsx', '.ts', '.tsx',
  '.css', '.scss', '.less', '.html', '.htm', '.xml', '.svg', '.yml', '.yaml',
  '.toml', '.ini', '.conf', '.config', '.env', '.log', '.sh', '.bash', '.zsh',
  '.py', '.rb', '.go', '.java', '.kt', '.c', '.cc', '.cpp', '.h', '.hpp',
  '.cs', '.sql', '.csv', '.tsx', '.vue', '.svelte', '.lock',
]);
const NOTIFY_CONFIG_PATH = path.join(CONFIG_DIR, 'notify.json');
const AUTH_CONFIG_PATH = path.join(CONFIG_DIR, 'auth.json');
const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json');
const CODEX_CONFIG_PATH = path.join(CONFIG_DIR, 'codex.json');
const PROMPTS_CONFIG_PATH = path.join(CONFIG_DIR, 'prompts.json');
const BANNED_IPS_PATH = path.join(CONFIG_DIR, 'banned_ips.json');
const CROSS_CONVERSATION_REPLIES_PATH = path.join(CONFIG_DIR, 'cross-conversation-replies.json');

fs.mkdirSync(SESSIONS_DIR, { recursive: true });
fs.mkdirSync(LOGS_DIR, { recursive: true });
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });

// === Process Lifecycle Logger ===
const LOG_FILE = path.join(LOGS_DIR, 'process.log');
const LOG_MAX_SIZE = 2 * 1024 * 1024; // 2MB per file

function plog(level, event, data = {}) {
  const entry = {
    ts: new Date().toISOString(),
    level,
    event,
    ...data,
  };
  const line = JSON.stringify(entry) + '\n';
  try {
    // Simple rotation: if file > 2MB, rename to .old and start fresh
    try {
      const stat = fs.statSync(LOG_FILE);
      if (stat.size > LOG_MAX_SIZE) {
        const oldFile = LOG_FILE.replace('.log', '.old.log');
        try { fs.unlinkSync(oldFile); } catch {}
        fs.renameSync(LOG_FILE, oldFile);
      }
    } catch {}
    fs.appendFileSync(LOG_FILE, line);
  } catch {}
}

// === Notification System ===
const DEFAULT_SUMMARY_CONFIG = {
  enabled: false,
  trigger: 'background', // 'background' | 'always'
  apiSource: 'claude',   // 'claude' | 'codex' | 'custom'
  apiBase: '',
  apiKey: '',
  model: '',
};

function loadNotifyConfig() {
  try {
    if (fs.existsSync(NOTIFY_CONFIG_PATH)) {
      const raw = JSON.parse(fs.readFileSync(NOTIFY_CONFIG_PATH, 'utf8'));
      // Ensure summary field exists for older configs
      if (!raw.summary) raw.summary = { ...DEFAULT_SUMMARY_CONFIG };
      return raw;
    }
  } catch {}
  // First run: migrate from .env PUSHPLUS_TOKEN
  const token = process.env.PUSHPLUS_TOKEN || '';
  const config = {
    provider: token ? 'pushplus' : 'off',
    pushplus: { token },
    telegram: { botToken: '', chatId: '' },
    serverchan: { sendKey: '' },
    feishu: { webhook: '' },
    qqbot: { qmsgKey: '' },
    summary: { ...DEFAULT_SUMMARY_CONFIG },
  };
  saveNotifyConfig(config);
  return config;
}

function saveNotifyConfig(config) {
  fs.writeFileSync(NOTIFY_CONFIG_PATH, JSON.stringify(config, null, 2));
}

function maskToken(str) {
  if (!str || str.length <= 8) return str ? '****' : '';
  return str.slice(0, 4) + '****' + str.slice(-4);
}

function getNotifyConfigMasked() {
  const config = loadNotifyConfig();
  const s = config.summary || {};
  return {
    provider: config.provider,
    pushplus: { token: maskToken(config.pushplus?.token) },
    telegram: { botToken: maskToken(config.telegram?.botToken), chatId: config.telegram?.chatId || '' },
    serverchan: { sendKey: maskToken(config.serverchan?.sendKey) },
    feishu: { webhook: maskToken(config.feishu?.webhook) },
    qqbot: { qmsgKey: maskToken(config.qqbot?.qmsgKey) },
    summary: {
      enabled: !!s.enabled,
      trigger: s.trigger || 'background',
      apiSource: s.apiSource || 'claude',
      apiBase: s.apiBase || '',
      apiKey: maskToken(s.apiKey),
      model: s.model || '',
    },
  };
}

// === Notification Summary ===

// Per-channel content length limits (chars)
const NOTIFY_CONTENT_LIMITS = {
  telegram: 3800,
  qqbot: 3800,
  serverchan: 30000,
  pushplus: 18000,
  feishu: 18000,
};

function truncateForChannel(text, provider) {
  const limit = NOTIFY_CONTENT_LIMITS[provider] || 18000;
  if (text.length <= limit) return text;
  return text.slice(0, limit - 20) + '\n\n[内容已截断]';
}

function getSummaryApiCredentials(summaryConfig) {
  // Returns { apiBase, apiKey, model } or null
  const src = summaryConfig.apiSource || 'claude';
  if (src === 'claude') {
    const modelCfg = loadModelConfig();
    if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
      const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate);
      if (tpl && tpl.apiKey && tpl.apiBase) {
        return { apiBase: tpl.apiBase, apiKey: tpl.apiKey, model: tpl.defaultModel || tpl.opusModel || '' };
      }
    }
    return null; // local mode — no API credentials available
  }
  if (src === 'codex') {
    const codexCfg = loadCodexConfig();
    if (codexCfg.mode === 'custom' && codexCfg.activeProfile) {
      const profile = (codexCfg.profiles || []).find(p => p.name === codexCfg.activeProfile);
      if (profile && profile.apiKey && profile.apiBase) {
        return { apiBase: profile.apiBase, apiKey: profile.apiKey, model: summaryConfig.model || '' };
      }
    }
    return null;
  }
  if (src === 'custom') {
    if (summaryConfig.apiBase && summaryConfig.apiKey) {
      return { apiBase: summaryConfig.apiBase, apiKey: summaryConfig.apiKey, model: summaryConfig.model || '' };
    }
    return null;
  }
  return null;
}

function callSummaryApi(creds, prompt) {
  return new Promise((resolve) => {
    try {
      const base = creds.apiBase.replace(/\/+$/, '');
      const url = new URL(base + '/v1/chat/completions');
      const mod = url.protocol === 'https:' ? require('https') : require('http');
      const model = creds.model || 'claude-opus-4-6';
      const body = JSON.stringify({
        model,
        max_tokens: 1024,
        messages: [{ role: 'user', content: prompt }],
      });
      const req = mod.request(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${creds.apiKey}`,
          'Content-Length': Buffer.byteLength(body),
        },
        timeout: 20000,
      }, (res) => {
        let data = '';
        res.on('data', c => data += c);
        res.on('end', () => {
          try {
            const json = JSON.parse(data);
            const text = json.choices?.[0]?.message?.content || json.content?.[0]?.text || '';
            resolve({ ok: !!text, text: text.trim() });
          } catch {
            resolve({ ok: false, text: '' });
          }
        });
      });
      req.on('error', () => resolve({ ok: false, text: '' }));
      req.on('timeout', () => { req.destroy(); resolve({ ok: false, text: '' }); });
      req.write(body);
      req.end();
    } catch {
      resolve({ ok: false, text: '' });
    }
  });
}

function buildSummaryPrompt(sessionTitle, lastUserMsg, fullText, isError, errorDesc) {
  const userSnip = (lastUserMsg || '').slice(0, 300);
  const outputSnip = (fullText || '').slice(0, 15000);
  const base = `会话：${sessionTitle}\n用户请求：${userSnip}\n\n以下是助手的输出内容：\n${outputSnip}`;
  if (isError) {
    return base + `\n\n错误信息：${(errorDesc || '').slice(0, 300)}\n\n` +
      `请用纯文本简要说明本次任务做了什么、遇到了什么问题。` +
      `要求：1. 不超过 200 字  2. 可以有序号和适当分段  3. 不要罗列具体代码、函数名、文件路径等细节  4. 不使用 markdown 格式（无星号、井号、横线等符号）`;
  }
  return base + `\n\n请用纯文本简要说明本次任务做了什么、结论是否成功。` +
    `要求：1. 不超过 200 字  2. 可以有序号和适当分段  3. 不要罗列具体代码、函数名、文件路径等细节  4. 不使用 markdown 格式（无星号、井号、横线等符号）`;
}

async function buildNotifyContent(entry, session, completionError, contextLimitExceeded) {
  const title = session?.title || 'Untitled';
  const agent = entry.agent || 'claude';
  const agentLabel = agent === 'codexapp' ? 'Codex App' : agent === 'codex' ? 'Codex' : 'Claude';
  const hasTools = (entry.toolCalls || []).length > 0;

  // Determine notify title
  let notifyTitle;
  if (contextLimitExceeded) {
    notifyTitle = `⚠ ${title} 上下文已压缩`;
  } else if (completionError) {
    notifyTitle = `✗ ${title} 任务异常`;
  } else if (hasTools) {
    notifyTitle = `✓ ${title} 任务完成`;
  } else {
    notifyTitle = `✓ ${title} 回复就绪`;
  }

  // Context limit: fixed message, no AI
  if (contextLimitExceeded) {
    return { title: notifyTitle, content: `${agentLabel} 会话上下文已达上限，已自动触发压缩。\n会话: ${title}` };
  }

  // Check if summary is enabled and applicable
  const notifyCfg = loadNotifyConfig();
  const summaryCfg = notifyCfg.summary || {};
  const summaryEnabled = !!summaryCfg.enabled;

  if (!summaryEnabled) {
    // Fallback: simple content
    const lines = [`会话: ${title}`];
    if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`);
    return { title: notifyTitle, content: lines.join('\n') };
  }

  const creds = getSummaryApiCredentials(summaryCfg);
  if (!creds) {
    // No credentials — fallback
    const lines = [`会话: ${title}`];
    if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`);
    return { title: notifyTitle, content: lines.join('\n') };
  }

  // Get last user message from session
  const messages = session?.messages || [];
  const lastUser = [...messages].reverse().find(m => m.role === 'user');
  const lastUserMsg = typeof lastUser?.content === 'string' ? lastUser.content : '';

  const prompt = buildSummaryPrompt(title, lastUserMsg, entry.fullText || '', !!completionError, completionError || '');
  const result = await callSummaryApi(creds, prompt);

  let bodyText;
  if (result.ok && result.text) {
    bodyText = result.text;
  } else {
    // Fallback on API failure
    const lines = [`会话: ${title}`];
    if (completionError) lines.push(`错误: ${completionError.slice(0, 200)}`);
    if (!result.ok) lines.push('（摘要生成失败，以上为原始信息）');
    bodyText = lines.join('\n');
  }

  return { title: notifyTitle, content: bodyText };
}

function sendNotification(title, content) {
  const config = loadNotifyConfig();
  if (!config.provider || config.provider === 'off') return Promise.resolve({ ok: true, skipped: true });
  const https = require('https');
  const truncated = truncateForChannel(content, config.provider);

  return new Promise((resolve) => {
    let url, data;
    let isFormData = false;
    switch (config.provider) {
      case 'pushplus': {
        if (!config.pushplus?.token) return resolve({ ok: false, error: 'PushPlus token 未配置' });
        url = 'https://www.pushplus.plus/send';
        data = JSON.stringify({ token: config.pushplus.token, title, content: truncated, template: 'txt' });
        break;
      }
      case 'telegram': {
        if (!config.telegram?.botToken || !config.telegram?.chatId) return resolve({ ok: false, error: 'Telegram botToken 或 chatId 未配置' });
        url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
        data = JSON.stringify({ chat_id: config.telegram.chatId, text: `${title}\n\n${truncated}` });
        break;
      }
      case 'serverchan': {
        if (!config.serverchan?.sendKey) return resolve({ ok: false, error: 'Server酱 sendKey 未配置' });
        url = `https://sctapi.ftqq.com/${config.serverchan.sendKey}.send`;
        data = JSON.stringify({ title, desp: truncated });
        break;
      }
      case 'feishu': {
        if (!config.feishu?.webhook) return resolve({ ok: false, error: '飞书 Webhook 未配置' });
        url = config.feishu.webhook;
        data = JSON.stringify({ msg_type: 'text', content: { text: `${title}\n\n${truncated}` } });
        break;
      }
      case 'qqbot': {
        if (!config.qqbot?.qmsgKey) return resolve({ ok: false, error: 'Qmsg Key 未配置' });
        url = `https://qmsg.zendee.cn/send/${config.qqbot.qmsgKey}`;
        data = `msg=${encodeURIComponent(`${title}\n\n${truncated}`)}`;
        isFormData = true;
        break;
      }
      default:
        return resolve({ ok: false, error: `未知通知方式: ${config.provider}` });
    }

    const parsed = new URL(url);
    const contentType = isFormData ? 'application/x-www-form-urlencoded' : 'application/json';
    const reqOptions = {
      method: 'POST',
      headers: { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(data) },
    };
    const req = https.request(parsed, reqOptions, (res) => {
      let body = '';
      res.on('data', (c) => body += c);
      res.on('end', () => {
        plog('INFO', 'notify_response', { provider: config.provider, status: res.statusCode, body: body.slice(0, 200) });
        resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: body.slice(0, 200) });
      });
    });
    req.on('error', (e) => {
      plog('WARN', 'notify_error', { provider: config.provider, error: e.message });
      resolve({ ok: false, error: e.message });
    });
    req.write(data);
    req.end();
  });
}

// Load config on startup (ensures migration)
loadNotifyConfig();

// === Auth Config ===
function generateRandomPassword(length = 12) {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  const bytes = crypto.randomBytes(length);
  for (let i = 0; i < length; i++) {
    result += chars[bytes[i] % chars.length];
  }
  return result;
}

function loadAuthConfig() {
  // Priority 1: config/auth.json exists with password
  try {
    if (fs.existsSync(AUTH_CONFIG_PATH)) {
      const config = JSON.parse(fs.readFileSync(AUTH_CONFIG_PATH, 'utf8'));
      if (config.password) return config;
    }
  } catch {}

  // Priority 2: .env has CC_WEB_PASSWORD → migrate
  const envPw = process.env.CC_WEB_PASSWORD;
  if (envPw && envPw !== 'changeme') {
    const config = { password: envPw, mustChange: false };
    saveAuthConfig(config);
    return config;
  }

  // Priority 3: Generate random password
  const pw = generateRandomPassword(12);
  const config = { password: pw, mustChange: true };
  saveAuthConfig(config);
  console.log('========================================');
  console.log('  自动生成初始密码: ' + pw);
  console.log('  首次登录后将要求修改密码');
  console.log('========================================');
  return config;
}

function saveAuthConfig(config) {
  fs.writeFileSync(AUTH_CONFIG_PATH, JSON.stringify(config, null, 2));
}

function validatePasswordStrength(pw) {
  if (!pw || pw.length < 8) {
    return { valid: false, message: '密码长度至少 8 位' };
  }
  let types = 0;
  if (/[a-z]/.test(pw)) types++;
  if (/[A-Z]/.test(pw)) types++;
  if (/[0-9]/.test(pw)) types++;
  if (/[^a-zA-Z0-9]/.test(pw)) types++;
  if (types < 2) {
    return { valid: false, message: '密码需包含至少 2 种字符类型（大写/小写/数字/特殊字符）' };
  }
  return { valid: true, message: '' };
}

let authConfig = loadAuthConfig();
let PASSWORD = authConfig.password;

const activeTokens = new Set();

// === Anti-brute-force ===
const AUTH_FAIL_WINDOW = 5 * 60 * 1000; // 5 minutes
const AUTH_FAIL_MAX = 3;
const authFailures = new Map(); // ip -> [timestamp, ...]
let bannedIPs = new Set();

// Tailscale / loopback whitelist — never ban these IPs.
// Extra whitelist can be provided via env var (comma/space separated):
//   CC_WEB_IP_WHITELIST="<ip1>,<ip2>"
const EXTRA_WHITELIST_IPS = new Set(
  String(process.env.CC_WEB_IP_WHITELIST || '')
    .split(/[\s,]+/)
    .map(s => s.trim())
    .filter(Boolean)
    .map(s => s.replace(/^::ffff:/, ''))
);

function isWhitelistedIP(ip) {
  if (!ip) return false;
  const cleaned = ip.replace(/^::ffff:/, '');
  return cleaned === '127.0.0.1'
    || cleaned === '::1'
    || cleaned.startsWith('100.')
    || EXTRA_WHITELIST_IPS.has(cleaned);
}

function loadBannedIPs() {
  try {
    if (fs.existsSync(BANNED_IPS_PATH)) {
      const list = JSON.parse(fs.readFileSync(BANNED_IPS_PATH, 'utf8'));
      bannedIPs = new Set(Array.isArray(list) ? list : []);
    }
  } catch { bannedIPs = new Set(); }
}
function saveBannedIPs() {
  fs.writeFileSync(BANNED_IPS_PATH, JSON.stringify([...bannedIPs], null, 2));
}
loadBannedIPs();

function getClientIP(ws) {
  const req = ws._req;
  if (!req) return null;
  const forwarded = req.headers['x-forwarded-for'];
  if (forwarded) return forwarded.split(',')[0].trim();
  return req.socket?.remoteAddress || null;
}

function recordAuthFailure(ip) {
  if (!ip || isWhitelistedIP(ip)) return false;
  const now = Date.now();
  let list = authFailures.get(ip) || [];
  list.push(now);
  list = list.filter(t => now - t < AUTH_FAIL_WINDOW);
  authFailures.set(ip, list);
  if (list.length >= AUTH_FAIL_MAX) {
    bannedIPs.add(ip);
    saveBannedIPs();
    authFailures.delete(ip);
    plog('WARN', 'ip_banned', { ip, reason: `${AUTH_FAIL_MAX} failed auth in ${AUTH_FAIL_WINDOW / 1000}s` });
    return true;
  }
  return false;
}

// Pending slash command metadata: sessionId -> { kind: string }
const pendingSlashCommands = new Map();

// Pending compact retry metadata: sessionId -> { text: string, mode: string, reason: string }
const pendingCompactRetries = new Map();

// Pending Codex transient retry metadata: sessionId -> { text, runtimeText, mode, attempts, timer }
const pendingCodexCapacityRetries = new Map();

// Active processes: sessionId -> { pid, ws, fullText, toolCalls, lastCost, tailer }
const activeProcesses = new Map();

// Active Codex app-server turns: sessionId -> { ws, threadId, turnId, fullText, toolCalls }
const activeCodexAppTurns = new Map();

// Active Codex app-server goal RPCs: sessionId -> { id, ws, action, cancelled }
const activeCodexAppGoalCommands = new Map();
// ccweb MCP child agents tracked from Codex App native collaboration mode:
// childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state }
const ccwebMcpChildThreads = new Map();
const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated';
const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb';
const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200;
const CODEX_APP_MCP_RELOAD_TRACK_MS = 15000;
const codexAppMcpStartupStatusByServer = new Map();
// sessionId -> { threadId, requestedAt, expiresAt, reloadRequestId }
const pendingCodexAppMcpReloads = new Map();
// sessionId -> Set<{ requestedAt, timer, resolve }>
const codexAppMcpStatusWaiters = new Map();
// 等待目标对话完成后回传给来源对话的跨对话请求：requestId -> metadata
const pendingCrossConversationReplies = new Map();
// Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer }
const pendingCodexAppUserInputs = new Map();
// Pending Codex app-server approval requests: requestId -> { sessionId, method, params, resolve, timer }
const pendingCodexAppApprovals = new Map();
let codexAppClient = null;
let codexAppClientSignature = '';
const CODEX_APP_STATE_FILE = 'codexapp-state.json';
const CODEX_APP_STATE_FLUSH_DELAY_MS = 250;

// Track which session each ws is viewing: ws -> sessionId
const wsSessionMap = new Map();

// Default fallback MODEL_MAP (overridden by model config at runtime)
// opus/sonnet use [1m] suffix to enable 1M context window by default
let MODEL_MAP = {
  opus: 'claude-opus-4-6[1m]',
  sonnet: 'claude-sonnet-4-6[1m]',
  haiku: 'claude-haiku-4-5-20251001',
};

const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);
const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']);
const VALID_TITLE_SOURCES = new Set(['derived', 'llm', 'manual', 'system']);
const MCP_CONVERSATION_TITLE_MAX_CHARS = 120;
const MCP_PROMPT_TITLE_MAX_CHARS = 160;
const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000;
const MCP_PROMPT_QUESTION_MAX_COUNT = 10;
const MCP_PROMPT_OPTION_MAX_COUNT = 8;
const MCP_PROMPT_QUESTION_MAX_CHARS = 4000;
const MCP_PROMPT_OPTION_MAX_CHARS = 1000;
const MCP_PROMPT_ANSWER_MAX_CHARS = 4000;
const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000;

// Codex 默认模型优先读取 ~/.codex/config.toml，缺失时再回退到旧默认值。
const FALLBACK_CODEX_MODEL = 'gpt-5.4';
const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh']);
const CCWEB_TITLE_TOOL_INSTRUCTIONS = [
  'Use the ccweb title tool sparingly. For a new chat, call "mcp__ccweb__ccweb_set_title" or "ccweb_set_title" once after the user\'s initial request is clear, and set a concise task title.',
  'Do not rename the chat for routine progress, substeps, implementation details, or slightly better wording. Rename only when the user\'s primary objective changes substantially and the existing title would be misleading.',
].join('\n');
const CODEX_APP_COLLABORATION_INSTRUCTIONS = [
  CCWEB_TITLE_TOOL_INSTRUCTIONS,
  'Codex sub-agent spawning rules:',
  '- Treat omitted fork_context the same as fork_context: true: a full-history fork inherits the parent agent type, model, and reasoning effort.',
  '- If you call spawn_agent with fork_context omitted or true, do not set agent_type, model, or reasoning_effort.',
  '- If you need a specific agent_type, model, or reasoning_effort, set fork_context: false and include only the necessary context in the message.',
  '- Do not rely on parent turn reasoning settings for spawned agents; only set reasoning_effort on spawn_agent when the chosen child model supports it.',
  '- When calling wait_agent, always pass timeout_ms explicitly. Use 300000ms for normal waits, and use a longer value when the child task is expected to run longer.',
  '- If wait_agent returns without a final child-agent status, keep waiting on the same target agents in additional wait_agent rounds until they return, the user interrupts, or the result is no longer needed.',
].join('\n');

function getLocalCodexConfigTomlPath() {
  const codexHome = String(process.env.CODEX_HOME || '').trim();
  if (codexHome) return path.join(codexHome, 'config.toml');
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
  return path.join(homeDir, '.codex', 'config.toml');
}

// === Model Config ===
const DEFAULT_MODEL_CONFIG = {
  mode: 'local',      // 'local' | 'custom'
  templates: [],      // array of { name, apiKey, apiBase, defaultModel, opusModel, sonnetModel, haikuModel }
  activeTemplate: '', // name of active template (for 'custom' mode)
};

const DEFAULT_CODEX_CONFIG = {
  mode: 'local',
  activeProfile: '',
  profiles: [],
  enableSearch: false,
  supportsSearch: false,
  retry: {
    mode: 'limited',
    intervalSeconds: Math.max(1, Math.ceil(CODEX_TRANSIENT_RETRY_BASE_DELAY_MS / 1000)),
    maxAttempts: CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS,
  },
};

function stripTomlInlineComment(value) {
  const raw = String(value || '');
  let inDoubleQuote = false;
  let inSingleQuote = false;
  let escaped = false;
  for (let i = 0; i < raw.length; i += 1) {
    const ch = raw[i];
    if (inDoubleQuote) {
      if (escaped) {
        escaped = false;
        continue;
      }
      if (ch === '\\') {
        escaped = true;
        continue;
      }
      if (ch === '"') inDoubleQuote = false;
      continue;
    }
    if (inSingleQuote) {
      if (ch === '\'') inSingleQuote = false;
      continue;
    }
    if (ch === '"') {
      inDoubleQuote = true;
      continue;
    }
    if (ch === '\'') {
      inSingleQuote = true;
      continue;
    }
    if (ch === '#') return raw.slice(0, i).trim();
  }
  return raw.trim();
}

function parseTomlStringValue(value) {
  const raw = stripTomlInlineComment(value);
  if (!raw) return '';
  if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) {
    if (raw.startsWith('"')) {
      try {
        return JSON.parse(raw);
      } catch {}
    }
    return raw.slice(1, -1);
  }
  return raw;
}

function splitTomlTopLevelList(value) {
  const parts = [];
  let current = '';
  let quote = '';
  let escaped = false;
  let depth = 0;
  for (const ch of String(value || '')) {
    if (quote) {
      current += ch;
      if (escaped) {
        escaped = false;
      } else if (ch === '\\' && quote === '"') {
        escaped = true;
      } else if (ch === quote) {
        quote = '';
      }
      continue;
    }
    if (ch === '"' || ch === '\'') {
      quote = ch;
      current += ch;
      continue;
    }
    if (ch === '[' || ch === '{') depth += 1;
    if (ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
    if (ch === ',' && depth === 0) {
      if (current.trim()) parts.push(current.trim());
      current = '';
      continue;
    }
    current += ch;
  }
  if (current.trim()) parts.push(current.trim());
  return parts;
}

function parseTomlValue(value) {
  const raw = stripTomlInlineComment(value);
  if (!raw) return '';
  if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) {
    return parseTomlStringValue(raw);
  }
  if (raw === 'true') return true;
  if (raw === 'false') return false;
  if (/^[+-]?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
  if (raw.startsWith('[') && raw.endsWith(']')) {
    const inner = raw.slice(1, -1).trim();
    if (!inner) return [];
    return splitTomlTopLevelList(inner).map((item) => parseTomlValue(item));
  }
  if (raw.startsWith('{') && raw.endsWith('}')) {
    const inner = raw.slice(1, -1).trim();
    const parsed = {};
    if (!inner) return parsed;
    for (const item of splitTomlTopLevelList(inner)) {
      const eqIndex = item.indexOf('=');
      if (eqIndex <= 0) continue;
      const key = String(item.slice(0, eqIndex)).trim().replace(/^["']|["']$/g, '');
      if (!key) continue;
      parsed[key] = parseTomlValue(item.slice(eqIndex + 1));
    }
    return parsed;
  }
  return raw;
}

function parseTomlBareKeyPath(pathText) {
  const parts = [];
  let current = '';
  let quote = '';
  let escaped = false;
  for (const ch of String(pathText || '').trim()) {
    if (quote) {
      if (escaped) {
        current += ch;
        escaped = false;
        continue;
      }
      if (ch === '\\' && quote === '"') {
        escaped = true;
        continue;
      }
      if (ch === quote) {
        quote = '';
        continue;
      }
      current += ch;
      continue;
    }
    if (ch === '"' || ch === '\'') {
      quote = ch;
      continue;
    }
    if (ch === '.') {
      if (current.trim()) parts.push(current.trim());
      current = '';
      continue;
    }
    current += ch;
  }
  if (current.trim()) parts.push(current.trim());
  return parts.filter(Boolean);
}

function commandExistsForMcp(command) {
  const raw = String(command || '').trim();
  if (!raw) return false;
  const hasPathSeparator = raw.includes('/') || raw.includes('\\');
  const candidates = hasPathSeparator
    ? [path.resolve(raw)]
    : String(process.env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, raw));
  for (const candidate of candidates) {
    try {
      const stat = fs.statSync(candidate);
      if (!stat.isFile()) continue;
      fs.accessSync(candidate, fs.constants.X_OK);
      return true;
    } catch {}
  }
  return false;
}

function getProjectCodexConfigTomlPath(cwd) {
  const start = normalizeExistingDirPath(cwd);
  if (!start) return '';
  const projectRoot = findNearestProjectRoot(start) || start;
  let dir = start;
  const seen = new Set();
  while (dir && !seen.has(dir)) {
    seen.add(dir);
    const configPath = path.join(dir, '.codex', 'config.toml');
    try {
      if (fs.statSync(configPath).isFile()) return configPath;
    } catch {}
    if (dir === projectRoot) break;
    const parent = path.dirname(dir);
    if (!parent || parent === dir) break;
    dir = parent;
  }
  return '';
}

function parseCodexMcpServerConfigToml(text, source) {
  const servers = [];
  let current = null;
  for (const line of String(text || '').split(/\r?\n/)) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
    if (sectionMatch) {
      const parts = parseTomlBareKeyPath(sectionMatch[1]);
      current = parts[0] === 'mcp_servers' && parts[1]
        ? { name: String(parts[1]).trim(), config: {} }
        : null;
      if (current?.name) servers.push(current);
      continue;
    }
    if (!current) continue;
    const eqIndex = trimmed.indexOf('=');
    if (eqIndex <= 0) continue;
    const key = String(trimmed.slice(0, eqIndex)).trim();
    if (!key) continue;
    current.config[key] = parseTomlValue(trimmed.slice(eqIndex + 1));
  }
  return servers
    .map((server) => normalizeCodexMcpServerConfig(server.name, server.config, source))
    .filter(Boolean);
}

function normalizeCodexMcpServerConfig(name, rawConfig, source) {
  const server = normalizeMcpServerName(name);
  if (!isLikelyMcpServerName(server) || !rawConfig || typeof rawConfig !== 'object') return null;
  if (rawConfig.enabled === false) return null;
  const type = String(rawConfig.type || (rawConfig.url ? 'streamable_http' : 'stdio')).trim();
  const config = { type };
  if (type === 'stdio') {
    const command = String(rawConfig.command || '').trim();
    if (!command || !commandExistsForMcp(command)) return null;
    config.command = command;
    if (Array.isArray(rawConfig.args)) config.args = rawConfig.args.map((item) => String(item));
    if (rawConfig.env && typeof rawConfig.env === 'object' && !Array.isArray(rawConfig.env)) config.env = rawConfig.env;
    if (Array.isArray(rawConfig.env_vars)) config.env_vars = rawConfig.env_vars.map((item) => String(item));
  } else {
    const url = normalizeSkillMcpUrl(rawConfig.url || rawConfig.server_url || '');
    if (!url) return null;
    config.url = url;
    const bearerTokenEnvVar = String(rawConfig.bearer_token_env_var || rawConfig.bearerTokenEnvVar || '').trim();
    if (bearerTokenEnvVar) config.bearer_token_env_var = bearerTokenEnvVar;
  }
  for (const key of ['startup_timeout_sec', 'tool_timeout_sec']) {
    if (Number.isFinite(rawConfig[key])) config[key] = rawConfig[key];
  }
  return {
    server,
    name: server,
    source,
    type,
    config,
    description: `MCP server: ${server}`,
  };
}

function loadProjectCodexMcpServerConfigs(cwd) {
  try {
    const configPath = getProjectCodexConfigTomlPath(cwd);
    if (!configPath || !fs.existsSync(configPath)) return [];
    return parseCodexMcpServerConfigToml(fs.readFileSync(configPath, 'utf8'), path.relative(process.cwd(), configPath) || configPath);
  } catch {
    return [];
  }
}

function loadLocalCodexTomlConfig() {
  try {
    const configPath = getLocalCodexConfigTomlPath();
    if (!configPath || !fs.existsSync(configPath)) {
      return { model: '', reasoningEffort: '' };
    }
    const text = fs.readFileSync(configPath, 'utf8');
    const parsed = { model: '', reasoningEffort: '' };
    for (const line of text.split(/\r?\n/)) {
      const trimmed = line.trim();
      if (!trimmed || trimmed.startsWith('#')) continue;
      if (trimmed.startsWith('[')) break;
      const eqIndex = trimmed.indexOf('=');
      if (eqIndex <= 0) continue;
      const key = trimmed.slice(0, eqIndex).trim();
      const value = trimmed.slice(eqIndex + 1).trim();
      if (key === 'model') parsed.model = parseTomlStringValue(value);
      if (key === 'model_reasoning_effort') parsed.reasoningEffort = parseTomlStringValue(value).toLowerCase();
    }
    return parsed;
  } catch {
    return { model: '', reasoningEffort: '' };
  }
}

function getDefaultCodexModel() {
  const localConfig = loadLocalCodexTomlConfig();
  const model = String(localConfig.model || '').trim() || FALLBACK_CODEX_MODEL;
  const reasoningEffort = String(localConfig.reasoningEffort || '').trim().toLowerCase();
  if (CODEX_REASONING_LEVELS.has(reasoningEffort)) {
    return `${model}(${reasoningEffort})`;
  }
  return model;
}

function loadModelConfig() {
  try {
    if (fs.existsSync(MODEL_CONFIG_PATH)) {
      return JSON.parse(fs.readFileSync(MODEL_CONFIG_PATH, 'utf8'));
    }
  } catch {}
  return JSON.parse(JSON.stringify(DEFAULT_MODEL_CONFIG));
}

function saveModelConfig(config) {
  fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify(config, null, 2));
}

function normalizeCodexRetryConfig(raw = {}) {
  const defaults = DEFAULT_CODEX_CONFIG.retry;
  const mode = ['off', 'limited', 'forever'].includes(raw?.mode) ? raw.mode : defaults.mode;
  const rawInterval = Number.parseInt(String(raw?.intervalSeconds ?? raw?.interval ?? ''), 10);
  const intervalSeconds = Number.isFinite(rawInterval)
    ? Math.max(1, Math.min(3600, rawInterval))
    : defaults.intervalSeconds;
  const rawMaxAttempts = Number.parseInt(String(raw?.maxAttempts ?? raw?.attempts ?? ''), 10);
  const maxAttempts = Number.isFinite(rawMaxAttempts)
    ? Math.max(1, Math.min(1000, rawMaxAttempts))
    : defaults.maxAttempts;
  return { mode, intervalSeconds, maxAttempts };
}

function loadCodexConfig() {
  try {
    if (fs.existsSync(CODEX_CONFIG_PATH)) {
      const raw = JSON.parse(fs.readFileSync(CODEX_CONFIG_PATH, 'utf8'));
      return {
        mode: raw.mode === 'custom' ? 'custom' : 'local',
        activeProfile: raw.activeProfile || '',
        profiles: Array.isArray(raw.profiles) ? raw.profiles.map((profile) => ({
          name: String(profile?.name || '').trim(),
          apiKey: String(profile?.apiKey || ''),
          apiBase: String(profile?.apiBase || '').trim(),
        })).filter((profile) => profile.name) : [],
        enableSearch: false,
        supportsSearch: false,
        storedEnableSearch: !!raw.enableSearch,
        retry: normalizeCodexRetryConfig(raw.retry),
      };
    }
  } catch {}
  return JSON.parse(JSON.stringify(DEFAULT_CODEX_CONFIG));
}

function saveCodexConfig(config) {
  fs.writeFileSync(CODEX_CONFIG_PATH, JSON.stringify({
    mode: config.mode === 'custom' ? 'custom' : 'local',
    activeProfile: config.activeProfile || '',
    profiles: Array.isArray(config.profiles) ? config.profiles.map((profile) => ({
      name: String(profile?.name || '').trim(),
      apiKey: String(profile?.apiKey || ''),
      apiBase: String(profile?.apiBase || '').trim(),
    })).filter((profile) => profile.name) : [],
    enableSearch: false,
    retry: normalizeCodexRetryConfig(config.retry),
  }, null, 2));
}

function getCodexConfigMasked() {
  const config = loadCodexConfig();
  return {
    mode: config.mode === 'custom' ? 'custom' : 'local',
    activeProfile: config.activeProfile || '',
    profiles: (config.profiles || []).map((profile) => ({
      name: profile.name,
      apiKey: maskSecret(profile.apiKey),
      apiBase: profile.apiBase || '',
    })),
    enableSearch: false,
    supportsSearch: false,
    storedEnableSearch: !!config.storedEnableSearch,
    retry: normalizeCodexRetryConfig(config.retry),
  };
}

function maskSecret(str) {
  if (!str || str.length <= 8) return str ? '****' : '';
  return str.slice(0, 4) + '****' + str.slice(-4);
}

function getModelConfigMasked() {
  const config = loadModelConfig();
  return {
    mode: config.mode,
    activeTemplate: config.activeTemplate,
    templates: (config.templates || []).map(t => ({
      name: t.name,
      apiKey: maskSecret(t.apiKey),
      apiBase: t.apiBase || '',
      defaultModel: t.defaultModel || '',
      opusModel: t.opusModel || '',
      sonnetModel: t.sonnetModel || '',
      haikuModel: t.haikuModel || '',
    })),
  };
}

const CODEX_RUNTIME_HOME = path.join(CONFIG_DIR, 'codex-runtime-home');

function tomlString(value) {
  return JSON.stringify(String(value || ''));
}

function prepareCodexCustomRuntime(config) {
  if (!config || config.mode !== 'custom') return { mode: 'local' };
  const profiles = Array.isArray(config.profiles) ? config.profiles : [];
  const activeProfile = profiles.find((profile) => profile.name === config.activeProfile) || null;
  if (!activeProfile) {
    return { error: 'Codex 自定义配置缺少已激活的 profile。请先在设置中创建并激活一个 API 配置。' };
  }
  if (!activeProfile.apiKey || !activeProfile.apiBase) {
    return { error: `Codex profile「${activeProfile.name}」缺少 API Key 或 API Base URL。` };
  }

  fs.mkdirSync(CODEX_RUNTIME_HOME, { recursive: true });
  const configToml = [
    'preferred_auth_method = "apikey"',
    'model_provider = "openai_compat"',
    '',
    '[model_providers.openai_compat]',
    `name = ${tomlString(activeProfile.name || 'OpenAI Compat')}`,
    `base_url = ${tomlString(activeProfile.apiBase)}`,
    'env_key = "OPENAI_API_KEY"',
    'wire_api = "responses"',
    '',
  ].join('\n');
  fs.writeFileSync(path.join(CODEX_RUNTIME_HOME, 'config.toml'), configToml);

  return {
    mode: 'custom',
    homeDir: CODEX_RUNTIME_HOME,
    apiKey: activeProfile.apiKey,
    apiBase: activeProfile.apiBase,
    profileName: activeProfile.name,
  };
}

// Read ~/.claude.json for model name overrides
function loadClaudeJsonModelMap() {
  try {
    const p = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude.json');
    if (!fs.existsSync(p)) return null;
    const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
    const env = raw?.env || {};
    const map = {};
    // Append [1m] to opus/sonnet for 1M context window; haiku uses model name as-is
    if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) map.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL + '[1m]';
    if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) map.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL + '[1m]';
    if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) map.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
    // Fallback: ANTHROPIC_MODEL maps to opus slot
    if (!map.opus && env.ANTHROPIC_MODEL) map.opus = env.ANTHROPIC_MODEL + '[1m]';
    return Object.keys(map).length > 0 ? map : null;
  } catch {
    return null;
  }
}

// Apply model config to runtime MODEL_MAP only (env vars are injected per-spawn, not here)
const CLAUDE_SETTINGS_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'settings.json');
const SETTINGS_API_KEYS = ['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_BASE_URL','ANTHROPIC_MODEL',
  'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL','ANTHROPIC_DEFAULT_HAIKU_MODEL',
  'ANTHROPIC_REASONING_MODEL'];

function applyCustomTemplateToSettings(tpl) {
  let settings = {};
  try { settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8')); } catch {}
  const cleanedEnv = {};
  for (const [k, v] of Object.entries(settings.env || {})) {
    if (!SETTINGS_API_KEYS.includes(k)) cleanedEnv[k] = v;
  }
  if (tpl.apiKey)       { cleanedEnv.ANTHROPIC_AUTH_TOKEN = tpl.apiKey; cleanedEnv.ANTHROPIC_API_KEY = tpl.apiKey; }
  if (tpl.apiBase)      cleanedEnv.ANTHROPIC_BASE_URL = tpl.apiBase;
  if (tpl.defaultModel) cleanedEnv.ANTHROPIC_MODEL = tpl.defaultModel;
  if (tpl.opusModel)    cleanedEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = tpl.opusModel;
  if (tpl.sonnetModel)  cleanedEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = tpl.sonnetModel;
  if (tpl.haikuModel)   cleanedEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = tpl.haikuModel;
  settings.env = cleanedEnv;
  // 原子写入：先写临时文件再 rename，避免 Claude 子进程读到写了一半的文件
  const tmpPath = CLAUDE_SETTINGS_PATH + '.tmp';
  try {
    fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2));
    fs.renameSync(tmpPath, CLAUDE_SETTINGS_PATH);
  } catch {
    try { fs.unlinkSync(tmpPath); } catch {}
  }
}

function applyModelConfig() {
  const config = loadModelConfig();
  if (config.mode === 'custom' && config.activeTemplate) {
    const tpl = (config.templates || []).find(t => t.name === config.activeTemplate);
    if (tpl) {
      if (tpl.opusModel) MODEL_MAP.opus = tpl.opusModel.endsWith('[1m]') ? tpl.opusModel : tpl.opusModel + '[1m]';
      if (tpl.sonnetModel) MODEL_MAP.sonnet = tpl.sonnetModel.endsWith('[1m]') ? tpl.sonnetModel : tpl.sonnetModel + '[1m]';
      if (tpl.haikuModel) MODEL_MAP.haiku = tpl.haikuModel;
      return;
    }
  }
  // mode === 'local': read model names from ~/.claude.json
  const localMap = loadClaudeJsonModelMap();
  if (localMap) {
    if (localMap.opus) MODEL_MAP.opus = localMap.opus;
    if (localMap.sonnet) MODEL_MAP.sonnet = localMap.sonnet;
    if (localMap.haiku) MODEL_MAP.haiku = localMap.haiku;
  }
}

// Apply on startup
applyModelConfig();

const MIME_TYPES = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.json': 'application/json',
  '.png': 'image/png',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
};

// === Utility Functions ===

function wsSend(ws, data) {
  if (!ws || ws.readyState !== 1) return;
  try {
    ws.send(JSON.stringify(data));
    markWsActivity(ws);
  } catch (err) {
    plog('WARN', 'ws_send_failed', {
      wsId: ws._ccWebId || null,
      type: data?.type || null,
      sessionId: data?.sessionId ? String(data.sessionId).slice(0, 8) : null,
      error: err?.message || String(err || ''),
    });
  }
}

function markWsActivity(ws) {
  if (!ws) return;
  ws.isAlive = true;
  ws._ccWebLastActivityAt = Date.now();
  ws._ccWebMissedPongs = 0;
}

function sanitizeId(id) {
  return String(id).replace(/[^a-zA-Z0-9\-]/g, '');
}

function sessionPath(id) {
  return path.join(SESSIONS_DIR, `${sanitizeId(id)}.json`);
}

function runDir(sessionId) {
  return path.join(SESSIONS_DIR, `${sanitizeId(sessionId)}-run`);
}

function attachmentDataPath(id, ext = '') {
  return path.join(ATTACHMENTS_DIR, `${sanitizeId(id)}${ext}`);
}

function attachmentMetaPath(id) {
  return path.join(ATTACHMENTS_DIR, `${sanitizeId(id)}.json`);
}

function safeFilename(name) {
  return String(name || 'image')
    .replace(/[\/\\?%*:|"<>]/g, '-')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, 120) || 'image';
}

function contentDispositionInline(filename) {
  const raw = safeFilename(filename || 'image');
  const fallback = raw
    .replace(/[^\x20-\x7E]/g, '_')
    .replace(/["\\;]/g, '_')
    .trim()
    .slice(0, 120) || 'image';
  return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(raw)}`;
}

function extFromMime(mime) {
  switch (mime) {
    case 'image/png': return '.png';
    case 'image/jpeg': return '.jpg';
    case 'image/webp': return '.webp';
    case 'image/gif': return '.gif';
    default: return '';
  }
}

function loadAttachmentMeta(id) {
  try {
    return JSON.parse(fs.readFileSync(attachmentMetaPath(id), 'utf8'));
  } catch {
    return null;
  }
}

function saveAttachmentMeta(meta) {
  fs.writeFileSync(attachmentMetaPath(meta.id), JSON.stringify(meta, null, 2));
}

function removeAttachmentById(id) {
  const meta = loadAttachmentMeta(id);
  const paths = new Set([attachmentMetaPath(id)]);
  if (meta?.path) paths.add(meta.path);
  for (const filePath of paths) {
    try {
      if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
    } catch {}
  }
}

function currentAttachmentState(meta) {
  if (!meta) return 'missing';
  const expiresAtMs = new Date(meta.expiresAt || 0).getTime();
  if (expiresAtMs && Date.now() > expiresAtMs) return 'expired';
  if (!meta.path || !fs.existsSync(meta.path)) return 'missing';
  return 'available';
}

function normalizeMessageAttachments(attachments) {
  if (!Array.isArray(attachments) || attachments.length === 0) return [];
  const normalized = [];
  for (const attachment of attachments) {
    const id = sanitizeId(attachment?.id || '');
    if (!id) continue;
    const meta = loadAttachmentMeta(id);
    const state = currentAttachmentState(meta);
    if (state === 'expired') removeAttachmentById(id);
    normalized.push({
      id,
      kind: 'image',
      filename: meta?.filename || attachment?.filename || 'image',
      mime: meta?.mime || attachment?.mime || 'image/png',
      size: meta?.size || attachment?.size || 0,
      createdAt: meta?.createdAt || attachment?.createdAt || null,
      expiresAt: meta?.expiresAt || attachment?.expiresAt || null,
      storageState: state === 'available' ? 'available' : 'expired',
    });
  }
  return normalized;
}

function resolveMessageAttachments(attachments) {
  const resolved = [];
  for (const attachment of normalizeMessageAttachments(attachments)) {
    if (attachment.storageState !== 'available') continue;
    const meta = loadAttachmentMeta(attachment.id);
    if (!meta?.path || !fs.existsSync(meta.path)) continue;
    resolved.push({
      ...attachment,
      path: meta.path,
    });
  }
  return resolved;
}

function cleanupExpiredAttachments() {
  try {
    const files = fs.readdirSync(ATTACHMENTS_DIR).filter((name) => name.endsWith('.json'));
    for (const file of files) {
      const id = file.replace(/\.json$/, '');
      const meta = loadAttachmentMeta(id);
      if (!meta || currentAttachmentState(meta) === 'expired') {
        removeAttachmentById(id);
      }
    }
  } catch {}
}

function collectSessionAttachmentIds(session) {
  const ids = new Set();
  for (const message of Array.isArray(session?.messages) ? session.messages : []) {
    for (const attachment of Array.isArray(message?.attachments) ? message.attachments : []) {
      const id = sanitizeId(attachment?.id || '');
      if (id) ids.add(id);
    }
  }
  return Array.from(ids);
}

function extractBearerToken(req) {
  const authHeader = String(req.headers.authorization || '');
  const m = authHeader.match(/^Bearer\s+(.+)$/i);
  return m ? m[1] : '';
}

function jsonResponse(res, statusCode, payload) {
  res.writeHead(statusCode, {
    'Content-Type': 'application/json; charset=utf-8',
    'Cache-Control': 'no-cache',
  });
  res.end(JSON.stringify(payload));
}

function readJsonBody(req, maxBytes = 1024 * 1024) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let total = 0;
    let finished = false;

    req.on('data', (chunk) => {
      if (finished) return;
      total += chunk.length;
      if (total > maxBytes) {
        finished = true;
        reject(new Error('请求体过大'));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });

    req.on('end', () => {
      if (finished) return;
      finished = true;
      try {
        const text = Buffer.concat(chunks).toString('utf8').trim();
        resolve(text ? JSON.parse(text) : {});
      } catch {
        reject(new Error('请求体不是有效 JSON'));
      }
    });

    req.on('error', (err) => {
      if (finished) return;
      finished = true;
      reject(err);
    });
  });
}

function normalizeRelativeBrowserPath(input) {
  return String(input || '')
    .replace(/\\/g, '/')
    .split('/')
    .filter((part) => part && part !== '.')
    .join('/');
}

function isPathInside(parentPath, targetPath) {
  const relative = path.relative(parentPath, targetPath);
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}

function normalizeExistingDirPath(candidate) {
  if (!candidate) return null;
  try {
    const resolvedPath = path.resolve(String(candidate));
    if (!fs.existsSync(resolvedPath)) return null;
    const realPath = fs.realpathSync(resolvedPath);
    if (!fs.statSync(realPath).isDirectory()) return null;
    return realPath;
  } catch {
    return null;
  }
}

function getDefaultSessionCwd() {
  return normalizeExistingDirPath(process.env.HOME || process.env.USERPROFILE || process.cwd())
    || normalizeExistingDirPath(process.cwd())
    || path.resolve(process.cwd());
}

function resolveSessionCwd(candidate, options = {}) {
  if (!candidate || !String(candidate).trim()) {
    return { ok: true, path: getDefaultSessionCwd(), created: false };
  }

  const resolvedPath = path.resolve(String(candidate).trim());

  try {
    if (fs.existsSync(resolvedPath)) {
      const realPath = fs.realpathSync(resolvedPath);
      const stat = fs.statSync(realPath);
      if (!stat.isDirectory()) {
        return {
          ok: false,
          code: 'new_session_cwd_invalid',
          resolvedPath,
          message: '工作目录不是目录，请重新选择。',
        };
      }
      return { ok: true, path: realPath, created: false };
    }

    if (!options.createMissing) {
      return {
        ok: false,
        code: 'new_session_cwd_missing',
        resolvedPath,
        message: '工作目录不存在',
      };
    }

    fs.mkdirSync(resolvedPath, { recursive: true });
    const createdPath = normalizeExistingDirPath(resolvedPath);
    if (!createdPath) {
      return {
        ok: false,
        code: 'new_session_cwd_create_failed',
        resolvedPath,
        message: '目录已创建，但当前进程无法访问，请检查权限。',
      };
    }
    return { ok: true, path: createdPath, created: true };
  } catch (err) {
    return {
      ok: false,
      code: options.createMissing ? 'new_session_cwd_create_failed' : 'new_session_cwd_invalid',
      resolvedPath,
      message: `${options.createMissing ? '创建工作目录失败' : '解析工作目录失败'}: ${err.message}`,
    };
  }
}

function collectRecentSessionCwds(limit = 12) {
  const results = [];
  const seen = new Set();
  const pushPath = (candidate) => {
    const normalized = normalizeExistingDirPath(candidate);
    if (!normalized || seen.has(normalized)) return;
    seen.add(normalized);
    results.push(normalized);
  };

  pushPath(getDefaultSessionCwd());
  pushPath(process.cwd());
  if (process.platform === 'win32') {
    for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
      pushPath(`${letter}:\\`);
      if (results.length >= limit) return results.slice(0, limit);
    }
  }

  try {
    const files = fs.readdirSync(SESSIONS_DIR)
      .filter((name) => name.endsWith('.json'))
      .map((name) => {
        const fullPath = path.join(SESSIONS_DIR, name);
        let updatedAt = 0;
        try {
          updatedAt = fs.statSync(fullPath).mtimeMs;
        } catch {}
        return { name, updatedAt };
      })
      .sort((a, b) => b.updatedAt - a.updatedAt);

    for (const file of files) {
      try {
        const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file.name), 'utf8')));
        pushPath(session.cwd);
        if (results.length >= limit) break;
      } catch {}
    }
  } catch {}

  return results.slice(0, limit);
}

function resolveDirectoryPickerTarget(requestedPath) {
  const fallbackPath = getDefaultSessionCwd();
  try {
    const rawPath = String(requestedPath || '').trim();
    const candidatePath = rawPath ? path.resolve(rawPath) : fallbackPath;
    if (!fs.existsSync(candidatePath)) {
      return { ok: false, statusCode: 404, message: '目标目录不存在' };
    }
    const realPath = fs.realpathSync(candidatePath);
    const stat = fs.statSync(realPath);
    if (!stat.isDirectory()) {
      return { ok: false, statusCode: 400, message: '目标路径不是目录' };
    }
    const parentPath = path.dirname(realPath);
    return {
      ok: true,
      realPath,
      parentPath: parentPath === realPath ? '' : parentPath,
      defaultPath: fallbackPath,
    };
  } catch (err) {
    return { ok: false, statusCode: 500, message: `解析目录失败: ${err.message}` };
  }
}

function getSessionBrowseContext(sessionId) {
  const session = loadSession(sessionId);
  if (!session) {
    return { ok: false, statusCode: 404, message: '会话不存在' };
  }

  const rootCandidate = session.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null;
  if (!rootCandidate) {
    return { ok: false, statusCode: 400, message: '当前会话没有可浏览的工作目录' };
  }

  try {
    const resolvedRoot = path.resolve(String(rootCandidate));
    if (!fs.existsSync(resolvedRoot)) {
      return { ok: false, statusCode: 404, message: '工作目录不存在' };
    }
    const realRoot = fs.realpathSync(resolvedRoot);
    const stat = fs.statSync(realRoot);
    if (!stat.isDirectory()) {
      return { ok: false, statusCode: 400, message: '工作目录不是目录' };
    }
    return { ok: true, session, rootDir: realRoot };
  } catch (err) {
    return { ok: false, statusCode: 500, message: `解析工作目录失败: ${err.message}` };
  }
}

function resolveBrowseTarget(rootDir, requestedPath) {
  try {
    const candidatePath = requestedPath
      ? path.resolve(rootDir, String(requestedPath))
      : rootDir;
    if (!fs.existsSync(candidatePath)) {
      return { ok: false, statusCode: 404, message: '目标路径不存在' };
    }
    const realPath = fs.realpathSync(candidatePath);
    if (!isPathInside(rootDir, realPath)) {
      return { ok: false, statusCode: 403, message: '目标路径超出允许范围' };
    }
    return {
      ok: true,
      realPath,
      relativePath: normalizeRelativeBrowserPath(path.relative(rootDir, realPath)),
    };
  } catch (err) {
    return { ok: false, statusCode: 500, message: `解析目标路径失败: ${err.message}` };
  }
}

function isPreviewTextExtension(filePath) {
  return TEXT_PREVIEW_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}

function isProbablyTextBuffer(buffer) {
  if (!buffer || buffer.length === 0) return true;
  const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
  let suspicious = 0;
  for (const byte of sample) {
    if (byte === 0) return false;
    if (byte < 7 || (byte > 13 && byte < 32)) suspicious++;
  }
  return suspicious / sample.length < 0.05;
}

function readFilePreviewBuffer(filePath, maxBytes) {
  const previewSize = Math.max(0, Math.min(maxBytes, fs.statSync(filePath).size));
  if (previewSize === 0) return Buffer.alloc(0);
  const fd = fs.openSync(filePath, 'r');
  try {
    const buffer = Buffer.alloc(previewSize);
    const read = fs.readSync(fd, buffer, 0, previewSize, 0);
    return buffer.subarray(0, read);
  } finally {
    fs.closeSync(fd);
  }
}

const COMPOSER_COMMANDS = [
  { name: '/clear', description: '清除当前会话', insertion: '/clear ' },
  { name: '/model', description: '查看/切换模型', insertion: '/model ' },
  { name: '/mode', description: '查看/切换权限模式', insertion: '/mode ' },
  { name: '/cost', description: '查看会话费用或 Token', insertion: '/cost ' },
  { name: '/compact', description: '压缩上下文', insertion: '/compact ' },
  { name: '/goal', description: '设置/查看/暂停/恢复/清除 Codex App 持久目标', insertion: '/goal ' },
  { name: '/init', description: '生成/更新 Agent 指南文件', insertion: '/init ' },
  { name: '/help', description: '显示帮助', insertion: '/help ' },
];

const DEFAULT_COMPOSER_PROMPTS = [
  {
    name: 'plan',
    title: '规划模式',
    description: '先分析需求并给出实施计划，暂不修改文件。',
    content: '请先分析需求、边界和风险，给出可执行的实施计划。暂时不要修改文件，等我确认后再落地。',
    source: 'builtin',
  },
  {
    name: 'review',
    title: '代码审查',
    description: '按代码审查视角优先指出问题。',
    content: '请以代码审查视角检查当前上下文，优先指出 bug、行为回归、安全风险和缺失测试；结论要可验证。',
    source: 'builtin',
  },
  {
    name: 'explain',
    title: '解释代码',
    description: '解释相关代码结构、调用链和取舍。',
    content: '请解释相关代码的结构、关键调用链、设计取舍和可能的扩展点，尽量结合具体文件或函数说明。',
    source: 'builtin',
  },
];

function getCodexHomeDir() {
  const explicit = String(process.env.CODEX_HOME || '').trim();
  if (explicit) return path.resolve(explicit);
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
  return homeDir ? path.join(homeDir, '.codex') : '';
}

function parseSimpleFrontmatter(text) {
  const raw = String(text || '');
  if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) {
    return { meta: {}, body: raw };
  }
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
  if (!match) return { meta: {}, body: raw };
  const meta = {};
  for (const line of match[1].split(/\r?\n/)) {
    const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
    if (!m) continue;
    let value = m[2].trim();
    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
      value = value.slice(1, -1);
    }
    meta[m[1]] = value;
  }
  return { meta, body: raw.slice(match[0].length) };
}

function stripSimpleYamlComment(line) {
  const value = String(line || '');
  let quote = '';
  let escaped = false;
  for (let i = 0; i < value.length; i += 1) {
    const ch = value[i];
    if (quote) {
      if (escaped) {
        escaped = false;
        continue;
      }
      if (ch === '\\' && quote === '"') {
        escaped = true;
        continue;
      }
      if (ch === quote) quote = '';
      continue;
    }
    if (ch === '"' || ch === '\'') {
      quote = ch;
      continue;
    }
    if (ch === '#' && (i === 0 || /\s/.test(value[i - 1]))) {
      return value.slice(0, i).trimEnd();
    }
  }
  return value;
}

function parseSimpleYamlScalar(rawValue) {
  const value = stripSimpleYamlComment(rawValue).trim();
  if (!value) return '';
  if (value === 'true') return true;
  if (value === 'false') return false;
  if (value === 'null' || value === '~') return null;
  if (value.startsWith('"') && value.endsWith('"')) {
    try { return JSON.parse(value); } catch { return value.slice(1, -1); }
  }
  if (value.startsWith('\'') && value.endsWith('\'')) {
    return value.slice(1, -1).replace(/''/g, '\'');
  }
  return value;
}

function parseSimpleYamlKeyValue(text) {
  const value = String(text || '').trim();
  if (!value) return null;
  let quote = '';
  let escaped = false;
  for (let i = 0; i < value.length; i += 1) {
    const ch = value[i];
    if (quote) {
      if (escaped) {
        escaped = false;
        continue;
      }
      if (ch === '\\' && quote === '"') {
        escaped = true;
        continue;
      }
      if (ch === quote) quote = '';
      continue;
    }
    if (ch === '"' || ch === '\'') {
      quote = ch;
      continue;
    }
    if (ch !== ':') continue;
    const key = value.slice(0, i).trim();
    if (!/^[A-Za-z0-9_-]+$/.test(key)) return null;
    return { key, value: parseSimpleYamlScalar(value.slice(i + 1)) };
  }
  return null;
}

function parseOpenAiSkillYaml(text) {
  const parsed = { interface: {}, policy: {}, dependencies: { tools: [] } };
  let section = '';
  let dependencyList = '';
  let currentTool = null;

  for (const rawLine of String(text || '').split(/\r?\n/)) {
    const line = stripSimpleYamlComment(rawLine);
    if (!line.trim()) continue;
    const indent = line.match(/^\s*/)?.[0]?.length || 0;
    const trimmed = line.trim();

    if (indent === 0) {
      const sectionName = trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : '';
      if (['interface', 'policy', 'dependencies'].includes(sectionName)) {
        section = sectionName;
        dependencyList = '';
        currentTool = null;
      } else {
        section = '';
      }
      continue;
    }

    if (!section) continue;

    if (section === 'dependencies') {
      if (indent === 2 && trimmed === 'tools:') {
        dependencyList = 'tools';
        currentTool = null;
        continue;
      }
      if (dependencyList === 'tools' && indent >= 4 && trimmed.startsWith('-')) {
        currentTool = {};
        parsed.dependencies.tools.push(currentTool);
        const rest = trimmed.slice(1).trim();
        const item = parseSimpleYamlKeyValue(rest);
        if (item) currentTool[item.key] = item.value;
        continue;
      }
      if (dependencyList === 'tools' && currentTool && indent >= 6) {
        const item = parseSimpleYamlKeyValue(trimmed);
        if (item) currentTool[item.key] = item.value;
      }
      continue;
    }

    if (indent >= 2) {
      const item = parseSimpleYamlKeyValue(trimmed);
      if (item) parsed[section][item.key] = item.value;
    }
  }

  return parsed;
}

function normalizeComposerTextValue(value, maxChars = COMPOSER_SKILL_TEXT_MAX_CHARS) {
  const text = String(value || '').trim().replace(/\s+/g, ' ');
  return text ? truncateTextValue(text, maxChars, '...') : '';
}

function normalizeSkillBrandColor(value) {
  const color = String(value || '').trim();
  return /^#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?$/.test(color) ? color : '';
}

function normalizeSkillIconUrl(value) {
  const url = String(value || '').trim();
  if (/^https?:\/\//i.test(url)) return url;
  if (/^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+$/i.test(url) && url.length <= 12000) {
    return url;
  }
  return '';
}

function normalizeComposerKey(value) {
  return String(value || '').trim().replace(/^[@$]/, '').toLowerCase();
}

function collectFilesByName(rootDir, fileNames, options = {}) {
  const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 4;
  const maxFiles = Number.isFinite(options.maxFiles) ? options.maxFiles : 200;
  const ignoreDirs = new Set(['node_modules', '.git', 'sessions', 'logs']);
  const found = [];

  function walk(dir, depth) {
    if (found.length >= maxFiles || depth > maxDepth) return;
    let entries;
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true });
    } catch {
      return;
    }
    entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' }));
    for (const entry of entries) {
      if (found.length >= maxFiles) return;
      const entryPath = path.join(dir, entry.name);
      if (entry.isFile() && fileNames.has(entry.name)) {
        found.push(entryPath);
      } else if (entry.isDirectory() && !ignoreDirs.has(entry.name)) {
        walk(entryPath, depth + 1);
      }
    }
  }

  try {
    const realRoot = fs.realpathSync(rootDir);
    if (fs.statSync(realRoot).isDirectory()) walk(realRoot, 0);
  } catch {}
  return found;
}

function collectFilesByExtension(rootDir, extensions, options = {}) {
  const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 4;
  const maxFiles = Number.isFinite(options.maxFiles) ? options.maxFiles : 200;
  const ignoreDirs = new Set(['node_modules', '.git', 'sessions', 'logs']);
  const normalizedExtensions = new Set(
    [...extensions].map((ext) => String(ext || '').trim().toLowerCase()).filter(Boolean)
  );
  const found = [];

  function walk(dir, depth) {
    if (found.length >= maxFiles || depth > maxDepth) return;
    let entries;
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true });
    } catch {
      return;
    }
    entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' }));
    for (const entry of entries) {
      if (found.length >= maxFiles) return;
      const entryPath = path.join(dir, entry.name);
      if (entry.isFile() && normalizedExtensions.has(path.extname(entry.name).toLowerCase())) {
        found.push(entryPath);
      } else if (entry.isDirectory() && !ignoreDirs.has(entry.name)) {
        walk(entryPath, depth + 1);
      }
    }
  }

  try {
    const realRoot = fs.realpathSync(rootDir);
    if (fs.statSync(realRoot).isDirectory()) walk(realRoot, 0);
  } catch {}
  return found;
}

function hasProjectRootMarker(dir) {
  return ['.git', '.hg', '.sl'].some((marker) => {
    try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
  });
}

function findNearestProjectRoot(startDir) {
  const start = normalizeExistingDirPath(startDir);
  if (!start) return null;
  let dir = start;
  const seen = new Set();
  while (dir && !seen.has(dir)) {
    seen.add(dir);
    if (hasProjectRootMarker(dir)) return dir;
    const parent = path.dirname(dir);
    if (!parent || parent === dir) break;
    dir = parent;
  }
  return start;
}

function composerProjectSkillRoots(cwd) {
  const start = normalizeExistingDirPath(cwd);
  const projectRoot = findNearestProjectRoot(start);
  if (!start || !projectRoot) return [];
  const roots = [];
  let dir = start;
  const seen = new Set();
  while (dir && !seen.has(dir)) {
    seen.add(dir);
    roots.push(path.join(dir, '.agents', 'skills'));
    roots.push(path.join(dir, '.codex', 'skills'));
    if (dir === projectRoot) break;
    const parent = path.dirname(dir);
    if (!parent || parent === dir) break;
    dir = parent;
  }
  return roots;
}

function composerSkillRoots(options = {}) {
  const roots = [];
  const session = options.session || (options.sessionId ? loadSession(options.sessionId) : null);
  roots.push(...composerProjectSkillRoots(options.cwd || session?.cwd));
  const codexHome = getCodexHomeDir();
  if (codexHome) roots.push(path.join(codexHome, 'skills'));
  const homeDir = normalizeExistingDirPath(process.env.HOME || process.env.USERPROFILE || '');
  if (homeDir) roots.push(path.join(homeDir, '.agents', 'skills'));
  roots.push(path.join(APP_DIR, '.codex', 'skills'));
  roots.push(path.join(APP_DIR, '.agents', 'skills'));
  roots.push('/etc/codex/skills');
  const seen = new Set();
  return roots.filter((root) => {
    const key = path.resolve(String(root || ''));
    if (!key || seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

function normalizeSkillMcpUrl(value) {
  const raw = String(value || '').trim();
  if (!raw) return '';
  try {
    const parsed = new URL(raw);
    return ['http:', 'https:'].includes(parsed.protocol) ? parsed.toString() : '';
  } catch {
    return '';
  }
}

function normalizeSkillMcpTool(rawTool) {
  if (!rawTool || typeof rawTool !== 'object') return null;
  const type = String(rawTool.type || '').trim().toLowerCase();
  const value = normalizeMcpServerName(rawTool.value || rawTool.name || rawTool.server);
  if (type !== 'mcp' || !isLikelyMcpServerName(value)) return null;
  return {
    type: 'mcp',
    value,
    name: value,
    server: value,
    description: normalizeComposerTextValue(rawTool.description || `MCP server: ${value}`),
    transport: normalizeComposerTextValue(rawTool.transport, 80),
    url: normalizeSkillMcpUrl(rawTool.url),
  };
}

function loadOpenAiSkillMetadata(skillPath) {
  const skillDir = path.dirname(skillPath);
  const metadataPath = path.join(skillDir, 'agents', 'openai.yaml');
  try {
    const stat = fs.statSync(metadataPath);
    if (!stat.isFile() || stat.size > COMPOSER_SKILL_METADATA_MAX_BYTES) return null;
    const parsed = parseOpenAiSkillYaml(fs.readFileSync(metadataPath, 'utf8'));
    const iface = parsed.interface || {};
    const policy = parsed.policy || {};
    const tools = Array.isArray(parsed.dependencies?.tools) ? parsed.dependencies.tools : [];
    const mcpTools = tools.map((tool) => normalizeSkillMcpTool(tool)).filter(Boolean);
    return {
      metadataSource: path.relative(process.cwd(), metadataPath) || metadataPath,
      displayName: normalizeComposerTextValue(iface.display_name, 100),
      shortDescription: normalizeComposerTextValue(iface.short_description, COMPOSER_SKILL_TEXT_MAX_CHARS),
      iconSmall: normalizeSkillIconUrl(iface.icon_small),
      iconLarge: normalizeSkillIconUrl(iface.icon_large),
      brandColor: normalizeSkillBrandColor(iface.brand_color),
      defaultPromptPreview: normalizeComposerTextValue(iface.default_prompt, COMPOSER_SKILL_DEFAULT_PROMPT_PREVIEW_CHARS),
      policy: {
        allowImplicitInvocation: policy.allow_implicit_invocation !== false,
      },
      dependencies: { tools: mcpTools },
    };
  } catch {
    return null;
  }
}

function loadCodexSkills(options = {}) {
  const seen = new Set();
  const skills = [];
  for (const root of composerSkillRoots(options)) {
    for (const skillPath of collectFilesByName(root, new Set(['SKILL.md']), { maxDepth: 5, maxFiles: 300 })) {
      let text = '';
      try { text = fs.readFileSync(skillPath, 'utf8'); } catch { continue; }
      const { meta } = parseSimpleFrontmatter(text);
      const dirName = path.basename(path.dirname(skillPath));
      const name = String(meta.name || dirName || '').trim();
      if (!name) continue;
      const key = normalizeComposerKey(name);
      if (!key || seen.has(key)) continue;
      seen.add(key);
      const metadata = loadOpenAiSkillMetadata(skillPath);
      const title = metadata?.displayName || name;
      const description = metadata?.shortDescription || String(meta.description || 'Codex skill').trim();
      skills.push({
        kind: 'skill',
        name,
        label: `$${name}`,
        title,
        description,
        insertion: `$${name}`,
        source: path.relative(process.cwd(), skillPath) || skillPath,
        metadataSource: metadata?.metadataSource || '',
        iconSmall: metadata?.iconSmall || '',
        iconLarge: metadata?.iconLarge || '',
        brandColor: metadata?.brandColor || '',
        defaultPromptPreview: metadata?.defaultPromptPreview || '',
        policy: metadata?.policy || { allowImplicitInvocation: true },
        dependencies: metadata?.dependencies || { tools: [] },
      });
    }
  }
  return skills;
}

function composerPromptRoots() {
  const roots = [];
  const codexHome = getCodexHomeDir();
  if (codexHome) roots.push(path.join(codexHome, 'prompts'));
  roots.push(path.join(APP_DIR, '.codex', 'prompts'));
  roots.push(path.join(APP_DIR, '.agents', 'prompts'));
  return roots;
}

function normalizePromptRecord(raw, source) {
  if (!raw || typeof raw !== 'object') return null;
  const name = String(raw.name || raw.id || '').trim().replace(/^@/, '');
  const content = String(raw.content || raw.prompt || raw.text || '').trim();
  if (!name || !content) return null;
  return {
    kind: 'prompt',
    name,
    title: String(raw.title || name).trim(),
    description: String(raw.description || '').trim(),
    content,
    label: `@${name}`,
    insertion: `@${name}`,
    source,
  };
}

function loadConfiguredPrompts() {
  try {
    if (!fs.existsSync(PROMPTS_CONFIG_PATH)) return [];
    const raw = JSON.parse(fs.readFileSync(PROMPTS_CONFIG_PATH, 'utf8'));
    const list = Array.isArray(raw) ? raw : Array.isArray(raw.prompts) ? raw.prompts : [];
    return list.map((item) => normalizePromptRecord(item, 'config/prompts.json')).filter(Boolean);
  } catch {
    return [];
  }
}

function loadPromptFiles() {
  const prompts = [];
  for (const root of composerPromptRoots()) {
    const files = Array.from(new Set([
      ...collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 300 }),
      ...collectFilesByExtension(root, new Set(['.md', '.txt']), { maxDepth: 5, maxFiles: 300 }),
    ]));
    for (const filePath of files) {
      let text = '';
      try { text = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
      const { meta, body } = parseSimpleFrontmatter(text);
      const baseName = path.basename(filePath).replace(/\.(md|txt)$/i, '');
      const relName = normalizeRelativeBrowserPath(path.relative(root, filePath)).replace(/\.(md|txt)$/i, '');
      const fallbackName = /^prompt$/i.test(baseName)
        ? path.basename(path.dirname(filePath))
        : relName;
      const prompt = normalizePromptRecord({
        name: meta.name || fallbackName,
        title: meta.title || meta.name || fallbackName,
        description: meta.description || '',
        content: body,
      }, path.relative(process.cwd(), filePath) || filePath);
      if (prompt) prompts.push(prompt);
    }
  }
  return prompts;
}

function loadComposerPrompts() {
  const seen = new Set();
  const prompts = [];
  for (const prompt of [
    ...loadConfiguredPrompts(),
    ...loadPromptFiles(),
    ...DEFAULT_COMPOSER_PROMPTS.map((item) => normalizePromptRecord(item, item.source)),
  ]) {
    if (!prompt) continue;
    const key = normalizeComposerKey(prompt.name);
    if (!key || seen.has(key)) continue;
    seen.add(key);
    prompts.push(prompt);
  }
  return prompts;
}

function getComposerPromptMap() {
  const map = new Map();
  for (const prompt of loadComposerPrompts()) {
    map.set(normalizeComposerKey(prompt.name), prompt);
  }
  return map;
}

function filterComposerItems(items, query) {
  const q = normalizeComposerKey(query);
  const filtered = q
    ? items.filter((item) => {
      const haystack = `${item.name || ''} ${item.label || ''} ${item.title || ''} ${item.description || ''}`.toLowerCase();
      return haystack.includes(q);
    })
    : items;
  return filtered.slice(0, COMPOSER_SUGGESTION_LIMIT);
}

function normalizeMcpServerName(value) {
  return String(value || '').trim().replace(/^mcp:/, '').replace(/^mcp__/, '').replace(/__.*$/, '');
}

function isLikelyMcpServerName(value) {
  const name = normalizeMcpServerName(value);
  if (!name || name.length > 80) return false;
  if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) return false;
  return !new Set([
    'content', 'text', 'input', 'result', 'status', 'server', 'tool', 'mcp',
    'true', 'false', 'null', 'undefined', 'currentConversationId',
  ]).has(name);
}

function mcpServerSuggestion(name, options = {}) {
  const server = normalizeMcpServerName(name);
  if (!isLikelyMcpServerName(server)) return null;
  return {
    kind: 'mcp',
    name: server,
    label: `mcp:${server}`,
    title: `${server} MCP`,
    description: options.description || `MCP server: ${server}`,
    insertion: `mcp:${server}`,
    appendSpace: true,
    server,
    source: options.source || 'mcp',
    itemType: 'server',
    state: options.state || 'configured',
    dependencyOf: options.dependencyOf || '',
    transport: options.transport || '',
    url: options.url || '',
  };
}

function summarizeSkillDependencies(skill) {
  const tools = Array.isArray(skill?.dependencies?.tools) ? skill.dependencies.tools : [];
  return tools
    .filter((tool) => tool?.type === 'mcp' && isLikelyMcpServerName(tool.value || tool.name || tool.server))
    .map((tool) => ({
      type: 'mcp',
      value: normalizeMcpServerName(tool.value || tool.name || tool.server),
      name: normalizeMcpServerName(tool.value || tool.name || tool.server),
      description: normalizeComposerTextValue(tool.description || ''),
      transport: normalizeComposerTextValue(tool.transport || '', 80),
      url: normalizeSkillMcpUrl(tool.url || ''),
      state: 'declared',
    }));
}

function summarizeSkillForMention(skill, configuredMcpNames = new Set()) {
  const dependencies = summarizeSkillDependencies(skill).map((dependency) => ({
    ...dependency,
    state: configuredMcpNames.has(normalizeMcpServerName(dependency.value)) ? 'configured' : 'declared',
  }));
  return {
    kind: 'skill',
    name: skill.name,
    label: `$${skill.name}`,
    title: skill.title || skill.name,
    description: skill.description || '',
    source: skill.source || '',
    metadataSource: skill.metadataSource || '',
    iconSmall: skill.iconSmall || '',
    iconLarge: skill.iconLarge || '',
    brandColor: skill.brandColor || '',
    defaultPromptPreview: skill.defaultPromptPreview || '',
    dependencies,
  };
}

function buildCcwebMcpRuntimeConfig(session, options = {}) {
  if (!session?.id || !INTERNAL_MCP_TOKEN) return null;
  const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10);
  const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0;
  if (normalizeAgent(options.agent || session?.agent || 'codex') === 'codexapp'
    && CODEX_APP_CCWEB_MCP_TRANSPORT !== 'stdio') {
    const params = new URLSearchParams({
      sourceSessionId: session.id,
      sourceHopCount: String(hopCount),
    });
    return {
      server: 'ccweb',
      name: 'ccweb',
      source: 'builtin',
      type: 'streamable_http',
      description: 'ccweb 内置共享 MCP server，可用于跨会话协作。',
      config: {
        type: 'streamable_http',
        url: `http://127.0.0.1:${PORT}/api/internal/mcp/stream?${params.toString()}`,
        bearer_token_env_var: CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV,
        startup_timeout_sec: 10,
        tool_timeout_sec: 60,
      },
    };
  }
  const env = codexAppCcwebMcpEnv(session, { ...options, mcpContext: { hopCount } });
  if (!env) return null;
  const commandSpec = ccwebMcpServerCommandSpec();
  return {
    server: 'ccweb',
    name: 'ccweb',
    source: 'builtin',
    type: 'stdio',
    description: 'ccweb 内置 MCP server，可用于跨会话协作。',
    config: {
      command: commandSpec.command,
      args: commandSpec.args,
      env,
      startup_timeout_sec: 10,
      tool_timeout_sec: 60,
    },
  };
}

function listRuntimeMcpServerConfigs(options = {}) {
  const session = options.session || (options.sessionId ? loadSession(options.sessionId) : null);
  const agent = normalizeAgent(options.agent || session?.agent || 'codex');
  if (!isCodexLikeAgent(agent)) return [];
  const configs = [];
  const seen = new Set();
  const push = (config) => {
    const server = normalizeMcpServerName(config?.server || config?.name);
    if (!server || seen.has(server)) return;
    seen.add(server);
    configs.push({ ...config, server, name: server });
  };
  for (const config of loadProjectCodexMcpServerConfigs(session?.cwd || options.cwd || getDefaultSessionCwd())) {
    push({ ...config, source: 'project-config' });
  }
  push(buildCcwebMcpRuntimeConfig(session, options));
  return configs;
}

function listComposerMcpItems(options = {}) {
  const items = [];
  const seen = new Set();
  const push = (item) => {
    if (!item?.server && !item?.name) return;
    const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
    if (seen.has(key)) return;
    seen.add(key);
    items.push(item);
  };

  for (const config of listRuntimeMcpServerConfigs(typeof options === 'string' ? { sessionId: options } : options)) {
    push(mcpServerSuggestion(config.server, {
      source: config.source || 'runtime',
      description: config.description || `MCP server: ${config.server}`,
      transport: config.type || '',
      url: config.config?.url || '',
    }));
    if (config.server === 'ccweb') {
      for (const tool of CCWEB_MCP_TOOLS) {
        const name = String(tool?.name || '').trim();
        const label = `mcp:ccweb/${name}`;
        push({
          kind: 'mcp',
          name,
          label,
          title: `ccweb/${name}`,
          description: String(tool?.description || 'MCP 工具').trim(),
          insertion: label,
          appendSpace: true,
          server: 'ccweb',
          source: 'mcp:ccweb',
          itemType: 'tool',
          action: '',
        });
      }
    }
  }
  return items;
}

function mergeComposerSuggestionGroups(...groups) {
  const merged = [];
  const seen = new Set();
  for (const group of groups) {
    for (const item of Array.isArray(group) ? group : []) {
      const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
      if (!key || seen.has(key)) continue;
      seen.add(key);
      merged.push(item);
      if (merged.length >= COMPOSER_SUGGESTION_LIMIT) return merged;
    }
  }
  return merged;
}

function listComposerFileSuggestions(sessionId, query) {
  const session = sessionId ? loadSession(sessionId) : null;
  const rootCandidate = session?.cwd || getDefaultSessionCwd();
  const rootDir = normalizeExistingDirPath(rootCandidate);
  if (!rootDir) return [];

  const raw = String(query || '').replace(/^@/, '').replace(/\\/g, '/');
  const slashIndex = raw.lastIndexOf('/');
  const dirPart = slashIndex >= 0 ? raw.slice(0, slashIndex) : '';
  const base = slashIndex >= 0 ? raw.slice(slashIndex + 1) : raw;
  const target = resolveBrowseTarget(rootDir, dirPart);
  if (!target.ok) return [];

  let stat;
  try { stat = fs.statSync(target.realPath); } catch { return []; }
  if (!stat.isDirectory()) return [];

  let entries = [];
  try { entries = fs.readdirSync(target.realPath, { withFileTypes: true }); } catch { return []; }
  const q = base.toLowerCase();
  const items = [];
  for (const entry of entries) {
    if (entry.name.startsWith('.')) continue;
    if (q && !entry.name.toLowerCase().includes(q)) continue;
    const rawEntryPath = path.join(target.realPath, entry.name);
    try {
      const lstat = fs.lstatSync(rawEntryPath);
      const symlink = lstat.isSymbolicLink();
      const realEntryPath = symlink ? fs.realpathSync(rawEntryPath) : rawEntryPath;
      if (!isPathInside(rootDir, realEntryPath)) continue;
      const finalStat = symlink ? fs.statSync(rawEntryPath) : lstat;
      const kind = finalStat.isDirectory() ? 'directory' : finalStat.isFile() ? 'file' : null;
      if (!kind) continue;
      const rel = normalizeRelativeBrowserPath(target.relativePath ? `${target.relativePath}/${entry.name}` : entry.name);
      items.push({
        kind: 'file',
        itemType: kind,
        name: rel,
        label: `@${rel}${kind === 'directory' ? '/' : ''}`,
        description: kind === 'directory' ? '工作目录' : `${finalStat.size} bytes`,
        insertion: `@${rel}${kind === 'directory' ? '/' : ''}`,
        appendSpace: kind !== 'directory',
      });
    } catch {}
  }
  items.sort((a, b) => {
    if (a.itemType !== b.itemType) return a.itemType === 'directory' ? -1 : 1;
    return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
  });
  return items.slice(0, COMPOSER_SUGGESTION_LIMIT);
}

function listComposerSuggestions(trigger, query, sessionId, agent, session = null) {
  const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : [];
  if (trigger === '/') {
    const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, session, agent }), query);
    const promptUserMcpItems = mcpItems.filter((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user');
    const otherMcpItems = mcpItems.filter((item) => !(item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user'));
    const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
      kind: 'command',
      name: cmd.name,
      label: cmd.name,
      description: cmd.description,
      insertion: cmd.insertion,
    })), query.replace(/^\//, ''));
    return mergeComposerSuggestionGroups(commands, promptUserMcpItems, otherMcpItems);
  }
  if (trigger === '$') {
    const skills = filterComposerItems(skillItems, query);
    return mergeComposerSuggestionGroups(skills);
  }
  if (trigger === '@') {
    const prompts = filterComposerItems(loadComposerPrompts().map((prompt) => ({
      kind: 'prompt',
      name: prompt.name,
      label: `@${prompt.name}`,
      title: prompt.title,
      description: prompt.description || 'Prompt 模板',
      insertion: `@${prompt.name}`,
      appendSpace: true,
    })), query);
    const files = listComposerFileSuggestions(sessionId, query);
    return mergeComposerSuggestionGroups(files, prompts);
  }
  return [];
}

function handleComposerSuggestions(ws, msg) {
  const trigger = ['/', '$', '@'].includes(msg.trigger) ? msg.trigger : '';
  const query = String(msg.query || '').replace(/^[@$/]/, '').trim();
  const requestId = String(msg.requestId || '');
  if (!trigger) {
    return wsSend(ws, { type: 'composer_suggestions', requestId, trigger, query, items: [] });
  }
  const sessionId = sanitizeId(msg.sessionId || '');
  const agent = normalizeAgent(msg.agent);
  const session = sessionId ? loadSession(sessionId) : null;
  const items = listComposerSuggestions(trigger, query, sessionId, agent, session);
  return wsSend(ws, { type: 'composer_suggestions', requestId, trigger, query, items });
}

function cleanMentionToken(raw) {
  return String(raw || '').replace(/[，。,;；:：!?！？)）\]}]+$/u, '');
}

function resolveComposerFileContext(session, rawPath) {
  const rootDir = normalizeExistingDirPath(session?.cwd || getDefaultSessionCwd());
  if (!rootDir) return null;
  const target = resolveBrowseTarget(rootDir, rawPath);
  if (!target.ok) return null;

  let stat;
  try { stat = fs.statSync(target.realPath); } catch { return null; }
  if (stat.isDirectory()) {
    let names = [];
    try {
      names = fs.readdirSync(target.realPath, { withFileTypes: true })
        .filter((entry) => entry.isDirectory() || entry.isFile())
        .slice(0, 80)
        .map((entry) => `${entry.isDirectory() ? '[dir]' : '[file]'} ${entry.name}`);
    } catch {}
    return {
      kind: 'file',
      type: 'directory',
      path: target.relativePath || '.',
      block: `----- BEGIN CC-WEB DIRECTORY: ${target.relativePath || '.'} -----\n${names.join('\n')}\n----- END CC-WEB DIRECTORY -----`,
    };
  }
  if (!stat.isFile()) return null;

  let buffer;
  try {
    buffer = readFilePreviewBuffer(target.realPath, COMPOSER_FILE_CONTEXT_MAX_BYTES);
  } catch {
    return null;
  }
  if (!isPreviewTextExtension(target.realPath) && !isProbablyTextBuffer(buffer)) {
    return {
      kind: 'file',
      type: 'binary',
      path: target.relativePath,
      block: `----- CC-WEB FILE: ${target.relativePath} -----\n该文件不是可安全内联的文本文件，未展开内容。`,
    };
  }
  const truncated = stat.size > buffer.length;
  return {
    kind: 'file',
    type: 'file',
    path: target.relativePath,
    truncated,
    block: `----- BEGIN CC-WEB FILE: ${target.relativePath}${truncated ? ' (truncated)' : ''} -----\n${buffer.toString('utf8')}\n----- END CC-WEB FILE -----`,
  };
}

function resolveComposerDecorators(text, session, agent) {
  const rawText = String(text || '');
  if (!rawText.trim() || rawText.trimStart().startsWith('/')) {
    return { runtimeText: rawText, mentions: [] };
  }

  const promptsByName = getComposerPromptMap();
  const skillsByName = new Map(loadCodexSkills({ session }).map((skill) => [normalizeComposerKey(skill.name), skill]));
  const mentions = [];
  const promptBlocks = [];
  const fileBlocks = [];
  const seenPromptNames = new Set();
  const seenFilePaths = new Set();
  const seenSkillNames = new Set();
  const configuredMcpNames = new Set(listComposerMcpItems({ sessionId: session?.id, agent, skills: [...skillsByName.values()] })
    .filter((item) => item.kind === 'mcp' && item.itemType === 'server' && item.state !== 'declared')
    .map((item) => normalizeMcpServerName(item.server || item.name)));

  const re = /(^|\s)([@$])([^\s]+)/g;
  let match;
  while ((match = re.exec(rawText))) {
    const trigger = match[2];
    const token = cleanMentionToken(match[3]);
    const key = normalizeComposerKey(token);
    if (!key) continue;

    if (trigger === '$' && isCodexLikeAgent(agent)) {
      const skill = skillsByName.get(key);
      if (skill && !seenSkillNames.has(key)) {
        seenSkillNames.add(key);
        mentions.push(summarizeSkillForMention(skill, configuredMcpNames));
      }
      continue;
    }

    if (trigger !== '@') continue;
    const prompt = promptsByName.get(key);
    if (prompt && !seenPromptNames.has(key) && seenPromptNames.size < COMPOSER_MAX_PROMPT_MENTIONS) {
      seenPromptNames.add(key);
      mentions.push({ kind: 'prompt', name: prompt.name, label: `@${prompt.name}`, title: prompt.title || prompt.name, source: prompt.source || '' });
      promptBlocks.push(`----- BEGIN CC-WEB PROMPT: ${prompt.name} -----\n${prompt.content}\n----- END CC-WEB PROMPT -----`);
      continue;
    }

    if (seenFilePaths.size >= COMPOSER_MAX_FILE_MENTIONS) continue;
    const fileContext = resolveComposerFileContext(session, token);
    if (fileContext && !seenFilePaths.has(fileContext.path)) {
      seenFilePaths.add(fileContext.path);
      mentions.push({
        kind: 'file',
        name: fileContext.path,
        label: `@${fileContext.path}`,
        type: fileContext.type,
        truncated: !!fileContext.truncated,
      });
      fileBlocks.push(fileContext.block);
    }
  }

  if (promptBlocks.length === 0 && fileBlocks.length === 0) {
    return { runtimeText: rawText, mentions };
  }

  const context = [
    ...promptBlocks,
    ...fileBlocks,
    '----- BEGIN USER MESSAGE -----',
    rawText,
    '----- END USER MESSAGE -----',
  ].join('\n\n');

  return { runtimeText: context, mentions };
}

function handleFileSystemListApi(req, res, url) {
  const token = extractBearerToken(req);
  if (!token || !activeTokens.has(token)) {
    return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
  }

  const sessionId = sanitizeId(url.searchParams.get('sessionId') || '');
  if (!sessionId) {
    return jsonResponse(res, 400, { ok: false, message: '缺少 sessionId' });
  }

  const browseContext = getSessionBrowseContext(sessionId);
  if (!browseContext.ok) {
    return jsonResponse(res, browseContext.statusCode, { ok: false, message: browseContext.message });
  }

  const target = resolveBrowseTarget(browseContext.rootDir, url.searchParams.get('path') || '');
  if (!target.ok) {
    return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
  }

  let stat;
  try {
    stat = fs.statSync(target.realPath);
  } catch (err) {
    return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
  }
  if (!stat.isDirectory()) {
    return jsonResponse(res, 400, { ok: false, message: '目标路径不是目录' });
  }

  let dirEntries = [];
  try {
    dirEntries = fs.readdirSync(target.realPath, { withFileTypes: true });
  } catch (err) {
    return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
  }

  const entries = [];
  for (const entry of dirEntries) {
    const rawEntryPath = path.join(target.realPath, entry.name);
    try {
      const lstat = fs.lstatSync(rawEntryPath);
      const symlink = lstat.isSymbolicLink();
      const resolvedEntryPath = symlink ? fs.realpathSync(rawEntryPath) : rawEntryPath;
      if (!isPathInside(browseContext.rootDir, resolvedEntryPath)) continue;
      const finalStat = symlink ? fs.statSync(rawEntryPath) : lstat;
      const kind = finalStat.isDirectory() ? 'directory' : finalStat.isFile() ? 'file' : null;
      if (!kind) continue;
      const childRelativePath = normalizeRelativeBrowserPath(
        target.relativePath ? `${target.relativePath}/${entry.name}` : entry.name
      );
      entries.push({
        name: entry.name,
        path: childRelativePath,
        kind,
        size: kind === 'file' ? finalStat.size : 0,
        updatedAt: finalStat.mtime.toISOString(),
        previewableHint: kind === 'file' && (isPreviewTextExtension(entry.name) || !path.extname(entry.name)),
        symlink,
      });
    } catch {}
  }

  entries.sort((a, b) => {
    if (a.kind !== b.kind) return a.kind === 'directory' ? -1 : 1;
    return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
  });

  const parentPath = target.relativePath
    ? normalizeRelativeBrowserPath(path.posix.dirname(target.relativePath))
    : null;

  return jsonResponse(res, 200, {
    ok: true,
    sessionId,
    rootPath: browseContext.rootDir,
    currentPath: target.relativePath,
    currentDisplayPath: target.realPath,
    parentPath: parentPath && parentPath !== '.' ? parentPath : '',
    truncated: entries.length > FILE_BROWSER_MAX_LIST_ENTRIES,
    entryLimit: FILE_BROWSER_MAX_LIST_ENTRIES,
    entries: entries.slice(0, FILE_BROWSER_MAX_LIST_ENTRIES),
  });
}

function handleFileSystemReadApi(req, res, url) {
  const token = extractBearerToken(req);
  if (!token || !activeTokens.has(token)) {
    return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
  }

  const sessionId = sanitizeId(url.searchParams.get('sessionId') || '');
  if (!sessionId) {
    return jsonResponse(res, 400, { ok: false, message: '缺少 sessionId' });
  }

  const browseContext = getSessionBrowseContext(sessionId);
  if (!browseContext.ok) {
    return jsonResponse(res, browseContext.statusCode, { ok: false, message: browseContext.message });
  }

  const target = resolveBrowseTarget(browseContext.rootDir, url.searchParams.get('path') || '');
  if (!target.ok) {
    return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
  }

  let stat;
  try {
    stat = fs.statSync(target.realPath);
  } catch (err) {
    return jsonResponse(res, 500, { ok: false, message: `读取文件失败: ${err.message}` });
  }
  if (!stat.isFile()) {
    return jsonResponse(res, 400, { ok: false, message: '目标路径不是文件' });
  }

  let previewBuffer;
  try {
    previewBuffer = readFilePreviewBuffer(target.realPath, FILE_BROWSER_MAX_PREVIEW_BYTES);
  } catch (err) {
    return jsonResponse(res, 500, { ok: false, message: `读取文件失败: ${err.message}` });
  }

  if (!isPreviewTextExtension(target.realPath) && !isProbablyTextBuffer(previewBuffer)) {
    return jsonResponse(res, 415, { ok: false, message: '当前仅支持预览简单文本文件' });
  }

  return jsonResponse(res, 200, {
    ok: true,
    sessionId,
    rootPath: browseContext.rootDir,
    path: target.relativePath,
    name: path.basename(target.realPath),
    size: stat.size,
    updatedAt: stat.mtime.toISOString(),
    truncated: stat.size > FILE_BROWSER_MAX_PREVIEW_BYTES,
    previewBytes: previewBuffer.length,
    content: previewBuffer.toString('utf8'),
  });
}

function handleDirectoryPickerListApi(req, res, url) {
  const token = extractBearerToken(req);
  if (!token || !activeTokens.has(token)) {
    return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
  }

  const target = resolveDirectoryPickerTarget(url.searchParams.get('path') || '');
  if (!target.ok) {
    return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
  }

  let dirEntries = [];
  try {
    dirEntries = fs.readdirSync(target.realPath, { withFileTypes: true });
  } catch (err) {
    return jsonResponse(res, 500, { ok: false, message: `读取目录失败: ${err.message}` });
  }

  const entries = [];
  for (const entry of dirEntries) {
    const rawEntryPath = path.join(target.realPath, entry.name);
    try {
      const lstat = fs.lstatSync(rawEntryPath);
      const symlink = lstat.isSymbolicLink();
      const realEntryPath = symlink ? fs.realpathSync(rawEntryPath) : rawEntryPath;
      const stat = symlink ? fs.statSync(rawEntryPath) : lstat;
      if (!stat.isDirectory()) continue;
      entries.push({
        name: entry.name,
        path: realEntryPath,
        kind: 'directory',
        updatedAt: stat.mtime.toISOString(),
        symlink,
      });
    } catch {}
  }

  entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' }));

  return jsonResponse(res, 200, {
    ok: true,
    defaultPath: target.defaultPath,
    currentPath: target.realPath,
    parentPath: target.parentPath,
    truncated: entries.length > FILE_BROWSER_MAX_LIST_ENTRIES,
    entryLimit: FILE_BROWSER_MAX_LIST_ENTRIES,
    entries: entries.slice(0, FILE_BROWSER_MAX_LIST_ENTRIES),
  });
}

const INITIAL_HISTORY_COUNT = 12;
const HISTORY_CHUNK_SIZE = 24;

function normalizeAgent(agent) {
  return VALID_AGENTS.has(agent) ? agent : 'claude';
}

function isCodexLikeAgent(agent) {
  const normalized = normalizeAgent(agent);
  return normalized === 'codex' || normalized === 'codexapp';
}

function normalizeSession(session) {
  if (!session || typeof session !== 'object') return session;
  session.agent = normalizeAgent(session.agent);
  if (!Object.prototype.hasOwnProperty.call(session, 'pinnedAt')) session.pinnedAt = null;
  if (session.pinnedAt && Number.isNaN(new Date(session.pinnedAt).getTime())) session.pinnedAt = null;
  const titleSource = String(session.titleSource || '').trim().toLowerCase();
  if (titleSource && VALID_TITLE_SOURCES.has(titleSource)) {
    session.titleSource = titleSource;
  } else if (Object.prototype.hasOwnProperty.call(session, 'titleSource')) {
    delete session.titleSource;
  }
  if (!Object.prototype.hasOwnProperty.call(session, 'claudeSessionId')) session.claudeSessionId = null;
  if (!Object.prototype.hasOwnProperty.call(session, 'codexThreadId')) session.codexThreadId = null;
  if (!Object.prototype.hasOwnProperty.call(session, 'codexAppThreadId')) session.codexAppThreadId = null;
  if (!Object.prototype.hasOwnProperty.call(session, 'totalCost')) session.totalCost = 0;
  if (!Object.prototype.hasOwnProperty.call(session, 'totalUsage') || !session.totalUsage) {
    session.totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
  }
  if (!Object.prototype.hasOwnProperty.call(session, 'messages')) session.messages = [];
  if (Array.isArray(session.messages)) {
    session.messages = session.messages.map((message) => {
      if (!message || typeof message !== 'object') return message;
      if (message.attachments) {
        return { ...message, attachments: normalizeMessageAttachments(message.attachments) };
      }
      return message;
    });
  }
  return session;
}

function getSessionAgent(session) {
  return normalizeAgent(session?.agent);
}

function isClaudeSession(session) {
  return getSessionAgent(session) === 'claude';
}

function isCodexSession(session) {
  return getSessionAgent(session) === 'codex';
}

function isCodexAppSession(session) {
  return getSessionAgent(session) === 'codexapp';
}

function isCodexLikeSession(session) {
  const agent = getSessionAgent(session);
  return agent === 'codex' || agent === 'codexapp';
}

function isSessionRunning(sessionId) {
  return activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId) || activeCodexAppGoalCommands.has(sessionId);
}

function getRuntimeSessionId(session) {
  if (!session) return null;
  const agent = getSessionAgent(session);
  if (agent === 'codex') return session.codexThreadId || null;
  if (agent === 'codexapp') return session.codexAppThreadId || null;
  return session.claudeSessionId || null;
}

function mcpStatusObject(value) {
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
}

function firstPresentValue(...values) {
  for (const value of values) {
    if (value === undefined || value === null) continue;
    if (typeof value === 'string' && !value.trim()) continue;
    return value;
  }
  return null;
}

function redactMcpStatusText(text) {
  return String(text || '')
    .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
    .replace(/\b((?:[A-Z0-9_]*_)?(?:TOKEN|API_KEY|SECRET|PASSWORD|AUTHORIZATION))\b\s*[:=]\s*["']?[^"',\s}]+/gi, '$1=[redacted]')
    .replace(/("(?:[^"]*(?:token|api[_-]?key|secret|password|authorization)[^"]*)"\s*:\s*)"[^"]*"/gi, '$1"[redacted]"');
}

function safeMcpStatusString(value, maxChars = 500) {
  if (value === undefined || value === null) return '';
  let text = '';
  if (typeof value === 'string') {
    text = value;
  } else if (typeof value === 'number' || typeof value === 'boolean') {
    text = String(value);
  } else if (mcpStatusObject(value)) {
    text = firstPresentValue(value.message, value.error, value.reason, value.detail);
    if (text === null) {
      try {
        text = JSON.stringify(value);
      } catch {
        text = String(value);
      }
    }
  } else {
    text = String(value);
  }
  const redacted = redactMcpStatusText(text).trim();
  if (!redacted) return '';
  return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...` : redacted;
}

function normalizeCodexAppMcpStartupStatus(value) {
  const raw = safeMcpStatusString(value, 80).toLowerCase();
  if (!raw) return 'unknown';
  if (/^(ready|running|ok|success|succeeded|started|available)$/.test(raw)) return 'ready';
  if (/^(starting|pending|loading|initializing|launching|connecting)$/.test(raw)) return 'starting';
  if (/^(failed|failure|error|errored|crashed)$/.test(raw)) return 'failed';
  if (/^(cancelled|canceled|disabled|stopped)$/.test(raw)) return 'cancelled';
  return raw;
}

function codexAppMcpStatusKey(name) {
  return safeMcpStatusString(name || CODEX_APP_MCP_DEFAULT_SERVER, 120).toLowerCase() || CODEX_APP_MCP_DEFAULT_SERVER;
}

function publicCodexAppMcpStatusRecord(record = {}) {
  const name = safeMcpStatusString(record.name || record.server || CODEX_APP_MCP_DEFAULT_SERVER, 120) || CODEX_APP_MCP_DEFAULT_SERVER;
  const status = normalizeCodexAppMcpStartupStatus(record.status || record.state || 'unknown');
  return {
    server: name,
    name,
    status,
    rawStatus: safeMcpStatusString(record.rawStatus || record.status || status, 80) || status,
    message: safeMcpStatusString(record.message || record.error || '', 500),
    threadId: safeMcpStatusString(record.threadId || '', 160) || null,
    updatedAt: safeMcpStatusString(record.updatedAt || new Date().toISOString(), 80),
    source: safeMcpStatusString(record.source || 'notification', 40) || 'notification',
  };
}

function parseCodexAppMcpStartupStatus(params = {}) {
  const direct = mcpStatusObject(params) || {};
  const statusObject = mcpStatusObject(direct.status) || mcpStatusObject(direct.startupStatus) || mcpStatusObject(direct.serverStatus) || {};
  const serverObject = mcpStatusObject(direct.server) || mcpStatusObject(direct.mcpServer) || mcpStatusObject(statusObject.server) || {};
  const name = safeMcpStatusString(firstPresentValue(
    direct.name,
    direct.serverName,
    direct.mcpServerName,
    typeof direct.server === 'string' ? direct.server : null,
    typeof direct.mcpServer === 'string' ? direct.mcpServer : null,
    serverObject.name,
    serverObject.server,
    statusObject.name,
    statusObject.server,
    statusObject.serverName
  ), 120);
  const rawStatus = firstPresentValue(
    typeof direct.status === 'string' ? direct.status : null,
    direct.state,
    direct.startupState,
    statusObject.status,
    statusObject.state,
    statusObject.startupState,
    direct.ready === true ? 'ready' : null,
    direct.ok === false ? 'failed' : null
  );
  const threadId = safeMcpStatusString(firstPresentValue(
    direct.threadId,
    direct.thread?.id,
    statusObject.threadId,
    statusObject.thread?.id
  ), 160) || null;
  if (!name && rawStatus === null && !threadId) return null;
  const status = normalizeCodexAppMcpStartupStatus(rawStatus);
  return publicCodexAppMcpStatusRecord({
    name: name || CODEX_APP_MCP_DEFAULT_SERVER,
    status,
    rawStatus: rawStatus || status,
    message: firstPresentValue(
      direct.message,
      direct.error,
      direct.reason,
      direct.detail,
      statusObject.message,
      statusObject.error,
      statusObject.reason,
      statusObject.detail
    ),
    threadId,
    updatedAt: new Date().toISOString(),
    source: 'notification',
  });
}

function ensureCodexAppMcpStartupState(session) {
  if (!session || typeof session !== 'object') return null;
  if (!mcpStatusObject(session.codexAppMcpStartupStatus)) {
    session.codexAppMcpStartupStatus = {};
  }
  const state = session.codexAppMcpStartupStatus;
  if (!mcpStatusObject(state.servers)) state.servers = {};
  return state;
}

function findCodexAppMcpStatusRecord(state, serverName = CODEX_APP_MCP_DEFAULT_SERVER) {
  const servers = mcpStatusObject(state?.servers) || {};
  const key = codexAppMcpStatusKey(serverName);
  if (servers[key]) return publicCodexAppMcpStatusRecord(servers[key]);
  return Object.values(servers)
    .map((record) => publicCodexAppMcpStatusRecord(record))
    .find((record) => codexAppMcpStatusKey(record.name) === key) || null;
}

function buildCodexAppMcpStatusSummary(session, options = {}) {
  const state = mcpStatusObject(session?.codexAppMcpStartupStatus) || {};
  const serversObject = mcpStatusObject(state.servers) || {};
  const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record));
  let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER);
  const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null;
  if (!current) {
    const threadId = safeMcpStatusString(state.threadId || getRuntimeSessionId(session) || '', 160) || null;
    current = publicCodexAppMcpStatusRecord({
      name: options.serverName || CODEX_APP_MCP_DEFAULT_SERVER,
      status: reloadRequestedAt ? 'pending' : 'unknown',
      rawStatus: reloadRequestedAt ? 'pending' : 'unknown',
      message: reloadRequestedAt ? '已请求重载，等待 app-server 上报启动状态' : '尚未收到 app-server 启动状态',
      threadId,
      updatedAt: reloadRequestedAt || new Date().toISOString(),
      source: reloadRequestedAt ? 'pending' : 'unknown',
    });
  }
  return {
    ...current,
    reloadRequestedAt,
    reloadRequestId: safeMcpStatusString(state.reloadRequestId || '', 80) || null,
    hasStartupStatus: current.source === 'notification',
    servers,
  };
}

function markCodexAppMcpReloadPending(session, sessionId) {
  const requestedAt = new Date().toISOString();
  const threadId = getRuntimeSessionId(session) || null;
  const reloadRequestId = crypto.randomUUID();
  const state = ensureCodexAppMcpStartupState(session);
  if (!state) return { requestedAt, summary: null };
  state.reloadRequestedAt = requestedAt;
  state.reloadRequestId = reloadRequestId;
  state.updatedAt = requestedAt;
  state.threadId = threadId;
  state.servers[codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER)] = publicCodexAppMcpStatusRecord({
    name: CODEX_APP_MCP_DEFAULT_SERVER,
    status: 'pending',
    rawStatus: 'pending',
    message: '已请求重载，等待 app-server 上报启动状态',
    threadId,
    updatedAt: requestedAt,
    source: 'pending',
  });
  pendingCodexAppMcpReloads.set(sessionId, {
    threadId,
    requestedAt,
    expiresAt: Date.now() + CODEX_APP_MCP_RELOAD_TRACK_MS,
    reloadRequestId,
  });
  saveSession(session);
  return {
    requestedAt,
    summary: buildCodexAppMcpStatusSummary(session, { reloadRequestedAt: requestedAt }),
  };
}

function cleanupExpiredCodexAppMcpReloads() {
  const now = Date.now();
  for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) {
    if ((pending.expiresAt || 0) <= now) pendingCodexAppMcpReloads.delete(sessionId);
  }
}

function isFinalCodexAppMcpStatus(status) {
  return status === 'ready' || status === 'failed' || status === 'cancelled';
}

function isFreshCodexAppMcpSummary(summary, requestedAt) {
  if (!summary || summary.source !== 'notification') return false;
  if (!requestedAt) return true;
  const updated = Date.parse(summary.updatedAt || '');
  const requested = Date.parse(requestedAt || '');
  if (!Number.isFinite(updated) || !Number.isFinite(requested)) return true;
  return updated >= requested;
}

function resolveCodexAppMcpStatusWaiters(sessionId, summary) {
  const waiters = codexAppMcpStatusWaiters.get(sessionId);
  if (!waiters || waiters.size === 0) return;
  for (const waiter of Array.from(waiters)) {
    if (!isFreshCodexAppMcpSummary(summary, waiter.requestedAt) || !isFinalCodexAppMcpStatus(summary.status)) continue;
    clearTimeout(waiter.timer);
    waiters.delete(waiter);
    waiter.resolve(summary);
  }
  if (waiters.size === 0) codexAppMcpStatusWaiters.delete(sessionId);
}

function waitForCodexAppMcpStatusAfterReload(sessionId, requestedAt, timeoutMs = CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS) {
  const current = buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt: requestedAt });
  if (isFreshCodexAppMcpSummary(current, requestedAt) && isFinalCodexAppMcpStatus(current.status)) {
    return Promise.resolve(current);
  }
  return new Promise((resolve) => {
    const waiter = {
      requestedAt,
      resolve,
      timer: setTimeout(() => {
        const waiters = codexAppMcpStatusWaiters.get(sessionId);
        if (waiters) {
          waiters.delete(waiter);
          if (waiters.size === 0) codexAppMcpStatusWaiters.delete(sessionId);
        }
        resolve(buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt: requestedAt }));
      }, timeoutMs),
    };
    let waiters = codexAppMcpStatusWaiters.get(sessionId);
    if (!waiters) {
      waiters = new Set();
      codexAppMcpStatusWaiters.set(sessionId, waiters);
    }
    waiters.add(waiter);
  });
}

function updateCodexAppSessionMcpStatus(sessionId, statusRecord) {
  const normalizedId = sanitizeId(sessionId || '');
  if (!normalizedId) return null;
  const session = loadSession(normalizedId);
  if (!session || !isCodexAppSession(session)) return null;
  const state = ensureCodexAppMcpStartupState(session);
  if (!state) return null;
  const record = publicCodexAppMcpStatusRecord({
    ...statusRecord,
    threadId: statusRecord.threadId || state.threadId || getRuntimeSessionId(session) || null,
    source: 'notification',
  });
  const key = codexAppMcpStatusKey(record.name);
  state.servers[key] = record;
  state.updatedAt = record.updatedAt;
  state.threadId = record.threadId || state.threadId || null;
  if (key === codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER) && isFinalCodexAppMcpStatus(record.status)) {
    pendingCodexAppMcpReloads.delete(normalizedId);
  }
  saveSession(session);
  const summary = buildCodexAppMcpStatusSummary(session);
  resolveCodexAppMcpStatusWaiters(normalizedId, summary);
  sendSessionEventToViewers(normalizedId, {
    type: 'mcp_startup_status',
    sessionId: normalizedId,
    status: summary,
    mcpStatus: summary,
  });
  return summary;
}

function markCodexAppMcpReloadFailed(sessionId, message) {
  return updateCodexAppSessionMcpStatus(sessionId, {
    name: CODEX_APP_MCP_DEFAULT_SERVER,
    status: 'failed',
    rawStatus: 'failed',
    message,
    updatedAt: new Date().toISOString(),
    source: 'notification',
  });
}

function codexAppMcpStatusTargetSessionIds(statusRecord, routed) {
  cleanupExpiredCodexAppMcpReloads();
  const targetSessionIds = new Set();
  if (routed?.sessionId) targetSessionIds.add(routed.sessionId);
  for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) {
    if (statusRecord.threadId && pending.threadId && statusRecord.threadId !== pending.threadId) continue;
    targetSessionIds.add(sessionId);
  }
  return targetSessionIds;
}

function handleCodexAppMcpStartupStatusNotification(notification, routed) {
  if (notification?.method !== CODEX_APP_MCP_STARTUP_STATUS_METHOD) return false;
  const statusRecord = parseCodexAppMcpStartupStatus(notification.params || {});
  if (!statusRecord) {
    plog('WARN', 'codex_app_mcp_startup_status_unparsed', { method: notification.method });
    return true;
  }
  codexAppMcpStartupStatusByServer.set(codexAppMcpStatusKey(statusRecord.name), statusRecord);
  const targetSessionIds = codexAppMcpStatusTargetSessionIds(statusRecord, routed);
  for (const sessionId of targetSessionIds) {
    updateCodexAppSessionMcpStatus(sessionId, statusRecord);
  }
  plog('INFO', 'codex_app_mcp_startup_status_updated', {
    server: statusRecord.name,
    status: statusRecord.status,
    threadId: statusRecord.threadId,
    targetSessions: targetSessionIds.size,
  });
  return true;
}

async function handleReloadMcpApi(req, res, rawSessionId) {
  const token = extractBearerToken(req);
  if (!token || !activeTokens.has(token)) {
    return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
  }

  const sessionId = sanitizeId(rawSessionId || '');
  if (!sessionId) {
    return jsonResponse(res, 400, { ok: false, code: 'missing_session_id', message: '缺少会话 ID' });
  }

  const session = loadSession(sessionId);
  if (!session) {
    return jsonResponse(res, 404, { ok: false, code: 'session_not_found', message: '会话不存在' });
  }

  if (!isCodexAppSession(session)) {
    return jsonResponse(res, 400, {
      ok: false,
      code: 'reload_mcp_unsupported_agent',
      message: '重载 MCP 仅支持 Codex App 会话。旧 Codex 会话请重启本地 Codex 后再继续。',
    });
  }

  let reloadRequestedAt = null;
  try {
    const clientResult = getCodexAppClient();
    if (clientResult.error) {
      return jsonResponse(res, 500, {
        ok: false,
        code: 'codexapp_client_unavailable',
        message: clientResult.error,
      });
    }

    const client = clientResult.client;
    await client.start();
    const pendingMcp = markCodexAppMcpReloadPending(session, sessionId);
    reloadRequestedAt = pendingMcp.requestedAt;
    const result = typeof client.reloadMcpServers === 'function'
      ? await client.reloadMcpServers()
      : await client.request('config/mcpServer/reload', {}, 30000);
    const mcpStatus = await waitForCodexAppMcpStatusAfterReload(sessionId, reloadRequestedAt);

    plog('INFO', 'codex_app_mcp_reload_requested', {
      sessionId: sessionId.slice(0, 8),
      threadId: getRuntimeSessionId(session) || null,
      status: mcpStatus?.status || pendingMcp.summary?.status || 'pending',
    });

    return jsonResponse(res, 200, {
      ok: true,
      sessionId,
      threadId: getRuntimeSessionId(session) || null,
      result: result || {},
      mcpStatus: mcpStatus || pendingMcp.summary || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }),
    });
  } catch (err) {
    const unsupported = err?.code === -32601 || /not found|unknown|unsupported|method/i.test(String(err?.message || ''));
    const message = unsupported
      ? `当前 Codex app-server 不支持重载 MCP，请重启 Codex App。${err?.message ? `（${err.message}）` : ''}`
      : `重载 MCP 失败: ${err?.message || err}`;
    const mcpStatus = reloadRequestedAt
      ? markCodexAppMcpReloadFailed(sessionId, message)
      : buildCodexAppMcpStatusSummary(session);
    return jsonResponse(res, unsupported ? 501 : 500, {
      ok: false,
      code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed',
      message,
      mcpStatus,
    });
  }
}

function setRuntimeSessionId(session, runtimeId) {
  if (!session) return;
  const agent = getSessionAgent(session);
  if (agent === 'codex') {
    session.codexThreadId = runtimeId || null;
  } else if (agent === 'codexapp') {
    session.codexAppThreadId = runtimeId || null;
  } else {
    session.claudeSessionId = runtimeId || null;
  }
}

function clearRuntimeSessionId(session) {
  setRuntimeSessionId(session, null);
}

function textByteLength(text) {
  return Buffer.byteLength(String(text || ''), 'utf8');
}

function truncateTextValue(value, maxChars, marker = PERSIST_TRUNCATED_TEXT) {
  const text = String(value || '');
  if (!Number.isFinite(maxChars) || maxChars <= 0 || text.length <= maxChars) return text;
  const suffix = `\n\n${marker}`;
  const keep = Math.max(0, maxChars - suffix.length);
  return `${text.slice(0, keep)}${suffix}`;
}

function sanitizePersistValue(value, options = {}, depth = 0, seen = new WeakSet()) {
  const maxString = options.maxString || SESSION_MESSAGE_CONTENT_MAX_CHARS;
  const maxDepth = options.maxDepth || 4;
  const maxArray = options.maxArray || 80;
  const maxKeys = options.maxKeys || 80;

  if (value === null || value === undefined) return value;
  if (typeof value === 'string') return truncateTextValue(value, maxString);
  if (typeof value === 'number' || typeof value === 'boolean') return value;
  if (typeof value === 'bigint') return String(value);
  if (typeof value === 'function' || typeof value === 'symbol') return undefined;
  if (value instanceof Date) return value.toISOString();
  if (Buffer.isBuffer(value)) return `[Buffer ${value.length} bytes]`;
  if (depth >= maxDepth) return '[Object truncated]';
  if (typeof value !== 'object') return String(value);
  if (seen.has(value)) return '[Circular]';
  seen.add(value);

  if (value instanceof Map) {
    const output = {};
    let index = 0;
    for (const [key, item] of value.entries()) {
      if (index >= maxKeys) {
        output.__truncated = `已省略 ${value.size - index} 项`;
        break;
      }
      output[String(key)] = sanitizePersistValue(item, options, depth + 1, seen);
      index += 1;
    }
    seen.delete(value);
    return output;
  }

  if (Array.isArray(value)) {
    const limit = Math.min(value.length, maxArray);
    const output = [];
    for (let index = 0; index < limit; index += 1) {
      output.push(sanitizePersistValue(value[index], options, depth + 1, seen));
    }
    if (value.length > limit) output.push({ __truncated: `已省略 ${value.length - limit} 项` });
    seen.delete(value);
    return output;
  }

  const output = {};
  const keys = Object.keys(value);
  const limit = Math.min(keys.length, maxKeys);
  for (let index = 0; index < limit; index += 1) {
    const key = keys[index];
    const next = sanitizePersistValue(value[key], options, depth + 1, seen);
    if (next !== undefined) output[key] = next;
  }
  if (keys.length > limit) output.__truncated = `已省略 ${keys.length - limit} 个字段`;
  seen.delete(value);
  return output;
}

function sanitizeToolCallsForPersist(toolCalls, limits = {}) {
  const list = Array.isArray(toolCalls) ? toolCalls : [];
  const maxCalls = limits.maxToolCalls || SESSION_MAX_TOOL_CALLS_PER_MESSAGE;
  const selected = list.slice(0, maxCalls);
  const sanitized = selected.map((toolCall) => {
    const output = sanitizePersistValue(toolCall || {}, {
      maxString: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
      maxDepth: 5,
      maxArray: 80,
      maxKeys: 80,
    });
    if (!output || typeof output !== 'object' || Array.isArray(output)) return output;
    if (Object.prototype.hasOwnProperty.call(toolCall || {}, 'input')) {
      output.input = sanitizePersistValue(toolCall.input, {
        maxString: limits.toolInputMaxChars || SESSION_TOOL_INPUT_MAX_CHARS,
        maxDepth: 5,
        maxArray: 80,
        maxKeys: 80,
      });
    }
    if (Object.prototype.hasOwnProperty.call(toolCall || {}, 'result')) {
      output.result = sanitizePersistValue(toolCall.result, {
        maxString: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
        maxDepth: 5,
        maxArray: 80,
        maxKeys: 80,
      });
    }
    return output;
  });
  if (list.length > maxCalls) {
    sanitized.push({
      id: 'ccweb-toolcalls-truncated',
      name: 'cc-web',
      kind: 'system',
      done: true,
      result: `工具调用过多，已省略 ${list.length - maxCalls} 条。`,
    });
  }
  return sanitized;
}

function sanitizeMessageForPersist(message, limits = {}) {
  if (!message || typeof message !== 'object') return message;
  const output = {};
  for (const [key, value] of Object.entries(message)) {
    if (key === 'content') {
      output.content = typeof value === 'string'
        ? truncateTextValue(value, limits.contentMaxChars || SESSION_MESSAGE_CONTENT_MAX_CHARS)
        : sanitizePersistValue(value, {
          maxString: limits.contentMaxChars || SESSION_MESSAGE_CONTENT_MAX_CHARS,
          maxDepth: 6,
          maxArray: 120,
          maxKeys: 80,
        });
      continue;
    }
    if (key === 'toolCalls') {
      output.toolCalls = sanitizeToolCallsForPersist(value, limits);
      continue;
    }
    if (key === 'attachments') {
      output.attachments = normalizeMessageAttachments(value);
      continue;
    }
    output[key] = sanitizePersistValue(value, {
      maxString: limits.metaMaxChars || 16 * 1024,
      maxDepth: 5,
      maxArray: 80,
      maxKeys: 80,
    });
  }
  return output;
}

function sanitizeMessagesForPersist(messages, limits = {}) {
  const list = Array.isArray(messages) ? messages : [];
  const maxMessages = limits.maxMessages || SESSION_PERSIST_MAX_MESSAGES;
  const selected = list.length > maxMessages ? list.slice(-maxMessages) : list;
  const output = selected.map((message) => sanitizeMessageForPersist(message, limits));
  if (list.length > selected.length) {
    output.unshift({
      role: 'system',
      content: `历史消息过多，cc-web 已只保留最近 ${selected.length} 条用于本地展示，省略 ${list.length - selected.length} 条旧消息。`,
      timestamp: new Date().toISOString(),
      ccwebPersistenceNotice: true,
    });
  }
  return output;
}

function sanitizeMessageForTransport(message) {
  return sanitizeMessageForPersist(message, {
    contentMaxChars: SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS,
    toolInputMaxChars: SESSION_TRANSPORT_TOOL_INPUT_MAX_CHARS,
    toolResultMaxChars: SESSION_TRANSPORT_TOOL_RESULT_MAX_CHARS,
    maxToolCalls: SESSION_TRANSPORT_MAX_TOOL_CALLS_PER_MESSAGE,
    metaMaxChars: SESSION_TRANSPORT_MESSAGE_CONTENT_MAX_CHARS,
  });
}

function sanitizeMessagesForTransport(messages) {
  const list = Array.isArray(messages) ? messages : [];
  return list.map((message) => sanitizeMessageForTransport(message));
}

function sanitizeSessionForPersist(session, limits = {}) {
  const output = {};
  const skipKeys = new Set([
    'ws',
    'tailer',
    'codexAppStateTimer',
    'codexAppStateDirty',
    'codexAppStateCleaned',
    'toolOutputDeltas',
    'agentMessageItems',
  ]);
  for (const [key, value] of Object.entries(session || {})) {
    if (skipKeys.has(key)) continue;
    if (key === 'messages') {
      output.messages = sanitizeMessagesForPersist(value, limits);
      continue;
    }
    output[key] = sanitizePersistValue(value, {
      maxString: limits.topLevelMaxChars || 16 * 1024,
      maxDepth: 4,
      maxArray: 100,
      maxKeys: 100,
    });
  }
  if (!Object.prototype.hasOwnProperty.call(output, 'messages')) output.messages = [];
  return normalizeSession(output);
}

function buildSessionJsonForPersist(session) {
  const attempts = [];
  let maxMessages = SESSION_PERSIST_MAX_MESSAGES;
  let contentMaxChars = SESSION_MESSAGE_CONTENT_MAX_CHARS;
  let toolResultMaxChars = SESSION_TOOL_RESULT_MAX_CHARS;

  for (let attempt = 0; attempt < 8; attempt += 1) {
    const persisted = sanitizeSessionForPersist(session, {
      maxMessages,
      contentMaxChars,
      toolResultMaxChars,
      toolInputMaxChars: Math.min(SESSION_TOOL_INPUT_MAX_CHARS, toolResultMaxChars),
      maxToolCalls: SESSION_MAX_TOOL_CALLS_PER_MESSAGE,
    });
    const json = JSON.stringify(persisted, null, 2);
    const bytes = textByteLength(json);
    attempts.push({ maxMessages, contentMaxChars, toolResultMaxChars, bytes });
    if (bytes <= SESSION_MAX_JSON_BYTES) {
      return {
        json,
        persisted,
        guarded: attempt > 0 || (Array.isArray(session?.messages) && session.messages.length !== persisted.messages.length),
        attempts,
      };
    }
    maxMessages = Math.max(1, Math.floor(maxMessages / 2));
    contentMaxChars = Math.max(2048, Math.floor(contentMaxChars / 2));
    toolResultMaxChars = Math.max(1024, Math.floor(toolResultMaxChars / 2));
  }

  const fallback = sanitizeSessionForPersist({
    ...session,
    messages: [{
      role: 'system',
      content: '当前会话历史过大，cc-web 已跳过本地历史明细写入，仅保留会话元数据以保护服务稳定性。',
      timestamp: new Date().toISOString(),
      ccwebPersistenceNotice: true,
    }],
  }, {
    maxMessages: 1,
    contentMaxChars: 2048,
    toolResultMaxChars: 1024,
    toolInputMaxChars: 1024,
    maxToolCalls: 1,
  });
  const json = JSON.stringify(fallback, null, 2);
  attempts.push({ maxMessages: 1, contentMaxChars: 2048, toolResultMaxChars: 1024, bytes: textByteLength(json), fallback: true });
  return { json, persisted: fallback, guarded: true, attempts };
}

function writeFileAtomicSync(filePath, content) {
  const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
  try {
    fs.writeFileSync(tmpPath, content);
    fs.renameSync(tmpPath, filePath);
  } finally {
    try {
      if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
    } catch {}
  }
}

function safeReadSessionJson(filePath, maxBytes, context = {}) {
  const stat = fs.statSync(filePath);
  if (stat.size > maxBytes) {
    plog('WARN', 'session_json_load_skipped_oversized', {
      sessionId: String(context.sessionId || '').slice(0, 8),
      fileBytes: stat.size,
      maxBytes,
    });
    return null;
  }
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}

function jsonStringFieldFromPreview(text, key) {
  const pattern = new RegExp(`"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`);
  const match = pattern.exec(text);
  if (!match) return null;
  if (match[1] === 'null') return null;
  try {
    return JSON.parse(match[1]);
  } catch {
    return null;
  }
}

function jsonNestedStringFieldFromPreview(text, objectKey, key) {
  const pattern = new RegExp(`"${objectKey}"\\s*:\\s*\\{[\\s\\S]{0,1200}?"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`);
  const match = pattern.exec(text);
  if (!match) return null;
  if (match[1] === 'null') return null;
  try {
    return JSON.parse(match[1]);
  } catch {
    return null;
  }
}

function jsonBooleanFieldFromPreview(text, key) {
  const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`);
  const match = pattern.exec(text);
  return match ? match[1] === 'true' : false;
}

function readSessionPreview(filePath, stat) {
  const headSize = Math.min(stat.size, SESSION_META_PREVIEW_BYTES);
  const tailSize = Math.min(stat.size, SESSION_META_PREVIEW_BYTES);
  const fd = fs.openSync(filePath, 'r');
  try {
    const head = Buffer.alloc(headSize);
    fs.readSync(fd, head, 0, headSize, 0);
    if (stat.size <= headSize) return head.toString('utf8');
    const tail = Buffer.alloc(tailSize);
    fs.readSync(fd, tail, 0, tailSize, Math.max(0, stat.size - tailSize));
    return `${head.toString('utf8')}\n${tail.toString('utf8')}`;
  } finally {
    fs.closeSync(fd);
  }
}

function loadSessionMetaFromFile(filePath) {
  const fallbackId = path.basename(filePath, '.json');
  try {
    const stat = fs.statSync(filePath);
    if (stat.size <= SESSION_META_FULL_PARSE_MAX_BYTES) {
      const session = normalizeSession(JSON.parse(fs.readFileSync(filePath, 'utf8')));
      const cwd = session.cwd || '';
      return {
        id: session.id || fallbackId,
        title: session.title || 'Untitled',
        updated: session.updated || session.created || stat.mtime.toISOString(),
        created: session.created || null,
        pinnedAt: session.pinnedAt || null,
        titleSource: session.titleSource || null,
        createdFromKind: sessionCreatedFromKind(session) || null,
        hasUnread: !!session.hasUnread,
        agent: getSessionAgent(session),
        cwd,
        projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
        fileBytes: stat.size,
        oversized: stat.size > SESSION_LOAD_MAX_BYTES,
      };
    }

    const preview = readSessionPreview(filePath, stat);
    const cwd = jsonStringFieldFromPreview(preview, 'cwd') || '';
    return {
      id: jsonStringFieldFromPreview(preview, 'id') || fallbackId,
      title: jsonStringFieldFromPreview(preview, 'title') || 'Untitled',
      updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(),
      created: jsonStringFieldFromPreview(preview, 'created') || null,
      pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null,
      titleSource: jsonStringFieldFromPreview(preview, 'titleSource') || null,
      createdFromKind: jsonNestedStringFieldFromPreview(preview, 'createdFrom', 'kind') || null,
      hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'),
      agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')),
      cwd,
      projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
      fileBytes: stat.size,
      oversized: stat.size > SESSION_LOAD_MAX_BYTES,
    };
  } catch (err) {
    plog('WARN', 'session_meta_load_failed', {
      sessionId: fallbackId.slice(0, 8),
      error: err?.message || String(err || ''),
    });
    return null;
  }
}

function loadSession(id) {
  const normalizedId = sanitizeId(id || '');
  if (!normalizedId) return null;
  try {
    const filePath = sessionPath(normalizedId);
    if (!fs.existsSync(filePath)) return null;
    return normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
  } catch (err) {
    plog('WARN', 'session_load_failed', {
      sessionId: normalizedId.slice(0, 8),
      error: err?.message || String(err || ''),
    });
    return null;
  }
}

function saveSession(session) {
  if (!session?.id) return false;
  normalizeSession(session);
  const targetPath = sessionPath(session.id);
  try {
    const result = buildSessionJsonForPersist(session);
    writeFileAtomicSync(targetPath, result.json);
    if (result.guarded) {
      const lastAttempt = result.attempts[result.attempts.length - 1] || {};
      plog('WARN', 'session_save_guard_applied', {
        sessionId: String(session.id || '').slice(0, 8),
        fileBytes: lastAttempt.bytes || textByteLength(result.json),
        maxBytes: SESSION_MAX_JSON_BYTES,
        attempts: result.attempts,
      });
    }
    return true;
  } catch (err) {
    plog('ERROR', 'session_save_failed', {
      sessionId: String(session.id || '').slice(0, 8),
      error: err?.message || String(err || ''),
    });
    return false;
  }
}

function compareSessionsForList(a, b) {
  const aPinned = a.pinnedAt ? new Date(a.pinnedAt).getTime() : 0;
  const bPinned = b.pinnedAt ? new Date(b.pinnedAt).getTime() : 0;
  if (aPinned !== bPinned) return bPinned - aPinned;
  return new Date(b.updated || b.updatedAt || 0) - new Date(a.updated || a.updatedAt || 0);
}

function modelShortName(fullModel) {
  if (!fullModel) return null;
  const entry = Object.entries(MODEL_MAP).find(([, v]) => v === fullModel);
  return entry ? entry[0] : null;
}

function sessionModelLabel(session) {
  if (!session) return null;
  if (!session.model) {
    return isClaudeSession(session) ? null : getDefaultCodexModel();
  }
  return isClaudeSession(session) ? (modelShortName(session.model) || session.model) : session.model;
}

function splitHistoryMessages(messages, options = {}) {
  const list = sanitizeMessagesForTransport(messages);
  if (list.length <= INITIAL_HISTORY_COUNT) {
    return { recentMessages: list, olderChunks: [], historyRemaining: 0, historyBuffered: list.length };
  }
  const prefetchChunks = Math.max(0, Math.min(
    Number.isFinite(options.prefetchChunks) ? options.prefetchChunks : HISTORY_PREFETCH_CHUNKS,
    HISTORY_MAX_CHUNKS_PER_LOAD,
  ));
  const recentMessages = list.slice(-INITIAL_HISTORY_COUNT);
  const older = list.slice(0, -INITIAL_HISTORY_COUNT);
  const olderChunks = [];
  let historyRemaining = older.length;
  for (let end = older.length; end > 0 && olderChunks.length < prefetchChunks; end -= HISTORY_CHUNK_SIZE) {
    const start = Math.max(0, end - HISTORY_CHUNK_SIZE);
    olderChunks.push(older.slice(start, end));
    historyRemaining = start;
  }
  const historyBuffered = recentMessages.length + olderChunks.reduce((total, chunk) => total + chunk.length, 0);
  return { recentMessages, olderChunks, historyRemaining, historyBuffered };
}

const IS_WIN = process.platform === 'win32';

function isProcessRunning(pid) {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

function killProcess(pid, force = false) {
  try {
    if (IS_WIN) {
      const args = ['/T', '/PID', String(pid)];
      if (force) args.unshift('/F');
      spawn('taskkill', args, { windowsHide: true, stdio: 'ignore' });
    } else {
      process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
    }
  } catch {}
}

function cleanRunDir(sessionId) {
  const dir = runDir(sessionId);
  try {
    if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
  } catch {}
}

function normalizeCrossConversationReplyState(raw = {}) {
  const requestId = String(raw.requestId || '').trim();
  if (!requestId) return null;
  const status = ['waiting', 'ready', 'delivering', 'returned', 'failed'].includes(raw.status)
    ? raw.status
    : 'waiting';
  const sourceConversationId = sanitizeId(raw.sourceConversationId || raw.sourceSessionId || '');
  const targetConversationId = sanitizeId(raw.targetConversationId || raw.targetSessionId || raw.conversationId || '');
  if (!sourceConversationId || !targetConversationId) return null;
  return {
    requestId,
    messageId: String(raw.messageId || '').trim() || null,
    sourceConversationId,
    sourceTitle: String(raw.sourceTitle || '').trim() || 'Untitled',
    targetConversationId,
    targetTitle: String(raw.targetTitle || '').trim() || 'Untitled',
    status,
    createdAt: String(raw.createdAt || '').trim() || new Date().toISOString(),
    hopCount: Math.max(0, Number.parseInt(String(raw.hopCount || 0), 10) || 0),
    sourceAutoRun: raw.sourceAutoRun !== false,
    replyText: truncateTextValue(String(raw.replyText || ''), CROSS_CONVERSATION_MAX_CONTENT_CHARS),
    completedAt: raw.completedAt || null,
    returnedAt: raw.returnedAt || null,
    replyMessageId: raw.replyMessageId || null,
    lastError: raw.lastError || null,
  };
}

function serializeCrossConversationReplies() {
  const replies = [];
  for (const pending of pendingCrossConversationReplies.values()) {
    const normalized = normalizeCrossConversationReplyState(pending);
    if (!normalized) continue;
    if (normalized.status === 'returned') continue;
    replies.push(normalized);
  }
  return {
    version: 1,
    updatedAt: new Date().toISOString(),
    replies,
  };
}

function saveCrossConversationReplies() {
  try {
    fs.mkdirSync(CONFIG_DIR, { recursive: true });
    writeFileAtomicSync(CROSS_CONVERSATION_REPLIES_PATH, JSON.stringify(serializeCrossConversationReplies(), null, 2));
  } catch (err) {
    plog('WARN', 'cross_conversation_replies_save_failed', {
      error: err?.message || String(err || ''),
    });
  }
}

function loadCrossConversationReplies() {
  try {
    if (!fs.existsSync(CROSS_CONVERSATION_REPLIES_PATH)) return;
    const parsed = JSON.parse(fs.readFileSync(CROSS_CONVERSATION_REPLIES_PATH, 'utf8'));
    const list = Array.isArray(parsed?.replies) ? parsed.replies : [];
    for (const item of list) {
      const normalized = normalizeCrossConversationReplyState(item);
      if (!normalized || normalized.status === 'returned') continue;
      pendingCrossConversationReplies.set(normalized.requestId, normalized);
    }
  } catch (err) {
    plog('WARN', 'cross_conversation_replies_load_failed', {
      error: err?.message || String(err || ''),
    });
  }
}

function setPendingCrossConversationReply(requestId, pending) {
  const normalized = normalizeCrossConversationReplyState({ ...pending, requestId });
  if (!normalized) return null;
  pendingCrossConversationReplies.set(normalized.requestId, normalized);
  saveCrossConversationReplies();
  return normalized;
}

function updatePendingCrossConversationReply(requestId, updater) {
  const existing = pendingCrossConversationReplies.get(String(requestId || '').trim());
  if (!existing) return null;
  const draft = { ...existing };
  updater(draft);
  const normalized = normalizeCrossConversationReplyState(draft);
  if (!normalized) {
    pendingCrossConversationReplies.delete(existing.requestId);
    saveCrossConversationReplies();
    return null;
  }
  pendingCrossConversationReplies.set(normalized.requestId, normalized);
  saveCrossConversationReplies();
  return normalized;
}

function deletePendingCrossConversationReply(requestId) {
  const normalizedRequestId = String(requestId || '').trim();
  if (!normalizedRequestId) return false;
  const deleted = pendingCrossConversationReplies.delete(normalizedRequestId);
  if (deleted) saveCrossConversationReplies();
  return deleted;
}

function deleteCrossConversationRepliesForSession(sessionId) {
  const normalizedId = sanitizeId(sessionId || '');
  if (!normalizedId) return 0;
  let deleted = 0;
  for (const [requestId, pending] of pendingCrossConversationReplies.entries()) {
    if (pending.sourceConversationId === normalizedId || pending.targetConversationId === normalizedId) {
      pendingCrossConversationReplies.delete(requestId);
      deleted += 1;
    }
  }
  if (deleted > 0) saveCrossConversationReplies();
  return deleted;
}

function crossConversationReplySummary(pending = {}) {
  return {
    requestId: pending.requestId,
    status: pending.status,
    sourceConversationId: pending.sourceConversationId,
    sourceTitle: pending.sourceTitle || 'Untitled',
    targetConversationId: pending.targetConversationId,
    targetTitle: pending.targetTitle || 'Untitled',
    createdAt: pending.createdAt || null,
    completedAt: pending.completedAt || null,
    returnedAt: pending.returnedAt || null,
    replyMessageId: pending.replyMessageId || null,
    sourceAutoRun: pending.sourceAutoRun !== false,
    preview: truncateTextValue(pending.replyText || '', 240),
  };
}

function listCrossConversationRepliesForSource(sourceSessionId, options = {}) {
  const sourceId = sanitizeId(sourceSessionId || '');
  if (!sourceId) return [];
  const statuses = Array.isArray(options.statuses) && options.statuses.length > 0
    ? new Set(options.statuses)
    : null;
  const output = [];
  for (const pending of pendingCrossConversationReplies.values()) {
    if (pending.sourceConversationId !== sourceId) continue;
    if (statuses && !statuses.has(pending.status)) continue;
    output.push(crossConversationReplySummary(pending));
  }
  output.sort((a, b) => new Date(a.completedAt || a.createdAt || 0) - new Date(b.completedAt || b.createdAt || 0));
  return output;
}

function crossConversationWaitState(sessionId) {
  const replies = listCrossConversationRepliesForSource(sessionId, {
    statuses: ['waiting', 'ready', 'delivering', 'failed'],
  });
  const readyReplies = replies.filter((reply) => reply.status === 'ready');
  const waitingReplies = replies.filter((reply) => reply.status === 'waiting' || reply.status === 'delivering');
  const failedReplies = replies.filter((reply) => reply.status === 'failed');
  return {
    waitingOnChildren: replies.length > 0,
    pendingReplyCount: replies.length,
    readyReplyCount: readyReplies.length,
    waitingReplyCount: waitingReplies.length,
    failedReplyCount: failedReplies.length,
    pendingReplies: replies,
  };
}

function findCrossConversationReplyInTargetSession(targetSession, requestId) {
  const normalizedRequestId = String(requestId || '').trim();
  const messages = Array.isArray(targetSession?.messages) ? targetSession.messages : [];
  if (!normalizedRequestId || messages.length === 0) return '';
  const requestIndex = messages.findIndex((message) => (
    message?.crossConversation?.replyRequestId === normalizedRequestId
  ));
  if (requestIndex < 0) return '';
  for (let index = messages.length - 1; index > requestIndex; index -= 1) {
    const message = messages[index];
    if (message?.role !== 'assistant') continue;
    if (message?.ccwebDisplayOnly) continue;
    const text = extractCrossConversationReplyText(message.content);
    if (text) return text;
  }
  return '';
}

function reconcilePendingCrossConversationReplies() {
  let changed = false;
  for (const pending of pendingCrossConversationReplies.values()) {
    if (pending.status !== 'waiting') continue;
    if (isSessionRunning(pending.targetConversationId)) continue;
    const targetSession = loadSession(pending.targetConversationId);
    const replyText = findCrossConversationReplyInTargetSession(targetSession, pending.requestId);
    if (!replyText) continue;
    pending.status = 'ready';
    pending.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS);
    pending.completedAt = pending.completedAt || new Date().toISOString();
    if (targetSession?.title) pending.targetTitle = targetSession.title;
    changed = true;
  }
  if (changed) saveCrossConversationReplies();
  for (const pending of pendingCrossConversationReplies.values()) {
    if (pending.status === 'ready') deliverCrossConversationReply(pending.requestId);
  }
  return changed;
}

function codexAppStatePath(sessionId) {
  return path.join(runDir(sessionId), CODEX_APP_STATE_FILE);
}

function mapToPairs(value, options = {}) {
  const maxEntries = options.maxEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES;
  const maxValueChars = options.maxValueChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS;
  const output = [];
  if (value instanceof Map) {
    for (const [key, item] of value.entries()) {
      if (output.length >= maxEntries) break;
      output.push([String(key), truncateTextValue(item, maxValueChars)]);
    }
    return output;
  }
  if (value && typeof value === 'object') {
    for (const [key, item] of Object.entries(value)) {
      if (output.length >= maxEntries) break;
      output.push([String(key), truncateTextValue(item, maxValueChars)]);
    }
    return output;
  }
  return output;
}

function codexAppTurnKey(sessionId, state = {}) {
  const threadId = state.threadId || '';
  const turnId = state.turnId || '';
  const startedAt = state.startedAt || '';
  return `${sanitizeId(sessionId)}:${threadId}:${turnId}:${startedAt}`;
}

function serializeCodexAppEntry(sessionId, entry, limits = {}) {
  return {
    version: 1,
    agent: 'codexapp',
    sessionId: sanitizeId(sessionId),
    cwd: entry.cwd || null,
    threadId: entry.threadId || null,
    turnId: entry.turnId || null,
    turnStatus: entry.turnStatus || 'running',
    startedAt: entry.startedAt || new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    clientUserMessageId: entry.clientUserMessageId || null,
    fullText: truncateTextValue(entry.fullText || '', limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS),
    toolCalls: sanitizeToolCallsForPersist(Array.isArray(entry.toolCalls) ? entry.toolCalls : [], {
      maxToolCalls: limits.maxToolCalls || CODEX_APP_STATE_MAX_MAP_ENTRIES,
      toolInputMaxChars: limits.toolInputMaxChars || SESSION_TOOL_INPUT_MAX_CHARS,
      toolResultMaxChars: limits.toolResultMaxChars || SESSION_TOOL_RESULT_MAX_CHARS,
      contentMaxChars: limits.fullTextMaxChars || CODEX_APP_STATE_FULL_TEXT_MAX_CHARS,
    }),
    toolOutputDeltas: mapToPairs(entry.toolOutputDeltas, {
      maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES,
      maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS,
    }),
    agentMessageItems: mapToPairs(entry.agentMessageItems, {
      maxEntries: limits.maxMapEntries || CODEX_APP_STATE_MAX_MAP_ENTRIES,
      maxValueChars: limits.mapValueMaxChars || CODEX_APP_STATE_MAP_VALUE_MAX_CHARS,
    }),
    lastUsage: entry.lastUsage || null,
    lastError: entry.lastError || null,
    userAborted: !!entry.userAborted,
  };
}

function buildCodexAppStateJson(sessionId, entry) {
  const attempts = [];
  let fullTextMaxChars = CODEX_APP_STATE_FULL_TEXT_MAX_CHARS;
  let toolResultMaxChars = SESSION_TOOL_RESULT_MAX_CHARS;
  let mapValueMaxChars = CODEX_APP_STATE_MAP_VALUE_MAX_CHARS;
  let maxToolCalls = CODEX_APP_STATE_MAX_MAP_ENTRIES;

  for (let attempt = 0; attempt < 6; attempt += 1) {
    const state = serializeCodexAppEntry(sessionId, entry, {
      fullTextMaxChars,
      toolResultMaxChars,
      mapValueMaxChars,
      maxToolCalls,
    });
    const json = JSON.stringify(state);
    const bytes = textByteLength(json);
    attempts.push({ fullTextMaxChars, toolResultMaxChars, mapValueMaxChars, maxToolCalls, bytes });
    if (bytes <= CODEX_APP_STATE_MAX_BYTES) return { json, attempts };
    fullTextMaxChars = Math.max(4096, Math.floor(fullTextMaxChars / 2));
    toolResultMaxChars = Math.max(1024, Math.floor(toolResultMaxChars / 2));
    mapValueMaxChars = Math.max(1024, Math.floor(mapValueMaxChars / 2));
    maxToolCalls = Math.max(5, Math.floor(maxToolCalls / 2));
  }

  const fallback = serializeCodexAppEntry(sessionId, {
    ...entry,
    fullText: truncateTextValue(entry.fullText || '', 2048),
    toolCalls: [],
    toolOutputDeltas: new Map(),
    agentMessageItems: new Map(),
  }, {
    fullTextMaxChars: 2048,
    toolResultMaxChars: 1024,
    mapValueMaxChars: 1024,
    maxToolCalls: 0,
  });
  fallback.stateGuarded = true;
  const json = JSON.stringify(fallback);
  attempts.push({ fallback: true, bytes: textByteLength(json) });
  return { json, attempts };
}

function writeCodexAppTurnState(sessionId, entry) {
  if (!entry || entry.codexAppStateCleaned) return;
  try {
    const dir = runDir(sessionId);
    fs.mkdirSync(dir, { recursive: true });
    const statePath = codexAppStatePath(sessionId);
    const result = buildCodexAppStateJson(sessionId, entry);
    writeFileAtomicSync(statePath, result.json);
    if (result.attempts.length > 1) {
      plog('WARN', 'codex_app_state_guard_applied', {
        sessionId: String(sessionId || '').slice(0, 8),
        attempts: result.attempts,
      });
    }
  } catch (err) {
    plog('WARN', 'codex_app_state_persist_failed', {
      sessionId: String(sessionId || '').slice(0, 8),
      error: err?.message || String(err || ''),
    });
  }
}

function persistCodexAppTurnState(sessionId, entry, options = {}) {
  if (!sessionId || !entry || entry.codexAppStateCleaned) return;
  const immediate = !!options.immediate;
  if (immediate && entry.codexAppStateTimer) {
    clearTimeout(entry.codexAppStateTimer);
    entry.codexAppStateTimer = null;
  }
  if (immediate) {
    entry.codexAppStateDirty = false;
    writeCodexAppTurnState(sessionId, entry);
    return;
  }
  entry.codexAppStateDirty = true;
  if (entry.codexAppStateTimer) return;
  entry.codexAppStateTimer = setTimeout(() => {
    entry.codexAppStateTimer = null;
    if (!entry.codexAppStateDirty) return;
    entry.codexAppStateDirty = false;
    writeCodexAppTurnState(sessionId, entry);
  }, CODEX_APP_STATE_FLUSH_DELAY_MS);
  if (typeof entry.codexAppStateTimer.unref === 'function') entry.codexAppStateTimer.unref();
}

function cleanupCodexAppTurnState(sessionId, entry) {
  if (entry?.codexAppStateTimer) {
    clearTimeout(entry.codexAppStateTimer);
    entry.codexAppStateTimer = null;
  }
  if (entry) entry.codexAppStateCleaned = true;
  cleanRunDir(sessionId);
}

function loadCodexAppTurnState(sessionId) {
  try {
    const statePath = codexAppStatePath(sessionId);
    if (!fs.existsSync(statePath)) return null;
    const stat = fs.statSync(statePath);
    if (stat.size > CODEX_APP_STATE_LOAD_MAX_BYTES) {
      plog('WARN', 'codex_app_state_load_skipped_oversized', {
        sessionId: String(sessionId || '').slice(0, 8),
        fileBytes: stat.size,
        maxBytes: CODEX_APP_STATE_LOAD_MAX_BYTES,
      });
      return { __invalid: true, reason: 'too_large', fileBytes: stat.size, agent: 'codexapp' };
    }
    const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
    if (!state || state.agent !== 'codexapp') return null;
    return state;
  } catch (err) {
    plog('WARN', 'codex_app_state_load_failed', {
      sessionId: String(sessionId || '').slice(0, 8),
      error: err?.message || String(err || ''),
    });
    return null;
  }
}

function appendCodexAppRecoveryNotice(sessionId, content) {
  const session = loadSession(sessionId);
  if (!session || !isCodexAppSession(session)) return false;
  session.messages.push({
    role: 'system',
    content,
    timestamp: new Date().toISOString(),
    codexAppRecoveredPartial: true,
  });
  session.hasUnread = true;
  session.updated = new Date().toISOString();
  return saveSession(session);
}

function hasCodexAppTurnMessage(session, turnKey) {
  return Array.isArray(session?.messages)
    && session.messages.some((message) => message?.codexAppTurnKey === turnKey);
}

function recoverCodexAppTurnState(sessionId) {
  const state = loadCodexAppTurnState(sessionId);
  if (state?.__invalid) {
    appendCodexAppRecoveryNotice(
      sessionId,
      `Codex App 上次运行状态文件异常（${state.reason || 'invalid'}），已跳过恢复并清理运行目录，避免 cc-web 启动时占用过多内存。`,
    );
    cleanRunDir(sessionId);
    return true;
  }
  if (!state) {
    cleanRunDir(sessionId);
    return false;
  }

  const session = loadSession(sessionId);
  if (!session || !isCodexAppSession(session)) {
    cleanRunDir(sessionId);
    return true;
  }

  const fullText = truncateTextValue(state.fullText || '', CODEX_APP_STATE_FULL_TEXT_MAX_CHARS);
  const toolCalls = sanitizeToolCallsForPersist(Array.isArray(state.toolCalls) ? state.toolCalls : [], {
    maxToolCalls: CODEX_APP_STATE_MAX_MAP_ENTRIES,
    toolInputMaxChars: SESSION_TOOL_INPUT_MAX_CHARS,
    toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS,
    contentMaxChars: CODEX_APP_STATE_FULL_TEXT_MAX_CHARS,
  });
  const hasRecoverableContent = fullText.trim() || toolCalls.length > 0;
  const turnKey = codexAppTurnKey(sessionId, state);
  let changed = false;

  if (state.threadId) {
    setRuntimeSessionId(session, state.threadId);
    changed = true;
  }
  if (state.lastUsage) {
    session.totalUsage = state.lastUsage;
    changed = true;
  }

  if (hasRecoverableContent && !hasCodexAppTurnMessage(session, turnKey)) {
    const interrupted = state.turnStatus !== 'completed';
    session.messages.push({
      role: 'assistant',
      content: fullText,
      toolCalls,
      timestamp: state.updatedAt || new Date().toISOString(),
      codexAppTurnKey: turnKey,
      codexAppThreadId: state.threadId || null,
      codexAppTurnId: state.turnId || null,
      codexAppRecoveredPartial: interrupted,
      interrupted,
    });
    if (interrupted) {
      session.messages.push({
        role: 'system',
        content: 'Codex App 服务重启前的未完成输出已恢复，原运行任务已中断。',
        timestamp: new Date().toISOString(),
        codexAppTurnKey: `${turnKey}:notice`,
      });
    }
    session.hasUnread = true;
    changed = true;
    plog('INFO', 'codex_app_state_recovered', {
      sessionId: sessionId.slice(0, 8),
      threadId: state.threadId || null,
      turnId: state.turnId || null,
      responseLen: fullText.length,
      toolCallCount: toolCalls.length,
      interrupted,
    });
  } else {
    plog('INFO', 'codex_app_state_recovery_skipped', {
      sessionId: sessionId.slice(0, 8),
      threadId: state.threadId || null,
      turnId: state.turnId || null,
      duplicate: hasCodexAppTurnMessage(session, turnKey),
      hasRecoverableContent: !!hasRecoverableContent,
    });
  }

  if (changed) {
    session.updated = new Date().toISOString();
    saveSession(session);
  }
  cleanRunDir(sessionId);
  return true;
}

function sendSessionList(ws) {
  try {
    const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
    const sessions = [];
    for (const f of files) {
      const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, f));
      if (!meta) continue;
      const waitState = crossConversationWaitState(meta.id);
      sessions.push({
        id: meta.id,
        title: meta.title || 'Untitled',
        updated: meta.updated,
        pinnedAt: meta.pinnedAt || null,
        titleSource: meta.titleSource || null,
        createdFromKind: meta.createdFromKind || null,
        hasUnread: !!meta.hasUnread,
        agent: normalizeAgent(meta.agent),
        cwd: meta.cwd || '',
        projectName: meta.projectName || '',
        isRunning: isSessionRunning(meta.id),
        waitingOnChildren: waitState.waitingOnChildren,
        pendingReplyCount: waitState.pendingReplyCount,
        readyReplyCount: waitState.readyReplyCount,
        waitingReplyCount: waitState.waitingReplyCount,
        failedReplyCount: waitState.failedReplyCount,
        oversized: !!meta.oversized,
        fileBytes: meta.fileBytes || 0,
      });
    }
    sessions.sort(compareSessionsForList);
    wsSend(ws, { type: 'session_list', sessions });
  } catch {
    wsSend(ws, { type: 'session_list', sessions: [] });
  }
}

function broadcastSessionList() {
  if (!wss) return;
  for (const client of wss.clients) {
    if (client.readyState === 1) sendSessionList(client);
  }
}

function findViewingSessionWs(sessionId) {
  const normalizedId = sanitizeId(sessionId || '');
  if (!normalizedId) return null;
  for (const [client, viewedSessionId] of wsSessionMap.entries()) {
    if (viewedSessionId === normalizedId && client?.readyState === 1) return client;
  }
  return null;
}

function sendSessionEventToViewers(sessionId, payload) {
  const normalizedId = sanitizeId(sessionId || '');
  if (!normalizedId) return 0;
  let sent = 0;
  for (const [client, viewedSessionId] of wsSessionMap.entries()) {
    if (viewedSessionId !== normalizedId || client?.readyState !== 1) continue;
    wsSend(client, payload);
    sent += 1;
  }
  return sent;
}

function getInternalMcpRequestToken(req) {
  return String(req.headers['x-cc-web-mcp-token'] || '').trim() || extractBearerToken(req);
}

function mcpToolError(code, message, extra = {}) {
  return { ok: false, code, message, ...extra };
}

function clampMcpLimit(value) {
  const parsed = Number.parseInt(String(value || ''), 10);
  if (!Number.isFinite(parsed)) return 50;
  return Math.max(1, Math.min(100, parsed));
}

function listConversationSummaries(args = {}, sourceSessionId = '') {
  reconcilePendingCrossConversationReplies();
  const agentFilter = VALID_AGENTS.has(args.agent) ? args.agent : '';
  const statusFilter = ['running', 'idle'].includes(args.status) ? args.status : 'all';
  const limit = clampMcpLimit(args.limit);
  const conversations = [];

  try {
    const files = fs.readdirSync(SESSIONS_DIR).filter((name) => name.endsWith('.json'));
    for (const file of files) {
      const meta = loadSessionMetaFromFile(path.join(SESSIONS_DIR, file));
      if (!meta) continue;
      const agent = normalizeAgent(meta.agent);
      const running = isSessionRunning(meta.id);
      const status = running ? 'running' : 'idle';
      if (agentFilter && agent !== agentFilter) continue;
      if (statusFilter !== 'all' && status !== statusFilter) continue;
      const waitState = crossConversationWaitState(meta.id);
      conversations.push({
        id: meta.id,
        title: meta.title || 'Untitled',
        agent,
        status,
        updatedAt: meta.updated || meta.created || null,
        pinnedAt: meta.pinnedAt || null,
        cwd: meta.cwd || '',
        projectName: meta.projectName || '',
        isCurrent: meta.id === sourceSessionId,
        oversized: !!meta.oversized,
        waitingOnChildren: waitState.waitingOnChildren,
        pendingReplyCount: waitState.pendingReplyCount,
        readyReplyCount: waitState.readyReplyCount,
        waitingReplyCount: waitState.waitingReplyCount,
      });
    }
  } catch {}

  conversations.sort(compareSessionsForList);
  const sourceWaitState = crossConversationWaitState(sourceSessionId);
  return {
    ok: true,
    currentConversationId: sourceSessionId || null,
    waitingOnChildren: sourceWaitState.waitingOnChildren,
    pendingReplyCount: sourceWaitState.pendingReplyCount,
    readyReplyCount: sourceWaitState.readyReplyCount,
    waitingReplyCount: sourceWaitState.waitingReplyCount,
    pendingReplies: sourceWaitState.pendingReplies,
    conversations: conversations.slice(0, limit),
  };
}

function normalizeMcpPromptText(value, maxChars) {
  if (value === null || value === undefined) return '';
  return truncateTextValue(String(value).trim(), maxChars, '...');
}

function uniqueMcpPromptId(value, fallback, used) {
  const base = normalizeMcpPromptText(value, 80)
    .replace(/\s+/g, '_')
    .replace(/[^\w.-]/g, '')
    || fallback;
  let id = base;
  let index = 2;
  while (used.has(id)) {
    id = `${base}_${index}`;
    index += 1;
  }
  used.add(id);
  return id;
}

function normalizeMcpPromptOption(rawOption, index, usedIds) {
  const raw = rawOption && typeof rawOption === 'object' ? rawOption : {};
  const label = normalizeMcpPromptText(raw.label ?? raw.title ?? raw.value ?? raw.answerText, 240);
  const answerText = normalizeMcpPromptText(raw.answerText ?? raw.answer ?? label, MCP_PROMPT_ANSWER_MAX_CHARS);
  if (!label && !answerText) return null;
  const id = uniqueMcpPromptId(raw.id ?? raw.value ?? label, `option_${index + 1}`, usedIds);
  return {
    id,
    label: label || answerText.slice(0, 80) || `选项 ${index + 1}`,
    description: normalizeMcpPromptText(raw.description ?? raw.desc, MCP_PROMPT_OPTION_MAX_CHARS),
    answerText,
    recommended: raw.recommended === true,
  };
}

function normalizeMcpPromptQuestion(rawQuestion, index, usedIds) {
  const raw = rawQuestion && typeof rawQuestion === 'object' ? rawQuestion : {};
  const title = normalizeMcpPromptText(raw.title ?? raw.header, MCP_PROMPT_TITLE_MAX_CHARS);
  const question = normalizeMcpPromptText(raw.question ?? raw.prompt ?? raw.text, MCP_PROMPT_QUESTION_MAX_CHARS);
  if (!title && !question) return null;
  const id = uniqueMcpPromptId(raw.id, `question_${index + 1}`, usedIds);
  const rawOptions = Array.isArray(raw.options) ? raw.options : [];
  const optionIds = new Set();
  const options = rawOptions
    .slice(0, MCP_PROMPT_OPTION_MAX_COUNT)
    .map((option, optionIndex) => normalizeMcpPromptOption(option, optionIndex, optionIds))
    .filter(Boolean);
  const mode = String(raw.selectionMode || raw.mode || '').trim();
  const selectionMode = mode === 'multi' || mode === 'multiple'
    ? 'multi'
    : mode === 'none' || options.length === 0
      ? 'none'
      : 'single';
  return {
    id,
    title: title || `问题 ${index + 1}`,
    question,
    required: raw.required !== false,
    selectionMode,
    answerPlaceholder: normalizeMcpPromptText(raw.answerPlaceholder ?? raw.placeholder, 240),
    defaultAnswer: normalizeMcpPromptText(raw.defaultAnswer ?? raw.answerText, MCP_PROMPT_ANSWER_MAX_CHARS),
    options,
  };
}

function normalizeCcwebPromptUserArgs(args = {}) {
  const rawQuestions = Array.isArray(args.questions) ? args.questions : [];
  const usedQuestionIds = new Set();
  const questions = rawQuestions
    .slice(0, MCP_PROMPT_QUESTION_MAX_COUNT)
    .map((question, index) => normalizeMcpPromptQuestion(question, index, usedQuestionIds))
    .filter(Boolean);
  if (questions.length === 0) {
    return mcpToolError('missing_questions', 'ccweb_prompt_user 需要至少一个有效问题。');
  }
  return {
    ok: true,
    title: normalizeMcpPromptText(args.title, MCP_PROMPT_TITLE_MAX_CHARS) || '需要用户确认',
    description: normalizeMcpPromptText(args.description ?? args.instructions, MCP_PROMPT_DESCRIPTION_MAX_CHARS),
    questions,
  };
}

function createCcwebPromptUser(args = {}, sourceSessionId = '') {
  const sourceId = sanitizeId(sourceSessionId || '');
  if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
  const session = loadSession(sourceId);
  if (!session) return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });

  const normalized = normalizeCcwebPromptUserArgs(args);
  if (!normalized.ok) return normalized;

  const now = new Date().toISOString();
  const promptId = crypto.randomUUID();
  const prompt = {
    id: promptId,
    status: 'pending',
    title: normalized.title,
    description: normalized.description,
    questions: normalized.questions,
    answers: {},
    createdAt: now,
    submittedAt: null,
  };
  const message = {
    role: 'assistant',
    content: '',
    timestamp: now,
    ccwebPrompt: prompt,
  };

  session.messages = Array.isArray(session.messages) ? session.messages : [];
  session.messages.push(message);
  session.updated = now;
  if (!findViewingSessionWs(sourceId)) session.hasUnread = true;
  saveSession(session);

  sendSessionEventToViewers(sourceId, {
    type: 'session_message',
    sessionId: sourceId,
    message,
  });
  broadcastSessionList();

  return {
    ok: true,
    promptId,
    status: 'rendered',
    sourceConversationId: sourceId,
    questionCount: normalized.questions.length,
    message: '已在 ccweb 前台展示问题，等待用户提交。',
  };
}

function findCcwebPromptMessage(session, promptId) {
  const normalizedPromptId = String(promptId || '').trim();
  if (!normalizedPromptId || !Array.isArray(session?.messages)) return null;
  for (let index = session.messages.length - 1; index >= 0; index -= 1) {
    const message = session.messages[index];
    if (message?.ccwebPrompt?.id === normalizedPromptId) {
      return { message, index, prompt: message.ccwebPrompt };
    }
  }
  return null;
}

function removeCcwebPromptMessage(session, promptId) {
  const found = findCcwebPromptMessage(session, promptId);
  if (!found || !Array.isArray(session?.messages)) return null;
  session.messages.splice(found.index, 1);
  return found;
}

function selectedPromptOptionIds(rawAnswer, question) {
  const raw = rawAnswer && typeof rawAnswer === 'object' ? rawAnswer : {};
  const source = Array.isArray(raw.selectedOptionIds)
    ? raw.selectedOptionIds
    : Array.isArray(raw.selectedOptions)
      ? raw.selectedOptions
      : Array.isArray(raw.optionIds)
        ? raw.optionIds
        : raw.selectedOptionId || raw.selectedOption || raw.optionId
          ? [raw.selectedOptionId || raw.selectedOption || raw.optionId]
          : [];
  const valid = new Set((question.options || []).map((option) => option.id));
  const ids = source.map((item) => String(item || '').trim()).filter((item) => valid.has(item));
  if (question.selectionMode !== 'multi') return ids.slice(0, 1);
  return [...new Set(ids)];
}

function normalizeCcwebPromptUserAnswers(prompt, rawAnswers = {}) {
  const raw = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {};
  const answers = {};
  const answerList = [];
  for (const question of prompt.questions || []) {
    const rawAnswer = raw[question.id] && typeof raw[question.id] === 'object' ? raw[question.id] : {};
    const selectedOptionIds = question.selectionMode === 'none' ? [] : selectedPromptOptionIds(rawAnswer, question);
    const selectedOptions = selectedOptionIds
      .map((id) => (question.options || []).find((option) => option.id === id))
      .filter(Boolean);
    let answerText = normalizeMcpPromptText(
      rawAnswer.answerText ?? rawAnswer.answer ?? rawAnswer.text ?? '',
      MCP_PROMPT_ANSWER_MAX_CHARS
    );
    if (!answerText && selectedOptions.length > 0) {
      answerText = selectedOptions.map((option) => option.answerText || option.label).filter(Boolean).join('\n');
    }
    if (!answerText && question.defaultAnswer) answerText = question.defaultAnswer;
    if (question.required && !answerText) {
      return mcpToolError('missing_answer', `问题「${question.title || question.id}」需要填写答案。`, {
        promptId: prompt.id,
        questionId: question.id,
      });
    }
    const answer = {
      questionId: question.id,
      selectedOptionIds,
      selectedOptionLabels: selectedOptions.map((option) => option.label),
      answerText,
    };
    answers[question.id] = answer;
    answerList.push({ question, answer });
  }
  return { ok: true, answers, answerList };
}

function buildCcwebPromptUserResponseText(prompt, answerList) {
  const lines = ['表单答案：'];
  if (prompt.title) {
    lines.push('', `表单：${prompt.title}`);
  }
  answerList.forEach(({ question, answer }, index) => {
    lines.push('', `${index + 1}. ${question.title || question.id}`);
    if (question.question) lines.push(`问题：${question.question}`);
    if (answer.selectedOptionLabels.length > 0) {
      lines.push(`选择：${answer.selectedOptionLabels.join('，')}`);
    }
    lines.push(`答案：${answer.answerText || '（空）'}`);
  });
  return truncateTextValue(lines.join('\n'), MCP_PROMPT_RESPONSE_MAX_CHARS);
}

function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = 0) {
  const sourceId = sanitizeId(sourceSessionId || '');
  const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);
  if (normalizedHopCount >= MCP_CREATE_CONVERSATION_MAX_HOP_COUNT) {
    return mcpToolError('hop_limit_exceeded', '跨对话创建层级过深，已拒绝继续创建新对话。', {
      hopCount: normalizedHopCount,
      maxHopCount: MCP_CREATE_CONVERSATION_MAX_HOP_COUNT,
    });
  }

  const sourceSession = sourceId ? loadSession(sourceId) : null;
  if (sourceId && !sourceSession) {
    return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
  }

  const initialMessage = typeof args.initialMessage === 'string'
    ? truncateTextValue(args.initialMessage.trim(), CROSS_CONVERSATION_MAX_CONTENT_CHARS)
    : '';
  const requestReply = args.requestReply === true || args.waitForReply === true;
  if (requestReply && !initialMessage) {
    return mcpToolError('reply_requires_initial_message', 'requestReply=true 时必须提供 initialMessage。');
  }
  if (initialMessage && !sourceId) {
    return mcpToolError('missing_source_conversation', '发送 initialMessage 需要来源对话 ID。');
  }

  const now = new Date().toISOString();
  const createArgs = { ...args };
  delete createArgs.agent;
  const created = createPersistentConversationSession(createArgs, {
    sourceSession,
    strict: true,
    requireAbsoluteCwd: true,
    inheritSourceMode: false,
    defaultMode: 'yolo',
    createdFrom: sourceSession ? {
      kind: 'mcp',
      sourceSessionId: sourceSession.id,
      sourceTitle: sourceSession.title || 'Untitled',
      hopCount: normalizedHopCount + 1,
      createdAt: now,
    } : null,
  });
  if (!created.ok) return created;

  const { session } = created;
  let delivery = null;
  if (initialMessage) {
    delivery = sendCrossConversationMessage({
      targetConversationId: session.id,
      content: initialMessage,
    }, sourceId, normalizedHopCount, { expectReply: requestReply });
    if (!delivery?.ok) {
      return mcpToolError(delivery?.code || 'initial_message_failed', delivery?.message || '创建对话后发送首条消息失败。', {
        conversationId: session.id,
        title: session.title,
        agent: getSessionAgent(session),
        cwd: session.cwd || '',
        mode: session.permissionMode || 'yolo',
        status: isSessionRunning(session.id) ? 'running' : 'idle',
      });
    }
  }

  broadcastSessionList();
  return {
    ok: true,
    conversationId: session.id,
    title: session.title || 'Untitled',
    agent: getSessionAgent(session),
    cwd: session.cwd || '',
    mode: session.permissionMode || 'yolo',
    status: isSessionRunning(session.id) ? 'running' : 'idle',
    sourceConversationId: sourceId || null,
    ...(delivery ? {
      messageId: delivery.messageId || null,
      deliveryStatus: delivery.deliveryStatus || 'delivered',
      ...(delivery.requestId ? {
        requestId: delivery.requestId,
        replyStatus: delivery.status || 'waiting',
        replyDelivery: delivery.replyDelivery || 'display_only',
        sourceAutoRun: delivery.sourceAutoRun === true,
      } : {}),
    } : {}),
  };
}

function buildCrossConversationRuntimeText(sourceSession, content) {
  const sourceTitle = sourceSession?.title || 'Untitled';
  const sourceId = sourceSession?.id || '';
  return `来自「${sourceTitle}」对话（ID: ${sourceId}）的消息：\n\n${truncateTextValue(content, CROSS_CONVERSATION_MAX_CONTENT_CHARS)}`;
}

function buildCrossConversationReplyContent(targetSession, replyText) {
  const targetTitle = targetSession?.title || 'Untitled';
  return `线程「${targetTitle}」已返回消息：\n\n${truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS)}`;
}

function buildCrossConversationReplyAutoRunText(targetSession, replyText) {
  const targetTitle = targetSession?.title || 'Untitled';
  const body = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS);
  return `子对话「${targetTitle}」已返回。请基于以下返回继续当前任务：\n\n${body}`;
}

function startCrossConversationReplyAutoRun(sourceSession, targetSession, replyText, crossConversation) {
  if (!sourceSession?.id) return false;
  const runtimeText = buildCrossConversationReplyAutoRunText(targetSession, replyText);
  const sourceWs = findViewingSessionWs(sourceSession.id);
  const result = handleMessage(sourceWs, {
    text: runtimeText,
    sessionId: sourceSession.id,
    mode: sourceSession.permissionMode || 'yolo',
    agent: getSessionAgent(sourceSession),
  }, {
    hideInHistory: true,
    runtimeText,
    crossConversation,
    mcpContext: { hopCount: crossConversation?.hopCount || 0 },
    skipPendingCrossConversationFlush: true,
  });
  if (!result?.ok) {
    plog('WARN', 'cross_conversation_reply_autorun_failed', {
      sourceSessionId: sourceSession.id.slice(0, 8),
      targetSessionId: targetSession?.id ? targetSession.id.slice(0, 8) : null,
      requestId: crossConversation?.replyToRequestId || null,
      code: result?.code || null,
      message: result?.message || null,
    });
    return false;
  }
  return true;
}

function hasProcessedCrossConversationReply(session, requestId) {
  const normalizedRequestId = String(requestId || '').trim();
  if (!normalizedRequestId || !Array.isArray(session?.messages)) return false;
  return session.messages.some((message) => (
    message?.crossConversation?.replyToRequestId === normalizedRequestId &&
    message?.crossConversation?.processed === true
  ));
}

function extractCrossConversationReplyText(content) {
  if (!content) return '';
  if (typeof content === 'string') return content.trim();
  if (Array.isArray(content)) {
    return content
      .map((item) => {
        if (!item) return '';
        if (typeof item === 'string') return item;
        if (typeof item.text === 'string') return item.text;
        if (typeof item.content === 'string') return item.content;
        return '';
      })
      .join('')
      .trim();
  }
  if (typeof content.text === 'string') return content.text.trim();
  if (typeof content.content === 'string') return content.content.trim();
  return '';
}

function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHopCount = 0, options = {}) {
  const sourceId = sanitizeId(sourceSessionId || '');
  const targetId = sanitizeId(args.targetConversationId || args.targetSessionId || args.conversationId || '');
  const content = typeof args.content === 'string'
    ? truncateTextValue(args.content.trim(), CROSS_CONVERSATION_MAX_CONTENT_CHARS)
    : '';
  const expectReply = !!options.expectReply;
  const sourceAutoRun = expectReply && options.sourceAutoRun !== false;

  if (!sourceId) {
    return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
  }
  if (!targetId) {
    return mcpToolError('missing_target_conversation', '缺少目标对话 ID。');
  }
  if (!content) {
    return mcpToolError('empty_content', '消息内容不能为空。');
  }

  const sourceSession = loadSession(sourceId);
  if (!sourceSession) {
    return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
  }

  const targetSession = loadSession(targetId);
  if (!targetSession) {
    return mcpToolError('target_not_found', '目标对话不存在。', { targetConversationId: targetId });
  }

  if (sourceId === targetId) {
    return mcpToolError('same_conversation', '不能把消息发送给当前同一个对话。', { targetConversationId: targetId });
  }

  const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);

  if (isSessionRunning(targetId)) {
    return mcpToolError('target_running', '目标对话正在处理中，请稍后再发送。', { targetConversationId: targetId });
  }

  const now = new Date().toISOString();
  const messageId = crypto.randomUUID();
  const requestId = expectReply ? crypto.randomUUID() : null;
  const crossConversation = {
    messageId,
    sourceSessionId: sourceSession.id,
    sourceTitle: sourceSession.title || 'Untitled',
    sentAt: now,
    hopCount: normalizedHopCount + 1,
  };
  if (requestId) {
    crossConversation.expectsReply = true;
    crossConversation.replyRequestId = requestId;
    setPendingCrossConversationReply(requestId, {
      requestId,
      messageId,
      sourceConversationId: sourceId,
      sourceTitle: sourceSession.title || 'Untitled',
      targetConversationId: targetId,
      targetTitle: targetSession.title || 'Untitled',
      status: 'waiting',
      createdAt: now,
      hopCount: crossConversation.hopCount,
      sourceAutoRun,
      replyText: '',
      completedAt: null,
      returnedAt: null,
    });
  }

  const targetWs = findViewingSessionWs(targetId);
  const result = handleMessage(targetWs, {
    text: content,
    sessionId: targetSession.id,
    mode: targetSession.permissionMode || 'yolo',
    agent: getSessionAgent(targetSession),
  }, {
    crossConversation,
    emitUserMessage: true,
    runtimeText: buildCrossConversationRuntimeText(sourceSession, content),
    mcpContext: { hopCount: crossConversation.hopCount },
  });

  if (!result?.ok) {
    if (requestId) deletePendingCrossConversationReply(requestId);
    return mcpToolError(result?.code || 'send_failed', result?.message || '跨对话消息发送失败。', {
      sourceConversationId: sourceId,
      targetConversationId: targetId,
    });
  }

  broadcastSessionList();
  return {
    ok: true,
    messageId,
    deliveryStatus: 'delivered',
    sourceConversationId: sourceId,
    targetConversationId: targetId,
    targetTitle: targetSession.title || 'Untitled',
    ...(requestId ? {
      requestId,
      status: 'waiting',
      replyDelivery: sourceAutoRun ? 'auto_run' : 'display_only',
      sourceAutoRun,
    } : {}),
  };
}

function requestCrossConversationReply(args = {}, sourceSessionId = '', sourceHopCount = 0) {
  return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount, { expectReply: true });
}

function listPendingCrossConversationReplies(args = {}, sourceSessionId = '') {
  reconcilePendingCrossConversationReplies();
  const sourceId = sanitizeId(sourceSessionId || '');
  if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
  const status = String(args.status || 'all').trim();
  const statuses = status && status !== 'all' ? [status] : ['waiting', 'ready', 'delivering', 'failed'];
  const validStatuses = statuses.filter((item) => ['waiting', 'ready', 'delivering', 'returned', 'failed'].includes(item));
  const replies = listCrossConversationRepliesForSource(sourceId, {
    statuses: validStatuses.length > 0 ? validStatuses : ['waiting', 'ready', 'delivering', 'failed'],
  });
  return {
    ok: true,
    sourceConversationId: sourceId,
    waitingOnChildren: replies.length > 0,
    pendingReplyCount: replies.length,
    readyReplyCount: replies.filter((reply) => reply.status === 'ready').length,
    replies,
  };
}

function getPendingCrossConversationReply(args = {}, sourceSessionId = '') {
  reconcilePendingCrossConversationReplies();
  const sourceId = sanitizeId(sourceSessionId || '');
  const requestId = String(args.requestId || '').trim();
  if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
  if (!requestId) return mcpToolError('missing_request_id', '缺少 requestId。');
  const pending = pendingCrossConversationReplies.get(requestId);
  if (!pending || pending.sourceConversationId !== sourceId) {
    const sourceSession = loadSession(sourceId);
    if (hasProcessedCrossConversationReply(sourceSession, requestId)) {
      const message = sourceSession.messages.find((item) => item?.crossConversation?.replyToRequestId === requestId);
      return {
        ok: true,
        requestId,
        status: 'returned',
        returned: true,
        replyText: extractCrossConversationReplyText(message?.content || ''),
        message: message || null,
      };
    }
    return mcpToolError('reply_not_found', '未找到该跨对话等待回复。', { requestId });
  }
  return {
    ok: true,
    ...crossConversationReplySummary(pending),
    returned: pending.status === 'returned',
    replyText: pending.replyText || '',
  };
}

function deliverCrossConversationReply(requestId) {
  const pending = pendingCrossConversationReplies.get(requestId);
  if (!pending || pending.status !== 'ready') return false;

  const sourceSession = loadSession(pending.sourceConversationId);
  const targetSession = loadSession(pending.targetConversationId);
  if (!sourceSession || !targetSession) {
    updatePendingCrossConversationReply(requestId, (draft) => {
      draft.status = 'failed';
      draft.lastError = sourceSession ? 'target_not_found' : 'source_not_found';
    });
    broadcastSessionList();
    return false;
  }
  if (isSessionRunning(sourceSession.id)) {
    broadcastSessionList();
    return false;
  }
  if (hasProcessedCrossConversationReply(sourceSession, requestId)) {
    updatePendingCrossConversationReply(requestId, (draft) => {
      draft.status = 'returned';
      draft.returnedAt = draft.returnedAt || new Date().toISOString();
    });
    deletePendingCrossConversationReply(requestId);
    return true;
  }

  const now = new Date().toISOString();
  const replyMessageId = crypto.randomUUID();
  const replyContent = buildCrossConversationReplyContent(targetSession, pending.replyText);
  const sourceAutoRun = pending.sourceAutoRun !== false;
  const crossConversation = {
    messageId: replyMessageId,
    sourceSessionId: targetSession.id,
    sourceTitle: targetSession.title || 'Untitled',
    sentAt: now,
    hopCount: Math.max(0, Number.parseInt(String(pending.hopCount || 0), 10) || 0) + 1,
    reply: true,
    replyToRequestId: requestId,
    processed: true,
    processedAt: now,
    autoRun: sourceAutoRun,
  };

  updatePendingCrossConversationReply(requestId, (draft) => {
    draft.status = 'delivering';
  });
  const replyMessage = {
    role: 'assistant',
    content: replyContent,
    timestamp: now,
    crossConversation,
    ccwebDisplayOnly: true,
  };
  sourceSession.messages = Array.isArray(sourceSession.messages) ? sourceSession.messages : [];
  sourceSession.messages.push(replyMessage);
  sourceSession.updated = now;
  const sourceWs = findViewingSessionWs(sourceSession.id);
  if (!sourceWs) sourceSession.hasUnread = true;
  saveSession(sourceSession);
  if (sourceWs) {
    wsSend(sourceWs, {
      type: 'session_message',
      sessionId: sourceSession.id,
      message: replyMessage,
    });
  }

  updatePendingCrossConversationReply(requestId, (draft) => {
    draft.status = 'returned';
    draft.returnedAt = now;
    draft.replyMessageId = replyMessageId;
  });
  deletePendingCrossConversationReply(requestId);
  broadcastSessionList();
  if (sourceAutoRun) {
    startCrossConversationReplyAutoRun(sourceSession, targetSession, pending.replyText, crossConversation);
  }
  return true;
}

function flushPendingCrossConversationReplies(sourceSessionId) {
  const sourceId = sanitizeId(sourceSessionId || '');
  if (!sourceId || isSessionRunning(sourceId)) return;
  for (const [requestId, pending] of pendingCrossConversationReplies.entries()) {
    if (pending.sourceConversationId === sourceId && pending.status === 'ready') {
      deliverCrossConversationReply(requestId);
    }
  }
}

function completeCrossConversationReply(requestId, entry = {}, targetSession = null) {
  const normalizedRequestId = String(requestId || '').trim();
  if (!normalizedRequestId) return false;
  const pending = pendingCrossConversationReplies.get(normalizedRequestId);
  if (!pending || pending.status !== 'waiting') return false;

  let replyText = extractCrossConversationReplyText(entry.contentBlocks || entry.fullText);
  if (!replyText && entry.lastError) {
    replyText = `目标对话运行失败：${entry.lastError}`;
  }
  if (!replyText) {
    replyText = '（目标对话没有返回可用文本。）';
  }

  updatePendingCrossConversationReply(normalizedRequestId, (draft) => {
    draft.status = 'ready';
    draft.replyText = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS);
    draft.completedAt = new Date().toISOString();
    if (targetSession?.title) draft.targetTitle = targetSession.title;
  });
  broadcastSessionList();
  return deliverCrossConversationReply(normalizedRequestId);
}

function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
  switch (tool) {
    case 'ccweb_list_conversations':
      return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
    case 'ccweb_set_title':
      return setCurrentConversationTitle(args, sourceSessionId);
    case 'ccweb_create_conversation':
      return createMcpConversation(args, sourceSessionId, sourceHopCount);
    case 'ccweb_send_message':
      return sendCrossConversationMessage(args, sourceSessionId, sourceHopCount);
    case 'ccweb_list_pending_replies':
      return listPendingCrossConversationReplies(args, sourceSessionId);
    case 'ccweb_get_pending_reply':
      return getPendingCrossConversationReply(args, sourceSessionId);
    case 'ccweb_request_reply':
      return requestCrossConversationReply(args, sourceSessionId, sourceHopCount);
    case 'ccweb_prompt_user':
      return createCcwebPromptUser(args, sourceSessionId);
    default:
      return mcpToolError('unknown_tool', `未知 MCP 工具: ${tool}`);
  }
}

function mcpJsonRpcResult(id, result) {
  return { jsonrpc: '2.0', id, result };
}

function mcpJsonRpcError(id, code, message, data) {
  const error = { code, message };
  if (data !== undefined) error.data = data;
  return { jsonrpc: '2.0', id, error };
}

function mcpToolResponse(payload) {
  const text = JSON.stringify(payload, null, 2);
  return {
    content: [{ type: 'text', text }],
    structuredContent: payload,
    isError: !payload?.ok,
  };
}

function handleMcpJsonRpcMessage(message, context = {}) {
  const hasId = Object.prototype.hasOwnProperty.call(message || {}, 'id');
  if (!hasId) return null;
  const id = message.id;
  const method = String(message?.method || '');
  try {
    switch (method) {
      case 'initialize':
        return mcpJsonRpcResult(id, {
          protocolVersion: message.params?.protocolVersion || '2024-11-05',
          capabilities: { tools: {} },
          serverInfo: CCWEB_MCP_SERVER_INFO,
        });
      case 'ping':
        return mcpJsonRpcResult(id, {});
      case 'tools/list':
        return mcpJsonRpcResult(id, { tools: CCWEB_MCP_TOOLS });
      case 'tools/call': {
        const name = String(message.params?.name || '');
        const args = message.params?.arguments || {};
        if (!CCWEB_MCP_TOOLS.some((tool) => tool.name === name)) {
          return mcpJsonRpcResult(id, mcpToolResponse(mcpToolError('unknown_tool', `未知工具: ${name}`)));
        }
        const payload = callInternalMcpTool(
          name,
          args,
          context.sourceSessionId || '',
          context.sourceHopCount || 0,
        );
        return mcpJsonRpcResult(id, mcpToolResponse(payload));
      }
      case 'resources/list':
        return mcpJsonRpcResult(id, { resources: [] });
      case 'prompts/list':
        return mcpJsonRpcResult(id, { prompts: [] });
      default:
        return mcpJsonRpcError(id, -32601, `Method not found: ${method}`);
    }
  } catch (err) {
    return mcpJsonRpcError(id, -32603, err?.message || 'Internal error');
  }
}

async function handleSharedMcpHttpApi(req, res, url) {
  if (req.method !== 'POST') {
    res.writeHead(405, {
      'Content-Type': 'application/json; charset=utf-8',
      'Cache-Control': 'no-cache',
      Allow: 'POST',
    });
    res.end(JSON.stringify(mcpJsonRpcError(null, -32600, 'Only POST is supported.')));
    return;
  }

  const token = getInternalMcpRequestToken(req);
  if (!token || token !== INTERNAL_MCP_TOKEN) {
    return jsonResponse(res, 401, mcpJsonRpcError(null, -32001, 'MCP 内部接口未授权。'));
  }

  let payload;
  try {
    payload = await readJsonBody(req);
  } catch (err) {
    return jsonResponse(res, 400, mcpJsonRpcError(null, -32700, err?.message || '请求体无效。'));
  }

  const context = {
    sourceSessionId: sanitizeId(url.searchParams.get('sourceSessionId') || ''),
    sourceHopCount: Number.parseInt(String(url.searchParams.get('sourceHopCount') || 0), 10) || 0,
  };

  const messages = Array.isArray(payload) ? payload : [payload];
  const responses = messages
    .map((message) => handleMcpJsonRpcMessage(message, context))
    .filter(Boolean);

  if (responses.length === 0) {
    res.writeHead(202, { 'Cache-Control': 'no-cache' });
    res.end();
    return;
  }

  return jsonResponse(res, 200, Array.isArray(payload) ? responses : responses[0]);
}

async function handleInternalMcpApi(req, res) {
  const token = getInternalMcpRequestToken(req);
  if (!token || token !== INTERNAL_MCP_TOKEN) {
    return jsonResponse(res, 401, mcpToolError('unauthorized', 'MCP 内部接口未授权。'));
  }

  let payload;
  try {
    payload = await readJsonBody(req);
  } catch (err) {
    return jsonResponse(res, 400, mcpToolError('bad_request', err.message || '请求体无效。'));
  }

  const tool = String(payload.tool || '');
  const args = payload.args && typeof payload.args === 'object' ? payload.args : {};
  const sourceSessionId = sanitizeId(payload.sourceSessionId || '');
  const sourceHopCount = Number.parseInt(String(payload.sourceHopCount || 0), 10) || 0;
  const result = callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount);
  return jsonResponse(res, result.ok ? 200 : 400, result);
}

// === File Tailer ===
// Tails a file and calls onLine for each new complete line.
class FileTailer {
  constructor(filePath, onLine) {
    this.filePath = filePath;
    this.onLine = onLine;
    this.offset = 0;
    this.buffer = '';
    this.watcher = null;
    this.interval = null;
    this.stopped = false;
  }

  start() {
    this.readNew();
    try {
      this.watcher = fs.watch(this.filePath, () => {
        if (!this.stopped) this.readNew();
      });
      this.watcher.on('error', () => {});
    } catch {}
    // Backup poll every 500ms (fs.watch not always reliable on all systems)
    this.interval = setInterval(() => {
      if (!this.stopped) this.readNew();
    }, 500);
  }

  readNew() {
    try {
      const stat = fs.statSync(this.filePath);
      if (stat.size <= this.offset) return;
      const unreadBytes = stat.size - this.offset;
      if (unreadBytes > RUN_OUTPUT_TAILER_MAX_READ_BYTES) {
        plog('WARN', 'runtime_output_tailer_skipped_oversized_gap', {
          file: path.basename(this.filePath),
          unreadBytes,
          maxBytes: RUN_OUTPUT_TAILER_MAX_READ_BYTES,
        });
        this.offset = Math.max(0, stat.size - RUN_OUTPUT_TAILER_MAX_READ_BYTES);
        this.buffer = '';
      }
      const readBytes = stat.size - this.offset;
      if (readBytes <= 0) return;
      const buf = Buffer.alloc(readBytes);
      const fd = fs.openSync(this.filePath, 'r');
      fs.readSync(fd, buf, 0, buf.length, this.offset);
      fs.closeSync(fd);
      this.offset = stat.size;
      this.buffer += buf.toString();
      const lines = this.buffer.split('\n');
      this.buffer = lines.pop();
      for (const line of lines) {
        if (line.trim()) this.onLine(line);
      }
    } catch {}
  }

  stop() {
    this.stopped = true;
    if (this.watcher) { this.watcher.close(); this.watcher = null; }
    if (this.interval) { clearInterval(this.interval); this.interval = null; }
  }
}

// === Process Lifecycle ===

function firstMeaningfulLine(text) {
  return String(text || '')
    .split('\n')
    .map((line) => line.trim())
    .find(Boolean) || '';
}

function condenseRuntimeError(raw) {
  const text = String(raw || '').trim();
  if (!text) return '';
  const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
  const usageIndex = lines.findIndex((line) => /^Usage:/i.test(line));
  if (usageIndex >= 0) return lines.slice(0, usageIndex).join(' ');
  return lines.slice(0, 3).join(' ');
}

function formatRuntimeError(agent, raw, context = {}) {
  const condensed = condenseRuntimeError(raw);
  const exitInfo = typeof context.exitCode === 'number' ? `（退出码 ${context.exitCode}）` : '';
  if (!condensed) {
    return agent === 'codex'
      ? `Codex 任务异常结束${exitInfo}，但 CLI 没有返回更多错误信息。`
      : `Claude 任务异常结束${exitInfo}，但 CLI 没有返回更多错误信息。`;
  }

  if (agent === 'codex') {
    if (/ENOENT|not found|No such file/i.test(condensed)) {
      return '找不到 Codex CLI。请检查 Codex 设置里的 CLI 路径，或确认系统 PATH 中可直接运行 `codex`。';
    }
    if (/unexpected argument|unexpected option|Usage:\s*codex/i.test(raw || '')) {
      return `Codex CLI 参数不兼容：${firstMeaningfulLine(condensed)}。建议检查当前 CLI 版本与 cc-web 的参数约定是否匹配。`;
    }
    if (/permission denied|EACCES|EPERM/i.test(condensed)) {
      return 'Codex CLI 启动失败：当前环境没有足够权限执行该命令或访问目标目录。';
    }
    if (/authentication|unauthorized|forbidden|login|api key|credential/i.test(condensed)) {
      return 'Codex 鉴权失败。请确认本机 Codex CLI 已完成登录，且当前凭据仍然有效。';
    }
    if (/rate limit|quota|billing|credits/i.test(condensed)) {
      return 'Codex 请求被额度或速率限制拦截。请检查账号配额、计费状态或稍后重试。';
    }
    if (isCodexTransientCapacityError(condensed)) {
      return 'Codex 服务暂时繁忙或所选模型容量不足。cc-web 已自动重试，仍未成功；请稍后再试或临时切换模型。';
    }
    if (isCodexTransientConnectionError(condensed)) {
      return 'Codex App 连接暂时中断。cc-web 已自动重试，仍未成功；请稍后再试或检查网络代理。';
    }
    if (/network|timed out|timeout|ECONNRESET|ENOTFOUND|TLS|certificate|fetch failed/i.test(condensed)) {
      return 'Codex 运行时网络请求失败。请检查当前网络、代理或证书环境后重试。';
    }
    if (/sandbox|approval|read-only|bypass-approvals/i.test(condensed)) {
      return `Codex 当前的审批或沙箱设置阻止了这次执行：${firstMeaningfulLine(condensed)}`;
    }
    return `Codex 任务失败${exitInfo}：${condensed}`;
  }

  if (/ENOENT|not found|No such file/i.test(condensed)) {
    return '找不到 Claude CLI。请检查当前环境是否能直接运行 `claude`。';
  }
  if (/authentication|unauthorized|forbidden|api key|credential/i.test(condensed)) {
    return 'Claude 鉴权失败。请确认本机 Claude CLI 已完成登录，且凭据仍然有效。';
  }
  return `Claude 任务失败${exitInfo}：${condensed}`;
}

function compactStartMessage(agent) {
  return agent === 'codex'
    ? '正在执行 Codex /compact 压缩上下文，请稍候…'
    : '正在执行 Claude 原生 /compact 压缩上下文，请稍候…';
}

function compactDoneMessage(agent) {
  return agent === 'codex'
    ? '上下文压缩完成。已执行 Codex /compact，下次继续在同一会话发送即可。'
    : '上下文压缩完成。已按 Claude Code 原生策略执行 /compact，下次继续在同一会话发送即可。';
}

function initStartMessage(agent) {
  return agent === 'codex' || agent === 'codexapp'
    ? '正在分析项目并生成 AGENTS.md ...'
    : '正在分析项目并生成 CLAUDE.md ...';
}

function buildCodexInitPrompt(cwd) {
  const targetPath = path.join(cwd || process.cwd(), 'AGENTS.md');
  return [
    'You are running cc-web\'s /init for a Codex session.',
    'Analyze the current workspace and create or update AGENTS.md at the repository root.',
    `The file path to write is: ${targetPath}`,
    'Requirements:',
    '- Actually write the file; do not stop after summarizing in chat.',
    '- If AGENTS.md already exists, update it in place instead of creating a duplicate.',
    '- Keep the document concise and practical for future coding agents working in this repo.',
    '- Include the project purpose, key entry points, dev/test commands, important workflows, and repo-specific safety constraints.',
    '- Prefer facts from the actual codebase over README claims when they differ.',
    '- After editing the file, reply with a brief summary of what you wrote.',
  ].join('\n');
}

function compactAutoStartMessage(agent) {
  return agent === 'codex'
    ? '检测到上下文达到上限，正在按 Codex /compact 自动压缩，然后继续当前任务…'
    : '检测到上下文达到上限，正在按 Claude Code 原版策略自动执行 /compact，然后继续当前任务…';
}

function compactAutoResumeMessage(agent) {
  return agent === 'codex'
    ? '检测到上一条请求因上下文过大失败，现已按 Codex 压缩计划继续执行。'
    : '检测到上一条请求因上下文过大失败，现已自动按压缩计划继续执行。';
}

function isContextLimitError(agent, raw) {
  const text = String(raw || '');
  if (!text) return false;
  if (agent === 'claude') {
    return /Request too large \(max 20MB\)/i.test(text);
  }
  return /context\s+(window|length)|maximum context length|context limit|token limit|too many tokens|input.*too long|prompt.*too long|request too large|please use\s*\/compact|use\s*\/compact|reduce (the )?(input|prompt|message)|exceed(?:ed|s).*(token|context)/i.test(text);
}

function isCodexTransientCapacityError(raw) {
  const text = String(raw || '');
  if (!text) return false;
  return /server_is_overloaded|service_unavailable_error|ServiceUnavailableError|servers?\s+(?:are\s+)?(?:currently\s+)?overloaded|server\s+is\s+overloaded|model\s+is\s+at\s+capacity|selected model is at capacity|model.*overloaded|503\b.*(?:overloaded|unavailable)|temporarily unavailable|please try again later/i.test(text);
}

function isCodexTransientConnectionError(raw) {
  const text = String(raw || '');
  if (!text) return false;
  return /reconnecting(?:\.\.\.)?\s*\d+\/\d+|connection\s+(?:lost|closed|reset|refused|interrupted)|disconnect(?:ed|ion)|ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|ENOTFOUND|fetch failed|network.*(?:error|failed)|TLS connection.*terminated/i.test(text);
}

function isCodexTransientRetryableError(raw) {
  return isCodexTransientCapacityError(raw) || isCodexTransientConnectionError(raw);
}

function hasRuntimeOutput(entry) {
  if ((entry.fullText || '').trim()) return true;
  if (Array.isArray(entry.contentBlocks) && entry.contentBlocks.length > 0) return true;
  return Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0;
}

function retryTailText(value, maxChars) {
  const text = String(value || '').trim();
  if (!text || text.length <= maxChars) return text;
  const marker = '[cc-web: 前文过长，下面仅保留尾部]\n';
  return `${marker}${text.slice(Math.max(0, text.length - Math.max(0, maxChars - marker.length)))}`;
}

function retryPreviewValue(value, maxChars) {
  if (value === null || value === undefined) return '';
  if (typeof value === 'string') return retryTailText(value, maxChars);
  try {
    return retryTailText(JSON.stringify(sanitizePersistValue(value, {
      maxString: maxChars,
      maxDepth: 4,
      maxArray: 20,
      maxKeys: 40,
    }), null, 2), maxChars);
  } catch {
    return retryTailText(String(value), maxChars);
  }
}

function buildCodexRetryToolSummary(toolCalls) {
  const list = Array.isArray(toolCalls) ? toolCalls.filter(Boolean).slice(-8) : [];
  if (list.length === 0) return '';
  return list.map((tool, index) => {
    const name = tool.name || tool.kind || tool.id || `tool-${index + 1}`;
    const status = tool.done ? 'done' : (tool.status || tool.meta?.status || 'inProgress');
    const lines = [`${index + 1}. ${name} (${status})`];
    if (tool.input !== undefined && tool.input !== null) {
      lines.push(`   input: ${retryPreviewValue(tool.input, 1200)}`);
    }
    if (tool.result !== undefined && tool.result !== null) {
      lines.push(`   result: ${retryPreviewValue(tool.result, 2400)}`);
    }
    return lines.join('\n');
  }).join('\n');
}

function shouldUseCodexAppContinuationRetry(entry) {
  return (entry?.agent || '') === 'codexapp' && !!(entry.turnId || hasRuntimeOutput(entry));
}

function buildCodexAppContinuationRetryText(entry, retryRequest, rawError) {
  const original = retryPreviewValue(
    retryRequest.originalRuntimeText || retryRequest.runtimeText || retryRequest.originalText || retryRequest.text || '',
    5000,
  );
  const partialText = retryPreviewValue(entry.fullText || '', 7000);
  const toolSummary = buildCodexRetryToolSummary(entry.toolCalls || []);
  const errorText = retryPreviewValue(rawError || entry.lastError || '', 1200);
  const parts = [
    '继续上一轮被临时服务或网络错误中断的 Codex App 任务。',
    '不要从头重做，不要重复已经完成的命令、工具调用或文件修改；请基于现有线程上下文和下面 cc-web 已观察到的中断前状态继续执行。不要在回复中复述这段内部重试说明。',
  ];
  if (original) {
    parts.push(`原始用户请求（仅用于理解目标，不要当成新请求从头执行）：\n${original}`);
  }
  if (partialText) {
    parts.push(`cc-web 已观察到的中断前助手输出（尾部）：\n${partialText}`);
  }
  if (toolSummary) {
    parts.push(`cc-web 已观察到的工具/执行摘要：\n${toolSummary}`);
  }
  if (errorText) {
    parts.push(`中断原因：\n${errorText}`);
  }
  parts.push('请从中断处继续完成剩余工作。');
  return parts.filter(Boolean).join('\n\n');
}

function getCodexRetryConfig() {
  return normalizeCodexRetryConfig(loadCodexConfig().retry);
}

function codexTransientRetryDelayMs(config) {
  return Math.max(0, (config?.intervalSeconds || DEFAULT_CODEX_CONFIG.retry.intervalSeconds) * 1000);
}

function cancelCodexCapacityRetry(sessionId) {
  const retry = pendingCodexCapacityRetries.get(sessionId);
  if (!retry) return false;
  if (retry.timer) clearTimeout(retry.timer);
  pendingCodexCapacityRetries.delete(sessionId);
  return true;
}

function shouldRetryCodexTransientFailure(entry, rawError, context = {}) {
  if (!['codex', 'codexapp'].includes(entry.agent || 'claude')) return false;
  if (!rawError || !isCodexTransientRetryableError(rawError)) return false;
  if (getCodexRetryConfig().mode === 'off') return false;
  if (context.contextLimitExceeded || context.pendingSlash) return false;
  if (entry.crossConversationReplyRequestId) return false;
  if ((entry.agent || 'claude') !== 'codexapp' && hasRuntimeOutput(entry)) return false;
  return !!(entry.retryRequest?.text || entry.retryRequest?.runtimeText);
}

function scheduleCodexCapacityRetry(sessionId, entry, rawError) {
  const retryRequest = entry.retryRequest || {};
  const previous = pendingCodexCapacityRetries.get(sessionId) || null;
  const retryConfig = getCodexRetryConfig();
  if (retryConfig.mode === 'off') {
    cancelCodexCapacityRetry(sessionId);
    return false;
  }
  const attempts = (previous?.attempts || entry.codexRetry?.attempt || 0) + 1;
  if (retryConfig.mode === 'limited' && attempts > retryConfig.maxAttempts) {
    cancelCodexCapacityRetry(sessionId);
    return false;
  }

  const delayMs = codexTransientRetryDelayMs(retryConfig);
  if (previous?.timer) clearTimeout(previous.timer);
  const expectedThreadId = retryRequest.expectedThreadId
    || entry.expectedThreadId
    || entry.codexRetry?.expectedThreadId
    || entry.threadId
    || previous?.expectedThreadId
    || null;
  const originalText = retryRequest.originalText || retryRequest.text || retryRequest.runtimeText || '';
  const originalRuntimeText = retryRequest.originalRuntimeText || retryRequest.runtimeText || retryRequest.text || '';
  const useContinuationRetry = shouldUseCodexAppContinuationRetry(entry);
  const continuationText = useContinuationRetry
    ? buildCodexAppContinuationRetryText(entry, retryRequest, rawError)
    : '';
  const retryRuntimeText = continuationText || originalRuntimeText || originalText;
  const retryText = retryRuntimeText || originalText;

  const retry = {
    text: retryText,
    runtimeText: retryRuntimeText,
    originalText,
    originalRuntimeText,
    mode: retryRequest.mode || 'yolo',
    agent: retryRequest.agent || entry.agent || 'codex',
    attachments: useContinuationRetry ? [] : (Array.isArray(retryRequest.attachments) ? retryRequest.attachments : []),
    mcpContext: retryRequest.mcpContext || {},
    expectedThreadId,
    useContinuationRetry,
    attempts,
    retryMode: retryConfig.mode,
    timer: null,
    ws: entry.ws || null,
  };

  retry.timer = setTimeout(() => {
    const latest = pendingCodexCapacityRetries.get(sessionId);
    if (!latest || latest.timer !== retry.timer) return;
    latest.timer = null;

    const session = loadSession(sessionId);
    if (!session) {
      pendingCodexCapacityRetries.delete(sessionId);
      return;
    }
    if (activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId)) {
      pendingCodexCapacityRetries.delete(sessionId);
      plog('WARN', 'codex_capacity_retry_skipped_busy', {
        sessionId: sessionId.slice(0, 8),
        attempt: latest.attempts,
      });
      if (latest.ws && latest.ws.readyState === 1) sendSessionList(latest.ws);
      return;
    }

    pendingCodexCapacityRetries.delete(sessionId);
    const ws = latest.ws && latest.ws.readyState === 1 ? latest.ws : null;
    plog('INFO', 'codex_capacity_retry_start', {
      sessionId: sessionId.slice(0, 8),
      attempt: latest.attempts,
      expectedThreadId: latest.expectedThreadId ? String(latest.expectedThreadId).slice(0, 24) : null,
      continuation: !!latest.useContinuationRetry,
    });
    handleMessage(ws, {
      type: 'message',
      text: latest.text,
      sessionId,
      mode: latest.mode,
      agent: latest.agent || 'codex',
      attachments: latest.attachments,
    }, {
      hideInHistory: true,
      runtimeText: latest.runtimeText,
      mcpContext: latest.mcpContext,
      codexRetry: latest.agent === 'codexapp'
        ? {
            isAutoRetry: true,
            attempt: latest.attempts,
            retryMode: latest.retryMode,
            expectedThreadId: latest.expectedThreadId || null,
            originalText: latest.originalText || latest.text || '',
            originalRuntimeText: latest.originalRuntimeText || latest.runtimeText || '',
            useContinuationRetry: !!latest.useContinuationRetry,
          }
        : null,
      skipPendingCrossConversationFlush: true,
    });
  }, delayMs);

  pendingCodexCapacityRetries.set(sessionId, retry);
  plog('WARN', 'codex_capacity_retry_scheduled', {
    sessionId: sessionId.slice(0, 8),
    attempt: attempts,
    maxAttempts: retryConfig.mode === 'limited' ? retryConfig.maxAttempts : null,
    retryMode: retryConfig.mode,
    delayMs,
    expectedThreadId: expectedThreadId ? String(expectedThreadId).slice(0, 24) : null,
    continuation: useContinuationRetry,
    error: String(rawError || '').slice(0, 300),
  });
  if (entry.ws) {
    const attemptText = retryConfig.mode === 'forever'
      ? `第 ${attempts} 次`
      : `第 ${attempts}/${retryConfig.maxAttempts} 次`;
    const continuationText = useContinuationRetry ? '，将从中断处继续' : '';
    wsSend(entry.ws, {
      type: 'system_message',
      sessionId,
      message: `Codex 服务暂时繁忙，${retryConfig.intervalSeconds} 秒后自动重试（${attemptText}${continuationText}）。`,
    });
  }
  return true;
}

function handleProcessComplete(sessionId, exitCode, signal) {
  const entry = activeProcesses.get(sessionId);
  if (!entry) return;
  if (entry.pidMonitorCompleteTimer) {
    clearTimeout(entry.pidMonitorCompleteTimer);
    entry.pidMonitorCompleteTimer = null;
  }

  const completeTime = new Date().toISOString();
  const wsConnected = !!entry.ws;
  const disconnectGap = entry.wsDisconnectTime
    ? ((new Date(completeTime) - new Date(entry.wsDisconnectTime)) / 1000).toFixed(1) + 's'
    : null;

  const pendingRetry = pendingCompactRetries.get(sessionId) || null;
  let contextLimitExceeded = false;

  // 先读完剩余 JSONL，再判定错误类型，避免退出监控早于 tailer 造成漏判。
  if (entry.tailer) {
    entry.tailer.readNew();
    entry.tailer.stop();
  }

  // Read stderr for error clues
  let stderrSnippet = '';
  try {
    const errPath = path.join(runDir(sessionId), 'error.log');
    if (fs.existsSync(errPath)) {
      const content = fs.readFileSync(errPath, 'utf8').trim();
      if (content) stderrSnippet = content.slice(-500);
    }
  } catch {}

  const rawCompletionError = entry.lastError || (
    ((typeof exitCode === 'number' && exitCode !== 0) || (!!signal && signal !== 'SIGTERM'))
      ? (stderrSnippet || null)
      : null
  );
  contextLimitExceeded = isContextLimitError(entry.agent || 'claude', `${entry.fullText || ''}\n${stderrSnippet || ''}\n${rawCompletionError || ''}`);
  const completionError = rawCompletionError ? formatRuntimeError(entry.agent || 'claude', rawCompletionError, { exitCode, signal }) : null;
  if (!entry.lastError && rawCompletionError) entry.lastError = rawCompletionError;

  plog(exitCode === 0 || exitCode === null ? 'INFO' : 'WARN', 'process_complete', {
    sessionId: sessionId.slice(0, 8),
    pid: entry.pid,
    agent: entry.agent || 'claude',
    exitCode,
    signal,
    wsConnected,
    wsDisconnectTime: entry.wsDisconnectTime || null,
    disconnectToDeathGap: disconnectGap,
    responseLen: (entry.fullText || '').length,
    toolCallCount: (entry.toolCalls || []).length,
    cost: entry.lastCost,
    usage: entry.lastUsage || null,
    error: rawCompletionError,
    stderr: stderrSnippet || null,
    requestTooLarge: contextLimitExceeded,
  });

  const pendingSlash = pendingSlashCommands.get(sessionId) || null;
  if (pendingSlash) pendingSlashCommands.delete(sessionId);

  // Save result to session
  const session = loadSession(sessionId);
  if (session && shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) {
    activeProcesses.delete(sessionId);
    cleanRunDir(sessionId);
    pendingSlashCommands.delete(sessionId);
    if (scheduleCodexCapacityRetry(sessionId, entry, rawCompletionError)) {
      return;
    }
  }

  if (!shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) {
    cancelCodexCapacityRetry(sessionId);
  }

  if (session && (entry.fullText || entry.contentBlocks)) {
    session.messages.push({
      role: 'assistant',
      content: entry.contentBlocks || entry.fullText,
      toolCalls: entry.toolCalls || [],
      timestamp: new Date().toISOString(),
    });
    session.updated = new Date().toISOString();
    if (!entry.ws) session.hasUnread = true;
    saveSession(session);
  }

  if (pendingSlash?.kind === 'compact' && session) {
    if (entry.lastCost) {
      session.totalCost = Math.max(0, (session.totalCost || 0) - entry.lastCost);
    }
    session.updated = new Date().toISOString();
    saveSession(session);
  }

  let shouldReturnForFollowup = false;
  let shouldAutoCompact = false;

  activeProcesses.delete(sessionId);
  cleanRunDir(sessionId);
  pendingSlashCommands.delete(sessionId);
  if (entry.crossConversationReplyRequestId) {
    completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
  }
  flushPendingCrossConversationReplies(sessionId);

  // Notify client
  if (entry.ws) {
    if (pendingSlash?.kind === 'compact') {
      const retry = pendingCompactRetries.get(sessionId);
      const autoRetryRequested = !!(retry?.text && retry?.reason === 'auto');
      if (autoRetryRequested) {
        if (contextLimitExceeded) {
          pendingCompactRetries.delete(sessionId);
          wsSend(entry.ws, { type: 'system_message', sessionId, message: '已尝试执行 /compact，但仍未成功解除上下文超限。请手动缩小输入范围后重试。' });
        } else {
          wsSend(entry.ws, { type: 'system_message', sessionId, message: compactDoneMessage(entry.agent || 'claude') });
          wsSend(entry.ws, { type: 'system_message', sessionId, message: compactAutoResumeMessage(entry.agent || 'claude') });
          shouldReturnForFollowup = true;
        }
      } else {
        wsSend(entry.ws, { type: 'system_message', sessionId, message: compactDoneMessage(entry.agent || 'claude') });
      }
    }

    if (contextLimitExceeded && !pendingSlash && session && getRuntimeSessionId(session)) {
      pendingCompactRetries.set(sessionId, { text: pendingRetry?.text || '', mode: pendingRetry?.mode || session.permissionMode || 'yolo', reason: 'auto' });
      wsSend(entry.ws, { type: 'system_message', sessionId, message: compactAutoStartMessage(entry.agent || 'claude') });
      shouldAutoCompact = true;
    }

    if (completionError && !entry.errorSent && !shouldAutoCompact) {
      entry.errorSent = true;
      wsSend(entry.ws, { type: 'error', sessionId, message: completionError });
    }

    wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null });
    sendSessionList(entry.ws);
    // Push notification when trigger='always' (user online but still wants notification)
    (() => {
      const notifyCfg = loadNotifyConfig();
      if (!notifyCfg.provider || notifyCfg.provider === 'off') return;
      if ((notifyCfg.summary?.trigger || 'background') !== 'always') return;
      const sess = loadSession(sessionId);
      buildNotifyContent(entry, sess, completionError, contextLimitExceeded).then(({ title: ntitle, content }) => {
        sendNotification(ntitle, content);
      });
    })();
  } else {
    // Process completed while browser was disconnected — notify all connected clients
    const sess = loadSession(sessionId);
    const title = sess?.title || 'Untitled';
    for (const client of wss.clients) {
      if (client.readyState === 1) {
        wsSend(client, {
          type: 'background_done',
          sessionId,
          title,
          costUsd: entry.lastCost || null,
          responseLen: (entry.fullText || '').length,
        });
      }
    }
    // Push notification (background task)
    buildNotifyContent(entry, sess, completionError, contextLimitExceeded).then(({ title: ntitle, content }) => {
      sendNotification(ntitle, content);
    });
  }

  if (!shouldReturnForFollowup && !shouldAutoCompact && !contextLimitExceeded && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) {
    pendingCompactRetries.delete(sessionId);
  }

  if (shouldReturnForFollowup && entry.ws && entry.ws.readyState === 1 && session) {
    if (pendingSlash?.kind === 'compact') {
      const retry = pendingCompactRetries.get(sessionId);
      if (retry?.text) {
        pendingCompactRetries.delete(sessionId);
        handleMessage(entry.ws, { text: retry.text, sessionId, mode: retry.mode || session.permissionMode || 'yolo' });
      }
      return;
    }
  }

  if (shouldAutoCompact && entry.ws && entry.ws.readyState === 1 && session) {
    pendingSlashCommands.set(sessionId, { kind: 'compact' });
    handleMessage(entry.ws, { text: '/compact', sessionId, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
    return;
  }
}

// Global PID monitor: detect process completion (especially after server restart)
setInterval(() => {
  for (const [sessionId, entry] of activeProcesses) {
    if (entry.pid && !isProcessRunning(entry.pid)) {
      if (entry.pidMonitorCompleteTimer) continue;
      plog('INFO', 'pid_monitor_detected_exit', {
        sessionId: sessionId.slice(0, 8),
        pid: entry.pid,
        wsConnected: !!entry.ws,
      });
      entry.pidMonitorCompleteTimer = setTimeout(() => {
        const latest = activeProcesses.get(sessionId);
        if (!latest || latest.pid !== entry.pid) return;
        latest.pidMonitorCompleteTimer = null;
        handleProcessComplete(sessionId, null, 'unknown (detected by monitor)');
      }, 1000);
    }
  }
}, 2000);

cleanupExpiredAttachments();
setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000);

// Recover processes that were running before server restart
function recoverProcesses() {
  try {
    const entries = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('-run') && fs.statSync(path.join(SESSIONS_DIR, f)).isDirectory());
    if (entries.length === 0) return;
    plog('INFO', 'recovery_start', { runDirs: entries.length });
    for (const dirName of entries) {
      const sessionId = dirName.replace('-run', '');
      const dir = path.join(SESSIONS_DIR, dirName);
      const pidPath = path.join(dir, 'pid');
      const outputPath = path.join(dir, 'output.jsonl');
      const session = loadSession(sessionId);
      const agent = getSessionAgent(session);

      if (fs.existsSync(codexAppStatePath(sessionId))) {
        recoverCodexAppTurnState(sessionId);
        continue;
      }

      if (!fs.existsSync(pidPath)) {
        try { fs.rmSync(dir, { recursive: true }); } catch {}
        continue;
      }

      const pid = parseInt(fs.readFileSync(pidPath, 'utf8'));

      if (isProcessRunning(pid)) {
        console.log(`[recovery] Re-attaching to session ${sessionId} (PID ${pid})`);
        plog('INFO', 'recovery_alive', { sessionId: sessionId.slice(0, 8), pid, agent });
        const entry = { pid, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null };
        activeProcesses.set(sessionId, entry);

        if (fs.existsSync(outputPath)) {
          entry.tailer = new FileTailer(outputPath, (line) => {
            try {
              const event = JSON.parse(line);
              processRuntimeEvent(entry, event, sessionId);
            } catch {}
          });
          entry.tailer.start();
        }
      } else {
        // Process finished while server was down — read all output and save
        console.log(`[recovery] Processing completed output for session ${sessionId}`);
        plog('INFO', 'recovery_dead', { sessionId: sessionId.slice(0, 8), pid, agent });
        if (fs.existsSync(outputPath)) {
          const outputStat = fs.statSync(outputPath);
          if (outputStat.size > RUN_OUTPUT_RECOVERY_MAX_BYTES) {
            plog('WARN', 'recovery_output_skipped_oversized', {
              sessionId: sessionId.slice(0, 8),
              pid,
              agent,
              fileBytes: outputStat.size,
              maxBytes: RUN_OUTPUT_RECOVERY_MAX_BYTES,
            });
            if (session) {
              session.messages.push({
                role: 'system',
                content: '服务重启期间的运行输出文件过大，cc-web 已跳过恢复完整输出，避免启动时占用过多内存。',
                timestamp: new Date().toISOString(),
              });
              session.updated = new Date().toISOString();
              saveSession(session);
            }
            try { fs.rmSync(dir, { recursive: true }); } catch {}
            continue;
          }
          const tempEntry = { pid: 0, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null };
          const content = fs.readFileSync(outputPath, 'utf8');
          for (const line of content.split('\n')) {
            if (!line.trim()) continue;
            try {
              const event = JSON.parse(line);
              processRuntimeEvent(tempEntry, event, sessionId);
            } catch {}
          }
          if (session && tempEntry.fullText) {
            session.messages.push({
              role: 'assistant',
              content: tempEntry.fullText,
              toolCalls: tempEntry.toolCalls || [],
              timestamp: new Date().toISOString(),
            });
            session.updated = new Date().toISOString();
            saveSession(session);
          }
        }
        try { fs.rmSync(dir, { recursive: true }); } catch {}
      }
    }
  } catch (err) {
    console.error('[recovery] Error:', err.message);
  }
}

// === HTTP Static File Server ===
const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);

  if (url.pathname === '/api/internal/mcp/stream') {
    return handleSharedMcpHttpApi(req, res, url);
  }

  if (req.method === 'POST' && url.pathname === '/api/internal/mcp') {
    return handleInternalMcpApi(req, res);
  }

  const reloadMcpMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/reload-mcp$/);
  if (req.method === 'POST' && reloadMcpMatch) {
    return handleReloadMcpApi(req, res, decodeURIComponent(reloadMcpMatch[1] || ''));
  }

  if (req.method === 'POST' && url.pathname === '/api/attachments') {
    const token = extractBearerToken(req);
    if (!token || !activeTokens.has(token)) {
      return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
    }
    const mime = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
    const rawName = decodeURIComponent(String(req.headers['x-filename'] || 'image'));
    const filename = safeFilename(rawName);
    if (!IMAGE_MIME_TYPES.has(mime)) {
      return jsonResponse(res, 400, { ok: false, message: '仅支持 PNG/JPG/WEBP/GIF 图片' });
    }

    const chunks = [];
    let total = 0;
    let aborted = false;
    req.on('data', (chunk) => {
      total += chunk.length;
      if (total > MAX_ATTACHMENT_SIZE) {
        aborted = true;
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on('end', () => {
      if (aborted) {
        return jsonResponse(res, 413, { ok: false, message: '图片大小不能超过 10MB' });
      }
      const buffer = Buffer.concat(chunks);
      if (buffer.length === 0) {
        return jsonResponse(res, 400, { ok: false, message: '图片内容为空' });
      }
      const id = crypto.randomUUID();
      const ext = extFromMime(mime) || path.extname(filename) || '';
      const dataPath = attachmentDataPath(id, ext);
      const now = new Date();
      const meta = {
        id,
        kind: 'image',
        filename,
        mime,
        size: buffer.length,
        createdAt: now.toISOString(),
        expiresAt: new Date(now.getTime() + ATTACHMENT_TTL_MS).toISOString(),
        path: dataPath,
      };
      try {
        fs.writeFileSync(dataPath, buffer);
        saveAttachmentMeta(meta);
        return jsonResponse(res, 200, {
          ok: true,
          attachment: {
            id,
            kind: 'image',
            filename,
            mime,
            size: buffer.length,
            createdAt: meta.createdAt,
            expiresAt: meta.expiresAt,
            storageState: 'available',
          },
        });
      } catch (err) {
        try { if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath); } catch {}
        try { if (fs.existsSync(attachmentMetaPath(id))) fs.unlinkSync(attachmentMetaPath(id)); } catch {}
        return jsonResponse(res, 500, { ok: false, message: `保存附件失败: ${err.message}` });
      }
    });
    req.on('error', () => {
      if (!res.headersSent) jsonResponse(res, 500, { ok: false, message: '上传过程中断' });
    });
    return;
  }

  if (url.pathname.startsWith('/api/attachments/')) {
    const token = extractBearerToken(req) || String(url.searchParams.get('token') || '');
    if (!token || !activeTokens.has(token)) {
      return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
    }
    const id = sanitizeId(url.pathname.split('/').pop() || '');
    if (!id) {
      return jsonResponse(res, 400, { ok: false, message: '缺少附件 ID' });
    }
    if (req.method === 'GET') {
      const meta = loadAttachmentMeta(id);
      const state = currentAttachmentState(meta);
      if (state !== 'available' || !meta?.path || !fs.existsSync(meta.path)) {
        return jsonResponse(res, 404, { ok: false, message: '附件不存在或已过期' });
      }
      try {
        const stat = fs.statSync(meta.path);
        res.writeHead(200, {
          'Content-Type': meta.mime || 'application/octet-stream',
          'Content-Length': stat.size,
          'Content-Disposition': contentDispositionInline(meta.filename || 'image'),
          'Cache-Control': 'private, no-store, max-age=0',
        });
        fs.createReadStream(meta.path).pipe(res);
      } catch (err) {
        return jsonResponse(res, 500, { ok: false, message: `读取附件失败: ${err.message}` });
      }
      return;
    }
    if (req.method === 'DELETE') {
      removeAttachmentById(id);
      return jsonResponse(res, 200, { ok: true });
    }
  }

  if (req.method === 'GET' && url.pathname === '/api/fs/list') {
    return handleFileSystemListApi(req, res, url);
  }

  if (req.method === 'GET' && url.pathname === '/api/fs/read') {
    return handleFileSystemReadApi(req, res, url);
  }

  if (req.method === 'GET' && url.pathname === '/api/fs/directories') {
    return handleDirectoryPickerListApi(req, res, url);
  }

  let filePath = path.join(PUBLIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname);
  filePath = path.resolve(filePath);

  if (!filePath.startsWith(PUBLIC_DIR)) {
    res.writeHead(403);
    return res.end('Forbidden');
  }

  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404);
      return res.end('Not Found');
    }
    const ext = path.extname(filePath);
    res.writeHead(200, {
      'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
      'Cache-Control': 'no-store, max-age=0',
    });
    res.end(data);
  });
});

// === WebSocket Server ===
const wss = new WebSocketServer({ noServer: true });
const WS_HEARTBEAT_INTERVAL_MS = 30000;
const WS_HEARTBEAT_MAX_MISSES = readPositiveIntEnv('CC_WEB_WS_HEARTBEAT_MAX_MISSES', 4, { min: 2, max: 20 });

server.on('upgrade', (req, socket, head) => {
  let pathname = '';
  try {
    pathname = new URL(req.url || '/', 'http://localhost').pathname;
  } catch {
    pathname = '';
  }

  if (pathname !== '/ws') {
    socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
    socket.destroy();
    return;
  }

  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit('connection', ws, req);
  });
});

wss.on('connection', (ws, req) => {
  ws._req = req;
  const clientIP = getClientIP(ws);

  // Check if IP is banned
  if (clientIP && bannedIPs.has(clientIP)) {
    plog('WARN', 'banned_ip_rejected', { ip: clientIP });
    wsSend(ws, { type: 'auth_result', success: false, banned: true });
    ws.close();
    return;
  }

  let authenticated = false;
  let authToken = null;
  const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
  const wsConnectTime = new Date().toISOString();
  ws.isAlive = true;
  ws._ccWebMissedPongs = 0;
  ws._ccWebId = wsId;
  markWsActivity(ws);
  plog('INFO', 'ws_connect', { wsId });

  ws.on('pong', () => {
    markWsActivity(ws);
  });

  ws.on('message', (raw) => {
    markWsActivity(ws);
    let msg;
    try {
      msg = JSON.parse(raw);
    } catch {
      return wsSend(ws, { type: 'error', message: 'Invalid JSON' });
    }

    if (msg.type === 'auth') {
      // Check ban before processing auth
      if (clientIP && bannedIPs.has(clientIP)) {
        wsSend(ws, { type: 'auth_result', success: false, banned: true });
        ws.close();
        return;
      }
      if (msg.password === PASSWORD || (msg.token && activeTokens.has(msg.token))) {
        authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex');
        activeTokens.add(authToken);
        authenticated = true;
        wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange });
        sendSessionList(ws);
      } else {
        const justBanned = recordAuthFailure(clientIP);
        wsSend(ws, { type: 'auth_result', success: false, banned: justBanned });
        if (justBanned) ws.close();
      }
      return;
    }

    if (!authenticated) {
      return wsSend(ws, { type: 'error', message: 'Not authenticated' });
    }

    switch (msg.type) {
      case 'message':
        if (msg.text && msg.text.trim().startsWith('/')) {
          handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);
        } else {
          handleMessage(ws, msg);
        }
        break;
      case 'composer_suggestions':
        handleComposerSuggestions(ws, msg);
        break;
      case 'abort':
        handleAbort(ws);
        break;
      case 'new_session':
        handleNewSession(ws, msg);
        break;
      case 'load_session':
        handleLoadSession(ws, msg);
        break;
      case 'resume_session':
        handleResumeSession(ws, msg);
        break;
      case 'load_history_page':
        handleLoadHistoryPage(ws, msg);
        break;
      case 'delete_session':
        handleDeleteSession(ws, msg.sessionId);
        break;
      case 'rename_session':
        handleRenameSession(ws, msg.sessionId, msg.title);
        break;
      case 'set_session_pinned':
        handleSetSessionPinned(ws, msg.sessionId, !!msg.pinned);
        break;
      case 'set_mode':
        handleSetMode(ws, msg.sessionId, msg.mode);
        break;
      case 'codex_app_user_input_response':
        handleCodexAppUserInputResponse(ws, msg);
        break;
      case 'ccweb_prompt_user_response':
        handleCcwebPromptUserResponse(ws, msg);
        break;
      case 'ccweb_prompt_user_dismiss':
        handleCcwebPromptUserDismiss(ws, msg);
        break;
      case 'codex_app_approval_response':
        handleCodexAppApprovalResponse(ws, msg);
        break;
      case 'ccweb_mcp_child_agent_close':
        handleCcwebMcpChildAgentClose(ws, msg);
        break;
      case 'list_sessions':
        sendSessionList(ws);
        break;
      case 'detach_view':
        handleDetachView(ws);
        break;
      case 'get_notify_config':
        wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() });
        break;
      case 'save_notify_config':
        handleSaveNotifyConfig(ws, msg.config);
        break;
      case 'test_notify':
        handleTestNotify(ws);
        break;
      case 'change_password':
        handleChangePassword(ws, msg, authToken);
        break;
      case 'get_model_config':
        wsSend(ws, { type: 'model_config', config: getModelConfigMasked() });
        break;
      case 'save_model_config':
        handleSaveModelConfig(ws, msg.config);
        break;
      case 'get_codex_config':
        wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
        break;
      case 'save_codex_config':
        handleSaveCodexConfig(ws, msg.config);
        break;
      case 'fetch_models':
        handleFetchModels(ws, msg);
        break;
      case 'check_update':
        handleCheckUpdate(ws);
        break;
      case 'list_native_sessions':
        handleListNativeSessions(ws);
        break;
      case 'import_native_session':
        handleImportNativeSession(ws, msg);
        break;
      case 'list_codex_sessions':
        handleListCodexSessions(ws, msg);
        break;
      case 'import_codex_session':
        handleImportCodexSession(ws, msg);
        break;
      case 'list_cwd_suggestions':
        handleListCwdSuggestions(ws);
        break;
      default:
        wsSend(ws, { type: 'error', message: `Unknown type: ${msg.type}` });
    }
  });

  ws.on('close', () => handleDisconnect(ws, wsId));
  ws.on('error', (err) => {
    plog('WARN', 'ws_error', { wsId, error: err.message });
    handleDisconnect(ws, wsId);
  });
});

// WebSocket 心跳：避免反向代理因空闲连接关闭 /ws。
const wsHeartbeatTimer = setInterval(() => {
  const now = Date.now();
  for (const client of wss.clients) {
    if (client.readyState !== 1) continue;
    if (client.isAlive === false) {
      const lastActivityAt = Number(client._ccWebLastActivityAt || 0);
      const hasRecentActivity = lastActivityAt > 0 && now - lastActivityAt < WS_HEARTBEAT_INTERVAL_MS * 2;
      if (hasRecentActivity) {
        client._ccWebMissedPongs = 0;
      } else {
        client._ccWebMissedPongs = Number(client._ccWebMissedPongs || 0) + 1;
      }
      if (client._ccWebMissedPongs >= WS_HEARTBEAT_MAX_MISSES) {
        plog('WARN', 'ws_heartbeat_terminate', {
          wsId: client._ccWebId || null,
          missedPongs: client._ccWebMissedPongs,
          lastActivityAgeMs: lastActivityAt ? now - lastActivityAt : null,
        });
        client.terminate();
        continue;
      }
    } else {
      client._ccWebMissedPongs = 0;
    }
    client.isAlive = false;
    try {
      client.ping();
    } catch (err) {
      plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message });
      client.terminate();
      continue;
    }
  }
}, WS_HEARTBEAT_INTERVAL_MS);
if (typeof wsHeartbeatTimer.unref === 'function') wsHeartbeatTimer.unref();

// === Notify Config Handlers ===
function handleSaveNotifyConfig(ws, newConfig) {
  if (!newConfig || !newConfig.provider) {
    return wsSend(ws, { type: 'error', message: '无效的通知配置' });
  }
  const current = loadNotifyConfig();
  // Merge: only update fields that are not masked (contain ****)
  const merged = { provider: newConfig.provider };
  // pushplus
  merged.pushplus = { token: (newConfig.pushplus?.token && !newConfig.pushplus.token.includes('****')) ? newConfig.pushplus.token : current.pushplus?.token || '' };
  // telegram
  merged.telegram = {
    botToken: (newConfig.telegram?.botToken && !newConfig.telegram.botToken.includes('****')) ? newConfig.telegram.botToken : current.telegram?.botToken || '',
    chatId: newConfig.telegram?.chatId !== undefined ? newConfig.telegram.chatId : current.telegram?.chatId || '',
  };
  // serverchan
  merged.serverchan = { sendKey: (newConfig.serverchan?.sendKey && !newConfig.serverchan.sendKey.includes('****')) ? newConfig.serverchan.sendKey : current.serverchan?.sendKey || '' };
  // feishu
  merged.feishu = { webhook: (newConfig.feishu?.webhook && !newConfig.feishu.webhook.includes('****')) ? newConfig.feishu.webhook : current.feishu?.webhook || '' };
  // qqbot
  merged.qqbot = { qmsgKey: (newConfig.qqbot?.qmsgKey && !newConfig.qqbot.qmsgKey.includes('****')) ? newConfig.qqbot.qmsgKey : current.qqbot?.qmsgKey || '' };
  // summary
  const ns = newConfig.summary || {};
  const cs = current.summary || {};
  merged.summary = {
    enabled: !!ns.enabled,
    trigger: ['background', 'always'].includes(ns.trigger) ? ns.trigger : (cs.trigger || 'background'),
    apiSource: ['claude', 'codex', 'custom'].includes(ns.apiSource) ? ns.apiSource : (cs.apiSource || 'claude'),
    apiBase: ns.apiBase !== undefined ? ns.apiBase : (cs.apiBase || ''),
    apiKey: (ns.apiKey && !ns.apiKey.includes('****')) ? ns.apiKey : (cs.apiKey || ''),
    model: ns.model !== undefined ? ns.model : (cs.model || ''),
  };

  saveNotifyConfig(merged);
  plog('INFO', 'notify_config_saved', { provider: merged.provider });
  wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() });
  wsSend(ws, { type: 'system_message', message: '通知配置已保存' });
}

function handleTestNotify(ws) {
  const config = loadNotifyConfig();
  if (!config.provider || config.provider === 'off') {
    return wsSend(ws, { type: 'notify_test_result', success: false, message: '通知已关闭，无法测试' });
  }
  sendNotification('CC-Web 测试通知', '这是一条测试消息，如果你收到了说明通知配置正确！').then((result) => {
    wsSend(ws, { type: 'notify_test_result', success: result.ok, message: result.ok ? '测试消息已发送，请检查是否收到' : `发送失败: ${result.error || result.body || '未知错误'}` });
  });
}

function handleChangePassword(ws, msg, currentToken) {
  const { currentPassword, newPassword } = msg;

  // Validate current password
  if (currentPassword !== PASSWORD) {
    return wsSend(ws, { type: 'password_changed', success: false, message: '当前密码错误' });
  }

  // Validate new password strength
  const strength = validatePasswordStrength(newPassword);
  if (!strength.valid) {
    return wsSend(ws, { type: 'password_changed', success: false, message: strength.message });
  }

  // Save new password
  authConfig = { password: newPassword, mustChange: false };
  saveAuthConfig(authConfig);
  PASSWORD = newPassword;
  plog('INFO', 'password_changed', {});

  // Clear all tokens (force all sessions to re-login)
  activeTokens.clear();

  // Generate new token for current connection
  const newToken = crypto.randomBytes(32).toString('hex');
  activeTokens.add(newToken);

  wsSend(ws, { type: 'password_changed', success: true, token: newToken, message: '密码修改成功' });
}

// === Model Config Handler ===
function handleSaveModelConfig(ws, newConfig) {
  if (!newConfig || !['local', 'custom'].includes(newConfig.mode)) {
    return wsSend(ws, { type: 'error', message: '无效的模型配置' });
  }
  const current = loadModelConfig();
  const merged = {
    mode: newConfig.mode,
    activeTemplate: newConfig.activeTemplate || '',
    templates: [],
  };

  // Merge templates: keep existing secrets if masked
  const newTemplates = Array.isArray(newConfig.templates) ? newConfig.templates : [];
  const oldTemplates = Array.isArray(current.templates) ? current.templates : [];
  for (const nt of newTemplates) {
    if (!nt.name || !nt.name.trim()) continue;
    const old = oldTemplates.find(t => t.name === nt.name);
    merged.templates.push({
      name: nt.name.trim(),
      apiKey: (nt.apiKey && !nt.apiKey.includes('****')) ? nt.apiKey : (old?.apiKey || ''),
      apiBase: nt.apiBase || '',
      defaultModel: nt.defaultModel || '',
      opusModel: nt.opusModel || '',
      sonnetModel: nt.sonnetModel || '',
      haikuModel: nt.haikuModel || '',
    });
  }

  saveModelConfig(merged);

  // Re-apply at runtime (mutate in-place to preserve agent-runtime closure reference)
  MODEL_MAP.opus = 'claude-opus-4-6';
  MODEL_MAP.sonnet = 'claude-sonnet-4-6';
  MODEL_MAP.haiku = 'claude-haiku-4-5-20251001';
  applyModelConfig();
  // custom mode: write to ~/.claude/settings.json immediately on save
  if (merged.mode === 'custom' && merged.activeTemplate) {
    const tpl = merged.templates.find(t => t.name === merged.activeTemplate);
    if (tpl) applyCustomTemplateToSettings(tpl);
  }

  // Remap ALL Claude sessions' model to current template values.
  // Build a reverse map: modelName → tier, from ALL templates (not just old/new).
  // This handles switches between any pair of templates regardless of name overlap.
  const modelToTier = new Map();
  for (const tpl of (merged.templates || [])) {
    if (tpl.opusModel) modelToTier.set(tpl.opusModel, 'opus');
    if (tpl.sonnetModel) modelToTier.set(tpl.sonnetModel, 'sonnet');
    if (tpl.haikuModel) modelToTier.set(tpl.haikuModel, 'haiku');
  }
  try {
    for (const file of fs.readdirSync(SESSIONS_DIR)) {
      if (!file.endsWith('.json')) continue;
      const sessionId = file.slice(0, -5);
      try {
        const session = loadSession(sessionId);
        if (!session?.model || session.agent === 'codex' || session.agent === 'codexapp') continue;
        const tier = modelToTier.get(session.model);
        if (tier && MODEL_MAP[tier] !== session.model) {
          session.model = MODEL_MAP[tier];
          session.updated = new Date().toISOString();
          saveSession(session);
        }
      } catch {}
    }
  } catch {}

  plog('INFO', 'model_config_saved', { mode: merged.mode, activeTemplate: merged.activeTemplate });
  wsSend(ws, { type: 'model_config', config: getModelConfigMasked() });
  wsSend(ws, { type: 'system_message', message: '模型配置已保存' });
}

function handleSaveCodexConfig(ws, newConfig) {
  if (!newConfig || typeof newConfig !== 'object') {
    return wsSend(ws, { type: 'error', message: '无效的 Codex 配置' });
  }
  const current = loadCodexConfig();
  const newProfiles = Array.isArray(newConfig.profiles) ? newConfig.profiles : [];
  const oldProfiles = Array.isArray(current.profiles) ? current.profiles : [];
  const mergedProfiles = [];
  for (const profile of newProfiles) {
    const name = String(profile?.name || '').trim();
    if (!name) continue;
    const old = oldProfiles.find((item) => item.name === name);
    const rawApiKey = String(profile?.apiKey || '');
    mergedProfiles.push({
      name,
      apiKey: rawApiKey && !rawApiKey.includes('****') ? rawApiKey : (old?.apiKey || ''),
      apiBase: String(profile?.apiBase || '').trim(),
    });
  }
  const requestedSearch = !!newConfig.enableSearch;
  const retry = normalizeCodexRetryConfig(newConfig.retry);
  const merged = {
    mode: newConfig.mode === 'custom' ? 'custom' : 'local',
    activeProfile: String(newConfig.activeProfile || '').trim(),
    profiles: mergedProfiles,
    enableSearch: false,
    supportsSearch: false,
    storedEnableSearch: requestedSearch,
    retry,
  };
  if (merged.mode === 'custom' && merged.profiles.length > 0 && !merged.profiles.some((profile) => profile.name === merged.activeProfile)) {
    merged.activeProfile = merged.profiles[0].name;
  }
  saveCodexConfig(merged);
  plog('INFO', 'codex_config_saved', {
    mode: merged.mode,
    activeProfile: merged.activeProfile || null,
    profileCount: merged.profiles.length,
    enableSearchRequested: requestedSearch,
    enableSearchEffective: false,
    retryMode: retry.mode,
    retryIntervalSeconds: retry.intervalSeconds,
    retryMaxAttempts: retry.mode === 'limited' ? retry.maxAttempts : null,
  });
  wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
  wsSend(ws, {
    type: 'system_message',
    message: requestedSearch
      ? 'Codex 配置已保存。当前 cc-web 的 Codex exec 路径暂未接入 Web Search，已自动忽略该开关。'
      : 'Codex 配置已保存',
  });
}

// === Fetch Upstream Models ===
function handleFetchModels(ws, msg) {
  const { apiBase, apiKey, modelsEndpoint } = msg;
  if (!apiBase || !apiKey) {
    return wsSend(ws, { type: 'fetch_models_result', success: false, message: '需要填写 API Base 和 API Key' });
  }
  // Build URL: apiBase + modelsEndpoint (default /v1/models)
  let base = apiBase.replace(/\/+$/, '');
  const endpoint = modelsEndpoint || '/v1/models';
  const fullUrl = base + endpoint;

  let parsed;
  try { parsed = new URL(fullUrl); } catch {
    return wsSend(ws, { type: 'fetch_models_result', success: false, message: '无效的 URL: ' + fullUrl });
  }

  // Resolve real apiKey (if masked, look up saved config by template name or apiBase)
  let realKey = apiKey;
  if (apiKey.includes('****')) {
    const config = loadModelConfig();
    const saved = (config.templates || []);
    // Match by template name first, then by apiBase
    const tpl = (msg.templateName && saved.find(t => t.name === msg.templateName))
      || saved.find(t => t.apiBase && t.apiBase.replace(/\/+$/, '') === base)
      || null;
    if (tpl && tpl.apiKey && !tpl.apiKey.includes('****')) realKey = tpl.apiKey;
    else return wsSend(ws, { type: 'fetch_models_result', success: false, message: 'API Key 已脱敏，请重新输入完整 Key' });
  }

  const mod = parsed.protocol === 'https:' ? require('https') : require('http');
  const reqOptions = {
    method: 'GET',
    headers: { 'Authorization': `Bearer ${realKey}` },
    timeout: 15000,
  };

  const req = mod.request(parsed, reqOptions, (res) => {
    let body = '';
    res.on('data', (chunk) => { body += chunk; });
    res.on('end', () => {
      if (res.statusCode !== 200) {
        return wsSend(ws, { type: 'fetch_models_result', success: false, message: `HTTP ${res.statusCode}: ${body.slice(0, 200)}` });
      }
      try {
        const json = JSON.parse(body);
        const models = (json.data || json.models || []).map(m => typeof m === 'string' ? m : m.id || m.name || '').filter(Boolean).sort();
        wsSend(ws, { type: 'fetch_models_result', success: true, models });
      } catch (e) {
        wsSend(ws, { type: 'fetch_models_result', success: false, message: '解析响应失败: ' + e.message });
      }
    });
  });

  req.on('error', (e) => {
    wsSend(ws, { type: 'fetch_models_result', success: false, message: '请求失败: ' + e.message });
  });
  req.on('timeout', () => {
    req.destroy();
    wsSend(ws, { type: 'fetch_models_result', success: false, message: '请求超时 (15s)' });
  });
  req.end();
}

function parseCodexGoalCommand(text) {
  const match = /^\s*\/goal(?:\s+([\s\S]*))?$/i.exec(String(text || ''));
  if (!match) return null;
  const rest = String(match[1] || '').trim();
  if (!rest) return { action: 'show' };
  const lower = rest.toLowerCase();
  if (lower === 'clear') return { action: 'clear' };
  if (lower === 'pause') return { action: 'pause' };
  if (lower === 'resume') return { action: 'resume' };
  if ([...rest].length > MAX_CODEX_GOAL_OBJECTIVE_CHARS) {
    return {
      action: 'set',
      error: `Goal 目标最多 ${MAX_CODEX_GOAL_OBJECTIVE_CHARS} 个字符。`,
    };
  }
  return { action: 'set', objective: rest };
}

function goalNumber(value) {
  if (typeof value === 'number' && Number.isFinite(value)) return value;
  if (typeof value === 'string' && value.trim()) {
    const parsed = Number(value);
    if (Number.isFinite(parsed)) return parsed;
  }
  return null;
}

function goalString(value) {
  if (value === null || value === undefined) return '';
  return String(value).trim();
}

function normalizeCodexThreadGoal(goal, fallbackThreadId = '') {
  if (!goal || typeof goal !== 'object') return null;
  const threadId = goalString(goal.threadId || goal.thread_id || fallbackThreadId);
  const objective = goalString(goal.objective);
  const rawStatus = goalString(goal.status) || 'active';
  return {
    ...goal,
    threadId,
    objective,
    status: rawStatus,
    tokenBudget: goalNumber(goal.tokenBudget ?? goal.token_budget),
    tokensUsed: goalNumber(goal.tokensUsed ?? goal.tokens_used) || 0,
    timeUsedSeconds: goalNumber(goal.timeUsedSeconds ?? goal.time_used_seconds) || 0,
    createdAt: goalNumber(goal.createdAt ?? goal.created_at) || 0,
    updatedAt: goalNumber(goal.updatedAt ?? goal.updated_at) || 0,
  };
}

function formatCodexGoalStatus(status) {
  const normalized = String(status || 'active').trim();
  const compact = normalized.toLowerCase().replace(/[\s_-]/g, '');
  if (compact === 'budgetlimited') return 'budget limited';
  if (compact === 'complete' || compact === 'completed') return 'complete';
  if (compact === 'paused') return 'paused';
  if (compact === 'active') return 'active';
  if (compact === 'blocked') return 'blocked';
  return normalized || 'updated';
}

function formatCodexGoalUsage(goal) {
  const normalized = normalizeCodexThreadGoal(goal);
  if (!normalized) return 'Goal updated';
  const parts = [`Goal ${formatCodexGoalStatus(normalized.status)}`];
  if (normalized.tokenBudget !== null) {
    parts.push(`${normalized.tokensUsed}/${normalized.tokenBudget} tokens`);
  } else if (normalized.tokensUsed > 0) {
    parts.push(`${normalized.tokensUsed} tokens`);
  }
  const objective = normalized.objective ? `\n目标: ${normalized.objective}` : '';
  return `${parts.join(' · ')}${objective}`;
}

async function ensureCodexAppGoalThread(session) {
  const clientResult = getCodexAppClient();
  if (clientResult.error) throw new Error(clientResult.error);
  const client = clientResult.client;
  await client.start();

  let threadId = getRuntimeSessionId(session);
  const threadParams = codexAppThreadParams(session);
  if (threadId) {
    const resumed = await client.request('thread/resume', { ...threadParams, threadId }, 60000);
    threadId = resumed?.thread?.id || threadId;
  } else {
    const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
    threadId = started?.thread?.id || null;
  }
  if (!threadId) throw new Error('Codex app-server 未返回 threadId。');

  setRuntimeSessionId(session, threadId);
  session.updated = new Date().toISOString();
  saveSession(session);
  return { client, threadId };
}

function isCodexGoalUnsupportedError(err) {
  const detail = `${err?.code || ''} ${err?.message || err || ''}`;
  return err?.code === -32601
    || /goals feature is disabled|unsupported remote app-server request|method not found|unknown mock method/i.test(detail);
}

function isCurrentCodexAppGoalCommand(sessionId, entry) {
  return !!entry && activeCodexAppGoalCommands.get(sessionId)?.id === entry.id && !entry.cancelled;
}

function finishCodexAppGoalCommand(sessionId, entry) {
  if (!entry || activeCodexAppGoalCommands.get(sessionId)?.id !== entry.id) return false;
  activeCodexAppGoalCommands.delete(sessionId);
  broadcastSessionList();
  return true;
}

function cancelCodexAppGoalCommand(sessionId, ws = null) {
  const entry = activeCodexAppGoalCommands.get(sessionId);
  if (!entry) return false;
  entry.cancelled = true;
  activeCodexAppGoalCommands.delete(sessionId);
  const targetWs = ws || entry.ws || null;
  if (targetWs) {
    wsSend(targetWs, {
      type: 'system_message',
      sessionId,
      message: '已取消 Goal 同步状态。底层 Codex app-server 请求可能仍会自然返回，结果将被忽略。',
    });
  }
  broadcastSessionList();
  return true;
}

async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
  const command = parseCodexGoalCommand(text);
  if (!command) return;
  const sendGoalResponse = (targetWs, payload, options = {}) => {
    wsSend(targetWs, attachClientRequestId({
      ...payload,
      ...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
    }, source));
  };
  const sendGoalSystemMessage = (targetWs, message, extra = {}, options = {}) => {
    sendGoalResponse(targetWs, { type: 'system_message', message, ...extra }, options);
  };

  if (!session) {
    sendGoalSystemMessage(ws, '请先进入一个 Codex App 会话后再执行 /goal。', {}, { preserveComposerDraft: true });
    return;
  }
  if (!isCodexAppSession(session)) {
    sendGoalSystemMessage(ws, '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。', { sessionId: session.id }, { preserveComposerDraft: true });
    return;
  }
  if (command.error) {
    sendGoalSystemMessage(ws, command.error, { sessionId: session.id }, { preserveComposerDraft: true });
    return;
  }
  if (activeCodexAppGoalCommands.has(session.id)) {
    sendGoalSystemMessage(ws, 'Codex App Goal 正在同步，请稍候。', { sessionId: session.id }, { preserveComposerDraft: true });
    return;
  }

  const activeGoalCommand = {
    id: crypto.randomUUID(),
    ws,
    action: command.action,
    cancelled: false,
    startedAt: new Date().toISOString(),
  };
  activeCodexAppGoalCommands.set(session.id, activeGoalCommand);
  sendGoalSystemMessage(ws, '正在同步 Goal...', { sessionId: session.id });
  broadcastSessionList();

  try {
    const { client, threadId } = await ensureCodexAppGoalThread(session);
    if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
    if (command.action === 'show') {
      const response = await client.request('thread/goal/get', { threadId }, 30000);
      if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
      const goal = normalizeCodexThreadGoal(response?.goal, threadId);
      const targetWs = activeGoalCommand.ws || ws;
      sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', { sessionId: session.id });
      sendSessionList(targetWs);
      return;
    }

    if (command.action === 'clear') {
      const response = await client.request('thread/goal/clear', { threadId }, 30000);
      if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
      const targetWs = activeGoalCommand.ws || ws;
      sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', { sessionId: session.id });
      sendSessionList(targetWs);
      return;
    }

    const response = await client.request('thread/goal/set', {
      threadId,
      ...(command.action === 'set' ? { objective: command.objective } : {}),
      status: command.action === 'pause' ? 'paused' : 'active',
    }, 30000);
    if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
    const goal = normalizeCodexThreadGoal(response?.goal, threadId);
    const targetWs = activeGoalCommand.ws || ws;
    sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : 'Goal updated', { sessionId: session.id });
    sendSessionList(targetWs);
  } catch (err) {
    const message = isCodexGoalUnsupportedError(err)
      ? '当前 Codex app-server 不支持 /goal，请升级 Codex 或启用 goals feature。'
      : `Goal failed: ${err?.message || err}`;
    if (isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) {
      sendGoalSystemMessage(activeGoalCommand.ws || ws, message, { sessionId: session.id }, { preserveComposerDraft: true });
    }
  } finally {
    finishCodexAppGoalCommand(session.id, activeGoalCommand);
  }
}

// === Slash Command Handler ===
function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
  const parts = text.split(/\s+/);
  const cmd = parts[0].toLowerCase();
  let session = sessionId ? loadSession(sessionId) : null;
  const agent = session ? getSessionAgent(session) : normalizeAgent(fallbackAgent);
  const codexLikeAgent = agent === 'codex' || agent === 'codexapp';
  const responseSessionId = session?.id || sessionId || undefined;
  const sendSlashResponse = (payload, options = {}) => {
    const base = {
      ...(responseSessionId && payload.sessionId === undefined ? { sessionId: responseSessionId } : {}),
      ...payload,
      ...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
    };
    wsSend(ws, attachClientRequestId(base, source));
  };
  const sendSlashSystemMessage = (message, extra = {}, options = {}) => {
    sendSlashResponse({ type: 'system_message', message, ...extra }, options);
  };

  if (session && isCodexAppSession(session) && activeCodexAppTurns.has(sessionId)) {
    sendSlashSystemMessage('Codex App 运行中暂不支持 slash 指令，请等待完成或点击停止。', {}, { preserveComposerDraft: true });
    return;
  }
  if (session && isCodexAppSession(session) && activeCodexAppGoalCommands.has(sessionId)) {
    sendSlashSystemMessage('Codex App Goal 正在同步，请稍候。', { sessionId }, { preserveComposerDraft: true });
    return;
  }

  switch (cmd) {
    case '/clear': {
      if (session) {
        cancelCodexCapacityRetry(sessionId);
        if (activeProcesses.has(sessionId)) {
          const entry = activeProcesses.get(sessionId);
          killProcess(entry.pid);
          if (entry.tailer) entry.tailer.stop();
          activeProcesses.delete(sessionId);
          cleanRunDir(sessionId);
        }
        session.messages = [];
        clearRuntimeSessionId(session);
        session.updated = new Date().toISOString();
        saveSession(session);
        sendSlashResponse({
          type: 'session_info',
          sessionId: session.id,
          messages: [],
          title: session.title,
          pinnedAt: session.pinnedAt || null,
          ...publicTitleMetadata(session),
          mode: session.permissionMode || 'yolo',
          model: sessionModelLabel(session),
          agent: getSessionAgent(session),
          cwd: session.cwd || null,
          totalCost: session.totalCost || 0,
          totalUsage: session.totalUsage || null,
        });
      }
      sendSlashSystemMessage('会话已清除，上下文已重置。');
      break;
    }

    case '/model': {
      const modelInput = parts[1];
      if (codexLikeAgent) {
        if (!modelInput) {
          const current = session?.model || getDefaultCodexModel();
          sendSlashSystemMessage(`当前 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型: ${current}\n用法: /model <模型名>`);
        } else {
          if (session) {
            session.model = modelInput;
            session.updated = new Date().toISOString();
            saveSession(session);
          }
          wsSend(ws, { type: 'model_changed', model: modelInput });
          sendSlashSystemMessage(`${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型已切换为: ${modelInput}`);
        }
      } else if (!modelInput) {
        const current = session?.model ? modelShortName(session.model) || session.model : 'opus (默认)';
        sendSlashSystemMessage(`当前模型: ${current}\n可选: opus, sonnet, haiku`);
      } else {
        const modelKey = modelInput.toLowerCase();
        if (!MODEL_MAP[modelKey]) {
          sendSlashSystemMessage(`无效模型: ${modelInput}\n可选: opus, sonnet, haiku`, {}, { preserveComposerDraft: true });
        } else {
          const model = MODEL_MAP[modelKey];
          if (session) {
            session.model = model;
            session.updated = new Date().toISOString();
            saveSession(session);
          }
          wsSend(ws, { type: 'model_changed', model: modelKey });
          sendSlashSystemMessage(`模型已切换为: ${modelKey}`);
        }
      }
      break;
    }

    case '/cost': {
      if (codexLikeAgent) {
        const usage = session?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
        sendSlashResponse({
          type: 'system_message',
          message: `当前会话累计 Token: 输入 ${usage.inputTokens}，缓存 ${usage.cachedInputTokens}，输出 ${usage.outputTokens}`,
        });
      } else {
        const cost = session?.totalCost || 0;
        sendSlashSystemMessage(`当前会话累计费用: $${cost.toFixed(4)}`);
      }
      break;
    }

    case '/goal': {
      handleCodexAppGoalSlashCommand(ws, text, session, source).catch((err) => {
        sendSlashResponse({
          type: 'system_message',
          sessionId: session?.id,
          message: `Goal failed: ${err?.message || err}`,
        }, { preserveComposerDraft: true });
      });
      break;
    }

    case '/compact': {
      if (!sessionId || !session) {
        sendSlashSystemMessage('当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。', {}, { preserveComposerDraft: true });
        break;
      }
      if (isSessionRunning(sessionId)) {
        sendSlashSystemMessage('当前会话正在处理中，请先等待完成或点击停止，再执行 /compact。', {}, { preserveComposerDraft: true });
        break;
      }
      if (isCodexAppSession(session)) {
        sendSlashSystemMessage('Codex App 模式暂不支持 /compact，请切换到旧 Codex 模式或等待后续接入。', {}, { preserveComposerDraft: true });
        break;
      }
      const runtimeId = getRuntimeSessionId(session);
      if (!runtimeId) {
        sendSlashResponse({
          type: 'system_message',
          message: agent === 'codex'
            ? '当前会话尚未建立 Codex 上下文，暂时无需压缩。'
            : '当前会话尚未建立 Claude 上下文，暂时无需压缩。',
        }, { preserveComposerDraft: true });
        break;
      }

      sendSlashSystemMessage(compactStartMessage(agent));
      pendingSlashCommands.set(session.id, { kind: 'compact' });
      handleMessage(ws, {
        text: '/compact',
        sessionId: session.id,
        mode: session.permissionMode || 'yolo',
        requestId: source?.requestId,
        preserveComposerDraft: true,
      }, { hideInHistory: true });
      break;
    }

    case '/init': {
      if (!sessionId || !session) {
        sendSlashSystemMessage('请先进入一个会话后再执行 /init。', {}, { preserveComposerDraft: true });
        break;
      }
      if (isSessionRunning(sessionId)) {
        sendSlashSystemMessage('当前会话正在处理中，请先等待完成或点击停止。', {}, { preserveComposerDraft: true });
        break;
      }
      sendSlashSystemMessage(initStartMessage(agent));
      pendingSlashCommands.set(session.id, { kind: 'init' });
      handleMessage(ws, {
        text: codexLikeAgent ? buildCodexInitPrompt(session.cwd) : '/init',
        sessionId: session.id,
        mode: session.permissionMode || 'yolo',
        requestId: source?.requestId,
        preserveComposerDraft: true,
      }, { hideInHistory: true });
      break;
    }

    case '/mode': {
      const modeInput = parts[1];
      const VALID_MODES = ['default', 'plan', 'yolo'];
      const MODE_DESC = { default: '默认（需权限审批，受限操作）', plan: 'Plan（需确认计划后执行）', yolo: 'YOLO（跳过所有权限检查）' };
      if (!modeInput) {
        const cur = session?.permissionMode || 'yolo';
        sendSlashSystemMessage(`当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo`);
      } else if (VALID_MODES.includes(modeInput.toLowerCase())) {
        const mode = modeInput.toLowerCase();
        if (session) {
          session.permissionMode = mode;
          // Mode switching should not reset runtime context (Claude/Codex both resume).
          session.updated = new Date().toISOString();
          saveSession(session);
        }
        sendSlashSystemMessage(`权限模式已切换为: ${MODE_DESC[mode]}`);
        wsSend(ws, { type: 'mode_changed', mode });
      } else {
        sendSlashSystemMessage(`无效模式: ${modeInput}\n可选: default, plan, yolo`, {}, { preserveComposerDraft: true });
      }
      break;
    }

    case '/help': {
      const base = '可用指令:\n' +
        '/clear — 清除当前会话（含上下文）\n' +
        '/mode [模式] — 查看/切换权限模式（default, plan, yolo）\n' +
        '/cost — 查看当前会话累计统计\n' +
        '/help — 显示本帮助';
      sendSlashResponse({
        type: 'system_message',
        message: codexLikeAgent
          ? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型（自由输入）${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标；支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
          : base + '\n/model [名称] — 查看/切换模型（opus, sonnet, haiku）\n/compact — 执行 Claude 原生上下文压缩（保留压缩计划并可自动续跑）\n/init — 分析项目并生成/更新 CLAUDE.md',
      });
      break;
    }

    default:
      sendSlashSystemMessage(`未知指令: ${cmd}\n输入 /help 查看可用指令`, {}, { preserveComposerDraft: true });
  }
}

// === Session Handlers ===
function normalizeConversationTitle(title, fallback = 'New Chat') {
  const normalized = String(title || '').replace(/\s+/g, ' ').trim();
  if (!normalized) return fallback;
  return truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...');
}

function normalizeSetTitleInput(title) {
  const normalized = String(title || '').replace(/\s+/g, ' ').trim();
  return normalized ? truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...') : '';
}

function sessionCreatedFromKind(session) {
  return String(session?.createdFrom?.kind || '').trim().toLowerCase();
}

function isTitleLockedByUser(session) {
  return String(session?.titleSource || '').trim().toLowerCase() === 'manual';
}

function publicTitleMetadata(session) {
  return {
    titleSource: String(session?.titleSource || '').trim().toLowerCase() || null,
    createdFromKind: sessionCreatedFromKind(session) || null,
  };
}

function setCurrentConversationTitle(args = {}, sourceSessionId = '') {
  const sourceId = sanitizeId(sourceSessionId || '');
  if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');

  const session = loadSession(sourceId);
  if (!session) return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });

  const previousTitle = session.title || 'Untitled';
  const normalizedTitle = normalizeSetTitleInput(args.title);
  if (!normalizedTitle) {
    return mcpToolError('invalid_title', 'title 不能为空。', {
      changed: false,
      ignored: false,
      title: previousTitle,
      previousTitle,
      lockedByUser: isTitleLockedByUser(session),
    });
  }

  if (isTitleLockedByUser(session)) {
    return {
      ok: true,
      changed: false,
      ignored: true,
      reason: 'ignored because title is manually locked',
      title: previousTitle,
      previousTitle,
      lockedByUser: true,
    };
  }

  const changed = previousTitle !== normalizedTitle;
  session.title = normalizedTitle;
  session.titleSource = 'llm';
  saveSession(session);
  sendSessionEventToViewers(session.id, {
    type: 'session_renamed',
    sessionId: session.id,
    title: session.title,
    ...publicTitleMetadata(session),
  });
  broadcastSessionList();

  return {
    ok: true,
    changed,
    ignored: false,
    reason: changed ? 'title_updated' : 'unchanged',
    title: session.title,
    previousTitle,
    lockedByUser: false,
  };
}

function resolveConversationAgent(rawAgent, fallbackAgent = 'claude', options = {}) {
  const value = String(rawAgent || '').trim().toLowerCase();
  if (value) {
    if (VALID_AGENTS.has(value)) return { ok: true, agent: value };
    if (options.strict) {
      return mcpToolError('invalid_agent', 'Agent 必须是 claude、codex 或 codexapp。', { agent: value });
    }
  }
  return { ok: true, agent: VALID_AGENTS.has(fallbackAgent) ? fallbackAgent : 'claude' };
}

function resolveConversationMode(rawMode, fallbackMode = 'yolo', options = {}) {
  const value = String(rawMode || '').trim().toLowerCase();
  if (value) {
    if (VALID_PERMISSION_MODES.has(value)) return { ok: true, mode: value };
    if (options.strict) {
      return mcpToolError('invalid_mode', 'mode 必须是 default、plan 或 yolo。', { mode: value });
    }
  }
  return { ok: true, mode: VALID_PERMISSION_MODES.has(fallbackMode) ? fallbackMode : 'yolo' };
}

function resolveConversationModel(rawModel, agent, sourceSession = null) {
  const value = String(rawModel || '').trim();
  if (value) {
    if (agent === 'codex' || agent === 'codexapp') return value;
    return MODEL_MAP[value.toLowerCase()] || value;
  }
  if (sourceSession?.model) return sourceSession.model;
  return agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : MODEL_MAP.opus;
}

function buildBranchSessionTitle(sourceSession) {
  const sourceTitle = normalizeConversationTitle(sourceSession?.title, '新会话');
  return normalizeConversationTitle(`${sourceTitle} 的分支`);
}

function resolveBranchSource(args = {}) {
  const sourceSessionId = sanitizeId(args.branchSourceSessionId || args.sourceSessionId || '');
  if (!sourceSessionId) return { ok: true, sourceSession: null };

  const sourceSession = loadSession(sourceSessionId);
  if (!sourceSession) {
    return mcpToolError('branch_source_not_found', '来源会话不存在，无法创建分支。', { sourceSessionId });
  }

  const sourceMessages = Array.isArray(sourceSession.messages) ? sourceSession.messages : [];
  if (sourceMessages.length === 0) {
    return mcpToolError('branch_source_empty', '来源会话没有可复制的上下文。', { sourceSessionId });
  }

  const parsedIndex = Number.parseInt(String(args.branchMessageIndex ?? ''), 10);
  const sourceMessageIndex = Number.isFinite(parsedIndex)
    ? Math.max(0, Math.min(sourceMessages.length - 1, parsedIndex))
    : sourceMessages.length - 1;
  const createdAt = new Date().toISOString();

  return {
    ok: true,
    sourceSession,
    initialMessages: sourceMessages.slice(0, sourceMessageIndex + 1),
    createdFrom: {
      kind: 'branch',
      sourceSessionId: sourceSession.id,
      sourceTitle: sourceSession.title || 'Untitled',
      sourceMessageIndex,
      createdAt,
    },
    defaultTitle: buildBranchSessionTitle(sourceSession),
  };
}

function createPersistentConversationSession(args = {}, options = {}) {
  const sourceSession = options.sourceSession || null;
  const strict = !!options.strict;
  const explicitCwd = typeof args.cwd === 'string' && args.cwd.trim();
  const fallbackAgent = getSessionAgent(sourceSession) || options.defaultAgent || 'claude';
  const fallbackMode = options.inheritSourceMode === false
    ? (options.defaultMode || 'yolo')
    : (sourceSession?.permissionMode || options.defaultMode || 'yolo');
  const agentResult = resolveConversationAgent(args.agent, fallbackAgent, { strict });
  if (!agentResult.ok) return agentResult;
  const modeResult = resolveConversationMode(args.mode, fallbackMode, { strict });
  if (!modeResult.ok) return modeResult;

  let cwdCandidate = explicitCwd ? String(args.cwd).trim() : '';
  if (explicitCwd && options.requireAbsoluteCwd && !path.isAbsolute(cwdCandidate)) {
    return mcpToolError('create_conversation_cwd_relative', 'cwd 必须是已存在的绝对路径。', { cwd: cwdCandidate });
  }
  if (!cwdCandidate && sourceSession?.cwd) {
    cwdCandidate = normalizeExistingDirPath(sourceSession.cwd) || '';
  }

  const cwdResult = resolveSessionCwd(cwdCandidate || null, {
    createMissing: !!(options.allowCreateCwd && args.createCwd),
  });
  if (!cwdResult.ok) {
    return mcpToolError(cwdResult.code, cwdResult.message, { cwd: cwdResult.resolvedPath || cwdCandidate || null });
  }

  const now = new Date().toISOString();
  const agent = agentResult.agent;
  const initialMessages = Array.isArray(options.initialMessages)
    ? sanitizeMessagesForPersist(options.initialMessages)
    : [];
  const session = {
    id: crypto.randomUUID(),
    title: normalizeConversationTitle(args.title),
    titleSource: 'system',
    created: now,
    updated: now,
    pinnedAt: null,
    agent,
    claudeSessionId: null,
    codexThreadId: null,
    codexAppThreadId: null,
    // Codex/Codex App 默认读取 ~/.codex/config.toml；分支会话优先继承来源模型。
    model: resolveConversationModel(
      args.model,
      agent,
      options.inheritSourceModel === true ? sourceSession : null,
    ),
    permissionMode: modeResult.mode,
    totalCost: 0,
    totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
    messages: initialMessages,
    cwd: cwdResult.path || getDefaultSessionCwd(),
  };
  if (options.createdFrom) session.createdFrom = options.createdFrom;

  if (!saveSession(session)) {
    return mcpToolError('session_save_failed', '创建会话失败，请检查 sessions 目录写入权限。');
  }
  return { ok: true, session };
}

function buildSessionInfoPayload(session) {
  const waitState = crossConversationWaitState(session.id);
  const messages = sanitizeMessagesForTransport(session.messages || []);
  return {
    type: 'session_info',
    sessionId: session.id,
    messages,
    title: session.title,
    pinnedAt: session.pinnedAt || null,
    ...publicTitleMetadata(session),
    mode: session.permissionMode || 'yolo',
    model: sessionModelLabel(session),
    agent: getSessionAgent(session),
    cwd: session.cwd,
    totalCost: session.totalCost || 0,
    totalUsage: session.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
    updated: session.updated,
    historyTotal: messages.length,
    historyBaseIndex: 0,
    hasUnread: false,
    historyPending: false,
    isRunning: false,
    waitingOnChildren: waitState.waitingOnChildren,
    pendingReplyCount: waitState.pendingReplyCount,
    readyReplyCount: waitState.readyReplyCount,
    waitingReplyCount: waitState.waitingReplyCount,
    failedReplyCount: waitState.failedReplyCount,
    pendingReplies: waitState.pendingReplies,
  };
}

function attachClientRequestId(payload, source = {}) {
  const requestId = String(source?.requestId || '').trim();
  return requestId ? { ...payload, requestId } : payload;
}

function handleNewSession(ws, msg) {
  const request = msg || {};
  const branch = resolveBranchSource(request);
  if (!branch.ok) {
    return wsSend(ws, {
      type: 'error',
      code: branch.code,
      message: branch.message,
    });
  }

  const createArgs = { ...request };
  if (branch.defaultTitle && !String(createArgs.title || '').trim()) {
    createArgs.title = branch.defaultTitle;
  }

  const result = createPersistentConversationSession(createArgs, {
    defaultAgent: normalizeAgent(request.agent),
    defaultMode: 'yolo',
    allowCreateCwd: true,
    sourceSession: branch.sourceSession || null,
    initialMessages: branch.initialMessages || null,
    createdFrom: branch.createdFrom || null,
    inheritSourceModel: !!branch.sourceSession,
  });
  if (!result.ok) {
    return wsSend(ws, {
      type: 'error',
      code: result.code,
      cwd: result.cwd || null,
      message: result.message,
    });
  }
  const { session } = result;
  detachWsFromActiveRuntimes(ws);
  wsSessionMap.set(ws, session.id);
  wsSend(ws, attachClientRequestId(buildSessionInfoPayload(session), msg));
  sendSessionList(ws);
}

function handleLoadHistoryPage(ws, msg = {}) {
  const sessionId = sanitizeId(msg.sessionId || '');
  const session = loadSession(sessionId);
  if (!session) {
    return wsSend(ws, attachClientRequestId({
      type: 'error',
      code: 'session_not_found',
      sessionId,
      message: 'Session not found',
    }, msg));
  }
  const list = sanitizeMessagesForTransport(session.messages);
  const requestedBefore = Number.parseInt(String(msg.before || ''), 10);
  const before = Number.isFinite(requestedBefore)
    ? Math.max(0, Math.min(list.length, requestedBefore))
    : Math.max(0, list.length - INITIAL_HISTORY_COUNT);
  const end = before;
  const start = Math.max(0, end - HISTORY_CHUNK_SIZE);
  wsSend(ws, {
    type: 'session_history_chunk',
    sessionId: session.id,
    messages: list.slice(start, end),
    remaining: 0,
    historyCursor: start,
    historyBaseIndex: start,
    historyTruncated: start > 0,
  });
}

function attachActiveRuntimeToWs(ws, sessionId, source = {}) {
  if (activeProcesses.has(sessionId)) {
    const entry = activeProcesses.get(sessionId);
    entry.ws = ws;
    entry.wsDisconnectTime = null; // clear disconnect marker
    plog('INFO', 'ws_resume_attach', {
      sessionId: sessionId.slice(0, 8),
      pid: entry.pid,
      responseLen: (entry.fullText || '').length,
    });
    wsSend(ws, attachClientRequestId({
      type: 'resume_generating',
      sessionId,
      text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
      toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
    }, source));
    return true;
  }

  if (activeCodexAppTurns.has(sessionId)) {
    const entry = activeCodexAppTurns.get(sessionId);
    entry.ws = ws;
    entry.wsDisconnectTime = null;
    plog('INFO', 'codex_app_ws_resume_attach', {
      sessionId: sessionId.slice(0, 8),
      threadId: entry.threadId || null,
      turnId: entry.turnId || null,
      responseLen: (entry.fullText || '').length,
    });
    wsSend(ws, attachClientRequestId({
      type: 'resume_generating',
      sessionId,
      text: truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS),
      toolCalls: sanitizeToolCallsForPersist(entry.toolCalls || []),
    }, source));
    return true;
  }

  if (activeCodexAppGoalCommands.has(sessionId)) {
    const entry = activeCodexAppGoalCommands.get(sessionId);
    entry.ws = ws;
    entry.wsDisconnectTime = null;
    wsSend(ws, attachClientRequestId({
      type: 'system_message',
      sessionId,
      message: '正在同步 Goal...',
    }, source));
    return true;
  }

  return false;
}

function handleResumeSession(ws, msg = {}) {
  const sessionId = sanitizeId(msg.sessionId || '');
  const session = loadSession(sessionId);
  if (!session) {
    return wsSend(ws, attachClientRequestId({
      type: 'error',
      code: 'session_not_found',
      sessionId,
      message: 'Session not found',
    }, msg));
  }

  detachWsFromActiveRuntimes(ws);
  wsSessionMap.set(ws, sessionId);
  const attached = attachActiveRuntimeToWs(ws, sessionId, msg);
  wsSend(ws, attachClientRequestId({
    type: 'resume_session_result',
    sessionId,
    isRunning: isSessionRunning(sessionId),
    attached,
  }, msg));
}

function handleLoadSession(ws, msg) {
  const sessionId = sanitizeId(typeof msg === 'string' ? msg : msg?.sessionId);
  reconcilePendingCrossConversationReplies();
  const session = loadSession(sessionId);
  if (!session) {
    return wsSend(ws, attachClientRequestId({
      type: 'error',
      code: 'session_not_found',
      sessionId,
      message: 'Session not found',
    }, msg));
  }
  flushPendingCrossConversationReplies(sessionId);
  const refreshedSession = loadSession(sessionId) || session;
  if (getSessionAgent(refreshedSession) === 'claude' && !refreshedSession.cwd && refreshedSession.claudeSessionId) {
    const localMeta = resolveClaudeSessionLocalMeta(refreshedSession.claudeSessionId);
    if (localMeta?.cwd) {
      refreshedSession.cwd = localMeta.cwd;
      if (!refreshedSession.importedFrom && localMeta.projectDir) refreshedSession.importedFrom = localMeta.projectDir;
      saveSession(refreshedSession);
    }
  }
  const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(refreshedSession.messages);
  const effectiveCwd = refreshedSession.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null;
  const waitState = crossConversationWaitState(sessionId);

  // Detach ws from any previous session's process
  detachWsFromActiveRuntimes(ws);

  wsSessionMap.set(ws, sessionId);

  // Read and clear unread flag
  const hadUnread = !!refreshedSession.hasUnread;
  if (refreshedSession.hasUnread) {
    refreshedSession.hasUnread = false;
    saveSession(refreshedSession);
  }

  wsSend(ws, attachClientRequestId({
    type: 'session_info',
    sessionId: refreshedSession.id,
    messages: recentMessages,
    title: refreshedSession.title,
    pinnedAt: refreshedSession.pinnedAt || null,
    ...publicTitleMetadata(refreshedSession),
    mode: refreshedSession.permissionMode || 'yolo',
    model: sessionModelLabel(refreshedSession),
    agent: getSessionAgent(refreshedSession),
    hasUnread: hadUnread,
    cwd: effectiveCwd,
    totalCost: session.totalCost || 0,
    totalUsage: session.totalUsage || null,
    historyTotal: refreshedSession.messages.length,
    historyBuffered,
    historyCursor: historyRemaining,
    historyBaseIndex: Math.max(0, refreshedSession.messages.length - recentMessages.length),
    historyTruncated: historyRemaining > 0,
    historyPending: olderChunks.length > 0,
    updated: refreshedSession.updated,
    isRunning: isSessionRunning(sessionId),
    waitingOnChildren: waitState.waitingOnChildren,
    pendingReplyCount: waitState.pendingReplyCount,
    readyReplyCount: waitState.readyReplyCount,
    waitingReplyCount: waitState.waitingReplyCount,
    failedReplyCount: waitState.failedReplyCount,
    pendingReplies: waitState.pendingReplies,
  }, msg));

  if (olderChunks.length > 0) {
    let chunkEnd = Math.max(0, refreshedSession.messages.length - recentMessages.length);
    olderChunks.forEach((chunk, index) => {
      const chunkStart = Math.max(0, chunkEnd - chunk.length);
      wsSend(ws, {
        type: 'session_history_chunk',
        sessionId: refreshedSession.id,
        messages: chunk,
        remaining: Math.max(0, olderChunks.length - index - 1),
        historyCursor: index === olderChunks.length - 1 ? historyRemaining : null,
        historyBaseIndex: chunkStart,
        historyTruncated: historyRemaining > 0,
      });
      chunkEnd = chunkStart;
    });
  }

  // Resume streaming if process is still active.
  attachActiveRuntimeToWs(ws, sessionId);
}

function sqlQuote(value) {
  return `'${String(value).replace(/'/g, "''")}'`;
}

function deleteClaudeLocalSession(claudeSessionId) {
  if (!claudeSessionId) return;
  const projectsDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
  try {
    for (const proj of fs.readdirSync(projectsDir)) {
      const target = path.join(projectsDir, proj, `${claudeSessionId}.jsonl`);
      if (fs.existsSync(target)) fs.unlinkSync(target);
    }
  } catch {}
}

function deleteCodexLocalSession(session) {
  const threadId = session?.codexThreadId;
  if (!threadId) return { removedFiles: 0, removedDbRows: false };

  const rolloutPaths = new Set();
  if (session.importedRolloutPath) rolloutPaths.add(path.resolve(session.importedRolloutPath));
  try {
    for (const filePath of getCodexRolloutFiles()) {
      if (filePath.includes(threadId)) rolloutPaths.add(path.resolve(filePath));
    }
  } catch {}

  let removedFiles = 0;
  for (const filePath of rolloutPaths) {
    try {
      if (filePath.startsWith(CODEX_SESSIONS_DIR) && fs.existsSync(filePath)) {
        fs.unlinkSync(filePath);
        removedFiles++;
      }
    } catch {}
  }

  let removedDbRows = false;
  try {
    const sqlitePath = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' });
    if (sqlitePath.status === 0) {
      const quotedThreadId = sqlQuote(threadId);
      const stateSql = [
        'PRAGMA foreign_keys = ON;',
        `DELETE FROM thread_dynamic_tools WHERE thread_id = ${quotedThreadId};`,
        `DELETE FROM stage1_outputs WHERE thread_id = ${quotedThreadId};`,
        `DELETE FROM logs WHERE thread_id = ${quotedThreadId};`,
        `DELETE FROM threads WHERE id = ${quotedThreadId};`,
      ].join(' ');
      const stateResult = spawnSync('sqlite3', [CODEX_STATE_DB_PATH, stateSql], { stdio: 'ignore' });
      if (stateResult.status === 0) removedDbRows = true;

      if (fs.existsSync(CODEX_LOG_DB_PATH)) {
        spawnSync('sqlite3', [CODEX_LOG_DB_PATH, `DELETE FROM logs WHERE thread_id = ${quotedThreadId};`], { stdio: 'ignore' });
      }
    }
  } catch {}

  return { removedFiles, removedDbRows };
}

function handleDeleteSession(ws, sessionId) {
  pendingSlashCommands.delete(sessionId);
  pendingCompactRetries.delete(sessionId);
  cancelCodexCapacityRetry(sessionId);
  if (activeCodexAppGoalCommands.has(sessionId)) {
    const entry = activeCodexAppGoalCommands.get(sessionId);
    entry.cancelled = true;
    activeCodexAppGoalCommands.delete(sessionId);
  }
  deleteCrossConversationRepliesForSession(sessionId);
  for (const [threadId, child] of ccwebMcpChildThreads.entries()) {
    if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId);
  }
  if (activeProcesses.has(sessionId)) {
    const entry = activeProcesses.get(sessionId);
    try { killProcess(entry.pid); } catch {}
    if (entry.tailer) entry.tailer.stop();
    activeProcesses.delete(sessionId);
    if (entry.ws) wsSend(entry.ws, { type: 'done', sessionId });
  }
  if (activeCodexAppTurns.has(sessionId)) {
    const entry = activeCodexAppTurns.get(sessionId);
    activeCodexAppTurns.delete(sessionId);
    if (entry?.ws) wsSend(entry.ws, { type: 'done', sessionId });
    if (entry?.threadId && entry?.turnId && codexAppClient?.isRunning()) {
      codexAppClient.request('turn/interrupt', {
        threadId: entry.threadId,
        turnId: entry.turnId,
      }, 30000).catch(() => {});
    }
  }
  cleanRunDir(sessionId);
  try {
    const p = sessionPath(sessionId);
    const session = loadSession(sessionId);
    const sessionAgent = getSessionAgent(session);
    for (const attachmentId of collectSessionAttachmentIds(session)) {
      removeAttachmentById(attachmentId);
    }
    if (fs.existsSync(p)) fs.unlinkSync(p);
    if (sessionAgent === 'codex') {
      const result = deleteCodexLocalSession(session);
      plog('INFO', 'codex_local_session_deleted', {
        sessionId: sessionId.slice(0, 8),
        threadId: session?.codexThreadId || null,
        removedFiles: result.removedFiles,
        removedDbRows: result.removedDbRows,
      });
    } else if (sessionAgent === 'claude') {
      deleteClaudeLocalSession(session?.claudeSessionId || null);
    }
    sendSessionList(ws);
  } catch {
    wsSend(ws, { type: 'error', message: 'Failed to delete session' });
  }
}

function handleRenameSession(ws, sessionId, title) {
  if (!sessionId || !title) return;
  const session = loadSession(sessionId);
  if (session) {
    session.title = String(title).slice(0, 100);
    session.titleSource = 'manual';
    session.updated = new Date().toISOString();
    saveSession(session);
    sendSessionList(ws);
    wsSend(ws, { type: 'session_renamed', sessionId, title: session.title, ...publicTitleMetadata(session) });
  }
}

function handleSetSessionPinned(ws, sessionId, pinned) {
  const normalizedId = sanitizeId(sessionId || '');
  if (!normalizedId) return wsSend(ws, { type: 'error', message: '缺少会话 ID' });
  const session = loadSession(normalizedId);
  if (!session) return wsSend(ws, { type: 'error', message: 'Session not found' });
  session.pinnedAt = pinned ? new Date().toISOString() : null;
  saveSession(session);
  wsSend(ws, {
    type: 'session_pinned',
    sessionId: session.id,
    pinnedAt: session.pinnedAt || null,
  });
  broadcastSessionList();
}

		function handleSetMode(ws, sessionId, mode) {
		  const VALID_MODES = ['default', 'plan', 'yolo'];
		  if (!mode || !VALID_MODES.includes(mode)) return;
		  if (sessionId) {
		    const session = loadSession(sessionId);
		    if (session) {
		      session.permissionMode = mode;
		      // Same rule as /mode: don't clear runtime context on mode changes.
		      session.updated = new Date().toISOString();
		      saveSession(session);
		    }
		  }
		  wsSend(ws, { type: 'mode_changed', mode });
		}

function handleDisconnect(ws, wsId) {
  const affectedSessions = [];
  for (const [sid, entry] of activeProcesses) {
    if (entry.ws === ws) {
      entry.ws = null;
      entry.wsDisconnectTime = new Date().toISOString();
      affectedSessions.push({ sessionId: sid.slice(0, 8), pid: entry.pid });
    }
  }
  for (const [sid, entry] of activeCodexAppTurns) {
    if (entry.ws === ws) {
      entry.ws = null;
      entry.wsDisconnectTime = new Date().toISOString();
      affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, turnId: entry.turnId || null });
    }
  }
  wsSessionMap.delete(ws);
  plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions });
}

function handleDetachView(ws) {
  detachWsFromActiveRuntimes(ws, { markDisconnect: true });
  wsSessionMap.delete(ws);
}

function handleAbort(ws) {
  const sessionId = wsSessionMap.get(ws);
  if (!sessionId) return;
  if (handleCodexAppAbortSession(sessionId, ws)) return;
  if (cancelCodexAppGoalCommand(sessionId, ws)) return;
  const entry = activeProcesses.get(sessionId);
  if (!entry) {
    if (cancelCodexCapacityRetry(sessionId)) {
      wsSend(ws, { type: 'system_message', sessionId, message: '已取消 Codex 自动重试。' });
      wsSend(ws, { type: 'done', sessionId });
      sendSessionList(ws);
    }
    return;
  }

  plog('INFO', 'user_abort', { sessionId: sessionId.slice(0, 8), pid: entry.pid });
  cancelCodexCapacityRetry(sessionId);
  killProcess(entry.pid);
  setTimeout(() => {
    killProcess(entry.pid, true);
  }, 3000);
  // handleProcessComplete will be triggered by the PID monitor
}

function closeCcwebMcpChildAgent(sessionId, childThreadId, options = {}) {
  const normalizedSessionId = sanitizeId(sessionId || '');
  const normalizedThreadId = String(childThreadId || '').trim();
  if (!normalizedSessionId || !normalizedThreadId) {
    return { ok: false, code: 'missing_child_agent', message: '缺少子代理线程 ID。' };
  }
  const child = ccwebMcpChildThreads.get(normalizedThreadId);
  if (!child || child.parentSessionId !== normalizedSessionId) {
    return { ok: false, code: 'child_agent_not_found', message: '未找到可关闭的 ccweb MCP 子代理。' };
  }

  const now = new Date().toISOString();
  child.status = 'closed';
  child.closedAt = now;
  child.updatedAt = now;
  child.closeReason = options.reason || 'manual';
  ccwebMcpChildThreads.set(normalizedThreadId, child);

  if (child.turnId && codexAppClient?.isRunning()) {
    codexAppClient.request('turn/interrupt', {
      threadId: child.threadId,
      turnId: child.turnId,
    }, 30000).catch((err) => {
      plog('INFO', 'ccweb_mcp_child_interrupt_failed', {
        sessionId: normalizedSessionId.slice(0, 8),
        childThreadId: normalizedThreadId,
        error: err?.message || String(err || ''),
      });
    });
  }

  sendCcwebMcpChildAgentUpdate(normalizedSessionId, child);
  return { ok: true, child: ccwebMcpChildPublicState(child) };
}

function handleCcwebMcpChildAgentClose(ws, msg = {}) {
  const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || '');
  const result = closeCcwebMcpChildAgent(sessionId, msg.threadId || msg.childThreadId, { reason: 'manual' });
  if (!result.ok) {
    wsSend(ws, {
      type: 'error',
      sessionId,
      code: result.code,
      message: result.message,
      transient: true,
      autoDismissMs: 6000,
    });
    return;
  }
  wsSend(ws, {
    type: 'system_message',
    sessionId,
    tone: 'info',
    transient: true,
    autoDismissMs: 4000,
    message: `已关闭子代理 ${result.child.label || result.child.threadId}。`,
  });
}

// === Runtime Message Handler ===
function handleMessage(ws, msg, options = {}) {
  const { text, sessionId, mode } = msg;
  const { hideInHistory = false } = options;
  const fail = (code, message) => {
    wsSend(ws, attachClientRequestId({
      type: 'error',
      code,
      message,
      ...(sessionId ? { sessionId } : {}),
      ...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
    }, msg));
    return { ok: false, code, message };
  };
  const textValue = typeof text === 'string' ? text : '';
  let runtimeTextValue = typeof options.runtimeText === 'string' ? options.runtimeText : textValue;
  const attachments = Array.isArray(msg.attachments) ? msg.attachments.slice(0, MAX_MESSAGE_ATTACHMENTS) : [];
  const normalizedText = textValue.trim();
  let normalizedRuntimeText = runtimeTextValue.trim();
  const resolvedAttachments = resolveMessageAttachments(attachments);
  if (attachments.length > 0 && resolvedAttachments.length === 0) {
    return fail('attachment_unavailable', '图片附件已过期或不可用，请重新上传后再发送。');
  }
  if (!normalizedText && resolvedAttachments.length === 0) {
    return fail('empty_message', '消息内容不能为空。');
  }
  if (sessionId && !hideInHistory) {
    cancelCodexCapacityRetry(sessionId);
  }

  const savedAttachments = resolvedAttachments.map((attachment) => ({
    id: attachment.id,
    kind: 'image',
    filename: attachment.filename,
    mime: attachment.mime,
    size: attachment.size,
    createdAt: attachment.createdAt,
    expiresAt: attachment.expiresAt,
    storageState: attachment.storageState,
  }));

  if (sessionId && activeCodexAppTurns.has(sessionId)) {
    return handleCodexAppSteerMessage(ws, msg, options);
  }

  if (sessionId && activeCodexAppGoalCommands.has(sessionId)) {
    return fail('session_running', 'Codex App Goal 正在同步，请稍候。');
  }

  if (sessionId && activeProcesses.has(sessionId)) {
    return fail('session_running', '正在处理中，请先点击停止按钮。');
  }

  const derivedTitle = normalizedText
    ? textValue.slice(0, 60).replace(/\n/g, ' ')
    : `图片: ${savedAttachments[0]?.filename || 'image'}`;

  let session;
  if (sessionId) {
    if (!options.skipPendingCrossConversationFlush) {
      reconcilePendingCrossConversationReplies();
      flushPendingCrossConversationReplies(sessionId);
    }
    session = loadSession(sessionId);
  }
  if (!session) {
    const id = crypto.randomUUID();
    const agent = normalizeAgent(msg.agent);
    const resolvedCwd = getDefaultSessionCwd();
	    session = {
	      id,
      title: derivedTitle,
      titleSource: 'derived',
      created: new Date().toISOString(),
      updated: new Date().toISOString(),
      pinnedAt: null,
	      agent,
	      claudeSessionId: null,
	      codexThreadId: null,
	      codexAppThreadId: null,
	      model: agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : null,
	      permissionMode: mode || 'yolo',
	      totalCost: 0,
	      totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
	      messages: [],
	      cwd: resolvedCwd,
	    };
  }
  normalizeSession(session);

  const decoratorResolution = typeof options.runtimeText === 'string'
    ? { runtimeText: runtimeTextValue, mentions: [] }
    : resolveComposerDecorators(textValue, session, getSessionAgent(session));
  runtimeTextValue = decoratorResolution.runtimeText;
  normalizedRuntimeText = runtimeTextValue.trim();

  if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {
    return fail('command_attachment_unsupported', '命令消息暂不支持同时附带图片。请先发送图片说明，再单独使用 /model 或 /mode。');
  }

  if (mode && ['default', 'plan', 'yolo'].includes(mode)) {
    session.permissionMode = mode;
  }

  if (!hideInHistory && normalizedRuntimeText !== '/compact' && !isCodexAppSession(session) && getRuntimeSessionId(session)) {
    const retryText = typeof options.runtimeText === 'string' ? normalizedRuntimeText : textValue;
    pendingCompactRetries.set(session.id, { text: retryText, mode: session.permissionMode || 'yolo', reason: 'normal' });
  }

  if (session.title === 'New Chat' || session.title === 'Untitled') {
    session.title = derivedTitle;
    session.titleSource = 'derived';
  }

  let persistedUserMessage = null;
  if (!hideInHistory) {
    persistedUserMessage = {
      role: 'user',
      content: textValue,
      attachments: savedAttachments,
      timestamp: new Date().toISOString(),
    };
    if (options.crossConversation) {
      persistedUserMessage.crossConversation = options.crossConversation;
    }
    if (decoratorResolution.mentions.length > 0) {
      persistedUserMessage.composerMentions = decoratorResolution.mentions;
    }
    session.messages.push(persistedUserMessage);
  }
  session.updated = new Date().toISOString();
  saveSession(session);

  const currentSessionId = session.id;

  if (ws) {
    detachWsFromActiveRuntimes(ws);
    wsSessionMap.set(ws, currentSessionId);
  }

  if (!sessionId) {
    wsSend(ws, buildSessionInfoPayload(session));
  }
  if (ws && options.emitUserMessage && persistedUserMessage) {
    wsSend(ws, { type: 'session_message', sessionId: currentSessionId, message: persistedUserMessage });
  }
  sendSessionList(ws);

  if (isCodexAppSession(session)) {
    return handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, {
      mcpContext: options.mcpContext || {},
      crossConversation: options.crossConversation || null,
      codexRetry: options.codexRetry || null,
    });
  }

  const runtimeOptions = {
    attachments: resolvedAttachments,
    mcpContext: options.mcpContext || {},
    projectMcpConfigs: isCodexSession(session) ? loadProjectCodexMcpServerConfigs(session.cwd || getDefaultSessionCwd()) : [],
  };
  const spawnSpec = isClaudeSession(session)
    ? buildClaudeSpawnSpec(session, runtimeOptions)
    : buildCodexSpawnSpec(session, runtimeOptions);
  if (spawnSpec?.error) {
    return fail('runtime_config_error', spawnSpec.error);
  }

  // === Detached process with file-based I/O ===
  const dir = runDir(currentSessionId);
  fs.mkdirSync(dir, { recursive: true });

  const inputPath = path.join(dir, 'input.txt');
  const outputPath = path.join(dir, 'output.jsonl');
  const errorPath = path.join(dir, 'error.log');

  if (isClaudeSession(session) && resolvedAttachments.length > 0) {
    const content = [];
    if (runtimeTextValue) content.push({ type: 'text', text: runtimeTextValue });
    for (const attachment of resolvedAttachments) {
      const data = fs.readFileSync(attachment.path).toString('base64');
      content.push({
        type: 'image',
        source: {
          type: 'base64',
          media_type: attachment.mime,
          data,
        },
      });
    }
    fs.writeFileSync(inputPath, `${JSON.stringify({
      type: 'user',
      message: {
        role: 'user',
        content,
      },
    })}\n`);
  } else {
    fs.writeFileSync(inputPath, runtimeTextValue);
  }

  const inputFd = fs.openSync(inputPath, 'r');
  const outputFd = fs.openSync(outputPath, 'w');
  const errorFd = fs.openSync(errorPath, 'w');

  let proc;
  try {
    proc = spawn(spawnSpec.command, spawnSpec.args, {
      env: spawnSpec.env,
      cwd: spawnSpec.cwd,
      stdio: [inputFd, outputFd, errorFd],
      detached: !IS_WIN,
      windowsHide: true,
    });
  } catch (err) {
    fs.closeSync(inputFd);
    fs.closeSync(outputFd);
    fs.closeSync(errorFd);
    cleanRunDir(currentSessionId);
    plog('ERROR', 'process_spawn_fail', { sessionId: currentSessionId.slice(0, 8), error: err.message });
    const agent = getSessionAgent(session);
    return fail('process_spawn_failed', formatRuntimeError(agent, err.message, { exitCode: null, signal: null }));
  }

  fs.closeSync(inputFd);
  fs.closeSync(outputFd);
  fs.closeSync(errorFd);

  fs.writeFileSync(path.join(dir, 'pid'), String(proc.pid));
  proc.unref(); // Process survives Node.js exit

  plog('INFO', 'process_spawn', {
    sessionId: currentSessionId.slice(0, 8),
    pid: proc.pid,
    agent: getSessionAgent(session),
    mode: spawnSpec.mode,
    model: session.model || 'default',
    resume: spawnSpec.resume,
    args: redactSpawnArgs(spawnSpec.args.join(' ')),
  });

  // Fast exit detection (while Node.js is running)
  proc.on('exit', (code, signal) => {
    plog('INFO', 'process_exit_event', {
      sessionId: currentSessionId.slice(0, 8),
      pid: proc.pid,
      exitCode: code,
      signal: signal,
    });
    // Small delay to ensure file is fully flushed
    setTimeout(() => handleProcessComplete(currentSessionId, code, signal), 300);
  });

  const entry = {
    pid: proc.pid,
    ws,
    agent: getSessionAgent(session),
    cwd: spawnSpec.cwd,
    fullText: '',
    attachments: resolvedAttachments,
    toolCalls: [],
    lastCost: null,
    lastUsage: null,
    lastError: null,
    errorSent: false,
    crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null,
    retryRequest: {
      text: textValue,
      runtimeText: runtimeTextValue,
      mode: session.permissionMode || 'yolo',
      attachments: savedAttachments,
      mcpContext: options.mcpContext || {},
    },
    tailer: null,
  };
  activeProcesses.set(currentSessionId, entry);
  sendSessionList(ws);

  // Tail the output file for real-time streaming
  entry.tailer = new FileTailer(outputPath, (line) => {
    try {
      const event = JSON.parse(line);
      processRuntimeEvent(entry, event, currentSessionId);
    } catch {}
  });
  entry.tailer.start();

  return { ok: true, sessionId: currentSessionId, pid: proc.pid };
}

function truncateObj(obj, maxLen) {
  const s = JSON.stringify(obj);
  if (s.length <= maxLen) return obj;
  return s.slice(0, maxLen) + '...';
}

function safeJsonParse(input) {
  if (input === null || input === undefined) return input;
  if (typeof input !== 'string') return input;
  const trimmed = input.trim();
  if (!trimmed) return input;
  if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')))) {
    return input;
  }
  try {
    return JSON.parse(trimmed);
  } catch {
    return input;
  }
}

function sanitizeToolInput(toolName, input) {
  const parsed = safeJsonParse(input);
  if (toolName === 'AskUserQuestion') {
    return parsed;
  }
  return truncateObj(parsed, 500);
}

function redactSpawnArgs(argsText) {
  return String(argsText || '')
    .replace(/CC_WEB_MCP_TOKEN[^\s,\]}]*/g, 'CC_WEB_MCP_TOKEN=****')
    .replace(/mcp_servers\.ccweb\.env=\{[^}]*\}/g, 'mcp_servers.ccweb.env={****}')
    .replace(/mcp_servers\.[^\s]+\.env=\{[^}]*\}/g, (match) => match.replace(/\{[^}]*\}/, '{****}'));
}

const {
  buildClaudeSpawnSpec,
  buildCodexSpawnSpec,
  processClaudeEvent,
  processCodexEvent,
  processRuntimeEvent,
} = createAgentRuntime({
  processEnv: process.env,
  CLAUDE_PATH,
  CODEX_PATH,
  MODEL_MAP,
  loadModelConfig,
  applyCustomTemplateToSettings,
  getDefaultCodexModel,
  loadCodexConfig,
  prepareCodexCustomRuntime,
  ccwebMcpServerArg: CCWEB_MCP_SERVER_ARG,
  ccwebMcpServerArgs: ccwebMcpServerCommandSpec().args,
  internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`,
  internalMcpToken: INTERNAL_MCP_TOKEN,
  nodePath: process.execPath,
  wsSend,
  truncateObj,
  sanitizeToolInput,
  loadSession,
  saveSession,
  setRuntimeSessionId,
  getRuntimeSessionId,
  titleToolInstructions: CCWEB_TITLE_TOOL_INSTRUCTIONS,
});

const codexAppRuntime = createCodexAppRuntime({
  wsSend,
  loadSession,
  saveSession,
  truncateObj,
});

function detachWsFromActiveRuntimes(ws, options = {}) {
  const disconnectTime = options.markDisconnect ? new Date().toISOString() : null;
  for (const [, entry] of activeProcesses) {
    if (entry.ws === ws) {
      entry.ws = null;
      if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
    }
  }
  for (const [, entry] of activeCodexAppTurns) {
    if (entry.ws === ws) {
      entry.ws = null;
      if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
    }
  }
  for (const [, entry] of activeCodexAppGoalCommands) {
    if (entry.ws === ws) {
      entry.ws = null;
      if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
    }
  }
}

function findCodexAppEntryByRuntime(params = {}) {
  const threadId = params.threadId || params.thread?.id || null;
  const turnId = params.turnId || params.turn?.id || null;
  if (threadId) {
    for (const [sessionId, entry] of activeCodexAppTurns) {
      if (entry.threadId === threadId) return { sessionId, entry };
    }
  }
  if (turnId) {
    for (const [sessionId, entry] of activeCodexAppTurns) {
      if (entry.turnId === turnId) return { sessionId, entry };
    }
  }
  return null;
}

function parseMaybeJsonObject(value) {
  if (value && typeof value === 'object' && !Array.isArray(value)) return value;
  if (typeof value !== 'string') return null;
  const text = value.trim();
  if (!text || !text.startsWith('{')) return null;
  try {
    const parsed = JSON.parse(text);
    return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
  } catch {
    return null;
  }
}

function codexAppCollabToolName(value) {
  const normalized = String(value || '').trim().toLowerCase().replace(/[\s_-]/g, '');
  if (normalized === 'spawn' || normalized === 'spawnagent') return 'spawn_agent';
  if (normalized === 'wait' || normalized === 'waitagent') return 'wait_agent';
  if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input';
  if (normalized === 'resume' || normalized === 'resumeagent') return 'resume_agent';
  if (normalized === 'close' || normalized === 'closeagent') return 'close_agent';
  return normalized || '';
}

function ccwebMcpChildStatus(value, fallback = 'running') {
  const normalized = String(value || '').trim().toLowerCase().replace(/[\s_-]/g, '');
  if (!normalized) return fallback;
  if (/^(closed|close|cleanup|cleaned)$/.test(normalized)) return 'closed';
  if (/^(returned|completed|complete|done|success|succeeded|finished)$/.test(normalized)) return 'returned';
  if (/^(failed|failure|error|errored)$/.test(normalized)) return 'failed';
  if (/^(cancelled|canceled|aborted|interrupted)$/.test(normalized)) return 'closed';
  if (/^(pending|pendinginit|queued|waiting|running|working|active|inprogress|started)$/.test(normalized)) return 'running';
  return fallback;
}

function extractCcwebMcpStringArray(...values) {
  for (const value of values) {
    if (!Array.isArray(value)) continue;
    const list = value.map((item) => String(item || '').trim()).filter(Boolean);
    if (list.length > 0) return list;
  }
  return [];
}

function extractCcwebMcpAgentText(value) {
  if (!value) return '';
  if (typeof value === 'string') return value.trim();
  if (typeof value.text === 'string') return value.text.trim();
  if (typeof value.message === 'string') return value.message.trim();
  if (typeof value.content === 'string') return value.content.trim();
  if (Array.isArray(value.content)) {
    return value.content.map((part) => {
      if (typeof part === 'string') return part;
      if (typeof part?.text === 'string') return part.text;
      if (typeof part?.content === 'string') return part.content;
      return '';
    }).filter(Boolean).join('').trim();
  }
  return '';
}

function extractCcwebMcpChildCandidate(state = {}) {
  if (!state || typeof state !== 'object') return '';
  const direct = extractCcwebMcpAgentText(state);
  if (direct) return direct;
  for (const key of ['summary', 'lastMessage', 'finalMessage', 'final_message', 'output', 'result']) {
    const value = state[key];
    if (value === undefined || value === null) continue;
    const text = extractCcwebMcpAgentText(value);
    if (text) return text;
    if (typeof value === 'object') {
      try { return JSON.stringify(value); } catch {}
    }
  }
  return '';
}

function ccwebMcpChildLabel(state = {}, fallbackThreadId = '') {
  const label = state?.label || state?.title || state?.nickname || state?.name || state?.agent || state?.agentType || state?.agent_type || '';
  return String(label || fallbackThreadId || '子代理').trim();
}

function ccwebMcpChildSummary(child = {}) {
  const candidate = String(child.candidateResult || child.finalMessage || child.lastAssistantMessage || '').replace(/\s+/g, ' ').trim();
  return candidate ? truncateTextValue(candidate, 180, '...') : '';
}

function ccwebMcpChildPublicState(child = {}) {
  const candidateResult = child.finalMessage || child.candidateResult || '';
  return {
    threadId: child.threadId || '',
    label: child.label || child.threadId || '子代理',
    role: child.role || '',
    status: child.status || 'running',
    detail: ccwebMcpChildSummary(child),
    candidateResult,
    finalMessage: child.finalMessage || '',
    spawnToolId: child.spawnToolId || '',
    parentThreadId: child.parentThreadId || '',
    createdAt: child.createdAt || null,
    updatedAt: child.updatedAt || null,
    returnedAt: child.returnedAt || null,
    closedAt: child.closedAt || null,
  };
}

function mergeCcwebMcpChildIntoTool(tool, child) {
  if (!tool) return null;
  const candidateResult = child.finalMessage || child.candidateResult || '';

  const input = parseMaybeJsonObject(tool.input) || (tool.input && typeof tool.input === 'object' ? tool.input : {});
  const result = parseMaybeJsonObject(tool.result) || (tool.result && typeof tool.result === 'object' ? tool.result : {});
  const receiverThreadIds = Array.from(new Set([
    ...extractCcwebMcpStringArray(input.receiverThreadIds, input.receiver_thread_ids, input.targets),
    ...extractCcwebMcpStringArray(result.receiverThreadIds, result.receiver_thread_ids, result.targets),
    child.threadId,
  ].filter(Boolean)));
  const agentsStates = {
    ...(input.agentsStates && typeof input.agentsStates === 'object' ? input.agentsStates : {}),
    ...(input.agents_states && typeof input.agents_states === 'object' ? input.agents_states : {}),
    ...(result.agentsStates && typeof result.agentsStates === 'object' ? result.agentsStates : {}),
    ...(result.agents_states && typeof result.agents_states === 'object' ? result.agents_states : {}),
  };
  agentsStates[child.threadId] = {
    ...(agentsStates[child.threadId] && typeof agentsStates[child.threadId] === 'object' ? agentsStates[child.threadId] : {}),
    name: child.label || agentsStates[child.threadId]?.name || child.threadId,
    role: child.role || agentsStates[child.threadId]?.role || '',
    status: child.status || 'running',
    summary: ccwebMcpChildSummary(child),
    candidateResult,
    finalMessage: child.finalMessage || '',
    closedAt: child.closedAt || null,
    returnedAt: child.returnedAt || null,
  };

  const nextResult = {
    ...result,
    status: child.status || result.status || null,
    receiverThreadIds,
    agentsStates,
  };
  tool.kind = tool.kind || 'collab_agent_tool_call';
  tool.name = tool.name || 'CollabAgentToolCall';
  tool.result = JSON.stringify(nextResult, null, 2);
  return {
    id: tool.id,
    name: tool.name,
    kind: tool.kind,
    input: tool.input,
    result: tool.result,
    meta: tool.meta || null,
    done: !!tool.done,
  };
}

function updateCcwebMcpChildToolState(sessionId, child) {
  const entry = activeCodexAppTurns.get(sessionId) || null;
  let tool = entry?.toolCalls?.find((item) => item.id === child.spawnToolId) || null;

  if (!tool) {
    const session = loadSession(sessionId);
    const messages = Array.isArray(session?.messages) ? session.messages : [];
    for (let i = messages.length - 1; i >= 0 && !tool; i -= 1) {
      const list = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : [];
      tool = list.find((item) => item.id === child.spawnToolId) || null;
    }
  }
  return mergeCcwebMcpChildIntoTool(tool, child);
}

function updatePersistedCcwebMcpChildTool(sessionId, child) {
  const session = loadSession(sessionId);
  if (!session || !Array.isArray(session.messages)) return null;
  let targetTool = null;
  for (let i = session.messages.length - 1; i >= 0 && !targetTool; i -= 1) {
    const list = Array.isArray(session.messages[i]?.toolCalls) ? session.messages[i].toolCalls : [];
    targetTool = list.find((item) => item.id === child.spawnToolId) || null;
  }
  if (!mergeCcwebMcpChildIntoTool(targetTool, child)) return null;
  session.updated = new Date().toISOString();
  if (!findViewingSessionWs(sessionId)) session.hasUnread = true;
  saveSession(session);
  return targetTool;
}

function sendCcwebMcpChildAgentUpdate(sessionId, child) {
  const activeTool = updateCcwebMcpChildToolState(sessionId, child);
  const persistedTool = updatePersistedCcwebMcpChildTool(sessionId, child);
  const tool = activeTool || (persistedTool ? {
    id: persistedTool.id,
    name: persistedTool.name,
    kind: persistedTool.kind || 'collab_agent_tool_call',
    input: persistedTool.input,
    result: persistedTool.result,
    meta: persistedTool.meta || null,
    done: !!persistedTool.done,
  } : null);
  const payload = {
    type: 'ccweb_mcp_child_agent_update',
    sessionId,
    toolUseId: child.spawnToolId || '',
    child: ccwebMcpChildPublicState(child),
    tool,
  };
  const targetWs = activeCodexAppTurns.get(sessionId)?.ws || findViewingSessionWs(sessionId);
  if (targetWs) wsSend(targetWs, payload);
  broadcastSessionList();
}

function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) {
  if (!routed?.sessionId || item?.type !== 'collabAgentToolCall') return;
  const toolName = codexAppCollabToolName(item.tool || item.name);
  const receiverThreadIds = extractCcwebMcpStringArray(item.receiverThreadIds, item.receiver_thread_ids, item.targets);
  if (receiverThreadIds.length === 0) return;
  const states = item.agentsStates && typeof item.agentsStates === 'object'
    ? item.agentsStates
    : (item.agents_states && typeof item.agents_states === 'object' ? item.agents_states : {});

  for (const threadId of receiverThreadIds) {
    const state = states[threadId] && typeof states[threadId] === 'object' ? states[threadId] : {};
    const existing = ccwebMcpChildThreads.get(threadId);
    const now = new Date().toISOString();
    const isSpawn = toolName === 'spawn_agent' || !existing;
    const child = existing || {
      threadId,
      turnId: null,
      parentSessionId: routed.sessionId,
      parentThreadId: routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || '',
      spawnToolId: isSpawn ? item.id : '',
      label: ccwebMcpChildLabel(state, threadId),
      role: String(state.role || state.agent || state.agentType || state.agent_type || '').trim(),
      lastAssistantMessage: '',
      candidateResult: '',
      finalMessage: '',
      status: 'running',
      summaryAttempts: 0,
      createdAt: now,
      updatedAt: now,
    };
    if (!child.spawnToolId && isSpawn) child.spawnToolId = item.id;
    if (!child.spawnToolId && item.id) child.spawnToolId = item.id;
    child.parentSessionId = child.parentSessionId || routed.sessionId;
    child.parentThreadId = child.parentThreadId || routed.entry?.threadId || item.senderThreadId || item.sender_thread_id || '';
    child.label = ccwebMcpChildLabel(state, child.label || threadId);
    child.role = String(state.role || state.agent || state.agentType || state.agent_type || child.role || '').trim();
    if (child.status !== 'closed') {
      const candidate = extractCcwebMcpChildCandidate(state);
      if (candidate) {
        child.candidateResult = truncateTextValue(candidate, SESSION_MESSAGE_CONTENT_MAX_CHARS);
        child.lastAssistantMessage = child.candidateResult;
      }
      const rawStatus = state.status || state.state || item.status;
      const fallback = child.status || 'running';
      const nextStatus = ccwebMcpChildStatus(rawStatus, fallback);
      child.status = nextStatus === 'returned' && !child.candidateResult && !candidate ? fallback : nextStatus;
      if (child.status === 'returned' && !child.returnedAt) child.returnedAt = now;
    }
    child.updatedAt = now;
    ccwebMcpChildThreads.set(threadId, child);
    sendCcwebMcpChildAgentUpdate(routed.sessionId, child);
  }
}

function processCcwebMcpChildNotification(child, notification) {
  const method = notification?.method || '';
  const params = notification?.params || {};
  const now = new Date().toISOString();
  if (params.turnId && !child.turnId) child.turnId = params.turnId;
  if (params.turn?.id && !child.turnId) child.turnId = params.turn.id;

  if (child.status === 'closed' && method !== 'turn/completed') {
    return { changed: false, done: false };
  }

  if (method === 'turn/started') {
    child.status = 'running';
    child.updatedAt = now;
    return { changed: true, done: false };
  }

  if (method === 'item/agentMessage/delta') {
    const itemId = String(params.itemId || 'agent-message');
    if (!child.messageItems) child.messageItems = new Map();
    const current = child.messageItems.get(itemId) || '';
    const next = truncateTextValue(`${current}${String(params.delta || '')}`, SESSION_MESSAGE_CONTENT_MAX_CHARS);
    child.messageItems.set(itemId, next);
    child.lastAssistantMessage = next;
    child.updatedAt = now;
    return { changed: true, done: false };
  }

  if (method === 'item/completed') {
    const item = params.item || {};
    if (item.type === 'agentMessage') {
      const text = extractCcwebMcpAgentText(item);
      if (text) {
        if (!child.messageItems) child.messageItems = new Map();
        const finalMessage = truncateTextValue(text, SESSION_MESSAGE_CONTENT_MAX_CHARS);
        child.messageItems.set(item.id || 'agent-message', finalMessage);
        child.lastAssistantMessage = finalMessage;
        child.finalMessage = finalMessage;
        child.updatedAt = now;
        return { changed: true, done: false };
      }
    }
    return { changed: false, done: false };
  }

  if (method === 'turn/completed') {
    if (child.status !== 'closed') {
      const status = params.turn?.status || params.status || '';
      child.status = ccwebMcpChildStatus(status, status && /fail|error/i.test(status) ? 'failed' : 'returned');
      child.finalMessage = child.finalMessage || child.lastAssistantMessage || '';
      child.candidateResult = child.finalMessage || child.candidateResult || '';
      child.returnedAt = child.returnedAt || now;
    }
    child.updatedAt = now;
    return { changed: true, done: true };
  }

  return { changed: false, done: false };
}

function findCodexAppRouteByRuntime(params = {}) {
  const parent = findCodexAppEntryByRuntime(params);
  if (parent) return { ...parent, role: 'parent' };
  const threadId = params.threadId || params.thread?.id || null;
  if (threadId && ccwebMcpChildThreads.has(threadId)) {
    const child = ccwebMcpChildThreads.get(threadId);
    return {
      role: 'child',
      sessionId: child.parentSessionId,
      entry: activeCodexAppTurns.get(child.parentSessionId) || null,
      child,
    };
  }
  return null;
}

function handleCodexAppNotification(notification) {
  const routed = findCodexAppRouteByRuntime(notification?.params || {});
  if (handleCodexAppMcpStartupStatusNotification(notification, routed)) return;
  if (!routed) {
    plog('INFO', 'codex_app_notification_unrouted', {
      method: notification?.method || '',
      threadId: notification?.params?.threadId || notification?.params?.thread?.id || null,
      turnId: notification?.params?.turnId || notification?.params?.turn?.id || null,
    });
    return;
  }

  if (routed.role === 'child') {
    const result = processCcwebMcpChildNotification(routed.child, notification);
    if (result.changed) sendCcwebMcpChildAgentUpdate(routed.sessionId, routed.child);
    return;
  }

  const result = codexAppRuntime.processCodexAppNotification(routed.entry, notification, routed.sessionId);
  const item = notification?.params?.item || null;
  if (item?.type === 'collabAgentToolCall') {
    syncCcwebMcpChildAgentsFromCollabItem(routed, item);
  }
  persistCodexAppTurnState(routed.sessionId, routed.entry, { immediate: !!result?.done });
  if (result?.done) {
    handleCodexAppTurnComplete(routed.sessionId);
  }
}

function codexAppCommunicationDynamicTools() {
  return [
    {
      name: 'ccweb_list_conversations',
      namespace: 'ccweb',
      description: '列出 cc-web 中可发送消息的对话。返回当前来源对话 ID、目标对话 ID、标题、Agent、状态和工作目录摘要。',
      inputSchema: {
        type: 'object',
        properties: {
          agent: {
            type: 'string',
            enum: ['claude', 'codex', 'codexapp'],
            description: '可选。只返回指定 Agent 类型的对话。',
          },
          status: {
            type: 'string',
            enum: ['all', 'running', 'idle'],
            description: '可选。按运行状态过滤，默认 all。',
          },
          limit: {
            type: 'integer',
            minimum: 1,
            maximum: 100,
            description: '可选。返回数量上限，默认 50。',
          },
        },
        additionalProperties: false,
      },
    },
    {
      name: 'ccweb_set_title',
      namespace: 'ccweb',
      description: '设置当前 cc-web 对话标题。只作用于当前来源对话；用户手动重命名后会成功返回但忽略，不再覆盖用户标题。',
      inputSchema: {
        type: 'object',
        required: ['title'],
        properties: {
          title: {
            type: 'string',
            maxLength: MCP_CONVERSATION_TITLE_MAX_CHARS,
            description: '新的当前对话标题。应简短概括用户主要目标。',
          },
        },
        additionalProperties: false,
      },
    },
    {
      name: 'ccweb_send_message',
      namespace: 'ccweb',
      description: '向另一个 cc-web 对话发送消息。目标对话必须处于空闲状态；不能发送给当前对话。',
      inputSchema: {
        type: 'object',
        required: ['targetConversationId', 'content'],
        properties: {
          targetConversationId: {
            type: 'string',
            description: '目标对话 ID。',
          },
          content: {
            type: 'string',
            description: '要发送给目标对话的消息内容。',
          },
        },
        additionalProperties: false,
      },
    },
    {
      name: 'ccweb_list_pending_replies',
      namespace: 'ccweb',
      description: '列出当前来源对话等待中的跨对话回复，包括已完成但尚未处理的子对话返回摘要。',
      inputSchema: {
        type: 'object',
        properties: {
          status: {
            type: 'string',
            enum: ['all', 'waiting', 'ready', 'delivering', 'returned', 'failed'],
            description: '可选。按回复状态过滤，默认 all。',
          },
        },
        additionalProperties: false,
      },
    },
    {
      name: 'ccweb_get_pending_reply',
      namespace: 'ccweb',
      description: '读取指定 requestId 的跨对话回复状态和正文；用于判断是否继续追问指定子对话。',
      inputSchema: {
        type: 'object',
        required: ['requestId'],
        properties: {
          requestId: {
            type: 'string',
            description: '等待回复 requestId。',
          },
        },
        additionalProperties: false,
      },
    },
    {
      name: 'ccweb_create_conversation',
      namespace: 'ccweb',
      description: '创建新的 cc-web 持久对话。Agent 固定继承来源对话，不作为参数指定；只用于需要长期追踪、后续继续对话或跨项目工作区管理的场景；一次性并行研究应使用子代能力。',
      inputSchema: {
        type: 'object',
        properties: {
          cwd: {
            type: 'string',
            description: '可选。新对话工作目录；指定时必须是已存在的绝对路径，默认继承来源对话 cwd。',
          },
          title: {
            type: 'string',
            maxLength: MCP_CONVERSATION_TITLE_MAX_CHARS,
            description: '可选。新对话标题。',
          },
          mode: {
            type: 'string',
            enum: ['default', 'plan', 'yolo'],
            description: '可选。权限模式，默认 yolo；只有显式传 default/plan/yolo 时才使用指定模式。',
          },
          initialMessage: {
            type: 'string',
            description: '可选。创建后立即发送到新对话的首条消息。',
          },
          requestReply: {
            type: 'boolean',
            description: '可选。若为 true，新对话完成本轮输出后会把回复写回来源对话，并继续触发来源对话运行。',
          },
        },
        additionalProperties: false,
      },
    },
    {
      name: 'ccweb_request_reply',
      namespace: 'ccweb',
      description: '向另一个 cc-web 对话发送消息，并在目标对话完成本轮输出后把回复写回当前对话，然后继续触发当前对话运行。',
      inputSchema: {
        type: 'object',
        required: ['targetConversationId', 'content'],
        properties: {
          targetConversationId: {
            type: 'string',
            description: '目标对话 ID。',
          },
          content: {
            type: 'string',
            description: '要发送给目标对话的消息内容。',
          },
        },
        additionalProperties: false,
      },
    },
  ];
}

function codexAppDynamicToolResponse(result) {
  const payload = result && typeof result === 'object' ? result : { ok: true, result };
  return {
    success: payload.ok !== false,
    contentItems: [{
      type: 'inputText',
      text: JSON.stringify(payload, null, 2),
    }],
  };
}

function handleCodexAppDynamicToolCall(routed, params = {}) {
  const tool = String(params.tool || '');
  const namespace = String(params.namespace || '');
  if (namespace && namespace !== 'ccweb') return null;
  if (
    tool !== 'ccweb_list_conversations' &&
    tool !== 'ccweb_set_title' &&
    tool !== 'ccweb_create_conversation' &&
    tool !== 'ccweb_send_message' &&
    tool !== 'ccweb_list_pending_replies' &&
    tool !== 'ccweb_get_pending_reply' &&
    tool !== 'ccweb_request_reply'
  ) return null;

  const sourceSessionId = routed?.sessionId || '';
  const sourceHopCount = Number.parseInt(String(routed?.entry?.mcpContext?.hopCount || 0), 10) || 0;
  const result = callInternalMcpTool(tool, params.arguments || {}, sourceSessionId, sourceHopCount);
  return codexAppDynamicToolResponse(result);
}

function normalizeCodexAppUserInputAnswers(rawAnswers = {}) {
  const answers = {};
  for (const [id, value] of Object.entries(rawAnswers || {})) {
    if (!id) continue;
    if (Array.isArray(value)) {
      answers[id] = { answers: value.map((item) => String(item || '')).filter(Boolean) };
      continue;
    }
    if (value && typeof value === 'object' && Array.isArray(value.answers)) {
      answers[id] = { answers: value.answers.map((item) => String(item || '')).filter(Boolean) };
      continue;
    }
    const text = String(value || '').trim();
    answers[id] = { answers: text ? [text] : [] };
  }
  return { answers };
}

function previewInlineText(text, maxLength = 36) {
  const normalized = String(text || '').replace(/\s+/g, ' ').trim();
  if (!normalized) return '空内容';
  return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}...` : normalized;
}

function requestCodexAppUserInput(routed, params = {}) {
  if (!routed?.entry?.ws) {
    return Promise.resolve({ answers: {} });
  }

  const requestId = crypto.randomUUID();
  return new Promise((resolve) => {
    const timer = setTimeout(() => {
      pendingCodexAppUserInputs.delete(requestId);
      resolve({ answers: {} });
    }, 10 * 60 * 1000);

    pendingCodexAppUserInputs.set(requestId, {
      sessionId: routed.sessionId,
      itemId: params.itemId || '',
      resolve,
      timer,
    });

    wsSend(routed.entry.ws, {
      type: 'codex_app_user_input_request',
      sessionId: routed.sessionId,
      requestId,
      itemId: params.itemId || '',
      questions: Array.isArray(params.questions) ? params.questions : [],
    });
  });
}

function handleCodexAppUserInputResponse(ws, msg = {}) {
  const requestId = String(msg.requestId || '').trim();
  const pending = pendingCodexAppUserInputs.get(requestId);
  if (!pending) {
    wsSend(ws, { type: 'error', code: 'codexapp_user_input_not_found', message: 'Codex App 引导输入请求不存在或已超时。' });
    return;
  }

  pendingCodexAppUserInputs.delete(requestId);
  clearTimeout(pending.timer);
  const action = String(msg.action || 'submit').trim();
  const isCancel = action === 'cancel';
  if (!isCancel) {
    wsSend(ws, {
      type: 'system_message',
      sessionId: pending.sessionId,
      message: '已提交 Codex App 引导输入。',
    });
  }
  pending.resolve(isCancel ? { answers: {} } : normalizeCodexAppUserInputAnswers(msg.answers || {}));
}

function resolvePendingCodexAppUserInputsForSession(sessionId) {
  for (const [requestId, pending] of pendingCodexAppUserInputs) {
    if (pending.sessionId !== sessionId) continue;
    pendingCodexAppUserInputs.delete(requestId);
    clearTimeout(pending.timer);
    pending.resolve({ answers: {} });
  }
}

function handleCcwebPromptUserResponse(ws, msg = {}) {
  const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || '');
  const promptId = String(msg.promptId || '').trim();
  const fail = (code, message, extra = {}) => {
    wsSend(ws, { type: 'error', sessionId, code, message, ...extra });
    return { ok: false, code, message, ...extra };
  };

  if (!sessionId) return fail('missing_session_id', '缺少会话 ID。');
  if (!promptId) return fail('missing_prompt_id', '缺少 promptId。');
  if (activeProcesses.has(sessionId) && !activeCodexAppTurns.has(sessionId)) {
    return fail('session_running', '当前会话正在运行，暂不能提交 ccweb 表单答案。');
  }

  const session = loadSession(sessionId);
  if (!session) return fail('session_not_found', '会话不存在。');

  const found = findCcwebPromptMessage(session, promptId);
  if (!found) return fail('prompt_not_found', 'ccweb 表单不存在或已过期。', { promptId });
  if (found.prompt.status && found.prompt.status !== 'pending') {
    return fail('prompt_already_completed', 'ccweb 表单已经提交。', { promptId, status: found.prompt.status });
  }

  const normalized = normalizeCcwebPromptUserAnswers(found.prompt, msg.answers || {});
  if (!normalized.ok) return fail(normalized.code || 'bad_answers', normalized.message || '答案无效。', normalized);

  const now = new Date().toISOString();
  const submittedPrompt = {
    ...found.prompt,
    status: 'submitted',
    answers: normalized.answers,
    submittedAt: now,
    submitMessageId: crypto.randomUUID(),
  };
  removeCcwebPromptMessage(session, promptId);
  session.updated = now;
  saveSession(session);

  sendSessionEventToViewers(sessionId, {
    type: 'ccweb_prompt_user_remove',
    sessionId,
    promptId,
    prompt: submittedPrompt,
  });

  const responseText = buildCcwebPromptUserResponseText(submittedPrompt, normalized.answerList);
  const result = handleMessage(ws, {
    type: 'message',
    text: responseText,
    sessionId,
    mode: session.permissionMode || 'yolo',
    agent: getSessionAgent(session),
    clientMessageId: submittedPrompt.submitMessageId,
  }, {
    emitUserMessage: true,
    runtimeText: responseText,
    skipPendingCrossConversationFlush: true,
  });

  if (!result?.ok) {
    return fail(result?.code || 'submit_failed', result?.message || '提交 ccweb 表单答案失败。', { promptId });
  }
  return { ok: true, sessionId, promptId };
}

function handleCcwebPromptUserDismiss(ws, msg = {}) {
  const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || '');
  const promptId = String(msg.promptId || '').trim();
  const fail = (code, message, extra = {}) => {
    wsSend(ws, { type: 'error', sessionId, code, message, ...extra });
    return { ok: false, code, message, ...extra };
  };

  if (!sessionId) return fail('missing_session_id', '缺少会话 ID。');
  if (!promptId) return fail('missing_prompt_id', '缺少 promptId。');

  const session = loadSession(sessionId);
  if (!session) return fail('session_not_found', '会话不存在。');
  const found = removeCcwebPromptMessage(session, promptId);
  if (!found) return fail('prompt_not_found', 'ccweb 表单不存在或已被清理。', { promptId });

  const now = new Date().toISOString();
  const dismissedPrompt = {
    ...found.prompt,
    status: 'dismissed',
    dismissedAt: now,
  };
  session.updated = now;
  saveSession(session);

  sendSessionEventToViewers(sessionId, {
    type: 'ccweb_prompt_user_remove',
    sessionId,
    promptId,
    prompt: dismissedPrompt,
    reason: 'dismissed',
  });
  broadcastSessionList();
  return { ok: true, promptId, status: 'dismissed' };
}

function codexAppRecord(value) {
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}

function codexAppString(value) {
  return typeof value === 'string' && value.trim() ? value.trim() : '';
}

function codexAppApprovalToolName(params = {}) {
  const record = codexAppRecord(params);
  return codexAppString(record.toolName)
    || codexAppString(record.tool_name)
    || codexAppString(record.tool)
    || codexAppString(record.name)
    || codexAppString(record.permission)
    || 'CodexTool';
}

function codexAppApprovalPreview(method, params = {}) {
  const record = codexAppRecord(params);
  const itemId = codexAppString(record.itemId) || codexAppString(record.item_id);
  const reason = codexAppString(record.reason) || codexAppString(record.message);
  const cwd = codexAppString(record.cwd);

  if (method === 'item/commandExecution/requestApproval') {
    const command = record.command ?? record.cmd ?? '';
    return {
      itemId,
      approvalType: 'command',
      title: 'Codex App 请求执行命令',
      reason,
      summary: codexAppString(command) || reason || cwd,
      payload: truncateObj({ command, cwd, reason }, 4000),
      allowSessionScope: true,
    };
  }

  if (method === 'item/fileChange/requestApproval') {
    const grantRoot = codexAppString(record.grantRoot) || codexAppString(record.path);
    return {
      itemId,
      approvalType: 'file_change',
      title: 'Codex App 请求修改文件',
      reason,
      summary: grantRoot || reason || cwd,
      payload: truncateObj({ grantRoot, cwd, reason, changes: record.changes || null }, 4000),
      allowSessionScope: true,
    };
  }

  if (method === 'item/permissions/requestApproval') {
    return {
      itemId,
      approvalType: 'permissions',
      title: 'Codex App 请求提升权限',
      reason,
      summary: reason || cwd || '权限配置请求',
      payload: truncateObj({ cwd, permissions: record.permissions || {} }, 4000),
      allowSessionScope: true,
    };
  }

  const toolName = codexAppApprovalToolName(record);
  const input = record.input ?? record.arguments ?? record.params ?? record;
  return {
    itemId,
    approvalType: 'tool',
    title: `Codex App 请求调用 ${toolName}`,
    reason,
    summary: reason || toolName,
    payload: truncateObj(input, 4000),
    allowSessionScope: true,
    toolName,
  };
}

function codexAppApprovalDecisionFromAction(action) {
  switch (String(action || '').trim()) {
    case 'approve':
      return 'approved';
    case 'approve_session':
      return 'approved_for_session';
    case 'deny':
      return 'denied';
    default:
      return 'abort';
  }
}

function codexAppDecisionResponse(decision) {
  switch (decision) {
    case 'approved':
      return { decision: 'accept' };
    case 'approved_for_session':
      return { decision: 'acceptForSession' };
    case 'denied':
      return { decision: 'decline' };
    default:
      return { decision: 'cancel' };
  }
}

function codexAppPermissionsResponse(params = {}, decision = 'abort') {
  if (decision === 'approved' || decision === 'approved_for_session') {
    return {
      permissions: codexAppRecord(params).permissions || {},
      scope: decision === 'approved_for_session' ? 'session' : 'turn',
    };
  }
  return {
    permissions: {
      network: null,
      fileSystem: null,
    },
    scope: 'turn',
  };
}

function codexAppApprovalResponse(method, params = {}, action = 'cancel') {
  const decision = codexAppApprovalDecisionFromAction(action);
  if (method === 'item/permissions/requestApproval') {
    return codexAppPermissionsResponse(params, decision);
  }
  return codexAppDecisionResponse(decision);
}

function requestCodexAppApproval(routed, method, params = {}) {
  const targetWs = routed?.entry?.ws || findViewingSessionWs(routed?.sessionId);
  if (!routed?.sessionId || !targetWs) {
    return Promise.resolve(codexAppApprovalResponse(method, params, 'cancel'));
  }

  const requestId = crypto.randomUUID();
  const preview = codexAppApprovalPreview(method, params);
  return new Promise((resolve) => {
    const timer = setTimeout(() => {
      pendingCodexAppApprovals.delete(requestId);
      resolve(codexAppApprovalResponse(method, params, 'cancel'));
    }, 10 * 60 * 1000);

    pendingCodexAppApprovals.set(requestId, {
      sessionId: routed.sessionId,
      method,
      params,
      resolve,
      timer,
    });

    wsSend(targetWs, {
      type: 'codex_app_approval_request',
      sessionId: routed.sessionId,
      requestId,
      method,
      ...preview,
    });
  });
}

function handleCodexAppApprovalResponse(ws, msg = {}) {
  const requestId = String(msg.requestId || '').trim();
  const pending = pendingCodexAppApprovals.get(requestId);
  if (!pending) {
    wsSend(ws, { type: 'error', code: 'codexapp_approval_not_found', message: 'Codex App 审批请求不存在或已超时。' });
    return;
  }

  pendingCodexAppApprovals.delete(requestId);
  clearTimeout(pending.timer);
  const action = String(msg.action || 'cancel').trim();
  const result = codexAppApprovalResponse(pending.method, pending.params, action);
  const approved = action === 'approve' || action === 'approve_session';
  const message = approved
    ? (action === 'approve_session' ? '已批准 Codex App 本会话执行。' : '已批准 Codex App 本次执行。')
    : (action === 'deny' ? '已拒绝 Codex App 执行请求。' : '已取消 Codex App 审批请求。');
  wsSend(ws, {
    type: 'system_message',
    sessionId: pending.sessionId,
    tone: approved ? 'info' : 'warning',
    transient: true,
    autoDismissMs: 5000,
    message,
  });
  pending.resolve(result);
}

function resolvePendingCodexAppApprovalsForSession(sessionId) {
  for (const [requestId, pending] of pendingCodexAppApprovals) {
    if (pending.sessionId !== sessionId) continue;
    pendingCodexAppApprovals.delete(requestId);
    clearTimeout(pending.timer);
    pending.resolve(codexAppApprovalResponse(pending.method, pending.params, 'cancel'));
  }
}

function handleCodexAppServerRequest(request) {
  const method = request?.method || '';
  const params = request?.params || {};
  const routed = findCodexAppEntryByRuntime(params);
  const dynamicToolResponse = method === 'item/tool/call'
    ? handleCodexAppDynamicToolCall(routed, params)
    : null;
  const isApprovalRequest = method === 'item/commandExecution/requestApproval'
    || method === 'item/fileChange/requestApproval'
    || method === 'item/permissions/requestApproval'
    || method === 'item/tool/requestApproval';
  if (!dynamicToolResponse && !isApprovalRequest && method !== 'item/tool/requestUserInput' && routed?.entry?.ws) {
    wsSend(routed.entry.ws, {
      type: 'system_message',
      sessionId: routed.sessionId,
      message: `Codex App 请求客户端处理 ${method}，cc-web 暂不支持该请求类型，已按保守策略拒绝。`,
    });
  }

  switch (method) {
    case 'item/commandExecution/requestApproval':
    case 'item/fileChange/requestApproval':
    case 'item/permissions/requestApproval':
    case 'item/tool/requestApproval':
      return requestCodexAppApproval(routed, method, params);
    case 'item/tool/call':
      if (dynamicToolResponse) return dynamicToolResponse;
      return {
        success: false,
        contentItems: [{ type: 'inputText', text: 'cc-web 暂不支持执行 Codex App 动态客户端工具。' }],
      };
    case 'item/tool/requestUserInput':
      return requestCodexAppUserInput(routed, params);
    default:
      throw new Error(`cc-web 暂不支持 Codex app-server 请求: ${method}`);
  }
}

function handleCodexAppServerExit(signature, info = {}) {
  if (signature && signature !== codexAppClientSignature) {
    plog('INFO', 'codex_app_server_exit_stale', {
      code: info.code ?? null,
      signal: info.signal || null,
      activeTurns: activeCodexAppTurns.size,
    });
    return;
  }
  if (signature && signature === codexAppClientSignature) {
    codexAppClient = null;
    codexAppClientSignature = '';
  }

  const stderr = String(info.stderr || '').trim();
  const message = stderr || `Codex app-server 已退出: code=${info.code ?? 'null'} signal=${info.signal || 'null'}`;
  const affected = Array.from(activeCodexAppTurns.keys());
  plog('WARN', 'codex_app_server_exit', {
    code: info.code ?? null,
    signal: info.signal || null,
    activeTurns: affected.length,
    stderr: stderr.slice(-500) || null,
  });

  for (const sessionId of affected) {
    handleCodexAppTurnFailure(sessionId, new Error(message));
  }
}

function codexAppModelSettings(session) {
  const raw = String(session?.model || getDefaultCodexModel() || '').trim();
  const match = raw.match(/^(.*)\((low|medium|high|xhigh)\)\s*$/i);
  if (!match) return { model: raw || null, effort: null };
  return {
    model: String(match[1] || '').trim() || null,
    effort: String(match[2] || '').trim().toLowerCase(),
  };
}

function codexAppPermissionParams(session) {
  const mode = session?.permissionMode || 'yolo';
  if (mode === 'plan') {
    return { approvalPolicy: 'never', sandbox: 'read-only' };
  }
  if (mode === 'default') {
    return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
  }
  return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
}

function codexAppTurnPermissionParams(session) {
  const mode = session?.permissionMode || 'yolo';
  if (mode === 'plan') {
    return { approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false } };
  }
  if (mode === 'default') {
    return {
      approvalPolicy: 'on-request',
      sandboxPolicy: {
        type: 'workspaceWrite',
        writableRoots: [],
        networkAccess: false,
        excludeTmpdirEnvVar: false,
        excludeSlashTmp: false,
      },
    };
  }
  return { approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } };
}

function codexAppCcwebMcpEnv(session, options = {}) {
  if (!session?.id || !INTERNAL_MCP_TOKEN) return null;
  const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10);
  const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0;
  return {
    CC_WEB_MCP_URL: `http://127.0.0.1:${PORT}/api/internal/mcp`,
    CC_WEB_MCP_TOKEN: INTERNAL_MCP_TOKEN,
    CC_WEB_SOURCE_SESSION_ID: session.id,
    CC_WEB_CROSS_HOP_COUNT: String(hopCount),
  };
}

function codexAppThreadConfig(session, options = {}) {
  const config = {};
  for (const item of listRuntimeMcpServerConfigs({ ...options, session, agent: 'codexapp' })) {
    if (!item?.server || !item?.config) continue;
    config[`mcp_servers.${item.server}`] = item.config;
  }
  return config;
}

function codexAppCollaborationMode(session, modelSettings) {
  const mode = (session?.permissionMode || 'yolo') === 'plan' ? 'plan' : 'default';
  const settings = {
    model: modelSettings.model || FALLBACK_CODEX_MODEL,
    reasoning_effort: modelSettings.effort || null,
    developer_instructions: CODEX_APP_COLLABORATION_INSTRUCTIONS,
  };
  return { mode, settings };
}

async function codexAppPostInitialize({ request, onLog } = {}) {
  if (typeof request !== 'function') return;
  try {
    await request('experimentalFeature/enablement/set', { enablement: { goals: true } }, 30000);
    if (typeof onLog === 'function') onLog('INFO', 'codex_app_goals_feature_enabled', {});
  } catch (err) {
    if (typeof onLog === 'function') {
      onLog('INFO', 'codex_app_goals_feature_enable_failed', { error: err?.message || String(err || '') });
    }
  }

  try {
    const result = await request('collaborationMode/list', {}, 30000);
    if (typeof onLog === 'function') onLog('INFO', 'codex_app_collaboration_modes', { result });
  } catch (err) {
    if (typeof onLog === 'function') {
      onLog('INFO', 'codex_app_collaboration_mode_list_failed', { error: err?.message || String(err || '') });
    }
  }
}

function buildCodexAppClientSpec() {
  const codexConfig = loadCodexConfig();
  const runtimeConfig = prepareCodexCustomRuntime(codexConfig);
  if (runtimeConfig?.error) return { error: runtimeConfig.error };

  const env = { ...process.env };
  const strippedEnvKeys = [];
  for (const key of CODEX_APP_PROCESS_ENV_STRIP_KEYS) {
    if (Object.prototype.hasOwnProperty.call(env, key)) strippedEnvKeys.push(key);
    delete env[key];
  }
  env.PATH = cleanProcessPathValue(env.PATH);
  delete env.CC_WEB_PASSWORD;
  delete env.CLAUDECODE;
  delete env.CLAUDE_CODE;
  if (CODEX_APP_CCWEB_MCP_TRANSPORT !== 'stdio') {
    env[CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV] = INTERNAL_MCP_TOKEN;
  }
  if (runtimeConfig?.mode === 'custom') {
    env.CODEX_HOME = runtimeConfig.homeDir;
    env.OPENAI_API_KEY = runtimeConfig.apiKey;
    delete env.OPENAI_BASE_URL;
  }

  const args = ['app-server', '--stdio'];
  const signature = JSON.stringify({
    command: CODEX_PATH,
    args,
    codexConfig,
    runtimeMode: runtimeConfig?.mode || 'local',
    codeHome: env.CODEX_HOME || '',
    apiKeyHash: runtimeConfig?.apiKey ? crypto.createHash('sha256').update(runtimeConfig.apiKey).digest('hex') : '',
    worker: CODEX_APP_WORKER_ENABLED,
    ccwebMcpTransport: CODEX_APP_CCWEB_MCP_TRANSPORT,
    internalMcpTokenHash: crypto.createHash('sha256').update(INTERNAL_MCP_TOKEN).digest('hex'),
  });

  return {
    command: CODEX_PATH,
    args,
    env,
    cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(),
    signature,
    strippedEnvKeys,
  };
}

function getCodexAppClient(options = {}) {
  const spec = buildCodexAppClientSpec();
  if (spec?.error) return { error: spec.error };

  if (codexAppClient && codexAppClientSignature !== spec.signature) {
    const excludeSessionId = sanitizeId(options.excludeSessionId || '');
    const blockingSessionIds = Array.from(activeCodexAppTurns.keys())
      .filter((sessionId) => !excludeSessionId || sessionId !== excludeSessionId);
    if (blockingSessionIds.length > 0) {
      if (codexAppClient.isRunning()) {
        plog('WARN', 'codex_app_config_changed_reusing_active_client', {
          activeTurns: activeCodexAppTurns.size,
          blockingTurns: blockingSessionIds.length,
          excludeSessionId: excludeSessionId ? excludeSessionId.slice(0, 8) : null,
        });
        return { client: codexAppClient, staleConfig: true };
      }
      const message = 'Codex App 配置已变更，旧 app-server 已不可用，已结束残留运行任务。请重试。';
      for (const sessionId of blockingSessionIds) {
        handleCodexAppTurnFailure(sessionId, new Error(message));
      }
    }
    codexAppClient.stop();
    codexAppClient = null;
    codexAppClientSignature = '';
  }

  if (codexAppClient && !codexAppClient.isRunning()) {
    try { codexAppClient.stop(); } catch {}
    codexAppClient = null;
    codexAppClientSignature = '';
  }

  if (!codexAppClient || !codexAppClient.isRunning()) {
    const signature = spec.signature;
    const clientOptions = {
      command: spec.command,
      args: spec.args,
      env: spec.env,
      cwd: spec.cwd,
      onNotification: handleCodexAppNotification,
      onServerRequest: handleCodexAppServerRequest,
      onExit: (info) => handleCodexAppServerExit(signature, info),
      onLog: (level, event, data) => plog(level, event, data),
      postInitialize: codexAppPostInitialize,
    };
    if (IS_BUN_SINGLE_EXECUTABLE) {
      clientOptions.workerCommand = process.execPath;
      clientOptions.workerArgs = [CODEX_APP_WORKER_ARG];
    }
    codexAppClient = CODEX_APP_WORKER_ENABLED
      ? createCodexAppWorkerClient(clientOptions)
      : createCodexAppServerClient(clientOptions);
    codexAppClientSignature = signature;
    plog('INFO', 'codex_app_client_created', {
      worker: CODEX_APP_WORKER_ENABLED,
      workerLauncher: IS_BUN_SINGLE_EXECUTABLE ? 'single-executable' : 'source-file',
      command: path.basename(spec.command || ''),
      strippedEnvKeys: spec.strippedEnvKeys || [],
    });
  }

  return { client: codexAppClient };
}

function codexAppInputFromMessage(runtimeTextValue, attachments = []) {
  const input = [];
  const text = String(runtimeTextValue || '').trim();
  if (text) input.push({ type: 'text', text });
  for (const attachment of attachments) {
    if (attachment?.path) input.push({ type: 'localImage', path: attachment.path, detail: 'auto' });
  }
  return input;
}

function codexAppThreadParams(session, options = {}) {
  const modelSettings = codexAppModelSettings(session);
  const config = codexAppThreadConfig(session, options);
  const params = {
    ...codexAppPermissionParams(session),
    cwd: session.cwd || getDefaultSessionCwd(),
    model: modelSettings.model,
    threadSource: 'user',
  };
  if (Object.keys(config).length > 0) params.config = config;
  return params;
}

function codexAppTurnParams(session, input, threadId, clientUserMessageId = null) {
  const modelSettings = codexAppModelSettings(session);
  const collaborationMode = codexAppCollaborationMode(session, modelSettings);
  const params = {
    ...codexAppTurnPermissionParams(session),
    threadId,
    input,
    cwd: session.cwd || getDefaultSessionCwd(),
    clientUserMessageId,
    collaborationMode,
  };
  if (!collaborationMode) {
    params.model = modelSettings.model;
    if (modelSettings.effort) params.effort = modelSettings.effort;
  }
  return params;
}

function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, options = {}) {
  const input = codexAppInputFromMessage(runtimeTextValue, resolvedAttachments);
  if (input.length === 0) {
    wsSend(ws, { type: 'error', code: 'empty_message', message: '消息内容不能为空。' });
    return { ok: false, code: 'empty_message', message: '消息内容不能为空。' };
  }

  const codexRetry = options.codexRetry && typeof options.codexRetry === 'object'
    ? {
        isAutoRetry: !!options.codexRetry.isAutoRetry,
        attempt: Number.isFinite(Number(options.codexRetry.attempt)) ? Number(options.codexRetry.attempt) : null,
        retryMode: options.codexRetry.retryMode || null,
        expectedThreadId: options.codexRetry.expectedThreadId || null,
        originalText: options.codexRetry.originalText || null,
        originalRuntimeText: options.codexRetry.originalRuntimeText || null,
        useContinuationRetry: !!options.codexRetry.useContinuationRetry,
      }
    : null;
  const currentThreadId = getRuntimeSessionId(session);
  const expectedThreadId = codexRetry?.expectedThreadId || currentThreadId || null;
  const retryAttachments = resolvedAttachments.map((attachment) => ({
    id: attachment.id,
    kind: 'image',
    filename: attachment.filename,
    mime: attachment.mime,
    size: attachment.size,
    createdAt: attachment.createdAt,
    expiresAt: attachment.expiresAt,
    storageState: attachment.storageState,
  }));
  const entry = {
    ws,
    agent: 'codexapp',
    cwd: session.cwd || getDefaultSessionCwd(),
    threadId: expectedThreadId,
    expectedThreadId,
    turnId: null,
    fullText: '',
    toolCalls: [],
    toolOutputDeltas: new Map(),
    agentMessageItems: new Map(),
    mcpContext: options.mcpContext || {},
    codexRetry,
    lastUsage: null,
    lastError: null,
    errorSent: false,
    crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null,
    retryRequest: {
      text: codexRetry?.originalText || runtimeTextValue,
      runtimeText: codexRetry?.originalRuntimeText || runtimeTextValue,
      originalText: codexRetry?.originalText || runtimeTextValue,
      originalRuntimeText: codexRetry?.originalRuntimeText || runtimeTextValue,
      lastRetryText: runtimeTextValue,
      mode: session.permissionMode || 'yolo',
      agent: 'codexapp',
      attachments: retryAttachments,
      mcpContext: options.mcpContext || {},
      expectedThreadId,
    },
    clientUserMessageId: crypto.randomUUID(),
    startedAt: new Date().toISOString(),
  };
  activeCodexAppTurns.set(session.id, entry);
  persistCodexAppTurnState(session.id, entry, { immediate: true });
  sendSessionList(ws);

  startCodexAppTurn(session.id, input).catch((err) => {
    handleCodexAppTurnFailure(session.id, err);
  });

  return { ok: true, sessionId: session.id };
}

async function startCodexAppTurn(sessionId, input) {
  const session = loadSession(sessionId);
  const entry = activeCodexAppTurns.get(sessionId);
  if (!session || !entry) return;

  const clientResult = getCodexAppClient({ excludeSessionId: sessionId });
  if (clientResult.error) throw new Error(clientResult.error);
  const client = clientResult.client;
  await client.start();

  const currentThreadId = getRuntimeSessionId(session);
  const expectedThreadId = entry.expectedThreadId
    || entry.codexRetry?.expectedThreadId
    || entry.retryRequest?.expectedThreadId
    || entry.threadId
    || currentThreadId
    || null;
  let threadId = expectedThreadId || currentThreadId;
  const threadParams = codexAppThreadParams(session, { mcpContext: entry.mcpContext || {} });
  if (threadId) {
    const requestedThreadId = threadId;
    const resumed = await client.request('thread/resume', { ...threadParams, threadId: requestedThreadId }, 60000);
    const resumedThreadId = resumed?.thread?.id || requestedThreadId;
    if (expectedThreadId && resumedThreadId !== expectedThreadId) {
      const expectedShort = String(expectedThreadId).slice(0, 24);
      const actualShort = String(resumedThreadId).slice(0, 24);
      plog('WARN', 'codex_app_thread_resume_mismatch', {
        sessionId: sessionId.slice(0, 8),
        expectedThreadId: expectedShort,
        actualThreadId: actualShort,
        autoRetry: !!entry.codexRetry?.isAutoRetry,
        retryAttempt: entry.codexRetry?.attempt || null,
      });
      const prefix = entry.codexRetry?.isAutoRetry ? 'Codex App 自动重试' : 'Codex App';
      throw new Error(`${prefix}恢复到不同线程，已停止以避免上下文丢失（期望 ${expectedShort}，实际 ${actualShort}）。`);
    }
    threadId = resumedThreadId;
  } else {
    const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
    threadId = started?.thread?.id || null;
  }
  if (!threadId) throw new Error('Codex app-server 未返回 threadId。');

  entry.threadId = threadId;
  setRuntimeSessionId(session, threadId);
  session.updated = new Date().toISOString();
  saveSession(session);
  persistCodexAppTurnState(sessionId, entry, { immediate: true });

  const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000);
  if (turn?.turn?.id) entry.turnId = turn.turn.id;
  persistCodexAppTurnState(sessionId, entry, { immediate: true });
}

function handleCodexAppTurnComplete(sessionId, options = {}) {
  const entry = activeCodexAppTurns.get(sessionId);
  if (!entry) return;
  resolvePendingCodexAppUserInputsForSession(sessionId);
  resolvePendingCodexAppApprovalsForSession(sessionId);

  const explicitError = options.error || null;
  const rawError = explicitError || entry.lastError || null;
  const completionError = rawError && !entry.userAborted
    ? formatRuntimeError('codex', rawError, { exitCode: null, signal: null })
    : null;

  const session = loadSession(sessionId);
  const turnKey = codexAppTurnKey(sessionId, entry);
  const assistantContent = truncateTextValue(entry.fullText || '', SESSION_MESSAGE_CONTENT_MAX_CHARS);
  const assistantToolCalls = sanitizeToolCallsForPersist(entry.toolCalls || [], {
    maxToolCalls: SESSION_MAX_TOOL_CALLS_PER_MESSAGE,
    toolInputMaxChars: SESSION_TOOL_INPUT_MAX_CHARS,
    toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS,
    contentMaxChars: SESSION_MESSAGE_CONTENT_MAX_CHARS,
  });

  if (session && shouldRetryCodexTransientFailure(entry, rawError)) {
    activeCodexAppTurns.delete(sessionId);
    cleanupCodexAppTurnState(sessionId, entry);
    if (scheduleCodexCapacityRetry(sessionId, entry, rawError)) {
      sendSessionList(entry.ws);
      return;
    }
  } else {
    cancelCodexCapacityRetry(sessionId);
  }

  if (session && (assistantContent.trim() || assistantToolCalls.length > 0) && !hasCodexAppTurnMessage(session, turnKey)) {
    session.messages.push({
      role: 'assistant',
      content: assistantContent,
      toolCalls: assistantToolCalls,
      timestamp: new Date().toISOString(),
      codexAppTurnKey: turnKey,
      codexAppThreadId: entry.threadId || null,
      codexAppTurnId: entry.turnId || null,
      interrupted: !!options.interrupted,
    });
    session.updated = new Date().toISOString();
    if (!entry.ws) session.hasUnread = true;
    saveSession(session);
  }

  activeCodexAppTurns.delete(sessionId);
  cleanupCodexAppTurnState(sessionId, entry);
  if (entry.crossConversationReplyRequestId) {
    completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
  }
  flushPendingCrossConversationReplies(sessionId);
  plog(completionError ? 'WARN' : 'INFO', 'codex_app_turn_complete', {
    sessionId: sessionId.slice(0, 8),
    threadId: entry.threadId || null,
    turnId: entry.turnId || null,
    responseLen: (entry.fullText || '').length,
    toolCallCount: (entry.toolCalls || []).length,
    error: rawError || null,
  });

  if (entry.ws) {
    if (completionError && !entry.errorSent) {
      entry.errorSent = true;
      wsSend(entry.ws, { type: 'error', sessionId, message: completionError });
    }
    wsSend(entry.ws, { type: 'done', sessionId, costUsd: null });
    sendSessionList(entry.ws);
    return;
  }

  if (wss && session) {
    for (const client of wss.clients) {
      if (client.readyState === 1) {
        wsSend(client, {
          type: 'background_done',
          sessionId,
          title: session.title || 'Untitled',
          costUsd: null,
          responseLen: (entry.fullText || '').length,
        });
      }
    }
    buildNotifyContent(entry, session, completionError, false).then(({ title, content }) => {
      sendNotification(title, content);
    });
  }
}

function handleCodexAppTurnFailure(sessionId, err) {
  const entry = activeCodexAppTurns.get(sessionId);
  if (!entry) return;
  entry.lastError = err?.message || String(err || 'Codex App 任务失败');
  handleCodexAppTurnComplete(sessionId, { error: entry.lastError });
}

function handleCodexAppSteerMessage(ws, msg, options = {}) {
  const sessionId = sanitizeId(msg?.sessionId || '');
  const entry = activeCodexAppTurns.get(sessionId);
  if (!entry) return null;
  const clientMessageId = String(msg?.clientMessageId || '').trim();

  const sendSteerStatus = (status, message) => {
    const targetWs = entry.ws || ws;
    if (!targetWs) return;
    const payload = {
      type: 'codex_app_steer_status',
      sessionId,
      status,
      message,
    };
    if (clientMessageId) payload.clientMessageId = clientMessageId;
    wsSend(targetWs, payload);
  };

  const fail = (code, message) => {
    sendSteerStatus('failed', '插入失败');
    wsSend(ws, { type: 'error', code, message });
    return { ok: false, code, message };
  };

  const session = loadSession(sessionId);
  if (!session || !isCodexAppSession(session)) {
    return fail('session_not_found', 'Codex App 会话不存在。');
  }

  const textValue = typeof msg.text === 'string' ? msg.text : '';
  const normalizedText = textValue.trim();
  const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
  if (!normalizedText) return fail('empty_message', '运行中插入内容不能为空。');
  if (normalizedText.startsWith('/')) return fail('codexapp_running_slash_unsupported', 'Codex App 运行中暂不支持 slash 指令插入。');
  if (attachments.length > 0) return fail('codexapp_running_attachment_unsupported', 'Codex App 运行中插入暂不支持图片附件。');
  if (!entry.threadId || !entry.turnId) return fail('codexapp_turn_not_ready', 'Codex App 当前 turn 尚未准备好，请稍后再插入。');
  if (!codexAppClient || !codexAppClient.isRunning()) return fail('codexapp_server_not_running', 'Codex app-server 未运行，无法插入。');

  const decoratorResolution = typeof options.runtimeText === 'string'
    ? { runtimeText: options.runtimeText, mentions: [] }
    : resolveComposerDecorators(textValue, session, getSessionAgent(session));
  const runtimeTextValue = String(decoratorResolution.runtimeText || '').trim();
  if (!runtimeTextValue) return fail('empty_message', '运行中插入内容不能为空。');

  if (msg.mode && ['default', 'plan', 'yolo'].includes(msg.mode)) {
    session.permissionMode = msg.mode;
  }

  const persistedUserMessage = {
    role: 'user',
    content: textValue,
    attachments: [],
    timestamp: new Date().toISOString(),
  };
  if (decoratorResolution.mentions.length > 0) {
    persistedUserMessage.composerMentions = decoratorResolution.mentions;
  }
  session.messages.push(persistedUserMessage);
  session.updated = new Date().toISOString();
  saveSession(session);

  if (ws) {
    detachWsFromActiveRuntimes(ws);
    entry.ws = ws;
    entry.wsDisconnectTime = null;
    wsSessionMap.set(ws, sessionId);
  }
  if (ws && options.emitUserMessage) {
    wsSend(ws, { type: 'session_message', sessionId, message: persistedUserMessage });
  }

  sendSteerStatus('pending', '引导中...');
  const input = codexAppInputFromMessage(runtimeTextValue, []);
  const expectedTurnId = entry.turnId;
  codexAppClient.request('turn/steer', {
    threadId: entry.threadId,
    expectedTurnId,
    input,
    clientUserMessageId: clientMessageId || crypto.randomUUID(),
  }, 60000).then(() => {
    sendSteerStatus('inserted', '已插入');
    wsSend(entry.ws || ws, {
      type: 'system_message',
      sessionId,
      tone: 'info',
      transient: true,
      autoDismissMs: 5000,
      message: `已引导对话: ${previewInlineText(textValue)}`,
    });
  }).catch((err) => {
    sendSteerStatus('failed', '插入失败');
    wsSend(entry.ws || ws, {
      type: 'error',
      sessionId,
      code: 'codexapp_steer_failed',
      transient: true,
      autoDismissMs: 7000,
      message: formatRuntimeError('codex', err?.message || err, { exitCode: null, signal: null }),
    });
  });

  sendSessionList(ws);
  return { ok: true, sessionId };
}

function handleCodexAppAbortSession(sessionId, ws = null) {
  const entry = activeCodexAppTurns.get(sessionId);
  if (!entry) return false;
  if (ws) entry.ws = ws;
  entry.userAborted = true;
  plog('INFO', 'codex_app_user_abort', {
    sessionId: sessionId.slice(0, 8),
    threadId: entry.threadId || null,
    turnId: entry.turnId || null,
  });

  if (!entry.threadId || !entry.turnId || !codexAppClient || !codexAppClient.isRunning()) {
    handleCodexAppTurnComplete(sessionId, { interrupted: true });
    return true;
  }

  codexAppClient.request('turn/interrupt', {
    threadId: entry.threadId,
    turnId: entry.turnId,
  }, 30000).then(() => {
    setTimeout(() => {
      if (activeCodexAppTurns.has(sessionId)) {
        handleCodexAppTurnComplete(sessionId, { interrupted: true });
      }
    }, 2000);
  }).catch((err) => {
    handleCodexAppTurnFailure(sessionId, err);
  });

  return true;
}

// === Check Update ===
function handleCheckUpdate(ws) {
  const localVersion = (() => {
    try {
      const cl = fs.readFileSync(path.join(APP_DIR, 'CHANGELOG.md'), 'utf8');
      const m = cl.match(/##\s*v([\d.]+)/) || cl.match(/\*\*v([\d.]+)\*\*/);
      if (m) return m[1];
    } catch {}
    try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || 'unknown'; } catch {}
    return 'unknown';
  })();

  const https = require('https');
  const options = {
    hostname: 'raw.githubusercontent.com',
    path: '/ZgDaniel/cc-web/main/CHANGELOG.md',
    headers: { 'User-Agent': 'cc-web-update-check' },
    timeout: 10000,
  };

  const req = https.request(options, (res) => {
    let body = '';
    res.on('data', c => body += c);
    res.on('end', () => {
      if (res.statusCode !== 200) {
        return wsSend(ws, { type: 'update_info', localVersion, error: `HTTP ${res.statusCode}` });
      }
      const m = body.match(/##\s*v([\d.]+)/) || body.match(/\*\*v([\d.]+)\*\*/);
      const latest = m ? m[1] : null;
      if (!latest) {
        return wsSend(ws, { type: 'update_info', localVersion, error: '无法解析远端版本号' });
      }
      const hasUpdate = latest !== localVersion;
      wsSend(ws, {
        type: 'update_info',
        localVersion,
        latestVersion: latest,
        hasUpdate,
        releaseUrl: 'https://github.com/ZgDaniel/cc-web',
      });
    });
  });
  req.on('error', (e) => {
    wsSend(ws, { type: 'update_info', localVersion, error: '网络请求失败: ' + e.message });
  });
  req.on('timeout', () => {
    req.destroy();
    wsSend(ws, { type: 'update_info', localVersion, error: '请求超时' });
  });
  req.end();
}

// === Native Session Import ===

const CLAUDE_PROJECTS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');
const CODEX_STATE_DB_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'state_5.sqlite');
const CODEX_LOG_DB_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'logs_1.sqlite');

function resolveClaudeSessionLocalMeta(claudeSessionId) {
  if (!claudeSessionId) return null;
  try {
    const dirs = fs.readdirSync(CLAUDE_PROJECTS_DIR).filter((dir) => {
      try { return fs.statSync(path.join(CLAUDE_PROJECTS_DIR, dir)).isDirectory(); } catch { return false; }
    });
    for (const dir of dirs) {
      const filePath = path.join(CLAUDE_PROJECTS_DIR, dir, `${sanitizeId(claudeSessionId)}.jsonl`);
      if (!fs.existsSync(filePath)) continue;
      try {
        const content = fs.readFileSync(filePath, 'utf8');
        const lines = content.split('\n');
        let cwd = null;
        for (const line of lines) {
          const trimmed = line.trim();
          if (!trimmed) continue;
          try {
            const entry = JSON.parse(trimmed);
            if (entry.type === 'user' && entry.cwd) {
              cwd = entry.cwd;
              break;
            }
          } catch {}
        }
        return { cwd, projectDir: dir, filePath };
      } catch {}
    }
  } catch {}
  return null;
}

function parseJsonlToMessages(lines) {
  const messages = [];
  for (const line of lines) {
    const trimmed = line.trim();
    if (!trimmed) continue;
    let entry;
    try { entry = JSON.parse(trimmed); } catch { continue; }
    if (entry.type === 'user') {
      const raw = entry.message?.content;
      let content = '';
      if (typeof raw === 'string') {
        content = raw;
      } else if (Array.isArray(raw)) {
        // skip tool_result blocks, only take text blocks
        content = raw
          .filter(b => b.type === 'text')
          .map(b => b.text || '')
          .join('');
      }
      if (content.trim()) {
        messages.push({ role: 'user', content, timestamp: entry.timestamp || null });
      }
    } else if (entry.type === 'assistant') {
      const blocks = entry.message?.content;
      if (!Array.isArray(blocks)) continue;
      let content = '';
      const toolCalls = [];
      for (const b of blocks) {
        if (b.type === 'text' && b.text) {
          content += b.text;
        } else if (b.type === 'tool_use') {
          toolCalls.push({ name: b.name, id: b.id, input: b.input, done: true });
        }
        // skip thinking blocks
      }
      if (content.trim() || toolCalls.length > 0) {
        messages.push({ role: 'assistant', content, toolCalls, timestamp: entry.timestamp || null });
      }
    }
    // skip other types
  }
  return messages;
}

const {
  parseCodexRolloutLines,
  getCodexRolloutFiles,
  getImportedCodexThreadIds,
  parseCodexRolloutFile,
} = createCodexRolloutStore({
  codexSessionsDir: CODEX_SESSIONS_DIR,
  sessionsDir: SESSIONS_DIR,
  normalizeSession,
  sanitizeToolInput,
});

function getImportedSessionIds() {
  const imported = new Set();
  try {
    for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
      try {
        const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
        if (s.claudeSessionId) imported.add(s.claudeSessionId);
      } catch {}
    }
  } catch {}
  return imported;
}

function handleListNativeSessions(ws) {
  const groups = [];
  try {
    const imported = getImportedSessionIds();
    const dirs = fs.readdirSync(CLAUDE_PROJECTS_DIR).filter(d => {
      try { return fs.statSync(path.join(CLAUDE_PROJECTS_DIR, d)).isDirectory(); } catch { return false; }
    });
    for (const dir of dirs) {
      const dirPath = path.join(CLAUDE_PROJECTS_DIR, dir);
      const sessionItems = [];
      try {
        const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
        for (const f of files) {
          const sessionId = f.replace('.jsonl', '');
          const filePath = path.join(dirPath, f);
          try {
            const content = fs.readFileSync(filePath, 'utf8');
            const lines = content.split('\n');
            // Find first user message for title
            let title = sessionId.slice(0, 20);
            let cwd = null;
            let updatedAt = null;
            let lastTs = null;
            for (const line of lines) {
              const t = line.trim();
              if (!t) continue;
              try {
                const e = JSON.parse(t);
                if (e.timestamp) lastTs = e.timestamp;
                if (e.type === 'user' && !cwd) {
                  cwd = e.cwd || null;
                  const raw = e.message?.content;
                  let text = '';
                  if (typeof raw === 'string') text = raw;
                  else if (Array.isArray(raw)) text = raw.filter(b => b.type === 'text').map(b => b.text || '').join('');
                  if (text.trim()) title = text.trim().slice(0, 80).replace(/\n/g, ' ');
                }
              } catch {}
            }
            updatedAt = lastTs;
            sessionItems.push({ sessionId, title, cwd, updatedAt, alreadyImported: imported.has(sessionId) });
          } catch {}
        }
      } catch {}
      if (sessionItems.length > 0) {
        sessionItems.sort((a, b) => {
          if (!a.updatedAt) return 1;
          if (!b.updatedAt) return -1;
          return new Date(b.updatedAt) - new Date(a.updatedAt);
        });
        groups.push({ dir, sessions: sessionItems });
      }
    }
  } catch {}
  wsSend(ws, { type: 'native_sessions', groups });
}

function handleImportNativeSession(ws, msg) {
  const { sessionId, projectDir } = msg;
  if (!sessionId || !projectDir) {
    return wsSend(ws, { type: 'error', message: '缺少 sessionId 或 projectDir' });
  }
  const filePath = path.join(CLAUDE_PROJECTS_DIR, String(projectDir), `${sanitizeId(sessionId)}.jsonl`);
  if (!filePath.startsWith(CLAUDE_PROJECTS_DIR)) {
    return wsSend(ws, { type: 'error', message: '非法路径' });
  }
  let content;
  try { content = fs.readFileSync(filePath, 'utf8'); } catch {
    return wsSend(ws, { type: 'error', message: '无法读取会话文件' });
  }
  const lines = content.split('\n');
  const messages = parseJsonlToMessages(lines);

  // Find or create cc-web session with this claudeSessionId
  let existingSession = null;
  try {
    for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
      try {
        const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
        if (s.claudeSessionId === sessionId) { existingSession = s; break; }
      } catch {}
    }
  } catch {}

  // Determine title and cwd from messages/raw
  let title = sessionId.slice(0, 20);
  let cwd = null;
  for (const line of lines) {
    const t = line.trim();
    if (!t) continue;
    try {
      const e = JSON.parse(t);
      if (e.type === 'user') {
        if (!cwd) cwd = e.cwd || null;
        const raw = e.message?.content;
        let text = '';
        if (typeof raw === 'string') text = raw;
        else if (Array.isArray(raw)) text = raw.filter(b => b.type === 'text').map(b => b.text || '').join('');
        if (text.trim()) { title = text.trim().slice(0, 60).replace(/\n/g, ' '); break; }
      }
    } catch {}
  }

  const id = existingSession ? existingSession.id : crypto.randomUUID();
  const session = {
    id,
    title,
    created: existingSession?.created || new Date().toISOString(),
    updated: new Date().toISOString(),
    pinnedAt: existingSession?.pinnedAt || null,
    agent: 'claude',
    claudeSessionId: sessionId,
    codexThreadId: null,
    importedFrom: projectDir,
    model: existingSession?.model || null,
    permissionMode: existingSession?.permissionMode || 'yolo',
    totalCost: existingSession?.totalCost || 0,
    totalUsage: existingSession?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
    messages,
    cwd: cwd || existingSession?.cwd || null,
  };
  saveSession(session);
  wsSessionMap.set(ws, id);
  const transportMessages = sanitizeMessagesForTransport(session.messages);
  wsSend(ws, attachClientRequestId({
    type: 'session_info',
    sessionId: id,
    messages: transportMessages,
    title: session.title,
    pinnedAt: session.pinnedAt || null,
    ...publicTitleMetadata(session),
    mode: session.permissionMode,
    model: sessionModelLabel(session),
    agent: getSessionAgent(session),
    cwd: session.cwd,
    totalCost: session.totalCost || 0,
    totalUsage: session.totalUsage || null,
    updated: session.updated,
    historyTotal: transportMessages.length,
    historyBaseIndex: 0,
    hasUnread: false,
    historyPending: false,
    isRunning: false,
  }, msg));
  sendSessionList(ws);
}

function resolveCodexImportAgent(value) {
  return value === 'codexapp' ? 'codexapp' : 'codex';
}

function codexImportSourceLabel(source) {
  if (!source) return '';
  if (typeof source === 'string') return source;
  if (source && typeof source === 'object') {
    if (source.subagent?.thread_spawn) return 'subagent';
    if (source.type) return String(source.type);
  }
  return '';
}

function extractCcwebSourceConversation(title) {
  const match = String(title || '').match(/^来自「([^」]+)」对话（ID:\s*([0-9a-fA-F-]{36})）的消息：/);
  if (!match) return null;
  return {
    id: match[2].toLowerCase(),
    title: match[1],
  };
}

function codexImportDedupeKey(item) {
  if (item.sourceConversationId) return `ccweb-source:${item.sourceConversationId}`;
  return `thread:${item.threadId}`;
}

function codexImportItemTime(item) {
  const time = new Date(item?.updatedAt || 0).getTime();
  return Number.isFinite(time) ? time : 0;
}

function preferCodexImportItem(next, current) {
  const nextTime = codexImportItemTime(next);
  const currentTime = codexImportItemTime(current);
  if (nextTime !== currentTime) return nextTime > currentTime ? next : current;
  return String(next.rolloutPath || '') > String(current.rolloutPath || '') ? next : current;
}

function handleListCodexSessions(ws, msg = {}) {
  const importAgent = resolveCodexImportAgent(msg?.agent);
  const imported = getImportedCodexThreadIds(importAgent);
  const itemsByKey = new Map();
  const seen = new Set();
  for (const filePath of getCodexRolloutFiles()) {
    const parsed = parseCodexRolloutFile(filePath);
    if (!parsed?.meta?.threadId) continue;
    if (seen.has(parsed.meta.threadId)) continue;
    seen.add(parsed.meta.threadId);
    const title = parsed.meta.title || parsed.meta.threadId.slice(0, 20);
    const sourceConversation = parsed.meta.sourceConversationId
      ? {
          id: parsed.meta.sourceConversationId,
          title: parsed.meta.sourceConversationTitle || '',
        }
      : extractCcwebSourceConversation(title);
    const item = {
      threadId: parsed.meta.threadId,
      title,
      cwd: parsed.meta.cwd || null,
      updatedAt: parsed.meta.updatedAt || null,
      cliVersion: parsed.meta.cliVersion || '',
      source: codexImportSourceLabel(parsed.meta.source),
      sourceConversationId: sourceConversation?.id || null,
      sourceConversationTitle: sourceConversation?.title || '',
      duplicateCount: 1,
      rolloutPath: filePath,
      agent: importAgent,
      alreadyImported: imported.has(parsed.meta.threadId),
    };
    const dedupeKey = codexImportDedupeKey(item);
    const current = itemsByKey.get(dedupeKey);
    if (!current) {
      itemsByKey.set(dedupeKey, item);
      continue;
    }
    const preferred = preferCodexImportItem(item, current);
    preferred.duplicateCount = (current.duplicateCount || 1) + 1;
    itemsByKey.set(dedupeKey, preferred);
  }
  const items = Array.from(itemsByKey.values()).sort((a, b) => {
    const timeDiff = codexImportItemTime(b) - codexImportItemTime(a);
    if (timeDiff) return timeDiff;
    return String(b.rolloutPath || '').localeCompare(String(a.rolloutPath || ''));
  });
  wsSend(ws, { type: 'codex_sessions', sessions: items });
}

function handleImportCodexSession(ws, msg) {
  const threadId = String(msg?.threadId || '').trim();
  const importAgent = resolveCodexImportAgent(msg?.agent);
  if (!threadId) {
    return wsSend(ws, { type: 'error', message: '缺少 threadId' });
  }

  let parsed = null;
  const requestedPath = msg?.rolloutPath ? path.resolve(String(msg.rolloutPath)) : '';
  if (requestedPath && requestedPath.startsWith(CODEX_SESSIONS_DIR) && fs.existsSync(requestedPath)) {
    parsed = parseCodexRolloutFile(requestedPath);
  }
  if (!parsed) {
    for (const filePath of getCodexRolloutFiles()) {
      const candidate = parseCodexRolloutFile(filePath);
      if (candidate?.meta?.threadId === threadId) {
        parsed = candidate;
        break;
      }
    }
  }

  if (!parsed || parsed.meta.threadId !== threadId) {
    return wsSend(ws, { type: 'error', message: '未找到对应的 Codex 会话文件' });
  }

  let existingSession = null;
  try {
    for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
      try {
        const s = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')));
        const runtimeThreadId = importAgent === 'codexapp' ? s.codexAppThreadId : s.codexThreadId;
        if (runtimeThreadId === threadId) { existingSession = s; break; }
      } catch {}
    }
  } catch {}

  const id = existingSession ? existingSession.id : crypto.randomUUID();
  const session = {
    id,
    title: parsed.meta.title || existingSession?.title || threadId.slice(0, 20),
    created: existingSession?.created || new Date().toISOString(),
    updated: new Date().toISOString(),
    pinnedAt: existingSession?.pinnedAt || null,
    agent: importAgent,
    claudeSessionId: null,
    codexThreadId: importAgent === 'codex' ? threadId : null,
    codexAppThreadId: importAgent === 'codexapp' ? threadId : null,
    importedFrom: importAgent,
    importedRolloutPath: parsed.filePath,
    model: existingSession?.model || null,
    permissionMode: existingSession?.permissionMode || 'yolo',
    totalCost: existingSession?.totalCost || 0,
    totalUsage: parsed.totalUsage || existingSession?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
    messages: parsed.messages,
    cwd: parsed.meta.cwd || existingSession?.cwd || null,
  };

  saveSession(session);
  wsSessionMap.set(ws, id);
  const transportMessages = sanitizeMessagesForTransport(session.messages);
  wsSend(ws, attachClientRequestId({
    type: 'session_info',
    sessionId: id,
    messages: transportMessages,
    title: session.title,
    pinnedAt: session.pinnedAt || null,
    ...publicTitleMetadata(session),
    mode: session.permissionMode,
    model: sessionModelLabel(session),
    agent: getSessionAgent(session),
    cwd: session.cwd,
    totalCost: session.totalCost || 0,
    totalUsage: session.totalUsage || null,
    updated: session.updated,
    historyTotal: transportMessages.length,
    historyBaseIndex: 0,
    hasUnread: false,
    historyPending: false,
    isRunning: false,
  }, msg));
  sendSessionList(ws);
}

function handleListCwdSuggestions(ws) {
  const defaultPath = getDefaultSessionCwd();
  const paths = collectRecentSessionCwds(12).filter((candidate) => candidate !== defaultPath);
  wsSend(ws, { type: 'cwd_suggestions', defaultPath, paths });
}

// === Startup ===
loadCrossConversationReplies();
recoverProcesses();
reconcilePendingCrossConversationReplies();

// Periodic heartbeat: log active processes status every 60s
setInterval(() => {
  if (activeProcesses.size === 0) return;
  const procs = [];
  for (const [sid, entry] of activeProcesses) {
    const alive = isProcessRunning(entry.pid);
    procs.push({
      sessionId: sid.slice(0, 8),
      pid: entry.pid,
      alive,
      wsConnected: !!entry.ws,
      wsDisconnectTime: entry.wsDisconnectTime || null,
      responseLen: (entry.fullText || '').length,
    });
  }
  plog('INFO', 'heartbeat', { activeCount: procs.length, wsClients: wss.clients.size, processes: procs });
}, 60000);

plog('INFO', 'server_start', { port: PORT });

server.listen(PORT, '0.0.0.0', () => {
  console.log(`CC-Web server listening on 0.0.0.0:${PORT}`);
});
