#!/usr/bin/env node
/**
 * Persistent Chat Hub — 唯一 HTTP 进程（端口 13458）
 * 所有 Cursor 工作区的 MCP 进程共享本 Hub，避免多进程抢端口导致面板回复进错进程。
 */
'use strict';

const fs = require('fs');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const os = require('os');
const { URL } = require('url');

const HOME = process.env.HOME || process.env.USERPROFILE;
const ROOT = path.join(HOME, '.persistent-chat-local');
const LOG_DIR = path.join(ROOT, 'logs');
const SESSIONS_FILE = path.join(ROOT, 'sessions.json');
const BINDINGS_FILE = path.join(ROOT, 'bindings.json');
const PLAN_BINDINGS_FILE = path.join(ROOT, 'plan-bindings.json');
const THREAD_BINDINGS_DIR = path.join(ROOT, 'thread-bindings');
const PANEL_HTML = path.join(__dirname, 'panel.html');
const HTTP_PORT = parseInt(process.env.PCHAT_HTTP_PORT || '13458', 10);
const POLL_TIMEOUT_MS = 10000;
const CONNECTION_KEEPALIVE_MS = 10000;
/** 超过此时长无保活且未挂起 → 归档到 archive/ */
const STALE_KEEPALIVE_MS = 2 * 60 * 60 * 1000;
const ARCHIVE_DIR = path.join(ROOT, 'archive');
const SETTINGS_FILE = path.join(ROOT, 'settings.json');
const DEFAULT_SETTINGS = {
  autoContinue: true,
  /** 全局默认续跑模式（单会话可覆盖）：standard=复盘 | turbo=直通 | optimize=优化 */
  autoContinueMode: 'standard',
  /** 单会话默认：自动续跑开、复盘模式 */
  defaultSessionAutoContinue: true,
  defaultContinueMode: 'standard',
  autoContinueDelayMs: 10000,
  autoContinueText: '继续',
  turboContinueText: '【直通续跑】继续执行当前计划，直推开发直到目标完成，无需等复盘/追问/检索确认。',
  autoContinueMaxStreak: 0,
  /** 定时回复：0=自动（仅走 autoContinue 规则）；>0 为固定间隔 */
  timedReplyEnabled: true,
  defaultTimedReplyEnabled: true,
  timedReplyIntervalMs: 10000,
  connectionKeepAliveMs: 10000,
  timedReplyPresets: [0, 10000, 30000, 60000, 120000, 240000, 540000, 900000, 1800000],
  timedReplyText: '【定时回复】保持持久对话连接，若仍有任务请继续执行。',
  timedReplyTurboText: '【定时续跑】保持连接，继续执行当前计划，直推开发直到目标完成。',
  /** 为 true 时：▶=无 / 100% 仍发保活续跑，不因 idle 停 scheduleAutoContinue */
  alwaysKeepAlive: true,
  keepAliveText: '【保活续跑】保持持久对话连接，继续执行当前任务与学习清单，按复盘▶推进，勿停。',
  keepAliveTurboText: '【保活续跑】保持连接，继续执行当前计划与学习清单，直推开发直到目标完成。',
  maxSessions: 30,
  maxIdleAgeDays: 14,
};

for (const d of [ROOT, LOG_DIR, ARCHIVE_DIR]) {
  try { fs.mkdirSync(d, { recursive: true }); } catch {}
}

const logFile = path.join(LOG_DIR, 'hub.log');
function log(...parts) {
  const line = `[${new Date().toISOString()}] ${parts.map((p) => (typeof p === 'string' ? p : JSON.stringify(p))).join(' ')}\n`;
  try { fs.appendFileSync(logFile, line); } catch {}
}

const sessions = new Map();
/** token -> { message, prompt, title, createdAt, workspace, pid, pollers, autoTimer?, timedReplyTimer?, timedReplyFireAt? } */
const pendingWaits = new Map();
const TIMED_REPLY_MIN_MS = 5000;
/** 全局唯一：只有这个 token 允许自动续跑，避免多标签乱继续 */
let activeWaitToken = null;
let hubSettings = { ...DEFAULT_SETTINGS };
/** bindingKey -> { token, updatedAt }  一会话一线程 权威映射 */
let bindingRegistry = {};
/** planBindingKey (ws::title@ip#plan) -> { token, planLabel, cursorTitle, updatedAt } */
let planRegistry = {};

function normPlanLabel(label) {
  const t = String(label || '').trim();
  return t || '(默认计划)';
}

function normalizeExactToken(raw) {
  const t = String(raw || '').trim();
  const m = t.match(/^(ct_[0-9a-f]{8})$/i);
  return m ? m[1].toLowerCase() : null;
}

function makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) {
  return `${makeBindingKey(workspace, cursorTitle, hostIp)}#${normPlanLabel(planLabel)}`;
}

function loadPlanRegistry() {
  try {
    planRegistry = JSON.parse(fs.readFileSync(PLAN_BINDINGS_FILE, 'utf8'));
  } catch {
    planRegistry = {};
  }
}

function savePlanRegistry() {
  try { fs.writeFileSync(PLAN_BINDINGS_FILE, JSON.stringify(planRegistry, null, 2)); } catch (e) {
    log('plan-bindings save failed', String(e.message || e));
  }
}

function registerPlanBinding(workspace, cursorTitle, hostIp, planLabel, token) {
  if (!cursorTitle || !token) return;
  const pk = makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel);
  planRegistry[pk] = {
    token,
    planLabel: normPlanLabel(planLabel),
    cursorTitle: normTitle(cursorTitle),
    workspace: normWorkspace(workspace),
    hostIp: normHostIp(hostIp),
    updatedAt: nowMs(),
  };
  planRegistry[token] = { planBindingKey: pk, updatedAt: nowMs() };
  savePlanRegistry();
  const s = sessions.get(token);
  if (s) {
    s.planLabel = normPlanLabel(planLabel);
    s.planBindingKey = pk;
  }
}

function getTokenByPlan(workspace, cursorTitle, hostIp, planLabel) {
  const pk = makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel);
  const hit = planRegistry[pk];
  if (!hit || !hit.token) return null;
  const tok = normalizeExactToken(hit.token);
  if (!tok || !sessions.has(tok) || sessions.get(tok).status === 'completed') return null;
  return tok;
}

function getPlanByToken(token) {
  const tok = normalizeExactToken(token);
  if (!tok) return null;
  const rev = planRegistry[tok];
  if (rev && rev.planBindingKey && planRegistry[rev.planBindingKey]) {
    return planRegistry[rev.planBindingKey];
  }
  const s = sessions.get(tok);
  if (!s) return null;
  return {
    token: tok,
    planLabel: s.planLabel || normPlanLabel(s.title),
    cursorTitle: s.cursorTitle || s.title || '',
    workspace: s.workspace || '',
  };
}

function loadBindingRegistry() {
  try {
    bindingRegistry = JSON.parse(fs.readFileSync(BINDINGS_FILE, 'utf8'));
  } catch {
    bindingRegistry = {};
  }
}

function saveBindingRegistry() {
  try { fs.writeFileSync(BINDINGS_FILE, JSON.stringify(bindingRegistry, null, 2)); } catch (e) {
    log('bindings save failed', String(e.message || e));
  }
}

function getHostIp() {
  const ifs = os.networkInterfaces();
  for (const name of Object.keys(ifs)) {
    for (const iface of ifs[name] || []) {
      if (iface.family === 'IPv4' && !iface.internal) return iface.address;
    }
  }
  return '127.0.0.1';
}

function hubEndpointLabel() {
  return `127.0.0.1:${HTTP_PORT}`;
}

function normHostIp(ip) {
  const t = String(ip || '').trim();
  return t || getHostIp();
}

function legacyBindingKey(workspace, cursorTitle) {
  return `${normWorkspace(workspace)}::${normTitle(cursorTitle)}`;
}

function makeBindingKey(workspace, cursorTitle, hostIp) {
  const ip = normHostIp(hostIp);
  return `${legacyBindingKey(workspace, cursorTitle)}@${ip}`;
}

function lookupRegistry(workspace, cursorTitle, hostIp) {
  if (!cursorTitle) return null;
  const key = makeBindingKey(workspace, cursorTitle, hostIp);
  const hit = bindingRegistry[key];
  if (hit && hit.token) return { key, token: hit.token, hostIp: hit.hostIp || ipFromKey(key), hub: hit.hub || '' };
  const leg = legacyBindingKey(workspace, cursorTitle);
  const old = bindingRegistry[leg];
  if (old && old.token) return { key: leg, token: old.token, legacy: true, hostIp: old.hostIp || '', hub: old.hub || '' };
  return null;
}

function ipFromKey(key) {
  const at = String(key || '').lastIndexOf('@');
  return at >= 0 ? key.slice(at + 1) : '';
}

function registerBinding(workspace, cursorTitle, token, meta = {}) {
  if (!cursorTitle || !token) return;
  const hostIp = normHostIp(meta.hostIp);
  const hub = meta.hub || hubEndpointLabel();
  const key = makeBindingKey(workspace, cursorTitle, hostIp);
  bindingRegistry[key] = { token, hostIp, hub, updatedAt: nowMs() };
  const leg = legacyBindingKey(workspace, cursorTitle);
  if (leg !== key && bindingRegistry[leg]) delete bindingRegistry[leg];
  saveBindingRegistry();
  const s = sessions.get(token);
  if (s) {
    s.bindingKey = key;
    s.cursorTitle = cursorTitle;
    s.workspace = workspace || s.workspace;
    s.hostIp = hostIp;
    s.hub = hub;
    s.bindingLockedAt = s.bindingLockedAt || nowMs();
    s.bindingCheckCount = (s.bindingCheckCount || 0) + 1;
    const pl = meta.planLabel || s.planLabel || s.title;
    registerPlanBinding(workspace, cursorTitle, hostIp, pl, token);
  }
}

function getRegistryToken(workspace, cursorTitle, hostIp) {
  const hit = lookupRegistry(workspace, cursorTitle, hostIp);
  return hit ? hit.token : null;
}

function getTokenOwnerTitle(workspace, token) {
  if (!token) return null;
  const s = sessions.get(token);
  if (s?.cursorTitle) return normTitle(s.cursorTitle);
  const ws = normWorkspace(workspace);
  const prefix = `${ws}::`;
  for (const [key, hit] of Object.entries(bindingRegistry)) {
    if (hit && hit.token === token && key.startsWith(prefix)) {
      let rest = key.slice(prefix.length);
      const at = rest.lastIndexOf('@');
      if (at >= 0) rest = rest.slice(0, at);
      return normTitle(rest);
    }
  }
  return null;
}

/** Cursor 标签改名：同 ct_ 迁移到新标题，删除旧 bindingKey */
function migrateBindingRename(workspace, token, newTitle, hostIp) {
  if (!token || !newTitle) return { ok: false };
  const oldTitle = getTokenOwnerTitle(workspace, token);
  const newNorm = normTitle(newTitle);
  if (!oldTitle || oldTitle === newNorm) return { ok: false, renamed: false };
  const ws = normWorkspace(workspace);
  const ip = normHostIp(hostIp);
  const prefix = `${ws}::`;
  for (const key of Object.keys(bindingRegistry)) {
    if (!key.startsWith(prefix)) continue;
    if (bindingRegistry[key]?.token === token) delete bindingRegistry[key];
  }
  const s = sessions.get(token);
  const hub = (s && s.hub) || hubEndpointLabel();
  registerBinding(workspace, newTitle, token, { hostIp: ip, hub });
  if (s) {
    s.cursorTitle = newTitle;
    s.title = newTitle;
    s.bindingRenamedFrom = oldTitle;
    s.bindingRenamedAt = nowMs();
  }
  saveBindingRegistry();
  saveSessions();
  log('binding rename', { token, from: oldTitle, to: newNorm, hostIp: ip });
  return { ok: true, renamed: true, from: oldTitle, to: newNorm };
}

