#!/usr/bin/env node
/**
 * Local Persistent Chat — MCP stdio client
 * HTTP/面板由 hub.js 独占；本进程只负责 MCP 工具，通过 Hub API 挂起/收回复。
 * 每个 Agent 线程（工作区+标签名）唯一锁定一个 pchat-token，禁止跨会话串数据。
 */
'use strict';

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const readline = require('readline');
const { resolveCursorTabTitle, writeActiveVertical, normTitle: normTabTitle } = require('./cursor-title');

const HOME = process.env.HOME || process.env.USERPROFILE;
const ROOT = path.join(HOME, '.persistent-chat-local');
const LOG_DIR = path.join(ROOT, 'logs');
const THREAD_BIND_DIR = path.join(ROOT, 'thread-bindings');

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

const argv = process.argv.slice(2);
function arg(key, fallback) {
  const hit = argv.find((a) => a.startsWith(`--${key}=`));
  return hit ? hit.slice(key.length + 3) : fallback;
}
const WORKSPACE_RAW = arg('workspace', process.cwd());
const WORKSPACE = (!WORKSPACE_RAW
  || WORKSPACE_RAW === 'none'
  || String(WORKSPACE_RAW).includes('${workspaceFolder}'))
  ? (process.cwd() || HOME)
  : WORKSPACE_RAW;
const HTTP_PORT = parseInt(process.env.PCHAT_HTTP_PORT || arg('port', '13458'), 10);
const HUB_HOST = process.env.PCHAT_HUB_HOST || '127.0.0.1';
const HUB = `http://${HUB_HOST}:${HTTP_PORT}`;
const HOST_IP = getHostIp();
const HUB_LABEL = `${HUB_HOST}:${HTTP_PORT}`;
/** 卡若AI 能力真源（每轮对话门禁） */
const KARUO_AI_ROOT = '/Users/karuo/Documents/个人/卡若AI';

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 hubMeta() {
  return { hostIp: HOST_IP, hub: HUB_LABEL };
}

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

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

function normTitle(title) {
  return normTabTitle(title);
}

/** 真源：Cursor 标签名（args > vscdb > 本标签 thread 文件；禁止跨标签用进程锁） */
function resolveCursorTitle(args, token) {
  const hit = resolveCursorTabTitle({
    cursorTitle: args.cursorTitle || args.title,
    title: args.title,
    workspace: WORKSPACE,
    composerId: process.env.CURSOR_CONVERSATION_ID,
  });
  if (hit.title && hit.title !== '(未命名标签)') {
    return { ...hit, title: normTitle(hit.title) };
  }
  const argTitle = normTitle(args.cursorTitle || args.title || '');
  if (argTitle && argTitle !== '(未命名标签)') {
    return { title: argTitle, source: 'args', composerId: hit.composerId || null };
  }
  if (token) {
    const local = loadThreadBindingByToken(token);
    if (local?.cursorTitle) {
      return { title: normTitle(local.cursorTitle), source: 'tokenFile', composerId: local.composerId || hit.composerId || null };
    }
  }
  return { title: '', source: 'missing', composerId: hit.composerId || null };
}

async function syncTitleBinding(token, cursorTitle, composerId) {
  if (!cursorTitle || !token) return null;
  try {
    return await hubJson('/api/binding/sync-title', {
      method: 'POST',
      body: JSON.stringify({
        token,
        cursorTitle,
        composerId: composerId || '',
        workspace: WORKSPACE,
        hostIp: HOST_IP,
      }),
    });
  } catch {
    return null;
  }
}

async function maybeRenameBinding(token, cursorTitle, composerId) {
  if (!token || !cursorTitle) return null;
  try {
    return await hubJson('/api/binding/rename', {
      method: 'POST',
      body: JSON.stringify({
        token,
        cursorTitle,
        composerId: composerId || '',
        workspace: WORKSPACE,
        hostIp: HOST_IP,
      }),
    });
  } catch {
    return null;
  }
}

function makeBindingKey(workspace, cursorTitle, hostIp) {
  const ip = String(hostIp || HOST_IP).trim() || HOST_IP;
  return `${normWorkspace(workspace)}::${normTitle(cursorTitle)}@${ip}`;
}

function threadBindingPath(cursorTitle) {
  const key = makeBindingKey(WORKSPACE, cursorTitle);
  const hash = crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
  return path.join(THREAD_BIND_DIR, `${hash}.json`);
}

function saveThreadBinding(cursorTitle, token, extra = {}) {
  if (!cursorTitle || !token) return;
  const payload = {
    token,
    cursorTitle: normTitle(cursorTitle),
    workspace: normWorkspace(WORKSPACE),
    bindingKey: makeBindingKey(WORKSPACE, cursorTitle, extra.hostIp || HOST_IP),
    hostIp: extra.hostIp || HOST_IP,
    hub: extra.hub || HUB_LABEL,
    composerId: extra.composerId || '',
    verticalLock: true,
    updatedAt: Date.now(),
    pid: process.pid,
    ...extra,
  };
  fs.writeFileSync(threadBindingPath(cursorTitle), JSON.stringify(payload, null, 2));
}

function loadThreadBindingByToken(token) {
  if (!token) return null;
  try {
    for (const f of fs.readdirSync(THREAD_BIND_DIR)) {
      if (!f.endsWith('.json')) continue;
      const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BIND_DIR, f), 'utf8'));
      if (hit?.token === token) return hit;
    }
  } catch {}
  return null;
}