function buildBindingContext(workspace, cursorTitle, hostIp, planLabel) {
  const ip = normHostIp(hostIp);
  const titleNorm = normTitle(cursorTitle);
  let regToken = null;
  if (planLabel) {
    regToken = getTokenByPlan(workspace, cursorTitle, ip, planLabel);
  }
  if (!regToken) {
    const hit = lookupRegistry(workspace, cursorTitle, ip);
    regToken = hit ? hit.token : null;
  }
  const wsNorm = normWorkspace(workspace);
  const active = [];
  for (const [token, s] of sessions.entries()) {
    if (s.status === 'completed') continue;
    if (normWorkspace(s.workspace) !== wsNorm) continue;
    const st = normTitle(s.cursorTitle || s.title || '');
    if (titleNorm && titleNorm !== '(未命名标签)' && st !== titleNorm) continue;
    active.push({
      token,
      title: s.title || s.cursorTitle || '',
      cursorTitle: s.cursorTitle || s.title || '',
      planLabel: s.planLabel || '',
      hostIp: s.hostIp || '',
      hub: s.hub || '',
      isActiveWait: token === activeWaitToken,
      status: pendingWaits.has(token) ? 'waiting' : (s.status || 'idle'),
    });
  }
  const mode = regToken ? 'reuse' : (active.length > 0 ? 'choice' : 'auto_new');
  return {
    mode,
    hostIp: ip,
    hub: hubEndpointLabel(),
    hubUrl: `http://${hubEndpointLabel()}/`,
    bindingKey: cursorTitle ? makeBindingKey(workspace, cursorTitle, ip) : '',
    planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, ip, planLabel) : '',
    boundToken: regToken,
    planLabel: planLabel ? normPlanLabel(planLabel) : '',
    activeSessions: active,
  };
}

function validateTokenBinding(token, workspace, cursorTitle, hostIp, opts = {}) {
  if (!cursorTitle || normTitle(cursorTitle) === '(未命名标签)') {
    return { ok: true };
  }
  const titleNorm = normTitle(cursorTitle);
  const ipNorm = normHostIp(hostIp);
  const key = makeBindingKey(workspace, cursorTitle, ipNorm);
  const s = sessions.get(token);
  if (!s) {
    return {
      ok: false,
      error: 'UNKNOWN_CONVERSATION_TOKEN',
      bindingKey: key,
      got: token,
      hint: `完整 token ${token} 不在 Hub 会话库/绑定库中；找不到时才允许新建计划。`,
    };
  }
  if (s?.hostIp && normHostIp(s.hostIp) !== ipNorm) {
    return {
      ok: false,
      error: 'HOST_IP_MISMATCH',
      bindingKey: key,
      expectedHostIp: s.hostIp,
      gotHostIp: ipNorm,
      hint: `ct_${token.slice(3)} 绑定在 IP ${s.hostIp}，当前连接 ${ipNorm}，禁止跨机串线。`,
    };
  }
  const ownerTitle = getTokenOwnerTitle(workspace, token);
  if (ownerTitle && ownerTitle !== titleNorm) {
    if (opts.allowTitleRename) {
      const mig = migrateBindingRename(workspace, token, cursorTitle, ipNorm);
      if (mig.renamed) {
        return { ok: true, titleRenamed: true, from: mig.from, to: mig.to };
      }
    }
    return {
      ok: false,
      error: 'TOKEN_BOUND_TO_OTHER_TITLE',
      bindingKey: key,
      tokenOwnerTitle: ownerTitle,
      gotTitle: titleNorm,
      expected: getRegistryToken(workspace, cursorTitle, ipNorm) || '(未绑定，请 init)',
      got: token,
      hint: `ct_${token.slice(3)} 已绑定标签「${ownerTitle}」，禁止在「${cursorTitle}」使用。请 init_conversation(cursorTitle=当前标签名, newPlan:true)。`,
    };
  }
  const regToken = getRegistryToken(workspace, cursorTitle, ipNorm);
  const plan = getPlanByToken(token);
  const planOwnsToken = !!(
    plan &&
    plan.token === token &&
    normWorkspace(plan.workspace) === normWorkspace(workspace) &&
    normTitle(plan.cursorTitle) === titleNorm
  );
  if (regToken && regToken !== token && !planOwnsToken) {
    return {
      ok: false,
      error: 'WRONG_TOKEN_FOR_BINDING',
      bindingKey: key,
      expected: regToken,
      got: token,
      hint: `标签「${cursorTitle}」已锁定 ${regToken}，禁止串用 ${token}；若这是旧计划，请带完整 token 且保持 planLabel 绑定。`,
    };
  }
  if (s?.bindingKey && s.bindingKey !== key && s.bindingKey !== legacyBindingKey(workspace, cursorTitle)) {
    return {
      ok: false,
      error: 'TOKEN_BINDING_MISMATCH',
      expected: s.bindingKey,
      got: key,
      hint: `ct_${token.slice(3)} 属于其它标签，本标签应使用 ${regToken || '对应 ct_'}`,
    };
  }
  if (s) {
    s.bindingCheckCount = (s.bindingCheckCount || 0) + 1;
    s.lastBindingCheckAt = nowMs();
  }
  return {
    ok: true,
    bindingKey: key,
    hostIp: ipNorm,
    hub: hubEndpointLabel(),
    bindingUnchanged: true,
    bindingCheckCount: s?.bindingCheckCount || 0,
  };
}

function normWorkspace(ws) {
  return String(ws || '').replace(/\/+$/, '') || '(unknown)';
}

function normTitle(title) {
  const t = String(title || '').trim();
  return t || '(未命名标签)';
}

function findBoundSession(workspace, cursorTitle, hostIp, planLabel) {
  if (!cursorTitle) return null;
  if (planLabel) {
    const byPlan = getTokenByPlan(workspace, cursorTitle, hostIp, planLabel);
    if (byPlan) return byPlan;
  }
  const reg = getRegistryToken(workspace, cursorTitle, hostIp);
  if (reg) {
    const tok = normalizeExactToken(reg);
    if (tok && sessions.has(tok) && sessions.get(tok).status !== 'completed') return tok;
  }
  return null;
}

/** 仅标记 MCP 最近挂起的 ct_；不清其它会话的续跑/定时器（多标签并行保活） */
function setActiveWaitToken(token) {
  activeWaitToken = token;
  log('activeWaitToken', token);
}

function parseTitleFromBindingKey(key) {
  const at = String(key || '').lastIndexOf('@');
  const left = at >= 0 ? key.slice(0, at) : key;
  const idx = left.indexOf('::');
  return idx >= 0 ? normTitle(left.slice(idx + 2)) : normTitle(left);
}

function parseWorkspaceFromBindingKey(key) {
  const at = String(key || '').lastIndexOf('@');
  const left = at >= 0 ? key.slice(0, at) : key;
  const idx = left.indexOf('::');
  return idx >= 0 ? normWorkspace(left.slice(0, idx)) : '(unknown)';
}

/** bindings.json 有、sessions 无 → 恢复占位会话，避免面板丢 ct_、乱绑 */
/** 从 thread-bindings/*.json 回填丢失的 bindings.json（防误删/多进程写丢） */
function rebuildBindingRegistryFromThreadFiles() {
  if (!fs.existsSync(THREAD_BINDINGS_DIR)) return 0;
  let n = 0;
  for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) {
    if (!name.endsWith('.json')) continue;
    try {
      const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8'));
      const token = hit && hit.token;
      const key = hit && hit.bindingKey;
      if (!token || !key) continue;
      const cur = bindingRegistry[key];
      if (cur && cur.token && cur.token !== token) continue;
      if (!cur || cur.token === token) {
        bindingRegistry[key] = {
          token,
          hostIp: hit.hostIp || ipFromKey(key) || getHostIp(),
          hub: hit.hub || hubEndpointLabel(),
          updatedAt: hit.updatedAt || nowMs(),
        };
        n++;
      }
    } catch (e) {
      log('thread-binding read skip', name, String(e.message || e));
    }
  }
  if (n > 0) {
    saveBindingRegistry();
    log('rebuild bindings from thread files', { addedOrUpdated: n });
  }
  return n;
}

function isVerticallyLockedToken(token) {
  if (!token || !fs.existsSync(THREAD_BINDINGS_DIR)) return false;
  try {
    for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) {
      if (!name.endsWith('.json')) continue;
      const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8'));
      if (hit && hit.token === token && hit.verticalLock) return true;
    }
  } catch {}
  return false;
}

function restoreSessionsFromBindings() {
  rebuildBindingRegistryFromThreadFiles();
  let n = 0;
  for (const [key, hit] of Object.entries(bindingRegistry)) {
    if (!hit || !hit.token) continue;
    if (sessions.has(hit.token)) continue;
    const title = parseTitleFromBindingKey(key);
    const workspace = parseWorkspaceFromBindingKey(key);
    const hostIp = hit.hostIp || ipFromKey(key) || getHostIp();
    sessions.set(hit.token, {
      token: hit.token,
      title,
      planLabel: '',
      cursorTitle: title,
      bindingKey: key,
      hostIp,
      hub: hit.hub || hubEndpointLabel(),
      workspace,
      status: 'idle',
      phase: 'running',
      preRun: null,
      optimizeRun: null,
      sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false,
      continueMode: normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode),
      timedReplyEnabled: hubSettings.defaultTimedReplyEnabled !== false,
      timedReplyIntervalMs: hubSettings.timedReplyIntervalMs != null ? hubSettings.timedReplyIntervalMs : 0,
      bindingCheckCount: 0,
      bindingLockedAt: hit.updatedAt || nowMs(),
      createdAt: hit.updatedAt || nowMs(),
      lastActiveAt: hit.updatedAt || nowMs(),
      messages: [],
      restoredFromBinding: true,
      connectionHeld: false,
      status: 'idle',
    });
    n++;
    log('restore session from binding', hit.token, key);
  }
  if (n > 0) saveSessions();
  return n;
}

function registryTokenForSession(token, s) {
  if (!s) return token;
  const key = s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title, s.hostIp);
  const byKey = bindingRegistry[key]?.token;
  if (byKey) return byKey;
  return getRegistryToken(s.workspace, s.cursorTitle || s.title, s.hostIp) || token;
}

function bindingOkForSession(token, s) {
  if (!s) return true;
  const reg = registryTokenForSession(token, s);
  return reg === token;
}

function clearActiveWaitToken(token) {
  if (activeWaitToken === token) activeWaitToken = null;
}

function dedupeBindingSessions() {
  const groups = new Map();
  for (const [token, s] of sessions.entries()) {
    if (s.status === 'completed') continue;
    if (pendingWaits.has(token)) continue;
    const key = s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title);
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push({ token, s });
  }
  let removed = 0;
  for (const [, arr] of groups) {
    if (arr.length <= 1) continue;
    arr.sort((a, b) => (b.s.lastActiveAt || 0) - (a.s.lastActiveAt || 0));
    for (let i = 1; i < arr.length; i++) {
      const { token, s } = arr[i];
      if ((s.messages || []).length <= 2 && s.status === 'idle') {
        sessions.delete(token);
        removed++;
      }
    }
  }
  if (removed > 0) {
    saveSessions();
    log('dedupe bindings', { removed });
  }
  return removed;
}

function loadSettings() {
  try {
    hubSettings = { ...DEFAULT_SETTINGS, ...JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) };
  } catch {
    hubSettings = { ...DEFAULT_SETTINGS };
  }
  if (Number(hubSettings.autoContinueDelayMs) === 400) {
    hubSettings.autoContinueDelayMs = 10000;
    saveSettings();
  }
}

function saveSettings() {
  try { fs.writeFileSync(SETTINGS_FILE, JSON.stringify(hubSettings, null, 2)); } catch (e) {
    log('settings save failed', String(e.message || e));
  }
}

loadSettings();

function loadSessions() {
  try {
    const raw = fs.readFileSync(SESSIONS_FILE, 'utf8');
    for (const [k, v] of Object.entries(JSON.parse(raw))) sessions.set(k, v);
    log('sessions loaded', { count: sessions.size });
  } catch (e) { log('sessions load skip', String(e.message || e)); }
}

let lastMongoSyncAt = 0;
const MONGO_SYNC_MIN_MS = 120000;

function syncMongoAsync() {
  try {
    const now = nowMs();
    if (now - lastMongoSyncAt < MONGO_SYNC_MIN_MS) return;
    lastMongoSyncAt = now;
    const syncScript = path.join(__dirname, 'mongo_sync.py');
    if (!fs.existsSync(syncScript)) return;
    const { spawn } = require('child_process');
    spawn('python3', [syncScript], { detached: true, stdio: 'ignore' }).unref();
  } catch {}
}

/** 面板 /api/state 轮询勿做重 IO；维护任务降频 */
let lastStateMaintenanceAt = 0;
const STATE_MAINTENANCE_MS = 60000;

function maybeStateMaintenance() {
  const now = nowMs();
  if (now - lastStateMaintenanceAt < STATE_MAINTENANCE_MS) return;
  lastStateMaintenanceAt = now;
  restoreSessionsFromBindings();
  pruneSessions();
}

function saveSessions() {
  try {
    const obj = {};
    for (const [k, v] of sessions.entries()) obj[k] = v;
    fs.writeFileSync(SESSIONS_FILE, JSON.stringify(obj, null, 2));
    syncMongoAsync();
  } catch (e) { log('sessions save failed', String(e.message || e)); }
}

loadSessions();
loadBindingRegistry();
loadPlanRegistry();
rebuildBindingRegistryFromThreadFiles();

/** 从 sessions 回填 registry（迁移） */
for (const [token, s] of sessions.entries()) {
  if (s.status === 'completed') continue;
  const key = s.bindingKey || (s.cursorTitle ? makeBindingKey(s.workspace, s.cursorTitle) : '');
  if (key && !bindingRegistry[key]) {
    bindingRegistry[key] = { token, updatedAt: s.lastActiveAt || s.createdAt || Date.now() };
  }
  const pl = String(s.planLabel || s.title || '').trim();
  const ct = s.cursorTitle || s.title || '';
  if (pl && ct && !getPlanByToken(token)) {
    registerPlanBinding(s.workspace, ct, s.hostIp, pl, token);
  }
}
saveBindingRegistry();

/** 清理 sessions.json 里残留的 waiting（旧架构遗留），仅以内存 pendingWaits 为准 */
for (const [token, s] of sessions.entries()) {
  if (s.status === 'waiting') s.status = 'idle';
}

const CONTINUE_MODES = new Set(['standard', 'turbo', 'optimize']);

function normalizeContinueMode(mode) {
  const m = String(mode || '').trim();
  return CONTINUE_MODES.has(m) ? m : 'standard';
}

function getSessionContinueMode(s) {
  if (s && s.continueMode) return normalizeContinueMode(s.continueMode);
  return normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode || 'standard');
}

function effectiveAutoContinue(s) {
  if (s && typeof s.sessionAutoContinue === 'boolean') return s.sessionAutoContinue;
  if (hubSettings.defaultSessionAutoContinue === false) return false;
  return hubSettings.autoContinue !== false;
}

function effectiveTimedReply(s) {
  if (hubSettings.timedReplyEnabled === false) return false;
  if (s && s.timedReplyEnabled === false) return false;
  return true;
}

function shouldAlwaysKeepAlive(s) {
  if (hubSettings.alwaysKeepAlive === false) return false;
  if (s && s.alwaysKeepAlive === false) return false;
  return effectiveAutoContinue(s);
}

function buildKeepAliveContinueText(s, review) {
  const mode = getSessionContinueMode(s);
  const goal = String((s && s.projectGoal) || (review && review.projectGoal) || '').slice(0, 150);
  if (review && review.nextStep && !review.isIdleNext) {
    return `【保活续跑】继续执行▶下一步：${review.nextStep.slice(0, 400)}。保持连接勿停。`;
  }
  if (mode === 'turbo') {
    return String(
      hubSettings.keepAliveTurboText
      || hubSettings.turboContinueText
      || '【保活续跑】保持连接，继续执行当前计划与学习清单，直推直到目标完成。',
    );
  }
  if (mode === 'optimize') {
    return '【保活续跑】保持连接，继续优化流程：总结→检索→补文档→开发，按学习清单推进。';
  }
  if (goal) {
    return `【保活续跑】继续推进：${goal}。按复盘与▶下一步执行，保持连接勿停。`;
  }
  return String(
    hubSettings.keepAliveText
    || hubSettings.timedReplyText
    || '【保活续跑】保持持久对话连接，继续执行与学习，勿停。',
  );
}

function isTimedReplyAuto(s) {
  if (s && s.sessionTimedIntervalMs != null) {
    const sv = Number(s.sessionTimedIntervalMs);
    if (Number.isFinite(sv) && sv <= 0) return true;
    if (Number.isFinite(sv) && sv >= TIMED_REPLY_MIN_MS) return false;
  }
  const raw = s && s.timedReplyIntervalMs != null
    ? Number(s.timedReplyIntervalMs)
    : Number(hubSettings.timedReplyIntervalMs);
  return !Number.isFinite(raw) || raw <= 0;
}

function getTimedReplyIntervalMs(s) {
  if (isTimedReplyAuto(s)) return 0;
  const raw = s.sessionTimedIntervalMs != null
    ? Number(s.sessionTimedIntervalMs)
    : (s && s.timedReplyIntervalMs != null
      ? Number(s.timedReplyIntervalMs)
      : Number(hubSettings.timedReplyIntervalMs));
  if (Number.isFinite(raw) && raw >= TIMED_REPLY_MIN_MS) return Math.floor(raw);
  return 0;
}

/** 「自动」间隔 = 10 秒保活连接 */
function getEffectiveTimedIntervalMs(s) {
  const raw = getTimedReplyIntervalMs(s);
  if (raw >= TIMED_REPLY_MIN_MS) return raw;
  return CONNECTION_KEEPALIVE_MS;
}

function markConnectionHeld(s) {
  if (!s || s.status === 'completed' || s.connectionEnded) return;
  s.connectionHeld = true;
  s.lastKeepAliveAt = nowMs();
  if (s.status !== 'waiting') s.status = 'holding';
}

function clearConnectionHeld(s) {
  if (!s) return;
  s.connectionHeld = false;
  if (s.status === 'holding') s.status = 'idle';
}

function isSessionConnectionHeld(s, pending) {
  if (!s || s.status === 'completed' || s.connectionEnded) return false;
  return !!(pending || s.connectionHeld);
}

function pruneSteerFromMessages(s, texts) {
  if (!s?.messages?.length || !texts?.length) return;
  const set = new Set(texts.map((t) => String(t || '').trim()).filter(Boolean));
  s.messages = s.messages.filter((m) => {
    if (!m.queued || m.role !== 'user') return true;
    return !set.has(String(m.content || '').trim());
  });
}

function clearWaitTimers(wait) {
  if (!wait) return;
  if (wait.autoTimer) {
    clearTimeout(wait.autoTimer);
    wait.autoTimer = null;
  }
  if (wait.timedReplyTimer) {
    clearTimeout(wait.timedReplyTimer);
    wait.timedReplyTimer = null;
  }
  wait.timedReplyFireAt = null;
}

function buildTimedReplyText(token, wait, s, review) {
  const mode = getSessionContinueMode(s);
  if (mode === 'turbo') {
    return String(
      hubSettings.timedReplyTurboText
      || hubSettings.turboContinueText
      || '【定时续跑】保持连接，继续执行当前计划，直推开发直到目标完成。',
    );
  }
  if (mode === 'optimize') {
    return '【定时续跑】保持连接，继续优化流程：总结→检索→补文档→开发。';
  }
  if (review.nextStep && !review.isIdleNext) {
    return `【定时回复】继续执行▶下一步：${review.nextStep.slice(0, 300)}`;
  }
  return String(hubSettings.timedReplyText || '【定时回复】保持持久对话连接，若仍有任务请继续执行。');
}

function triggerTimedReply(token) {
  const wait = pendingWaits.get(token);
  if (!wait) return { ok: false, error: 'no pending wait' };
  focusPanelTarget(token);
  const s = sessions.get(token);
  if (!effectiveTimedReply(s)) return { ok: false, error: 'timed reply disabled' };
  const review = parseKaruoReview(wait.message || '');
  const text = buildTimedReplyText(token, wait, s, review);
  if (wait.timedReplyTimer) clearTimeout(wait.timedReplyTimer);
  wait.timedReplyTimer = null;
  wait.timedReplyFireAt = null;
  deliverReply(token, text, { auto: true, timed: true });
  log('timedReply fired', token, { text: text.slice(0, 80) });
  return { ok: true, token, text: text.slice(0, 120) };
}

function scheduleTimedReply(token) {
  const s = sessions.get(token);
  if (!effectiveTimedReply(s)) return;
  const wait = pendingWaits.get(token);
  if (!wait) return;
  if (wait.timedReplyTimer) clearTimeout(wait.timedReplyTimer);
  const interval = getEffectiveTimedIntervalMs(s);
  if (interval < TIMED_REPLY_MIN_MS) return;
  wait.timedReplyFireAt = nowMs() + interval;
  wait.timedReplyTimer = setTimeout(() => {
    if (!pendingWaits.has(token)) return;
    triggerTimedReply(token);
  }, interval);
  log('timedReply scheduled', token, { intervalMs: interval, fireAt: wait.timedReplyFireAt });
}

/** 迁移：旧会话补默认单会话配置 */
for (const [, s] of sessions.entries()) {
  if (s.continueMode == null) {
    s.continueMode = normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode);
  }
  if (typeof s.sessionAutoContinue !== 'boolean') {
    s.sessionAutoContinue = hubSettings.defaultSessionAutoContinue !== false;
  }
  if (typeof s.timedReplyEnabled !== 'boolean') {
    s.timedReplyEnabled = hubSettings.defaultTimedReplyEnabled !== false;
  }
  if (s.timedReplyIntervalMs == null) {
    s.timedReplyIntervalMs = hubSettings.timedReplyIntervalMs != null
      ? hubSettings.timedReplyIntervalMs
      : CONNECTION_KEEPALIVE_MS;
  }
  if (s.sessionTimedIntervalMs == null && s.timedReplyIntervalMs == null) {
    s.sessionTimedIntervalMs = 0;
  }
  if (typeof s.connectionHeld !== 'boolean') s.connectionHeld = false;
  if (s.archived) continue;
  if (typeof s.sessionAutoContinue !== 'boolean' || s.sessionAutoContinue === false) {
    s.sessionAutoContinue = true;
  }
  if (s.timedReplyEnabled === false) s.timedReplyEnabled = true;
  if (s.alwaysKeepAlive !== false) s.alwaysKeepAlive = true;
  if (!s.lastKeepAliveAt) s.lastKeepAliveAt = s.lastActiveAt || s.createdAt || nowMs();
}
saveSessions();