async function verifyVerticalBinding(token, cursorTitle) {
  const title = normTitle(cursorTitle || threadBoundTitle || '');
  if (!token) {
    return { ok: false, text: 'BINDING_MISSING_TOKEN: wait/init 必须带 pchat-token。' };
  }
  if (!title || title === '(未命名标签)') {
    return { ok: false, text: 'BINDING_MISSING_TITLE: wait/init 必须带 title=cursorTitle（当前 Cursor 标签名）。' };
  }
  try {
    const q = new URLSearchParams({
      token,
      workspace: WORKSPACE,
      cursorTitle: title,
      hostIp: HOST_IP,
      allowTitleRename: '1',
    });
    const v = await hubJson(`/api/session/verify?${q.toString()}`);
    if (!v.match) {
      return {
        ok: false,
        text:
          `VERTICAL_BINDING_BROKEN: ct_${token.slice(3)} 与标签「${title}」@ ${HOST_IP} 未对齐。\n` +
          `期望 token: ${v.expectedToken || '见 Hub 注册表'}。\n` +
          `修复：本对话全程只用 init 返回的 ct_；换计划用 newPlan:true；禁止串 token。`,
      };
    }
    lockThread(token, title);
    saveThreadBinding(title, token, { hostIp: v.hostIp || HOST_IP, hub: v.hub || HUB_LABEL });
    return {
      ok: true,
      token,
      title,
      bindingKey: v.bindingKey,
      hostIp: v.hostIp || HOST_IP,
      hub: v.hub || HUB_LABEL,
      bindingUnchanged: v.bindingUnchanged !== false,
      bindingCheckCount: v.bindingCheckCount || 0,
    };
  } catch (e) {
    if (e.status === 403 && e.data) {
      return {
        ok: false,
        text:
          `VERTICAL_BINDING_BROKEN: ${e.data.error || 'verify failed'}\n` +
          `${e.data.hint || ''}\n期望: ${e.data.expected || e.data.expectedToken || '?'}`,
      };
    }
    throw e;
  }
}

function bindingFingerprint(token, cursorTitle, verify) {
  const t = normTitle(cursorTitle || threadBoundTitle || '');
  const ok = verify && verify.ok !== false;
  const n = verify?.bindingCheckCount != null ? verify.bindingCheckCount : '—';
  return (
    `[绑定指纹·${ok ? '不变' : '异常'}] IP=${HOST_IP} | Hub=${HUB} | ct=${token} | 标签=${t} | 校验#${n}`
  );
}

function bindingLockLine(token, cursorTitle, verify) {
  const t = normTitle(cursorTitle || threadBoundTitle || '');
  const fp = bindingFingerprint(token, t, verify || { ok: true });
  return `🔒 [垂直绑定] ${token} ↔「${t}」@ ${HOST_IP} · ${fp}`;
}

function loadThreadBinding(cursorTitle) {
  if (!cursorTitle) return null;
  try {
    return JSON.parse(fs.readFileSync(threadBindingPath(cursorTitle), 'utf8'));
  } catch {
    return null;
  }
}

/** 本 MCP 进程内锁定的 token（一个 Agent 线程唯一） */
let threadBoundToken = null;
let threadBoundTitle = null;

function lockThread(token, cursorTitle, extra = {}) {
  threadBoundToken = token;
  if (cursorTitle) {
    threadBoundTitle = normTitle(cursorTitle);
    saveThreadBinding(cursorTitle, token, { hostIp: HOST_IP, hub: HUB_LABEL, ...extra });
    writeActiveVertical({
      composerId: extra.composerId || '',
      title: threadBoundTitle,
      workspace: WORKSPACE,
      token,
    });
  }
  log('thread lock', { token, cursorTitle: threadBoundTitle, hostIp: HOST_IP, composerId: extra.composerId || '' });
}

async function hubBindingToken(cursorTitle) {
  if (!cursorTitle || cursorTitle === '(未命名标签)') return null;
  try {
    const q = new URLSearchParams({ workspace: WORKSPACE, cursorTitle, hostIp: HOST_IP });
    const b = await hubJson(`/api/binding?${q.toString()}`);
    return b.token || null;
  } catch {
    return null;
  }
}

async function hubBindingContext(cursorTitle, planLabel) {
  const q = new URLSearchParams({ workspace: WORKSPACE, cursorTitle, hostIp: HOST_IP });
  if (planLabel) q.set('planLabel', planLabel);
  return hubJson(`/api/binding/context?${q.toString()}`);
}

function parseChoiceToken(choiceText) {
  const m = String(choiceText || '').match(/ct_[a-f0-9]{8}/i);
  return m ? m[0].toLowerCase() : null;
}

function askOptionsFromCtx(ctx, titleNorm, opts = {}) {
  const { recommendedToken, excludeToken, reason } = opts;
  const options = [];
  const canonical = recommendedToken || ctx.boundToken || null;
  if (canonical) {
    options.push(
      `🔄 恢复正确任务: ${canonical} (${titleNorm}) [与本标签匹配·推荐]`
    );
  }
  options.push(`🆕 新建本标签专用: ${titleNorm} (init newPlan)`);
  for (const s of ctx.activeSessions || []) {
    if (!s.token || s.token === canonical || s.token === excludeToken) continue;
    const label = s.cursorTitle || s.title || '未命名';
    options.push(
      `🔄 恢复其它任务: ${s.token} (${label}) ${s.isActiveWait ? '[当前活跃]' : ''} ${s.status === 'waiting' ? '[等待中]' : ''}`
    );
  }
  return { options, canonical, reason: reason || ctx.mode || 'choice' };
}

async function formatMismatchChoice(cursorTitle, requestedToken, reason, extra = {}) {
  const titleNorm = normTitle(cursorTitle);
  const ctx = await hubBindingContext(titleNorm);
  const owner = requestedToken ? await hubTokenOwnerTitle(requestedToken) : (extra.ownerTitle || null);
  const { options, canonical } = askOptionsFromCtx(ctx, titleNorm, {
    recommendedToken: ctx.boundToken,
    excludeToken: null,
    reason,
  });
  const lines = [
    '[BINDING_MISMATCH]',
    `reason: ${reason}`,
    `当前 Cursor 标签（Agent 应对应的任务）: ${titleNorm}`,
    requestedToken ? `Agent 当前误用 token: ${requestedToken}` : '',
    owner ? `该 token 实际绑定任务: ${owner}` : '',
    extra.processLock ? `MCP 进程误锁: ${extra.processLock}` : '',
    `hostIp: ${ctx.hostIp}`,
    `hub: ${ctx.hubUrl}`,
    canonical ? `本标签正确 token: ${canonical}` : '',
    '',
    '⚠️ 任务与 token 不一致 — 必须 ask_user_question 让用户选，禁止自行猜测：',
    '问题: 请选择与本 Cursor 标签一致的持久对话任务',
    '',
    'ASK_OPTIONS_JSON:',
    JSON.stringify(options),
    '',
    ...options.map((o) => `OPTION: ${o}`),
    '',
    '用户选完后调用 apply_binding_choice(choice=用户原文, cursorTitle=当前标签名)',
    '[transport: codeblock]',
  ].filter(Boolean);
  return lines.join('\n');
}

function formatBindingChoice(ctx, titleNorm) {
  const { options } = askOptionsFromCtx(ctx, titleNorm, { reason: 'choice' });
  const lines = [
    '[BINDING_CHOICE_REQUIRED]',
    `hostIp: ${ctx.hostIp}`,
    `hub: ${ctx.hubUrl}`,
    `cursorTitle: ${titleNorm}`,
    '本工作区有其它活跃任务，须 ask_user_question 让用户选择：',
    '问题: 请选择要绑定的持久对话任务',
    '',
    'ASK_OPTIONS_JSON:',
    JSON.stringify(options),
    '',
    ...options.map((o) => `OPTION: ${o}`),
    '',
    '用户选完后调用 apply_binding_choice(choice=用户原文, cursorTitle=当前标签名)',
    '[transport: codeblock]',
  ];
  return lines.join('\n');
}

async function resolveOrPromptChoice(resolved, cursorTitle) {
  if (resolved.ok) return null;
  if (resolved.needsChoice) {
    return makeContentText(
      await formatMismatchChoice(
        resolved.cursorTitle || cursorTitle,
        resolved.requestedToken,
        resolved.reason,
        { ownerTitle: resolved.ownerTitle, processLock: resolved.processLock }
      )
    );
  }
  if (resolved.text) return makeContentText(resolved.text);
  return null;
}

function formatBindingReuseText(ctx, titleNorm, token) {
  const plan = ctx.planLabel ? `\nplanLabel: ${ctx.planLabel}` : '';
  return (
    `[BINDING_REUSE]\n` +
    `hostIp: ${ctx.hostIp}\n` +
    `hub: ${ctx.hubUrl}\n` +
    `bindingKey: ${ctx.bindingKey}${plan}\n` +
    `本标签+本机IP已绑定: ${token} ↔ 「${titleNorm}」\n` +
    `🔄 恢复对话: ${token} (${titleNorm}) [已绑定·完整 token·勿换]\n` +
    `${bindingLockLine(token, titleNorm, { ok: true })}\n` +
    `[transport: codeblock]`
  );
}

async function hubTokenOwnerTitle(token) {
  if (!token) return null;
  try {
    const q = new URLSearchParams({ token, workspace: WORKSPACE });
    const v = await hubJson(`/api/session/verify?${q.toString()}`);
    if (v.tokenOwnerTitle) return normTitle(v.tokenOwnerTitle);
    const s = await hubJson('/api/state');
    const hit = (s.sessions || []).find((x) => x.token === token);
    if (hit?.cursorTitle) return normTitle(hit.cursorTitle);
  } catch {}
  return null;
}

async function hubEnsureTokenExists(token, cursorTitle) {
  if (!token) return { ok: false, error: 'missing token' };
  try {
    await hubJson('/api/session/restore', {
      method: 'POST',
      body: JSON.stringify({ token }),
    });
  } catch (e) {
    if (e.status !== 404) throw e;
  }
  try {
    const q = new URLSearchParams({
      token,
      workspace: WORKSPACE,
      cursorTitle: normTitle(cursorTitle || ''),
      hostIp: HOST_IP,
    });
    const v = await hubJson(`/api/session/verify?${q.toString()}`);
    return { ok: !!v.sessionExists, verify: v };
  } catch (e) {
    if (e.status === 404) {
      return { ok: false, error: e.data?.error || 'UNKNOWN_CONVERSATION_TOKEN', hint: e.data?.hint || '' };
    }
    if (e.status === 403 && e.data?.sessionExists === false) {
      return { ok: false, error: e.data.error || 'UNKNOWN_CONVERSATION_TOKEN', hint: e.data.hint || '' };
    }
    if (e.status === 403) return { ok: true, verifyError: e.data };
    throw e;
  }
}