function archiveStaleSessions() {
  const now = nowMs();
  const stale = [];
  for (const [token, s] of sessions.entries()) {
    if (s.archived || s.status === 'completed') continue;
    if (pendingWaits.has(token)) continue;
    const last = s.lastKeepAliveAt || s.lastActiveAt || s.createdAt || now;
    if (now - last < STALE_KEEPALIVE_MS) continue;
    stale.push({
      token,
      title: s.title,
      cursorTitle: s.cursorTitle,
      planLabel: s.planLabel,
      workspace: s.workspace,
      lastKeepAliveAt: s.lastKeepAliveAt,
      lastActiveAt: s.lastActiveAt,
      msgCount: (s.messages || []).length,
      messages: (s.messages || []).slice(-50),
    });
    s.archived = true;
    s.status = 'completed';
    s.sessionAutoContinue = false;
    s.timedReplyEnabled = false;
    s.connectionHeld = false;
    s.archivedAt = now;
    s.archiveReason = 'stale_keepalive_2h';
  }
  if (!stale.length) return 0;
  const day = new Date().toISOString().slice(0, 10);
  const dir = path.join(ARCHIVE_DIR, day);
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
  const file = path.join(dir, `stale_${now}.json`);
  try {
    fs.writeFileSync(file, JSON.stringify({ archivedAt: now, reason: 'no_keepalive_2h', sessions: stale }, null, 2));
  } catch (e) {
    log('archive write failed', String(e.message || e));
  }
  saveSessions();
  log('archiveStaleSessions', { count: stale.length, file });
  return stale.length;
}

function isSilentMessage(message, token) {
  const s = token ? sessions.get(token) : null;
  if (effectiveAutoContinue(s)) return true;
  const msg = String(message || '').replace(/```text|```/g, '').trim();
  if (!msg) return true;
  if (/自动续跑空转|续跑机制正常|机制已就绪|空转中|Hub.*自动|仍在.*续跑/.test(msg)) return true;
  return false;
}

function stripTransportFence(message) {
  let msg = String(message || '');
  const fenced = msg.match(/^```text\n([\s\S]*)\n```\s*$/);
  if (fenced) msg = fenced[1];
  return msg.replace(/```text|```/g, '').trim();
}

/** 从 Agent 回复解析卡若复盘 v5（🎯📌💡📝▶） */
function parseKaruoReview(message) {
  const raw = stripTransportFence(message);
  const hasReview = /\[卡若复盘\]|🎯\s*目标/.test(raw);
  let completionPct = null;
  const goalSection = raw.match(/🎯[\s\S]*?(?=\n\*\*📌|\n\*\*💡|\n\*\*📝|\n\*\*▶|$)/);
  const pctMatch = goalSection && goalSection[0].match(/([0-9]{1,3})\s*%/);
  if (pctMatch) completionPct = Math.min(100, Math.max(0, parseInt(pctMatch[1], 10)));
  let nextStep = '';
  const nextBlock = raw.match(/\*\*▶\s*下一步执行\*\*\s*\n([\s\S]*?)(?=\n\*\*[📌💡📝🎯▶]|$)/);
  if (nextBlock) nextStep = nextBlock[1].trim();
  if (!nextStep) {
    const alt = raw.match(/▶\s*下一步执行[^\n]*\n([\s\S]{1,400}?)(?=\n\n\*\*|$)/);
    if (alt) nextStep = alt[1].trim();
  }
  nextStep = nextStep.replace(/^[\d]+[.、]\s*/, '').trim();
  const isIdleNext = !nextStep || /^(无[。.]?|暂无|待命|等下一|等你的|等用户|无新任务|无待办|在\.|在$|\.\.\.|…)/.test(nextStep);
  const needsZhuiwen = completionPct != null && completionPct >= 100;
  let projectGoal = '';
  const goalLine = raw.match(/🎯[^\n]+/);
  if (goalLine) {
    projectGoal = goalLine[0]
      .replace(/^\*\*🎯\s*目标·结果·达成率\*\*\s*/, '')
      .replace(/^🎯\s*目标·结果·达成率\s*/, '')
      .trim();
  }
  return { hasReview, completionPct, nextStep, isIdleNext, needsZhuiwen, projectGoal, raw };
}

function isIdleAssistantWait(message) {
  if (isSilentMessage(message)) return true;
  const review = parseKaruoReview(message);
  if (review.hasReview && review.isIdleNext && !review.needsZhuiwen) return true;
  const msg = stripTransportFence(message);
  if (msg.length < 50) return true;
  if (/^(\[RENEWAL\]|继续|__TIMEOUT_RENEW__)/.test(msg)) return true;
  return false;
}

function initPreRun(session, nextStep) {
  if (!nextStep) return;
  const prev = session.preRun;
  if (prev && prev.nextStep === nextStep && prev.stage && prev.stage !== 'done') return;
  session.preRun = {
    nextStep,
    stage: 'zhuiwen',
    zhuiwenSent: false,
    searchSent: false,
  };
}

function initOptimizeRun(session, nextStep) {
  if (!nextStep) return;
  const prev = session.optimizeRun;
  if (prev && prev.nextStep === nextStep && prev.stage && prev.stage !== 'done') return;
  session.optimizeRun = { nextStep, stage: 'summarize' };
  session.preRun = null;
}

function advanceOptimizeRunStage(session) {
  const opt = session.optimizeRun;
  if (!opt) return;
  const order = ['summarize', 'search', 'doc', 'execute', 'done'];
  const i = order.indexOf(opt.stage);
  if (i >= 0 && i < order.length - 1) opt.stage = order[i + 1];
}

/** 优化续跑：总结 → 检索 → 补文档 → 执行 */
function resolveOptimizeContinueText(session, review) {
  const opt = session.optimizeRun;
  if (!opt?.nextStep || review.isIdleNext) return null;
  const step = opt.nextStep.slice(0, 300);
  const texts = {
    summarize:
      `【优化续跑①总结】回顾本会话聊天与已改代码，提炼/优化任务目标、缺口与风险，写入📌过程。`,
    search:
      `【优化续跑②检索】针对「${step}」：WebSearch 全网最佳实践 + GitHub 开源方案（各≥1次），对比选配套方案，摘要写📌。`,
    doc:
      `【优化续跑③补文档】把总结与检索结论补充进项目 开发文档/ 对应 MD（列出路径，不删旧内容）。`,
    execute:
      `【优化续跑④执行】文档已对齐，按文档继续执行▶下一步：${step}`,
  };
  if (opt.stage === 'done') return null;
  const text = texts[opt.stage];
  if (!text) return null;
  session.phase = `opt_${opt.stage}`;
  return text;
}

/** 续跑三段式：追问 → 全网/GitHub 检索 → 执行 ▶（挂起后自动续跑前必经） */
function resolvePreRunContinueText(session, review) {
  const pre = session.preRun;
  if (!pre || !pre.nextStep || review.isIdleNext) return null;

  const step = pre.nextStep.slice(0, 300);
  const ZHUIWEN_SKILL = '04_卡火（火）/火眼_智能追问/智能追问/SKILL.md';

  if (pre.stage === 'zhuiwen') {
    if (pre.zhuiwenSent) {
      log('preRun pause await 追问完成', pre.stage);
      return null;
    }
    pre.zhuiwenSent = true;
    pre.stage = 'zhuiwen_wait';
    session.phase = 'pre_zhuiwen';
    return (
      `【续跑①追问】挂起后续跑前，先读 \`${ZHUIWEN_SKILL}\`，对▶下一步「${step}」做 Human 3.0 结构化追问（目标/边界/验收，2～3 轮）。` +
      `在本轮正文输出追问清单与结论，复盘后 wait。你或面板确认后只回复「追问完成」。`
    );
  }
  if (pre.stage === 'zhuiwen_wait') {
    log('preRun pause zhuiwen_wait');
    return null;
  }
  if (pre.stage === 'search') {
    if (pre.searchSent) {
      log('preRun pause await 检索完成');
      return null;
    }
    pre.searchSent = true;
    pre.stage = 'search_wait';
    session.phase = 'pre_search';
    return (
      `【续跑②检索】追问已完成。针对「${step}」：` +
      `① WebSearch 全网查最佳实践/踩坑 ② GitHub 搜开源实现/Discussion/Issue（至少各 1 次有效检索）。` +
      `摘要写入📌过程，结论影响执行方案。完成后只回复「检索完成」。`
    );
  }
  if (pre.stage === 'search_wait') {
    log('preRun pause search_wait');
    return null;
  }
  if (pre.stage === 'execute') {
    session.preRun = { ...pre, stage: 'done' };
    session.phase = 'running';
    return `【续跑③执行】追问与检索已完成。现在执行▶下一步：${step}`;
  }
  return null;
}

function advancePreRunOnReply(session, reply) {
  if (!session.preRun) return;
  const r = String(reply || '');
  if (/追问完成|验收通过|追问结束|验收完毕/.test(r)) {
    if (session.preRun.stage === 'zhuiwen_wait') {
      session.preRun.stage = 'search';
      session.preRun.searchSent = false;
      session.phase = 'pre_search';
      log('preRun advanced → search');
    }
  }
  if (/检索完成|搜索完成|检索完毕|搜完/.test(r)) {
    if (session.preRun.stage === 'search_wait') {
      session.preRun.stage = 'execute';
      session.phase = 'pre_execute';
      log('preRun advanced → execute');
    }
  }
}

function autoContinueMaxStreakLimit() {
  const n = Number(hubSettings.autoContinueMaxStreak);
  if (!Number.isFinite(n) || n <= 0) return 0;
  return Math.floor(n);
}

function deleteSession(token, force = false, opts = {}) {
  if (!sessions.has(token)) return { ok: false, error: 'not found' };
  if (isVerticallyLockedToken(token) && !opts.unbind) {
    return {
      ok: false,
      error: 'BINDING_PROTECTED',
      hint: '该 ct_ 为垂直绑定会话，禁止面板/脚本误删；仅可删会话占位，绑定保留。如需解除请传 unbind:true。',
    };
  }
  if (pendingWaits.has(token)) {
    if (!force) return { ok: false, error: 'waiting', hint: '会话挂起中，可强制删除' };
    const wait = pendingWaits.get(token);
    if (wait) clearWaitTimers(wait);
    for (const r of wait.pollers) {
      try { json(r, 200, { status: 'gone', token }); } catch {}
    }
    pendingWaits.delete(token);
  }
  sessions.delete(token);
  saveSessions();
  log('session deleted', token, { force });
  return { ok: true, token };
}

function pruneSessions() {
  const protectedTokens = new Set(pendingWaits.keys());
  for (const hit of Object.values(bindingRegistry)) {
    if (hit && hit.token) protectedTokens.add(hit.token);
  }
  const max = Math.max(5, Number(hubSettings.maxSessions) || 30);
  const maxAgeMs = Math.max(1, Number(hubSettings.maxIdleAgeDays) || 7) * 86400000;
  const now = nowMs();
  const entries = [...sessions.entries()].filter(([t]) => !protectedTokens.has(t));
  entries.sort((a, b) => (b[1].lastActiveAt || 0) - (a[1].lastActiveAt || 0));

  let pruned = 0;
  for (let i = max; i < entries.length; i++) {
    sessions.delete(entries[i][0]);
    pruned++;
  }
  for (const [token, s] of [...sessions.entries()]) {
    if (protectedTokens.has(token)) continue;
    const age = now - (s.lastActiveAt || s.createdAt || now);
    if (age > maxAgeMs && (s.status === 'idle' || s.status === 'completed')) {
      sessions.delete(token);
      pruned++;
    }
  }
  if (pruned > 0) {
    saveSessions();
    log('pruned sessions', { count: pruned, remain: sessions.size });
  }
  return pruned;
}