async function resolveThreadToken(requestedToken, cursorTitle, composerId) {
  const title = normTitle(cursorTitle || '');
  const explicitTitle = title && title !== '(未命名标签)';

  // 同工作区多标签共用一个 MCP 进程：必须以「当前标签 + composerId」为准
  if (explicitTitle) {
    if (composerId) {
      try {
        const sync = await hubJson('/api/binding/sync-title', {
          method: 'POST',
          body: JSON.stringify({
            token: requestedToken || '',
            cursorTitle: title,
            composerId,
            workspace: WORKSPACE,
            hostIp: HOST_IP,
          }),
        });
        if (sync?.token) {
          lockThread(sync.token, title, { composerId });
          return { ok: true, token: sync.token, titleRenamed: !!sync.renamed };
        }
      } catch {}
    }
    const hubTok = await hubBindingToken(title);
    const local = loadThreadBinding(title);
    const canonical = hubTok || local?.token || null;
    if (canonical) {
      if (requestedToken && requestedToken !== canonical) {
        return {
          ok: false,
          needsChoice: true,
          reason: 'TOKEN_TITLE_MISMATCH',
          cursorTitle: title,
          requestedToken,
        };
      }
      lockThread(canonical, title, { composerId });
      return { ok: true, token: canonical };
    }
    if (requestedToken) {
      const exists = await hubEnsureTokenExists(requestedToken, title);
      if (!exists.ok) {
        return {
          ok: false,
          text:
            `UNKNOWN_CONVERSATION_TOKEN: ${requestedToken} 不在 Hub 会话库/绑定库中。\n` +
            `请先用完整 token 恢复；若库里确实没有，再 init_conversation(newPlan:true, cursorTitle=当前标签名, planLabel=计划名)。\n` +
            `[transport: codeblock]`,
        };
      }
      const owner = await hubTokenOwnerTitle(requestedToken);
      if (owner && owner !== title) {
        return {
          ok: false,
          needsChoice: true,
          reason: 'TOKEN_BOUND_TO_OTHER_TITLE',
          cursorTitle: title,
          requestedToken,
          ownerTitle: owner,
        };
      }
      lockThread(requestedToken, title, { composerId });
      return { ok: true, token: requestedToken };
    }
  }

  if (threadBoundToken && threadBoundTitle) {
    if (explicitTitle && title !== threadBoundTitle) {
      return {
        ok: false,
        needsChoice: true,
        reason: 'PROCESS_LOCK_WRONG_TASK',
        cursorTitle: title,
        requestedToken: requestedToken || threadBoundToken,
        processLock: `${threadBoundToken}（${threadBoundTitle}）`,
      };
    }
    if (requestedToken && requestedToken !== threadBoundToken) {
      return {
        ok: false,
        needsChoice: true,
        reason: 'TOKEN_THREAD_MISMATCH',
        cursorTitle: title || threadBoundTitle,
        requestedToken,
        processLock: `${threadBoundToken}（${threadBoundTitle}）`,
      };
    }
    return { ok: true, token: threadBoundToken };
  }

  if (requestedToken) {
    const exists = await hubEnsureTokenExists(requestedToken, title || threadBoundTitle || '');
    if (!exists.ok) {
      return {
        ok: false,
        text:
          `UNKNOWN_CONVERSATION_TOKEN: ${requestedToken} 不在 Hub 会话库/绑定库中。\n` +
          `找不到完整 token 时，才允许新建计划：init_conversation(newPlan:true, cursorTitle=标签名, planLabel=计划名)。\n` +
          `[transport: codeblock]`,
      };
    }
    if (explicitTitle) lockThread(requestedToken, title, { composerId });
    return { ok: true, token: requestedToken };
  }
  return {
    ok: false,
    text: 'UNKNOWN_CONVERSATION_TOKEN: 请 init_conversation(cursorTitle=当前 Cursor 标签名) 获取本标签专用 ct_。',
  };
}

async function hubJson(pathname, opts = {}) {
  const url = HUB + pathname;
  const res = await fetch(url, {
    ...opts,
    headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
  });
  const text = await res.text();
  let data = {};
  try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
  if (!res.ok) {
    const err = new Error(data.error || data.hint || `HTTP ${res.status}`);
    err.status = res.status;
    err.data = data;
    throw err;
  }
  return data;
}

async function hubPollWait(token, onReRegister) {
  let hubRetries = 0;
  let goneRetries = 0;
  while (true) {
    try {
      const res = await fetch(`${HUB}/api/wait/poll?token=${encodeURIComponent(token)}`, {
        signal: AbortSignal.timeout(35000),
      });
      const data = await res.json();
      hubRetries = 0;
      if (data.status === 'replied') return data.reply;
      if (data.status === 'renewal') continue;
      if (data.status === 'gone') {
        if (typeof onReRegister === 'function' && goneRetries < 8) {
          goneRetries++;
          log('wait gone, re-register', { token, attempt: goneRetries });
          await onReRegister();
          continue;
        }
        throw new Error('wait gone: ' + token);
      }
      throw new Error('unexpected poll: ' + JSON.stringify(data));
    } catch (e) {
      const msg = String(e.message || e);
      const transient = /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up/i.test(msg);
      if (transient && hubRetries < 60) {
        hubRetries++;
        await new Promise((r) => setTimeout(r, 300));
        continue;
      }
      throw e;
    }
  }
}

function send(obj) {
  process.stdout.write(JSON.stringify(obj) + '\n');
}

function makeContentText(text) {
  return { content: [{ type: 'text', text: String(text) }] };
}

function makeError(id, code, message) {
  return { jsonrpc: '2.0', id, error: { code, message } };
}

function clearThreadBinding(cursorTitle) {
  if (!cursorTitle) return;
  threadBoundToken = null;
  threadBoundTitle = null;
  try { fs.unlinkSync(threadBindingPath(cursorTitle)); } catch {}
  log('thread binding cleared', { cursorTitle: normTitle(cursorTitle) });
}

const TOOLS = [
  {
    name: 'init_conversation',
    description: '创建/复用持久对话。同标签续同一计划可复用；新计划必须 newPlan:true 新建 ct_，禁止挂旧线程。',
    inputSchema: {
      type: 'object',
      properties: {
        title: { type: 'string', description: 'Cursor Chat 窗口标题（标签名，必填）' },
        cursorTitle: { type: 'string', description: '同 title' },
        force: { type: 'boolean', description: '强制新建（同 newPlan）' },
        newPlan: {
          type: 'boolean',
          description: '新计划/新任务：归档同标签旧 ct_，创建新线程，禁止复用 Codex网关修复等旧会话',
        },
        planLabel: { type: 'string', description: '新计划简称，写入会话标题（如「自传小说」）' },
      },
      required: [],
    },
  },
  {
    name: 'select_conversation',
    description: '检查绑定；任务/token 不匹配时返回 BINDING_MISMATCH 供 ask_user_question 选人。',
    inputSchema: {
      type: 'object',
      properties: {
        newPlan: { type: 'boolean', description: 'true 时跳过旧锁，等价于 init newPlan' },
        planLabel: { type: 'string' },
        cursorTitle: { type: 'string' },
        title: { type: 'string' },
        'pchat-token': { type: 'string', description: '可选；若与标签不匹配则弹出任务选择' },
      },
      required: [],
    },
  },
  {
    name: 'wait_for_user_input',
    description: '挂起 Agent，等待本地面板 http://127.0.0.1:13458 回复。必须使用本线程锁定的 pchat-token。',
    inputSchema: {
      type: 'object',
      properties: {
        'pchat-token': { type: 'string' },
        message: { type: 'string' },
        reply_file: { type: 'string' },
        prompt: { type: 'string' },
        title: { type: 'string', description: 'Cursor 标签名（必填，与 init 一致）' },
      },
      required: ['pchat-token', 'message'],
    },
  },
  {
    name: 'verify_karuo_ai_gate',
    description:
      '卡若AI 每轮门禁：校验 BOOTSTRAP/AGENTS 可读 +（可选）垂直绑定。每对话至少调用一次。',
    inputSchema: {
      type: 'object',
      properties: {
        'pchat-token': { type: 'string', description: '当前 ct_；传入则同时 verify_binding' },
        title: { type: 'string' },
        cursorTitle: { type: 'string' },
      },
      required: [],
    },
  },
  {
    name: 'verify_binding',
    description: '校验 pchat-token 与 Cursor 标签名垂直绑定是否一致（wait 前可主动调）。',
    inputSchema: {
      type: 'object',
      properties: {
        'pchat-token': { type: 'string' },
        title: { type: 'string' },
        cursorTitle: { type: 'string' },
      },
      required: ['pchat-token'],
    },
  },
  {
    name: 'apply_binding_choice',
    description: '用户经 ask_user_question 选定任务后，应用绑定（解析选项中的 ct_ 或新建）。',
    inputSchema: {
      type: 'object',
      properties: {
        choice: { type: 'string', description: '用户选择的完整选项原文' },
        cursorTitle: { type: 'string', description: '当前 Cursor 标签名（必填）' },
        title: { type: 'string' },
      },
      required: ['choice', 'cursorTitle'],
    },
  },
  {
    name: 'merge_conversation',
    description: '合并会话（已锁定线程时禁止）。',
    inputSchema: {
      type: 'object',
      properties: { source: { type: 'string' }, target: { type: 'string' } },
      required: ['source', 'target'],
    },
  },
];