/** 删除隔离测试/空 holding/无消息 orphan；保留 registry+plan 绑定及有实质消息的会话 */
function cleanupUnusedPlans(opts = {}) {
  const keepExact = new Set((opts.keepTokens || []).map((t) => normalizeExactToken(t)).filter(Boolean));
  for (const hit of Object.values(bindingRegistry)) {
    if (hit && hit.token) keepExact.add(normalizeExactToken(hit.token));
  }
  for (const [k, hit] of Object.entries(planRegistry)) {
    if (k.startsWith('ct_') && hit && hit.planBindingKey) continue;
    if (hit && hit.token) keepExact.add(normalizeExactToken(hit.token));
  }
  if (activeWaitToken) keepExact.add(normalizeExactToken(activeWaitToken));
  for (const t of pendingWaits.keys()) keepExact.add(normalizeExactToken(t));

  const deletedTokens = [];
  for (const [token, s] of [...sessions.entries()]) {
    const tok = normalizeExactToken(token);
    if (!tok) { sessions.delete(token); deletedTokens.push(token); continue; }
    if (keepExact.has(tok)) continue;
    const title = String(s.cursorTitle || s.title || '');
    const msgs = (s.messages || []).length;
    const isTest = /隔离测试|isolation.?test/i.test(title);
    const isEmptyHold = (s.status === 'holding' || s.status === 'idle') && msgs === 0;
    const isCompleted = s.status === 'completed';
    const isOrphan = msgs === 0 && !s.bindingKey && !s.planBindingKey;
    if (isTest || isCompleted || isEmptyHold || isOrphan) {
      if (pendingWaits.has(token)) {
        const wait = pendingWaits.get(token);
        if (wait.autoTimer) clearTimeout(wait.autoTimer);
        pendingWaits.delete(token);
      }
      if (activeWaitToken === token) clearActiveWaitToken(token);
      sessions.delete(token);
      deletedTokens.push(token);
    } else if (msgs >= 3) {
      keepExact.add(tok);
    }
  }

  for (const token of deletedTokens) {
    for (const [key, hit] of Object.entries(bindingRegistry)) {
      if (hit && hit.token === token) delete bindingRegistry[key];
    }
    if (planRegistry[token]) delete planRegistry[token];
    for (const [pk, hit] of Object.entries(planRegistry)) {
      if (hit && hit.token === token) delete planRegistry[pk];
    }
    try {
      const tb = path.join(THREAD_BINDINGS_DIR, `${token}.json`);
      if (fs.existsSync(tb)) fs.unlinkSync(tb);
    } catch {}
  }

  saveSessions();
  saveBindingRegistry();
  savePlanRegistry();
  log('cleanupUnusedPlans', { deleted: deletedTokens.length, remain: sessions.size });
  return { deleted: deletedTokens.length, deletedTokens, remain: sessions.size };
}

restoreSessionsFromBindings();
pruneSessions();
dedupeBindingSessions();
saveSessions();

function newToken() { return 'ct_' + crypto.randomBytes(4).toString('hex'); }
function nowMs() { return Date.now(); }

function readBody(req, max = 1024 * 1024) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', (c) => {
      body += c;
      if (body.length > max) { req.destroy(); reject(new Error('body too large')); }
    });
    req.on('end', () => resolve(body));
    req.on('error', reject);
  });
}

function json(res, code, obj) {
  res.writeHead(code, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(obj));
}

function buildStateList() {
  const list = [];
  for (const [token, s] of sessions.entries()) {
    const pending = pendingWaits.get(token);
    const hideWaiting = effectiveAutoContinue(s);
    const needsUser = pending && !pending.silent && !hideWaiting;
    const connectionHeld = isSessionConnectionHeld(s, pending);
    list.push({
      token,
      title: s.title || '(未命名)',
      displayTitle: s.displayTitle || '',
      status: needsUser ? 'waiting' : (pending || connectionHeld ? 'holding' : (s.status === 'completed' ? 'completed' : 'idle')),
      workspace: s.workspace,
      createdAt: s.createdAt,
      lastActiveAt: s.lastActiveAt,
      msgCount: (s.messages || []).length,
      pendingMessage: pending ? pending.message : null,
      pendingPrompt: pending ? pending.prompt : null,
      pendingTitle: pending ? pending.title : null,
      pendingSince: pending ? pending.createdAt : null,
      silent: pending ? !!pending.silent : false,
      autoRunning: !!(pending && hideWaiting),
      hasPending: !!pending,
      agentWaitActive: !!(pending && !pending.silent),
      waitingForUser: !!needsUser,
      connectionHeld: !!connectionHeld,
      isHung: !!(pending || connectionHeld),
      lastKeepAliveAt: s.lastKeepAliveAt || null,
      autoStreak: s.autoStreak || 0,
      cursorTitle: s.cursorTitle || s.title || '',
      bindingKey: s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title, s.hostIp),
      hostIp: s.hostIp || '',
      hub: s.hub || hubEndpointLabel(),
      bindingCheckCount: s.bindingCheckCount || 0,
      isActiveWait: token === activeWaitToken,
      projectGoal: s.projectGoal || '',
      completionPct: typeof s.completionPct === 'number' ? s.completionPct : null,
      lastReview: s.lastReview || null,
      phase: s.phase || 'running',
      planLabel: s.planLabel || '',
      preRun: s.preRun || null,
      optimizeRun: s.optimizeRun || null,
      continueMode: getSessionContinueMode(s),
      sessionAutoContinue: effectiveAutoContinue(s),
      continueModeLabel: ({ standard: '复盘', turbo: '直通', optimize: '优化' })[getSessionContinueMode(s)],
      timedReplyEnabled: effectiveTimedReply(s),
      sessionTimedReply: effectiveTimedReply(s),
      timedReplyAuto: isTimedReplyAuto(s),
      timedReplyIntervalMs: getTimedReplyIntervalMs(s),
      sessionTimedIntervalMs: isTimedReplyAuto(s) ? 0 : getTimedReplyIntervalMs(s),
      effectiveTimedIntervalMs: getEffectiveTimedIntervalMs(s),
      timedReplyFireAt: pending && pending.timedReplyFireAt ? pending.timedReplyFireAt : null,
      registryToken: registryTokenForSession(token, s),
      bindingOk: bindingOkForSession(token, s),
      restoredFromBinding: !!s.restoredFromBinding,
      steerQueueCount: Array.isArray(s.steerQueue) ? s.steerQueue.length : 0,
      steerQueuePreview: (s.steerQueue || []).map((item, index) => ({
        index,
        text: String(item.text || '').slice(0, 240),
        at: item.at || null,
      })),
      recentMessages: (s.messages || []).slice(-8).map((m) => ({
        role: m.role,
        preview: String(m.content || '').replace(/```text|```/g, '').slice(0, 400),
        at: m.at,
        auto: !!m.auto,
        timed: !!m.timed,
      })),
    });
  }
  list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
  return list;
}

function queueSteerReply(token, reply) {
  const s = sessions.get(token);
  if (!s) return { ok: false, error: 'unknown token' };
  if (!Array.isArray(s.steerQueue)) s.steerQueue = [];
  const text = String(reply || '').trim();
  if (!text) return { ok: false, error: 'empty reply' };
  s.steerQueue.push({ text, at: nowMs() });
  (s.messages = s.messages || []).push({ role: 'user', content: text, at: nowMs(), queued: true });
  s.lastActiveAt = nowMs();
  saveSessions();
  log('steer queued', token, { queueLen: s.steerQueue.length });
  return { ok: true, queued: true, queueLen: s.steerQueue.length };
}

function takeSteerReply(token) {
  const s = sessions.get(token);
  if (!s?.steerQueue?.length) return null;
  const items = s.steerQueue.splice(0);
  saveSessions();
  const combined = items.map((i) => i.text).join('\n\n');
  log('steer dequeue', token, { count: items.length, len: combined.length });
  return combined || null;
}

function deliverReply(token, reply, meta = {}) {
  const wait = pendingWaits.get(token);
  if (!wait) return false;
  clearWaitTimers(wait);
  pendingWaits.delete(token);
  const s = sessions.get(token);
  if (s) {
    s.lastActiveAt = nowMs();
    if (/结束持久对话/.test(String(reply || ''))) {
      clearConnectionHeld(s);
      s.connectionEnded = true;
      s.sessionAutoContinue = false;
      s.status = 'idle';
    } else {
      // 回复送达后仍保持计划级连接态；下一次 Agent wait 会继续接收排队消息。
      markConnectionHeld(s);
    }
    (s.messages = s.messages || []).push({
      role: 'user',
      content: reply,
      at: nowMs(),
      auto: !!meta.auto,
      timed: !!meta.timed,
    });
    advancePreRunOnReply(s, reply);
    if (/追问完成|验收通过|追问结束|验收完毕/.test(reply) && !s.preRun) {
      s.phase = 'zhuiwen_done';
    }
    if (meta.auto || meta.timed) {
      s.lastKeepAliveAt = nowMs();
    }
    if (meta.auto) {
      s.autoStreak = (s.autoStreak || 0) + 1;
      if (getSessionContinueMode(s) === 'optimize' && s.optimizeRun && s.optimizeRun.stage !== 'done') {
        advanceOptimizeRunStage(s);
      }
    } else {
      s.autoStreak = 0;
    }
    saveSessions();
  }
  for (const res of wait.pollers) {
    try { json(res, 200, { status: 'replied', reply, token }); } catch {}
  }
  wait.pollers.clear();
  if (typeof wait.resolve === 'function') wait.resolve(reply);
  clearActiveWaitToken(token);
  log('reply delivered', token, `len=${reply.length}`, meta.timed ? 'timed' : (meta.auto ? 'auto' : 'manual'));
  return true;
}

function isTurboMode(s) {
  return getSessionContinueMode(s) === 'turbo';
}

function isOptimizeMode(s) {
  return getSessionContinueMode(s) === 'optimize';
}

/** 构建自动/手动「继续」文案；返回 null 表示本轮不续跑 */
function buildAutoContinueText(token, wait, s, review) {
  const sGoal = s && s.projectGoal ? String(s.projectGoal) : review.projectGoal || '';
  const sPct = review.completionPct != null
    ? review.completionPct
    : (s && typeof s.completionPct === 'number' ? s.completionPct : null);
  let text = String(hubSettings.autoContinueText || '继续');

  if (isOptimizeMode(s)) {
    if (review.isIdleNext && !review.needsZhuiwen && sPct != null && sPct >= 100) {
      log('optimizeContinue stop complete', token, { sPct });
      return null;
    }
    if (!s.optimizeRun) {
      const seed = (review.nextStep && !review.isIdleNext)
        ? review.nextStep
        : (sGoal || '继续推进当前任务');
      initOptimizeRun(s, seed);
    }
    const optText = resolveOptimizeContinueText(s, review);
    if (optText) {
      saveSessions();
      return optText;
    }
    if (review.nextStep && !review.isIdleNext) {
      return `【优化续跑·执行】继续▶：${review.nextStep.slice(0, 400)}。按已补文档直推。`;
    }
    if (sGoal) {
      const pctPart = sPct != null ? `（约 ${sPct}%）` : '';
      return `【优化续跑】继续推进：${sGoal.slice(0, 150)}${pctPart}。`;
    }
    return null;
  }

  if (isTurboMode(s)) {
    if (review.isIdleNext && !review.needsZhuiwen && sPct != null && sPct >= 100) {
      log('turboContinue stop complete', token, { sPct });
      return null;
    }
    if (review.nextStep && !review.isIdleNext) {
      return (
        `【直通续跑】继续执行▶下一步：${review.nextStep.slice(0, 400)}。` +
        '直推开发不要停，补缺失步骤即可，跳过追问/检索等待。'
      );
    }
    if (sGoal) {
      const pctPart = sPct != null ? `（当前约 ${sPct}%）` : '';
      if (sPct != null && sPct >= 100 && review.isIdleNext) return null;
      return (
        `【直通续跑】继续推进：${sGoal.slice(0, 150)}${pctPart}。` +
        '一直开发直到目标完成，勿停勿等复盘确认。'
      );
    }
    if (!review.hasReview) {
      return String(hubSettings.turboContinueText || '【直通续跑】继续执行当前计划，直推开发直到完成。');
    }
    return text;
  }

  if (!review.hasReview) {
    log('autoContinue skipped no karuo review', token);
    return null;
  }
  if (review.isIdleNext && !review.needsZhuiwen) {
    log('autoContinue skipped idle next step', token, { nextStep: review.nextStep });
    return null;
  }

  const preText = s ? resolvePreRunContinueText(s, review) : null;
  if (preText) {
    saveSessions();
    return preText;
  }
  if (s && s.preRun && !review.isIdleNext) {
    log('autoContinue paused preRun pipeline', token, { stage: s.preRun.stage });
    return null;
  }
  if (review.needsZhuiwen && s && s.phase !== 'zhuiwen_done') {
    if (!review.isIdleNext) initPreRun(s, review.nextStep);
    const retry = s ? resolvePreRunContinueText(s, review) : null;
    if (retry) {
      saveSessions();
      return retry;
    }
    return null;
  }
  if (review.nextStep && !review.isIdleNext) {
    if (s) initPreRun(s, review.nextStep);
    const retry = s ? resolvePreRunContinueText(s, review) : null;
    if (retry) {
      saveSessions();
      return retry;
    }
    return null;
  }
  if (sGoal) {
    const pctPart = sPct != null ? `（当前完成度约 ${sPct}%）` : '';
    if (sPct != null && sPct >= 100 && s && s.phase === 'zhuiwen_done') {
      log('autoContinue skipped after zhuiwen', token);
      return null;
    }
    return `继续推进：${sGoal.slice(0, 120)}${pctPart}。按▶下一步执行，勿重复已完成步骤。`;
  }
  return text;
}

function focusPanelTarget(token) {
  if (!pendingWaits.has(token)) return false;
  if (activeWaitToken !== token) {
    log('panel focus activeWaitToken', { from: activeWaitToken, to: token });
    setActiveWaitToken(token);
  }
  return true;
}

function triggerContinue(token, meta = {}) {
  const wait = pendingWaits.get(token);
  if (!wait) {
    return { ok: false, error: 'no pending wait', hint: '该 ct_ 未挂起，请在对应 Cursor 标签等 Agent wait 后再发' };
  }
  focusPanelTarget(token);
  const s = sessions.get(token);
  const review = parseKaruoReview(wait.message || '');
  let text = buildAutoContinueText(token, wait, s, review);
  if (!text && shouldAlwaysKeepAlive(s)) {
    text = buildKeepAliveContinueText(s, review);
  }
  if (!text) {
    const mode = getSessionContinueMode(s);
    text = mode === 'turbo'
      ? String(hubSettings.turboContinueText || '【直通续跑】继续执行当前计划，直推开发直到完成。')
      : mode === 'optimize'
        ? '【优化续跑】继续执行当前计划：总结→检索→补文档→开发。'
        : String(hubSettings.autoContinueText || '继续');
  }
  if ((isTurboMode(s) || isOptimizeMode(s)) && s && s.preRun) {
    s.preRun = null;
    s.phase = 'running';
    saveSessions();
  }
  if (wait.autoTimer) clearTimeout(wait.autoTimer);
  deliverReply(token, text, { auto: !!meta.auto, manualContinue: !!meta.manualContinue });
  return { ok: true, token, text: text.slice(0, 120) };
}

function scheduleAutoContinue(token) {
  const s0 = sessions.get(token);
  if (!effectiveAutoContinue(s0)) return;
  const wait = pendingWaits.get(token);
  if (!wait) return;
  const s = sessions.get(token);
  const maxStreak = autoContinueMaxStreakLimit();
  if (maxStreak > 0 && s && (s.autoStreak || 0) >= maxStreak) {
    log('autoContinue paused', token, { streak: s.autoStreak, maxStreak });
    return;
  }
  if (wait.autoTimer) clearTimeout(wait.autoTimer);
  const delay = Math.max(1000, Number(hubSettings.autoContinueDelayMs) || 10000);
  const review = parseKaruoReview(wait.message || '');
  let text = buildAutoContinueText(token, wait, s, review);
  if (!text && shouldAlwaysKeepAlive(s)) {
    text = buildKeepAliveContinueText(s, review);
    log('autoContinue keepalive fallback', token, { mode: getSessionContinueMode(s) });
  }
  if (!text) return;
  wait.autoTimer = setTimeout(() => {
    if (!pendingWaits.has(token)) return;
    triggerContinue(token, { auto: true });
  }, delay);
}

function readPanelVersion() {
  try {
    const m = fs.readFileSync(PANEL_HTML, 'utf8').match(/PANEL_VERSION\s*=\s*['"]([^'"]+)['"]/);
    if (m) return m[1];
  } catch {}
  return 'unknown';
}