async function handleToolCall(name, args) {
  args = args || {};

  if (name === 'init_conversation') {
    try {
      const titleHit = resolveCursorTitle(args);
      const cursorTitle = titleHit.title || '';
      if (!cursorTitle || cursorTitle === '(未命名标签)') {
        return makeContentText(
          'BINDING_MISSING_TITLE: 未读到 Cursor 标签名。请传 title/cursorTitle，或确认 Hook active-vertical / state.vscdb 可用。\n[transport: codeblock]'
        );
      }
      const newPlan = !!(args.newPlan || args.force);
      if (newPlan) clearThreadBinding(cursorTitle);
      const r = await hubJson('/api/init', {
        method: 'POST',
        body: JSON.stringify({
          title: cursorTitle || '新对话',
          cursorTitle,
          composerId: titleHit.composerId || '',
          workspace: WORKSPACE,
          force: newPlan,
          newPlan,
          planLabel: args.planLabel || '',
          ...hubMeta(),
        }),
      });
      lockThread(r.token, cursorTitle, { planLabel: args.planLabel || r.planLabel || '', composerId: titleHit.composerId || '' });
      log('init_conversation', r.token, r.reused ? 'reused' : (newPlan ? 'new_plan' : 'new'));
      const reuseNote = r.reused ? '\n（已复用同标签+同IP，勿再 init）' : '';
      const planNote = r.newPlan ? `\n🆕 新计划线程：${r.token}（旧 ct_ 已归档）` : '';
      const bind = bindingLockLine(r.token, cursorTitle, { ok: true, bindingCheckCount: 1 });
      const modeNote = r.bindingMode === 'auto_new' ? '\n[BINDING_AUTO_NEW] 新对话已自动绑定，全程勿换。' : '';
      return makeContentText(
        `Conversation initialized. pchat-token: ${r.token}${reuseNote}${planNote}${modeNote}\n` +
        `${bind}\n` +
        `[transport: codeblock]\n` +
        `cursorTitle: ${cursorTitle}（来源: ${titleHit.source}）\n` +
        `线程绑定: ${r.bindingKey || makeBindingKey(WORKSPACE, cursorTitle, HOST_IP)}\n` +
        `hostIp: ${r.hostIp || HOST_IP} · hub: ${HUB}\n` +
        `本 Agent 垂直锁定：同一 ct_ + 同一 Cursor 标签 + 同一 IP；标签改名自动迁移 binding。\n` +
        `⚠️ 换计划用 newPlan:true；禁止中途换 ct_。\n` +
        `本地回复面板: ${HUB.replace('/api', '')}/`
      );
    } catch (e) {
      if (e.status === 409) {
        return makeContentText(
          `SESSION_LIST_NOT_EMPTY: 存在 ${e.data.count || '?'} 个活跃会话。\n` +
          `请 init 时传 cursorTitle=当前标签名 自动复用绑定；勿 force 乱建。\n` +
          `仅当用户明确说「新建持久对话」时才 force=true。`
        );
      }
      throw e;
    }
  }

  if (name === 'apply_binding_choice') {
    const titleHit = resolveCursorTitle(args);
    const cursorTitle = titleHit.title || args.cursorTitle || args.title || '';
    const titleNorm = normTitle(cursorTitle);
    const choice = String(args.choice || '');
    if (!titleNorm || titleNorm === '(未命名标签)') {
      return makeContentText('BINDING_CHOICE_FAIL: 必须传 cursorTitle=当前 Cursor 标签名');
    }
    if (/新建|newPlan|新计划|新标签专用|🆕/i.test(choice)) {
      return handleToolCall('init_conversation', {
        cursorTitle: titleNorm,
        title: titleNorm,
        newPlan: true,
        planLabel: args.planLabel || '',
      });
    }
    const token = parseChoiceToken(choice);
    if (!token) {
      return makeContentText(
        'BINDING_CHOICE_PARSE_FAIL: 无法解析 ct_。请让用户重选，或选「🆕 新建本标签专用」。\n[transport: codeblock]'
      );
    }
    const owner = await hubTokenOwnerTitle(token);
    if (owner && owner !== titleNorm) {
      const canonical = await hubBindingToken(titleNorm);
      if (token !== canonical) {
        return makeContentText(
          await formatMismatchChoice(titleNorm, token, 'USER_PICKED_WRONG_TASK', { ownerTitle: owner })
        );
      }
    }
    lockThread(token, titleNorm);
    const v = await verifyVerticalBinding(token, titleNorm);
    if (!v.ok) return makeContentText(v.text + '\n[transport: codeblock]');
    return makeContentText(
      `✅ 已绑定任务\n${bindingLockLine(v.token, v.title, v)}\n` +
      `pchat-token: ${token}（全程勿换）\n[transport: codeblock]`
    );
  }

  if (name === 'select_conversation') {
    const titleHit = resolveCursorTitle(args, args['pchat-token']);
    const cursorTitle = titleHit.title || args.cursorTitle || args.title || threadBoundTitle || '';
    const titleNorm = normTitle(cursorTitle);
    const planLabel = String(args.planLabel || '').trim();
    if (args.newPlan) {
      return handleToolCall('init_conversation', {
        ...args,
        newPlan: true,
        cursorTitle,
        title: cursorTitle,
        planLabel,
      });
    }
    if (titleNorm && titleNorm !== '(未命名标签)' && args['pchat-token']) {
      const pre = await resolveThreadToken(args['pchat-token'], titleNorm, titleHit.composerId);
      const prompt = await resolveOrPromptChoice(pre, titleNorm);
      if (prompt) return prompt;
    }
    if (titleNorm && titleNorm !== '(未命名标签)') {
      const ctx = await hubBindingContext(titleNorm, planLabel);
      if (ctx.mode === 'reuse' && ctx.boundToken) {
        lockThread(ctx.boundToken, titleNorm, { composerId: titleHit.composerId || '', planLabel: ctx.planLabel || planLabel });
        return makeContentText(formatBindingReuseText(ctx, titleNorm, ctx.boundToken));
      }
      if (ctx.mode === 'auto_new') {
        return handleToolCall('init_conversation', {
          cursorTitle: titleNorm,
          title: titleNorm,
          newPlan: false,
          planLabel,
        });
      }
      if (ctx.mode === 'choice') {
        const matched = (ctx.activeSessions || []).filter(
          (x) => normTitle(x.cursorTitle || x.title) === titleNorm
        );
        if (matched.length === 1) {
          lockThread(matched[0].token, titleNorm, { composerId: titleHit.composerId || '', planLabel: matched[0].planLabel || planLabel });
          return makeContentText(formatBindingReuseText(ctx, titleNorm, matched[0].token));
        }
        const lines = matched.map((s) => {
          const pl = s.planLabel ? ` · 计划:${s.planLabel}` : '';
          return `🔄 恢复对话: ${s.token} (${s.cursorTitle || s.title || '未命名'})${pl} ${s.isActiveWait ? '[当前活跃]' : ''} ${s.status === 'waiting' ? '[等待中]' : ''}（完整 token·如不确定请勿选择）`;
        });
        if (lines.length > 0) {
          return makeContentText(
            `[BINDING_CHOICE] cursorTitle: ${titleNorm}\n` +
            lines.join('\n') +
            `\n⚠️ 须选完整 ct_xxxxxxxx，禁止前缀泛搜。\n[transport: codeblock]`
          );
        }
        return makeContentText(formatBindingChoice(ctx, titleNorm));
      }
    }
    if (threadBoundToken && threadBoundTitle) {
      return makeContentText(
        `本 MCP 进程已锁定: ${threadBoundToken}（标签: ${threadBoundTitle}）\n` +
        `🔄 恢复对话: ${threadBoundToken} (${threadBoundTitle}) [当前线程·完整 token]（如不确定请勿选择）\n` +
        `⚠️ 新标签/新任务 → init_conversation(newPlan:true, planLabel=计划名, cursorTitle=标签名)\n` +
        `[transport: codeblock]`
      );
    }
    const st = await hubJson('/api/state');
    const wsNorm = WORKSPACE.replace(/\/+$/, '');
    let list = (st.sessions || []).filter((s) => s.status !== 'completed')
      .filter((s) => {
        const sw = String(s.workspace || '').replace(/\/+$/, '');
        return !sw || sw === wsNorm;
      });
    if (titleNorm && titleNorm !== '(未命名标签)') {
      list = list.filter((s) => normTitle(s.cursorTitle || s.title || '') === titleNorm);
    }
    const lines = list.map((s) => {
      const pl = s.planLabel ? ` · 计划:${s.planLabel}` : '';
      return `🔄 恢复对话: ${s.token} (${s.title || s.cursorTitle || '未命名'})${pl} ${s.isActiveWait ? '[当前活跃]' : ''} ${s.status === 'waiting' ? '[等待中]' : ''}（完整 token·如不确定请勿选择）`;
    });
    log('select_conversation', { count: lines.length, titleNorm, planLabel });
    if (lines.length === 0) {
      return makeContentText('当前没有活跃会话。请 init_conversation（传 cursorTitle=标签名, planLabel=计划名）。\n[transport: codeblock]');
    }
    return makeContentText(
      'Active sessions (latest first):\n' + lines.join('\n') +
      `\n⚠️ 必须传 cursorTitle=当前标签名；须选完整 ct_xxxxxxxx，禁止前缀泛搜。\n` +
      `[transport: codeblock]\n本地回复面板: ${HUB.replace('127.0.0.1', '127.0.0.1')}/`
    );
  }

  if (name === 'verify_karuo_ai_gate') {
    const titleHit = resolveCursorTitle(args, args['pchat-token']);
    const cursorTitle = titleHit.title || '';
    const checks = [
      { name: 'BOOTSTRAP.md', path: path.join(KARUO_AI_ROOT, 'BOOTSTRAP.md') },
      { name: 'AGENTS.md', path: path.join(KARUO_AI_ROOT, 'AGENTS.md') },
      { name: 'SKILL_REGISTRY.md', path: path.join(KARUO_AI_ROOT, 'SKILL_REGISTRY.md') },
    ];
    const fileLines = checks.map((c) => {
      const ok = fs.existsSync(c.path);
      return `${ok ? '✅' : '❌'} ${c.name} @ ${c.path}`;
    });
    let pass = checks.every((c) => fs.existsSync(c.path));
    const lines = [
      '[KARUO_AI_GATE]',
      `karuoAiRoot: ${KARUO_AI_ROOT}`,
      '--- 卡若AI 真源 ---',
      ...fileLines,
      `cursorTitle: ${cursorTitle || '(未解析)'}（来源: ${titleHit.source}）`,
    ];
    if (args['pchat-token']) {
      const resolved = await resolveThreadToken(args['pchat-token'], cursorTitle, titleHit.composerId);
      if (!resolved.ok) {
        pass = false;
        lines.push('❌ 垂直绑定: 未对齐（见 BINDING_MISMATCH）');
        const prompt = await resolveOrPromptChoice(resolved, cursorTitle);
        if (prompt) return prompt;
      } else {
        const v = await verifyVerticalBinding(resolved.token, cursorTitle);
        if (v.ok) {
          lines.push(`✅ 垂直绑定: ${bindingLockLine(v.token, v.title, v)}`);
        } else {
          pass = false;
          lines.push(`❌ 垂直绑定: ${v.text}`);
        }
      }
    } else {
      lines.push('⚠️ 未传 pchat-token：仅校验卡若AI 文件；init/select 后请再验一次');
    }
    lines.push(`[KARUO_AI_GATE] ${pass ? 'PASS' : 'FAIL'}`);
    lines.push('[transport: codeblock]');
    log('verify_karuo_ai_gate', pass ? 'PASS' : 'FAIL', { cursorTitle, token: args['pchat-token'] });
    return makeContentText(lines.join('\n'));
  }

  if (name === 'verify_binding') {
    const titleHit = resolveCursorTitle(args, args['pchat-token']);
    const cursorTitle = titleHit.title || args.cursorTitle || args.title || threadBoundTitle || '';
    const resolved = await resolveThreadToken(args['pchat-token'], cursorTitle);
    const prompt = await resolveOrPromptChoice(resolved, cursorTitle);
    if (prompt) return prompt;
    const v = await verifyVerticalBinding(resolved.token, cursorTitle);
    if (!v.ok) return makeContentText(v.text + '\n[transport: codeblock]');
    return makeContentText(
      `✅ 垂直绑定 OK（校验不变）\n${bindingLockLine(v.token, v.title, v)}\n` +
      `bindingKey: ${v.bindingKey || makeBindingKey(WORKSPACE, v.title, v.hostIp)}\n` +
      `[transport: codeblock]`
    );
  }

  if (name === 'wait_for_user_input') {
    const titleHit = resolveCursorTitle(args, args['pchat-token']);
    const cursorTitle = titleHit.title || args.title || threadBoundTitle || '';
    if (!cursorTitle || cursorTitle === '(未命名标签)') {
      return makeContentText(
        'BINDING_MISSING_TITLE: wait 需要 Cursor 标签名（自动读 vscdb 失败时请传 title）。\n[transport: codeblock]'
      );
    }
    await syncTitleBinding(args['pchat-token'], cursorTitle, titleHit.composerId);
    await maybeRenameBinding(args['pchat-token'], cursorTitle, titleHit.composerId);
    const resolved = await resolveThreadToken(args['pchat-token'], cursorTitle, titleHit.composerId);
    const prompt = await resolveOrPromptChoice(resolved, cursorTitle);
    if (prompt) return prompt;
    const token = resolved.token;
    const bindCheck = await verifyVerticalBinding(token, cursorTitle);
    if (!bindCheck.ok) return makeContentText(bindCheck.text);
    const message = String(args.message || '');
    const registerWait = async () => {
      await hubJson('/api/wait/register', {
        method: 'POST',
        body: JSON.stringify({
          token,
          message,
          prompt: args.prompt || '',
          title: cursorTitle,
          composerId: titleHit.composerId || '',
          workspace: WORKSPACE,
          pid: process.pid,
          ...hubMeta(),
        }),
      });
    };
    try {
      await registerWait();
    } catch (e) {
      if (e.status === 403) {
        return makeContentText(
          `${e.data.error || 'BINDING_REJECTED'}: ${e.data.hint || e.message}\n` +
          `请使用本标签绑定的 token: ${e.data.expected || threadBoundToken || '见面板'}`
        );
      }
      throw e;
    }
    log('wait begin', token);
    activeWaitToken = token;
    let reply;
    let outerRetries = 0;
    try {
      while (outerRetries < 120) {
        try {
          await registerWait();
          reply = await hubPollWait(token, registerWait);
          break;
        } catch (e) {
          const msg = String(e.message || e);
          const retriable = /fetch failed|ECONNREFUSED|ECONNRESET|wait gone|unknown token|socket hang up/i.test(msg);
          if (retriable && outerRetries < 119) {
            outerRetries++;
            log('wait outer retry', { token, attempt: outerRetries, msg: msg.slice(0, 80) });
            await new Promise((r) => setTimeout(r, 500));
            continue;
          }
          return makeContentText(
            `HUB_CONNECTION_ERROR: ${msg}\n` +
            `Hub ${HUB} 暂不可达。已重试 ${outerRetries} 次；请执行 sync-runtime.sh 或重开 MCP。\n` +
            `[transport: codeblock]`
          );
        }
      }
    } finally {
      if (activeWaitToken === token) activeWaitToken = null;
    }
    log('wait reply', token, `len=${reply.length}`);
    return {
      content: [
        { type: 'text', text: reply },
        { type: 'text', text: `[pchat-token: ${token}]` },
        { type: 'text', text: bindingLockLine(token, cursorTitle, bindCheck) },
        { type: 'text', text: bindingFingerprint(token, cursorTitle, bindCheck) },
      ],
      structuredContent: {
        'pchat-token': token,
        reply,
        cursorTitle: normTitle(cursorTitle),
        hostIp: HOST_IP,
        hub: HUB_LABEL,
        bindingUnchanged: bindCheck.bindingUnchanged !== false,
      },
    };
  }

  if (name === 'merge_conversation') {
    if (threadBoundToken) {
      return makeContentText(`MERGE_FORBIDDEN: 本线程已锁定 ${threadBoundToken}，禁止 merge 串会话。`);
    }
    await hubJson('/api/merge', {
      method: 'POST',
      body: JSON.stringify({ source: args.source, target: args.target }),
    });
    return makeContentText(`merged ${args.source} -> ${args.target}`);
  }

  return makeContentText(`unknown tool: ${name}`);
}