const server = http.createServer(async (req, res) => {
  try {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
    if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }

    const u = new URL(req.url, `http://${req.headers.host}`);

    if (u.pathname === '/api/health') {
      return json(res, 200, {
        ok: true,
        pid: process.pid,
        waiting: pendingWaits.size,
        activeWaitToken,
        panelVersion: readPanelVersion(),
      });
    }

    if (u.pathname === '/' || u.pathname === '/panel') {
      let html = fs.readFileSync(PANEL_HTML, 'utf8');
      const boot = JSON.stringify({
        sessions: buildStateList(),
        settings: hubSettings,
        panelVersion: readPanelVersion(),
        activeWaitToken,
      }).replace(/</g, '\\u003c');
      html = html.replace('</head>', `<script>window.__PCHAT_BOOT__=${boot};</script>\n</head>`);
      res.writeHead(200, {
        'Content-Type': 'text/html; charset=utf-8',
        'Cache-Control': 'no-store, no-cache, must-revalidate',
        'Pragma': 'no-cache',
      });
      return res.end(html);
    }

    if (u.pathname === '/api/state' && req.method === 'GET') {
      maybeStateMaintenance();
      for (const [, s] of sessions.entries()) markConnectionHeld(s);
      const list = buildStateList();
      const primary = (activeWaitToken && pendingWaits.has(activeWaitToken))
        ? activeWaitToken
        : (list.find((s) => s.hasPending)?.token
          || list.find((s) => s.status === 'waiting')?.token
          || null);
      return json(res, 200, {
        sessions: list,
        primaryWaitingToken: primary,
        activeWaitToken,
        hubPid: process.pid,
        settings: hubSettings,
        sessionCount: list.length,
      });
    }

    if (u.pathname === '/api/settings' && req.method === 'GET') {
      return json(res, 200, { ok: true, settings: hubSettings });
    }

    if (u.pathname === '/api/archive' && req.method === 'GET') {
      const files = [];
      try {
        if (fs.existsSync(ARCHIVE_DIR)) {
          for (const day of fs.readdirSync(ARCHIVE_DIR)) {
            const dayDir = path.join(ARCHIVE_DIR, day);
            if (!fs.statSync(dayDir).isDirectory()) continue;
            for (const f of fs.readdirSync(dayDir)) {
              if (!f.endsWith('.json')) continue;
              const fp = path.join(dayDir, f);
              let meta = { file: fp, day };
              try {
                const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
                meta = { ...meta, archivedAt: j.archivedAt, reason: j.reason, count: (j.sessions || []).length };
              } catch {}
              files.push(meta);
            }
          }
        }
      } catch (e) {
        log('archive list err', String(e.message || e));
      }
      files.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0));
      return json(res, 200, { ok: true, archiveDir: ARCHIVE_DIR, staleMs: STALE_KEEPALIVE_MS, files });
    }

    if (u.pathname === '/api/settings' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      hubSettings = {
        ...hubSettings,
        ...(typeof body.autoContinue === 'boolean' ? { autoContinue: body.autoContinue } : {}),
        ...(CONTINUE_MODES.has(body.autoContinueMode)
          ? { autoContinueMode: body.autoContinueMode, defaultContinueMode: body.autoContinueMode } : {}),
        ...(typeof body.defaultSessionAutoContinue === 'boolean'
          ? { defaultSessionAutoContinue: body.defaultSessionAutoContinue } : {}),
        ...(body.autoContinueDelayMs != null ? { autoContinueDelayMs: Number(body.autoContinueDelayMs) } : {}),
        ...(body.autoContinueText != null ? { autoContinueText: String(body.autoContinueText) } : {}),
        ...(body.turboContinueText != null ? { turboContinueText: String(body.turboContinueText) } : {}),
        ...(body.autoContinueMaxStreak != null ? { autoContinueMaxStreak: Number(body.autoContinueMaxStreak) } : {}),
        ...(typeof body.timedReplyEnabled === 'boolean' ? { timedReplyEnabled: body.timedReplyEnabled } : {}),
        ...(typeof body.defaultTimedReplyEnabled === 'boolean'
          ? { defaultTimedReplyEnabled: body.defaultTimedReplyEnabled } : {}),
        ...(body.timedReplyIntervalMs != null ? { timedReplyIntervalMs: Number(body.timedReplyIntervalMs) } : {}),
        ...(body.timedReplyText != null ? { timedReplyText: String(body.timedReplyText) } : {}),
        ...(body.timedReplyTurboText != null ? { timedReplyTurboText: String(body.timedReplyTurboText) } : {}),
      };
      saveSettings();
      log('settings updated', hubSettings);
      return json(res, 200, { ok: true, settings: hubSettings });
    }

    if (u.pathname === '/api/session/config' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token } = body;
      if (!token || !sessions.has(token)) {
        return json(res, 404, { ok: false, error: 'not found' });
      }
      const s = sessions.get(token);
      if (typeof body.sessionAutoContinue === 'boolean') {
        s.sessionAutoContinue = body.sessionAutoContinue;
      }
      if (body.continueMode && CONTINUE_MODES.has(body.continueMode)) {
        s.continueMode = body.continueMode;
        if (body.continueMode !== 'standard') {
          s.preRun = null;
        }
        if (body.continueMode !== 'optimize') {
          s.optimizeRun = null;
        }
      }
      if (typeof body.timedReplyEnabled === 'boolean') {
        s.timedReplyEnabled = body.timedReplyEnabled;
      }
      if (typeof body.sessionTimedReply === 'boolean') {
        s.timedReplyEnabled = body.sessionTimedReply;
        s.sessionTimedReply = body.sessionTimedReply;
      }
      const applyTimedIntervalMs = (ms) => {
        if (!Number.isFinite(ms)) return;
        if (ms <= 0) {
          s.timedReplyIntervalMs = 0;
          s.sessionTimedIntervalMs = 0;
        } else if (ms >= TIMED_REPLY_MIN_MS) {
          const v = Math.floor(ms);
          s.timedReplyIntervalMs = v;
          s.sessionTimedIntervalMs = v;
        }
      };
      if (body.timedReplyIntervalMs != null) {
        applyTimedIntervalMs(Number(body.timedReplyIntervalMs));
      }
      if (body.sessionTimedIntervalMs != null) {
        applyTimedIntervalMs(Number(body.sessionTimedIntervalMs));
      }
      if (body.displayTitle != null) {
        const t = String(body.displayTitle).trim().slice(0, 120);
        if (t) s.displayTitle = t;
        else delete s.displayTitle;
      }
      s.lastActiveAt = nowMs();
      saveSessions();
      if (pendingWaits.has(token)) {
        scheduleTimedReply(token);
        if (effectiveAutoContinue(s)) scheduleAutoContinue(token);
      }
      log('session config', token, {
        sessionAutoContinue: s.sessionAutoContinue,
        continueMode: getSessionContinueMode(s),
        timedReplyEnabled: effectiveTimedReply(s),
        timedReplyIntervalMs: getTimedReplyIntervalMs(s),
      });
      return json(res, 200, {
        ok: true,
        token,
        sessionAutoContinue: effectiveAutoContinue(s),
        continueMode: getSessionContinueMode(s),
        continueModeLabel: ({ standard: '复盘', turbo: '直通', optimize: '优化' })[getSessionContinueMode(s)],
        timedReplyEnabled: effectiveTimedReply(s),
        timedReplyAuto: isTimedReplyAuto(s),
        timedReplyIntervalMs: getTimedReplyIntervalMs(s),
        timedReplyFireAt: pendingWaits.get(token)?.timedReplyFireAt || null,
        autoContinueDelayMs: hubSettings.autoContinueDelayMs,
      });
    }

    if (u.pathname === '/api/reply' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token, reply, cursorTitle: replyTitle } = body;
      if (!token || typeof reply !== 'string') {
        return json(res, 400, { ok: false, error: 'bad request' });
      }
      const s = sessions.get(token);
      if (!s) {
        return json(res, 404, { ok: false, error: 'unknown token' });
      }
      if (replyTitle) {
        const v = validateTokenBinding(token, s.workspace, replyTitle, s.hostIp || body.hostIp);
        if (!v.ok) {
          return json(res, 403, { ok: false, ...v });
        }
      }
      if (!s) {
        return json(res, 404, { ok: false, error: 'unknown token' });
      }
      if (!pendingWaits.has(token)) {
        const q = queueSteerReply(token, reply);
        if (!q.ok) return json(res, 400, q);
        return json(res, 200, q);
      }
      focusPanelTarget(token);
      deliverReply(token, reply);
      return json(res, 200, { ok: true, token });
    }

    if (u.pathname === '/api/continue' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token } = body;
      if (!token) return json(res, 400, { ok: false, error: 'bad request' });
      const result = triggerContinue(token, { manualContinue: true });
      if (!result.ok) {
        const code = result.error === 'NOT_ACTIVE_WAIT_TOKEN' ? 403 : 404;
        return json(res, code, { ok: false, ...result });
      }
      return json(res, 200, result);
    }

    if (u.pathname === '/api/wait/register' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token, message, prompt, title, workspace, pid, hostIp: bodyHostIp, hub: bodyHub } = body;
      if (!token || !sessions.has(token)) {
        return json(res, 404, { ok: false, error: 'unknown token' });
      }
      const s = sessions.get(token);
      const cursorTitle = title || s.cursorTitle || '';
      const hostIp = normHostIp(bodyHostIp || s.hostIp);
      const hub = bodyHub || s.hub || hubEndpointLabel();
      const v = validateTokenBinding(token, workspace || s.workspace, cursorTitle, hostIp, {
        allowTitleRename: true,
      });
      if (!v.ok) {
        log('wait register rejected', v);
        return json(res, 403, { ok: false, ...v });
      }
      if (cursorTitle) {
        s.cursorTitle = cursorTitle;
        s.bindingKey = makeBindingKey(workspace || s.workspace, cursorTitle, hostIp);
        if (!s.title || s.title === '新对话') s.title = cursorTitle;
      }
      if (workspace) s.workspace = workspace;
      s.hostIp = hostIp;
      s.hub = hub;
      s.lastActiveAt = nowMs();
      s.status = 'waiting';
      markConnectionHeld(s);
      (s.messages = s.messages || []).push({ role: 'assistant', content: String(message || ''), at: nowMs() });
      const review = parseKaruoReview(message);
      s.lastReview = {
        hasReview: review.hasReview,
        completionPct: review.completionPct,
        nextStep: review.nextStep,
        isIdleNext: review.isIdleNext,
        needsZhuiwen: review.needsZhuiwen,
        at: nowMs(),
      };
      if (review.projectGoal) s.projectGoal = review.projectGoal.slice(0, 200);
      if (review.completionPct != null) s.completionPct = review.completionPct;
      if (!review.isIdleNext && review.nextStep) {
        if (getSessionContinueMode(s) === 'standard') initPreRun(s, review.nextStep);
        if (isOptimizeMode(s)) initOptimizeRun(s, review.nextStep);
      }
      if (/追问完成|验收通过|追问结束/.test(String(message || ''))) {
        advancePreRunOnReply(s, String(message || ''));
      }
      if (effectiveAutoContinue(s) && !isIdleAssistantWait(message)) {
        s.autoStreak = 0;
      }
      saveSessions();
      registerBinding(workspace || s.workspace, cursorTitle, token, { hostIp, hub });

      setActiveWaitToken(token);

      const old = pendingWaits.get(token);
      if (old) {
        clearWaitTimers(old);
        for (const r of old.pollers) {
          try { json(r, 200, { status: 'renewal', token }); } catch {}
        }
      }
      pendingWaits.set(token, {
        message: String(message || ''),
        prompt: prompt || '',
        title: title || '',
        createdAt: nowMs(),
        workspace: workspace || s.workspace,
        pid: pid || null,
        pollers: new Set(),
        resolve: null,
        autoTimer: null,
        timedReplyTimer: null,
        timedReplyFireAt: null,
        silent: isSilentMessage(message, token),
      });
      log('wait register', token, {
        pid,
        autoContinue: effectiveAutoContinue(s),
        timedReply: effectiveTimedReply(s),
        timedIntervalMs: getTimedReplyIntervalMs(s),
        mode: getSessionContinueMode(s),
        silent: isSilentMessage(message, token),
      });
      if (effectiveAutoContinue(s)) scheduleAutoContinue(token);
      if (effectiveTimedReply(s)) scheduleTimedReply(token);
      return json(res, 200, {
        ok: true,
        token,
        autoContinue: effectiveAutoContinue(s),
        continueMode: getSessionContinueMode(s),
        hostIp,
        hub,
        bindingKey: s.bindingKey,
        bindingCheckCount: s.bindingCheckCount || 0,
        bindingUnchanged: true,
      });
    }

    if (u.pathname === '/api/wait/poll' && req.method === 'GET') {
      const token = u.searchParams.get('token');
      if (!token) return json(res, 400, { error: 'missing token' });

      const wait = pendingWaits.get(token);
      if (!wait) {
        return json(res, 404, { status: 'gone', error: 'no pending wait for token' });
      }

      const steered = takeSteerReply(token);
      if (steered) {
        deliverReply(token, steered, { steer: true });
        return json(res, 200, { status: 'replied', reply: steered, token });
      }

      let done = false;
      const timer = setTimeout(() => {
        if (done) return;
        done = true;
        wait.pollers.delete(res);
        json(res, 200, { status: 'renewal', token });
      }, POLL_TIMEOUT_MS);

      wait.pollers.add(res);
      res.on('close', () => {
        if (!done) { done = true; clearTimeout(timer); wait.pollers.delete(res); }
      });
      return;
    }

    if (u.pathname === '/api/init' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const workspace = body.workspace || '';
      const cursorTitle = body.cursorTitle || body.title || '';
      const newPlan = !!(body.newPlan || body.force);
      const planLabel = String(body.planLabel || '').trim();
      const hostIp = normHostIp(body.hostIp);
      const hub = body.hub || hubEndpointLabel();
      let existing = findBoundSession(workspace, cursorTitle, hostIp, planLabel || null);

      if (newPlan && existing) {
        // 新计划只创建新的完整 token，不归档旧计划；旧 token 继续保持 holding/waiting。
        log('init keep old for newPlan', existing, { planLabel });
        existing = null;
      }

      if (existing && !newPlan) {
        const s = sessions.get(existing);
        s.lastActiveAt = nowMs();
        if (cursorTitle) {
          s.cursorTitle = cursorTitle;
          s.bindingKey = makeBindingKey(workspace, cursorTitle, hostIp);
        }
        s.hostIp = hostIp;
        s.hub = hub;
        saveSessions();
        registerBinding(workspace, cursorTitle, existing, { hostIp, hub, planLabel: planLabel || s.planLabel || s.title });
        log('init reuse', existing, s.bindingKey);
        return json(res, 200, {
          ok: true,
          token: existing,
          reused: true,
          bindingKey: s.bindingKey,
          hostIp,
          hub,
          bindingMode: 'reuse',
          hint: '同标签+同IP已绑定此 token；换新计划请 newPlan:true',
        });
      }
      const activeCount = Array.from(sessions.values()).filter((s) => s.status !== 'completed').length;
      if (activeCount > 0 && !body.force && !cursorTitle) {
        return json(res, 409, {
          ok: false,
          error: 'SESSION_LIST_NOT_EMPTY',
          count: activeCount,
          hint: '传 cursorTitle 复用/新建绑定；或用户明确「新建持久对话」时 force=true',
        });
      }
      const token = newToken();
      const sessionTitle = planLabel || cursorTitle || body.title || '新对话';
      sessions.set(token, {
        token,
        title: sessionTitle,
        planLabel: planLabel || '',
        cursorTitle: cursorTitle || '',
        bindingKey: cursorTitle ? makeBindingKey(workspace, cursorTitle, hostIp) : '',
        hostIp,
        hub,
        workspace,
        status: 'holding',
        connectionHeld: true,
        phase: 'running',
        preRun: null,
        optimizeRun: null,
        sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false,
        continueMode: normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode),
        timedReplyEnabled: hubSettings.defaultTimedReplyEnabled !== false,
        timedReplyIntervalMs: hubSettings.timedReplyIntervalMs != null
          ? hubSettings.timedReplyIntervalMs
          : 0,
        bindingCheckCount: 0,
        bindingLockedAt: nowMs(),
        createdAt: nowMs(),
        lastActiveAt: nowMs(),
        messages: [],
      });
      saveSessions();
      dedupeBindingSessions();
      registerBinding(workspace, cursorTitle, token, { hostIp, hub, planLabel: planLabel || sessionTitle });
      log('init', token, { bindingKey: sessions.get(token).bindingKey, newPlan, planLabel, hostIp });
      return json(res, 200, {
        ok: true,
        token,
        newPlan,
        planLabel,
        bindingKey: sessions.get(token).bindingKey,
        hostIp,
        hub,
        bindingMode: 'auto_new',
      });
    }

    if (u.pathname === '/api/session/restore' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token } = body;
      if (!token) return json(res, 400, { ok: false, error: 'missing token' });
      rebuildBindingRegistryFromThreadFiles();
      let found = false;
      for (const [key, hit] of Object.entries(bindingRegistry)) {
        if (hit && hit.token === token) {
          if (!sessions.has(token)) {
            bindingRegistry[key] = hit;
          }
          found = true;
          break;
        }
      }
      if (!found) {
        if (fs.existsSync(THREAD_BINDINGS_DIR)) {
          for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) {
            if (!name.endsWith('.json')) continue;
            try {
              const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8'));
              if (hit && hit.token === token && hit.bindingKey) {
                bindingRegistry[hit.bindingKey] = {
                  token,
                  hostIp: hit.hostIp || ipFromKey(hit.bindingKey),
                  hub: hit.hub || hubEndpointLabel(),
                  updatedAt: hit.updatedAt || nowMs(),
                };
                saveBindingRegistry();
                found = true;
                break;
              }
            } catch {}
          }
        }
      }
      if (!found && !sessions.has(token)) {
        return json(res, 404, { ok: false, error: 'no binding for token' });
      }
      restoreSessionsFromBindings();
      saveSessions();
      return json(res, 200, { ok: true, token, restored: sessions.has(token) });
    }

    if (u.pathname === '/api/bindings/rebuild' && req.method === 'POST') {
      const n = rebuildBindingRegistryFromThreadFiles();
      restoreSessionsFromBindings();
      return json(res, 200, { ok: true, rebuilt: n, sessions: sessions.size });
    }

    if (u.pathname === '/api/binding/context' && req.method === 'GET') {
      const workspace = u.searchParams.get('workspace') || '';
      const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || '';
      const hostIp = u.searchParams.get('hostIp') || '';
      const planLabel = u.searchParams.get('planLabel') || '';
      const ctx = buildBindingContext(workspace, cursorTitle, hostIp, planLabel);
      return json(res, 200, { ok: true, ...ctx });
    }

    if (u.pathname === '/api/plan/bind' && req.method === 'GET') {
      const token = normalizeExactToken(u.searchParams.get('token') || '');
      if (!token) {
        return json(res, 400, { ok: false, error: 'INVALID_TOKEN', hint: '须完整 ct_xxxxxxxx，禁止前缀泛搜' });
      }
      const plan = getPlanByToken(token);
      if (!plan) {
        return json(res, 404, { ok: false, error: 'NO_PLAN_BINDING', token });
      }
      return json(res, 200, { ok: true, ...plan });
    }

    if (u.pathname === '/api/cleanup-unused' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const r = cleanupUnusedPlans({ keepTokens: body.keepTokens || [] });
      return json(res, 200, { ok: true, ...r });
    }

    if (u.pathname === '/api/binding/rename' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token, cursorTitle, workspace, hostIp: bodyHostIp } = body;
      if (!token || !cursorTitle) {
        return json(res, 400, { ok: false, error: 'missing token or cursorTitle' });
      }
      const mig = migrateBindingRename(
        workspace || sessions.get(token)?.workspace,
        token,
        cursorTitle,
        bodyHostIp || sessions.get(token)?.hostIp
      );
      return json(res, 200, { ok: true, ...mig });
    }

    if (u.pathname === '/api/session/verify' && req.method === 'GET') {
      const token = u.searchParams.get('token') || '';
      const workspace = u.searchParams.get('workspace') || '';
      const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || '';
      const hostIp = u.searchParams.get('hostIp') || '';
      if (!token) {
        return json(res, 400, { ok: false, match: false, error: 'missing token' });
      }
      const allowRename = u.searchParams.get('allowTitleRename') === '1';
      if (!sessions.has(token)) {
        restoreSessionsFromBindings();
      }
      const s = sessions.get(token);
      if (!s) {
        return json(res, 404, {
          ok: false,
          match: false,
          token,
          error: 'UNKNOWN_CONVERSATION_TOKEN',
          sessionExists: false,
          hint: 'Hub 会话库/绑定库都找不到该完整 token；此时才允许 init_conversation(newPlan:true) 新建计划。',
        });
      }
      const v = validateTokenBinding(token, workspace || s.workspace, cursorTitle || s.cursorTitle, hostIp || s.hostIp, { allowTitleRename: allowRename });
      const regToken = getRegistryToken(workspace || s.workspace, cursorTitle || s.cursorTitle, hostIp || s.hostIp);
      const match = v.ok && (!regToken || regToken === token);
      const tokenOwnerTitle = getTokenOwnerTitle(workspace || s.workspace, token);
      const payload = {
        ok: match,
        match,
        token,
        expectedToken: regToken || token,
        tokenOwnerTitle: tokenOwnerTitle || null,
        bindingKey: makeBindingKey(workspace, cursorTitle, hostIp),
        hostIp: normHostIp(hostIp || s?.hostIp),
        hub: s?.hub || hubEndpointLabel(),
        bindingUnchanged: !!match,
        bindingCheckCount: s?.bindingCheckCount || 0,
        sessionExists: sessions.has(token),
        ...(v.ok ? v : v),
      };
      return json(res, match ? 200 : 403, payload);
    }

    if (u.pathname === '/api/session/goal' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token, projectGoal, completionPct } = body;
      if (!token || !sessions.has(token)) {
        return json(res, 404, { ok: false, error: 'unknown token' });
      }
      const s = sessions.get(token);
      if (typeof projectGoal === 'string') s.projectGoal = projectGoal.trim();
      if (typeof completionPct === 'number' && !Number.isNaN(completionPct)) {
        s.completionPct = Math.max(0, Math.min(100, completionPct));
      }
      s.lastActiveAt = nowMs();
      saveSessions();
      return json(res, 200, {
        ok: true,
        token,
        projectGoal: s.projectGoal || '',
        completionPct: s.completionPct ?? null,
      });
    }

    if (u.pathname === '/api/binding' && req.method === 'GET') {
      const workspace = u.searchParams.get('workspace') || '';
      const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || '';
      const hostIp = u.searchParams.get('hostIp') || '';
      const planLabel = u.searchParams.get('planLabel') || '';
      const token = (planLabel ? getTokenByPlan(workspace, cursorTitle, hostIp, planLabel) : null)
        || getRegistryToken(workspace, cursorTitle, hostIp)
        || findBoundSession(workspace, cursorTitle, hostIp, planLabel || null);
      if (!token) {
        return json(res, 404, {
          ok: false,
          error: 'no binding',
          bindingKey: makeBindingKey(workspace, cursorTitle, hostIp),
          planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) : '',
          hostIp: normHostIp(hostIp),
          hub: hubEndpointLabel(),
        });
      }
      const s = sessions.get(token);
      return json(res, 200, {
        ok: true,
        token,
        planLabel: s?.planLabel || planLabel || '',
        bindingKey: makeBindingKey(workspace, cursorTitle, hostIp || s?.hostIp),
        planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) : (s?.planBindingKey || ''),
        hostIp: s?.hostIp || normHostIp(hostIp),
        hub: s?.hub || hubEndpointLabel(),
        title: s?.title || cursorTitle,
        workspace: s?.workspace || workspace,
      });
    }

    if (u.pathname === '/api/new' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const workspace = body.workspace || '';
      const cursorTitle = body.cursorTitle || body.title || '新对话';
      const token = newToken();
      sessions.set(token, {
        token,
        title: cursorTitle,
        cursorTitle,
        bindingKey: makeBindingKey(workspace, cursorTitle),
        workspace,
        status: 'holding',
        connectionHeld: true,
        sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false,
        createdAt: nowMs(),
        lastActiveAt: nowMs(),
        messages: [],
      });
      saveSessions();
      pruneSessions();
      return json(res, 200, { ok: true, token });
    }


    if (u.pathname === '/api/steer/delete' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token, index, all } = body;
      if (!token || !sessions.has(token)) {
        return json(res, 404, { ok: false, error: 'unknown token' });
      }
      const s = sessions.get(token);
      if (!Array.isArray(s.steerQueue) || !s.steerQueue.length) {
        return json(res, 404, { ok: false, error: 'empty', hint: '无排队方向可删' });
      }
      let removed = 0;
      if (all) {
        const texts = s.steerQueue.map((i) => i.text);
        removed = s.steerQueue.length;
        s.steerQueue = [];
        pruneSteerFromMessages(s, texts);
      } else if (Number.isInteger(index) && index >= 0 && index < s.steerQueue.length) {
        const text = s.steerQueue[index].text;
        s.steerQueue.splice(index, 1);
        if (text) pruneSteerFromMessages(s, [text]);
        removed = 1;
      } else {
        return json(res, 400, { ok: false, error: 'bad index' });
      }
      s.lastActiveAt = nowMs();
      saveSessions();
      log('steer delete', token, { removed, remain: (s.steerQueue || []).length });
      return json(res, 200, {
        ok: true,
        token,
        removed,
        queueLen: (s.steerQueue || []).length,
      });
    }

    if (u.pathname === '/api/delete' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const { token, force } = body;
      if (!token) return json(res, 400, { ok: false, error: 'missing token' });
      const r = deleteSession(token, !!force);
      if (!r.ok) return json(res, 409, r);
      pruneSessions();
      return json(res, 200, r);
    }

    if (u.pathname === '/api/delete-idle' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const keep = body.keep || null;
      const maxIdleHours = Math.max(1, Number(body.maxIdleHours) || 72);
      const maxIdleMs = maxIdleHours * 3600000;
      const now = nowMs();
      const protectedTokens = new Set(pendingWaits.keys());
      if (keep) protectedTokens.add(keep);
      for (const hit of Object.values(bindingRegistry)) {
        if (hit && hit.token && pendingWaits.has(hit.token)) protectedTokens.add(hit.token);
      }
      let deleted = 0;
      const deletedTokens = [];
      for (const [token, s] of [...sessions.entries()]) {
        if (protectedTokens.has(token)) continue;
        if (pendingWaits.has(token)) continue;
        const age = now - (s.lastActiveAt || s.createdAt || now);
        const idleLike = s.status === 'idle' || s.status === 'completed' || !(s.messages || []).length;
        if (!idleLike) continue;
        if (age < maxIdleMs && (s.messages || []).length > 0) continue;
        if (isVerticallyLockedToken(token) && age < maxIdleMs * 2) continue;
        sessions.delete(token);
        deletedTokens.push(token);
        deleted++;
      }
      if (deleted) saveSessions();
      pruneSessions();
      log('delete-idle', { deleted, remain: sessions.size, maxIdleHours, deletedTokens });
      return json(res, 200, { ok: true, deleted, remain: sessions.size, maxIdleHours });
    }

    if (u.pathname === '/api/prune' && req.method === 'POST') {
      const n = pruneSessions();
      return json(res, 200, { ok: true, pruned: n, remain: sessions.size });
    }

    if (u.pathname === '/api/merge' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req) || '{}');
      const src = sessions.get(body.source);
      const tgt = sessions.get(body.target);
      if (!src || !tgt) return json(res, 400, { ok: false, error: 'invalid token' });
      (tgt.messages = tgt.messages || []).push(...(src.messages || []));
      src.status = 'completed';
      tgt.lastActiveAt = nowMs();
      saveSessions();
      return json(res, 200, { ok: true });
    }

    res.writeHead(404); res.end('not found');
  } catch (e) {
    log('http err', String(e.stack || e.message || e));
    try { json(res, 500, { error: String(e.message || e) }); } catch {}
  }
});

server.on('error', (err) => {
  log('server listen error', err && err.code, err && err.message);
  process.exit(1);
});

server.listen(HTTP_PORT, '127.0.0.1', () => {
  log('hub listening', HTTP_PORT, 'pid', process.pid);
  try { archiveStaleSessions(); } catch (e) { log('archive boot err', String(e.message || e)); }
});

setInterval(() => {
  try { archiveStaleSessions(); } catch (e) { log('archive tick err', String(e.message || e)); }
}, 5 * 60 * 1000);

setInterval(() => {
  let changed = false;
  for (const [token, s] of sessions.entries()) {
    if (s.status === 'completed') continue;
    if (!effectiveAutoContinue(s)) continue;
    if (pendingWaits.has(token)) {
      if (!s.connectionHeld) {
        s.connectionHeld = true;
        changed = true;
      }
      s.lastKeepAliveAt = nowMs();
      if (s.status !== 'waiting') { s.status = 'waiting'; changed = true; }
    } else {
      if (s.connectionHeld) {
        s.connectionHeld = false;
        changed = true;
      }
      if (s.status === 'holding') {
        s.status = 'idle';
        changed = true;
      }
    }
  }
  if (changed) saveSessions();
}, CONNECTION_KEEPALIVE_MS);

process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