const rl = readline.createInterface({ input: process.stdin, terminal: false });
rl.on('line', async (line) => {
  if (!line.trim()) return;
  let msg;
  try { msg = JSON.parse(line); } catch {
    log('bad json', line.slice(0, 200));
    return;
  }
  log('rx', { id: msg.id, method: msg.method });

  try {
    if (msg.method === 'initialize') {
      return send({
        jsonrpc: '2.0', id: msg.id,
        result: {
          protocolVersion: '2024-11-05',
          capabilities: { tools: { listChanged: false } },
          serverInfo: { name: 'local-persistent-chat', version: '1.6.30' },
        },
      });
    }
    if (msg.method === 'notifications/initialized' || msg.method === 'initialized') return;
    if (msg.method === 'tools/list') {
      return send({ jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } });
    }
    if (msg.method === 'tools/call') {
      const result = await handleToolCall(msg.params?.name, msg.params?.arguments);
      return send({ jsonrpc: '2.0', id: msg.id, result });
    }
    if (msg.method === 'ping') {
      return send({ jsonrpc: '2.0', id: msg.id, result: {} });
    }
    if (msg.id != null) {
      return send(makeError(msg.id, -32601, `method not found: ${msg.method}`));
    }
  } catch (e) {
    log('handler err', String(e.stack || e.message || e));
    if (msg.id != null) send(makeError(msg.id, -32000, String(e.message || e)));
  }
});

/** Cursor 可能关闭 MCP stdin；挂起 poll 期间禁止 exit，否则 Hub 仍 waiting 但无 poller */
let activeWaitToken = null;
process.stdin.on('end', () => {
  log('stdin end', { activeWait: activeWaitToken });
  if (!activeWaitToken) setTimeout(() => process.exit(0), 200);
});

process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
process.on('uncaughtException', (e) => {
  const msg = String(e.stack || e.message || e);
  log('uncaught', msg);
  if (/EPIPE|ERR_STREAM_DESTROYED/i.test(msg)) return;
});
process.stdout.on('error', (e) => {
  if (e && e.code === 'EPIPE') return;
});
