Ctrl K
ComunidadGuías…

server.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (c) 2026 开发者猫叔 (DeveloperCatUncle) · 商业使用需授权:COMMERCIAL-LICENSE.md
"use strict";
/**
 * OpenWorkBuddy — 服务器主入口。
 * 功能:Web 工作台(SSE 流式)、技能系统、MCP 连接器、专家团多智能体、IM 远程指挥(飞书/企业微信/通用 Webhook)。
 */

const BOOT_T0 = Date.now(); // server.js 从加载到 listen 的耗时,启动慢时先看这行日志
// Node 太老 / 依赖缺文件:这句必须排在 express 这一批 require 前面。排在后面的话,
// 缺依赖的人看到的是 `Cannot find module 'express'`——对不写 Node 的人等于没说。
// 有壳的时候不自己 exit:抛回去让壳把原因画在窗口上(exit 会让那个窗口根本没机会出现)。
require("./src/platform/boot-check").enforce({ rootDir: __dirname, packaged: require("./src/platform/paths").isPackaged(), throwInstead: !!global.__wbBootFail });
const express = require("express");
const fs = require("fs");
const path = require("path");
const { APP_DIR, DATA_DIR, dataPath, appPath, seedDataDir, resolvePort } = require("./src/platform/paths");
const migrate = require("./src/server/migrate");
// 数据目录跟代码目录不是同一个地方时(装机版、以及 Docker 里设了 OPENWORKBUDDY_HOME),
// 得先把随包出厂的技能和专家铺过去,否则 skills.js 只认 dataPath("skills"),
// 容器起来是能起来,但技能列表空空如也。开发态两个目录本来就是一个,这行是空操作。
// 放在这儿是因为下面 require 的 tools/skills 一加载就按数据目录算路径了。
seedDataDir();
const { mergeBuiltinExperts } = require("./src/agent/experts-lib");
const mcpCatalog = require("./src/core/ext/mcp-catalog");
const mcpPolicy = require("./src/core/ext/mcp-policy"); // 连接器工具一个个设的权限
const mcpImport = require("./src/core/ext/mcp-import"); // 从本机别的应用里导连接器
const { createOAuthManager } = require("./src/core/ext/mcp-oauth");
const { createLLM, createEmbedder, pingRequest, probeEmbedding, embedCandidates, embedChannels, embedHostable } = require("./src/core/model/llm");
const sessSearch = require("./src/core/memory/session-search");
const { outputFiles, noteUserInput, moveUserInput, isUserInput, filesScope, safePath, safePathIn, workspaceKeyOf, getWorkspaceDir, getDefaultWorkspaceDir, setWorkspaceDir, setLibraryDir, withLibraryBase, withLibraryDir, libBase, notesFileOf, withWorkspace, enterWorkspace, withPolicy, orgPolicy, canvasReadState, canvasWriteState, canvasNormalizeState, canvasList, canvasSafeName, SEARCH_PROVIDERS, searchProviderKey, searchProviderReady, shellPath } = require("./src/agent/tools");
const checkpointReview = require("./src/agent/checkpoint-review");
const checkpoints = require("./src/agent/checkpoints"); // 这条对话改过的文件:列出来、整步退回去
const worktree = require("./src/agent/worktree"); // 两条任务同时改一个仓库时,后来的那条进自己的 git worktree
const canvasRoutes = require("./src/server/routes/canvas"); // 画布读写 + 短剧素材台账 + 制片进度
const dramaRoutes = require("./src/server/routes/drama"); // 短剧分镜表 + 一镜一镜的版本留底
const { createComposeRouter } = require("./src/server/routes/compose"); // 一键合成的两条接口
const { createCodeRouter } = require("./src/server/routes/code"); // 工程线的改动审阅
const connectorGuard = require("./src/core/safety/connector-guard"); // 连接器接入的内置检查
const memberAccess = require("./src/core/ext/member-access"); // 哪些连接器和技能不给成员
const storageUsage = require("./src/domains/account/storage-usage"); // 各账号占的磁盘和配额
const libraryRoutes = require("./src/server/routes/library"); // 资料库的封面、正文摘录、收藏
const { createPromptTplsRouter } = require("./src/server/routes/prompt-tpls"); // 参考模板库里「我的」「公司」两层的增删改
const { createSessionImportRouter } = require("./src/server/routes/session-import"); // 会话导入入口默认关闭
const { publicRun } = require("./src/core/automation/run-view");
const sessionImport = require("./src/domains/import/session-import"); // 导进来的会话接着聊:在原来的文件夹里、接原来那根线
const { createGeoRouter } = require("./src/server/routes/geo"); // 行程卡:查地点、两站间路线、底图瓦片和地点照片
const { createChromeRouter } = require("./src/server/routes/chrome"); // 设置 → 浏览器:配对码、连没连上、扩展文件夹
const { createShareStore } = require("./src/domains/account/session-shares");
const { createSessionSharesRouter } = require("./src/server/routes/session-shares");
const geoPlaces = require("./src/domains/geo/places"); // 行程卡查地点:设置页要它折算高德上限、存设置时解除当天停用
const { createComposeJobs } = require("./src/domains/media/compose-jobs"); // 一键合成的任务队列:把镜头真的拼成成片
const taskDirs = require("./src/util/task-dirs"); // 成果按对话分文件夹:哪些根下分、文件夹叫什么
const taskDelete = require("./src/util/task-delete");
const deletingTasks = new Set();
const taskFileWrites = new Map();
function taskFileWrite(req, res, next) {
  let id;
  try { id = String(req.body && req.body.session || (req.is("application/octet-stream") && uploadHeaders(req).session) || ""); }
  catch (e) { return res.status(400).json({ error: e.message }); }
  if (deletingTasks.has(id)) return res.status(409).json({ error: "正在删除此任务,请稍候" });
  if (!id) return next();
  taskFileWrites.set(id, (taskFileWrites.get(id) || 0) + 1);
  let released = false;
  const release = () => {
    if (released) return;
    released = true;
    const n = (taskFileWrites.get(id) || 1) - 1;
    if (n) taskFileWrites.set(id, n); else taskFileWrites.delete(id);
  };
  // A disconnected upload can still be writing: the handler's promise, rather
  // than the socket closing, releases the lease (see taskFileHandler).
  res.locals.taskFileRelease = release;
  next();
}
const taskFileHandler = fn => async (req, res) => {
  try { await fn(req, res); } finally { if (res.locals.taskFileRelease) res.locals.taskFileRelease(); }
};
const prefs = require("./src/core/config/prefs"); // 按账号存的个人偏好:底层引擎 / 思考档 / 上次选的模型 / 宠物 / 快捷键
const { previewData } = require("./src/domains/library/preview");
const evolve = require("./src/agent/evolve");
const { McpManager, cfgFingerprint, scrubText: mcpScrub } = require("./src/agent/mcp");
const { createAgentRuntime, scanOutputs, sweepPlanOffThread } = require("./src/agent/agent");
const { createImRouter } = require("./src/im/im");
const imConfig = require("./src/im/im-config"); // IM 配置的合并 / 掩码:管理员那份和成员自己那份同一套规矩
const { createImAccounts } = require("./src/im/im-accounts"); // 每个成员自己绑的消息渠道
const { createScheduler, setActiveScheduler, SCHEDULE_LABEL } = require("./src/core/automation/scheduler");
const account = require("./src/domains/account/account");
const { createStaticCompress: staticCompress } = require("./src/server/static-compress");
const { createJsonCompress: jsonCompress } = require("./src/server/json-compress");
const { thumbFileAsync } = require("./src/platform/render/thumb");
const org = require("./src/domains/account/org"); // 组织(租户)层:席位、部门、邀请码、审计
const taskBilling = require("./src/core/billing/task-billing");
const usageStore = require("./src/core/billing/usage-store");
const budget = require("./src/core/billing/budget"); // 钱闸:中转站发出去的 Key 和公司内部自己用,花的是同一笔预算
const admin = require("./src/domains/account/admin"); // 企业管理后台的接口层 /api/admin/*
const spaces = require("./src/domains/account/spaces"); // 不止一个账号时,各账号自己的工作目录
const engines = require("./src/engines"); // 底层引擎:内置循环 / 本机 Claude Code / 本机 Codex
const engineGate = require("./src/engines/gate"); // 外部引擎开跑前那道闸(型号、附加参数、命令行开关)
const lanes = require("./src/core/config/lanes"); // 两条工作线:办公(桌面办公 agent)/ 工程(本机 openworkbuddy 命令行)
const cliLive = require("./src/core/obs/cli-live"); // 终端里起的任务挂在盘上的那个目录,网页/手机靠它看见并插话
const thinking = require("./src/core/model/thinking"); // 思考模式档位表(各家参数名都不一样,集中在那儿)
const security = require("./src/core/safety/security");
const netGuard = require("./src/core/safety/net-guard"); // AI 联网工具的地址闸:绑上端口后把自己登记进去,AI 一律打不到
const netAddr = require("./src/util/net-addr");
const childEnv = require("./src/platform/child-env"); // AI 起的子进程只拿最小环境变量;属主清单和「几个账号」从这里注册进去
const toolward = require("./src/core/safety/toolward");
const sweep = require("./src/agent/sweep");
const fileTrash = require("./src/platform/file-trash");
const modes = require("./src/core/config/modes"); // 执行模式的唯一真源(craft/goal/plan/ask)
const cfgMerge = require("./src/core/config/config-merge"); // 存配置时把外面手改的那些合进来,不整份覆盖
const cfgLint = require("./src/core/config/config-lint"); // 手改配置写错了当场说,别让人以为「改了没反应」
const projectMemo = require("./src/core/memory/project-memo"); // 工作目录往上到 git 根的 AGENTS.md / CLAUDE.md,命令行也用这一份
const mediaModels = require("./src/core/model/media-models");
const mediaHealth = require("./src/core/model/media-health"); // 连不通的媒体渠道熔断表:设置页要显示,保存时要清空 // 图/视频/语音/视觉:渠道表 + 每路多模型
const chatModels = require("./src/core/model/chat-models");
const modelDiscovery = require("./src/core/model/model-discovery");
const retiredModels = require("./src/core/model/retired-models"); // 服务商宣布下线的型号:设置页提醒、测试连接说清楚
const memberModels = require("./src/core/model/member-models"); // 成员能用哪些对话模型:管理员共享的 + 自己填 Key 的 // 对话模型:渠道共用一把 Key(跟上面共用 config.providers)
const systemOne = require("./src/core/judge/systemone"); // 判断模型(Jev)的纯逻辑:请求怎么拼、回答怎么读
const taskVerdict = require("./src/core/automation/task-verdict"); // 定时任务裁定层的纯函数(这一轮办完没有 / 怎么问 / 怎么读)
const pushGate = require("./src/core/automation/push-gate"); // 推之前那一问:跟上一次真推出去的那条比,有没有新东西
const jev = require("./src/core/judge/jev"); // 判断模型的调用路:挑渠道、取 Key、发请求
const quota = require("./src/core/billing/quota"); // 按次计费的外部 API:调之前问一句额度,调完记一笔
const pricing = require("./src/core/billing/pricing"); // 按量价目:批量生成前的预估跟记账查的是同一张表
const tracing = require("./src/core/obs/trace"); // 执行追踪(Langfuse),默认关;跟 agent.js 共用同一个追踪器
const genCache = require("./src/domains/media/gen-cache"); // 生成结果缓存:同一格重跑别再烧第二次钱
const toolJobs = require("./src/domains/media/tool-jobs"); // 直调生成的收单台账:同一个 clientJobId 只真跑一次
const uploadName = require("./src/util/upload-name"); // 上传同名不覆盖:挑 名字_2、名字_3
const memory = require("./src/core/memory/memory");
const notify = require("./src/core/obs/notify");
const log = require("./src/platform/log");
const metrics = require("./src/core/obs/metrics");
const callout = require("./src/util/callout"); // 正文提示条:机器人推送里换成文字标签
const store = require("./src/platform/store");
const petSprites = require("./src/desktop/pet-sprites"); // 桌面宠物的精灵图(吃 Codex / Petdex 的格式)
const pet = require("./src/desktop/pet"); // 只为拿默认值(没有 electron 时它自己降级成空壳,纯 node 也 require 得动)
const { createImSessionStore } = require("./src/im/im-store");

// config.json 不入 git(可能含 API Key);首次运行自动从模板复制
const CONFIG_PATH = dataPath("config.json");
if (!fs.existsSync(CONFIG_PATH)) {
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
  fs.copyFileSync(appPath("config.example.json"), CONFIG_PATH);
  console.log("已从 config.example.json 生成 config.json,请填入你的模型 API Key");
}
// 这是全机器上唯一一份明文装着所有 API Key 的文件,权限必须是「只有文件主人读得到」。
// 以前没人管这一位,落地就是 umask 给的 0644——同一台 VPS / 同一台办公电脑上的任何一个
// 别的账号,一句 cat 就把九把 Key 全拿走了。开机先收一次,把装了半年的老机器一并修好;
// 之后每次存盘再收一次(见 store.writeJsonAtomic 的 mode)。.bak 跟正本一字不差,一起收。
store.tighten(CONFIG_PATH, store.SECRET_MODE);
store.tighten(CONFIG_PATH + ".bak", store.SECRET_MODE);
// 同一道理的还有另外两本,一起在开机时收一次(新写入的由各自的 writeJsonAtomic 保着):
//   users.json —— 所有活着的会话令牌(拿到就是别人的登录态,不要密码、也绕过二次验证)、
//                 密码哈希和盐、TOTP 密钥、一次性找回码;
//   orgs.json  —— 还没用完的邀请码(拿到就能自己开号进来,角色是发码的人预置好的)、整本审计流水。
// 这两本原来都是 umask 给的 0644。列在这里而不是各自模块里:收权限是一次性的开机动作,
// 散进每次读盘就是每读一条记录多一次 chmod 系统调用。
// OPENWORKBUDDY_DATA_DIR 照认:account.js / org.js 都按它算目录,这里算得不一样就等于没收
const ACCOUNT_DIR = process.env.OPENWORKBUDDY_DATA_DIR || dataPath("data");
for (const f of ["users.json", "orgs.json"]) {
  const p0 = path.join(ACCOUNT_DIR, f);
  store.tighten(p0, store.SECRET_MODE);
  store.tighten(p0 + ".bak", store.SECRET_MODE);
}
// 再把整个 data/ 目录收成 0700,当作上面那几条的兜底。
// 逐个文件收权限有个绕不过去的毛病:**这个目录还在长**。今天列全了,下一个功能加一本新账
// (会话正文、记忆库、IM 消息、审计流水都在里面),忘了带 mode 就又是一个 0644。
// 目录没有执行位,别的本地账号连 `ls` 都进不来,里头单个文件是什么位都不要紧了。
// 只收 data/ 不收上一层:开发态下 DATA_DIR 就是仓库根目录(见 paths.js),
// 把源码目录改成 0700 是件没人预期的事,而真正要护的东西一件都不在那儿。
// 失败照例吞掉——Windows 上 chmod 基本是空操作,容器挂载卷也可能不让改(见 store.tighten)。
store.tighten(ACCOUNT_DIR, 0o700);
// 配置读坏了不能就这么空着起来:那样界面上所有 Key 都变成空的,用户随手一保存就把
// 真 Key 覆盖没了。store 会先拿 .bak 顶(Key 原样还在),实在顶不住才把坏文件改名隔离、
// 退回模板——原文还在 .corrupt-时间戳 里,Key 捞得回来。
const CONFIG_DEFAULTS = JSON.parse(fs.readFileSync(appPath("config.example.json"), "utf8"));
// 模板另拷一份当「文件不存在」的底:readJson 找不到文件时原样把 empty 交回来,不拷的话
// 下面所有对 config 的改动都会写进 CONFIG_DEFAULTS,后面 mergeDiskEdits 拿模板兜底时兜到的就是活配置
const rawConfig = store.readJson(CONFIG_PATH, JSON.parse(JSON.stringify(CONFIG_DEFAULTS)));
// 0.2 之前的老配置压根没有 models 表(只有 provider / openai / anthropic 三块)——要在模板补缺之前认出来
const legacyNoModels = !Array.isArray(rawConfig.models);
const config = fillDefaults(rawConfig, CONFIG_DEFAULTS);
// 子进程环境的策略:每次起子进程现问,设置页改了清单不用重启。
// 像 Key 的名字只在一个账号时给——账号多了,成员的任务也跑在这台机器上(见 platform/child-env.js 顶上)
childEnv.setPolicy(() => ({ allow: security.getSecurity(config).env_passthrough, keys: !admin.multiUser() }));
// 多人共用时命令、代码碰了文件黑名单直接拦、不出审批卡:卡是发起任务的人自己批的
security.setMultiUser(() => admin.multiUser());
// 本机引擎那道「多人共用时要管理员打开」的闸只拦别人,管理员自己用不用先打开(engines/gate.js)
security.setPlatformAdmin((name) => {
  const u = account._internals.loadUsers().users.find((x) => x.username === name);
  return !!u && admin.ownsGlobalWorkspace(u);
});
// 系统沙箱:成员的任务要把属主的项目目录藏起来;启动时先预检一遍,设置页一打开就有现状
require("./src/agent/tools").setOwnerDirs(() => (config.projects || []).map((p) => p && p.dir).filter(Boolean));
require("./src/agent/tools").warmSandbox(security.getSecurity(config));
// 助理的名字和头像:想叫它「小秘」就叫「小秘」。界面(气泡头像/侧栏/品牌位)和系统提示词都跟着这里走
// "@cat" 是内置猫标的哨兵值,跟应用图标同一只猫;前端 avatarBits 认它,account.normalizeAvatar 放行
const ASSISTANT_DEFAULT = { name: "OpenWorkBuddy", avatar: "@cat" };
// 盯住这个文件的改动时间,还有它上一次跟磁盘对齐时的样子。
// config.json 是明确让人手改的文件——README 里就写着「把 API Key 填进来」。可 saveConfig() 存的是
// 内存里那整份 config:手改完之后在界面上随便点一下保存,刚粘进去的 Key 就被整份盖掉了,一句提示也没有。
// 有了这两样,存盘前才问得出那句「这文件在我背后动过没有」(见 saveConfig)。
let CONFIG_MTIME = cfgMerge.mtimeOf(CONFIG_PATH);
let CONFIG_BASE = cfgMerge.snapshot(config);
// 连接器那两个键(mcp_servers / mcp_disabled)不等下次存盘:运行中盯着改动时间,变了就只把这两个键认进来
// 并重连变了的那几台(见 pollConfig)。CONFIG_SEEN = 磁盘上那份我们最后看过(读过或写过)的改动时间;
// 跟 CONFIG_MTIME 分开记,因为只认两个键不算「整份对齐过」,下次存盘照样要走合并。
let CONFIG_SEEN = CONFIG_MTIME;
// 连接器管理器手上正跑着的是哪一版配置:{ servers: 名字→指纹, disabled: 名字集合 }。开机起完之前是 null
let MCP_APPLIED = null;
// 手改写错了当场说出来。不说的话界面上一点反应也没有,
// 然后去怀疑是不是没保存、要不要重启、这功能是不是坏了——查半天发现是键名少了个字母。
for (const line of cfgLint.lines(cfgLint.lint(config, CONFIG_DEFAULTS))) console.warn(`[配置] ${line}`);

/**
 * 只补缺的,不改已有的(顶层键 + 顶层对象里的子键,两层就够)。
 *
 * config.json 是明确让用户手改的文件,删掉整个 server / agent 块很常见。以前那种情况下
 * 启动会抛 “Cannot read properties of undefined (reading 'host')” 或 “...(reading 'max_steps')”——
 * 一条完全看不出跟配置有关的报错,用户只会以为程序坏了。缺的用模板里的默认值填上就行。
 * 数组不合并:mcp_servers、models 这些用户清空是有意为之,不能又给他塞回来。
 */
function fillDefaults(cur, def) {
  for (const [k, v] of Object.entries(def)) {
    if (cur[k] === undefined) { cur[k] = v; continue; }
    if (v && typeof v === "object" && !Array.isArray(v) && cur[k] && typeof cur[k] === "object" && !Array.isArray(cur[k])) {
      for (const [k2, v2] of Object.entries(v)) if (cur[k][k2] === undefined) cur[k][k2] = v2;
    }
  }
  return cur;
}

// 老配置迁移:还没有 models 表的,只把填了 Key 的那家搬成一条模型行。
// 以前这里会塞五家厂商的模板行——用户看到的就是一排没 Key 的占位渠道,删了下次启动又长回来。
// models 是空数组的不碰:那是用户自己清空的(或者就是没配),空着才是对的
if (legacyNoModels) {
  config.models = chatModels.legacyRows(config);
  config.active_model = config.models.length ? config.models[0].name : "";
  saveConfig();
}

// 四路媒体模型迁移:老的「一路一个模型、Key 抄四份」→ 渠道表 + 模型表。幂等,老用户什么都不用做
// 对话模型跟着做同一件事:每条自己抄一份地址和 Key → 抽到渠道那一层,一把 Key 底下挂一排模型。
// 两者共用同一张 config.providers 表,所以媒体那边先跑(它的渠道认领只看地址+Key,不看协议)
// 启动这一趟替用户改挂了哪几条媒体模型:只记在内存里,设置页头一回来取时说一次(GET /api/settings 的 moved_on_boot)
const bootNorm = {};
let migrated = mediaModels.normalize(config, bootNorm);
let movedOnBoot = bootNorm.moved || [];
if (chatModels.normalize(config)) migrated = true;
// 老版本出厂 config 里那九行没 Key 的厂商模板、以及它们留下的空壳渠道,这一趟收掉。只认名字+地址
// 跟出厂一模一样、Key 空着、又不是正在用的那条;用户自己起名建的行一条不碰。跑过一遍就没得收了
// 只跑一次(presets_pruned 章):之后用户自己清空某条渠道的 Key、或者起了个跟老模板同名的行,都不许再被当成占位收走
// 判断模型(Jev)混进了对话模型列表(早先的版本、或拉清单时点中了它):它没有 /chat/completions,
// 挂着就是每一趟 400,定时任务一条条挂。收掉;正在用的是它,就换回第一条——运行时本来也是这么兜的
{
  const jevRows = (config.models || []).filter((m) => m && systemOne.isDecisionModel(m.model));
  if (jevRows.length) {
    config.models = config.models.filter((m) => !jevRows.includes(m));
    if (jevRows.some((m) => m.name === config.active_model)) config.active_model = (config.models[0] || {}).name || "";
    console.log(`[渠道整理] 判断模型不能当对话模型用,已从模型列表移除:${jevRows.map((m) => m.name).join("、")}`);
    migrated = true;
  }
}
if (!config.presets_pruned) {
  const pruned = chatModels.pruneSeededPresets(config);
  if (pruned.models.length || pruned.channels.length) console.log(`[渠道整理] 收回出厂占位 → 模型行:${pruned.models.join("、") || "无"};空壳渠道:${pruned.channels.join("、") || "无"}`);
  config.presets_pruned = true;
  migrated = true;
}
if (migrated) saveConfig();

security.getSecurity(config); // 补齐安全中心默认策略
config.shortcuts = config.shortcuts || {}; // 快捷键自定义绑定(只存改过的项,默认值在前端定义)
config.assistant = { ...ASSISTANT_DEFAULT, ...(config.assistant || {}) };

if (config.workspace_dir) {
  try {
    setWorkspaceDir(config.workspace_dir);
  } catch (e) {
    console.warn("workspace_dir 无效,使用默认工作空间:", e.message);
  }
}
// 开机把当前项目挂载的那块资料库也接回去。少了这一句,重启之后工作目录是对的、
// 资料库却退回了整个库,agent 在「客户 A」的项目里照样翻得到别家的合同——
// 而这种错不会报错,只会安静地引用错资料。
try {
  const boot = (config.projects || []).find((p) => p && p.name === config.active_project);
  if (boot && boot.library_dir) setLibraryDir(boot.library_dir);
} catch {}

// 从老版本升上来的那一下:之前的文件得替人收进新文件夹里整理好。
// 放在这儿是因为工作目录到这一行才算最终确定(上面刚认完 workspace_dir 和资料库)。
// 每条迁移只跑一次,记在 data/migrations.json;跑不动就只是打一行日志——
// 为了整理文件而开不了应用,那是本末倒置
try {
  // 「这台机器以前用过没有」——判升级还是全新装全靠它。看的是有没有会话/账号留下来,
  // 不看 config.json:那玩意儿第一次启动就会从 example 生成一份,永远为真,什么也证明不了
  const priorUse = (() => {
    try { if (fs.existsSync(dataPath("data", "users.json"))) return true; } catch {}
    try { return fs.readdirSync(dataPath("data", "sessions")).some((f) => f.endsWith(".json")); } catch {}
    return false;
  })();
  // 记忆向量以前没选嵌入模型时会借聊天 / 媒体渠道的 Key 去算,这一版起只认设置里选定的那个。
  // 盘上有算好的向量、设置里却没选 = 以前靠的就是借来的那条,升上来后改按关键词召回,得说一声
  const embedOff = (() => {
    if (config.embedding && String(config.embedding.model || "").trim()) return false;
    try { return memory.vectorStatus().have > 0; } catch { return false; }
  })();
  // 中转站以前把没登记型号的渠道当通用网关,这一版起只转登记过的。发过 Key、又有渠道停转或 Key 受影响才提示
  const relayHit = (() => {
    try {
      const vk = require("./src/domains/account/vkeys");
      const keys = vk.list();
      if (!keys.length) return false;
      const g = vk.relayAudit(config, keys);
      return g.stopped.length > 0 || g.keys.length > 0;
    } catch { return false; }
  })();
  // 外部引擎:以前型号能留空、Codex 里的命令默认联网,这一版两样都变了。看属主和各账号选着哪个引擎
  const enginesInUse = (() => {
    const ids = new Set([String((config.agent && config.agent.engine) || "")]);
    try {
      for (const f of fs.readdirSync(dataPath("prefs"))) {
        if (!f.endsWith(".json")) continue;
        try { ids.add(String((JSON.parse(fs.readFileSync(dataPath("prefs", f), "utf8")).agent || {}).engine || "")); } catch {}
      }
    } catch {}
    return [...ids].filter((id) => id && id !== "builtin" && engines.get(id));
  })();
  const engineNoModel = enginesInUse.filter((id) => !engines.gateView(id, config).allowed.length).map((id) => engines.get(id).label || id).join("、");
  const codexNet = enginesInUse.includes("codex") && ((((config.agent || {}).engine_options || {}).codex || {}).network === undefined);
  const notes = migrate.runMigrations(getWorkspaceDir(), dataPath("data", "migrations.json"), {
    version: String(require("./package.json").version || ""), priorUse, embedOff, relayHit, engineNoModel, codexNet,
  });
  for (const n of notes) console.log(`[升级整理] ${n.note}`);
  global.__wbMigrationNotes = notes;   // 界面上给用户看一眼:动过他的文件,得说
} catch (e) {
  console.warn("[升级整理] 这次没做成,不影响使用:" + e.message);
}
let llmInner = createLLM(config);
// 记忆向量召回:只用设置里选定的嵌入模型,没选就是 null,记忆按关键词匹配(memory 自己兜底)
memory.setEmbedder(createEmbedder(config));
// 任务历史检索的向量渠道。这两个变量本该跟下面那一块检索代码放在一起,但接线在这儿就发生了——
// let 声明在后面的话是暂时性死区,进程会直接起不来(不是搜索不好使,是整个服务起不来)
let sessEmbedder = null;
let sessVecRunning = false;
setSessEmbedder(createEmbedder(config));
memory.ensureVectors().then((r) => { if (r.computed) console.log(`[记忆向量] 启动补算了 ${r.computed} 条`); }).catch((e) => console.warn("[记忆向量] 启动补算失败:", e.message));
// 可热替换的 LLM 包装:设置修改后 runtime 无需重建
const llm = {
  get provider() { return llmInner.provider; },
  get model() { return llmInner.model; },
  chat: (args) => llmInner.chat(args),
};
/** 按对话覆盖模型:会话里选过就用会话的,没选跟全局默认。
 *  选过的模型已被从列表删掉 → 明确报错,绝不悄悄换成别的模型跑 */
function llmForSession(sess, fallbackUser) {
  const name = sess && sess.model;
  if (sess?.billing_scope === "team") { const row = taskBilling.sharedModel(config, name); return createLLM({ ...config, models: [row], active_model: row.name }); }
  // 多人服务器上的成员:只认「管理员共享给他的 + 他自己填 Key 的」那几条(member-models.js)。
  // 管理员和个人桌面版不走这段,跟以前一个字节不差
  const who = modelWho((sess && sess.user) || fallbackUser);
  if (!who.owner) {
    const row = name ? memberModels.find(config, who, name) : memberModels.defaultFor(config, who);
    if (row) return !row.own && row.name === config.active_model ? llm : createLLM({ ...config, models: [row], active_model: row.name });
    const why = name
      ? `「${String(name).replace(/^my:/, "")}」不在你能用的模型里了。点输入框右下角的模型按钮重新选一个。`
      : "你还没有能用的对话模型:平台管理员没有共享给你,可以在「设置 → 模型」里加一把自己的 Key。";
    return { provider: name || "", model: name || "", chat: () => Promise.reject(new Error(why)) };
  }
  if (!name) return llm;
  if (Array.isArray(config.models) && config.models.some((m) => m.name === name)) {
    return createLLM({ ...config, active_model: name });
  }
  return {
    provider: name,
    model: name,
    chat: () => Promise.reject(new Error(`该对话指定的模型「${name}」已不在模型列表里。点输入框右下角的模型按钮重新选一个,或选「跟随全局默认」。`)),
  };
}
/**
 * 某个人在「用哪些模型」这件事上的身份:平台管理员 / 个人桌面版 / 认不出是谁(命令行、IM、定时任务)
 * 都算 owner,看全表;其余的是成员,带上他的偏好(里面有他自己填的模型)。
 * @param {any} user 登录名或账号对象
 */
function modelWho(user) {
  const name = typeof user === "string" ? user : (user && user.username) || "";
  if (!name || admin.isSoloDesktop()) return { owner: true };
  let u = null;
  try { u = account._internals.loadUsers().users.find((x) => x.username === name) || null; } catch {}
  if (!u && user && typeof user === "object") u = user;
  if (u && admin.ownsGlobalWorkspace(u)) return { owner: true };
  let p = {};
  try { p = prefs.read(name); } catch {}
  return { owner: false, prefs: p };
}
/**
 * 价目闸:头上设了预算的人,这一趟的对话模型查不到价就不开跑(见 budget.unpriced)。
 * 拦下返回那句话,放行返回 ""。护的是预算本身:查不到价的那一趟记账是 0,
 * 设了上限的人点名一个没登记价目的型号,花多少都扣不到预算上。
 * 只管内置引擎——外部 CLI 引擎走它自己的登录和订阅,不经这本价目。
 * 会话点名的模型已经不在列表里的(llmForSession 给的是报错桩),开跑时自有那句「已不在模型列表里」,这儿不抢着报。
 * @param {any} user
 * @param {{ provider?: string, model?: string }} runLLM
 */
function unpricedChat(user, runLLM) {
  if (!user || !runLLM || !runLLM.model) return "";
  if (engines.currentId(config, { user: user.username }) !== "builtin") return "";
  if (!(Array.isArray(config.models) && config.models.some((m) => m && m.name === runLLM.provider))) return "";
  try {
    const o = org.getOrg(org.orgIdOf(user));
    const s = org.settingsOf(o);
    const hit = budget.unpriced({
      org: s, orgId: o.id, user, usage: { model: runLLM.model },
      price: { config, discount: s.price_discount, name: runLLM.provider },
    });
    return hit ? hit.message : "";
  } catch (e) {
    // 预算模块自己坏了不该拦住正事,跟钱闸同一个选择:放行,但喊一声
    console.warn("[预算] 价目闸没能判(本次放行):" + (e && e.message));
    return "";
  }
}
// 同一模型连续「整跑失败」计数(成功一次即清零):连挂说明是模型/渠道本身的问题,光报错用户不知道该干嘛
const modelFailStreak = new Map();

// 模型健康账本:每次整跑记一笔成败(按模型条目名,滚动只留最近 20 次),选模型时能看到
// 「这个渠道最近靠不靠谱」,不用踩了才知道。落盘 data/model_health.json,重启不清零
const HEALTH_FILE = dataPath("data", "model_health.json");
const modelHealth = store.readJson(HEALTH_FILE, {}) || {};
function recordModelHealth(name, ok, failMsg) {
  if (!name) return;
  const h = (modelHealth[name] = modelHealth[name] || { recent: [] });
  h.recent.push(ok ? 1 : 0);
  if (h.recent.length > 20) h.recent = h.recent.slice(-20);
  if (ok) h.last_ok_t = Date.now();
  else { h.last_fail_t = Date.now(); h.last_fail = String(failMsg || "").slice(0, 200); }
  healthWriter.request();
}
// 每跑完一轮记一笔,以前每笔都在主线程上 fsync(实测单次约 4.5ms,5 条对话并发时跟会话存盘挤在一起)。
// 账本本身在内存里,盘上那份只是重启用的:后台写、一秒最多一次,进程退出时同步补一次(见 flushPendingWritesSync)
const healthWriter = store.coalesce(() => store.writeJsonAtomicAsync(HEALTH_FILE, modelHealth), {
  minGapMs: 1000, onError: (e) => console.warn(`[模型健康] 账本存盘失败:${e.message}`),
});
function healthSummary() {
  const out = {};
  for (const [name, h] of Object.entries(modelHealth)) {
    const recent = Array.isArray(h.recent) ? h.recent : [];
    let streak = 0;
    for (let i = recent.length - 1; i >= 0 && !recent[i]; i--) streak++;
    out[name] = { n: recent.length, ok: recent.filter(Boolean).length, fail_streak: streak, last_fail: h.last_fail || "", last_fail_t: h.last_fail_t || 0 };
  }
  return out;
}

// 专家团:数组引用被 runtime 闭包持有,增删改都就地改这个数组(热生效,无需重启)
const EXPERTS_FILE = dataPath("experts.json");
const experts = [];
const expertTeams = []; // 专家团 = 智能体团队,同样是被 runtime 闭包持有的活引用
let expertsMeta = store.readJson(EXPERTS_FILE, {}) || {};
// 打包版升级后,包里新出的内置专家/专家团要补进用户那份(用户改过、删过的不动);
// 只靠首次安装时的 copyIfMissing,老用户永远见不到新专家。补了几个写日志留痕。
if (appPath("experts.json") !== EXPERTS_FILE) {
  try {
    const bundled = store.readJson(appPath("experts.json"), null);
    const hadSeen = Array.isArray(expertsMeta.seen_builtins);
    // 技能那本「见过」账第一次记也要落盘:不记下来,下次启动又当第一次跑,用户删掉的技能会被补回去
    const hadSeenSkills = !!(expertsMeta.seen_builtin_skills && typeof expertsMeta.seen_builtin_skills === "object");
    const r = bundled ? mergeBuiltinExperts(expertsMeta, bundled) : { added: [], addedTeams: [], bound: [] };
    if (r.added.length || r.addedTeams.length || r.bound.length || (bundled && (!hadSeen || !hadSeenSkills))) {
      store.writeJsonAtomic(EXPERTS_FILE, expertsMeta, { pretty: true });
      if (r.added.length || r.addedTeams.length) console.log(`[专家] 升级补入内置专家 ${r.added.length} 位、专家团 ${r.addedTeams.length} 个:${[...r.added, ...r.addedTeams].join("、")}`);
      if (r.bound.length) console.log(`[专家] 升级给内置专家补绑技能 ${r.bound.length} 个:${r.bound.join("、")}`);
    }
  } catch (e) { console.warn("[专家] 合并内置专家失败(不影响启动):", e.message); }
}
experts.push(...(expertsMeta.experts || []));
expertTeams.push(...(expertsMeta.teams || []));
function saveExperts() {
  store.writeJsonAtomic(EXPERTS_FILE, { ...expertsMeta, experts, teams: expertTeams }, { pretty: true });
}
const mcpOAuth = createOAuthManager({ file: dataPath("data", "mcp-oauth.json"), security: () => security.getSecurity(config) });
const mcpManager = new McpManager({ oauthToken: (cfg, opts) => mcpOAuth.status(cfg).authorized ? mcpOAuth.token(cfg, opts) : null, oauthFetch: mcpOAuth.fetch });
let imBridge = null; // IM 桥(含飞书长连接控制),init() 里创建
let imAccounts = null; // 成员自己绑的通道,跟 imBridge 一起建

// ---------- 会话持久化:内存 + 磁盘(data/sessions/<id>.json) ----------
// 结构:{ history: 统一格式历史(供LLM), transcript: 界面回放记录, title, updated_at }
const SESS_DIR = dataPath("data", "sessions");
const sessions = new Map();
const activeRuns = new Map(); // sessionId -> { ctrl: AbortController, interject: [] }(「停止」与「插队」用)
// 浏览器标签页的闲置关页、到顶腾位,不许收还在跑的任务的页(等用户回话超过 10 分钟的,回来表单还得在)
try { require("./src/platform/render/cdp").setActivePredicate((sid) => activeRuns.has(sid) || require("./src/agent/tools").runHeld(sid)); } catch {}
// 正在跑的任务落一份名单到磁盘:应用中途被关/被重启时,内存里的 activeRuns 直接蒸发,
// 下次启动就靠这份名单知道哪些会话是被打断的,在回放里明说,而不是让那一轮无声地断在半空
const RUNNING_FILE = dataPath("data", "running.json");
// git worktree 分身放这儿。放在用户仓库外面:放里面等于让 git 观察自己,清理时手一滑就删到人家代码上。
// 开发态数据根就是应用仓库,dataPath 那个默认值落在仓库里,由 worktree.defaultStore 挪出去(2026-09-29,见那里)。
// 用到才算:要问一趟 git,别压在 require 这一步上
const WORKTREE_LEGACY = dataPath("data", "worktrees");
let worktreeDir = "";
const worktreeStore = () => worktreeDir || (worktreeDir = worktree.defaultStore(WORKTREE_LEGACY));
// 每轮开跑、收尾各写一次。后台写,一次只在路上一份:名单在开写那一刻才取,所以落地的永远是最新的那份
const runningWriter = store.coalesce(() => store.writeJsonAtomicAsync(RUNNING_FILE, [...activeRuns.keys()]), {
  onError: (e) => console.warn(`[恢复] 正在跑的名单存盘失败:${e.message}`),
});
function persistRunning() {
  runningWriter.request();
}
function sweepInterruptedRuns() {
  const ids = store.readJson(RUNNING_FILE, []);
  if (!Array.isArray(ids) || !ids.length) return;
  let marked = 0;
  for (const id of ids) {
    try {
      const sess = getSession(id);
      const last = sess.transcript[sess.transcript.length - 1];
      if (!last || last.type !== "assistant") continue;
      const evs = last.events || (last.events = []);
      const tail = evs[evs.length - 1];
      if (tail && tail.type === "error") continue; // 已有明确收场就别重复盖章
      evs.push({ type: "error", message: "应用在任务执行中被关闭或重启,这一轮已中断(已完成的进度和文件都还在)。可以对我说「接着上次进度继续」。" });
      saveSession(id);
      marked++;
    } catch {}
  }
  try { store.writeJsonAtomic(RUNNING_FILE, []); } catch {}
  if (marked) console.log(`[恢复] 检测到 ${marked} 个被重启打断的任务,已在会话回放里标注中断`);
}
const assignedDirs = new Set(); // 刚分配、还没写出文件的对话文件夹名:两个新对话同时起步不许撞同名

/** 判「这个根按不按对话分文件夹」要的几个坐标(口径见 src/util/task-dirs.js 开头;命令行 chatDirHere 用同一套) */
function layoutAnchors() {
  return { workspace: dataPath("workspace"), projects: dataPath("projects"), tenants: org.tenantsDir(), homes: spaces.homes(), layouts: config.folder_layouts || {} };
}
/** 当前工作目录下要不要按对话分成果文件夹(口径见 src/util/task-dirs.js 开头) */
function perChatHere() {
  return taskDirs.perChatRoot(getWorkspaceDir(), layoutAnchors());
}
/**
 * 用户选定一个文件夹的那一刻,把「每个对话一个文件夹 / 直接放进去」记进 config.folder_layouts。
 * 没明说就把此刻判出来的那种记下:不然今天选进来是普通文件夹(按对话分),哪天里面 git init 了,
 * 后面的对话就悄悄改成摊在根上,前后两半成果一半在 任务_ 里一半在外面。
 * 应用自己的目录(默认工作空间、projects/ 下各项目)永远按对话分,不记。只改内存,落盘跟着调用方的 saveConfig。
 */
function noteLayout(dir, layout) {
  if (!dir) return;
  const now = taskDirs.folderLayout(dir, layoutAnchors());
  if (now.locked) return;
  if (layout === "per_chat" || layout === "flat") config.folder_layouts = taskDirs.setLayout(config.folder_layouts, dir, layout);
  else if (!now.chosen) config.folder_layouts = taskDirs.setLayout(config.folder_layouts, dir, now.layout);
}
/** 会话记的成果文件夹还在不在「此刻这个根」下(口径在 src/util/task-dirs.js,命令行那头用同一套) */
function sessDirHere(sess) {
  return taskDirs.sessDirIn(sess, getWorkspaceDir(), dataPath("workspace"));
}
/**
 * 会话在「此刻这个根」下的成果文件夹:正用着的那格,或者以前在这个根下用过、盘上还在的那格;都没有给 null。
 * 会话只有一个 dir 槽的时候,聊到一半去别的项目转一圈再回来,原来那格就被新根的名字盖掉了,
 * 回来只能另起一个 任务_…_2,前后两半成果拆在两个文件夹里,「本对话」也只认后一半
 */
function sessDirOf(sess) {
  return taskDirs.sessDirAt(sess, getWorkspaceDir(), dataPath("workspace"));
}
/** 当前根下这个会话已经有自己的那格就接着用它;返回用上没有 */
function useSessionDirHere(sess) {
  return taskDirs.useSessDirAt(sess, getWorkspaceDir(), dataPath("workspace"));
}
/** 这个会话以前摊在「此刻这个根」上的产出(相对名),盘上还在的 */
function rootFilesOf(sess) {
  const root = getWorkspaceDir();
  return taskDirs.flatOutputs(sess, root, dataPath("workspace"), workspaceKeyOf(root))
    .filter((n) => { try { return fs.statSync(path.join(root, n)).isFile(); } catch { return false; } });
}

/**
 * 换过根的对话:历史里的附件留在别的根下那格里,那边的格名跟这边的还不一样(任务_1001_周报 / 任务_1001_周报_2),
 * 前端按「当前那格/名字」去取就是一排灰方块。这里报「当前格里没有、别的根那格里有」的那些:名字 → {root 指纹, dir}。
 * 只看每格第一层(附件就落在第一层),最多 300 个;指纹按 allRoots 里那份原样算,rootFromKey 才认得回来。
 */
function attachSpotsOf(sess) {
  const here = getWorkspaceDir(), def = dataPath("workspace");
  const curDir = taskDirs.sessDirAt(sess, here, def);
  const pairs = [];
  if (sess.dir && !taskDirs.samePlace(sess.root || def, here)) pairs.push([sess.root || def, sess.dir]);
  for (const [r, d] of Object.entries(sess.dirs || {})) if (d && !taskDirs.samePlace(r, here)) pairs.push([r, d]);
  const out = {};
  let n = 0;
  if (!pairs.length) return out;
  const known = allRoots(); // 这些格子都是这条会话自己的,不用再按人收窄
  for (const [r, d] of pairs) {
    const root = known.find((k) => taskDirs.samePlace(k, r));
    if (!root) continue;
    let names = [];
    try { names = fs.readdirSync(path.join(root, d), { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name); } catch { continue; }
    for (const name of names) {
      if (n >= 300) return out;
      if (Object.prototype.hasOwnProperty.call(out, name)) continue;
      if (curDir && fs.existsSync(path.join(here, curDir, name))) continue;
      out[name] = { root: workspaceKeyOf(root), dir: d };
      n++;
    }
  }
  return out;
}

/** 给对话分配成果文件夹(任务_月日_标题)。「消息发出前就传上来的」附件由 settlePendingUploads 搬进去 */
function assignSessionDir(sess, message) {
  // 存进会话,后续轮次/重启都落同一个文件夹。连**哪个根**下的这个文件夹也一起记住:
  // 只记相对名的后果就是用户换一次工作目录,这条对话的成果全部变成「文件不存在」——
  // 文件没丢,是坐标系换了而没人记得旧的那套。
  const dir = taskDirs.newSessDir(sess, getWorkspaceDir(), dataPath("workspace"), message, assignedDirs);
  rememberRoot(sess.root);
  return dir;
}
/** 改名搬文件;工作目录在外置盘、跨盘 rename 会 EXDEV,就拷过去再删原件(拷不成原件不动) */
function moveFileAcross(from, to) {
  try { fs.renameSync(from, to); return; } catch (e) { if (e.code !== "EXDEV") throw e; }
  fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
  fs.unlinkSync(from);
}
/**
 * 「先传后发」的附件搬到这条对话此刻干活的地方:按对话分文件夹的根就是它那格(baseDir),摊在根上的就是根。
 * 每笔都记着是在哪个根下传的({name, root, rel};老的只有名字,就是当前这个根)。只记名字的时候,
 * 传完换了文件夹再发,这里拿名字去新根里找——找不到还好,碰上新根里正好有个同名的,搬进来的就是别人的文件。
 * 搬运失败一概不抛:一个附件搬不动就让整条对话起不来,代价太大。
 * 返回真搬了的那几个名字:页面上那条消息的气泡是按上传那一刻的落点画的,
 * 不告诉它搬了,一点预览就是「文件不存在」——文件好好的,只是换了地方没人说。
 */
function settlePendingUploads(sess, baseDir) {
  const here = getWorkspaceDir();
  const moved = [];
  for (const p of sess.pending_uploads || []) {
    try {
      const n = typeof p === "string" ? p : p && p.name;
      if (!n) continue;
      const fromRoot = (p && p.root) || here, same = taskDirs.samePlace(fromRoot, here);
      const fromRel = (p && p.rel) || n, destRel = baseDir ? path.join(baseDir, n) : n;
      if (same && fromRel === destRel) continue; // 摊在根上的目录里传、在这儿发:本来就在该在的地方
      const from = path.join(fromRoot, fromRel), to = path.join(here, destRel);
      // 目标位置还空着才搬:同名的已经在了,宁可不动也不盖
      if (!fs.existsSync(from) || !fs.statSync(from).isFile() || fs.existsSync(to)) continue;
      // 不在本根根上的那份(别的根、别的对话那格)得核实还是用户传上来的那一份:mtime 对不上就是改写过、换过了,不动
      if (!(same && fromRel === n) && !isUserInput({ name: fromRel, mtime: fs.statSync(from).mtime.toISOString() }, fromRoot)) continue;
      // 那格可能空了十分钟被收掉了(listEmptyTaskDirs),会话还记着名字:先建回来,不然搬不进去、附件留在根上
      fs.mkdirSync(path.dirname(to), { recursive: true });
      moveFileAcross(from, to);
      // 「这份是用户传的」那笔账记的是路径,搬完得跟着改键,否则这张图换个位置就又变成产出了
      moveUserInput(fromRel, destRel, same ? undefined : fromRoot);
      moved.push(n);
    } catch {}
  }
  sess.pending_uploads = [];
  return moved;
}
/**
 * 输入框上那几枚 chip 是「下一条消息」的草稿,不跟着对话走:传完点了「新任务」、点开别的对话、换了项目,chip 都还在。
 * 可文件是按传的那一刻是哪条对话落的——记在那条对话的待搬账上,或者已经进了那条对话自己那格。
 * 这条消息一发,Agent 在这边按名字找就扑空;文件还留在别的对话那格里,那边的成果区平白多一份。
 * 前端把「不是在这条对话里传的」那几枚报上来(传的时候用的会话 id + 上传回的相对路径),这里核实后记到这条对话的待搬账上。
 * 核实三件:那条对话归这个人(待搬账上那笔还得是他传的);文件就在它的待搬账上或它自己那格里;
 * mtime 跟上传那一刻记的对得上(settlePendingUploads 里查)。
 */
function adoptUploads(sess, user, list) {
  if (!Array.isArray(list)) return;
  const fence = rootFence({ user }); // 不止一个账号时只搬自己根里的:管理员能看成员的对话,不等于能把成员传的文件搬走
  for (const a of list.slice(0, 50)) {
    try {
      const sid = String((a && a.sid) || ""), rel = path.normalize(String((a && a.path) || ""));
      if (!sid || !rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
      // 待搬账只在内存里;内存里没有、盘上又没这条的,就是没这回事——别为一个随手报上来的 id 建一条空会话
      const o = sessions.get(sid) || (fs.existsSync(sessFile(sid)) ? getSession(sid) : null);
      if (!o || o === sess || !sessionAllowed(user, o)) continue;
      const name = path.basename(rel), sub = path.dirname(rel);
      let hit = null;
      if (sub === ".") {
        const i = (o.pending_uploads || []).findIndex((p) => (typeof p === "string" ? p : p && p.name) === name);
        const p = i >= 0 ? o.pending_uploads[i] : null;
        // 只传了附件、还没发过的空会话没有主人,sessionAllowed 谁来都放行:这里再认一次是谁传的,
        // 不然知道会话 id 和文件名的人能把别人刚传上来、还没发出去的文件搬进自己那格
        if (!p || (p.by && user && p.by !== user.username)) continue;
        o.pending_uploads.splice(i, 1);
        hit = typeof p === "string" ? { name, root: getWorkspaceDir() } : p;
      } else {
        // 那条对话在哪个根下用的是这一格:当前那格,或者以前在别的根下用过的
        const spots = [[o.root || dataPath("workspace"), o.dir], ...Object.entries(o.dirs || {})];
        const at = spots.find(([, d]) => d && path.normalize(d) === sub);
        if (at) hit = { name, root: at[0], rel };
      }
      if (!hit || (fence && !fence(hit.root || getWorkspaceDir()))) continue;
      sess.pending_uploads = (sess.pending_uploads || []).filter((p) => (typeof p === "string" ? p : p && p.name) !== name).concat(hit);
    } catch {}
  }
}

function sessFile(id) {
  return path.join(SESS_DIR, id.replace(/[^\w-]/g, "_") + ".json");
}
// 每个会话文件「我们最后一次读/写它时」的样子。用来分辨盘上那份是不是被别的进程改过。
const sessStamp = new Map(); // id -> { mtime, size }
function sessStat(id) {
  try { const st = fs.statSync(sessFile(id)); return { mtime: st.mtimeMs, size: st.size }; } catch { return null; }
}
/**
 * 盘上那份比我们手里的新吗。
 *
 * 只比 mtime 会漏:原子改名两次可能落在同一毫秒里。加上体积,改了内容基本不可能两项都一样。
 */
function sessChangedOnDisk(id) {
  const now = sessStat(id);
  const mine = sessStamp.get(id);
  if (!now || !mine) return false; // 文件没了 / 我们压根没记过,都不算「别人改的」
  return now.mtime !== mine.mtime || now.size !== mine.size;
}
/* ---------- 会话缓存:内存里只留最近用过的那几条 ----------
 *
 * sessions 是**缓存**不是账本 —— 真本一直在 data/sessions/<id>.json,内存里这份丢了,
 * 大不了下次重读一遍(本机实测:最大的一条 1.3MB 重读 7.07ms,中位数那条 0.30ms,用户根本感觉不到)。
 *
 * 可它以前只进不出:点开过的每一条会话都原地住到进程退出为止。本机 203 条会话全读进来实测
 * 堆 +39.6MB、进程 RSS +76MB,而且再也不回落 —— 这正是 metrics 里那条 rss 告警说的
 * 「如果它不再回落,值得看一眼是不是有超大会话没被回收」。多人服务器上这个数还要乘人头。
 *
 * 所以按「最近用过」排队,超预算就从队头往外扔。四道闸门缺一不可,少一道都会悄悄吃掉数据:
 *   1. 正在跑的不扔(activeRuns)—— 那份对象正被这一轮改着;
 *   2. 有人长期攥着的不扔(sessHold,定时任务那条路)—— 它的 emit 还在往里写;
 *   3. 刚碰过的不扔(SESS_CACHE_IDLE_MS)—— 来回切两条大会话不能变成来回读盘;
 *   4. 内存里那份跟盘上那份必须一字不差才扔 —— 这是兜底的那道。
 *      举个真会发生的:附件是「先传后发」的,上传接口把文件名记在 sess.pending_uploads 上
 *      **而且不存盘**(见 /api/upload),等下一条消息发出去才用。这种只活在内存里的字段
 *      光靠前三道闸门拦不住,第 4 道能:它跟盘上对不上,就不许扔。
 */
// 预算定在 8MB 而不是"看着够用就行":重读一条 1MB 的会话实测 4.5ms、中位数那条 0.3ms,
// 留着它省下的就是这几毫秒;而多留一份就是实打实几十 MB 常驻,还乘人头。
// 这笔账两边差着三个数量级,那就往小了定。真正在跑的那些走 activeRuns,本来就不受这道线管。
const SESS_CACHE_BYTES = 8 * 1024 * 1024;  // 按盘上那份的字节算;堆里大约是它的 1.36 倍(实测)
const SESS_CACHE_KEEP = 8;                 // 不管多大,最近这几条一定留着
const SESS_CACHE_IDLE_MS = 60 * 1000;      // 一分钟内碰过的不动,哪怕超预算
const sessUsedAt = new Map();              // id -> 上次碰它的毫秒数
const sessHold = new Map();                // id -> 还有几处长期攥着这份内存对象

/** 有人要长期拿着这份对象(不是一次请求内用完就扔),先说一声,别让它被清掉 */
function holdSession(id) { sessHold.set(id, (sessHold.get(id) || 0) + 1); }
function releaseSession(id) {
  const n = (sessHold.get(id) || 0) - 1;
  if (n > 0) sessHold.set(id, n); else sessHold.delete(id);
}
/** 用过一次:挪到队尾。Map 记插入顺序,删了再塞就是一条现成的 LRU 队列 */
function touchSession(id) {
  if (!sessions.has(id)) return;   // 不在缓存里就别记:记了这个 Map 自己就成了新的只进不出
  sessUsedAt.set(id, Date.now());
  const v = sessions.get(id);
  sessions.delete(id);
  sessions.set(id, v);
}
/** 内存里这份跟盘上那份一字不差吗。不一样就说明还有没落盘的改动,这条不许清 */
function sessSynced(id) {
  const s = sessions.get(id);
  if (!s) return false;
  let text = "";
  try { text = JSON.stringify(s); } catch { return false; }   // 循环引用之类:拿不准就当它脏的
  const f = sessFile(id);
  let st = null;
  try { st = fs.statSync(f); } catch { return false; }        // 盘上压根没有(新会话还没存过):更不能清
  if (st.size !== Buffer.byteLength(text)) return false;      // 先比长度,绝大多数不一样的在这儿就刷掉了
  try { return fs.readFileSync(f, "utf8") === text; } catch { return false; }
}
/** 这条会话跟它在内存里的一切痕迹都抹掉(删会话、清定时任务残留都走这儿)。
 *  四个 Map 是一套的,漏掉任何一个,那个 Map 就变成新的只进不出 */
function forgetSession(id) {
  sessions.delete(id);
  sessStamp.delete(id);
  sessSaveAt.delete(id);
  sessUsedAt.delete(id);
  sessHold.delete(id);
  // 排着的后台存盘一起作废:不作废的话,删会话之后一秒它又把整份写回盘上,等于没删
  const w = sessWriters.get(id);
  if (w) w.w.cancel();
  sessWriters.delete(id);
}
/**
 * 删会话:盘上跟它有关的每一份一起删——正本、.bak、.corrupt / .corrupt-<时间戳>、写到一半的 .tmp,
 * 外加这条会话压缩时归档的原文(compact-archive/<id>-<毫秒>.json)。
 * 以前只删正本:2026-09-28 实测 data/sessions 下 5 个 .bak 的正本早没了——
 * 用户点了删除,整段对话原样躺在 .bak 里。按文件名精确比对,别的会话一个字节都碰不到。
 * 目录从 SESS_DIR 推,不用 dataPath():e2e / memory 两个测试把这一整段切出去单跑,注进去的就那几个依赖。
 */
function removeSessionFiles(id, strict = false) {
  const base = path.basename(sessFile(id));
  const safe = base.slice(0, -".json".length);
  // <pid>.<序号>.tmp 是后台存盘那条路的临时名(store.writeTextAtomicAsync)
  const tail = /^(?:bak|corrupt(?:-\d+)?|\d+(?:\.\d+)?\.tmp|bak\.\d+\.tmp)$/;
  const sweep = (dir, hit) => {
    let names = [];
    try { names = fs.readdirSync(dir); } catch (e) { if (strict && e.code !== "ENOENT") throw e; return; }
    for (const n of names) if (hit(n)) { try { fs.rmSync(path.join(dir, n), { force: true }); } catch (e) { if (strict) throw e; } }
  };
  sweep(SESS_DIR, (n) => n === base || (n.startsWith(base + ".") && tail.test(n.slice(base.length + 1))));
  sweep(path.join(path.dirname(SESS_DIR), "compact-archive"),
    (n) => n.startsWith(safe + "-") && /^\d{13}\.json$/.test(n.slice(safe.length + 1)));
}
/** 当前内存里这些会话占了多少字节(按盘上那份算) */
function sessCacheBytes() {
  let n = 0;
  for (const id of sessions.keys()) n += ((sessStamp.get(id) || {}).size) || 0;
  return n;
}
let sessTrimAt = 0;   // 这个时刻之前不再白跑一趟(见下)
/** 超预算就从「最久没碰」那头往外扔。keepId 是这次要用的那条,永远不许扔它 */
function trimSessionCache(keepId) {
  let bytes = sessCacheBytes();
  if (bytes <= SESS_CACHE_BYTES) return 0;
  const now = Date.now();
  // 上一趟一条都没清掉(全在用着、或者全都还没落盘):歇一会儿再试。
  // 不歇的话,超着预算的这段时间里每开一条会话都要把整份缓存重扫一遍,
  // 而那一趟是要逐个读盘比对的——本来是省内存的,反倒成了新的开销。
  if (now < sessTrimAt) return 0;
  let gone = 0, looked = 0;
  for (const id of [...sessions.keys()]) {
    if (bytes <= SESS_CACHE_BYTES || sessions.size <= SESS_CACHE_KEEP) break;
    looked++;
    if (id === keepId || activeRuns.has(id) || sessHold.has(id)) continue;
    if (now - (sessUsedAt.get(id) || 0) < SESS_CACHE_IDLE_MS) continue;
    const w = sessWriters.get(id);
    if (w && w.w.busy()) continue;   // 还有一趟后台存盘没落地:内存里这份就是还没上盘的改动
    if (!sessSynced(id)) continue;
    bytes -= ((sessStamp.get(id) || {}).size) || 0;
    sessions.delete(id);
    sessStamp.delete(id);     // 跟着一起清,不然这两个 Map 自己变成新的泄漏
    sessSaveAt.delete(id);
    sessUsedAt.delete(id);
    sessWriters.delete(id);
    gone++;
  }
  // 只有「真挑过、一条都挑不动」才歇(全在用着、或者全都还没落盘)。
  // 一条都没挑过就歇是个陷阱:缓存刚好卡在保底条数上又超了预算时,这一趟什么都干不了,
  // 可接下来这一分钟里用户再点开多少条都不会再清——内存在这一分钟里是彻底敞开的。
  // 本机实测:预算 8MB、保底 8 条、每条 1MB,正好在第 8 条撞上这一格,
  // 于是翻完 30 条一条都没清掉,30.1MB 全留着,等于这道闸门白装。
  if (!gone && looked) sessTrimAt = now + SESS_CACHE_IDLE_MS;
  return gone;
}

function getSession(id) {
  /**
   * 内存里有了也要回头看一眼盘:命令行的 openworkbuddy 写的是同一批文件(data/sessions/<id>.json),
   * 而 openworkbuddy resume 不给 id 时接的就是「最近动过的那个」,包括桌面上刚开的那条。
   * 以前这个 Map 一进来就再也不回头,于是「桌面开个头 → 终端 openworkbuddy resume 接着做 → 回桌面再发一句」
   * 这条路上,桌面用的是几小时前那份内存副本,一存盘就把终端那几轮整段盖掉了——
   * 用户看到的是「我在终端做的那半截凭空消失了」。
   *
   * 正在跑任务的会话不重读:那份内存对象正被这一轮改着,从盘上盖回去等于把自己的进度丢了。
   */
  if (sessions.has(id) && !activeRuns.has(id) && sessChangedOnDisk(id)) {
    console.log(`[会话] ${id} 在别处改过(多半是命令行 openworkbuddy),重新读一遍磁盘,免得把那边的记录覆盖掉`);
    sessions.delete(id);
  }
  if (!sessions.has(id)) {
    // 会话文件坏了不抛错(不能因为一条对话打不开就让整个工作台起不来),
    // 但也绝不装作没有过这条对话:store 会先拿 .bak 顶上,实在不行把坏文件改名隔离
    const data = store.readJson(sessFile(id), { history: [], transcript: [], title: "", updated_at: null });
    // 目录迁移可能只改过 root 字段,旧 files.root 哈希不可逆;仅用同一会话的备份核对原根。
    try {
      const staleRoot = data.root && data.dir && (data.transcript || []).some(row => (row.events || []).some(e => e.type === "files" && e.root && e.root !== workspaceKeyOf(data.root)));
      if (staleRoot) {
        const previous = JSON.parse(fs.readFileSync(sessFile(id) + ".bak", "utf8"));
        if (taskDirs.recoverFileRoots(data, previous, workspaceKeyOf)) store.writeJsonAtomic(sessFile(id), data);
      }
    } catch { /* 没有迁移证据时保留原链接,不按文件名跨项目猜测。 */ }
    sessions.set(id, data);
    sessStamp.set(id, sessStat(id));
    sessUsedAt.set(id, Date.now());
    trimSessionCache(id);   // 刚读进来的这条排在队尾,清的是队头那些老的
  } else {
    touchSession(id);
  }
  return sessions.get(id);
}
/**
 * 把会话写回磁盘。
 *
 * sess 传不传都行:长期攥着对象的调用方(定时任务录制器)把手里那份直接递进来,
 * 这样哪怕它已经被缓存清掉了,写盘照样落在对的内容上 —— 而不是悄没声地什么都不做。
 */
function saveSession(id, sess, { keepTime = false } = {}) {
  const s = sess || sessions.get(id);
  // 内存里没有、调用方也没递一份进来:这不是正常状态,必须留个痕。
  // 悄悄 return 的话,真出了「跑完一整趟、回放里一片空白」这种事,日志里一个字都没有。
  if (!s) return void console.warn(`[会话] 要存 ${id},可内存里没有这份,也没人把对象递进来——这一次写盘跳过了`);
  if (!s.history) return;
  touchSession(id);
  // 同步写了最新的一整份:后台排着的那趟作废,路上那趟也别晚到把这份盖回旧的(见 sessWriter 的 shouldCommit)
  const rec = sessWriters.get(id);
  if (rec) { rec.gen++; rec.w.cancel(); }
  // 置顶这种只改标记的写盘不算「动过」:不然一置顶,它在侧栏里的先后就跳到最前
  if (!keepTime || !s.updated_at) s.updated_at = new Date().toISOString();
  // 盘上那份还是我们上回读 / 写下的样子(mtime+size 都对得上):上一版不用再整份读回来 JSON.parse
  // 一遍才配当 .bak,直接硬链接过去(见 store.js linkBackup)。对不上——命令行写过、
  // 或者这条压根不在缓存里(定时任务把对象直接递进来那条路)——就还走老路先验再拷,
  // 残骸永远顶不掉一份好的 .bak。
  const mine = sessStamp.get(id), disk = mine ? sessStat(id) : null;
  const trustPrev = !!(mine && disk && disk.size > 0 && disk.mtime === mine.mtime && disk.size === mine.size);
  store.writeJsonAtomic(sessFile(id), s, { trustPrev });
  // 记下自己写完之后的样子,别把自己这次写当成「别人改的」。
  // 只给还在缓存里的记:清掉的那些记了也没人看,反倒让这个 Map 接着涨
  if (sessions.has(id)) sessStamp.set(id, sessStat(id));
}
/** 跑任务途中顺手存一次盘,最快 5 秒一次。
 *  跟 saveSession / 缓存清理摆在一块儿:它写的 sessSaveAt 是会话缓存那一套 Map 里的一个,
 *  拆开放的话,forgetSession 清不到它,而 e2e 切这一整段出去单跑时也会当场少一个名字。 */
const sessSaveAt = new Map();
function autosaveSession(id, minGapMs = 5000) {
  const now = Date.now();
  if (now - (sessSaveAt.get(id) || 0) < minGapMs) return;
  sessSaveAt.set(id, now);
  queueSessionSave(id);
}
/**
 * 跑任务途中的存盘走后台,而且每条会话一秒最多写一次。
 *
 * 以前这里直接调 saveSession:整份 JSON.stringify + 写临时文件 + fsync 全在主线程上。
 * 2026-09-29 实测 5 条对话并发、每条会话 1.4MB:540 秒里主线程 fsync 11426 次、合计 51.6 秒
 * (单次约 4.5ms),这段时间所有对话的流式输出和所有接口一起停着。按 120 秒 CPU 采样分:
 * 会话存盘 4.5 秒、running.json 4.5 秒、模型健康账本 2.2 秒,三处都改成了后台写。
 *
 * 现在:写临时文件和 fsync 交给线程池(store.writeJsonAtomicAsync),主线程只剩序列化和改名那一下;
 * 同一条会话排着的几次并成一次(store.coalesce,一次只在路上一份,旧的不会晚到盖掉新的)。
 * 丢不了:回合收尾 await flushSessionSave 落完盘才发 done;进程退出走 flushSessionSavesSync 同步补写。
 */
const SESS_SAVE_GAP_MS = 1000;
const sessWriters = new Map(); // id -> { w: 排班, gen: 同步那条路写过几次 }
function sessWriter(id) {
  const had = sessWriters.get(id);
  if (had) return had;
  /** @type {{ gen: number, w: any }} */
  const me = { gen: 0, w: null };
  me.w = store.coalesce(() => {
    if (sessWriters.get(id) !== me) return;   // 删了 / 清出缓存了:这一趟作废
    const s = sessions.get(id);
    if (!s) return void console.warn(`[会话] 要存 ${id},可内存里没有这份——这一次写盘跳过了`);
    if (!s.history) return;
    const gen = me.gen;
    s.updated_at = new Date().toISOString();
    return store.writeJsonAtomicAsync(sessFile(id), s, {
      // 跟 saveSession 同一个判据,只是挪到改名前那一刻才判
      trustPrev: () => {
        const mine = sessStamp.get(id), disk = mine ? sessStat(id) : null;
        return !!(mine && disk && disk.size > 0 && disk.mtime === mine.mtime && disk.size === mine.size);
      },
      // 等线程池这几毫秒里同步那条路写过更新的一份(gen 变了),或者会话被删了:这份旧的不许落地
      shouldCommit: () => me.gen === gen && sessWriters.get(id) === me,
      onCommit: () => { if (sessions.has(id)) sessStamp.set(id, sessStat(id)); },
    });
  }, { minGapMs: SESS_SAVE_GAP_MS, onError: (e) => console.warn(`[会话] 中途存盘失败(${id}):${e.message}`) });
  sessWriters.set(id, me);
  return me;
}
function queueSessionSave(id) {
  touchSession(id);
  sessWriter(id).w.request();
}
/** 不等间隔立刻存,落完盘才 resolve(回合收尾用:告诉界面「完成了」之前记录必须已经在盘上) */
function flushSessionSave(id) {
  touchSession(id);
  return sessWriter(id).w.flush();
}
/** 进程要退了:还没落地的那些同步补写一遍(退出钩子里等不了线程池) */
function flushSessionSavesSync() {
  let n = 0;
  for (const [id, rec] of [...sessWriters]) {
    if (!rec.w.busy()) continue;
    try { saveSession(id); n++; } catch (e) { console.warn(`[会话] 退出前补存失败(${id}):${e.message}`); }
  }
  return n;
}

/**
 * 磁盘上的会话清单(给侧栏的任务历史用)。
 *
 * 侧栏那份列表一直只活在浏览器 localStorage 里,而对话本体在 data/sessions/。
 * 于是「换台机器 / 清缓存 / 改用户名 / 换个账号先登进来」任意一件事,列表就空了,
 * 用户看到的是「我的历史任务全没了」,其实一条都没丢。这里按磁盘给一份权威清单。
 *
 * 按文件 mtime 做增量缓存:没变的文件不重读,跑着任务时也不会每次全量读一百个 JSON。
 * id 取的是文件名(sessFile 会把非 \w 字符换成 _,而 id 本来就是 s_<时间戳>_<随机数>,不会被改写)。
 */
const sessMetaCache = new Map(); // 文件名 -> { mtime, row }
// 上面那个 Map 只在进程活着的时候管用。每次重启后第一次拉侧栏,还得把 data/sessions/ 下
// 每个 JSON 整个读出来、整个 parse 一遍,只为了取标题和轮数——1500 条会话(20MB)实测 326ms,
// 而且用得越久越长。这一下正好卡在「点开应用、侧栏还是空的」那段空白上。
// 所以把 {mtime, row} 落一份到盘上:重启后 mtime 对得上就直接用,对不上才回去读原文件。
// 判据仍然是 mtime,跟内存那份一模一样,不存在「缓存比文件旧」这种状态。
// ⚠️ 名字不带 .json,是为了能安心放在 SESS_DIR 里头。扫这个目录的一共四处
// (这儿、改名时的批量重写、成果清单、cli 的会话列表),四处都是 filter(endsWith(".json")),
// 所以一个没后缀的点文件对它们全都不存在——真叫 sessions-index.json 的话,
// 侧栏第一条就会多出一个叫「sessions-index」的假会话。
// 路径从 SESS_DIR 推、不用 dataPath():e2e 把这一整段切出去单跑,注进去的就那几个依赖。
const SESS_INDEX = path.join(SESS_DIR, ".index");
let sessIndexLoaded = false;
let sessIndexDirty = false;
let sessIndexTimer = null;
function loadSessIndex() {
  if (sessIndexLoaded) return;
  sessIndexLoaded = true;
  const raw = store.readJson(SESS_INDEX, null);
  if (!raw || raw.v !== 1 || !raw.rows) return;
  for (const [name, hit] of Object.entries(raw.rows)) {
    // 只认形状对的:这文件坏了顶多是白读一次盘,绝不能让侧栏拿着半个 row 去渲染。
    // row 为 null 是**有效**结论(「这个文件不进侧栏」),跟「少写了 row 这个字段」不是一回事,
    // 所以判的是「有没有 row 这个键」,不是「row 真不真」——见下面 saveSessIndex 那段。
    if (!hit || typeof hit.mtime !== "number" || !("row" in hit)) continue;
    if (hit.row !== null && !(hit.row && hit.row.id)) continue;
    sessMetaCache.set(name, hit);
  }
}
function saveSessIndex() {
  if (!sessIndexDirty || sessIndexTimer) return;
  // 攒 2 秒再写。攒的不是「一次列表里的几十条」(一次列表只调一次这儿),
  // 是「侧栏在轮询」:网页 + 手机 + 第二个标签页,几秒一趟,每趟都重写 37KB 没意义。
  sessIndexTimer = setTimeout(() => {
    sessIndexTimer = null;
    sessIndexDirty = false;
    const rows = {};
    // ⚠️ row 为 null 的也要写进去。它的意思是「这个文件我看过了,不进侧栏」——
    // 空壳会话、写坏的 JSON 都落在这一类。漏掉它们的话,恰恰是这批「读了也没用」的文件
    // 每次重启都要被完整读一遍,而这正是这份索引要省掉的开销。
    for (const [name, hit] of sessMetaCache) if (hit && "row" in hit) rows[name] = hit;
    try { store.writeJsonAtomic(SESS_INDEX, { v: 1, rows }, { backup: false }); } catch {}
  }, 2000);
  if (sessIndexTimer.unref) sessIndexTimer.unref(); // 测试里起完就关的进程,别被这个定时器吊住
}
function listSessionsOnDisk() {
  loadSessIndex();
  let names = [];
  try { names = fs.readdirSync(SESS_DIR).filter((n) => n.endsWith(".json")); } catch { return []; }
  const rows = [];
  const seen = new Set();
  for (const n of names) {
    const id = n.slice(0, -5);
    seen.add(n);
    const live = sessions.get(id); // 内存里有就用内存的:刚写过的盘没必要再读一遍
    if (!live) {
      let mtime = 0;
      try { mtime = fs.statSync(path.join(SESS_DIR, n)).mtimeMs; } catch { continue; }
      const hit = sessMetaCache.get(n);
      if (hit && hit.mtime === mtime) {
        if (hit.row) {
          // 老版本的 .index 尚未记创建时间:用索引里已有的稳定时间补齐,不为侧栏首次打开重读整份会话。
          if (!hit.row.created_at) {
            hit.row.created_at = new Date(Number(hit.row.at) || mtime).toISOString();
            sessIndexDirty = true;
          }
          rows.push(hit.row);
          if (typeof hit.row.files !== "number") sessFilesTodo.add(n);
        }
        continue;
      }
      const data = store.readJson(path.join(SESS_DIR, n), null);
      const row = sessionRow(id, data);
      sessMetaCache.set(n, { mtime, row });
      sessIndexDirty = true;
      if (row) rows.push(row);
      continue;
    }
    const row = sessionRow(id, live);
    if (row) rows.push(row);
  }
  for (const k of sessMetaCache.keys()) if (!seen.has(k)) { sessMetaCache.delete(k); sessIndexDirty = true; } // 删掉的会话别赖在缓存里
  rows.sort((a, b) => b.at - a.at);
  saveSessIndex();
  fillSessFilesLater();
  return rows;
}
/** 这条会话前后产出过几个文件(同名改写算一个)。侧栏靠它在行上挂文件图标,顶栏靠它写「N 个文件」 */
function sessFileCount(s) {
  return s ? sessSearch.filesOf(s).length : 0;
}
// 「产出过几个文件」是后加的字段,升级前落盘的 .index 里没有。那几行照常进侧栏,
// 由这里在后台一条条回去补,补完下一次拉列表就带上了。不在拉列表那一趟里当场补:
// 几百条会话整份重读一遍要一两秒,正好卡在打开应用、侧栏还空着的那一下。
const sessFilesTodo = new Set(); // 文件名
let sessFilesTimer = null;
function fillSessFilesLater() {
  if (sessFilesTimer || !sessFilesTodo.size) return;
  const later = (ms) => {
    sessFilesTimer = setTimeout(step, ms);
    if (sessFilesTimer.unref) sessFilesTimer.unref();
  };
  const step = () => {
    sessFilesTimer = null;
    const t0 = Date.now();
    for (const n of sessFilesTodo) {
      sessFilesTodo.delete(n);
      const hit = sessMetaCache.get(n);
      if (!hit || !hit.row || typeof hit.row.files === "number") continue;
      let mtime = 0;
      try { mtime = fs.statSync(path.join(SESS_DIR, n)).mtimeMs; } catch { continue; }
      if (mtime !== hit.mtime) continue; // 文件又动过:下一趟列表会整份重读它,轮不到这儿
      hit.row.files = sessFileCount(store.readJson(path.join(SESS_DIR, n), null));
      sessIndexDirty = true;
      if (Date.now() - t0 > 30) break; // 一口气最多占 30ms,别卡住正在出字的对话
    }
    if (sessFilesTodo.size) later(50);
    else saveSessIndex();
  };
  later(500);
}
function sessionRow(id, s) {
  // 一句话都没说过的空壳不进侧栏:点进去还是空的,只会让人以为「历史又乱了」
  if (!s || !Array.isArray(s.transcript) || !s.transcript.length) return null;
  // 定时任务跑出来的那段会话也不进侧栏:一天几十条 cron 会把真正的对话整个挤下去。
  // 它们有自己的正经去处——自动化 → 运行记录,那边每条记录都挂着「看执行过程」直接开这一段。
  if (s.kind === "schedule") return null;
  const firstUserAt = (s.transcript || []).find((t) => t && t.type === "user" && t.at)?.at;
  return {
    id,
    title: s.title || "未命名任务",
    dir: s.dir || "",
    // 固定展示创建日期,让任务在自动改标题、继续执行后仍有稳定的「名称 · 日期」标识。
    // 旧会话没有首条消息时间时退回更新时间,避免侧栏出现空日期。
    created_at: firstUserAt || s.created_at || s.updated_at || "",
    user: s.user || "",
    project: s.project || "",
    at: Date.parse(s.updated_at || "") || 0,
    turns: s.transcript.length,
    // 侧栏按标签分栏。这儿只如实报「这条会话当初记的是哪条线」,没记过就不报——
    // 老会话该归到哪条线要看**读它的这个人**现在配的是什么引擎,那是前端拿 /api/lanes 算的事;
    // 在这儿按服务器配置替他填死,多用户下就会把别人的引擎口径安到他头上。
    lane: lanes.normalize(s.lane) || undefined,
    billing_scope: s.billing_scope === "team" ? "team" : "personal",
    engine: typeof s.engine === "string" ? s.engine : undefined,
    pinned_at: Number(s.pinned_at) > 0 ? Number(s.pinned_at) : undefined,
    files: sessFileCount(s),
  };
}
/**
 * 侧栏只列「我自己的」,故意比 sessionAllowed 更窄:
 * 后者管的是「能不能打开」(管理员拿到同组织的链接可以打开),
 * 这里管的是「侧栏该不该出现」——把同事的任务铺进管理员的侧栏是另一种事故。
 * 没开账号体系(req.user 为空)本来就是一个人用;没记归属的老会话归管理员(见 legacySessionOwner),
 * 不再当公共的——那一条正是「新建的号一打开,侧栏里全是别人的对话」的出处。
 * 这里的 row 是磁盘清单里的一行,不会是空壳,所以直接按 legacySessionOwner 兜底。
 */
function ownSession(user, row) {
  if (!user) return true;
  const owner = (row && row.user) || legacySessionOwner();
  return !owner || owner === user.username;
}

// 任务跑一半崩了 / 用户直接退出 App,这一轮的过程就全没了——中途也存,最多每 5 秒一次。
// 存的是同一份对象,落盘又是原子改名,跟收尾时那次 saveSession 不会打架。
// IM 会话跟网页会话分开存(data/im-sessions/<键>.json),重启不丢上下文
const imSessions = createImSessionStore({ dir: dataPath("data", "im-sessions") });
// 助理页的上下文以前全服务器共用一个键(local_assist),现在一人一段。老库里那一段就这么晾着的话,
// 升级完第一句话它就「突然失忆」——那段话本来就是管理员跟它说的,认到管理员名下即可
try {
  const boss = account.defaultUser();
  const bossKey = boss ? "local_" + prefs.keyOf(boss) : "";
  if (bossKey && imSessions.has("local_assist") && !imSessions.has(bossKey)) {
    imSessions.set(bossKey, imSessions.get("local_assist"));
    imSessions.clear((k) => k === "local_assist");
    console.log("[IM会话] 助理页上下文已按账号拆开,原来那段归到管理员名下");
  }
} catch (e) {
  console.warn("[IM会话] 助理页上下文迁移失败(不影响启动):", e.message);
}

/** 包装 emit:把事件同时记录到 transcript(文本增量合并,跳过噪音事件),顺便中途存盘 */
// ---------- Goal 目标模式 ----------
// 拆验收标准、对着标准验收这套逻辑搬到了 goal.js,网页端和 `openworkbuddy` 命令行共用同一份。
// 搬家的直接原因:这段代码原本只长在下面那个 /api/chat 里,于是 Goal 就只对网页存在——
// 命令行的模式表是手抄的三个,goal 没抄进去,`/mode goal` 敲得进去却按 craft 跑。
// 这里只留「动脑那句话问谁」(goalThink),因为它要看登录用户配的是哪个引擎。
/**
 * 目标验收那一步借判断模型(Jev)。
 *
 * 走跟 /api/decide 同一道额度闸:它一道题两万分之一美金,贵不起来,可它是**在循环里**跑的——
 * 自动补跑几轮、每轮几条标准,一晚上能问出很多道。限的是失控的量,不是钱。
 * 没配渠道 / 被闸拦了都返回 ok:false,goal.js 会安静地退回对话模型那条老路。
 */
const decideForGoal = (args) => jev.askMetered(config, args, { meta: "目标验收" });
const goalKit = require("./src/core/automation/goal").createGoalEngine({ workspaceDir: getWorkspaceDir, decide: decideForGoal });
const GOAL_MAX_ROUNDS = goalKit.MAX_ROUNDS;

/**
 * 把一次调用(或 agent 返回的一整轮)的用量并进本次任务的总账。
 *
 * 以前这件事在五个地方各手写一遍 `total.prompt += …; total.completion += …`,于是
 * llm.js 好不容易统一读出来的 cached 在这一层被五处同时丢掉——账本记的 cached 恒为 0,
 * 「有没有在反复全价重买同一段上下文」这个问题看不见,扣积分也没法给缓存打折。
 * 收成一个函数,以后 usage 再多一个字段只改这一处,不会有第六处漏掉。
 *
 * calls:llm.chat() 一次就是一次;agent 返回的 usage 自带 calls(里面是好多次),照抄。
 */
function addUsage(total, u) {
  if (!u) return;
  total.prompt += u.prompt || 0;
  total.completion += u.completion || 0;
  total.cached = (total.cached || 0) + (u.cached || 0);
  total.calls += u.calls != null ? u.calls : 1;
  total.elapsed_ms = (total.elapsed_ms || 0) + (u.elapsed_ms || 0);
}

/**
 * Goal 模式的「动脑」通道:拆验收标准、对着标准判分,这两句该问谁。
 *
 * 本机引擎(Claude Code / Codex)在跑时,就借那个 CLI 问——用户切过去图的就是不花 API 的钱,
 * 这两步却偷偷走 API:没配 Key 的人于是永远拆不出标准也验不了收,目标卡卡在 0/N 不动,
 * 从用户那边看就是「Goal 模式没做」。同一份订阅已经付过钱了,问它就是了。
 */
/** 这个请求所在的组织关了命令行(外部引擎自带命令行,跟着一起不能用) */
function orgShellOff() {
  const p = orgPolicy();
  return !!p && p.allow_shell === false;
}

async function goalThink(sessLLM, { system, prompt, timeoutMs, total, user }) {
  const id = engines.currentId(config, { user }); // 引擎是按账号存的,得看这一趟任务是谁发起的
  if (id !== "builtin" && engines.get(id)) {
    // 跟开跑那条过同一道闸:组织关了命令行、属主没打开、型号没钉都直接报错,不悄悄改问 API 模型
    const pass = engines.admit(id, config, { shellOff: orgShellOff(), user });
    return await engines.ask({ id, opts: pass.opts, system, prompt, timeoutMs });
  }
  const r = await sessLLM.chat({
    system,
    history: [{ role: "user", content: prompt }],
    tools: [],
    signal: AbortSignal.timeout(timeoutMs),
  });
  addUsage(total, r.usage);
  return r.text;
}

/**
 * 把 goalThink 包成 goal.js 要的那个 think(\{system,prompt,timeoutMs\}) —— 用量记在这一趟任务的总账上。
 * 拆出去之后这一层就是全部的粘合剂:goal.js 不认识 sessLLM、不认识引擎、也不记账。
 */
const goalThinkFor = (sessLLM, total, user) => (a) => goalThink(sessLLM, { ...a, total, user });

/**
 * 把任务事件翻译成桌面宠物的表情。只认深度 0 的事件——专家子代理的动静太密,
 * 宠物跟着抽风反而看不出主线在干什么。纯 node 模式下 global.__openworkbuddyPet 不存在,整个是空操作。
 */
function petSay(ev) {
  const P = global.__openworkbuddyPet;
  if (!P) return;
  try {
    switch (ev.type) {
      case "ask_user": P.alertAsk(ev.question); break;             // 跳 + 通知 + Dock 弹,最高一档
      case "ask_answer": P.clearAsk(true); break;
      case "tool_use": P.setState("working", "正在用 " + (ev.name || "工具")); break;
      case "expert_start": P.setState("working", `${ev.expert || "专家"} 接手了:${String(ev.task || "").slice(0, 40)}`); break;
      case "sleep": P.setState("sleep", ev.note); break;
      case "failover": P.setState("working", ev.note); break;
      case "limit": P.setState("error", ev.note); break;
      case "error": P.setState("error", String(ev.message || "任务出错了").slice(0, 80)); break;
      default: break;
    }
  } catch {}
}

/**
 * @param opts.pet 要不要让桌面宠物跟着这趟任务动表情。定时任务传 false:
 *   它是背着人跑的,让宠物替一条 cron 手舞足蹈会盖掉用户手头正在看的那件事。
 */
function recordingEmit(send, events, sessionId, { pet = true } = {}) {
  return (ev) => {
    send(ev);
    if (pet && !(ev.depth > 0)) petSay(ev);
    if (ev.type === "text") {
      if (ev.depth > 0) return;
      const last = events[events.length - 1];
      if (last && last.type === "text") last.delta += ev.delta;
      else events.push({ type: "text", delta: ev.delta });
    } else if (ev.type === "files") {
      // 产出卡片得能活过一次重开:历史回放全靠这条事件重建。以前它不在存盘清单里,
      // 于是任务当场看得见成果卡,退出再进来那片卡就凭空消失了。
      // 但整份清单最多 500 条、每批工具跑完就来一次,原样存会把会话文件撑爆——
      // 只留这一批真正变更的那几条(回放时 renderFiles 被 isReplaying 挡住,用不到全量)
      const chg = ev.changed || [];
      // partial 是给回放用的实话:存下来的这份 files 已经被裁成「这一批变更」了,不是全量清单。
      // 前端拿不到全量就不能判定谁没了——早先没这个标记,回放时每来一批就把上一批的产出
      // 全盖上「已删除」,用户看到的是四个文件全被划掉,其实一个都没删
      // 第 4 层往下、挤出最新 500 条的产出不在 files 里,在 turn_files 里(两份不重名):并起来再裁,不然回放时那几张卡画不出来
      const pool = (ev.files || []).concat(ev.turn_files || []);
      // gone 是服务端当场 stat 过、确实没了的那几个:回放时清单是裁过的,前端只能靠它撤卡
      const gone = Array.isArray(ev.gone) ? ev.gone : [];
      if (chg.length || gone.length) events.push({ type: "files", changed: chg, files: pool.filter((f) => chg.includes(f.name)), partial: true, root: ev.root, ...(gone.length ? { gone } : {}) });
    } else if (ev.type === "status") {
      // 状态播报大多是进度(重试中、思考中),过去就过去了,不存。要存的两种是这一趟的事实:
      // 本机引擎启动成功那条(谁在跑、哪个模型),和标了 notice 的(工具没挂上、连接器挂不上、
      // 只读模式停用了哪些设置……)。以前一条都不存,重开对话就不知道那趟是怎么跑的
      if (ev.depth > 0) return;
      if (ev.notice || ("model" in ev && !ev.starting)) events.push(ev);
    } else if (["tool_use", "tool_result", "parallel", "expert_start", "expert_done", "error", "limit", "auto_continue", "failover", "sleep", "trim", "compact", "usage", "interject", "worktree", "credits", "sources", "ask_user", "ask_answer", "milestones", "todos", "context", "trace"].includes(ev.type)) {
      // 工具事件盖个时间戳(send 已经发出去了,这里只影响存盘):回放时轨迹条才算得出每步耗时
      if (ev.type === "tool_use" || ev.type === "tool_result") ev.at = ev.at || Date.now();
      events.push(ev);
      // 一步走完就是个存盘点:跑了半小时的任务不该因为一次崩溃从头再来
      if (sessionId && ev.type === "tool_result") autosaveSession(sessionId);
    }
  };
}

const app = express();
// 大小写敏感路由。Express 默认是 false,于是 POST /API/settings 会命中 /api/settings 的
// 处理器,而 account.js 的 authGuard 和 admin.js 的门禁表都是用 startsWith 按**原样**路径
// 判的——把首字母改成大写就既不用登录、也绕开整张写表。判据那两处也一并小写化了(两头都堵,
// 只堵一头都会留下半扇门)。全仓路由注册和前端请求本来就全小写,打开这个开关不改变任何现有行为。
app.set("case sensitive routing", true);
app.use(express.json({ limit: "60mb" }));

/**
 * 几条响应头。本来这东西跑在本机没人惦记,但只要往 VPS 上一放就是公网服务了。
 *
 * - no-referrer:配对码是从 ?pair= 进来的。带 Referer 的话,这一页上任何一个外链
 *   (AI 生成的网页里随便一个 <a>)都会把整条地址、连着那串码一起送到对面站点去。
 * - nosniff:/api/files 那条路会把用户自己的文件原样吐出来。不关嗅探的话,
 *   一个存成 .txt 的 html 会被浏览器当页面执行,那就是同源下的 XSS。
 * - SAMEORIGIN:别人用 iframe 套住这一页骗点击(点的是「删除」,他看见的是「领奖」)。
 * - base-uri/form-action:真出了 XSS 时,这两条能拦住最顺手的那两种偷法。
 *
 * - X-Powered-By:Express 默认写的是 "Express",换成产品名。不带版本号——版本号只对挑漏洞的人有用。
 *   写产品名是为了让放到公网上的部署能被认出来:商用没授权的,大多就是这么被发现的。
 *
 * 没上完整 CSP 是故意的:预览 AI 生成的网页用的是 blob: iframe,而 blob: 会继承
 * 这一页的 CSP。加上 script-src/connect-src,生成的网页里引个图表库就白屏了——
 * 那是主线功能。iframe 那边已经用 sandbox 隔成独立源,先靠它。
 */
app.disable("x-powered-by");
app.use((req, res, next) => {
  res.setHeader("X-Powered-By", "OpenWorkBuddy");
  res.setHeader("X-OWB-Mark", require("./src/util/provenance").MARK); // 改了名部署出去也认得出来源
  res.setHeader("Referrer-Policy", "no-referrer");
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader("X-Frame-Options", "SAMEORIGIN");
  res.setHeader("Content-Security-Policy", "base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'");
  next();
});
// 来了请求就把卡顿表打开(闲着时它是关的,见 metrics.js 的 wake)。
// 页面开着时自己隔十几秒问一次的那几条(连接器状态、待审批、终端在跑的活)带 X-OWB-Poll,不算有事——
// 算的话窗口一开着,表就永远关不掉(2026-10-07 实测:三条轮询把它一直顶着,空转照样每秒醒 40 次)
app.use((req, res, next) => {
  if (!req.headers["x-owb-poll"]) metrics.wake();
  next();
});
/**
 * /api 的 GET 默认 no-store,别让 Chromium 往盘上的 HTTP 缓存里写。
 * 这些回应本来就不复用(下一次一定回来重新要),可不写头的话 Chromium 照样把它们落进
 * userData/Cache:GET /api/session/:id 一份就 936 KB,切一次任务写一份,2026-09-28 看到那个目录
 * 大部分是这种用不上的旧会话。要缓存的路由自己会再 res.set 一次、盖掉这里的默认值——
 * /api/files/view 带对版本号时就是这么拿到长期缓存的(见 viewCacheHeader);
 * sendFile 看到已经有 Cache-Control 就不再写它那个 public, max-age=0。
 */
app.use((req, res, next) => {
  if ((req.method === "GET" || req.method === "HEAD") && req.path.startsWith("/api/")) res.setHeader("Cache-Control", "no-store");
  next();
});
/**
 * 静态文本先压再发,摆在 express.static 前面。
 *
 * 首屏那 1.9 MB(HTML + 八个 app-0*.js + i18n 词典 + JointJS + Dagre)全是文本,
 * 压完只剩三成。本机上差别看不出来,走隧道或者手机流量上来的人差十几秒。
 * vendor 那两个不在 public 底下,得单独把前缀指过去——它们俩加起来 558 KB,
 * 恰恰是首屏里最大的一块,漏了等于白做。
 */
// Viewer code is public, immutable application code. CORS lets the opaque
// sandbox import OFD modules; document bytes still arrive only from its parent.
for (const asset of ["ofd", "msdoc"]) app.use("/vendor/" + asset, (_req, res, next) => { res.set("Access-Control-Allow-Origin", "*"); next(); });
app.get("/vendor/document/jszip.min.js", (_req, res) => res.sendFile(appPath("node_modules/jszip/dist/jszip.min.js")));
app.get("/vendor/document/docx-preview.min.js", (_req, res) => res.sendFile(appPath("node_modules/docx-preview/dist/docx-preview.min.js")));
app.use(staticCompress([
  { prefix: "/", dir: appPath("public") },
  { prefix: "/vendor/joint/", dir: appPath("node_modules/@joint/core/dist") },
  { prefix: "/vendor/dagre/", dir: appPath("node_modules/@dagrejs/dagre/dist") },
]));
/**
 * 动态 JSON 也压。上面那层只碰磁盘文件,而这台服务器上最大的一个响应恰恰是动态的:
 * GET /api/session/:id 实测 936 KB(brotli 后 189 KB),用户每切一次任务就下一遍。
 * 只包 res.json,SSE 走的是 res.write,结构上够不着——理由写在 json-compress.js 开头。
 */
app.use(jsonCompress());
app.use(express.static(appPath("public")));
// Only a one-use state-bound code is deposited here. The authenticated initiating
// owner exchanges it from the app; an external browser need not possess its cookie.
app.get("/api/mcp/oauth/callback", (req, res) => {
  res.set({ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'; base-uri 'none'" });
  try { mcpOAuth.receive(req.query); res.type("html").send('<!doctype html><meta charset="utf-8"><title>OpenWorkBuddy</title><h1>授权回调已收到</h1><p>请返回 OpenWorkBuddy。The authorization response was received. Return to OpenWorkBuddy.</p>'); }
  catch { res.status(400).type("html").send('<!doctype html><meta charset="utf-8"><title>OpenWorkBuddy</title><h1>授权请求已失效</h1><p>请在连接器页面重新开始。Restart authorization from the Connectors page.</p>'); }
});
// JointJS 是短剧画布的开源底座。它从 node_modules 原样提供给前端,
// 不复制、不改写 vendor 源码;这样开发态、桌面包和离线模式使用的是同一份 MPL-2.0 文件。
app.get("/vendor/joint/joint.min.js", (_req, res) => {
  res.sendFile(appPath("node_modules/@joint/core/dist/joint.min.js"));
});
app.get("/vendor/joint/LICENSE", (_req, res) => {
  res.sendFile(appPath("node_modules/@joint/core/LICENSE"));
});
// Dagre(MIT)只负责按真实有向边计算节点层级和紧凑坐标,JointJS 继续负责渲染与交互。
app.get("/vendor/dagre/dagre.min.js", (_req, res) => {
  res.sendFile(appPath("node_modules/@dagrejs/dagre/dist/dagre.min.js"));
});
app.get("/vendor/dagre/LICENSE", (_req, res) => {
  res.sendFile(appPath("node_modules/@dagrejs/dagre/LICENSE"));
});
/**
 * 「这个端口上应答的是谁」——唯一一个不需要登录的接口。
 *
 * 两个地方指着它,问的是同一件事「3800 上应答的是不是我自己」:
 *   1)`openworkbuddy doctor`:端口被占着,占的是 OpenWorkBuddy 自己(那叫「已经开着」),
 *      还是别的程序(那才要去处理)。不分的话,每个正常使用中的用户跑一次体检都会看到
 *      一条红色告警,看两次就再也不信这个工具了。
 *   2)桌面壳的 listenWithFallback:少了它,壳只能把窗口指给任何一个会应答的服务,
 *      用户看到的是个陌生页面。
 *
 * 只回应用名和版本号——登录页上本来就写着这两样,不多泄露任何东西。
 * 免登录有两把锁,各自独立:这一行在 authGuard **前面**,account.js 的 PUBLIC_API 里也列着它
 * (那段注释在 account.js 里写着)。拆掉任意一把都还能应答,两把一起没了才会回 401——
 * 那时候壳把自己人当成陌生人,转头换个口又起一台。
 *
 * 只许注册一次。原先这个文件里有一模一样的第二处,在 authGuard 后面;Express 只派给先注册的
 * 那个,那份从来没被走到过。真正的危险是有人清理时留后删前——接口悄悄挪到闸后头,当时看着还好
 * (PUBLIC_API 兜着),等哪天有人顺手精简那个 Set,doctor 和壳就一起瞎掉。test/remote.js 盯着这两件事。
 */
app.get("/api/ping", (_req, res) => {
  res.json({ app: "openworkbuddy", version: require("./package.json").version });
});
// /api/auth/* /api/usage /api/credits/*
app.use(
  account.createRouter({
    // 账号改了登录名,历史会话的归属得跟着走,不然那些任务就成了没主的(列表里直接消失)。
    // 内存和磁盘两头都要改:只改盘上的,下一次 saveSession 会拿内存里的旧名字盖回去。
    onRename: (from, to) => {
      for (const s of sessions.values()) if (s && s.user === from) s.user = to;
      let files = 0;
      for (const f of fs.existsSync(SESS_DIR) ? fs.readdirSync(SESS_DIR) : []) {
        if (!f.endsWith(".json")) continue;
        const p = path.join(SESS_DIR, f);
        const s = store.readJson(p, null);
        if (s && s.user === from) {
          s.user = to;
          store.writeJsonAtomic(p, s);
          files++;
        }
      }
      const mems = memory.renameScope(from, to); // 记忆也认登录名,不搬就成了孤儿
      // 个人工作目录:文件夹名不动,表里换个键。个人资料库的目录名是按登录名算的,得跟着挪,不然改完名打开是空的
      try { spaces.rename(from, to); } catch (e) { console.warn("[账号] 个人工作目录没跟上改名:" + e.message); }
      try {
        const was = dataPath("data", "library-users", prefs.keyOf(from)), now = dataPath("data", "library-users", prefs.keyOf(to));
        if (fs.existsSync(was) && !fs.existsSync(now)) fs.renameSync(was, now);
      } catch (e) { console.warn("[账号] 个人资料库没跟上改名:" + e.message); }
      console.log(`[账号] 登录名 ${from} → ${to},${files} 条会话、${mems} 条记忆的归属已迁移`);
    },
  })
);
/**
 * 企业加装包。
 *
 * 开源版**只留口子,不留实现**。SSO / 目录同步、审计外送 SIEM、高可用、
 * 白标这几样归商业授权,将来住在私有仓库 openworkbuddy-enterprise 里,以一个 npm 包装上来。
 * 线是怎么划的见 docs/开源与商业版边界.md。
 *
 * 为什么包还不存在就先把口子留出来:没有口子,将来接企业版只能去改主干,
 * 那意味着**每次都要两边同步 diff**;有了口子,企业版永远是加法——
 * 装了就多几条路由,没装就当它不存在。
 *
 * 这里刻意**不做**三件事(做了这就成了「阉割版」,那是开源项目最招人烦的东西):
 *   · 不因为没装而画灰按钮、弹「升级解锁」、写「此功能需企业版」;
 *   · 不因为没装而少给任何一样现在有的能力;
 *   · 不探测、不上报「这台装没装」。
 * 没装的唯一表现就是:这几行什么也没干。
 */
const enterprise = (() => {
  let entry = null;
  try {
    entry = require.resolve("@openworkbuddy/enterprise");
  } catch {
    return null; // 没装。这是**正常状态**不是错误,一个字都不该往日志里写
  }
  try {
    return require(entry);
  } catch (e) {
    // 装了却加载不起来是真事故(版本对不上、依赖缺了)。必须吵出来,
    // 否则客户拿到的是一台「企业功能静悄悄失踪」的服务器,这种最难查。
    // 先 resolve 探、再 require 载,就是为了把这一档和「没装」彻底分开:
    // 光看 MODULE_NOT_FOUND 分不开——企业包自己缺个依赖,报的也是这个码。
    console.error("[enterprise] 加装包装上了,但加载失败:", (e && e.stack) || e);
    return null;
  }
})();
// deps 是**传**进去的不是让它 require 的,跟下面 createAdminRouter 一个形状——
// 企业包不去猜开源版的目录结构,我们内部怎么重构都不会把它碰散。
const relay = require("./src/server/relay");
// 中转站那条路自己的防连打闸:60 次撞门就歇一会儿。跟登录那把分开计数,
// 不然一个刷 Key 的脚本会把正常同事的登录一起锁死。
const relayLimiter = account.createLimiter();

const entDeps = { org, account, security, config, admin };
/**
 * 第一个口子:**认证之前**。
 *
 * 这一档是给 SSO 回调用的(SAML 的 ACS、OIDC 的 redirect_uri)。
 * 这些请求按定义就是**还没登录**的那一个——身份正是它们要带回来的东西。
 * 挂在 authGuard 后面的话,IdP 打回来的那一跳会被自己人挡在门外,SSO 根本走不通。
 *
 * 所以这里是**窄门**,不是后门:企业包该只把回调这类路由放进来,别的一律走下面那个。
 * 名字分成两个就是为了让这件事在调用处一眼看得见——`mount` 是安全的默认,
 * 想要免登录得**显式**写 `mountPublic`,手滑写不出这种口子。
 */
if (enterprise && typeof enterprise.mountPublic === "function") {
  enterprise.mountPublic(app, entDeps);
}
/**
 * 第二个口子:**API 中转站**,也在认证之前。
 *
 * 它的身份不是 cookie,是 Authorization 头里那把我们自己发的虚拟 Key
 * (owb-sk-…,见 vkeys.js)。挂在 authGuard 后面的话,每个请求都会被「未登录」挡掉,
 * 而业务方那边用的是 openai 官方 SDK——它根本没有 cookie 这个概念。
 *
 * 这不是一个敞开的口子:relay.js 里第一件事就是验 Key,验不过一律 401,
 * 而且带着自己的防连打闸(跟登录用的是同一个 createLimiter)。
 * 路径前缀是 /v1/,跟整个 /api/ 不重叠,不会误放行任何一条内部接口。
 */
app.use(relay.createRouter({
  config: () => config,
  orgSettings: (id) => org.settingsOf(org.getOrg(id)),
  user: account.billingUser,
  orgProblem: (id) => org.expiredProblem(id),
  clientIp: account.clientIp,
  limiter: relayLimiter,
}));

app.use(account.authGuard); // 其余 /api/* 与 /im/*(除外部回调)需要登录
app.use("/api/session/:id", (req, res, next) => {
  if (req.method !== "GET" && deletingTasks.has(req.params.id)) return res.status(409).json({ error: "正在删除此任务,请稍候" });
  next();
});

// 租户工作目录 → 服务器级接口的闸 → 凭证脱敏。三段的说明都在 admin.js 里
const tenantScopeMw = admin.tenantScope({ withWorkspace, withPolicy, getWorkspaceDir, readConfig: () => config, withLibraryBase, withLibraryDir, libraryRootOf: (u) => libraryRootOf(u) });
app.use(tenantScopeMw);
/**
 * 没有 HTTP 请求的那几条路(定时任务)也要落进某个人的租户范围:
 * 工作目录、命令行开关、联网白名单、额度,跟他本人在网页上发一句话时一模一样。
 * 直接复用同一个中间件,不另写一份——另写的那份迟早跟这份漂开。
 */
function inTenantOf(user, source, fn) {
  return new Promise((resolve, reject) => {
    try {
      tenantScopeMw({ user, quotaSource: source }, null, () => { Promise.resolve().then(fn).then(resolve, reject); });
    } catch (e) { reject(e); }
  });
}
app.use(admin.platformGuard);
app.use(admin.sharedWorkspaceGuard);
app.use(admin.redactGuard);
const ownsGlobalWorkspace = admin.ownsGlobalWorkspace;
/**
 * 界面靠它决定「服务器级的那些控件画不画」。画了却一点就 403,比不画更气人——
 * 用户看到的是一颗明明能点的按钮点了没反应,只能自己去猜是不是坏了。
 */
const isPlatformOwner = (req) => admin.isSoloDesktop() || ownsGlobalWorkspace(req && req.user);
/**
 * 「能不能从网页/手机操控终端里正在跑的任务」。两个条件都要满足:
 *   ① 你得是这台机器的主人(原来就有的那道闸,租户看不见别人电脑里在跑什么)
 *   ② 组织后台把 remote_control 打开了(**默认关**,见 org.js)
 * 分开两道是因为它们防的不是一件事:①防的是别人,②防的是「我自己没想开着这个口子」。
 */
const canRemoteControl = (req) => isPlatformOwner(req) && account.remoteAllowed("remote_control", req && req.user);
/** 拒绝的理由分清楚:是「你不是主人」还是「这台机器把远程操控关了」。混成一句话,用户不知道该去哪儿改 */
const cliOffReason = (req, verb) => isPlatformOwner(req)
  ? `这台机器关掉了「允许远程操控终端任务」,去 企业管理后台 → 客户端安全 打开`
  : `终端里的任务只有这台机器的主人${verb}`;

// 企业管理后台 /api/admin/*:自带 adminGuard(管理员+审计员可看,只有管理员能改)
app.use(
  admin.createAdminRouter({
    // 付费 API 那一页要判「这一路配没配 Key」。传函数而不是传 config 本身:
    // config 是活的(设置页存一次就整体替换),传引用会让后台一直看着一份旧快照
    readConfig: () => config,
    // 中转站那一页要改价目表(config.prices)。跟 im.js 一个形状:存盘这件事只有 server 会做,
    // admin.js 不去猜 config.json 在哪、也不重复实现一遍原子写
    saveConfig: () => saveConfig(),
    // 「这个组织的成果目录占了多大」——直接数当前请求这条链上的根,跨租户不会串
    orgUsage: () => {
      const fs2 = outputFiles();
      const list = Array.isArray(fs2) ? fs2 : fs2.files || [];
      return { files: list.length, bytes: list.reduce((n, f) => n + (f.size || 0), 0) };
    },
    /**
     * 办离职时把这个人正在跑的任务掐断,返回掐了几个。
     *
     * 为什么非得掐:停用只挡住**下一次**请求。已经跑起来的那一趟在自己的循环里,
     * 手上攥着工具权限,还在读文件、还在调模型、还在往他的通知渠道推结果——
     * 一趟深度任务能跑十几分钟,足够他走到楼下了。
     */
    stopRunsOf: (username) => {
      let n = 0;
      for (const [sid, run] of activeRuns) {
        const sess = sessions.get(sid);
        if (!sess || sess.user !== username) continue;
        try { run.ctrl.abort(); n++; } catch {}
      }
      return n;
    },
  })
);

/**
 * 第二个口子:**认证之后**,也过完了租户隔离和凭证脱敏那几道。
 * 企业包绝大部分东西(审计外送、白标设置、目录同步的管理面)都该走这儿——
 * 进到这里的请求,req.user 已经有了,租户的根也已经绑好了。
 *
 * mount 故意**不**包 try:要不要「加载失败就别让服务起来」(比如 SSO 没挂上就不该
 * 悄悄退回密码登录)是企业包自己的判断——它 throw 就是 fail-closed,不 throw 就照常起。
 * 开源版不替它做这个决定,也就不该把它的 throw 吃掉。
 */
if (enterprise && typeof enterprise.mount === "function") {
  enterprise.mount(app, entDeps);
}



/**
 * 会话归属。会话文件是按 id 存的,接口只要拿到 id 就给内容——同一台服务器上的另一个账号
 * 猜中/拿到一个 id 就能读走整段对话(含产出文件名、模型原文)。这里补上归属判定:
 * 本人一定能看;管理员只能看**本组织**的;老会话没记 user 的按公共处理(不然升级上来全打不开)。
 */
/**
 * 没记归属的那些老会话算谁的。
 *
 * 以前一律按「公共」处理,本意是别让升级上来的历史消失。可账号体系上来之后,「公共」
 * 的实际意思是:**每注册一个新账号,他一进来就看见前面所有人的任务历史**——刚建的 demo 号
 * 打开侧栏,里头是管理员那几十条对话,标题、产出文件名、点开就是全文。
 *
 * 跟助理页上下文那次(local_assist,见下面那段迁移)是同一个判断:没记归属 = 账号体系之前
 * 留下的,那会儿这台机器上就一个人在用,认到管理员名下即可。历史一条不丢,也不铺给别人。
 *
 * 不去改盘上的文件(那是 1500 个 JSON 的批量重写,写坏一个就是一段对话没了),只在读的时候
 * 认这一笔。管理员是谁会变(改名、删号),所以每 30 秒回头问一次账号表。
 */
let legacyOwner = { at: 0, name: "" };
function legacySessionOwner() {
  const now = Date.now();
  if (now - legacyOwner.at < 30000) return legacyOwner.name;
  let name = "";
  try { const boss = account.defaultUser(); name = boss ? boss.username : ""; } catch {}
  legacyOwner = { at: now, name };
  return name;
}
/**
 * 一条会话(内存里的那份对象)现在算谁的。
 *
 * 空壳不算谁的:前端是先自己生成 id、再发第一句话,中间这一下 getSession 会凭空造一份空的出来。
 * 把空壳认到管理员名下的话,别的账号连自己刚开的那条新对话都打不开(403)。
 */
function sessionOwner(s) {
  if (!s) return "";
  if (s.user) return s.user;
  const 有内容 = (s.transcript && s.transcript.length) || (s.history && s.history.length);
  return 有内容 ? legacySessionOwner() : "";
}
function sessionAllowed(user, s) {
  if (!user || !s) return true;
  const owner = sessionOwner(s);
  if (!owner || owner === user.username) return true;
  if (!account.canAdmin(user)) return false;
  const o = account._internals.loadUsers().users.find((u) => u.username === owner);
  if (o) return org.orgIdOf(o) === org.orgIdOf(user);
  // 主人已经删了:按删除时记下的组织认。原来这里是「找不到人就放行」——
  // 任何一家公司的管理员、审计员都能翻别家删掉的员工留下的全部对话
  const was = account.removedOrgOf(owner);
  return was ? was === org.orgIdOf(user) : ownsGlobalWorkspace(user);
}
function guardSession(req, res) {
  const s = getSession(req.params.id);
  if (sessionAllowed(req.user, s)) return s;
  res.status(403).json({ error: "这条对话不属于你" });
  return null;
}
/**
 * 正在跑的那个任务,这次请求的人碰不碰得到。
 *
 * 插队 / 回答 / 停止 / 续流这四条走的是 body.sessionId 或自己的 :id,绕开了 guardSession,
 * 以前一个字都没查归属:知道一个会话 id,就能往别人正在跑的任务里塞一句话、替他回答
 * agent 弹出来的问题、把他的任务掐掉,或者把他的实时输出整段读走。会话 id 是
 * s_<时间戳>_<随机数>,但它会出现在链接、日志、截图里,不是秘密,更不该当权限用。
 * 判据跟 guardSession 同一条(sessionAllowed):本人放行,管理员限同组织,没记归属的老会话照旧。
 */
function guardRun(req, res, id) {
  if (sessionAllowed(req.user, getSession(id))) return true;
  res.status(403).json({ ok: false, error: "这条对话不属于你" });
  return false;
}

/**
 * 一页只占一条直播连接:/api/chat/live 用到的几样东西。
 *
 * 2026-09-28 实测:同时跑 6 个对话,每个对话各占一条长连接(POST /api/chat 的 SSE、断线后的
 * /api/chat/stream),Chromium 对同一主机最多开 6 条 HTTP/1.1 连接——池子被占满,点开别的对话、
 * 点停止、开预览、预览里的 iframe 全排在后面干等,界面看着就是「点了没反应、右边一片白」。
 * 所以运行中的事件改成整页合用一条 /api/chat/live,按 sid 分流,几条对话都只占这一个连接。
 *
 * lastRuns:刚跑完的那一趟再留 60 秒。detach 模式下 POST 先回「收到了」、页面随后才来订阅,
 * 跑得快的任务(秒回、开头就报错)这中间可能已经收尾——不留着的话订上来只剩一句「没有这趟」,
 * 这一轮在页面上就是空白。
 * side:不进回放记录、页面却要用的事件(成果目录、目标卡、标题、收尾文件清单、清理提示、done),
 * 每种只留最新一条,订阅晚到或重连时补发;前端按内容去重,补几遍都不会重复画。
 */
const lastRuns = new Map(); // sessionId -> 最近一趟的 runState(跑完后再留 60 秒)
let liveRunSeq = 0;
const LIVE_SIDE = new Set(["dir", "goal", "title", "sweep", "files", "done"]);
function noteSide(run, ev) {
  if (!ev || !LIVE_SIDE.has(ev.type)) return;
  if (ev.type === "files" && (ev.changed || []).length) return; // 有变更的那批已经进了记录,回放里有
  if (!run.side) run.side = new Map();
  run.side.set(ev.type, ev);
}
/**
 * 第 k 条记录发出之前、记录里的位置:n = 已经记了几条,t = 最后一条是文本时它有多长(不是文本记 -1)。
 * 每帧都带上它,前端拿来校正自己的计数——以前全靠前端照着服务端的记账口径自己数,
 * 口径差一点(比如 files 事件)重连时就从错的位置补发,同一段过程画两遍。
 */
function livePos(events, k) {
  const prev = k > 0 ? events[k - 1] : null;
  return { n: k, t: prev && prev.type === "text" ? String(prev.delta || "").length : -1 };
}

let runtime; // MCP 启动后创建

app.get("/api/info", (_req, res) => {
  res.json({
    app: "openworkbuddy",
    version: require("./package.json").version,
    provider: llm.provider,
    model: llm.model,
    skills: runtime ? runtime.getSkills().map((s) => s.name) : [],
    experts: experts.map((e) => e.name),
    mcp_tools: mcpManager.toolDefs().length,
    agent_plugins_spec: require("./src/core/ext/plugins").SPEC_VERSION,
  });
});

// 版本 / 更新检查。查的是 GitHub Releases,最快 6 小时一次(force=1 强查)。
// 界面上「有没有新版」和「你这种装法怎么升」是一起给的——只报版本号等于没说。
const updater = require("./src/server/updater");
// Windows 安装版能在应用里直接下、直接装(auto-update.js,在主进程里)。别的装法 auto.supported=false,界面照旧只讲「怎么升」
async function autoUpdate(op) {
  const bridge = require("./src/platform/electron-bridge");
  try {
    if (bridge.isRemote()) {
      if (op === "state") return await bridge.call("update.state", {}, { timeoutMs: 5000 });
      if (op === "download") return await bridge.call("update.download", {}, { timeoutMs: 5000 });
      if (op === "install") return await bridge.call("update.install", {}, { timeoutMs: 5000 });
      throw new Error("不支持的更新操作");
    }
    if (bridge.mode() === "inproc") return await require("./src/desktop/auto-update").shared(require("electron"))[op]();
  } catch (e) { return { supported: false, why: "error", phase: "error", error: String(e.message || e) }; }
  return { supported: false, why: "no-shell" };
}
app.get("/api/update", async (req, res) => {
  const [d, auto] = await Promise.all([updater.checkUpdate({ force: req.query.force === "1" }), autoUpdate("state")]);
  res.json({ ...d, auto });
});
// 下载、安装只给管理员:个人桌面版那一个人就是管理员;多人的服务器上本来就没有这个能力(auto.supported=false)
app.get("/api/update/auto", async (_req, res) => res.json(await autoUpdate("state")));
const updateAllowed = (req, res) => {
  if (req.user && account.isAdmin(req.user)) return true;
  res.status(403).json({ error: "只有管理员能装更新" });
  return false;
};
app.post("/api/update/download", async (req, res) => {
  if (!updateAllowed(req, res)) return;
  res.json(await autoUpdate("download"));
});
app.post("/api/update/install", async (req, res) => {
  if (!updateAllowed(req, res)) return;
  res.json(await autoUpdate("install"));
});

// 跟 files 事件同一份清单,走后台线程翻目录(见 agent.js scanOutputs):输入框里敲 @ 就会拉它,
// 在主线程上同步走整棵树,那一下所有对话的流式输出一起停
app.get("/api/files", (req, res, next) => {
  try {
    const dir = req.query.root ? rootedPath(req, ".") : getWorkspaceDir();
    scanOutputs(dir).then((files) => res.json(files), next);
  } catch (error) { res.status(400).json({ error: error.message }); }
});

// 资料库「工作区」那一栏:一次列一层,像访达那样一层层点进去。/api/files 是「最近动过的 500 个、
// 最深 3 层」,拿它当全集的话,第 4 层往下和第 501 个往后的文件哪儿都找不到(细账在 src/domains/library/ws-browse.js 开头)。
// 根跟 /api/files 是同一个:getWorkspaceDir() 已经被 tenantScope 绑到这个人所属组织、当前项目的工作区上。
// dir 里的 ..、绝对路径、链接一律 400;文件夹已经没了回 404,前端据此退回根。
const wsBrowse = require("./src/domains/library/ws-browse");
const wsIndex = require("./src/domains/library/ws-index"); // 工作区清单:后台线程走树,过期了先给旧的
app.get("/api/files/tree", (req, res) => {
  try {
    const root = getWorkspaceDir();
    const out = wsBrowse.listDir(root, req.query.dir, { appDataDir: dataPath("data"), offset: req.query.offset });
    res.json({ ...out, root: workspaceKeyOf(root) });
  } catch (e) {
    res.status((e && e.status) || 500).json({ error: (e && e.message) || "读不了这个文件夹" });
  }
});

// 画布 / 短剧分镜表 / 一键合成三组路由在 src/server/routes/ 下,合成的任务队列在 src/domains/media/compose-jobs.js。
// 挂在这个位置不挪:登录、租户根、平台权限、脱敏这几道闸都在前面,过完了 getWorkspaceDir 才认得出是谁的工作区。
// 三份之间互相要用的几样(素材定位、读分镜表、等时钟走过版本号)也在这儿递过去,路由文件不回头 require server.js
app.use(canvasRoutes.createCanvasRouter({
  getWorkspaceDir, outputFiles, safePath, rootedPath, canvasList, canvasReadState, canvasWriteState, canvasNormalizeState, canvasSafeName,
  readDramaJson: dramaRoutes.readDramaJson,
}));
app.use(createCodeRouter({
  getWorkspaceDir,
  gitIn: (cwd, args, req, o) => require("./src/agent/tools").sandboxedGit(cwd, args, { sec: config.security, actor: req.user && req.user.username, ...o }),
  isBusy: (dir) => {
    const dirs = [...activeRuns.values()].map((r) => r.root);
    try { for (const r of cliLive.list({ prune: false })) if (r.live) dirs.push(r.cwd); } catch {}
    return dirs.some((d) => d && taskDirs.samePlace(d, dir));
  },
}));
app.use(createComposeRouter({
  jobs: createComposeJobs({
    getWorkspaceDir, outputFiles, safePath, shellPath, canvasReadState, canvasWriteState,
    assetBase: canvasRoutes.assetBase, canvasAssetLocator: canvasRoutes.canvasAssetLocator,
    canvasAssetNear: canvasRoutes.canvasAssetNear, canvasTickPast: canvasRoutes.canvasTickPast,
  }),
}));
app.use(dramaRoutes.createDramaRouter({
  getWorkspaceDir, outputFiles, safePath, account, org, budget, llm, llmForSession, unpricedChat, addUsage, toolRunSubdir, toolRunSubdirReady,
  canvasAssetNear: canvasRoutes.canvasAssetNear,
}));
app.use(libraryRoutes.createLibraryRouter({ libraryRootOf, rootedPath, rootOfResolved, getWorkspaceDir, safePathIn, workspaceKeyOf, thumbsDir: path.join(dataPath("data"), "thumbs"), busy: () => activeRuns.size > 0, libWalk: () => libWalk(), appDataDir: dataPath("data") }));
// 用户自己的 Chrome:扩展连的那条 WebSocket 在 main() 里挂(src/core/ext/chrome-link.js),这里只是设置页那几个接口。
// 「打开扩展文件夹」只给桌面版的本机主人:服务器版点了是在服务器上开窗口,没人看得见
app.use(createChromeRouter({
  canOpenFolder: (req) => !!(process.versions.electron && !process.env.ELECTRON_RUN_AS_NODE) && (admin.isSoloDesktop() || admin.platformAdmin(req.user)),
  openFolder: (dir) => openWithSystem(dir),
  extDir: appPath("extension", "chrome"),
  copyTo: dataPath("data", "chrome-extension"),
  packaged: () => require("./src/platform/paths").isPackaged(),
  desktop: !!process.versions.electron,
}));
app.use(createPromptTplsRouter({
  isSolo: admin.isSoloDesktop,
  canEditOrg: (u) => require("./src/domains/account/rbac").can(u, "admin.write"),
  orgIdOf: org.orgIdOf,
  orgName: (u) => (org.getOrg(org.orgIdOf(u)) || {}).name || "",
  audit: org.audit,
}));
app.use(createGeoRouter({ getConfig: () => config }));
// 只有这台机器的主人能导(个人桌面版或平台管理员):~/.claude、~/.codex 是跑服务器的那个系统账号的
// 保留已导入会话的续聊兼容;关闭新导入接口,不创建扫描器。
app.use(createSessionImportRouter({ isOwner: isPlatformOwner }));

// 助理身份:界面一进来就要拿它画头像,所以单开一个轻接口,不用为了个名字去拉整份设置
// 多人服务器上成员给自己的助理另起过名字、换过头像的,看到的是他自己那份(prefs.assistantFor)
app.get("/api/assistant", (req, res) => {
  if (!ownPrefs(req)) return res.json(config.assistant);
  const a = prefs.assistantFor(req.user && req.user.username, config);
  res.json({ ...config.assistant, name: a.name, avatar: a.avatar || config.assistant.avatar });
});
/**
 * 成员自己的助理:名字、头像、偏好,只影响他自己的对话(存 data/prefs/<账号>.json 的 assistant)。
 * 留空 = 跟全局那份走。偏好是加在全局偏好后面的,平台管理员定的那份照样带着。
 * 平台管理员和个人桌面版改的就是全局那份,走 POST /api/settings,这条不收。
 */
app.get("/api/my-assistant", (req, res) => {
  if (!ownPrefs(req)) return res.json({ applies: false });
  const mine = (prefs.read(req.user).assistant) || {};
  res.json({ applies: true, name: mine.name || "", avatar: mine.avatar || "", persona: mine.persona || "",
    default_name: config.assistant.name, default_avatar: config.assistant.avatar });
});
app.post("/api/my-assistant", (req, res) => {
  if (!ownPrefs(req)) return res.status(400).json({ error: "平台管理员改的是全局那份,在上面的卡里改" });
  const b = req.body || {};
  const old = (prefs.read(req.user).assistant) || {};
  const next = { ...old };
  if (b.name !== undefined) next.name = String(b.name || "").trim().slice(0, 24);
  try { if (b.avatar !== undefined) next.avatar = b.avatar ? (account._internals.normalizeAvatar(b.avatar) || "") : ""; }
  catch (e) { return res.status(400).json({ error: e.message }); }
  if (b.persona !== undefined) next.persona = String(b.persona || "").slice(0, 4000);
  prefs.write(req.user, { assistant: next });
  const a = prefs.assistantFor(req.user.username, config);
  res.json({ ok: true, name: a.name, avatar: a.avatar || config.assistant.avatar });
});

// ---------- 应用内设置(模型 + IM),保存到 config.json 并热生效 ----------
/**
 * 下拉框的数据源之一:精选模型目录。
 *
 * 为什么不把目录硬编在前端:目录会过期,而前端是缓存在浏览器里的,用户可能几个月都拿着旧的那份。
 * 放服务端,跟着版本走,升级一次就全对上了。
 */
app.get("/api/model-catalog", (req, res) => {
  // brand_hints 跟着一起下发:前端要在**保存之前**就认出「这个型号是别家的」,
  // 认的规矩必须和服务端是同一份,否则前端放行、后端自愈,用户看到的就是「我选了 A,存完变成 B」
  // kind_hosts(地址认家门那张表)同理:渠道类型选了官方、地址填的是别家,两边得判成同一家
  res.json(mediaModels.clientCatalog());
});

// 从渠道现拉一次模型清单,10 分钟内不重复拉(换渠道来回点几下不该把人家接口打一遍)
const modelListCache = new Map();
/**
 * 活列表:拿渠道自己的 /models 接口问「你这儿都有啥」。
 *
 * 精选目录只放确认跑得通的那几款,各家发新版比我们发版快得多;活列表补的就是这一段。
 * 拉不到不算错(很多国产渠道压根没有这个接口),前端照样有目录和手填两条路可走。
 */
app.post("/api/provider-models", async (req, res) => {
  // 出网请求 + 带着 Key,只有平台管理员能发起;成员那边界面本来也不画这个按钮
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "渠道归平台管理员配", platform_only: true });
  try {
    const b = req.body || {};
    // 和渠道测活共用凭据边界:改地址/协议时,绝不把库里那把 Key 送过去。
    const plan = chatModels.testPlan(config, { ...b, model: "" });
    if (plan.error) return res.json({ ok: false, why: plan.error, models: [] });
    const { base, key } = plan;
    const kind = plan.kind || mediaModels.guessKind(base);
    const api = mediaModels.protoOfChannel({ kind, api: plan.api });
    if (base && !/^https?:\/\//i.test(base)) return res.json({ ok: false, why: "接口地址得是 http(s) 开头的完整地址", models: [] });
    if (!base && !["anthropic", "gemini"].includes(api)) return res.json({ ok: false, why: "还没填接口地址", models: [] });
    const input = { base_url: mediaModels.baseForUse(base, "chat", kind), api_key: key, api };
    const cacheKey = modelDiscovery.cacheKey(input);
    const hit = modelListCache.get(cacheKey);
    if (b.refresh !== true && hit && Date.now() - hit.at < 600000) return res.json({ ...hit.result, cached: true });
    const result = await modelDiscovery.discover(input);
    if (!result.ok) return res.json(result);
    result.models = result.models.filter((m) => !systemOne.isDecisionModel(m.id));
    // 空清单不进缓存。它几乎不是稳定状态:Ollama 刚起来还没 pull 过东西、网关那头还在初始化,
    // 都会先回一个空的 data。缓住它,用户照着提示去 ollama pull 完回来点「重新问一次」,
    // 十分钟内拿到的还是那份空的——提示教他做的事做完了,界面上却没有任何变化
    if (result.models.length) {
      if (modelListCache.size >= 100) modelListCache.delete(modelListCache.keys().next().value);
      modelListCache.set(cacheKey, { at: Date.now(), result });
    }
    res.json(result);
  } catch (e) {
    // 超时、DNS 挂了、返回的不是 JSON——都算「这家没有列表」,不是错误页
    res.json({ ok: false, why: String(e.message || e).slice(0, 200), models: [] });
  }
});

/**
 * 渠道测活:拿这条渠道真发一次「ping」,把上游的回答翻成人话。
 *
 * 为什么非有不可:界面上一把 Key 填进去就显示「已配置」,可「填了」和「能用」是两回事——
 * 余额扣光了、Key 是别家的、模型名在这家不存在,界面全都看不出来,非要等某个任务跑到一半才炸。
 * probeModel 早就把 401/402/404/429 翻成了人话,只是一直只有开箱向导在用;这里把它摆到渠道卡上。
 *
 * 拿哪个模型去 ping:只认这条渠道底下已登记的(点名的必须在列表里,没点名拿第一个)。
 * 不再退到精选目录:那是用户没配过的型号,拿他的 Key 去打,猜错了报的 404 还会让人以为 Key 坏了。
 * 用存着的 Key 测时地址和类型不许改,要换地址就连 Key 一起重填。规矩在 chatModels.testPlan 一处。
 */
app.post("/api/provider-test", async (req, res) => {
  // 出网请求 + 带着 Key,跟 /api/provider-models 同一条规矩:只有平台管理员能发起
  if (!isPlatformOwner(req)) return res.status(403).json({ ok: false, error: "渠道归平台管理员配", platform_only: true });
  const b = req.body || {};
  const plan = chatModels.testPlan(config, b);
  if (plan.error) return res.json({ ok: false, error: plan.error });
  const { known, kind, base, key } = plan;
  const local = kind === "ollama" || /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(base);
  if (!key && !local) return res.json({ ok: false, error: "这个渠道还没填 Key,填完再测" });
  // 判断模型(Jev)没有 /chat/completions 这条路,拿它去 ping 必然 400。
  // 它有自己的测活:真问一道题,把答案也带回来——「通了」和「答得对不对」一次看完
  if ((mediaModels.PROVIDER_KINDS.find((k) => k.kind === kind) || {}).decide_only) {
    const t = Date.now();
    // 型号跟真跑时同一个:config.decide.model 钉过就测钉的那个,没钉才是出厂型号
    const pinned = String((config.decide || {}).model || "").trim();
    const r = await jev.selftest({ providers: [{ id: known.id || "tmp", kind, base_url: base, api_key: key }], ...(pinned ? { decide: { model: pinned } } : {}) }, { timeoutMs: 20000 });
    return res.json({
      ok: !!r.ok, ms: Date.now() - t, model: r.model || "",
      error: r.ok ? "" : r.error || "没答上来",
      answers: r.ok ? r.answers.map((a) => systemOne.lineOf(a)) : [],
    });
  }
  const api = mediaModels.protoOfChannel({ kind, api: plan.api });
  if (!["anthropic", "gemini"].includes(api) && !/^https?:\/\//i.test(base)) return res.json({ ok: false, error: "接口地址得是 http(s) 开头的完整地址" });
  const model = plan.model;
  if (!model) {
    // 专门挂生图 / 生视频的渠道底下本来就一个对话模型都没有。以前这儿直接甩一句
    // 「还没有对话模型」,等于告诉人「你这条渠道没法测」——可它明明配好了、也在用。
    // 这种渠道改走不花钱的清单测活:Key 认不认、模型名在不在,一样能测出来。
    // 挂在对话列表里、看名字却是生图 / 视频 / 配音的型号(testPlan 的 skipped)同样走这条,
    // 绝不拿它发对话请求:上游回的 400 只会让人以为 Key 或地址坏了
    const media = (config.media_models || []).filter((m) => m.provider === known.id);
    const odd = (plan.skipped || [])[0];
    if (odd || media.length) {
      const t = Date.now();
      const hit = odd ? {} : (mediaModels.resolve(config).list || []).find((x) => x.id === media[0].id) || {};
      const name = odd || media[0].model;
      const cap = odd ? mediaModels.mediaCapOf(odd) : media[0].cap;
      const capCn = mediaModels.CAP_CN[cap] || cap;
      const r = await probeMediaModel({ base_url: hit.base_url || mediaModels.baseForUse(base, "media", kind), api_key: hit.api_key || key, model: name, capCn, kind });
      const pre = odd ? `「${odd}」看名字是${capCn},没拿它发对话请求。` : "";
      return res.json({ ok: !r.error, ms: Date.now() - t, model: name, partial: !!r.partial || !!odd, note: r.note ? pre + r.note : "", error: r.error ? pre + r.error : "" });
    }
    return res.json({ ok: false, error: "这个渠道下面还没挂任何模型。加一个再测——测活要拿一个真模型去打一次招呼,瞎猜一个名字测出来的 404 会让人误以为 Key 坏了" });
  }
  const t0 = Date.now();
  // 地址换算跟真跑对话同一份(baseForUse):百炼渠道存的是原生 /api/v1,对话在 /compatible-mode/v1。
  // 拿原地址直接拼 /chat/completions,测的就不是真跑时那个地址
  const why = await probeModel({ provider: api, base_url: mediaModels.baseForUse(base, "chat", kind), api_key: key, model }, { custom: ownBase(kind, base) });
  res.json({ ok: !why, ms: Date.now() - t0, model, error: why || "" });
});

/**
 * 生图 / 生视频 / 配音 / 转写这四路的测活:**不真生成**。
 *
 * 为什么不像对话那样真跑一次:生一次视频的钱够 ping 一千次,生一张图也不便宜。
 * 一颗写着「测一下」的按钮,不该在人没预期的时候扣一笔——尤其它常常要连点好几次
 * (改个模型名再测、换条渠道再测)。所以这儿只验两件不花钱的事:
 *   ① 这把 Key 上游认不认(拿模型清单就知道,401/403 立刻现形)
 *   ② 配的那个模型名在这家的清单里有没有(「模型不存在」是排第二常见的坑)
 * 验不到的那件必须说出来,不许拿「✓ 通了」糊过去:余额够不够、这个模型让不让你调,
 * 只有真生成一次才知道。含糊其辞的绿勾比红叉更坑人——人会拿它当「已经能用」。
 */
async function probeMediaModel({ base_url, api_key, model, capCn, kind }) {
  const base = String(base_url || "").trim().replace(/\/+$/, "");
  if (!/^https?:\/\//i.test(base)) return { error: "这条渠道的接口地址不是 http(s) 开头的完整地址" };
  // 百炼原生的清单(GET /api/v1/models)是分页的:一页 20 个、一共一百多个,要测的型号多半不在第一页。
  // 官方给了按型号 ID 精确查的参数 model(https://help.aliyun.com/zh/model-studio/list-models),原生地址上带上它
  const byId = !!model && /\/api\/v1$/i.test(base) && (kind === "dashscope" || /dashscope|\.maas\.aliyuncs\.com/i.test(base));
  let r;
  try {
    r = await fetch(`${base}/models${byId ? "?model=" + encodeURIComponent(model) : ""}`, {
      headers: api_key ? { Authorization: `Bearer ${api_key}` } : {},
      signal: AbortSignal.timeout(15000),
    });
  } catch (e) {
    const msg = String((e && e.message) || e);
    if (/timeout|abort/i.test(msg)) return { error: "连不上(15 秒超时)。国外服务商在国内直连经常打不通,挂代理或换国产渠道" };
    return { error: "连不上:" + msg.slice(0, 160) };
  }
  // 不是 2xx:状态码和上游原文照搬。403 不替人下结论——百炼上它常是「这把 Key 没开这个型号 / 这条路」,不等于 Key 错了
  const said = r.ok ? "" : String(await r.text().catch(() => "")).replace(/\s+/g, " ").trim().slice(0, 200);
  const orig = said ? `。上游原文:${said}` : "";
  if (r.status === 401) return { error: `这个 Key 上游不认(HTTP 401),检查有没有复制全、是不是这家服务商的 Key${orig}` };
  if (r.status === 403) return { error: `上游拒绝了这次请求(HTTP 403)${orig}` };
  if (r.status === 429) return { error: `被限流了(429),等一会儿再试${orig}` };
  // 给不出清单不等于坏了:不少专做生图/生视频的接口根本没有 /models 这条路。
  // 这种情况老老实实说「只验到这儿」,不编一个绿勾出来
  if (!r.ok) return { partial: true, note: `这条渠道不给模型清单(HTTP ${r.status}),只验到「地址是通的」。Key 对不对、${capCn}「${model}」在不在,得真生成一次才知道${orig}` };
  const j = await r.json().catch(() => null);
  // 清单的形状:OpenAI 兼容是 data[].id;百炼原生是 output.models[].model(那里的 name 是中文显示名,不是型号)
  const out = (j && j.output) || {};
  const raw = (j && (Array.isArray(j.data) ? j.data : Array.isArray(j.models) ? j.models : Array.isArray(out.models) ? out.models : [])) || [];
  const ids = raw.map((m) => (typeof m === "string" ? m : String((m || {}).id || (m || {}).model || (m || {}).name || ""))).filter(Boolean);
  if (!ids.length) {
    return { partial: true, note: byId
      ? `Key 这一关过了,按型号查清单没查到${capCn}「${model}」。名字对不对,得真生成一次才知道`
      : `Key 这一关过了(清单接口没拒绝我们),但这条渠道一个模型都没列出来,没法核对${capCn}「${model}」这个名字` };
  }
  if (!ids.includes(model)) {
    // 分了页的清单只回了一页:这一页里没有,不等于这条渠道没有
    const total = Number(out.total);
    if (total > ids.length) return { partial: true, note: `Key 这一关过了。清单分了页(这一页 ${ids.length} 个、一共 ${total} 个),这一页里没有${capCn}「${model}」,没法下结论` };
    return { error: `Key 是好的,但这条渠道列出来的 ${ids.length} 个模型里没有「${model}」。去 设置 → 模型 把它改成清单里的名字(在模型下拉框里挑,别手打)` };
  }
  if (byId) return { note: `Key 认了,按型号查清单查到了「${model}」。这一步不花钱所以没真生成——余额够不够,得生成一次才知道` };
  return { note: `Key 认了,「${model}」在这条渠道的清单里(一共 ${ids.length} 个)。这一步不花钱所以没真生成——余额够不够,得生成一次才知道` };
}

/**
 * 一行一测:设置 → 模型 里每一行模型后面那颗「测」。
 *
 * 跟渠道那颗「测一下」的分工:渠道那颗问的是「这条线通不通」,这颗问的是「**这一行**能不能用」。
 * 一条渠道下面挂五个模型,通的是渠道、挂的却可能有三个模型名是错的——
 * 只有渠道级测活的话,那三个要等任务跑到一半才炸。
 */
app.post("/api/model-test", async (req, res) => {
  // 出网请求 + 带着 Key,跟 /api/provider-test 同一条规矩
  if (!isPlatformOwner(req)) return res.status(403).json({ ok: false, error: "渠道归平台管理员配", platform_only: true });
  const t0 = Date.now();
  try {
    const b = req.body || {};
    const isMedia = b.scope === "media";
    const list = isMedia ? config.media_models || [] : config.models || [];
    const m = list[Number(b.index)];
    if (!m) return res.json({ ok: false, error: "这一行已经不在了(多半是刚删过或者别处改了配置),刷新一下再试" });
    // 测得通也要说:这个型号下个月就没了,现在能用不代表能继续用
    const retire = retiredModels.noteOf(retiredModels.retirementOf(m.model));
    const reply = (/** @type {any} */ o) => res.json(!retire ? o : { ...o, retire, error: o.error ? `${retire}(${o.error})` : "", note: o.ok ? `${o.note ? o.note + "。" : ""}注意:${retire}` : o.note });
    const chanId = isMedia ? m.provider : m.channel;
    const p = (config.providers || []).find((x) => x.id === chanId);
    if (!p) return res.json({ ok: false, error: chanId ? "这一行挂的渠道已经被删了,编辑它重新挑一条" : "这一行没挂渠道,编辑它挑一条渠道再测" });
    const local = p.kind === "ollama" || /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(p.base_url || "");
    if (!p.api_key && !local) return res.json({ ok: false, error: `渠道「${p.name}」还没填 Key,先去下面那张渠道卡里填上` });
    if (isMedia) {
      // 走 resolve 而不是直接读 p.base_url:生图那几路的地址跟对话不一定是同一个前缀,
      // 换算规则只有 media-models 那一份,这儿抄一遍迟早跟真跑的时候对不上
      const hit = (mediaModels.resolve(config).list || []).find((x) => x.id === m.id) || {};
      const capCn = mediaModels.CAP_CN[m.cap] || m.cap;
      const r = await probeMediaModel({ base_url: hit.base_url || p.base_url, api_key: hit.api_key || p.api_key, model: m.model, capCn, kind: p.kind });
      return reply({ ok: !r.error, ms: Date.now() - t0, model: m.model, partial: !!r.partial, note: r.note || "", error: r.error || "" });
    }
    // 对话列表里混进了生图 / 视频 / 配音的型号:不拿它发对话请求,改走不花钱的清单测活,结论只给半格
    const odd = mediaModels.mediaCapOf(m.model);
    if (odd) {
      const capCn = mediaModels.CAP_CN[odd] || odd;
      const r = await probeMediaModel({ base_url: mediaModels.baseForUse(p.base_url, "media", p.kind), api_key: p.api_key, model: m.model, capCn, kind: p.kind });
      const pre = `「${m.model}」看名字是${capCn},没拿它发对话请求。`;
      return reply({ ok: !r.error, ms: Date.now() - t0, model: m.model, partial: true, note: r.note ? pre + r.note : "", error: r.error ? pre + r.error : "" });
    }
    const why = await probeModel({ provider: mediaModels.protoOfChannel(p), base_url: mediaModels.baseForUse(p.base_url, "chat", p.kind), api_key: p.api_key, model: m.model }, { custom: ownBase(p.kind, p.base_url) });
    reply({
      ok: !why, ms: Date.now() - t0, model: m.model, error: why || "",
      note: why ? "" : `「${m.model}」在渠道「${p.name}」上答得上话`,
    });
  } catch (e) {
    res.json({ ok: false, ms: Date.now() - t0, error: String((e && e.message) || e).slice(0, 200) });
  }
});

/**
 * 判断模型(Jev):一段状态 + 几道有类型的题 → 几个代码能直接用的值。
 *
 * 为什么它不在 /api/chat 那条路上:那条路是「拿文字」,这条是「拿主意」。
 * Jev 不会写字,它返回的是选中的选项、一个分数、或者一个 0~1 的概率,外加一个确定度。
 * 混进对话接口只会让两边的返回都得多带一个「这次其实是另一种东西」的标记。
 *
 * GET 只说「现在能不能用、走的哪条路」,**不带 Key**——这一条给设置页和 doctor 看。
 */
app.get("/api/decide", (req, res) => {
  res.json({
    ...jev.status(config),
    kinds: systemOne.KINDS, kind_cn: systemOne.KIND_CN,
    routes: systemOne.ROUTE_IDS.map((id) => ({ ...systemOne.ROUTES[id] })),
    max_state: systemOne.MAX_STATE, max_questions: systemOne.MAX_QUESTIONS, sure_min: systemOne.SURE_MIN,
  });
});

/**
 * 真问一趟。
 *
 * 额度按**题数**算,不按请求数:一次请求里塞 30 道题和塞 1 道题,上游收的钱差 30 倍,
 * 按请求记的话「一天 800 次」这道闸拦不住任何东西——一次请求就能把一天的量跑完。
 */
app.post("/api/decide", async (req, res) => {
  const b = req.body || {};
  const { questions, errs } = systemOne.normalizeQuestions(b.questions);
  if (errs.length) return res.status(400).json({ ok: false, error: errs.join(";") });
  // 材料也在这儿查,不留到 jev.ask 里边——不然「既没材料又没配渠道」的那一趟会先被说成
  // 「去配渠道」,人配完回来还是同一个 400。请求本身不对,跟配没配渠道没关系
  const stateText = typeof b.state === "string" ? b.state.trim() : b.state;
  if (!stateText || (typeof stateText === "object" && !Object.keys(stateText).length)) {
    return res.status(400).json({ ok: false, error: "没给它要判断的东西(state)——问题问得再清楚,没有材料它也判断不了" });
  }
  const out = await jev.askMetered(config, { state: b.state, questions, model: b.model, timeoutMs: Number(b.timeout_ms) || 0 }, { meta: Object.keys(questions).join("、") });
  if (!out.ok) {
    if (out.quota) {
      security.audit("额度拦截", "判断模型:" + out.error, "拦截");
      return res.status(429).json({ ok: false, error: out.error, quota: true });
    }
    // 「没配」这一类必须带上 how:界面上就这一句话,只说「还没有能用的渠道」等于没说
    return res.status(out.notReady ? 503 : out.badRequest ? 400 : 502)
      .json({ ok: false, error: out.error + (out.how ? "。" + out.how : ""), ms: out.ms || 0, not_ready: !!out.notReady });
  }
  res.json({
    ...out,
    lines: out.answers.map((a) => systemOne.lineOf(a)),
    cost: systemOne.costOf(out.usage),
    cost_text: systemOne.costText(out.usage),
  });
});

/** 一键测活:拿一段固定材料真问三道题。设置页那个「测一下」和命令行的 `openworkbuddy jev` 走的是同一条 */
app.post("/api/decide/selftest", async (req, res) => {
  const g = quota.gate("decide", { n: 3, provider: (jev.status(config) || {}).route || "" });
  if (!g.ok) return res.status(429).json({ ok: false, error: g.why, quota: true });
  const out = await jev.selftest(config);
  if (!out.ok) { quota.undo(g.hold); return res.status(502).json({ ok: false, error: out.error }); }
  quota.record("decide", { n: 3, provider: out.route || "", model: out.model || "", meta: "测活", hold: g.hold });
  res.json({ ...out, lines: out.answers.map((a) => systemOne.lineOf(a)), cost_text: systemOne.costText(out.usage) });
});

/**
 * 四路媒体的老扁平配置(tools.js 读的那份)发给界面之前,按身份把 Key 掩掉。
 * 掩成一串星号而不是空串:空串会被界面读成「还没配」,于是有人会去重填一遍——
 * 而他根本没有改这台服务器 Key 的权限,白填一次还要吃一个 403。
 */
function maskMedia(media, owner) {
  const out = {};
  for (const cap of mediaModels.CAPS) {
    const c = { base_url: "", api_key: "", model: "", ...(cap === "tts" ? { voice: "" } : {}), ...((media || {})[cap] || {}) };
    out[cap] = { ...c, api_key: owner ? c.api_key || "" : (c.api_key ? "********" : ""), has_key: !!c.api_key };
  }
  return out;
}

// 升级整理做了什么。动过用户的文件就必须让他知道,而且要说清搬到哪儿了、原件还在。
// 界面看过一次就记下 id,不再打扰;这里不存「看过没有」,那是每台机器自己的事
app.get("/api/migrations", (_req, res) => res.json({ notes: global.__wbMigrationNotes || [] }));

function embeddingView(req) {
  const e = config.embedding || {};
  const own = !e.provider; // 单独填的一组地址 / Key;点名渠道的那种 Key 在渠道表里,这儿不回
  const prov = e.provider ? (config.providers || []).find((p) => p && String(p.id) === String(e.provider)) : null;
  return {
    provider: e.provider ? String(e.provider) : "",
    base_url: own ? e.base_url || "" : "", model: e.model || "",
    api_key: own && e.api_key ? "********" : "",
    key_hint: own && isPlatformOwner(req) ? keyHint(e.api_key) : "",
    has_key: own && !!e.api_key,
    // 能挑的已配渠道(只聊天 / 只做媒体的不在里面),只回名字和型号建议
    channels: embedChannels(config),
    // 选的那条渠道被删了、或换成了算不了向量的类型:界面照实说,让人重选
    missing: e.provider && !(prov && embedHostable(prov.kind)) ? String(e.provider) : "",
  };
}

/** 启动时改挂的那批只回一次:回过就清,不然每进一次设置页都弹同一句 */
function takeMovedOnBoot() {
  const m = movedOnBoot;
  movedOnBoot = [];
  return m;
}
function modelRetireNotes() {
  const seen = new Set();
  const out = [];
  for (const m of [...(config.models || []), ...(config.media_models || [])]) {
    const id = String((m && m.model) || "");
    if (!id || seen.has(id)) continue;
    seen.add(id);
    const r = retiredModels.retirementOf(id);
    if (r && (r.gone || r.soon)) out.push({ name: m.name || id, model: id, gone: r.gone, days: r.days, to: r.to, note: retiredModels.noteOf(r) });
  }
  return out.sort((a, b) => Number(b.gone) - Number(a.gone) || a.days - b.days);
}
app.get("/api/settings", (req, res) => {
  // 个人偏好压在全局配置上面。没有个人偏好文件时这几个 *Cfg 原样返回 config 的那一份
  const myAgent = prefs.agentCfg(config);
  const myPet = prefs.petCfg(config);
  const myModel = prefs.modelCfg(config);
  // 跟真跑的时候同一个判断:成员没选过、管理员那个本机引擎又没给他打开,这里就报内置
  const myEngine = engines.currentId(config, { user: req.user && req.user.username });
  const lay = taskDirs.folderLayout(getWorkspaceDir(), layoutAnchors());
  res.json({
    workspace_dir: getWorkspaceDir(),
    // 用的是自己那份工作目录(不止一个账号、又不是老主人):界面上不给换文件夹;项目是他自己那份(/api/projects 回 own)
    workspace_personal: !admin.keepsSharedSpace(req.user),
    // 默认工作空间里每个对话各有一个成果文件夹:成果区据此默认只摆「本对话」那一格
    workspace_is_default: path.resolve(getWorkspaceDir()) === dataPath("workspace"),
    workspace_per_chat: perChatHere(), // 成果面板靠它决定新对话先摆「本对话」那一格,还是整个目录摊开
    // 设置页那组单选按它画:每个对话一个文件夹 / 直接放进去;locked = 应用自己的目录,只能按对话分
    workspace_layout: lay.layout,
    workspace_layout_locked: lay.locked,
    // Key 从不发原文,谁来问都一样:回一串八颗星(= 「没改」的暗号)+ has_key(配没配)。
    // 平台管理员多拿一个 key_hint(sk-…4f2a)用来认「我装的是哪一把」;普通成员连这截也没有——
    // 那是整台服务器的账单凭证,他既改不了也不该拿到手。
    // 管理员也一样掩:八颗星是「原样传回来 = 没改」的约定暗号(POST 那边认这个正则),
    // 界面真正显示的是 key_hint 那一小截
    // 成员只看得到共享给他的那几家,外加他自己填 Key 的(own: true,Key 同样不回原文)
    models: memberModels.listFor(config, isPlatformOwner(req) ? { owner: true } : modelWho(req.user)).map((m) => ({
      ...m,
      team_available: !m.own && memberModels.sharedToMembers(config, m),
      api_key: m.api_key ? "********" : "",
      key_hint: isPlatformOwner(req) ? keyHint(m.api_key) : "",
      has_key: !!m.api_key,
    })),
    // 成员的「全局默认」:管理员那个没共享给他时,是他能用的第一个(跟 llmForSession 一个算法)
    active_model: isPlatformOwner(req) ? config.active_model : ((memberModels.defaultFor(config, modelWho(req.user)) || {}).name || ""),
    // 成员能不能自己填 Key;平台管理员在模型页那张「成员的模型」卡片上开关
    member_byok: memberModels.byokAllowed(config),
    session_shares: config.session_shares === true && admin.multiUser(),
    model_health: healthSummary(),
    // 配着的模型里,服务商已经下线或快下线的。在设置页说,别等任务跑到那一步才报错
    model_retire: isPlatformOwner(req) ? modelRetireNotes() : [],
    // 界面靠它决定「服务器级的那些输入框画不画」:画了却一存就 403,比不画更气人
    platform_owner: isPlatformOwner(req),
    // 「助理设置」那页成员也画:他绑的是自己那套通道(/im/my/*)
    im_own: !!(req.user && !admin.isSoloDesktop() && !ownsGlobalWorkspace(req.user)),
    model_follow_last: myModel.model_follow_last,
    last_picked_model: myModel.last_picked_model,
    assist_model: myModel.assist_model,
    assist_engine: assistEngineOf(req.user),
    agent: {
      max_steps: config.agent.max_steps,
      tool_timeout_ms: config.agent.tool_timeout_ms,
      max_runtime_ms: config.agent.max_runtime_ms || 1800000,
      auto_continue_rounds: config.agent.auto_continue_rounds || 0,
      gen_parallel_max: config.agent.gen_parallel_max || 2,
      max_parallel_tasks: config.agent.max_parallel_tasks || 0,
      auto_parallel_tasks: require("./src/core/automation/run-governor").autoTasks(require("os").totalmem()),
      llm_timeout_ms: config.agent.llm_timeout_ms || 300000,
      max_context_chars: config.agent.max_context_chars || 0, // 0 = 没设,按模型窗口算(agent.js contextBudgetChars)
      max_tokens_budget: config.agent.max_tokens_budget || 0,
      second_opinion: !!config.agent.second_opinion,
      push_gate: !!config.agent.push_gate,
      continue_gate: !!config.agent.continue_gate,
      memory_gate: !!config.agent.memory_gate,
      ask_gate: !!config.agent.ask_gate,
      skill_gate: !!config.agent.skill_gate,
      // 上面这两个开关共用一面旗子:没配判断模型的时候,它们打开也不会生效,别让界面假装能开
      judge_ready: jev.status(config).ready,
      failover_model: config.agent.failover_model || "",
      thinking: thinking.norm(myAgent.thinking), // 思考模式档位,默认 auto=跟随模型自己的默认
      engine: myEngine,
      // 前端那个模型选择器要靠它说实话:走本机 CLI 的时候,API 模型列表整个不生效
      engine_label: (engines.list().find((e) => e.id === myEngine) || {}).label || "",
      engine_options: myAgent.engine_options || {},
    },
    pet: {
      enabled: myPet.enabled === true, // 默认没有宠物:得用户在对话里开口要,或来这儿手动打开
      character: myPet.character || "cat",
      scale: myPet.scale || pet.DEFAULT_SCALE,
      opacity: myPet.opacity || 1,
      notify: myPet.notify !== false,
      notify_done: myPet.notifyDone !== false,
      wander: myPet.wander === true,
      sprite: myPet.sprite || "",
      has_photo: !!petPhotoPath(),
      // 本机装了哪些精灵图宠物(~/.codex/pets、~/.petdex/pets、data/pets)。
      // 扫的是文件头不是整张图,几毫秒的事,跟设置一起返回省一次往返。
      sprites: petSprites.scanPets().map((x) => ({ id: x.id, name: x.displayName, source: x.source, ok: x.ok, why: x.why })),
      available: !!global.__openworkbuddyPet, // 纯 node 模式没有桌面窗口,前端要如实说明
    },
    persona: config.persona || "",
    assistant: config.assistant,
    search: {
      provider: (config.search || {}).provider || "",
      jina_key: (config.search || {}).jina_key || (config.search || {}).api_key || "",
      tavily_key: (config.search || {}).tavily_key || "",
      brave_key: (config.search || {}).brave_key || "",
      bocha_key: (config.search || {}).bocha_key || "",
      zhipu_key: (config.search || {}).zhipu_key || "",
      qiniu_key: (config.search || {}).qiniu_key || "",
      serper_key: (config.search || {}).serper_key || "",
      custom_key: (config.search || {}).custom_key || "",
      custom_url: (config.search || {}).custom_url || "",
      custom_query_field: (config.search || {}).custom_query_field || "",
    },
    // 行程卡的地图:用哪家查地点、高德 Web 服务 Key、每月打高德的上限(搜索、路线分开)、国内路线默认用哪家打开。
    // 上限给的是折算后的:老配置只有「每天上限」,怎么折见 places.settingsOf
    map: {
      provider: (config.map || {}).provider || "auto",
      amap_key: (config.map || {}).amap_key || "",
      amap_search_cap: geoPlaces.settingsOf(config).caps.search,
      amap_route_cap: geoPlaces.settingsOf(config).caps.route,
      nav: geoPlaces.settingsOf(config).nav,
    },
    im: {
      feishu: (config.im || {}).feishu || { app_id: "", app_secret: "", verification_token: "", group_reply_mode: "mention" },
      qq: (config.im || {}).qq || { app_id: "", app_secret: "" },
      wecom_app: (config.im || {}).wecom_app || { corp_id: "", agent_id: "", secret: "", token: "", aes_key: "" },
      wechat_mp: (config.im || {}).wechat_mp || { app_id: "", app_secret: "", token: "", aes_key: "" },
      smtp: (config.im || {}).smtp || { host: "", port: "", user: "", pass: "", from: "", allow_to: "" },
      // iLink 的 bot_token 是扫码换来的长期凭证,不回给前端(前端只需要知道连没连上,状态走 /im/status)
      wechat_ilink: { bot_id: ((config.im || {}).wechat_ilink || {}).ilink_bot_id || "" },
      wecom_bot_webhook: (config.im || {}).wecom_bot_webhook || "",
      dingtalk_webhook: (config.im || {}).dingtalk_webhook || "",
      dingtalk_secret: (config.im || {}).dingtalk_secret || "",
      webhook_secret: (config.im || {}).webhook_secret || "",
      session_idle_hours: +(config.im || {}).session_idle_hours || 0,
    },
    media: maskMedia(config.media, isPlatformOwner(req)),
    // 嵌入接口(语义召回)。Key 跟别的一样只回八颗星 + has_key
    embedding: embeddingView(req),
    // 渠道表:一把 Key 一行,图/视频/语音/视觉都引用它。普通成员看得见有哪些渠道,但看不到 Key——
    // 那是整台服务器的账单凭证,他既改不了也不该拿到手
    // 没共享给成员的渠道,成员连名字都不列(对话模型那边同样滤掉了,见 member-models.js)
    providers: (config.providers || []).filter((p) => isPlatformOwner(req) || p.share_members !== false).map((p) => ({
      id: p.id, name: p.name, kind: p.kind, base_url: p.base_url, api: p.api || "",
      share_members: p.share_members !== false,
      image_api: p.image_api || "", video_api: p.video_api || "", tts_api: p.tts_api || "", asr_api: p.asr_api || "",
      // 模板里可能写着请求头,只给管理员看
      ...(isPlatformOwner(req) && p.http_api ? { http_api: p.http_api } : {}),
      api_key: p.api_key ? "********" : "",
      key_hint: isPlatformOwner(req) ? keyHint(p.api_key) : "",
      has_key: !!p.api_key,
    })),
    media_models: config.media_models || [],
    // 启动时替用户改挂过的媒体模型,只回一次。只给平台管理员:改的是整台机器的配置
    moved_on_boot: isPlatformOwner(req) ? takeMovedOnBoot() : [],
    security: config.security,
    // 系统沙箱眼下立没立起来、为什么:开着却没生效时界面要说出来,不能只摆一个「已开启」
    sandbox_status: require("./src/agent/tools").sandboxStatus(),
    // 安全中心「命令能看到的环境变量」那张卡要照实说:像 Key 的名字现在写进清单给不给,看的是账号数
    env_keys_pass: !admin.multiUser(),
    // 沙箱安全总开关只有一个账号时才认(security.sandboxOff),多个账号时设置页置灰并说明
    solo: !admin.multiUser(),
    shortcuts: prefs.shortcutsCfg(config),
    // 执行追踪。私钥跟别的 Key 一个待遇:只有平台管理员看得见原文,其余人拿到八个星号。
    // stats 是**实打实的上报账本**(发出去多少、丢了多少、上一次为什么失败)——
    // 没有它,"开了但一条都没到" 和 "开了且正常" 在界面上长得一模一样
    langfuse: {
      enabled: !!(config.langfuse || {}).enabled,
      host: (config.langfuse || {}).host || "https://cloud.langfuse.com",
      public_key: (config.langfuse || {}).public_key || "",
      secret_key: isPlatformOwner(req)
        ? (config.langfuse || {}).secret_key || ""
        : ((config.langfuse || {}).secret_key ? "********" : ""),
      has_secret: !!(config.langfuse || {}).secret_key,
      stats: tracing.getTracer(config).stats(),
    },
  });
});

/**
 * 个人偏好落盘:底层引擎、思考档、上次选的模型、宠物开关、全局快捷键。
 *
 * 校验一条都不能省。这些值不是摆设——引擎名写错会让任务在启动那一刻就炸,思考档写错
 * 会让「我明明关了思考」和账单对不上。所以宁可当场 400,也绝不悄悄退回默认值。
 * 抛出去的错由 /api/settings 那层的 try/catch 变成 400,跟全局设置的错误路径同一条。
 */
/**
 * 这次的「个人偏好」该落哪儿。
 *
 * false(落 config.json):个人桌面版——屏幕前就一个人,config 就是他的偏好;而且 Electron 壳
 *   在任何人登录之前就要靠 config.pet / config.shortcuts 把宠物和快捷键装起来,写别处等于重启后消失。
 *   平台管理员同理:他设的那份是这台机器的默认,定时任务和 IM 消息没有「当前登录的人」,只能读 config。
 * true(落 data/prefs/<账号>.json):多人服务器上的其他人,谁也不覆盖谁。
 */
/**
 * 设全局默认模型(设置页点「主用」、首次向导)。
 *
 * 这也是一次「手动选模型」,得顺手记进 last_picked_model。不记的话,开着「新对话沿用上次
 * 手动选的模型」时,上次在输入框里点过的那个永远压着:设置里刚把主用换成 A、点新对话,
 * 标签和真跑的都还是之前输入框里挑的 B——设置页上的选择等于没用。
 * 只动 config 那份:能改全局默认的就是平台管理员,他的个人偏好本来就存在 config 里。
 */
function setGlobalModel(name) {
  config.active_model = name;
  config.last_picked_model = name;
}

/** 属主给成员放行的型号:数组或逗号/换行分隔的一串都收,去空去重,最多 50 个 */
function engineModelList(v) {
  const raw = Array.isArray(v) ? v : String(v == null ? "" : v).split(/[,,\n]/);
  const out = [];
  for (const x of raw) {
    const s = String(x == null ? "" : x).trim().slice(0, 120);
    if (s && !out.includes(s)) out.push(s);
  }
  return out.slice(0, 50);
}

function ownPrefs(req) {
  return !(admin.isSoloDesktop() || ownsGlobalWorkspace(req && req.user));
}

function savePersonalPrefs(user, personal) {
  const out = {};
  if (personal.agent) {
    const a = {};
    if (personal.agent.engine !== undefined) {
      const id = String(personal.agent.engine || "builtin").trim() || "builtin";
      if (engines.get(id) === undefined) throw new Error("没有这个底层引擎:" + id);
      // 多人共用时外部引擎要管理员逐个给别的账号打开;没打开的现在就说,别等开跑才报(这一段只有成员走得到)
      if (id !== "builtin" && security.isMultiUser() && !engines.gateView(id, config).enabled) {
        throw new Error(`${engines.get(id).label || id}要平台管理员打开才能用,也可以在 ${engineGate.WHERE} 切回内置引擎。`);
      }
      a.engine = id;
    }
    if (personal.agent.thinking !== undefined) {
      const lv = String(personal.agent.thinking || "").trim().toLowerCase();
      if (!thinking.LEVELS.includes(lv)) throw new Error("没有这个思考模式档位:" + lv);
      a.thinking = lv;
    }
    if (personal.agent.engine_options && typeof personal.agent.engine_options === "object") {
      const eo = {};
      for (const [id, v] of Object.entries(personal.agent.engine_options)) {
        if (engines.get(id) === undefined) continue; // 前端可能带上已经不存在的引擎,忽略即可,不值得整单失败
        const cur = {};
        if (v.model !== undefined) {
          // 成员自己挑的型号必须落在属主放行的列表里;空串 = 跟属主钉的那个走
          const m = String(v.model || "").trim();
          const allowed = engines.gateView(id, config).allowed;
          const name = engines.get(id).label || id;
          if (m && !allowed.length) throw new Error(`属主还没给${name}放行型号,请平台属主在 ${engineGate.WHERE} 里填。`);
          if (m && !allowed.includes(m)) throw new Error(`型号「${m}」不在属主给${name}放行的列表里,从列表里选一个。`);
          cur.model = m;
        }
        if (v.thinking !== undefined) {
          const lv = String(v.thinking || "").trim().toLowerCase();
          if (lv && !thinking.LEVELS.includes(lv)) throw new Error("没有这个思考模式档位:" + lv);
          cur.thinking = lv; // 空串 = 这个引擎跟随全局档位
        }
        if (Object.keys(cur).length) eo[id] = cur;
      }
      if (Object.keys(eo).length) a.engine_options = eo;
    }
    if (Object.keys(a).length) out.agent = a;
  }
  if (typeof personal.model_follow_last === "boolean") out.model_follow_last = personal.model_follow_last;
  if (personal.last_picked_model !== undefined) {
    const n = String(personal.last_picked_model || "").trim();
    if (n && !(config.models || []).some((m) => m.name === n)) throw new Error(`模型「${n}」不在模型列表里`);
    out.last_picked_model = n;
  }
  if (personal.pet && typeof personal.pet === "object") {
    const q = {};
    for (const k of ["enabled", "notify", "notifyDone", "wander"]) if (typeof personal.pet[k] === "boolean") q[k] = personal.pet[k];
    if (personal.pet.sprite !== undefined) {
      const sp = String(personal.pet.sprite || "").slice(0, 80);
      if (sp && !petSprites.findPet(sp)) throw new Error("没找到这只精灵图宠物(或它的图集不合规):" + sp);
      q.sprite = sp;
    }
    if (personal.pet.character !== undefined) {
      const c = personal.pet.character === "photo" ? "photo" : personal.pet.character === "sprite" ? "sprite" : "cat";
      const sp = q.sprite !== undefined ? q.sprite : (prefs.read(user).pet || {}).sprite || "";
      if (c === "sprite" && !sp) throw new Error("先选一只精灵图宠物,再切到这个形象");
      q.character = c;
    }
    if (personal.pet.scale !== undefined) q.scale = Math.max(0.6, Math.min(2, Number(personal.pet.scale) || pet.DEFAULT_SCALE));
    if (personal.pet.opacity !== undefined) q.opacity = Math.max(0.25, Math.min(1, Number(personal.pet.opacity) || 1));
    if (Object.keys(q).length) out.pet = q;
  }
  if (personal.shortcuts && typeof personal.shortcuts === "object") {
    const sc = {};
    for (const [k, v] of Object.entries(personal.shortcuts)) if (typeof v === "string" && v.length < 60) sc[k] = v;
    out.shortcuts = sc;
  }
  if (!Object.keys(out).length) return null;
  return prefs.write(user, out);
}

app.post("/api/settings", (req, res) => {
  try {
    let b = req.body || {};
    const norm = {}; // 媒体两张表规整时替用户改挂了哪几条,回给前端
    /**
     * 个人偏好分流。
     *
     * 谁写 config.json:个人桌面版(屏幕前就一个人,config 就是他的偏好;而且 Electron 壳在
     * 任何人登录之前就要靠 config.pet / config.shortcuts 把宠物和快捷键装起来),
     * 以及多人服务器上的平台管理员(他设的那份是这台机器的默认——定时任务和 IM 消息没有
     * 「当前登录的人」,只能读 config)。
     * 其余人写各自的 data/prefs/<账号>.json,谁也不覆盖谁。
     */
    if (ownPrefs(req)) {
      const { personal, rest } = prefs.split(b);
      const saved = Object.keys(personal).length ? savePersonalPrefs(req.user, personal) : null;
      b = rest;
      // 整单都是个人项:config 一个字节都不用动,也别白跑一次 saveConfig
      if (!Object.keys(b).length) return res.json({ ok: true, personal: true, saved: !!saved });
      // 走到这儿说明单子里还剩服务器级的项(模型渠道、API Key、安全档位、IM、工作目录……),
      // 而能走进这个 if 的人按定义就不是平台管理员。界面本来就不给他画这些输入框,
      // 但接口不能只靠界面守——不然一条 curl 就能把整台机器的 Key 换掉。
      return res.status(403).json({
        error: `这些是整台服务器一份的设置,归平台管理员改:${Object.keys(b).join("、")}`,
        platform_only: true,
      });
    }
    // 能换型号的附加参数在动任何配置之前就拦:型号只认「模型」栏和放行列表(engines/gate.js)
    for (const [id, v] of Object.entries((b.agent && b.agent.engine_options) || {})) {
      const badArg = v && Array.isArray(v.extraArgs) ? engineGate.modelArg(v.extraArgs, id) : "";
      if (badArg) throw new Error(`附加参数里有换型号的「${badArg}」,型号请在「模型」栏里填。`);
    }
    if (Array.isArray(b.models)) {
      const old = new Map((config.models || []).map((m) => [m.name, m]));
      for (const m of b.models) {
        if (!m.name || !m.model) throw new Error("每个模型需要 name 和 model 字段");
        // 判断模型(Jev)挂到对话模型列表里:下拉按渠道种类挡住了,可模型名是个自由输入框,
        // 手打一个照收——存得下、选得中,可它没有 /chat/completions,每一趟都是 400。
        // 不在存的时候拦,人要等到真发一句话才知道,而那时候收到的是上游的 400,
        // 根本看不出是「挂错了地方」。拦住也得指路,否则就成了第二种摸不着头脑。
        if (systemOne.isDecisionModel(m.model)) {
          throw new Error(`「${m.model}」是判断模型(Jev),它不产文字、没有 /chat/completions,挂在对话模型列表里每一趟都是 400。它走自己那条路:命令行 openworkbuddy jev、接口 /api/decide,或在渠道里加一条「TypeSafe Jev」再点那颗「测一下」`);
        }
        m.provider = mediaModels.normApi(m.provider);
        delete m.has_key;  // 读接口给界面加的,不进配置文件
        delete m.key_hint; // 同上:Key 的末四位只是给人看的,落盘就成了第二份 Key 副本
        // 读接口给非管理员回的是掩码。真有人把掩码原样存回来,按「没改」处理,别把 Key 抹成八个星号
        if (/^\*+$/.test(String(m.api_key == null ? "" : m.api_key).trim())) {
          m.api_key = String((old.get(m.name) || {}).api_key || "");
        }
      }
      config.models = b.models;
    }
    if (b.active_model !== undefined) {
      if (!config.models.some((m) => m.name === b.active_model)) throw new Error("active_model 不在模型列表中");
      setGlobalModel(b.active_model);
    }
    if (typeof b.model_follow_last === "boolean") config.model_follow_last = b.model_follow_last;
    if (b.last_picked_model !== undefined) {
      const n = String(b.last_picked_model || "").trim();
      if (n && !(config.models || []).some((m) => m.name === n)) throw new Error(`模型「${n}」不在模型列表里`);
      config.last_picked_model = n;
    }
    // 内网模式。这是整台机器的网络事实,不是谁的偏好,所以走服务器级这一支(prefs.split
    // 的个人项是白名单,认不出的键一律算服务器级,非管理员改会拿到 403)。
    // 它只负责「据实相告」:把连不上的连接器标出来、GitHub 装技能当场拒掉,
    // 不动任何已经配好的东西——万一判断错了,用户自己关掉就全恢复。
    if (typeof b.intranet === "boolean") config.intranet = b.intranet;
    if (b.agent) {
      if (b.agent.max_steps) config.agent.max_steps = Math.max(1, Math.min(100, +b.agent.max_steps));
      if (b.agent.tool_timeout_ms) config.agent.tool_timeout_ms = Math.max(5000, +b.agent.tool_timeout_ms);
      if (b.agent.max_runtime_ms) config.agent.max_runtime_ms = Math.max(60000, +b.agent.max_runtime_ms);
      if (b.agent.auto_continue_rounds !== undefined) config.agent.auto_continue_rounds = Math.max(0, Math.min(20, Math.round(+b.agent.auto_continue_rounds) || 0));
      // 上限 4:这一档每条都花钱,给得太大等于把「手滑并发十条视频」变成一次点击的事
      if (b.agent.gen_parallel_max !== undefined) config.agent.gen_parallel_max = Math.max(1, Math.min(4, Math.round(+b.agent.gen_parallel_max) || 2));
      // 0 = 按这台机器的内存自动定
      if (b.agent.max_parallel_tasks !== undefined) config.agent.max_parallel_tasks = Math.max(0, Math.min(32, Math.round(+b.agent.max_parallel_tasks) || 0));
      if (b.agent.llm_timeout_ms) config.agent.llm_timeout_ms = Math.max(30000, +b.agent.llm_timeout_ms);
      // 下限 2 万字符:再小连最近几步的工具原文都留不住,agent 会失忆式反复重做
      if ("max_context_chars" in b.agent) {
        const v = +b.agent.max_context_chars;
        if (v > 0) config.agent.max_context_chars = Math.max(20000, Math.min(2000000, v));
        else delete config.agent.max_context_chars; // 清空 = 回到按模型窗口算
      }
      if (b.agent.max_tokens_budget !== undefined) config.agent.max_tokens_budget = Math.max(0, Math.round(+b.agent.max_tokens_budget) || 0);
      // 定时任务跑绿之后再让判断模型看一眼。默认关,因为它每条绿都要花一道题的钱
      if (b.agent.second_opinion !== undefined) config.agent.second_opinion = !!b.agent.second_opinion;
      // 没变化就不推。默认关:它少响一声铃,而「少响的那一声」用户看不见,得他自己点头
      if (b.agent.push_gate !== undefined) config.agent.push_gate = !!b.agent.push_gate;
      if (b.agent.continue_gate !== undefined) config.agent.continue_gate = !!b.agent.continue_gate;
      // 往长期记忆里写之前先判一句。默认关:它拒错一条,用户只会觉得「说过的事它又忘了」
      if (b.agent.memory_gate !== undefined) config.agent.memory_gate = !!b.agent.memory_gate;
      if (b.agent.ask_gate !== undefined) config.agent.ask_gate = !!b.agent.ask_gate;
      if (b.agent.skill_gate !== undefined) config.agent.skill_gate = !!b.agent.skill_gate;
      if (b.agent.thinking !== undefined) {
        const lv = String(b.agent.thinking || "").trim().toLowerCase();
        // 写错档位当场拒绝,不悄悄退回 auto:用户以为关掉了思考、账单却照着思考的量涨
        if (!thinking.LEVELS.includes(lv)) throw new Error("没有这个思考模式档位:" + lv);
        config.agent.thinking = lv;
      }
      if (b.agent.failover_model !== undefined) {
        const fm = String(b.agent.failover_model || "").trim();
        if (fm && !config.models.some((m) => m.name === fm)) throw new Error("备用渠道不在模型列表中");
        config.agent.failover_model = fm; // 空串 = 关闭自动换道(默认)
      }
      if (b.agent.engine !== undefined) {
        const id = String(b.agent.engine || "builtin").trim() || "builtin";
        // 写错名字当场拒绝。悄悄退回内置 = 用户以为在用免费的本机订阅,账单却在涨
        if (engines.get(id) === undefined) throw new Error("没有这个底层引擎:" + id);
        config.agent.engine = id;
      }
      if (b.agent.engine_options && typeof b.agent.engine_options === "object") {
        config.agent.engine_options = config.agent.engine_options || {};
        for (const [id, v] of Object.entries(b.agent.engine_options)) {
          if (engines.get(id) === undefined) continue;
          if (!v || typeof v !== "object") continue;
          const cur = (config.agent.engine_options[id] = config.agent.engine_options[id] || {});
          for (const k of ["model", "bin", "permissionMode", "sandbox"]) if (v[k] !== undefined) cur[k] = String(v[k] || "").trim();
          // 多人共用时这个引擎开不开、成员能挑哪几个型号:只有属主写得进来(这一段只有属主走得到)
          if (v.enabled !== undefined) cur.enabled = !!v.enabled;
          if (v.models !== undefined) cur.models = engineModelList(v.models);
          if (v.thinking !== undefined) {
            const lv = String(v.thinking || "").trim().toLowerCase();
            if (lv && !thinking.LEVELS.includes(lv)) throw new Error("没有这个思考模式档位:" + lv);
            cur.thinking = lv; // 空串 = 这个引擎跟随全局档位
          }
          if (v.network !== undefined) cur.network = !!v.network;
          if (v.approval !== undefined) cur.approval = !!v.approval; // claude 要审批的动作交给安全中心判(engines/approve.js)
          if (v.relay !== undefined) cur.relay = !!v.relay; // 借出去的工具交回主进程跑(engines/tool-relay.js)
          if (Array.isArray(v.extraArgs)) cur.extraArgs = v.extraArgs.map((x) => String(x)).slice(0, 20);
        }
      }
    }
    if (b.pet) {
      const cur = config.pet || {};
      // 精灵图那两个字段先验后写:这个 handler 一路在往 config 上直接赋值,
      // 半路 return 400 会留下「内存里改了、盘上没存」的半拉状态。所以校验全部前置。
      const nextSprite = b.pet.sprite !== undefined ? String(b.pet.sprite || "").slice(0, 80) : cur.sprite || "";
      const nextChar = b.pet.character === undefined ? cur.character || "cat"
        : b.pet.character === "photo" ? "photo" : b.pet.character === "sprite" ? "sprite" : "cat";
      // 存一个扫不到的 id 进去,结果是宠物窗口悄悄回落成内置猫,
      // 用户体感是「换了但没生效」——不如当场拒了,把原因说清楚。
      if (nextSprite && !petSprites.findPet(nextSprite)) return res.status(400).json({ error: "没找到这只精灵图宠物(或它的图集不合规):" + nextSprite });
      if (nextChar === "sprite" && !nextSprite) return res.status(400).json({ error: "先选一只精灵图宠物,再切到这个形象" });

      config.pet = cur;
      if (typeof b.pet.enabled === "boolean") config.pet.enabled = b.pet.enabled;
      if (typeof b.pet.notify === "boolean") config.pet.notify = b.pet.notify;
      if (typeof b.pet.notifyDone === "boolean") config.pet.notifyDone = b.pet.notifyDone;
      if (typeof b.pet.wander === "boolean") config.pet.wander = b.pet.wander;
      config.pet.sprite = nextSprite;
      config.pet.character = nextChar;
      if (b.pet.scale !== undefined) config.pet.scale = Math.max(0.6, Math.min(2, Number(b.pet.scale) || pet.DEFAULT_SCALE));
      if (b.pet.opacity !== undefined) config.pet.opacity = Math.max(0.25, Math.min(1, Number(b.pet.opacity) || 1));
      if (global.__openworkbuddyPet) try { global.__openworkbuddyPet.applyConfig({ ...config.pet, enabled: config.pet.enabled === true }); } catch {}
    }
    if (b.evolve) {
      config.evolve = config.evolve || {};
      // 默认关:每晚要调一次模型,是花钱的事,得你自己点开
      if (typeof b.evolve.auto === "boolean") config.evolve.auto = b.evolve.auto;
      if (b.evolve.hour !== undefined) config.evolve.hour = Math.max(0, Math.min(23, Math.round(+b.evolve.hour) || 0));
      if (b.evolve.days !== undefined) config.evolve.days = Math.max(1, Math.min(365, Math.round(+b.evolve.days) || 14));
    }
    if (b.persona !== undefined) config.persona = String(b.persona).slice(0, 4000);
    if (b.assistant) {
      if (b.assistant.name !== undefined) {
        const n = String(b.assistant.name).replace(/\s+/g, " ").trim();
        if (n.length > 24) throw new Error("助理名字最多 24 个字");
        config.assistant.name = n || ASSISTANT_DEFAULT.name; // 清空就退回默认,别让它变成没名字的空气
      }
      // 头像的校验规则和用户头像完全一样(emoji 或 ≤256KB 的 data URI),共用一处免得两边规则跑偏
      if (b.assistant.avatar !== undefined) config.assistant.avatar = account._internals.normalizeAvatar(b.assistant.avatar) || ASSISTANT_DEFAULT.avatar;
    }
    if (b.search) {
      config.search = config.search || {};
      // 先存 Key,再校验 provider。顺序反过来踩过一次大的:
      // 选了一家这个进程还不认的服务商(程序更新了但没重开,内存里还是老名单),
      // provider 那一句先 throw,整个保存被回滚——人刚一个一个敲进去的七八把 Key 一起没了,
      // 而界面只说了句「provider 只认这几家」,谁也想不到自己的 Key 被顺手丢了。
      for (const k of ["jina_key", "tavily_key", "brave_key", "bocha_key", "zhipu_key", "qiniu_key", "serper_key", "custom_key", "custom_url", "custom_query_field"]) {
        if (b.search[k] !== undefined) config.search[k] = String(b.search[k]).trim();
      }
      if (b.search.provider !== undefined) {
        // 这张名单跟 tools.js 的 SEARCH_PROVIDERS 是同一份;加一家要两边一起加,
        // 只加一边的后果是:设置里存得下,真搜的时候那家不存在,整条接力从第二家才开始
        const 支持的 = Object.keys(SEARCH_PROVIDERS);
        if (b.search.provider !== "" && !支持的.includes(b.search.provider)) {
          saveConfig(); // Key 已经写进 config 了,先落盘再报错——别让人白填一遍
          throw new Error(
            `正在跑的这个程序还不认「${b.search.provider}」这一家(它认的是:${支持的.join(" / ")})。` +
            "多半是程序升级前就一直开着,退出重开一次就好。你刚填的 Key 已经存下来了,不用重填。"
          );
        }
        config.search.provider = b.search.provider;
      }
    }
    if (b.map && typeof b.map === "object") {
      config.map = config.map || {};
      if (b.map.amap_key !== undefined) config.map.amap_key = String(b.map.amap_key).trim();
      if (b.map.provider !== undefined) {
        if (!["auto", "amap", "osm"].includes(b.map.provider)) throw new Error(`地图只认 auto / amap / osm,收到的是「${b.map.provider}」`);
        config.map.provider = b.map.provider;
      }
      if (b.map.nav !== undefined) {
        if (!["amap", "google"].includes(b.map.nav)) throw new Error(`打开路线只认 amap / google,收到的是「${b.map.nav}」`);
        config.map.nav = b.map.nav;
      }
      // 老界面(升级前开着没刷新的那页)还会送每天的上限:照收,places.settingsOf 会折成每月
      if (b.map.amap_daily_cap !== undefined) {
        const n = Math.floor(+b.map.amap_daily_cap);
        if (!Number.isFinite(n) || n < 0) throw new Error("每天打高德的上限要填 0 或正整数");
        config.map.amap_daily_cap = n;
      }
      for (const [k, what] of [["amap_search_cap", "每月高德搜索的上限"], ["amap_route_cap", "每月高德路线的上限"]]) {
        if (b.map[k] === undefined) continue;
        const n = Math.floor(+b.map[k]);
        if (!Number.isFinite(n) || n < 0) throw new Error(`${what}要填 0 或正整数`);
        config.map[k] = n;
      }
      // 两项新的都存了,老的每天上限就用不上了,删掉免得以后看配置的人以为它还管用
      if (b.map.amap_search_cap !== undefined && b.map.amap_route_cap !== undefined) delete config.map.amap_daily_cap;
      // 地图设置存过(多半是换了 Key):今天因为 Key 用不了停掉的高德,按新设置再试
      geoPlaces.resetStop();
    }
    if (b.workspace_layout !== undefined && b.workspace_layout !== "per_chat" && b.workspace_layout !== "flat") {
      throw new Error("文件夹用法只有两种:per_chat(每个对话一个文件夹)、flat(直接放进去)");
    }
    if (b.workspace_dir !== undefined && b.workspace_dir !== getDefaultWorkspaceDir()) {
      config.workspace_dir = setWorkspaceDir(b.workspace_dir);
      // 输入框旁的快速切换是临时的(新任务会切回项目目录);设置中心改路径才算改默认,同步进当前项目
      if (b.workspace_permanent === true) {
        ensureProjects();
        const ap = config.projects.find((p) => p.name === config.active_project);
        if (ap) ap.dir = config.workspace_dir;
      }
      // 选了文件夹就把它怎么放成果记下来:没明说按此刻判的记,见 noteLayout
      noteLayout(config.workspace_dir, b.workspace_layout);
    } else if (b.workspace_layout !== undefined) {
      // 文件夹没换、只改了用法(设置页同一个文件夹换了单选,或输入框旁那个菜单里切的):改的是眼下这个文件夹
      noteLayout(getWorkspaceDir(), b.workspace_layout);
    }
    config.im = config.im || {};
    if (b.im) imConfig.mergeImPatch(config.im, b.im);
    if (Array.isArray(b.providers)) {
      const old = new Map((config.providers || []).map((p) => [p.id, p]));
      config.providers = b.providers.map((p) => {
        const prev = old.get(p.id) || {};
        // 读接口给非管理员回的是掩码。真有人把掩码原样存回来,按「没改」处理,别把 Key 抹成八个星号
        const key = String(p.api_key == null ? prev.api_key || "" : p.api_key).trim();
        return {
          id: String(p.id || "").trim(),
          name: String(p.name || "").trim(),
          kind: String(p.kind || "").trim(),
          base_url: String(p.base_url || "").trim(),
          api_key: /^\*+$/.test(key) ? prev.api_key || "" : key,
          // 接口格式:空 = 按渠道类型;认不出的值不落盘,免得一个拼错的字把整条渠道带去走最通用那种还不自知
          ...(mediaModels.isApiFormat(p.api) ? { api: p.api } : {}),
          // 生图 / 视频 / 配音接口同理:空 = 自动,认不出的值和对话专用渠道上的都不落盘
          ...mediaModels.mediaApisOf(p),
          // 中转站「放行任意型号」只能在中转站页由平台超级管理员开关(admin.js 那条路由会记审计)。
          // 这里只从旧值抄过来、不认表单里的:设置页的保存权限比那个开关宽,不能变成一条绕过去的路
          ...(prev.relay_any_model === true ? { relay_any_model: true } : {}),
          // 「给不给成员用」只走 POST /api/models/share,整表回存时抄旧值,表单漏带也不会悄悄共享出去
          ...(prev.share_members === false ? { share_members: false } : {}),
        };
      });
      auditKeyChanges(req, old, config.providers);
    }
    if (Array.isArray(b.media_models)) {
      for (const m of b.media_models) {
        if (!String(m.model || "").trim()) throw new Error("每个模型都得填模型名");
        if (!mediaModels.CAPS.includes(m.cap)) throw new Error("不认识这一路能力:" + m.cap);
      }
      // protocol 也得留住。它是手改 config.json 的人才会用的那道口子:一台 new-api 网关
      // 后面挂着好几家上游,地址和 Key 只有一份,只能逐条说「这个型号后面是万相」。
      // 这里不白名单它的话,下次在设置页点一下保存就被抹掉了,人还找不出是哪一步弄丢的。
      config.media_models = b.media_models.map((m) => ({
        id: String(m.id || "").trim(), cap: m.cap,
        name: String(m.name || "").trim(), provider: String(m.provider || "").trim(),
        model: String(m.model || "").trim(), voice: String(m.voice || "").trim(),
        protocol: String(m.protocol || "").trim(),
        default: !!m.default,
      }));
    }
    if (b.media) {
      // 扁平写法(向导第三步):直接落成渠道 + 模型条目并设为默认。只写 config.media 的话,
      // 下面 normalize 按 media_models 重算时会把它原样抹掉,界面却已经亮了「已配」。
      // 排在两张表整表替换之后,同一次提交里带了 providers 也不会把刚建的渠道冲掉;先全验一遍再动手,免得存一半
      const caps = mediaModels.CAPS.filter((k) => b.media[k]); // 单一真源:加一路能力只改 media-models.js 的 CAPS
      for (const k of caps) {
        if (!String(b.media[k].base_url || "").trim() || !String(b.media[k].model || "").trim()) throw new Error("接口地址和模型名都要填");
      }
      for (const k of caps) mediaModels.upsertLegacy(config, k, b.media[k]);
    }
    // 两张表任何一张动过,就重算 id、补默认项、把「默认那条」压平回 config.media,
    // 这样 tools.js 那边永远读到一份现成的扁平配置,不用关心多模型这套
    if (b.media || b.providers || b.media_models) {
      mediaModels.normalize(config, norm);
      // 渠道配置动过 = 用户已经去处理那条断掉的路了(换了 Key、换了型号、换了家)。
      // 熔断记录这时必须清空,否则他改完还得干等半小时,然后来问「我都改好了怎么还不动」。
      mediaHealth.reset();
    }
    // 渠道改了 Key,挂在它底下的对话模型要跟着换——压平这一步就是干这个的
    if (b.models || b.providers) chatModels.normalize(config);
    if (b.security) {
      const sec = security.getSecurity(config);
      for (const k of ["gateway", "delete_protect", "cmd_risk_gate", "runtime_node", "runtime_python", "sandbox_safety"]) {
        if (typeof b.security[k] === "boolean") sec[k] = b.security[k];
      }
      for (const k of ["batch_delete_threshold", "approval_timeout_s"]) {
        if (b.security[k] !== undefined) sec[k] = Math.max(1, +b.security[k] || security.DEFAULTS[k]);
      }
      for (const k of ["file_whitelist", "file_blacklist", "cmd_allow", "cmd_ask", "url_whitelist", "url_blacklist"]) {
        if (Array.isArray(b.security[k])) sec[k] = b.security[k].map((s) => String(s)).filter((s) => s.trim()).slice(0, 100);
      }
      // 外挂的第二把尺子:auto / advisory / off 三挡,别的值一律当没填过(默认 auto)
      if (b.security.toolward !== undefined) {
        const m = String(b.security.toolward || "").toLowerCase();
        sec.toolward = ["auto", "advisory", "off"].includes(m) ? m : security.DEFAULTS.toolward;
      }
      // 指死一个可执行文件的路径。这是条会被执行的路径,长度掐住,别让它变成往配置里塞东西的口子
      if (b.security.toolward_bin !== undefined) sec.toolward_bin = String(b.security.toolward_bin || "").trim().slice(0, 500);
      // 子进程额外放行的环境变量名。只收合法变量名、最多 50 条;像 Key 的照收,给不给由账号数在起子进程时判
      if (Array.isArray(b.security.env_passthrough)) sec.env_passthrough = childEnv.cleanNames(b.security.env_passthrough);
      // 本机/内网放行清单:只收 host:端口(端口可写 *,主机可带 * 通配),写成网址的剥成 host:端口,最多 50 条
      if (Array.isArray(b.security.url_allow_local)) sec.url_allow_local = netAddr.cleanAllow(b.security.url_allow_local);
      // 系统沙箱档位:认不出的值一律当没填过(按部署走),不能因为一个错字就变成不套
      if (b.security.sandbox !== undefined) {
        const m = String(b.security.sandbox || "");
        sec.sandbox = ["default", "auto", "required", "off"].includes(m) ? m : "default";
      }
      if (b.security.sandbox_level !== undefined) sec.sandbox_level = b.security.sandbox_level === "basic" ? "basic" : "hardened";
      if (b.security.sandbox !== undefined || b.security.sandbox_level !== undefined || b.security.sandbox_safety !== undefined) require("./src/agent/tools").warmSandbox(sec);
    }
    if (b.langfuse && typeof b.langfuse === "object") {
      const cur = config.langfuse || (config.langfuse = { enabled: false, host: "https://cloud.langfuse.com", public_key: "", secret_key: "" });
      if (b.langfuse.host !== undefined) {
        const h = String(b.langfuse.host || "").trim().replace(/\/+$/, "");
        // 地址写错是这一块最常见的坑(漏 https://、把浏览器地址栏里带路径的整条粘进来)。
        // 不在这儿拦的话,上报会一直静默失败,用户只会看到"开着,但一条都没有"
        if (h && !/^https?:\/\/[^\s]+$/.test(h)) throw new Error("Langfuse 地址得是 http:// 或 https:// 开头的网址");
        cur.host = h || "https://cloud.langfuse.com";
      }
      if (b.langfuse.public_key !== undefined) cur.public_key = String(b.langfuse.public_key || "").trim();
      if (b.langfuse.secret_key !== undefined) {
        const k = String(b.langfuse.secret_key || "").trim();
        cur.secret_key = /^\*+$/.test(k) ? cur.secret_key || "" : k; // 掩码原样存回来 = 没改
      }
      if (b.langfuse.enabled !== undefined) {
        const on = !!b.langfuse.enabled;
        // 开着却没钥匙,等于以为在记其实一条都没发。这种"看起来成了"的状态最坑人,当场拦掉
        if (on && !(cur.public_key && cur.secret_key)) throw new Error("要开执行追踪,公钥和私钥都得填(在 Langfuse 项目设置里生成一对)");
        cur.enabled = on;
      }
    }
    if (b.embedding !== undefined) {
      // 语义召回用的嵌入模型。全空 = 清掉,记忆按关键词召回——不会再从聊天 / 媒体渠道里借 Key 自己找一个,
      // 用户没选过的型号不该花他的钱(llm.js embedCandidates)。两种存法:点名一条已配渠道 + 型号
      // (Key 用时从渠道表现取),或者单独填一组地址 / Key / 型号(只接了 DeepSeek 这类没有嵌入接口的人靠它)
      const e = b.embedding || {};
      const provider = String(e.provider || "").trim();
      const base = String(e.base_url || "").trim().replace(/\/+$/, "");
      const model = String(e.model || "").trim();
      if (!provider && !base && !model) delete config.embedding;
      else if (provider) {
        const p = (config.providers || []).find((x) => x && String(x.id) === provider);
        if (!p) throw new Error("没有这条渠道,先去 设置 → 模型 把它加上");
        if (!embedHostable(p.kind)) throw new Error(`「${p.name || p.id}」算不了向量,换一条渠道或单独填接口`);
        if (!model) throw new Error("模型名要填,比如 text-embedding-3-small");
        config.embedding = { provider: String(p.id), model };
      } else {
        if (!/^https?:\/\/[^\s/]+/i.test(base)) throw new Error("接口地址要以 http:// 或 https:// 开头");
        if (!model) throw new Error("模型名要填,比如 text-embedding-3-small");
        const old = config.embedding || {};
        let key = String(e.api_key == null ? "" : e.api_key).trim();
        if (/^\*+$/.test(key)) {
          // 八颗星 = 没改。可地址换了还沿用旧 Key,等于把这家的 Key 发给了另一家
          if (old.provider || String(old.base_url || "").replace(/\/+$/, "") !== base) throw new Error("换了接口地址,Key 要重新填一遍");
          key = String(old.api_key || "");
        }
        config.embedding = { base_url: base, api_key: key, model };
      }
    }
    if (b.shortcuts && typeof b.shortcuts === "object") {
      config.shortcuts = {};
      for (const [k, v] of Object.entries(b.shortcuts)) {
        if (typeof v === "string" && v.length < 60) config.shortcuts[k] = v;
      }
      if (global.__wbRegisterShortcuts) global.__wbRegisterShortcuts(config.shortcuts); // 桌面版重注册全局快捷键
    }
    llmInner = createLLM(config); // 模型热切换
    memory.setEmbedder(createEmbedder(config));
    setSessEmbedder(createEmbedder(config));
    memory.ensureVectors().catch(() => {});
    saveConfig();
    if (b.im && b.im.feishu && imBridge) {
      imBridge.startFeishuWs(true).catch((e) => console.warn("[飞书] 长连接重启失败:", e.message)); // 飞书配置变更后重建长连接
    }
    if (b.im && b.im.qq && imBridge) {
      imBridge.startQQ(true).catch((e) => console.warn("[QQ] 长连接重启失败:", e.message));
    }
    // 这一趟存的时候替用户改挂了哪几条(moved),前端拿去说一声
    res.json({ ok: true, provider: llm.provider, model: llm.model, moved: norm.moved || [] });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

/**
 * 执行追踪连通性自检。
 *
 * 为什么要有这颗按钮:这一块的失败全是静默的——地址少个字母、Key 是另一个项目的、
 * 自建实例的端口没开,任务照跑不误,只是 trace 永远是空的。等用户想起来去点链接,
 * 已经过去好几天了。所以在设置页当场发一条真的上去,成不成立刻说,顺带把那条探针的链接给他。
 * 没存的草稿值也能测(body 里带什么就用什么),不然只能"先存了再看结果"。
 */
app.post("/api/trace/test", async (req, res) => {
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "执行追踪是整台服务器一份的设置,归平台管理员改", platform_only: true });
  const b = req.body || {};
  const cur = config.langfuse || {};
  const sk = String(b.secret_key == null ? "" : b.secret_key).trim();
  const out = await tracing.getTracer(config).probe({
    host: b.host !== undefined ? b.host : cur.host,
    public_key: b.public_key !== undefined ? String(b.public_key || "").trim() : cur.public_key,
    // 界面上私钥那格给非管理员是掩码;管理员拿到的是原文。原样传回来(或压根没动)都按"用已存的那把"
    secret_key: !sk || /^\*+$/.test(sk) ? cur.secret_key || "" : sk,
  });
  res.json(out);
});

// 本地 Trace:默认记录在当前 workspace/.openworkbuddy/traces.jsonl,不依赖 Langfuse。
// Langfuse 只是可选的外部副本,用户可以先用本地追踪排查任务,再决定是否外发。
// 这三条一律锁平台管理员。一本账里记的是**整台服务器**上跑过的每趟任务:提示词原文、
// 工具参数、文件路径、最终回复,全都在里面,而且不带账号维度——多人服务器上放开给成员看,
// 等于把同事的对话内容原样端出去。界面上这一页也只对平台管理员显示(PLATFORM_ONLY_VIEWS)。
const traceOwnerOnly = (req, res) => {
  if (isPlatformOwner(req)) return false;
  res.status(403).json({ error: "执行记录是整台服务器一份的,归平台管理员看", platform_only: true, traces: [] });
  return true;
};
// 执行记录跨项目可见;只查本应用记住的根,不扫描磁盘、不接受请求传入任意目录。
function traceReadOptions() {
  return { roots: [...new Set([...allRoots(), dataPath("workspace"), dataPath()])] };
}
app.get("/api/traces", (req, res) => {
  if (traceOwnerOnly(req, res)) return;
  try {
    const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 50));
    const sessionId = String(req.query.session || "").slice(0, 120);
    res.json({ traces: tracing.getTracer(config).localTraces({ ...traceReadOptions(), limit, sessionId }) });
  } catch (e) { res.status(500).json({ error: e.message, traces: [] }); }
});
app.get("/api/traces/:id", (req, res) => {
  if (traceOwnerOnly(req, res)) return;
  try {
    const trace = tracing.getTracer(config).localTraces({ ...traceReadOptions(), traceId: String(req.params.id || "") });
    if (!trace) return res.status(404).json({ error: "找不到这条 Trace" });
    res.json({ trace });
  } catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete("/api/traces", (req, res) => {
  if (traceOwnerOnly(req, res)) return;
  try { tracing.getTracer(config).clearLocalTraces(traceReadOptions()); res.json({ ok: true }); }
  catch (e) { res.status(500).json({ error: e.message }); }
});

// ---------- 系统日志 / 指标 / 告警 ----------
// 跟 Trace 一样锁平台管理员,理由也一样:这几本账都是**整台机器一份**的。
// 日志里带着别人的登录名、会话 id、报错原文;指标里带着别人的 token 花销和任务量。
// 多人共用一台服务器时,放开给成员看等于把同事干了什么摊开。
const opsOwnerOnly = (req, res) => {
  if (isPlatformOwner(req)) return false;
  res.status(403).json({ error: "系统日志和运行指标是整台机器一份的,归平台管理员看", platform_only: true, rows: [] });
  return true;
};
app.get("/api/ops/logs", (req, res) => {
  if (opsOwnerOnly(req, res)) return;
  try {
    res.json({
      days: log.days(),
      rows: log.tail({ day: String(req.query.day || ""), level: String(req.query.level || ""), q: String(req.query.q || ""), limit: Math.min(1000, +req.query.limit || 200) }),
    });
  } catch (e) { res.status(500).json({ error: e.message, rows: [] }); }
});
app.get("/api/ops/metrics", (req, res) => {
  if (opsOwnerOnly(req, res)) return;
  try {
    const rows = metrics.read({ from: String(req.query.from || ""), to: String(req.query.to || ""), limit: Math.min(5000, +req.query.limit || 720) });
    // 「现在」也给一份:刚重启完还没到一分钟时,图上总得有个东西,不然像坏了
    res.json({ rows, alerts: metrics._internals.loadState(), rules: metrics._internals.RULES.length });
  } catch (e) { res.status(500).json({ error: e.message, rows: [] }); }
});
/**
 * Prometheus 抓取口。**故意跟别的接口一样锁在平台管理员后面**——
 * 这套东西是打包成桌面应用发出去的,多开一个不要认证的端口出去,
 * 等于在用户机器上开了个谁都能读的运行数据口子。要接 Prometheus 的人
 * 在 scrape_configs 里带上 openworkbuddy_token 这个 cookie 就行(部署文档里写了怎么配)。
 */
app.get("/api/ops/metrics.prom", (req, res) => {
  if (opsOwnerOnly(req, res)) return;
  const rows = metrics.read({ limit: 1 });
  const m = rows[rows.length - 1] || {};
  const out = [];
  const put = (k, v, help) => {
    if (typeof v !== "number" || !isFinite(v)) return;
    out.push(`# HELP openworkbuddy_${k} ${help}`, `# TYPE openworkbuddy_${k} gauge`, `openworkbuddy_${k} ${v}`);
  };
  put("tasks", m.tasks, "tasks finished in the last snapshot window");
  put("tasks_failed", m.tasks_failed, "tasks that ended with an error");
  put("task_fail_rate", m.task_fail_rate, "failed / total in the window");
  put("task_p95_ms", m.task_p95_ms, "95th percentile wall-clock task duration");
  put("tokens", m.tokens, "prompt + completion tokens in the window");
  put("disk_free_pct", m.disk_free_pct, "free space on the data volume");
  put("rss_mb", m.rss_mb, "resident memory of the server process");
  put("loop_p99_ms", m.loop_p99_ms, "99th percentile event-loop delay in the window");
  put("loop_max_ms", m.loop_max_ms, "worst event-loop delay in the window");
  put("loop_util", m.loop_util, "event-loop utilization (plain node only)");
  for (const [name, n] of Object.entries(m.channel_fail_streak || {})) {
    out.push(`openworkbuddy_channel_fail_streak{channel="${String(name).replace(/["\\]/g, "")}"} ${n}`);
  }
  res.type("text/plain; version=0.0.4").send(out.join("\n") + "\n");
});

// ---------- 首次开箱引导:没有 API Key 时,什么都干不了,得先把这一步走完 ----------
// 只回布尔值,绝不把 key 原文吐给前端(设置页要改 key 走 /api/settings)
function isLocalModel(m) {
  return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(m.base_url || "");
}
function hasKey(m) {
  return isLocalModel(m) || !!String(m.api_key || "").trim();
}

/**
 * 换 Key 要留痕。
 *
 * 审计流水里躺着「谁充了值」「谁改了成员角色」「谁重置了密码」,唯独没有「谁换了那把
 * 全组织都在用的 Key」——而这恰恰是最值得留痕的一条:多管理员的组织里,任何一个管理员
 * 都能把渠道悄悄指到自己的账号、或者指到一个会把对话原文全存下来的中转,事后翻遍后台
 * 一个字都查不到。企业客户做合规的时候,这一条是必问项。
 *
 * 只记「哪条渠道、从哪把换成哪把」,两头都是 keyHint(前三位…末四位)——
 * 审计表是管理员和审计员都看得到的,把明文 Key 写进去等于给它开了第二个出口。
 */
function auditKeyChanges(req, before, after) {
  if (!req || !req.user) return; // 个人桌面版没有账号这回事,也就没有「谁改的」可记
  const changed = [];
  for (const p of after) {
    const prev = before.get(p.id);
    const oldKey = String((prev || {}).api_key || "");
    const newKey = String(p.api_key || "");
    if (oldKey === newKey) continue;
    const name = p.name || p.id;
    if (!oldKey) changed.push(`${name}:首次填入 ${keyHint(newKey)}`);
    else if (!newKey) changed.push(`${name}:清空(原 ${keyHint(oldKey)})`);
    else changed.push(`${name}:${keyHint(oldKey)} → ${keyHint(newKey)}`);
  }
  if (!changed.length) return;
  try {
    org.audit({
      org: org.orgIdOf(req.user), actor: req.user.username,
      action: "更换模型 Key", target: "渠道", detail: changed.join(";"),
    });
  } catch {} // 审计写不进去也不能把「存设置」整个失败掉
}

/**
 * Key 的「认得出是哪一把」凭据:前三位 + 末四位,中间省略。
 *
 * 成熟产品(OpenAI / Stripe / Vercel 后台)一律是这个做法:Key 写进去就再也读不出来,
 * 界面上只留末四位。我们以前是平台管理员每打开一次设置页,九把明文 Key 就往浏览器里送一趟——
 * 页面上任何一处 XSS 都能整包端走。现在管理员那边也只给这一小截:
 * 「我装的是哪一把」还看得出来,「这把还能不能用」交给渠道卡上的「测一下」。
 */
function keyHint(k) {
  const v = String(k || "").trim();
  if (!v) return "";
  return v.length <= 8 ? "•".repeat(v.length) : v.slice(0, 3) + "…" + v.slice(-4);
}

/** 发一条最小的真实请求验活。返回 null = 通过,返回字符串 = 人话版失败原因。
 *  刻意不走 createLLM:它会把工具 schema 一起发过去,这里只想知道"这个 key 认不认"。 */
/** 地址是用户自己填的(中转 / 网关 / 改过官方地址):404 时地址和模型名都可能是错的那一个 */
function ownBase(kind, base) {
  const k = mediaModels.PROVIDER_KINDS.find((x) => x.kind === kind);
  if (!k || k.relay) return true;
  const b = String(base || "").trim();
  if (!b) return false;                          // 没填 = 用这家官方的默认地址
  return mediaModels.guessKind(b) !== kind;      // 域名认得出就是这家自己的
}
async function probeModel(m, opts = {}) {
  return (await probeModelRaw(m, opts)).error;
}
/** 同 probeModel,另带上游状态码(没连上是 0):自填地址那条路要凭 404 决定补不补 /v1 */
async function probeModelRaw(m, { custom = false } = {}) {
  // 每种接口格式的地址算法只有一份(llm.js 的 pingRequest),验活和真跑必须打同一个地址:
  // 否则填了中转的人验活验的是中转、跑起来打的是官方,绿勾骗人
  const { url, headers, body } = pingRequest(m);
  try {
    const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30000) });
    if (r.ok) return { status: r.status, error: null };
    const txt = (await r.text()).slice(0, 300);
    const out = (error) => ({ status: r.status, error });
    // 每一句都带上状态码和上游原文。403 不替人下结论:百炼上它常是「这把 Key 没开这个型号」,不等于 Key 错了
    const said = txt.replace(/\s+/g, " ").trim().slice(0, 200);
    const orig = said ? `。上游原文:${said}` : "";
    if (r.status === 401) return out("这个 Key 上游不认(HTTP 401),检查有没有复制全、是不是这家服务商的 Key" + orig);
    if (r.status === 403) return out("上游拒绝了这次请求(HTTP 403)" + orig);
    if (r.status === 402) return out("Key 有效但余额不足 / 未开通付费(HTTP 402),去服务商控制台充值后再试" + orig);
    // 方舟对不存在和未授权共用 404;已关停的接入点还可能回 400。不能都说成用户写错了型号。
    if (/InvalidEndpoint\.ClosedEndpoint|ModelNotOpen|InvalidEndpointOrModel\.NotFound|do not have access/i.test(txt))
      return out(`模型或接入点「${m.model}」当前不可访问(HTTP ${r.status}):可能未开通、已关停或型号不匹配。在当前页面刷新模型列表,选择已开通的型号;火山方舟也可填写控制台的 ep- 接入点 ID${orig}`);
    if (r.status === 404) return out(custom
      ? `HTTP 404:地址或模型名有一处对不上(打的是 ${url}),对照接口文档再看一眼${orig}`
      : `模型「${m.model}」不存在或当前账号无权访问(HTTP 404),刷新模型列表后重选,并到服务商控制台确认开通状态${orig}`);
    if (r.status === 429) return out("被限流了(429),等一会儿再试,或换个渠道" + orig);
    return out(`上游返回 HTTP ${r.status}:${txt}`);
  } catch (e) {
    const msg = String((e && e.message) || e);
    if (/timeout|abort/i.test(msg)) return { status: 0, error: "连不上(30 秒超时)。国外服务商在国内直连经常打不通,挂代理或换国产渠道" };
    return { status: 0, error: "连不上:" + msg.slice(0, 200) };
  }
}

/**
 * 首次开箱向导要的一张「体检表」:每项能力配没配、配的是什么,一次性给全。
 *
 * 为什么不让前端各拉各的接口再拼:向导弹在登录后第一屏,多拉五个接口就是多五次转圈;
 * 而且「大脑到底算不算接上了」只有服务端知道——走本机 Claude Code / Codex 时根本不需要 API Key,
 * 前端只看 has_key 会把已经能用的人拦在门口。
 *
 * seen = 用户走完(或明确跳过)过一次向导。没走完的每次打开都弹,走完的只在「设置 → 关于」里能再打开。
 */
app.get("/api/onboarding", async (req, res) => {
  const models = (config.models || []).map((m) => ({
    name: m.name,
    channel: m.channel || "", api: m.provider || "openai",
    model: m.model,
    base_url: m.base_url || "",
    local: isLocalModel(m),
    has_key: hasKey(m),
  }));
  const active = (config.models || []).find((m) => m.name === config.active_model) || (config.models || [])[0];
  const myAgent = prefs.agentCfg(config);
  const engineId = engines.currentId(config, { user: req.user && req.user.username });
  const brainViaEngine = engineId !== "builtin" && engines.get(engineId) !== undefined;
  const brainOk = brainViaEngine || !!(active && hasKey(active));
  const seen = !!((config.onboarding || {}).done_at);

  // 本机 CLI 探测要跑 which + --version,只在向导真要渲染的时候做,老用户每次开机别白等。
  // 开机那一趟(maybeOnboard)只是拿来判断"要不要弹",不带 probe;真弹出来时会再拉一次带 probe 的。
  let found = [];
  if (req.query.probe === "1") {
    try { found = await engines.detectAll(myAgent.engine_options || {}); } catch {}
  }
  const media = config.media || {};
  // 本机接口(Ollama、自建 whisper 之类)本来就不要 Key;只有地址没有型号的是半截,调不通,不算配好
  const mediaOk = (kind) => { const c = media[kind] || {}; return !!(c.base_url && c.model && (c.api_key || chatModels.isLocalBase(c.base_url))); };
  const im = config.im || {};
  const pair = (o, ...ks) => !!(o && ks.every((k) => String(o[k] || "").trim()));
  const imConfigured = [
    pair(im.feishu, "app_id", "app_secret"),
    pair(im.qq, "app_id", "app_secret"),
    pair(im.wecom_app, "corp_id", "secret", "agent_id"),
    pair(im.wechat_mp, "app_id", "app_secret"),
    pair(im.wechat_ilink, "bot_token", "ilink_bot_id"),
    !!String(im.wecom_bot_webhook || "").trim(),
    !!String(im.dingtalk_webhook || "").trim(),
  ].filter(Boolean).length;
  // 「自动」也要报得出实际会用哪家:报成写死的一家,体检页说「已配」而真跑的是另一家
  const sc = config.search || {};
  const sp = (sc.provider || "").toLowerCase()
    || Object.keys(SEARCH_PROVIDERS).find((p) => searchProviderReady(sc, p, searchProviderKey(sc, p))) || "bocha";
  res.json({
    // 大脑没接上 = 一句话都发不出去,必须弹引导。接上了就不再自动弹了:
    // 用户在第一步填完 Key 就跳过是最常见的一条路,以前那种"没走完就再弹一次"每次开机都要拦他一遍。
    needs_setup: !brainOk,
    seen,
    // 向导最后一步写的是服务器级设置(工作目录 + done_at),只有平台管理员落得了盘。
    // 成员那边压根不该弹:弹了他一步步认真填完,最后一颗「开始使用」必然 403,退都退不出去。
    can_finish: isPlatformOwner(req),
    brain: {
      ok: brainOk,
      via: brainViaEngine ? "engine" : "api",
      name: brainViaEngine ? engineId : (active ? active.name : ""),
      model: brainViaEngine ? ((config.agent.engine_options || {})[engineId] || {}).model || "" : (active ? active.model : ""),
    },
    active_model: config.active_model,
    workspace_dir: getWorkspaceDir(),
    models,
    // 向导的「服务商」清单从目录来,不再靠 config 里那排没 Key 的模板行撑场面
    templates: chatModels.templates(),
    // 「自己填地址」那一项的接口格式下拉:跟设置里渠道卡用的同一份
    api_formats: mediaModels.API_FORMATS.map((f) => ({ id: f.id, label: f.label })),
    any_key: models.some((m) => m.has_key && !m.local),
    // models / model:向导里给本机 CLI 钉型号用(候选 + 已存的那个),型号不填不让过
    engines: found.map((e) => ({ id: e.id, label: e.label, installed: e.installed, version: e.version, install: e.install || "", note: e.note || "",
      models: Array.isArray(e.models) ? e.models.slice(0, 50) : [], model: String(((e.options || {}).model) || "") })),
    engine: engineId,
    // 「配好了」不等于「有 Key」:自定义那家认的是地址,不要鉴权的自建接口本来就没有 Key
    search: { provider: sp, has_key: searchProviderReady(sc, sp, searchProviderKey(sc, sp)) },
    media: Object.fromEntries(mediaModels.CAPS.map((c) => [c, mediaOk(c)])),
    im: { configured: imConfigured },
  });
});

// 填 key → 真发一条最小请求验活 → 通过才落盘。不验就存等于把坑留到用户第一次提问时才炸
app.post("/api/onboarding", async (req, res) => {
  try {
    const b = req.body || {};
    const key = String(b.api_key || "").trim();
    // 自己填地址:中转站、自建网关、本机 vLLM……跟模板那条一样先算、验活、验过了才落
    if (b.custom && typeof b.custom === "object") {
      let plan = chatModels.planCustom(config, b.custom);
      if (plan.err) throw new Error(plan.err);
      if (systemOne.isDecisionModel(plan.row.model)) throw new Error(`「${plan.row.model}」是判断模型(Jev),不能当对话模型用`);
      if (!key && !plan.t.local) throw new Error("API Key 不能为空(本机接口可以不填)");
      if (b.skip_test !== true) {
        const keyOf = (p) => key || (p.prov ? p.prov.api_key : "");
        let r = await probeModelRaw({ ...plan.row, api_key: keyOf(plan) }, { custom: true });
        // 只填了域名、原样打回 404:换成带 /v1 的再验一次,通了就按通了的那个地址存
        if (r.error && r.status === 404 && plan.alt) {
          const alt = chatModels.planCustom(config, { ...b.custom, base_url: plan.alt });
          const r2 = alt.err ? r : await probeModelRaw({ ...alt.row, api_key: keyOf(alt) }, { custom: true });
          if (!r2.error) { plan = alt; r = r2; }
        }
        if (r.error) return res.json({ ok: false, error: r.error });
      }
      const row = chatModels.commitTemplate(config, plan, key);
      chatModels.normalize(config);
      return landBrain(row.name);
    }
    // 按厂商模板新起一条:先算好、验活、验过了才落进 config——验不过的 Key 不该留下一个半成品渠道
    if (b.kind) {
      const plan = chatModels.planTemplate(config, b.kind, b.model_id);
      if (!plan) throw new Error("没有这家服务商:" + b.kind);
      if (systemOne.isDecisionModel(plan.row.model)) throw new Error(`「${plan.row.model}」是判断模型(Jev),不能当对话模型用`);
      if (!key && !plan.t.local) throw new Error("API Key 不能为空");
      if (b.skip_test !== true) {
        const bad = await probeModel({ ...plan.row, api_key: key || (plan.prov ? plan.prov.api_key : "") });
        if (bad) return res.json({ ok: false, error: bad });
      }
      const row = chatModels.commitTemplate(config, plan, key);
      chatModels.normalize(config);
      return landBrain(row.name);
    }
    const entry = (config.models || []).find((m) => m.name === b.model);
    if (!entry) throw new Error("没有这个模型:" + b.model);
    if (!key && !isLocalModel(entry)) throw new Error("API Key 不能为空");
    // 本机那条(Ollama)的型号是向导现问出来的:他电脑上装了什么只有他知道,
    // 库里那条多半还写着出厂模板里的 qwen3:14b。点名了就照他说的来。
    // 先只当作「这一趟拿它去验」,验过了才真写进 config——验不过还把人家原来能用的
    // 型号改掉,等于一次失败的尝试把他本来跑得好好的配置弄坏了。
    const wantModel = String(b.model_id || "").trim() || entry.model;
    if (systemOne.isDecisionModel(wantModel)) throw new Error(`「${wantModel}」是判断模型(Jev),不能当对话模型用`);

    if (b.skip_test !== true) {
      const bad = await probeModel({ ...entry, model: wantModel, api_key: key || entry.api_key });
      if (bad) return res.json({ ok: false, error: bad });
    }
    entry.model = wantModel;

    if (key) {
      // Key 归渠道那一层管(config.providers),不是模型条目。写在条目上看着像成了,
      // 下一次规整会把渠道的空 Key 压平回来,把它抹掉——
      // 于是首页明明填过 Key,后台设置还说没设置。
      const prov = (config.providers || []).find((p) => p.id === entry.channel);
      if (prov) prov.api_key = key;
      else entry.api_key = key; // 还没挂渠道的条目:先写着,紧接着的规整会照它认出或建出渠道
    }
    // 规整一趟:认渠道、并掉重复的空壳行、再把渠道的地址和 Key 压平回模型条目。
    // 不跑这一趟,下面 createLLM 读到的还是旧的扁平字段,这一趟对话照样 401
    chatModels.normalize(config);
    return landBrain(entry.name);
  } catch (e) {
    res.status(400).json({ ok: false, error: e.message });
  }

  /** 三条路验过之后都一样:设成全局在用的模型、按需换工作目录、重建客户端、落盘 */
  function landBrain(name) {
    const b = req.body || {};
    setGlobalModel(name);
    if (b.workspace_dir) {
      config.workspace_dir = setWorkspaceDir(b.workspace_dir);
      ensureProjects();
      const ap = config.projects.find((p) => p.name === config.active_project);
      if (ap) ap.dir = config.workspace_dir;
      noteLayout(config.workspace_dir);
    }
    llmInner = createLLM(config);
    memory.setEmbedder(createEmbedder(config));
    setSessEmbedder(createEmbedder(config));
    memory.ensureVectors().catch(() => {});
    saveConfig();
    return res.json({ ok: true, active_model: config.active_model, model: llm.model, workspace_dir: getWorkspaceDir() });
  }
});

// 向导走完 / 明确跳过:记一笔,下次开机不再弹。大脑没接上时不许记——记了等于把一个什么都干不了的界面留给用户
app.post("/api/onboarding/done", (req, res) => {
  try {
    const b = req.body || {};
    const active = (config.models || []).find((m) => m.name === config.active_model) || (config.models || [])[0];
    const engineId = engines.currentId(config, { user: req.user && req.user.username });
    const brainOk = (engineId !== "builtin" && engines.get(engineId) !== undefined) || !!(active && hasKey(active));
    if (!brainOk) return res.status(400).json({ ok: false, error: "还没接上任何大模型,先把第一步走完" });
    if (b.workspace_dir) {
      config.workspace_dir = setWorkspaceDir(b.workspace_dir);
      ensureProjects();
      const ap = config.projects.find((p) => p.name === config.active_project);
      if (ap) ap.dir = config.workspace_dir;
      noteLayout(config.workspace_dir);
    }
    const skipped = Array.isArray(b.skipped) ? b.skipped.map((x) => String(x).slice(0, 20)).slice(0, 10) : [];
    config.onboarding = { done_at: Date.now(), skipped };
    saveConfig();
    res.json({ ok: true, workspace_dir: getWorkspaceDir(), skipped });
  } catch (e) {
    res.status(400).json({ ok: false, error: e.message });
  }
});

// 直连所配搜索服务商测活(不走 DDG 回退,测的就是这家 key 能不能用)
app.get("/api/search/test", async (req, res) => {
  const t0 = Date.now();
  try {
    const cfg = config.search || {};
    // ?provider=bocha 指名测某一家(设置页每一行后面那颗「测」按的就是这个)。
    // 不指名就跟真搜的时候走同一个挑法:首选,没首选就从上往下第一个配好了的。
    // 这里另外写死一家的话,测试按的是 A、实际搜的是 B,测出来的「可用」不算数
    const asked = String(req.query.provider || "").trim().toLowerCase();
    if (asked && !SEARCH_PROVIDERS[asked]) {
      return res.json({ ok: false, error: `没有「${asked}」这家;这个版本认的是:${Object.keys(SEARCH_PROVIDERS).join(" / ")}` });
    }
    const provider = asked || (cfg.provider || "").toLowerCase()
      || Object.keys(SEARCH_PROVIDERS).find((p) => searchProviderReady(cfg, p, searchProviderKey(cfg, p))) || "";
    if (!provider) return res.json({ ok: false, error: "一家都还没配:先填一个服务商的 Key" });
    const fn = SEARCH_PROVIDERS[provider];
    const key = searchProviderKey(cfg, provider);
    if (!searchProviderReady(cfg, provider, key)) {
      return res.json({ ok: false, provider, error: provider === "custom" ? "自定义搜索还没填接口地址" : "这家还没填 API Key" });
    }
    const items = await fn(key, "OpenAI", 3, cfg);
    // 「0 条」单独说清楚:Key 是好的、接口也通了,就是这一趟没结果。
    // 跟「Key 坏了」混成一句话的话,人会跑去重新申请一把本来好好的 Key
    if (!items.length) return res.json({ ok: false, provider, ms: Date.now() - t0, error: "接口通了,但这次一条结果都没回。Key 应该是好的,多半是对方这趟没搜到" });
    res.json({ ok: true, provider, ms: Date.now() - t0, n: items.length, sample: (items[0].title || items[0].url || "").slice(0, 60) });
  } catch (e) {
    res.json({ ok: false, provider: String(req.query.provider || "").trim().toLowerCase() || undefined, ms: Date.now() - t0, error: e.message });
  }
});

// ---------- 安全中心:审计 / 审批 / 系统授权 ----------
app.get("/api/security/audit", (req, res) => res.json(security.auditList(Math.min(1000, +req.query.limit || 100))));
app.post("/api/security/audit/clear", (_req, res) => {
  security.auditClear();
  res.json({ ok: true });
});
app.get("/api/security/audit/export", (_req, res) => {
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
  res.setHeader("Content-Disposition", `attachment; filename="openworkbuddy-audit-${new Date().toISOString().slice(0, 10)}.log"`);
  res.send(security.auditExport());
});
/**
 * 审批看得见谁的、批得动谁的。
 *
 * 返回 undefined = 不限定(平台管理员,以及双击打开的桌面版——那儿屏幕前就一个人)。
 * 返回登录名 = 只限定他自己那条:审批卡片上写的是别人任务要跑的整条命令,路径、域名、
 * 脚本片段都在里面,多人共用一台服务器时那是别人的东西。
 */
const approvalScope = (req) =>
  admin.isSoloDesktop() || admin.platformAdmin(req.user) ? undefined : (req.user && req.user.username) || "";
app.get("/api/security/approvals", (req, res) => {
  const scopeTo = approvalScope(req);
  res.json({
    items: security.listApprovals(scopeTo),
    mode: security.permissionMode(config.security),
    session_allow: security.listSessionAllow(scopeTo),
    // 界面照这个决定要不要画「一直允许」那颗按钮:会 403 的按钮不该摆在那儿
    can_always: scopeTo === undefined,
    // 服务器此刻的钟:界面按 deadline - now 算还剩多久,两边的钟差多少都不影响倒计时
    now: Date.now(),
  });
});
/**
 * 批准/拒绝一条审批。scope:once 只这一次 / session 本次运行期间同类不再问 / always 永久写进放行名单。
 * always 要落盘——「一直允许」点完重启又来问,等于没这个按钮;落盘的规则在安全中心看得见、删得掉。
 *
 * 三档里只有 always 是「改这台服务器」:它把规则写进 config 的永久放行名单,对所有人生效。
 * 所以非平台管理员的 always 降一档按 session 处理,而不是回 403——他那个任务正挂着等这个回答,
 * 403 换来的是任务干等到超时。降了要在返回里说清楚,界面照实说,不许悄悄换个档还报「已永久放行」。
 */
app.post("/api/security/approvals/:id", (req, res) => {
  const body = req.body || {};
  const scopeTo = approvalScope(req);
  let { scope, downgraded } = security.effectiveScope(body.scope, scopeTo !== undefined);
  const r = security.resolveApproval(req.params.id, !!body.allow, scope, scopeTo);
  if (!r.ok) {
    return res
      .status(r.forbidden ? 403 : 409)
      .json({ ...r, error: r.error || "这条审批已经结束了(等超时了,或者别处已经点过)" });
  }
  // danger:/write:/code: 这几类 cmd_allow 管不到(闸里只认本会话),写进去重启后照样问,
  // 按钮说的「重启也生效」就成了假话。降成本会话,照实说
  let reason = "";
  if (scope === "always" && r.ruleKey && !security.isPersistableRule(r.ruleKey)) {
    scope = "session";
    downgraded = true;
    reason = /^chrome:/.test(r.ruleKey) ? "动你 Chrome 的只在这次任务里放行,不进永久名单" : "高危命令、写文件、跑代码只能本次运行期间放行,不进永久名单";
  }
  if (r.ok && body.allow && scope === "always" && r.ruleKey) {
    const sec = security.getSecurity(config);
    const list = Array.isArray(sec.cmd_allow) ? sec.cmd_allow : [];
    if (!list.includes(r.ruleKey)) {
      // 另起一份再写:没配过名单时这就是 DEFAULTS 里那个数组,原地 push 会连默认值一起改掉
      sec.cmd_allow = [...list, r.ruleKey];
      saveConfig();
    }
  }
  res.json({ ...r, scope, downgraded, ...(reason ? { reason } : {}) });
});
// 执行模式表。界面上那个下拉不再自己写四行 HTML,从这儿取——
// 「网页四个、命令行三个」就是抄出来的:goal 是后加的,抄到第三份就漏了。
// 这里没有任何机密,也不按人区分,所以不设门禁:没登录的首屏也得画得出模式菜单。
// plan:Plan 跑完那两颗按钮的字和「开干」时真正发出去的那句(跟终端 /mode 单子同一份,见 modes.js)
app.get("/api/modes", (_req, res) => res.json({ modes: modes.EXEC_MODES, default: modes.DEFAULT_MODE, plan: modes.PLAN_HANDOFF }));

/**
 * 哪条媒体渠道正被熔断闸停着。
 *
 * 熔断本身在后台默默生效就行,但「为什么它不给我看图了」必须有地方能看见——
 * 否则用户只会觉得功能坏了,而真相是我们替他拦下了一条撞不通的路。
 * 设置 → 模型 页顶上那条黄条读的就是这个。
 */
app.get("/api/media-health", (_req, res) => res.json({ paused: mediaHealth.list() }));
app.post("/api/media-health/reset", (req, res) => {
  mediaHealth.reset(String((req.body || {}).cap || "") || undefined);
  res.json({ ok: true, paused: mediaHealth.list() });
});
app.get("/api/security/modes", (req, res) =>
  // can_switch:档位是整台服务器一份(决定 agent 动手前问不问),普通成员改不了。
  // 界面拿它决定那个 🛡️ 菜单画成可点的还是只读的——不然点下去只有一句「切换失败」。
  res.json({ modes: security.PERMISSION_MODES, current: security.permissionMode(config.security), can_switch: isPlatformOwner(req) })
);
app.post("/api/security/mode", (req, res) => {
  // 自守一道,别只靠门禁表。这个开关一改,全站的 agent 动手前问不问就变了——
  // 「只有一层前缀表把门」的东西,表上出一点差错就是整台机器的审批被人关掉。
  // 上面那条 GET 已经回了 can_switch: isPlatformOwner(req),判据用同一个,不会出现「界面说能改、后端不让」。
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "权限档位是整台服务器一份的,归平台管理员管", platform_only: true });
  const mode = String((req.body || {}).mode || "");
  if (!security.PERMISSION_MODES[mode]) return res.status(400).json({ error: "未知的权限档位" });
  security.getSecurity(config).permission_mode = mode; // 走 getSecurity 补默认值,别把别的字段挤掉
  saveConfig();
  security.audit("权限档位", `切换到「${security.PERMISSION_MODES[mode].label}」`, "放行");
  res.json({ ok: true, mode });
});
/**
 * 外挂的第二把尺子现在在不在。界面拿它决定那张卡片怎么画——
 * 没装就别摆一排看着能用、实际没生效的开关,直接给一行安装命令和那句授权说明。
 * 只回「有没有、哪个版本、在哪」,不回扫描结果:这个接口是给设置页用的,不该顺手跑一趟扫描。
 */
app.get("/api/security/toolward", (req, res) => {
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "这项是整台服务器一份的,归平台管理员管", platform_only: true });
  try {
    res.json(toolward.status(config));
  } catch (e) {
    // 探测本身出错也得给界面一个能画的形状,别让设置页因为一个可选组件白屏
    res.json({ mode: "auto", on: false, bin: "", version: "", why: String(e.message || e), install: toolward.INSTALL_HINT, licence: toolward.LICENCE_NOTE });
  }
});
app.post("/api/security/session-allow/clear", (req, res) => {
  security.clearSessionAllow(approvalScope(req));
  res.json({ ok: true });
});
app.get("/api/security/system", (_req, res) => {
  res.json({
    platform: process.platform,
    desktop: !!process.versions.electron,
    fulldisk: security.checkFullDisk(),
    accessibility: security.checkAccessibility(),
    automation: "unchecked", // 主动探测会触发系统弹窗,改为用户点「检测/授权」时才查
  });
});
app.post("/api/security/system/check-automation", async (_req, res) => {
  res.json({ automation: await security.checkAutomation() });
});
app.post("/api/security/system/open", (req, res) => {
  res.json({ ok: security.openPrefPane((req.body || {}).pane) });
});
// 桌面版窗口全屏切换(Web 版由前端 requestFullscreen 兜底)
app.post("/api/app/fullscreen", (_req, res) => {
  if (global.__wbWin) {
    try {
      global.__wbWin.setFullScreen(!global.__wbWin.isFullScreen());
      return res.json({ ok: true });
    } catch {}
  }
  res.json({ ok: false });
});

// 检查更新只有一条路:updater.js 的 GET /api/update(查 GitHub Releases,按安装方式给该做什么)。
// 以前这里还有一个在服务端跑 git 的检查更新接口——装机版根本没有 .git,
// 点一次报一次「未配置更新源」,等于一颗永远点不动的按钮,删了。

// ---------- MCP 连接器管理 ----------
// 底层引擎:探测本机装没装 Claude Code / Codex。只跑 --version,不消耗任何额度
app.get("/api/engines", async (req, res) => {
  try {
    // ?force=1 = 用户点了「重新检测本机」(刚装完 CLI,必须当场看见);平时吃缓存,别每开一次设置页就起一堆子进程
    const myAgent = prefs.agentCfg(config); // 「当前用的是哪个引擎」是按账号的,别把别人选的报给他
    // Windows:进程里的 PATH 定格在启动那一刻,刚 winget 装的东西不在里面。点「重新检测」时现读一次注册表,
    // 跟启动时那份合并(体检、run_shell 都用这一份)。读不成它自己会留日志,照旧按手里那份 PATH 检测;别的系统直接返回
    if (req.query.force === "1") await require("./src/platform/which").refreshWinPath();
    // 点这一下顺带联网问一下本机 CLI 有没有新版(平时开设置页只看缓存,不联网)
    const found = await engines.detectAll(myAgent.engine_options || {}, { force: req.query.force === "1", latest: req.query.force === "1" });
    // 闸的状态一并给前端:多人共用时开没开、放行哪些型号、命令能不能联网。只读属主那份,成员改不了
    const withGate = found.map((e) => ({ ...e, gate: engines.gateView(e.id, config) }));
    res.json({ current: engines.currentId(config, { user: req.user && req.user.username }), builtin: engines.BUILTIN, engines: withGate, multiUser: security.isMultiUser(), shellOff: orgShellOff() });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

/**
 * 两条工作线:侧栏那两个标签各自是什么、此刻有没有活儿在跑。
 *
 * 工作线分的是**干哪种活儿、在哪儿干**(办公 / 工程),不是「用哪个模型」。
 * 底层引擎(内置循环 / 本机 Claude Code / Codex)是用户在设置里挑一次、两条线共用的另一件事——
 * 早先版本把它俩绑在一起,结果「切个标签」能把别人配好的引擎一起换掉。
 *
 * 工程线连的是**这台机器上的 `openworkbuddy` 命令行**:终端里起的任务会自己挂到 data/cli-live/ 下,
 * 这里如实报「现在有几趟在跑」。前端不许自己猜这件事——命令行是不是还活着要看心跳和 pid,
 * 只有服务端摸得到。
 */
app.get("/api/lanes", (req, res) => {
  try {
    let cli = [];
    // 终端属于这台机器的主人。租户账号看见别人电脑里正在跑什么,是越权
    if (canRemoteControl(req)) { try { cli = cliLive.list(); } catch {} }
    const liveCount = cli.filter((r) => r.live).length;
    res.json({
      lanes: lanes.LANES.map((l) => ({ id: l.id, name: l.name, short: l.short, hint: l.hint, detail: l.detail })),
      current: lanes.DEFAULT_LANE,
      // 工程线上此刻从终端起的活儿:有几趟在跑、都是什么
      cliLive: cli,
      cliRunning: liveCount,
    });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

/**
 * 终端里正在跑的那几趟活儿。
 *
 * 跟 /api/chat/running 是两本账:那本是这个服务进程自己跑的(内存里),这本是**别的进程**
 * (`openworkbuddy` 命令行)跑的,只能从盘上那个目录读。手机上打开「工程」标签看到的就是这一份。
 */
app.get("/api/cli/live", (req, res) => {
  // allowed:false 前端本来就认(画成一句「这里看不到」而不是空列表),所以关掉开关不会白屏
  if (!canRemoteControl(req)) return res.json({ rows: [], allowed: false, remote_off: !account.remoteAllowed("remote_control", req.user) });
  try {
    res.json({ rows: cliLive.list(), allowed: true, staleMs: cliLive.STALE_MS });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

/**
 * 跟一趟终端里的活儿:先补上已经发生的,再接着直播。
 *
 * 口径跟 /api/chat/stream 一样(from = 已经看过几个事件),前端一套代码两边都能用。
 * 直播靠轮询文件尾部——fs.watch 在 macOS/Linux/容器里的行为各不相同,几百毫秒一次的
 * 读尾部比它可靠得多,而且读的是增量,不是整个文件。
 */
app.get("/api/cli/stream/:id", (req, res) => {
  if (!canRemoteControl(req)) return res.status(403).json({ error: cliOffReason(req, "看得到") });
  const sid = String(req.params.id || "");
  const meta = cliLive.get(sid);
  if (!meta) return res.status(404).json({ error: "终端里没有这趟活儿(可能已经跑完很久了)" });
  // 同 /api/chat/live:请求在中间件那段就被掐了,close 早发过,下面 400ms 一次的读尾部定时器就一直转到这趟活儿跑完。
  // 2026-09-29 复审同一个写法,终端里一趟活儿跑几个小时,页面每重开一次都可能多留一个
  if (res.destroyed || req.socket.destroyed) return;
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.on("error", () => {});
  const from = Math.max(0, parseInt(req.query.from, 10) || 0);
  const send = (ev) => { try { res.write(`data: ${JSON.stringify(ev)}\n\n`); } catch {} };
  let pos = 0;
  try {
    const first = cliLive.read(sid, { fromLine: from });
    first.events.forEach(send);
    pos = first.pos;
  } catch {}
  let closed = false;
  req.on("close", () => { closed = true; clearInterval(timer); });
  const timer = setInterval(() => {
    if (closed) return;
    try {
      const more = cliLive.read(sid, { fromByte: pos });
      if (more.reset) { pos = 0; return; } // 命令行开了新的一轮,下一拍从头补
      more.events.forEach(send);
      pos = more.pos;
      const now = cliLive.get(sid);
      if (!now || !now.live) {
        // 跑完了(或者被强杀了):如实说一声再收摊,别让手机上那个圈一直转
        send({ type: "cli_end", ok: !!(now && now.endedAt && !now.error), error: (now && now.error) || (now && now.died ? "命令行进程没了(可能是终端被关掉了)" : null) });
        clearInterval(timer);
        try { res.end(); } catch {}
      }
    } catch {}
  }, 400);
});

/**
 * 往终端里那趟活儿插一句话。
 *
 * 写进 data/cli-live/<id>.in,命令行在两步之间读走。写不进去就如实报错——
 * 界面上显示「已发送」而其实没送到,比直接说送不到糟得多。
 */
app.post("/api/cli/interject", (req, res) => {
  if (!canRemoteControl(req)) return res.status(403).json({ ok: false, error: cliOffReason(req, "插得上话") });
  const { sessionId, message } = req.body || {};
  const text = String(message || "").trim();
  if (!text) return res.status(400).json({ ok: false, error: "消息为空" });
  const row = cliLive.get(String(sessionId || ""));
  if (!row) return res.status(404).json({ ok: false, error: "终端里没有这趟活儿" });
  if (!row.live) return res.status(409).json({ ok: false, error: "这趟已经跑完了,插话没人接" });
  if (!cliLive.interject(row.id, text)) return res.status(500).json({ ok: false, error: "写不进去(磁盘满了或者目录没权限)" });
  res.json({ ok: true });
});

/**
 * 终端里那趟活儿此刻卡在等什么(一道选择题,或者一条要批准的命令)。
 *
 * 这条口子是给手机用的。终端里等回答是「卡住不动直到超时」,而超时对审批来说等于**拒绝**:
 * 人去楼下拿了杯咖啡,回来只看见「用户没批准,我跳过了」——他从来没被问到过。
 */
app.get("/api/cli/pending", (req, res) => {
  if (!canRemoteControl(req)) return res.json({ rows: [], allowed: false, remote_off: !account.remoteAllowed("remote_control", req.user) });
  const sid = String(req.query.sessionId || "").trim();
  try {
    const ids = sid ? [sid] : cliLive.list({ prune: false }).filter((r) => r.live).map((r) => r.id);
    const rows = [];
    for (const id of ids) for (const a of cliLive.pending(id)) rows.push({ ...a, sessionId: id });
    // now 同审批列表:手机和电脑的钟对不齐,倒计时按服务器的钟校正
    res.json({ rows, allowed: true, now: Date.now() });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

/**
 * 在手机上把那道题答了。
 *
 * 不在这儿判答案对不对:终端那边 cli-ask 本来就有一套「敲一半算选了哪条」的解析,
 * 手机上答的走同一份,省得两边对同一句话给出两种理解。
 */
app.post("/api/cli/answer", (req, res) => {
  if (!canRemoteControl(req)) return res.status(403).json({ ok: false, error: cliOffReason(req, "答得了") });
  const { sessionId, askId, value } = req.body || {};
  const id = String(askId || "").trim();
  if (!id) return res.status(400).json({ ok: false, error: "不知道在答哪道题" });
  const row = cliLive.get(String(sessionId || ""));
  if (!row) return res.status(404).json({ ok: false, error: "终端里没有这趟活儿" });
  if (!row.live) return res.status(409).json({ ok: false, error: "这趟已经跑完了,答案没人接" });
  // 已经不等了(终端那边先答了,或者超时了)就说清楚,别让界面显示「已提交」而其实没人收
  if (!cliLive.pending(row.id).some((a) => a.id === id)) {
    return res.status(409).json({ ok: false, error: "这道题已经答过了,或者等超时了" });
  }
  if (!cliLive.answer(row.id, id, value)) return res.status(500).json({ ok: false, error: "写不进去(磁盘满了或者目录没权限)" });
  res.json({ ok: true });
});

/**
 * 思考模式:每一档到底会发什么,如实报给设置页。
 *
 * 为什么不让前端自己写死一张表:各家的参数名一年能改两回,而「这一档对你选的这个模型
 * 到底生不生效」只有服务端知道(要看 provider、base_url、模型名,走本机 CLI 时还要看
 * 那个 CLI 认不认这个选项)。写死在前端 = 界面上写着"已关闭"、实际一个参数都没发出去。
 *
 * 所以这里把 5 个档位逐个算一遍,supported=false 的连同原因一起给出去,界面照抄。
 */
app.get("/api/thinking", async (req, res) => {
  try {
    const myAgent = prefs.agentCfg(config);
    const engineId = engines.currentId(config, { user: req.user && req.user.username });
    const entry = (config.models || []).find((m) => m.name === config.active_model) || (config.models || [])[0] || {};
    // 本机 CLI 的能力探测(claude 对不认识的选项是静默忽略的,非探不可),探不动就当没有
    let caps = {};
    if (engineId !== "builtin") {
      try {
        const found = await engines.detectAll(myAgent.engine_options || {});
        caps = (found.find((e) => e.id === engineId) || {}).caps || {};
      } catch {}
    }
    const levels = thinking.LEVELS.map((lv) => {
      const api = thinking.planFor(entry, lv);
      const eng = engineId === "builtin" ? null : thinking.planForEngine(engineId, lv, caps);
      const active = eng || api;
      return { level: lv, label: thinking.LEVEL_LABEL[lv], supported: active.supported, note: active.note };
    });
    res.json({
      current: thinking.norm(myAgent.thinking),
      via: engineId === "builtin" ? "api" : "engine",
      target: engineId === "builtin" ? (entry.name || "") : engineId,
      levels,
    });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

/**
 * 一键连接:真的连一次,不是看看文件在不在。
 *
 * 设置页上「已装 ✓」和「装了但没登录」长得一模一样——用户点了切换,然后每个任务都在
 * 原地报错。这个接口花几十个 token 真跑一句话过去,把「能用 / 没登录 / 限流 / 装坏了」
 * 分开告诉用户,并且给出下一步该干什么。跑在系统临时目录里,不往工作区留东西。
 */
app.post("/api/engines/test", async (req, res) => {
  const id = String((req.body && req.body.id) || "").trim();
  if (!id || id === "builtin") return res.status(400).json({ error: "内置引擎不用测,它走的是你配的 API Key" });
  try {
    // 用户可能刚在输入框里改了路径/模型还没保存,先用他正在填的那份测
    const patch = (req.body && req.body.options) || {};
    const member = ownPrefs(req);
    const typed = patch.model !== undefined ? String(patch.model || "").trim() : undefined;
    // 试连也过闸:成员试的型号得在放行列表里、引擎得已打开;属主试连可以先于打开和保存,
    // 但命令行开关、附加参数、要有型号这几条照查——试连本身就是真跑一句话
    let pass;
    try {
      pass = engines.admit(id, config, { shellOff: orgShellOff(), ...(typed ? { model: typed } : {}), trial: !member });
    } catch (e) {
      if (!e || !e.engineGate) throw e;
      return res.json({ ok: false, ms: 0, engine: id, path: "", version: "", reply: "", model: "", why: e.message, hint: "" });
    }
    const opts = { ...pass.opts };
    // bin 是「起哪个可执行文件」——在多人服务器上等于任意命令执行。只有平台管理员能指定,
    // 其他人一律用已保存的那份(他们本来也改不了它)
    if (!member && patch.bin !== undefined) opts.bin = String(patch.bin || "").trim();
    for (const k of Object.keys(opts)) if (opts[k] === "" || opts[k] == null) delete opts[k];
    res.json(await engines.testConnect(id, opts));
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// 预设目录:常用 MCP 服务器一键接入;顺带告诉前端本机找没找到 npx / uvx
app.get("/api/mcp/catalog", (_req, res) => {
  const configured = new Set((config.mcp_servers || []).map((s) => s.name));
  // 把已经在内存里的 config 递进去:内网开关就在里面,别让每次请求都去读一遍盘
  const cat = mcpCatalog.catalog({ cfg: config });
  res.json({ ...cat, items: cat.items.map((it) => ({ ...it, configured: configured.has(it.name) })) });
});

/**
 * 给非平台管理员看的连接器地址:只留「协议 + 主机」。
 * 很多托管 MCP 把密钥直接拼在地址里(路径里一截 sk-…、查询串 ?api_key=…、user:pass@),
 * 启动参数里也常有 --token=…。redactGuard 只认字段名,url/args 这两个名字它不管,
 * 原来多人部署下普通成员 GET 一下就能拿到原文。
 */
function mcpPublicUrl(u) {
  try { const x = new URL(String(u || "")); return x.protocol + "//" + x.host; } catch { return u ? "(地址已隐藏)" : ""; }
}
/**
 * 一条自配连接器此刻的版本号:连接器页打开时记下,保存时原样带回来(base_revs),
 * 后端拿它认出「页上这条已经旧了」——页面开着的这会儿,agent 或手改 config.json 把它换过了。
 * 不直接给 cfgFingerprint:那是连 Key 一起算的哈希,短 Key 拿去离线撞得出来;
 * 套一层进程里随机钥匙的 HMAC,只能比对、倒推不出。重启后钥匙换了,旧页面带回来的一律对不上,
 * 按「旧了」处理——最坏是那几条原样不动,不会写坏。
 */
const MCP_REV_KEY = require("crypto").randomBytes(16);
function mcpRev(s) {
  return require("crypto").createHmac("sha256", MCP_REV_KEY).update(cfgFingerprint(s || {})).digest("hex").slice(0, 16);
}
/** 「授权没了」的判据:mcp.js 里 whyFailed 翻出来的那句话,401/403 那条固定带着「(401)Key/令牌」 */
const MCP_AUTH_BAD = /(401)|(403)|Key/令牌/;
/** 模型这会儿手上的连接器工具有几个:设了不让用的不算(agent.js 摆工具表时也摘掉了) */
const mcpOffered = () => mcpManager.toolDefs((s, t) => mcpPolicy.offered(config.mcp_tool_policy, s, t)).length;
/** 从本机别的应用导连接器:只在服务跑在自己电脑上(只绑回环地址、只有一个账号)、由平台管理员来做 */
const mcpImportAvailable = (req) => admin.servesLoopback() && !admin.multiUser() && isPlatformOwner(req);
/** 连接器的名字在不在(自配的 + 插件带的)。插件坏了不该让连接器页上的按钮点不动 */
function mcpFind(name) {
  let fromPlugins = [];
  try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch {}
  return [...(config.mcp_servers || []), ...fromPlugins].find((x) => x && x.name === name) || null;
}
app.get("/api/mcp", (req, res) => {
  const full = isPlatformOwner(req);
  const view = (s, plugin) => {
    const client = mcpManager.clients.get(s.name);
    const failure = mcpManager.failures.find((f) => f.name === s.name);
    return {
      name: s.name,
      // 本机路径和启动参数只给平台管理员:成员反正改不了(POST /api/mcp 在平台写表里),用不着原文
      command: full ? s.command || "" : path.basename(String(s.command || "")),
      args: full ? s.args || [] : [],
      args_hidden: full ? 0 : (s.args || []).length,
      // 环境变量里基本都是 API Key,和请求头一样只回键名,不回值
      env_keys: Object.keys(s.env || {}),
      url: full ? s.url || "" : mcpPublicUrl(s.url),
      masked: !full,
      // 请求头里常有 token,界面上只说有几个,不回传值
      header_keys: Object.keys(s.headers || {}),
      transport: s.transport || (s.command ? "stdio" : "streamable-http"),
      plugin, // 插件带来的:界面上只读,不许当成 config 里的条目存回去
      // 保存时带回来比对用(见 mcpRev)。只有能存的人要:成员拿到也没用
      rev: full && !plugin ? mcpRev(s) : "",
      // 关掉的那几台:既没连上也没失败,界面要能分清「连不上」和「我自己关的」——
      // 少了这一个字段,用户关掉一台之后看到的是一张灰卡片,跟连接失败长得一模一样
      enabled: !mcpManager.disabled.has(s.name),
      error: failure ? failure.error : "",
      // 「授权没了」和「连不上」在界面上是两件事:前者要用户现在就去换 Key,后者多半过会儿自己就好。
      // 判据就是 mcp.js 里 whyFailed 翻出来的那句话——401/403 那条固定带着「(401)Key/令牌」。
      auth_bad: failure ? MCP_AUTH_BAD.test(failure.error || "") : false,
      connected: !!client,
      oauth: s.url ? mcpOAuth.status(s) : { available: false, authorized: false },
      tools: client ? client.tools.map((t) => ({ name: t.name, description: (t.description || "").slice(0, 200) })) : [],
      // 每个工具设的权限(直接用 / 先问我 / 不让用,空 = 跟着安全档位走):页上照着画,改只有平台管理员能改
      policy: mcpPolicy.viewOf(config.mcp_tool_policy, s.name),
    };
  };
  // config.json 里配的 + 插件 mcp.json 里声明的,一起列出来,
  // 否则「已注入 N 个工具」的 N 里有一半找不到对应的卡片,用户以为是幻觉。
  let fromPlugins = [];
  try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了不该让连接器页打不开 */ }
  const servers = (config.mcp_servers || []).map((s) => view(s, ""))
    .concat(fromPlugins.map((s) => view(s, s.plugin)))
    .filter((s) => !memberAccess.hidden(config, "mcp", s.name, req.user ? req.user.username : ""));
  res.json({
    servers, total_tools: mcpOffered(), can_toggle: isPlatformOwner(req),
    // 没设权限的工具跟着这一档走(页上要说清「先问我」在全自动下也问)
    perm_mode: security.permissionMode(security.getSecurity(config)),
    import_available: mcpImportAvailable(req),
  });
});

/**
 * 单台连接器的开关。
 *
 * 为什么不复用 POST /api/mcp:那条是「把整张表存回去」。GET 出去的时候 env / headers 的值
 * 被抹成了键名(里头是 API Key),前端手上根本没有完整的表;为了点一下开关而整表回存,
 * 等于每次都拿一份缺了密钥的副本去覆盖真的那份——现在靠 prevByName 兜着,但那是兜底,不该当主路走。
 * 何况插件带来的连接器压根不在 config.mcp_servers 里,整表回存对它们无从下手。
 *
 * 关掉 = 停掉进程 + 从工具表里摘掉。摘掉这一步才是重点:留着定义,模型会先想一个用它的方案、
 * 调一次、吃一条「连不上」、再重想——白烧一轮。这和 toolList 里对 run_shell 的处理是同一个道理。
 */
function oauthServer(name) {
  adoptDiskMcp();
  const all = [...(config.mcp_servers || []), ...pluginsMgr.pluginMcpServers()];
  return all.find((s) => s.name === name && s.url && !s.command) || null;
}
const oauthOwner = (req, res) => { if (isPlatformOwner(req)) return true; res.status(403).json({ error: "连接器授权由平台管理员管理", platform_only: true }); return false; };
app.post("/api/mcp/oauth/start", async (req, res) => {
  if (!oauthOwner(req, res)) return;
  try {
    const body = req.body || {}, cfg = oauthServer(String(body.name || ""));
    if (!cfg) return res.status(404).json({ error: "没有可授权的远程连接器" });
    const origin = req.get("origin") || req.protocol + "://" + req.get("host");
    const callback = new URL("/api/mcp/oauth/callback", origin).href;
    if (body.redirect_uri !== callback) return res.status(400).json({ error: "OAuth 回调必须回到当前工作台" });
    res.set("Cache-Control", "no-store");
    res.json(await mcpOAuth.begin(cfg, req.user.username, callback, { client_id: body.client_id, client_secret: body.client_secret, client_metadata_url: body.client_metadata_url, scope: body.scope }));
  } catch (e) { res.status(400).json({ error: mcpScrub(e.message) }); }
});
app.post("/api/mcp/oauth/finish", async (req, res) => {
  if (!oauthOwner(req, res)) return;
  try {
    const result = await mcpOAuth.finish(String((req.body || {}).flow_id || ""), req.user.username, (cfg) => oauthServer(cfg.name)?.url === cfg.url);
    if (!result.ready) return res.json({ ready: false });
    const cfg = oauthServer(result.cfg.name);
    if (!cfg || cfg.url !== result.cfg.url) { mcpOAuth.revoke(result.cfg); return res.status(409).json({ error: "连接器已改变,请重新开始授权" }); }
    await mcpApply(async () => { mcpManager.stop([cfg.name]); await mcpManager.startAll([cfg]); });
    res.json({ ready: true, connected: mcpManager.clients.has(cfg.name), enabled: !mcpManager.disabled.has(cfg.name), name: cfg.name });
  } catch (e) { res.status(400).json({ error: mcpScrub(e.message) }); }
});
app.post("/api/mcp/oauth/cancel", (req, res) => {
  if (!oauthOwner(req, res)) return;
  mcpOAuth.cancel(String((req.body || {}).flow_id || ""), req.user.username); res.json({ ok: true });
});
app.post("/api/mcp/oauth/remove", async (req, res) => {
  if (!oauthOwner(req, res)) return;
  try {
    const cfg = oauthServer(String((req.body || {}).name || ""));
    if (!cfg) return res.status(404).json({ error: "没有这台连接器" });
    mcpOAuth.revoke(cfg); await mcpApply(async () => { mcpManager.stop([cfg.name]); });
    res.json({ ok: true });
  } catch { res.status(400).json({ error: "移除授权失败,请稍后重试" }); }
});
app.post("/api/mcp/toggle", async (req, res) => {
  // 连接器是整台机器一份的:一个人关掉,所有人的任务都少一批工具。跟权限档位同一个判据。
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "连接器是整台机器一份的,开关归平台管理员", platform_only: true });
  try {
    const name = String((req.body || {}).name || "").trim();
    if (!name) throw new Error("要说明开关哪一台连接器");
    adoptDiskMcp(); // mcp_disabled 整张表一起存:外面刚改的先认进来,别拿旧表盖掉
    let fromPlugins = [];
    try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了不该让开关点不动 */ }
    const all = [...(config.mcp_servers || []), ...fromPlugins];
    const cfg = all.find((x) => x.name === name);
    if (!cfg) throw new Error(`没有这台连接器:${name}`);
    const on = (req.body || {}).enabled !== false;

    const off = new Set(config.mcp_disabled || []);
    if (on) off.delete(name); else off.add(name);
    // 只留还存在的名字:连接器删掉之后,它的名字不该在这张表里长住
    config.mcp_disabled = [...off].filter((n) => all.some((x) => x.name === n));
    saveConfig();
    await mcpApply(async () => {
      mcpManager.setDisabled(config.mcp_disabled);
      if (on) await mcpManager.startAll([cfg]); // 只起这一台,别把别人的连接踢了重连
      else mcpManager.stop([name]);
      // 只记这一台的开关已生效:别的名字要是外面也改了开关还没同步,留给 syncMcp
      if (MCP_APPLIED) {
        if (on) MCP_APPLIED.disabled.delete(name); else MCP_APPLIED.disabled.add(name);
        if (!cfg.plugin) MCP_APPLIED.servers.set(name, cfgFingerprint(cfg));
      }
    });
    res.json({ ok: true, name, enabled: on, connected: mcpManager.clients.has(name), total_tools: mcpOffered() });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

/** 只重连这一台:Key 刚换过、对方服务刚恢复。别的连接器不动,回它现在的样子 */
app.post("/api/mcp/reconnect", async (req, res) => {
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "连接器是整台机器一份的,重连归平台管理员", platform_only: true });
  try {
    const name = String((req.body || {}).name || "").trim();
    if (!name) throw new Error("要说明重连哪一台连接器");
    adoptDiskMcp();
    const cfg = mcpFind(name);
    if (!cfg) throw new Error(`没有这台连接器:${name}`);
    if ((config.mcp_disabled || []).includes(name)) throw new Error("这台关着,先打开再重连");
    await mcpApply(async () => {
      mcpManager.setDisabled(config.mcp_disabled || []);
      mcpManager.stop([name]);
      await mcpManager.startAll([cfg]);
      if (MCP_APPLIED && !cfg.plugin) MCP_APPLIED.servers.set(name, cfgFingerprint(cfg));
    });
    const c = mcpManager.clients.get(name);
    const f = mcpManager.failures.find((x) => x.name === name);
    res.json({
      ok: true, name, enabled: true, connected: !!c, tool_count: c ? c.tools.length : 0,
      error: f ? f.error : "", auth_bad: f ? MCP_AUTH_BAD.test(f.error || "") : false, total_tools: mcpOffered(),
    });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

app.post("/api/mcp/credentials", async (req, res) => {
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "连接器凭据归平台管理员管理", platform_only: true });
  try {
    adoptDiskMcp();
    const b = req.body || {};
    const name = typeof b.name === "string" ? b.name : "";
    const prev = (config.mcp_servers || []).find((s) => s.name === name);
    if (!prev) return res.status(404).json({ error: "没有这台连接器" });
    if (!b.rev || b.rev !== mcpRev(prev)) return res.status(409).json({ error: "连接器已被修改,请刷新后再改凭据" });
    if (prev.url && mcpOAuth.status(prev).authorized && Object.entries(b.headers_patch || {}).some(([k, v]) => k.toLowerCase() === "authorization" && typeof v === "string" && v.trim())) {
      return res.status(409).json({ error: "请先移除本机 OAuth 授权,再修改 Authorization 请求头" });
    }
    const entry = normalizeMcpServer({ ...prev, env_patch: b.env_patch, headers_patch: b.headers_patch }, 0, new Map([[name, prev]]));
    const status = await connectorCommit(entry, { expectedRev: b.rev });
    res.json({ ok: true, ...status });
  } catch (e) { res.status(e.status || 400).json({ error: e.message }); }
});

/**
 * 一台连接器的工具权限:{server, default?, tools?: {工具: 策略}},策略是 allow / ask / deny,给 "" 就是回到跟着档位走。
 * 跟开关同一个判据:只有平台管理员能改(成员的任务也在用这台)
 */
app.post("/api/mcp/policy", (req, res) => {
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "连接器工具的权限归平台管理员设", platform_only: true });
  try {
    const b = req.body || {};
    const server = String(b.server || "").trim();
    if (!mcpFind(server)) throw new Error(`没有这台连接器:${server}`);
    /** @type {{default?: unknown, tools?: unknown}} */
    const patch = {};
    if (Object.prototype.hasOwnProperty.call(b, "default")) patch.default = b.default;
    if (b.tools && typeof b.tools === "object") patch.tools = b.tools;
    config.mcp_tool_policy = mcpPolicy.withServer(config.mcp_tool_policy, server, patch);
    saveConfig();
    res.json({ ok: true, server, policy: mcpPolicy.viewOf(config.mcp_tool_policy, server), total_tools: mcpOffered() });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

/**
 * 从本机别的应用(Claude Desktop、Claude Code、Cursor、Codex)导连接器。
 * GET 只给候选清单:来源、名字、命令或地址、键名,一个值都不带(见 core/ext/mcp-import.js);
 * POST 只递「哪个来源的哪一台」,值由这边自己从文件里取了写进 config,不经过页面。
 * 多人共用的服务端上不出现:那台机器上的文件不是这个人的
 */
app.get("/api/mcp/import", (req, res) => {
  if (!mcpImportAvailable(req)) return res.status(403).json({ error: "从本机导入只在自己电脑上、由管理员来做", platform_only: true });
  const taken = [...(config.mcp_servers || []).map((s) => s.name)];
  try { taken.push(...pluginsMgr.pluginMcpServers().map((s) => s.name)); } catch {}
  res.json(mcpImport.list({ taken }));
});
app.post("/api/mcp/import", async (req, res) => {
  if (!mcpImportAvailable(req)) return res.status(403).json({ error: "从本机导入只在自己电脑上、由管理员来做", platform_only: true });
  try {
    const picks = Array.isArray((req.body || {}).picks) ? req.body.picks.slice(0, 50) : [];
    if (!picks.length) throw new Error("先勾上要导入的连接器");
    const imported = [], skipped = [];
    for (const p of picks) {
      const label = String((p && (p.as || p.name)) || "");
      let raw = null;
      try {
        raw = mcpImport.pick(String(p.source || ""), String(p.name || ""));
        const name = String(p.as || mcpImport.safeName(raw.name)).trim();
        const input = raw.transport === "http" ? { name, url: raw.url, headers: raw.headers } : { name, command: raw.command, args: raw.args, env: raw.env };
        const { entry, exists } = connectorPrepare(input);
        if (exists && p.overwrite !== true) { skipped.push({ name, why: "已经有同名的了" }); continue; }
        imported.push(await connectorCommit(entry));
      } catch (e) {
        // 报错原文里可能带着地址(地址里可能有密钥):换成一个占位再回页面
        const msg = String((e && e.message) || e);
        skipped.push({ name: label, why: raw && raw.url ? msg.split(raw.url).join("(地址)") : msg });
      }
    }
    res.json({ ok: true, imported, skipped, total_tools: mcpOffered() });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

/**
 * 这一刻模型手上到底有哪些工具。
 *
 * 以前这个问题在界面上无解——
 * 工具表是 agent.js 的 toolList() 按「组织关没关命令行、有没有渲染器、配没配发信通道、
 * 连上了几台连接器」当场算出来的,界面上一份都没有。于是「它到底能不能发邮件」
 * 只能靠问它一次、等它答「没配」来确认。
 *
 * 所以这里不另抄一份清单,直接调 runtime.toolList()——摆出来的就是模型看见的那一份,
 * 不会漂。抄一份的下场在 modes 那儿已经演过一遍了(三份手抄,goal 只抄进两份)。
 */
app.get("/api/tools", (req, res) => {
  if (!runtime) return res.json({ tools: [], groups: [], total: 0 });
  const mode = String(req.query.mode || "craft");
  let defs = [];
  try { defs = runtime.toolList(0, mode) || []; } catch (e) { return res.status(500).json({ error: e.message }); }
  const tools = defs.map((t) => {
    const m = String(t.name).match(/^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/);
    return {
      name: t.name,
      // 连接器工具的描述前面带着 `[MCP:服务器] `,那是给模型认来源的,界面上有分组标题了,重复一遍是噪音
      description: String(t.description || "").replace(/^\[MCP:[^\]]+\]\s*/, "").split("\n")[0].slice(0, 160),
      source: m ? "mcp" : "builtin",
      server: m ? m[1] : "",
      short: m ? m[2] : "",
    };
  });
  const groups = [{ key: "builtin", label: "内置工具", count: tools.filter((t) => t.source === "builtin").length }];
  for (const name of new Set(tools.filter((t) => t.source === "mcp").map((t) => t.server))) {
    groups.push({ key: "mcp:" + name, label: name, count: tools.filter((t) => t.server === name).length });
  }
  res.json({ tools, groups, total: tools.length, mode });
});

/** 一条连接器配置规整成后端认的形状;stdio 看 command,远程看 url */
function normalizeMcpServer(s, i, prevByName = new Map()) {
  // 「改 Key」只递改了的那几项(env_patch / headers_patch):值从来不回页面,页上没法整份带回来。
  // 填了的换掉,空着的保持原值;拷一份再改,别动到 config 里那份
  const mcpPatch = (base, patch) => {
    const out = { ...base };
    if (patch && typeof patch === "object" && !Array.isArray(patch)) {
      for (const [k, v] of Object.entries(patch)) if (typeof v === "string" && v !== "" && k !== "__proto__") out[String(k)] = v;
    }
    return out;
  };
  const at = `第 ${i + 1} 个连接器`;
  const name = String(s.name || "").trim();
  if (!name) throw new Error(`${at}缺少 name`);
  if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`${at}的 name「${name}」只能用字母、数字、- 和 _(工具名要按 mcp__服务器__工具 拼)`);
  const url = String(s.url || "").trim();
  const command = String(s.command || "").trim();
  if (!command && !url) throw new Error(`${at}要么填 command(本地进程),要么填 url(远程 Streamable HTTP)`);
  if (url) {
    let u;
    try { u = new URL(url); } catch { throw new Error(`${at}的 url 不是合法地址:${url}`); }
    if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error(`${at}的 url 只支持 http/https`);
    // 请求头里多半是 Authorization,GET 只回 header_keys 不回值;
    // 前端原样存回来时没带 headers,就沿用原来那份,别把令牌洗没了。
    const prev = prevByName.get(name);
    const headers = mcpPatch(s.headers && typeof s.headers === "object"
      ? Object.fromEntries(Object.entries(s.headers).map(([k, v]) => [String(k), String(v)]))
      : (prev && prev.headers) || {}, s.headers_patch);
    const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1";
    if (Object.keys(headers).length && u.protocol === "http:" && !local) {
      throw new Error(`${at}带了请求头(多半是令牌)却走明文 http,令牌会在路上被看光——请改成 https`);
    }
    return { name, transport: "streamable-http", url, headers };
  }
  // env 里是 API Key,GET 只回键名;前端原样存回来时不带 env,就沿用原来那份
  const prev = prevByName.get(name);
  const env = mcpPatch(s.env && typeof s.env === "object"
    ? Object.fromEntries(Object.entries(s.env).map(([k, v]) => [String(k), String(v)]))
    : (prev && prev.env) || {}, s.env_patch);
  for (const k of Object.keys(env)) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Error(`${at}的环境变量名「${k}」不合法(只能字母、数字、下划线,不能以数字开头)`);
  return { name, transport: "stdio", command, args: Array.isArray(s.args) ? s.args.map(String) : [], env };
}

/**
 * 存之前让 toolward 看一眼这批连接器。
 *
 * 连接器以前一次安全检查都不过:从 GitHub 装个技能要过两道闸,而一条
 * `command: npx` + 一串参数的连接器,点个保存就在这台机器上跑起来了——权限比技能大得多。
 * 拿不到结果(没装 toolward、它崩了)就是 null,一切照旧。
 *
 * **页面上只提醒,不拦**:这是用户自己填的命令和地址,不是陌生人的代码。在「保存」这一步硬拦,
 * 用户能做的只有改回去或者关掉整个检查,那这道提醒就等于逼人学会无视它。
 * agent 加的那一路不一样:命令和地址是模型写的,有提醒就摆审批卡(见 tools.js 的 add_connector)。
 * 密钥不出门——redactServers 把 env/headers 的值全换成 ***,只留键名,见 toolward.js。
 */
function connectorAdvice(servers) {
  try {
    // 内置规则先上:toolward 是可选的,没装时不能一条都不查
    const own = connectorGuard.scan(servers), rep = toolward.scanConnectors(servers, config);
    const findings = [...own.findings, ...((rep && rep.findings) || [])];
    if (!findings.length) return null;
    log.warn("mcp", "连接器配置有被提醒的地方(不影响保存)", {
      count: findings.length, rules: [...new Set(findings.map((f) => f.rule))].slice(0, 10),
    });
    return { level: own.level === "block" ? "block" : (rep && rep.level) || own.level, findings: findings.slice(0, 8), more: Math.max(0, findings.length - 8), toolward: rep ? rep.toolward : null };
  } catch (e) {
    log.warn("mcp", "连接器体检没跑成(不影响保存)", { err: e });
    return null;
  }
}

/**
 * 页面整表保存要存成的那张表:规整、查重。
 * baseNames = 页面打开那一刻看到的自配连接器名字。给了就只替换这些,之后才冒出来的
 * (agent 刚加的、有人手改 config.json 加的)原样留着——页面手上那张旧表压根不知道它们,
 * 不能当成「用户删了」。不给(老页面、脚本)还是整表替换,跟以前一样。
 * baseRevs = 页面打开时每条的版本号(GET 回的 rev)。页上只能加、删,已有的那几条是原样带回来的旧样子:
 * 版本号对不上 = 页面开着的时候别处把它换过了,留现在这条,不拿页上的旧地址配新 Key 存回去;
 * 页上见过、现在已经没了 = 别处删了,不复活。
 */
function prepareMcpServers(list, baseNames, baseRevs) {
  if (!Array.isArray(list)) throw new Error("需要 servers 数组");
  const prevByName = new Map((config.mcp_servers || []).filter((s) => s && typeof s === "object").map((s) => [s.name, s]));
  const revs = baseRevs && typeof baseRevs === "object" && !Array.isArray(baseRevs) ? baseRevs : null;
  const sawRev = (k) => !!revs && Object.prototype.hasOwnProperty.call(revs, k);
  let next = [];
  list.forEach((s, i) => {
    const key = String((s && s.name) || "");
    const cur = prevByName.get(key);
    if (sawRev(key) && !cur) return;
    if (sawRev(key) && String(revs[key]) !== mcpRev(cur)) { next.push(cur); return; }
    next.push(normalizeMcpServer(s, i, prevByName));
  });
  if (!Array.isArray(baseNames) && revs) baseNames = Object.keys(revs);
  if (Array.isArray(baseNames)) {
    const seen = new Set(baseNames.map(String));
    const given = new Set(next.map((s) => s.name));
    next = [...next, ...(config.mcp_servers || []).filter((s) => s && !seen.has(s.name) && !given.has(s.name))];
  }
  const dup = next.map((s) => s.name).find((n, i, a) => a.indexOf(n) !== i);
  if (dup) throw new Error(`连接器名字重复:${dup}`);
  return next;
}

/**
 * 连接器的停和起排成一队。页面保存、开关、插件装卸、agent 加连接器、外面改了 config.json 的同步——
 * 谁都可能在别人 startAll 到一半时进来,交错着 stop/start 的结果是一台连两次,或者刚连上就被摘掉。
 */
let mcpQueue = Promise.resolve();
function mcpApply(fn) {
  const run = mcpQueue.then(fn);
  mcpQueue = run.catch(() => {}); // 前一单挂了不该把后面的全堵死;挂的原因由调用方自己接
  return run;
}

/** 整批重启:config 里的加上插件带的。插件那些不在 config 里,只重启 config 的会把它们整批打没(旧版就是这个 bug) */
async function restartAllMcp() {
  const snap = mcpSnapshot(); // 先拍:起的是这一刻的配置,起到一半配置又变了,那是下一单的事
  let fromPlugins = [];
  try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了不该拖累连接器保存 */ }
  const servers = [...(config.mcp_servers || []), ...fromPlugins];
  mcpManager.setDisabled(config.mcp_disabled || []);
  mcpManager.stop([...mcpManager.clients.keys(), ...mcpManager.failures.map((f) => f.name), ...servers.map((s) => s.name)]);
  await mcpManager.startAll(servers); // 失败的单独在日志告警,不阻塞其他
  markMcpApplied(snap);
}

/**
 * agent 加一台连接器的前半段:跟页面保存同一个 normalizeMcpServer、同一份 toolward 体检,
 * 只是不落盘——要不要摆审批卡由工具那边按档位定(见 tools.js add_connector),批了才 commit。
 */
function connectorPrepare(input) {
  adoptDiskMcp(); // 外面刚改过的先认进来,不然「是不是覆盖」会按旧表判
  const prevByName = new Map((config.mcp_servers || []).map((s) => [s.name, s]));
  // 没给请求头 / 环境变量时,原来的 Key 只在还发往同一处时沿用(见 mcp.js sameTarget):
  // 模型换个地址不带头,不该把用户原来的令牌一起递过去。页面保存那条路照旧全沿用
  const want = String((input && input.name) || "").trim();
  const keep = require("./src/agent/mcp").sameTarget(prevByName.get(want), input || {}) ? new Map([[want, prevByName.get(want)]]) : new Map();
  const entry = normalizeMcpServer(input || {}, 0, keep);
  let fromPlugins = [];
  try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了照样能加自己的 */ }
  const owner = fromPlugins.find((s) => s.name === entry.name);
  // 同名的插件连接器开机时会把这条顶掉,加了等于白加,还会让人以为连上的是自己这台
  if (owner) throw new Error(`「${entry.name}」这个名字已经被插件 ${owner.plugin || ""} 的连接器占了,换个名字`);
  const prev = prevByName.get(entry.name) || null;
  const advice = connectorAdvice([entry]);
  return {
    entry,
    exists: !!prev,
    // 同名同配置 = 重连一下,不算覆盖
    same: !!prev && cfgFingerprint(prev) === cfgFingerprint(entry),
    findings: (advice && advice.findings) || [],
  };
}

/** 后半段:落盘、只重启这一台,回它现在的状态 */
function connectorCommit(entry, { expectedRev = "" } = {}) {
  return mcpApply(async () => {
    adoptDiskMcp(); // 等审批那会儿外面可能又改过:在最新那张表上替换,别拿旧表盖回去
    if (expectedRev) {
      const current = (config.mcp_servers || []).find((s) => s && s.name === entry.name);
      if (!current || mcpRev(current) !== expectedRev) throw Object.assign(new Error("连接器已被修改,请刷新后再改凭据"), { status: 409 });
    }
    const list = (config.mcp_servers || []).filter((s) => s && s.name !== entry.name);
    const at = (config.mcp_servers || []).findIndex((s) => s && s.name === entry.name);
    list.splice(at < 0 ? list.length : at, 0, entry); // 覆盖的留在原位,连接器页上不跳位置
    config.mcp_servers = list;
    saveConfig();
    // 上面认进来(或存盘时合进来)的开关表先灌给管理器再起:外面刚把这台关了,startAll 拿旧表判,
    // 就会先把用户刚关掉的进程拉起来、回模型一句「连上了」,等下一轮同步才又停掉
    mcpManager.setDisabled(config.mcp_disabled || []);
    mcpManager.stop([entry.name]);
    await mcpManager.startAll([entry]);
    // 只记这一台已生效(配置和开关都是):别的条目要是外面也改了还没同步,留给 syncMcp 去起
    if (MCP_APPLIED) {
      MCP_APPLIED.servers.set(entry.name, cfgFingerprint(entry));
      if (mcpManager.disabled.has(entry.name)) MCP_APPLIED.disabled.add(entry.name); else MCP_APPLIED.disabled.delete(entry.name);
    }
    return connectorStatus(entry.name);
  });
}

/** 一台连接器此刻的样子,给 agent 的结果用:连上没、几个工具、连不上时的原文 */
function connectorStatus(name) {
  const c = mcpManager.clients.get(name);
  const f = mcpManager.failures.find((x) => x.name === name);
  const cfg = (config.mcp_servers || []).find((s) => s && s.name === name) || {};
  return {
    name,
    disabled: mcpManager.disabled.has(name),
    connected: !!c,
    tools: c ? c.tools.map((t) => `mcp__${name}__${t.name}`) : [],
    // 原文要照抄给人看(不替人猜原因),但先把回显的令牌洗掉
    error: f ? mcpScrub(f.raw || f.error, cfg) : "",
  };
}

app.post("/api/mcp", async (req, res) => {
  try {
    adoptDiskMcp(); // 外面刚改的先认进来:下面替换只动页面认识的那几条,其余的要是旧的就白留了
    const next = prepareMcpServers((req.body || {}).servers, (req.body || {}).base_names, (req.body || {}).base_revs);
    const advice = connectorAdvice(next);

    config.mcp_servers = next;
    // 删掉的连接器,它的工具权限别长住:以后来一台同名的不该继承(插件列不出来时不清,免得误删插件那几台的)
    try { config.mcp_tool_policy = mcpPolicy.prune(config.mcp_tool_policy, [...next.map((s) => s.name), ...pluginsMgr.pluginMcpServers().map((s) => s.name)]); } catch {}
    saveConfig();
    await mcpApply(restartAllMcp);
    res.json({
      ok: true,
      total_tools: mcpOffered(),
      connected: [...mcpManager.clients.keys()],
      // 接入弹窗当场报「已连接 · N 个工具」用
      tool_counts: Object.fromEntries([...mcpManager.clients].map(([n, c]) => [n, c.tools.length])),
      failures: mcpManager.failures,
      advice, // toolward 的第二意见;没装它就是 null
    });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// ---------- 项目(多工作空间,仿官方「项目」:每个项目一个独立工作目录,成果互不混淆) ----------
/**
 * 项目自带的「指令」和挂载的专家/技能/连接器。
 * 指令不是装饰:切到这个项目之后,它会进系统提示词(见 accountedRuntime),
 * 否则用户在弹窗里写一大段项目背景,agent 一个字都看不见。
 */
function projectMeta(body) {
  const arr = (v) => (Array.isArray(v) ? v.map((x) => String(x)).filter(Boolean).slice(0, 30) : []);
  // library_dir:这个项目挂载资料库的哪一块。""=整个资料库(老行为)。
  // 挂坏了不该让整次保存失败——目录可能是后来被人在磁盘上删的——所以校验不过就当没挂
  let lib = "";
  try { lib = libRel(body.library_dir); } catch { lib = ""; }
  return {
    instructions: String(body.instructions || "").slice(0, 4000),
    connectors: arr(body.connectors),
    experts: arr(body.experts),
    skills: arr(body.skills),
    library_dir: lib,
  };
}
function activeProject() {
  ensureProjects();
  return config.projects.find((p) => p.name === config.active_project) || null;
}

/**
 * 组装进系统提示词的项目上下文:指令 + 挂载清单。
 * 挂载在弹窗里勾了才有;勾过但后来被删掉的专家/技能/连接器要过滤掉,不然提示词里指着空气让 agent 用。
 */
function projectContextOf(p) {
  if (!p) return ""; // 带谁的项目由 projectFor 定:跑在别人根里的那趟拿到的是 null,不带共享根项目的说明和规范
  const parts = [];
  if (p.instructions) parts.push(p.instructions);
  // 挂载了资料库的某一块就说出来。不说的话模型只会看见一个小得可疑的文件清单,
  // 然后自作主张去 run_shell 翻整个 data 目录找「应该还有的那些文件」——那条路本来就该被安全中心拦下
  if (p.library_dir) parts.push(`本项目只挂载了资料库的「${p.library_dir}」这一块,library_list 列出来的就是全部;别去别处找资料,也别猜库里还有什么。`);
  const alive = (names, pool) => (names || []).filter((n) => pool.includes(n));
  const exps = alive(p.experts, experts.map((e) => e.name));
  if (exps.length) parts.push(`本项目挂载的专家:${exps.join("、")}。相应领域的子任务优先 delegate_to_expert 委派给他们。`);
  let skillNames = [];
  try { skillNames = skillsMgr.loadSkills().map((s) => s.name); } catch {}
  const sks = alive(p.skills, skillNames);
  if (sks.length) parts.push(`本项目挂载的技能:${sks.join("、")}。做对应任务前先 use_skill 加载,按技能里的规范执行。`);
  const conns = alive(p.connectors, (config.mcp_servers || []).map((s) => s.name));
  if (conns.length) parts.push(`本项目挂载的连接器:${conns.join("、")}。涉及外部系统时优先用这些连接器提供的工具。`);
  // 项目目录里的 AGENTS.md / CLAUDE.md 是写给 agent 看的项目规范(pi / Claude Code 的通行惯例),
  // 用户既然放了就自动带上,不用再往项目指令里手抄一遍。往上一直找到 git 仓库根,和命令行用的是同一份逻辑
  const memo = projectMemo.memoContext(p.dir);
  if (memo) parts.push(memo);
  return parts.join("\n\n");
}

function ensureProjects() {
  if (!Array.isArray(config.projects) || !config.projects.length) {
    config.projects = [{ name: "默认项目", dir: getDefaultWorkspaceDir() }];
    config.active_project = "默认项目";
  }
  if (!config.projects.some((p) => p.name === config.active_project)) config.active_project = config.projects[0].name;
}

/**
 * 把一个项目的「空间」落到运行时:工作空间根 + 资料库挂载的那一块。
 *
 * 这两件事必须一起做。只切工作目录不切资料库挂载,就是「我明明在客户 A 的项目里,
 * 它翻出来的是客户 B 的合同」;只切挂载不切目录,产出会写进上一个项目的文件夹。
 * 切完顺手把根记进名单(rememberRoot),这样以后回看这条项目下的老对话还找得到文件。
 */
function applyProjectSpaces(p) {
  if (!p) return;
  try { setLibraryDir(p.library_dir || ""); } catch {}
  if (p.dir) rememberRoot(p.dir);
}
/**
 * config.json 是唯一一份存着所有 API Key 的文件,还不入 git——写坏了就是全丢。
 * 所以全应用只留这一个写入口,走原子改名 + .bak。
 */
/**
 * 存配置。
 *
 * 难点不在写,在于**别把别人的改动盖掉**。config.json 同时有两个人在改:
 * 界面(走这个函数,写的是内存里那整份 config)和用户本人(文本编辑器里粘一个 API Key、
 * 加一条 mcp_servers)。老写法不管三七二十一整份覆盖,于是手改的那些全没了——
 * 最常见的是刚粘进去的 Key,用户的体感是「填了不生效」,回头一看文件,空的。
 *
 * 所以存盘前先看一眼文件的改动时间:跟我们上次对齐时不一样,说明外面有人动过。
 * 这时候不是二选一,而是把两边合起来:磁盘上那份当底,只把**这个进程真改过的那几处**
 * 按路径盖上去(哪几处 = 内存里的 config 跟基线快照的差集)。用户粘的 Key 在底上,一个字不动。
 */
function saveConfig() {
  const now = cfgMerge.mtimeOf(CONFIG_PATH);
  if (now && CONFIG_MTIME && now !== CONFIG_MTIME) mergeDiskEdits();
  store.writeJsonAtomic(CONFIG_PATH, config, { pretty: true, mode: store.SECRET_MODE });
  CONFIG_MTIME = cfgMerge.mtimeOf(CONFIG_PATH);
  CONFIG_BASE = cfgMerge.snapshot(config);
  CONFIG_SEEN = CONFIG_MTIME;
}

/** 把磁盘上那份读回来当底,只把本进程改过的那几处盖上去。合不了就照旧覆盖,但要留一句。 */
function mergeDiskEdits() {
  let disk;
  try {
    disk = store.readJson(CONFIG_PATH, null);
  } catch (e) {
    console.warn(`[配置] config.json 在外面被改过,但读不回来(${(e && e.message) || e}),这次按内存里的存`);
    return;
  }
  if (!disk || typeof disk !== "object" || Array.isArray(disk)) return; // 不成形的就别拿来当底
  const { changed: mine } = cfgMerge.mergeOnto(disk, CONFIG_BASE, config);
  // 用户可能整块删掉(server / agent 这种),跟启动时同一套兜底,免得合完之后某个字段没了就崩
  fillDefaults(disk, CONFIG_DEFAULTS);
  disk.shortcuts = disk.shortcuts || {};
  disk.assistant = { ...ASSISTANT_DEFAULT, ...(disk.assistant || {}) };
  // 换内容不换对象:llm、security、tools 这些模块启动时就把 config 的引用拿走了,
  // 这里要是 config = disk,它们手上还攥着老的那份,界面显示新值、干活用老值。
  for (const k of Object.keys(config)) delete config[k];
  Object.assign(config, disk);
  security.getSecurity(config); // 安全策略跟着新内容重新补齐
  console.log(`[配置] config.json 在外面被改过,已合并:磁盘那份当底,本进程改的 ${mine.length} 处盖上去`);
}

/** 此刻 config 里的连接器配置,拍成跟 MCP_APPLIED 同样的形状好比对 */
function mcpSnapshot() {
  return {
    servers: new Map((config.mcp_servers || []).filter((s) => s && s.name).map((s) => [s.name, cfgFingerprint(s)])),
    disabled: new Set((config.mcp_disabled || []).map(String)),
  };
}
function markMcpApplied(snap = mcpSnapshot()) {
  MCP_APPLIED = snap;
}
function mcpChanged() {
  if (!MCP_APPLIED) return false; // 开机那一批还没起完:起完会自己记
  const now = mcpSnapshot();
  const same = (a, b) => a.size === b.size && [...a].every((x) => (Array.isArray(x) ? b.get(x[0]) === x[1] : b.has(x)));
  return !same(now.servers, MCP_APPLIED.servers) || !same(now.disabled, MCP_APPLIED.disabled);
}

/**
 * config 里的连接器跟正在跑的对不上了(外面改了 config.json、或者别的路存盘时合并进来的):
 * 只动变了的那几台——删掉的停、新加的和改过的重连、开关翻了的按新开关来。没变的一台都不碰,
 * 正连着的 npx 进程不该因为隔壁加了一条就被踢下线。
 */
async function syncMcp() {
  if (!mcpChanged()) return;
  const want = mcpSnapshot();
  const had = MCP_APPLIED;
  let fromPlugins = [];
  try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了照样同步自己配的 */ }
  const pluginNames = new Set(fromPlugins.map((s) => s.name));
  const flipped = (n) => had.disabled.has(n) !== want.disabled.has(n);
  const gone = [...had.servers.keys()].filter((n) => !want.servers.has(n) && !pluginNames.has(n));
  const redo = [
    ...(config.mcp_servers || []).filter((s) => s && s.name && (had.servers.get(s.name) !== want.servers.get(s.name) || flipped(s.name))),
    ...fromPlugins.filter((s) => flipped(s.name)),
  ];
  mcpManager.setDisabled(config.mcp_disabled || []);
  if (gone.length) mcpManager.stop(gone);
  if (redo.length) {
    mcpManager.stop(redo.map((s) => s.name)); // 连缓存那条一起清:配置改了就当场连一次,别拿旧工具表糊过去
    await mcpManager.startAll(redo);
  }
  markMcpApplied(want);
  console.log(`[MCP] config.json 里的连接器变了,已同步:${[...gone.map((n) => `停 ${n}`), ...redo.map((s) => `重连 ${s.name}`)].join("、") || "开关表更新"}`);
}

/**
 * 磁盘上的 config.json 自从上回看过之后被改了,就只把 mcp_servers / mcp_disabled 认进来。
 *
 * 为什么只认这两个:别的键(Key、模型)下次存盘时 saveConfig 会三方合并,丢不了;
 * 连接器不一样,改了没人点保存就一直不生效——模型让人「去 config.json 里加一条」,加完连接器页上看不见,
 * 下一次随便存点什么,数组整块按内存那份盖回去(数组不逐项合并),手加的那条就悄悄没了。
 *
 * 用 JSON.parse 直接读、不走 store.readJson:别人写到一半时读到的是半截 JSON,
 * readJson 会把它当坏文件挪去 .corrupt、退回备份——半截不是坏,等它写完就好。
 * 读不成:这一版改动时间只说一次,CONFIG_SEEN 不动,下一轮接着读。
 * 不碰 CONFIG_MTIME:只认了两个键不算整份对齐,下次存盘照样合并别的键。
 */
let configPollWarned = 0;
function adoptDiskMcp() {
  const now = cfgMerge.mtimeOf(CONFIG_PATH);
  if (!now || now === CONFIG_SEEN) return false;
  let disk;
  try {
    disk = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
  } catch (e) {
    if (configPollWarned !== now) {
      configPollWarned = now;
      console.warn(`[配置] config.json 改过了,但现在读不成(${(e && e.message) || e}),连接器先按原样跑,改好了会自动认`);
    }
    return false;
  }
  CONFIG_SEEN = now;
  if (!disk || typeof disk !== "object" || Array.isArray(disk)) return false;
  let took = false;
  for (const k of ["mcp_servers", "mcp_disabled"]) {
    const v = disk[k] === undefined ? [] : disk[k]; // 整个键删掉 = 清空
    if (!Array.isArray(v)) {
      console.warn(`[配置] config.json 里的 ${k} 不是数组,没认,连接器先按原样跑`);
      continue;
    }
    if (JSON.stringify(v) === JSON.stringify(config[k] || [])) continue;
    if (JSON.stringify(config[k] || []) !== JSON.stringify(CONFIG_BASE[k] || [])) {
      // 本进程改了还没落盘就撞上外面也改:两边都是整张表,合不了。说出来,别悄悄丢一边
      console.warn(`[配置] config.json 里的 ${k} 在外面被改了,这边也有没存的改动,这次没认外面那份`);
      continue;
    }
    config[k] = v;
    CONFIG_BASE[k] = cfgMerge.snapshot(v); // 基线跟着走:下次存盘合并时这一键不算本进程改的,磁盘那份不会被盖
    took = true;
  }
  if (took) console.log("[配置] config.json 里的连接器在外面被改过,已认进来");
  return took;
}

let configPolling = false;
async function pollConfig() {
  if (configPolling) return; // 上一轮还在重连(npx 首次能拖一分钟),别叠着起
  configPolling = true;
  try {
    adoptDiskMcp();
    // 不只看这一轮认没认:别的路存盘时 mergeDiskEdits 也可能把外面改的连接器合进来
    if (mcpChanged()) await mcpApply(syncMcp);
  } catch (e) {
    console.warn(`[配置] 同步 config.json 里的连接器出错了:${(e && e.message) || e}`);
  } finally {
    configPolling = false;
  }
}


/**
 * 这个人用的是哪份项目清单:
 *   "shared" 留在共享根上的平台管理员:config.projects,切的是共享根(老行为);
 *   "own"    用自己工作目录的人(成员、审计员、分公司的人):他自己那份,全在自己的工作目录里(spaces.js);
 *   "none"   剩下的(共享根上的老主人却不是管理员,老账本才有):没有项目这回事。
 */
function projectScope(user) {
  if (user && !admin.keepsSharedSpace(user)) return "own";
  return ownsGlobalWorkspace(user) ? "shared" : "none";
}
/** 他自己的项目清单,每项补上绝对路径给界面显示(他自己那份,设置页本来就显示)。存盘时不带 dir */
function ownProjects(user) {
  const home = spaces.rootOf(user);
  const { projects, active } = spaces.projectsOf(user.username);
  const dirOf = (p) => (p.folder ? path.join(home, spaces.PROJECTS, p.folder) : home);
  return { home, active, projects: projects.map((p) => ({ ...p, dir: dirOf(p) })) };
}
const ownStored = (list) => list.map(({ dir, ...p }) => p);
/** 项目名:去空白、必填、30 字以内、清单里不重名 */
function projectName(raw, list, self) {
  const name = String(raw || "").trim();
  if (!name) throw new Error("缺少项目名");
  if (name.length > 30) throw new Error("项目名太长(最多 30 字)");
  if (list.some((p) => p !== self && p.name === name)) throw new Error("同名项目已存在");
  return name;
}
const OWN_DIR_FIXED = "你的项目都建在自己的工作目录里,不能另选文件夹";
/** 改名时把旧名字记进 old_names:老对话记的是当时的项目名,侧栏靠它把它们认回这个项目(不去改一条条对话) */
function renameProject(p, to) {
  const old = Array.isArray(p.old_names) ? p.old_names : [];
  p.old_names = [p.name, ...old].filter((n, i, a) => typeof n === "string" && n && n !== to && a.indexOf(n) === i).slice(0, 20);
  p.name = to;
}
/** 用自己工作目录的人这一趟带哪个项目的说明;留在共享根上的带当前项目(跑在别人根里的那趟不带) */
function projectFor(user) {
  if (projectScope(user) === "own") {
    try {
      const { projects, active } = ownProjects(user);
      return projects.find((p) => p.name === active) || null;
    } catch { return null; }
  }
  return sharedRootHere() ? activeProject() : null;
}

app.get("/api/projects", (req, res) => {
  // 租户看到的是自己那一个根,不是总部的项目清单——后者连目录名都是信息。
  // 以前这里编了个叫「本组织工作目录」的假项目顶上,两头都出事:侧栏多一个点不动的 tab,
  // 而且这个名字跟老会话记的项目名对不上,前端按项目过滤后整排任务历史都没了。
  // 没有项目这回事的如实说 locked,前端见到就整块不画、也不按项目过滤;
  // 用自己工作目录的人回他自己那份清单(own: true,前端不给选文件夹)
  const scope = projectScope(req.user);
  if (scope === "none") return res.json({ projects: [], active: "", locked: true, root: workspaceKeyOf(getWorkspaceDir()) });
  if (scope === "own") {
    try {
      const { projects, active } = ownProjects(req.user);
      return res.json({ projects, active, locked: false, own: true, root: workspaceKeyOf(getWorkspaceDir()) });
    } catch (e) {
      return res.status(503).json({ error: "你的工作目录建不出来,稍后再试或找管理员" });
    }
  }
  ensureProjects();
  res.json({ projects: config.projects, active: config.active_project, locked: false, root: workspaceKeyOf(getWorkspaceDir()) });
});

app.post("/api/projects", (req, res) => {
  try {
    if (projectScope(req.user) === "own") {
      // 只动他自己那份清单,config 里共享根的项目、工作目录一个字不碰
      const b = req.body || {};
      const { home, projects } = ownProjects(req.user);
      const name = projectName(b.name, projects);
      if (String(b.dir || "").trim()) throw new Error(OWN_DIR_FIXED);
      const folder = spaces.newFolder(projects, name);
      spaces.openProject(home, folder); // 先把文件夹建好:软链接指出去的、建不出来的,当场报错,不留一条打不开的项目
      spaces.saveProjects(req.user.username, [...ownStored(projects), { name, folder, ...projectMeta(b), created_at: new Date().toISOString() }], name);
      const now = ownProjects(req.user);
      return res.json({ ok: true, projects: now.projects, active: now.active });
    }
    ensureProjects();
    const name = String((req.body || {}).name || "").trim();
    if (!name) throw new Error("缺少项目名");
    if (name.length > 30) throw new Error("项目名太长(最多 30 字)");
    if (config.projects.some((p) => p.name === name)) throw new Error("同名项目已存在");
    let dir = String((req.body || {}).dir || "").trim();
    if (!dir) dir = dataPath("projects", name.replace(/[/\\:*?"<>|]/g, "_"));
    const real = setWorkspaceDir(dir); // 建目录并切换过去
    noteLayout(real); // 填的是用户自己的文件夹:按此刻判的记下(应用替项目建的 projects/<名> 不记,永远按对话分)
    config.projects.push({ name, dir: real, ...projectMeta(req.body || {}), created_at: new Date().toISOString() });
    rememberRoot(real); // 以后在别的项目里回看这条项目下的老对话,靠的就是这份名单
    config.active_project = name;
    config.workspace_dir = real;
    applyProjectSpaces(config.projects[config.projects.length - 1]);
    saveConfig();
    res.json({ ok: true, projects: config.projects, active: name });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

app.post("/api/projects/switch", (req, res) => {
  try {
    if (projectScope(req.user) === "own") {
      // 只记「他现在在哪个项目」;下一条请求起 tenantScope 就把他绑到那个项目的文件夹上
      const { home, projects } = ownProjects(req.user);
      const p = projects.find((x) => x.name === (req.body || {}).name);
      if (!p) return res.status(404).json({ error: "项目不存在" });
      const dir = spaces.openProject(home, p.folder);
      spaces.saveProjects(req.user.username, ownStored(projects), p.name);
      return res.json({ ok: true, active: p.name, dir, library_dir: p.library_dir || "" });
    }
    ensureProjects();
    const p = config.projects.find((x) => x.name === (req.body || {}).name);
    if (!p) return res.status(404).json({ error: "项目不存在" });

    config.workspace_dir = setWorkspaceDir(p.dir);
    config.active_project = p.name;
    applyProjectSpaces(p);
    saveConfig();
    res.json({ ok: true, active: p.name, dir: p.dir, library_dir: p.library_dir || "" });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// 新任务回到当前项目自己的目录:输入框里临时切过的文件夹不带进下一个任务
app.post("/api/workspace/reset", (req, res) => {
  // 用自己工作目录的人没有项目可回,回一句现状就行;往下走会把老主人的根切回他的项目
  if (!admin.keepsSharedSpace(req.user)) return res.json({ ok: true, workspace_dir: getWorkspaceDir() });
  try {
    // 跑着的任务不怕:每趟在开头就把自己的根钉住了(/api/chat 的 enterWorkspace、accountedRuntime 的 withWorkspace),
    // 这里改的只是下一条新任务从哪开始。以前在这儿遇忙就跳过,结果忙的时候点「新任务」,新任务落在上一条临时切过的文件夹里
    ensureProjects();
    const ap = config.projects.find((p) => p.name === config.active_project) || config.projects[0];
    if (ap && ap.dir && ap.dir !== getDefaultWorkspaceDir()) {
      config.workspace_dir = setWorkspaceDir(ap.dir);
      saveConfig();
    }
    applyProjectSpaces(ap);
    res.json({ ok: true, workspace_dir: getWorkspaceDir() });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// 设置页刚选了个文件夹、还没保存:问一声它会按哪种用法放成果(每个对话一个文件夹 / 直接放进去),单选框先摆对。
// 只给能改工作目录的人:它会回「这个路径看着像不像代码仓库」,等于能拿来探服务器上任意目录
app.get("/api/workspace/layout", (req, res) => {
  if (!isPlatformOwner(req) || !admin.keepsSharedSpace(req.user)) return res.status(403).json({ error: "这块是服务器级设置,归平台管理员管", platform_only: true });
  const dir = String(req.query.dir || "").trim();
  if (!dir || !path.isAbsolute(dir)) return res.status(400).json({ error: "要一个绝对路径" });
  const { layout, locked, chosen, repo } = taskDirs.folderLayout(dir, layoutAnchors());
  res.json({ layout, locked, chosen, repo });
});

// 改项目:名字之外的东西(指令、挂载的专家/技能/连接器)都能改,改完立即对新任务生效
app.patch("/api/projects/:name", (req, res) => {
  if (projectScope(req.user) === "own") {
    try {
      const b = req.body || {};
      const { projects, active } = ownProjects(req.user);
      const p = projects.find((x) => x.name === req.params.name);
      if (!p) return res.status(404).json({ error: "项目不存在" });
      // 弹窗把原来的路径原样带回来不算改;真换成别处的不行
      const nd = String(b.dir || "").trim();
      if (nd && !taskDirs.samePlace(nd, p.dir)) return res.status(400).json({ error: OWN_DIR_FIXED });
      Object.assign(p, projectMeta({ ...p, ...b }));
      let now = active;
      const rename = String(b.name || "").trim();
      if (rename && rename !== p.name) {
        projectName(rename, projects, p);
        if (active === p.name) now = rename;
        renameProject(p, rename); // 文件夹名不跟着改:改了就是搬家,跑着的任务和老对话都指着原来那个
      }
      spaces.saveProjects(req.user.username, ownStored(projects), now);
      const out = ownProjects(req.user);
      return res.json({ ok: true, project: out.projects.find((x) => x.name === p.name), projects: out.projects, active: out.active });
    } catch (e) {
      return res.status(400).json({ error: e.message });
    }
  }
  ensureProjects();
  const p = config.projects.find((x) => x.name === req.params.name);
  if (!p) return res.status(404).json({ error: "项目不存在" });
  Object.assign(p, projectMeta({ ...p, ...(req.body || {}) }));
  // 换工作空间目录。老版本这儿根本收不了 dir,新建项目时也没处填,用户只能拿到
  // data/projects/<名字> 这个藏在应用数据里的目录——「新增的项目好像没地方设置目录」说的就是它。
  const nd = String((req.body || {}).dir || "").trim();
  if (nd) {
    try {
      const real = path.resolve(nd);
      if (!path.isAbsolute(real)) throw new Error("工作空间必须是绝对路径");
      fs.mkdirSync(real, { recursive: true }); // 没权限/盘不在,在这儿就炸,别等任务跑到一半
      p.dir = real;
      rememberRoot(real);
      noteLayout(real);
      // 改的是当前项目就得同步切根,否则界面显示新目录、任务还往老目录写,
      // 下一次回看就是满屏「文件不存在」
      if (config.active_project === p.name) config.workspace_dir = setWorkspaceDir(real);
    } catch (e) {
      return res.status(400).json({ error: "工作空间目录设置失败:" + e.message });
    }
  }
  const rename = String((req.body || {}).name || "").trim();
  if (rename && rename !== p.name) {
    if (config.projects.some((x) => x.name === rename)) return res.status(400).json({ error: "同名项目已存在" });
    if (config.active_project === p.name) config.active_project = rename;
    renameProject(p, rename);
  }
  if (config.active_project === p.name) applyProjectSpaces(p);
  saveConfig();
  res.json({ ok: true, project: p, projects: config.projects, active: config.active_project });
});

// 只从列表移除,不删磁盘文件
app.delete("/api/projects/:name", (req, res) => {
  if (projectScope(req.user) === "own") {
    try {
      const { projects, active } = ownProjects(req.user);
      if (projects.length <= 1) return res.status(400).json({ error: "至少保留一个项目" });
      const i = projects.findIndex((p) => p.name === req.params.name);
      if (i < 0) return res.status(404).json({ error: "项目不存在" });
      // 默认项目就是工作目录本身:移掉它,摊在工作目录里的老成果就没有哪个项目能打开了
      if (!projects[i].folder) return res.status(400).json({ error: "这个项目就是你的工作目录本身,不能移除" });
      projects.splice(i, 1);
      const now = active === req.params.name ? projects[0].name : active;
      spaces.saveProjects(req.user.username, ownStored(projects), now);
      const out = ownProjects(req.user);
      return res.json({ ok: true, projects: out.projects, active: out.active });
    } catch (e) {
      return res.status(400).json({ error: e.message });
    }
  }
  ensureProjects();
  if (config.projects.length <= 1) return res.status(400).json({ error: "至少保留一个项目" });
  const i = config.projects.findIndex((p) => p.name === req.params.name);
  if (i < 0) return res.status(404).json({ error: "项目不存在" });
  config.projects.splice(i, 1);
  if (config.active_project === req.params.name) {
    const p0 = config.projects[0];
    config.active_project = p0.name;
    try {
      config.workspace_dir = setWorkspaceDir(p0.dir);
    } catch {}
  }
  saveConfig();
  res.json({ ok: true, projects: config.projects, active: config.active_project });
});

// ---------- 资料库·灵感(跨项目共享:参考文件 + 灵感笔记,agent 可用 library_* 工具读取) ----------
const LIB_DIR = dataPath("data", "library");
/**
 * 资料库的根:一人一个。
 *
 * 老库 data/library 原地不动,归留在共享根上的人:只有一个账号时就是他,不止一个时是默认组织的管理员
 * (admin.keepsSharedSpace,跟工作目录、项目同一个判据)。别人一人一个 data/library-users/<账号>/,
 * 头一次用的时候才建。
 *
 * 为什么非改不可:资料库以前是整台机器**共用的一份**,而且 admin.js 那张读表还特地把
 * /api/library 放行了(理由是拦了也白拦,agent 的 library_list 照样念得出来)。两件事叠在一起,
 * 结果就是新注册的号打开资料库,看见的是别人传进去的合同和素材——跟侧栏里那条会话历史
 * 是同一个事故的两个面。
 *
 * 不搬文件:管理员那一份还躺在原地,路径一个字符没变;新号拿到的是一个空目录。
 */
function libraryRootOf(user) {
  if (!user || admin.keepsSharedSpace(user)) return LIB_DIR;
  return dataPath("data", "library-users", prefs.keyOf(user));
}
function readNotes() {
  const list = store.readJson(notesFileOf(libBase()), []);
  return Array.isArray(list) ? list : [];
}
function writeNotes(notes) {
  store.writeJsonAtomic(notesFileOf(libBase()), notes, { pretty: true });
}
/**
 * 资料库里的相对路径 → 绝对路径。
 *
 * 老版本这儿是一句 `path.basename(name)`:资料库被硬压成**一层平铺**。用户传二十份合同进来
 * 就是二十条并排,分不出「客户 A / 客户 B」,项目也没法只挂载其中一块——
 * 这正是「资料库看起来没有子目录或者指定目录的概念」那条反馈的根。
 *
 * 允许子目录之后越界检查就得自己做:basename 那一刀顺手也把 `../` 剁掉了,现在没有了。
 * 三道:段里不许有 `..`、不许以 `.` 开头(.git/.ssh 这类别被翻出来)、不许有 Windows 非法字符;
 * 拼完再让 safePathIn 按根复核一遍,两道都过才算数。
 */
function libPath(rel, root = libBase()) {
  const parts = String(rel || "").replace(/\\/g, "/").split("/").filter((x) => x && x !== ".");
  for (const seg of parts) {
    if (seg === ".." || seg.startsWith(".") || /[<>:"|?*\u0000-\u001f]/.test(seg)) throw new Error(`路径不合法:${seg}`);
  }
  return safePathIn(root, parts.join("/"));
}
/** 规整成对外用的相对路径(统一正斜杠、去掉首尾斜杠);越界的在 libPath 里抛 */
function libRel(rel) {
  const parts = String(rel || "").replace(/\\/g, "/").split("/").filter((x) => x && x !== ".");
  libPath(parts.join("/")); // 只为触发校验
  return parts.join("/");
}
/** 资料库里所有子目录的相对路径(给项目弹窗那个「挂哪一块」的下拉用)。深度封 4 层,防止有人把整个硬盘软链进来 */
function libFolders(rel = "", depth = 0, out = []) {
  if (depth >= 4) return out;
  let ents = [];
  try { ents = fs.readdirSync(libPath(rel), { withFileTypes: true }); } catch { return out; }
  for (const e of ents) {
    if (!e.isDirectory() || e.name.startsWith(".")) continue;
    const child = rel ? `${rel}/${e.name}` : e.name;
    out.push(child);
    libFolders(child, depth + 1, out);
  }
  return out;
}
/** 一个资料库目录里直接躺着几个文件(列表上给个「3 份」的角标,免得点进去才发现是空的) */
function libCount(abs) {
  try { return fs.readdirSync(abs, { withFileTypes: true }).filter((e) => !e.name.startsWith(".")).length; }
  catch { return 0; }
}

app.get("/api/library", (req, res) => {
  const dir = (() => { try { return libRel((req.query || {}).dir); } catch { return ""; } })();
  let files = [];
  let dirs = [];
  // 收藏跟着资料库的根走(src/server/routes/library.js),列的时候逐个打上 fav,前端不用再单独问一趟
  const favs = libraryRoutes.libFavKeys(libraryRootOf(req.user));
  try {
    for (const e of fs.readdirSync(libPath(dir), { withFileTypes: true })) {
      if (e.name.startsWith(".")) continue;
      const abs = path.join(libPath(dir), e.name);
      let st;
      try { st = fs.statSync(abs); } catch { continue; }
      const row = { name: e.name, path: dir ? `${dir}/${e.name}` : e.name, size: st.size, mtime: st.mtime.toISOString() };
      if (e.isDirectory()) dirs.push({ ...row, count: libCount(abs) });
      else if (e.isFile()) files.push({ ...row, fav: favs.has("lib:" + row.path) });
    }
  } catch {}
  dirs.sort((a, b) => a.name.localeCompare(b.name, "zh"));
  files.sort((a, b) => b.mtime.localeCompare(a.mtime));
  // crumbs:["", "客户A", "客户A/2026"] 这样一层层的可点路径,前端不用自己切字符串
  const segs = dir ? dir.split("/") : [];
  const crumbs = segs.map((name, i) => ({ name, path: segs.slice(0, i + 1).join("/") }));
  res.json({ dir, crumbs, dirs, files, folders: libFolders(), notes: readNotes() });
});

app.post("/api/library/folder", (req, res) => {
  try {
    const parent = libRel((req.body || {}).dir);
    const name = String((req.body || {}).name || "").trim();
    if (!name) return res.status(400).json({ error: "文件夹名不能为空" });
    const rel = parent ? `${parent}/${name}` : name;
    const abs = libPath(rel);
    if (fs.existsSync(abs)) return res.status(400).json({ error: "同名文件夹已存在" });
    fs.mkdirSync(abs, { recursive: true });
    res.json({ ok: true, dir: rel });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// 删空文件夹。非空的不给删:这个接口的调用方是一个「删除」小链接,
// 点错的代价不该是一整个文件夹的东西不可逆地没了
app.delete("/api/library/folder", async (req, res) => {
  try {
    const rel = libRel((req.query || {}).dir || (req.body || {}).dir);
    if (!rel) return res.status(400).json({ error: "没说删哪个" });
    const abs = libPath(rel);
    if (!fs.existsSync(abs)) return res.json({ ok: true });
    if (libCount(abs)) return res.status(400).json({ error: "文件夹里还有东西,先清空再删" });
    assertCleanupIdle(libBase());
    const moved = await fileTrash.move(libBase(), abs, { beforeMove: () => assertCleanupIdle(libBase()) });
    res.json({ ok: true, ...moved });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// 跟 /api/upload 一样认两种请求体:原始字节(名字、目录在 X-Upload-Name / X-Upload-Dir 里)边收边写盘,
// 大视频不过内存;老的 JSON + base64 照收,受 express.json 60MB 的限制
app.post("/api/library/upload", async (req, res) => {
  const raw = !!req.is("application/octet-stream");
  let tmp = "";
  try {
    const meta = raw ? uploadHeaders(req) : (req.body || {});
    const { name, data_b64 } = meta;
    if (!name || (!raw && !data_b64)) return res.status(400).json({ error: raw ? "缺少文件名(X-Upload-Name)" : "缺少 name 或 data_b64" });
    const dir = libRel(meta.dir);
    // 浏览器拖整个文件夹进来时 file.name 可能自带层级,一并接住;两边都有就拼起来
    const rel = libRel(dir ? `${dir}/${name}` : name);
    const abs = libPath(rel);
    fs.mkdirSync(path.dirname(abs), { recursive: true });
    if (raw) {
      tmp = await uploadName.receiveUploadToTemp(req, path.dirname(abs));
      uploadName.checkUploadContent(rel, fs.statSync(tmp).size);
      fs.renameSync(tmp, abs);
      tmp = "";
    } else {
      const buf = Buffer.from(data_b64, "base64");
      uploadName.checkUploadContent(rel, buf.length);
      fs.writeFileSync(abs, buf);
    }
    res.json({ ok: true, name: path.basename(rel), path: rel });
  } catch (e) {
    if (tmp) { try { fs.unlinkSync(tmp); } catch {} }
    res.status(400).json({ error: e.message });
  }
});

// 通配而不是 :name —— 子目录以后路径里有斜杠,:name 只吃得下一段
app.get("/api/library/file/*", async (req, res) => {
  try {
    const p = libPath(relOf(req));
    if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).send("文件不存在");
    // ?thumb=160:资料库那一页的图框最大 64px,一屏摆 120 张。缩不动就照旧发原件,
    // 跟 /api/files/view/ 走的是同一段(thumb.js),缓存键带绝对路径所以两处不会串
    res.set("Cache-Control", viewCacheHeader(req, p)); // 跟 /api/files/view/ 一个规矩:换过图不许再摆七天旧缩略图
    const thumb = await thumbFileAsync(p, parseInt(req.query.thumb, 10), path.join(dataPath("data"), "thumbs"));
    if (thumb) {
      res.set("Content-Type", "image/png");
      return res.sendFile(thumb);
    }
    // 默认内联发。以前这里一律 res.download,带上 Content-Disposition: attachment 之后
    // <audio>/<video>/<iframe> 全都渲染不出来,点什么都预览不了。
    // 真要存盘的走 ?dl=1,附件头只在那一条路上加。
    if (String(req.query.dl || "") === "1") return res.download(p);
    const mm = mediaMime(p);
    if (mm) res.set("Content-Type", mm);
    // 资料库里的网页更是外来的:被当顶层页面直接打开时同样要落进 sandbox(见「工作区预览的隔离」)。
    // 应用内的资料库预览走的是 blob: + sandbox 属性,不经过这个响应头,不受影响
    sandboxPreviewDoc(res, viewTypeOf(p));
    res.sendFile(p);
  } catch (e) {
    res.status(400).send(e.message);
  }
});

// 资料库里的 docx / xlsx / pptx / zip:浏览器自己打不开(zip 里一包 XML),得服务端先拆。
// 跟 /api/files/preview 是同一个 previewData,只是换一个根——资料库在 data/library 下,
// 工作目录在别处。以前这条不存在,于是同一份 .pptx 在对话里点得开、拖进资料库就点不开了。
app.get("/api/library/preview/*", async (req, res) => {
  try {
    const rel = relOf(req);
    const p = libPath(rel);
    if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).json({ error: "文件不存在" });
    const controller = new AbortController(); res.once("close", () => { if (!res.writableEnded) controller.abort(); });
    res.json(await previewData(p, rel, { layout: true, signal: controller.signal }));
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});
for (const [prefix, resolve] of [["/api/library", (req) => libPath(relOf(req))], ["/api/files", (req) => rootedPath(req, relOf(req))]]) {
  for (const mode of ["office-info", "office-view", "markdown"]) app.get(prefix + "/" + mode + "/*", async (req, res) => {
    try {
      const p = resolve(req);
      if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).json({ error: "文件不存在" });
      if (mode === "markdown") {
        const controller = new AbortController();
        res.once("close", () => { if (!res.writableEnded) controller.abort(); });
        const result = await require("./src/domains/library/document-markdown").documentMarkdown(p, relOf(req), { signal: controller.signal });
        res.set("Cache-Control", "private, no-store"); return res.json(result);
      }
      const result = await require("./src/domains/library/office-compat").officeFile(p, relOf(req));
      res.set("Cache-Control", "private, no-store");
      if (mode === "office-info") return res.json({ kind: result.kind, converted: result.converted });
      res.type(result.ext).sendFile(result.file);
    } catch (error) { res.status(400).json({ error: error.message }); }
  });
}

app.delete("/api/library/file/*", async (req, res) => {
  try {
    const p = libPath(relOf(req));
    if (fs.existsSync(p) && fs.statSync(p).isDirectory()) return res.status(400).json({ error: "这是文件夹,用删文件夹那个接口" });
    if (!fs.existsSync(p)) return res.json({ ok: true });
    assertCleanupIdle(libBase());
    const moved = await fileTrash.move(libBase(), p, { beforeMove: () => assertCleanupIdle(libBase()) });
    res.json({ ok: true, ...moved });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

app.post("/api/library/note", (req, res) => {
  const text = String((req.body || {}).text || "").trim();
  if (!text) return res.status(400).json({ error: "内容为空" });
  const notes = readNotes();
  notes.unshift({ id: "n_" + Date.now() + "_" + Math.floor(Math.random() * 1e4), text: text.slice(0, 2000), at: new Date().toISOString() });
  writeNotes(notes.slice(0, 200));
  res.json({ ok: true });
});

app.delete("/api/library/note/:id", (req, res) => {
  writeNotes(readNotes().filter((n) => n.id !== req.params.id));
  res.json({ ok: true });
});

// ---------- 资料库:按任务看产出 / 全库搜索 ----------
/**
 * 「这次任务到底产出了哪些文件」——这份数据一直都在,只是从来没人从这个方向读过。
 *
 * 每跑完一批工具,recordingEmit 会往 transcript 里压一条 { type:"files", changed:[…] },
 * changed 是 makeOwnership 认过主的那几个文件(不是整棵树的快照)。把一条会话里所有这种
 * 事件的 changed 并起来,就是「这条任务产出了什么」的权威答案。
 *
 * 以前资料库只能按目录翻,而人记事情是按
 * 「我那天让它做的那份周报」记的,不是按 out/2026-09/report-final-v3.md 记的。
 *
 * 按文件 mtime 增量缓存,跟 listSessionsOnDisk 同一个路子:几百个会话不能每次全量解析 JSON。
 */
const outIndexCache = new Map(); // 会话文件名 -> { mtime, row }
function sessionOutputRow(id, s) {
  if (!s || !Array.isArray(s.transcript) || !s.transcript.length) return null;
  const names = [];
  const turns = [];
  const seen = new Set();
  let firstAt = 0;
  // 「第几回合」只能数 user 条目,不能拿数组下标充数:回放时一条 user 起一个回合
  // (openSession 就是这么拼界面的),下标里还夹着 assistant,差一倍。
  // 同一个文件被改过好几轮就只认头一轮——用户点它是想看「这东西怎么来的」,
  // 那句话在第一次写出它的那一段里,后面几轮是修修补补。
  let ti = -1;
  const fev = []; // 只留 files 事件:不在自己根里的那几个,sessionGrant 照这条会话自己的格子去找
  for (const turn of s.transcript) {
    if (turn.type === "user") { ti++; continue; }
    if (turn.type !== "assistant" || !Array.isArray(turn.events)) continue;
    for (const ev of turn.events) {
      if (ev.type !== "files") continue;
      fev.push({ type: "files", root: ev.root, changed: ev.changed });
      for (const n of ev.changed || []) {
        if (typeof n !== "string" || seen.has(n)) continue;
        seen.add(n);
        names.push(n);
        turns.push(ti);
        if (!firstAt) firstAt = Date.parse(turn.at || "") || 0;
      }
    }
  }
  if (!names.length) return null; // 没产出的对话不进这一页:这一页问的是「东西呢」,不是「聊过啥」
  return {
    id,
    title: s.title || "未命名任务",
    user: s.user || "",
    project: s.project || "",
    lane: lanes.normalize(s.lane) || undefined,
    dir: s.dir || "",
    at: Date.parse(s.updated_at || "") || firstAt,
    names,
    turns,
    spot: { root: s.root || "", dir: s.dir || "", dirs: s.dirs || {}, transcript: [{ events: fev }] },
  };
}
/**
 * 任务清单里这条任务的一个文件,在请求人自己的根里找不到:不止一个账号时,可能是分到个人目录之前
 * 在原来那份共享根里写的。按这条任务自己的格子找回来(sessionGrant 同一套),前端打开时带上 sid。
 */
function taskSpotStat(req, row, n) {
  if (!rootFence(req)) return null;
  const g = sessionGrant(row.spot, n);
  if (!g) return null;
  try { const st = fs.statSync(g.path); return st.isFile() ? { size: st.size, mtime: st.mtime.toISOString() } : null; } catch { return null; }
}
function listTaskOutputs() {
  let files = [];
  try { files = fs.readdirSync(SESS_DIR).filter((n) => n.endsWith(".json")); } catch { return []; }
  const rows = [];
  const seen = new Set();
  for (const n of files) {
    const id = n.slice(0, -5);
    seen.add(n);
    const live = sessions.get(id);
    if (live) { const r = sessionOutputRow(id, live); if (r) rows.push(r); continue; }
    let mtime = 0;
    try { mtime = fs.statSync(path.join(SESS_DIR, n)).mtimeMs; } catch { continue; }
    const hit = outIndexCache.get(n);
    if (hit && hit.mtime === mtime) { if (hit.row) rows.push(hit.row); continue; }
    const row = sessionOutputRow(id, store.readJson(path.join(SESS_DIR, n), null));
    outIndexCache.set(n, { mtime, row });
    if (row) rows.push(row);
  }
  for (const k of outIndexCache.keys()) if (!seen.has(k)) outIndexCache.delete(k);
  rows.sort((a, b) => b.at - a.at);
  return rows;
}

/**
 * 清单不全时别猜,去看一眼。
 *
 * outputFiles() 按 mtime 倒序只留最新 500 条(FILES_CAP)。工作目录攒到 500 个以上,
 * 旧任务的产出就整批掉出这份快照——而"不在快照里"跟"文件没了"是两回事。以前这儿只能耸耸肩:
 * size 记 0、mtime 记空、gone 一律 false,于是界面把一个根本不存在的文件画成一行正常记录,
 * 体积那一栏还是 libSize(0) 撞下限撞出来的「1 KB」。点进去才发现是张 404 的碎图,
 * 点「新窗口打开」也是空的。
 *
 * 快照查不到的名字,就单独 stat 一次。任务清单最多 200 条、每条产出屈指可数,这是笔小账;
 * 同名只查一次,再给个总预算兜底,免得某天真有人攒出几万条来。查不动的(预算用完)记 null,
 * 那才是唯一该说「我不知道」的情况——size 留 0、gone 留 false,由界面显示成「—」。
 */
function statLookup(roots) {
  const cache = new Map();
  let budget = 4000;
  const probe = (abs) => {
    try { const st = fs.statSync(abs); return st.isFile() ? { size: st.size, mtime: st.mtime.toISOString() } : null; } catch { return null; }
  };
  /**
   * 升级时 migrate.js 会把工作区根目录下的散文件收进「以前的文件_日期」。搬完之后,
   * 资料库拿当时记下的裸文件名再去查,一律查不到——一整批还好端端躺在盘上的文件
   * 被判成「已经不在」,点开是一句「这个文件已经不在工作目录里了」,而它只是往下挪了一层。
   *
   * 文件是这个程序自己搬的,那就自己跟着搬过去找。两条限制是故意的:
   *   · 只认「以前的文件_」这一个前缀 —— 那是我们自己建的目录,跟进去不算猜;
   *   · 只认根目录下的裸名字 —— 当初也只搬了这一种(见 migrate.js 的 tidyLooseFiles)。
   * 不做全盘按文件名搜:两个任务目录里各有一个 cover.png,搜回来的很可能是另一份,
   * 而「打开的不是我要的那张图」比「说它不在了」更难发现。
   */
  const archives = (() => {
    let list = null;
    return () => {
      if (list) return list;
      list = [];
      try {
        list = fs.readdirSync(getWorkspaceDir()).filter((n) => n.startsWith("以前的文件_")).sort().reverse().slice(0, 8);
      } catch {}
      return list;
    };
  })();
  return (rel) => {
    if (cache.has(rel)) return cache.get(rel);
    let v = null; // null = 没查(预算用完了);false = 确实不在了;对象 = 还在
    if (budget > 0) {
      budget--;
      v = false;
      try { v = probe(safePath(rel)) || false; } catch {} // 越界的名字:safePath 直接抛,那就是不在
      // 当前根下没有,不等于没了:这次任务可能是在别的项目目录里跑的(中途切过工作目录/换过项目)。
      // 只在用户自己配过的那几个根里找,跟 rootedPath 同一条口径,不会读到工作区以外的地方。
      if (v === false) {
        for (const d of roots) {
          let hit = null;
          try { hit = probe(safePathIn(d, rel)); } catch { continue; }
          if (hit) { v = hit; break; }
        }
      }
      // 最后一手:升级那次被收进「以前的文件_日期」的那批。只在**当前**工作目录下找,
      // 因为 at 是要拿去当地址用的(/api/files/view/ 按当前根解析),
      // 在别的根里找到一个同名文件却给不出能打开的地址,比说它不在了还糟。
      if (v === false && !String(rel).includes("/")) {
        for (const a of archives()) {
          const at = a + "/" + rel;
          let hit = null;
          try { hit = probe(safePath(at)); } catch { continue; }
          if (hit) { v = { ...hit, at }; break; }
        }
      }
    }
    cache.set(rel, v);
    return v;
  };
}

app.get("/api/library/outputs", async (req, res, next) => { try {
  // 认领和「未归属」都拿**整个**工作区比,不拿 outputFiles():那份只有最近的 500 个、最深 3 层,
  // 拿它比的话,第 4 层往下的文件既不算任务产出、也不进「未归属」,整页哪儿都找不到它。
  // full 说的是「files 事件那份快照截没截断」,老调用方靠它判「已删除」。那份快照是这份清单的子集,
  // 全量不到 500 个它就一定没截;到了就报「截了」,老调用方走保守那条路。
  // 以前这里为了这一个布尔值再同步走一遍树:两万文件时每次请求主线程白停 100ms
  const walk = await wsIndex.walk(getWorkspaceDir(), { appDataDir: dataPath("data") });
  const { byName: meta, deckDirs } = wsIndex.derive(walk);
  const scope = filesScope(walk.files);
  if (walk.capped) scope.full = false;
  // 未归属回多少条:默认 200(文件夹视图、搜索只借这份数据反查「出自哪次任务」,用不着全量);
  // 「按任务」视图要翻完全部,会自己带上一个大数
  const lim = Math.max(0, Math.min(wsBrowse.WALK_CAP, parseInt(String((req.query || {}).orphan_limit || ""), 10) || 200));
  // 翻页:前端一页要 2000 条(2026-09-29 起,原来一口气要两万条,一条 JSON 就是几 MB),往后翻带 orphan_offset
  const off = Math.max(0, parseInt(String((req.query || {}).orphan_offset || ""), 10) || 0);
  const favs = libraryRoutes.libFavKeys(libraryRootOf(req.user));
  const isFav = (n) => favs.has("ws:" + n);
  // 网页幻灯片:同目录有 deck.json 的 html 标 deck:true,前端据此归到「幻灯片」页签。
  // 只看这次遍历已经拿到的文件名,不读 html 正文、不多一次 stat——285 个 html 的工作区读一遍正文就是几十 MB
  const deckOf = (n) => (deckDirs.size && /\.html?$/i.test(n) && deckDirs.has(path.posix.dirname(n)) ? { deck: true } : {});
  const statOf = statLookup(knownRoots(req).slice(0, 12)); // 根的条数不设限的话,一次请求能把 stat 乘成几万次
  const claimed = new Set();
  const tasks = [];
  for (const row of listTaskOutputs()) {
    if (!ownSession(req.user, row)) continue; // 跟侧栏一个口径:别人的任务不该出现在我的资料库里
    const files = [];
    for (let i = 0; i < row.names.length; i++) {
      const n = row.names[i];
      // 回合号是给「跳到写出它的那段对话」用的。老的内存缓存行没有这个字段,
      // -1 一路传到前端会被当成第 0 回合,所以查不到就写 undefined,让界面自己决定退回顶部
      const tn = row.turns && row.turns[i] >= 0 ? row.turns[i] : undefined;
      const f = meta.get(n);
      if (f) { claimed.add(n); files.push({ name: n, size: f.size, mtime: f.mtime, gone: false, turn: tn, fav: isFav(n), ...deckOf(n) }); continue; }
      const st = statOf(n);
      // 搬过家的按新地址报出去:名字给人看的那一截没变(前端只取最后一段),
      // 但地址是能打开的那个。顺带认领一下,免得同一个文件在「未归属」里再出现一遍
      if (st) { const at = st.at || n; claimed.add(at); files.push({ name: at, size: st.size, mtime: st.mtime, gone: false, turn: tn, fav: isFav(at), ...deckOf(at) }); continue; }
      const old = st === false ? taskSpotStat(req, row, n) : null;
      if (old) { files.push({ name: n, size: old.size, mtime: old.mtime, gone: false, turn: tn, fav: isFav(n) }); continue; }
      files.push({ name: n, size: 0, mtime: "", gone: st === false, turn: tn, fav: isFav(n) });
    }
    files.sort((a, b) => String(b.mtime).localeCompare(String(a.mtime)));
    const { names, turns, user, spot, ...rest } = row;
    tasks.push({ ...rest, files, live: files.filter((f) => !f.gone).length });
    if (tasks.length >= 200) break;
  }
  // 「未归属」:工作目录里确实有、但没有任何一条任务认领过的文件——手动拷进来的素材、
  // 更早版本产出的东西、别的工具写的。不列出来的话这一页就成了半份清单,用户会以为文件丢了。
  const orphans = walk.files.filter((f) => !claimed.has(f.name));
  // ws_total / ws_capped:工作区里一共多少个文件、数没数全(撞了两万个或十二层的线就是没数全)。
  // 文件夹视图的「全部 N 个」和这里的 orphan_total 都照这个说,数不全就明说数不全
  // 只把要回的那一页拼成行:两万个文件逐个拼对象、再扔掉一万八,是白干
  const page = orphans.slice(off, off + lim).map((f) => ({ name: f.name, size: f.size, mtime: f.mtime, gone: false, fav: isFav(f.name), ...deckOf(f.name) }));
  res.json({ tasks, orphans: page, orphan_total: orphans.length, orphan_offset: off, ws_total: walk.files.length, ws_capped: walk.capped, ...scope });
} catch (e) { next(e); } });

// 能当正文搜的类型。二进制(图片/压缩包/PDF)只搜文件名——把 PDF 当 utf8 读进来
// 匹配到的只会是一堆乱码里的巧合,比搜不到更糟
const TEXTY = /\.(md|markdown|txt|csv|tsv|json|jsonl|ya?ml|html?|xml|log|ini|conf|toml|js|ts|py|sh|css|srt|vtt)$/i;
const SEARCH_MAX_BYTES = 512 * 1024;  // 单个文件超过这个大小只搜名字:搜一个 30MB 的日志会把请求拖死
const SEARCH_SCAN_CAP = 600;          // 最多读多少个文件的正文
// 光数文件个数不够:600 个 400KB 的导出日志就是 240MB 的同步读,那几百毫秒里事件循环整个停着。
// 两道闸哪道先到都停,然后如实把 capped 报给界面——搜不全可以,假装搜全了不行。
const SEARCH_BYTE_CAP = 12 * 1024 * 1024;

/**
 * 正文缓存。搜索框是边打字边搜的(前端 260ms 防抖),「周报」四个字就是四趟请求,
 * 不缓存的话同一批文件要被原样读四遍。键里带了大小和 mtime——文件一动键就失效,
 * 所以永远读不到过期的内容(跟 tools.js 里 digestCache 同一个路子)。
 * 满了整份丢掉重来:维护 LRU 不值这个复杂度,代价不过是下一次键入冷一趟。
 */
const searchTextCache = new Map();
let searchTextBytes = 0;
function searchRead(full, size, mtime) {
  const key = `${full}|${size}|${mtime}`;
  const hit = searchTextCache.get(key);
  if (hit !== undefined) return hit;
  let text = "";
  try { text = fs.readFileSync(full, "utf8"); } catch { text = ""; }
  if (searchTextBytes > SEARCH_BYTE_CAP) { searchTextCache.clear(); searchTextBytes = 0; }
  searchTextCache.set(key, text);
  searchTextBytes += text.length;
  return text;
}
/** 命中行的上下文摘录:给出行号和那一行,用户一眼能判断「是不是我要找的那份」 */
function searchExcerpt(text, needle, max = 3) {
  const out = [];
  const lower = text.toLowerCase();
  let from = 0;
  while (out.length < max) {
    const i = lower.indexOf(needle, from);
    if (i < 0) break;
    const s = text.lastIndexOf("\n", i) + 1;
    let e = text.indexOf("\n", i);
    if (e < 0) e = text.length;
    const line = text.slice(s, e).trim();
    out.push({ line: text.slice(0, s).split("\n").length, text: line.length > 200 ? line.slice(0, 200) + "…" : line });
    from = e + 1;
  }
  return out;
}
/** 递归走资料库;depth 封 6 层、条数封 4000,软链成环也走不死 */
function libWalk(rel = "", depth = 0, out = []) {
  if (depth >= 6 || out.length >= 4000) return out;
  let ents = [];
  try { ents = fs.readdirSync(libPath(rel), { withFileTypes: true }); } catch { return out; }
  for (const e of ents) {
    if (e.name.startsWith(".")) continue;
    const child = rel ? `${rel}/${e.name}` : e.name;
    if (e.isDirectory()) { libWalk(child, depth + 1, out); continue; }
    if (!e.isFile()) continue;
    let st; try { st = fs.statSync(libPath(child)); } catch { continue; }
    out.push({ name: e.name, path: child, size: st.size, mtime: st.mtime.toISOString() });
    if (out.length >= 4000) break;
  }
  return out;
}

/**
 * 全库搜索。
 *
 * 以前那个 #lb-q 只是把**当前这一层已经加载出来的**文件名过滤一遍——换句话说,
 * 东西在隔壁文件夹里就搜不到,正文里写了什么更是无从谈起。那不叫搜索,叫筛选。
 *
 * 这里搜四种东西,每种都如实标明来源:
 *   lib   资料库文件(文件名 + 正文,正文命中给出行号和那一行)
 *   ws    当前工作区的产出文件(文件名 + 正文,跟资料库共用同一份扫描预算)
 *   note  灵感笔记
 *   task  任务标题,附带这条任务的产出文件——「那份周报是哪次做的」这种找法才是常态
 */
app.get("/api/library/search", async (req, res, next) => { try {
  const q = String((req.query || {}).q || "").trim().slice(0, 120);
  if (!q) return res.json({ q: "", lib: [], ws: [], notes: [], tasks: [], scanned: 0, capped: false });
  const needle = q.toLowerCase();
  const hitName = (n) => String(n).toLowerCase().includes(needle);

  // 两棵树共用一份预算(资料库先扫、工作区后扫)。scanned/bytes 任一道到顶就只剩搜名字,
  // capped 一路带到界面上明说「还有没搜到的」。
  const budget = { scanned: 0, bytes: 0, capped: false };
  /** 名字 + 正文各搜一遍,返回 null 表示这个文件根本没命中 */
  const probe = (name, full, size, mtime) => {
    const byName = hitName(name);
    let lines = [];
    if (TEXTY.test(name) && size <= SEARCH_MAX_BYTES) {
      if (budget.scanned >= SEARCH_SCAN_CAP || budget.bytes >= SEARCH_BYTE_CAP) budget.capped = true;
      else {
        budget.scanned++;
        budget.bytes += size;
        const text = searchRead(full, size, mtime);
        if (text) lines = searchExcerpt(text, needle);
      }
    }
    if (!byName && !lines.length) return null;
    return { by: byName && lines.length ? "both" : byName ? "name" : "text", lines };
  };
  // 名字命中排在正文命中前面:搜「周报」的人多半在找那个叫周报的文件,不是找提到周报的文件
  const byNameFirst = (a, b) =>
    (a.by === "text" ? 1 : 0) - (b.by === "text" ? 1 : 0) || String(b.mtime).localeCompare(String(a.mtime));

  const lib = [];
  for (const f of libWalk()) {
    let hit;
    try { hit = probe(f.path, libPath(f.path), f.size, f.mtime); } catch { continue; }
    if (hit) lib.push({ ...f, ...hit });
    if (lib.length >= 200) break;
  }
  lib.sort(byNameFirst);

  // 工作区产出也搜正文。以前这儿只过滤文件名,理由写的是「正文可能是几百兆的中间产物」——
  // 可那正是 TEXTY 和 SEARCH_MAX_BYTES 两道闸在管的事。真实后果是:这台机器上资料库一个文件
  // 都没有,东西全在工作区,于是「全文搜索」实际上一次都没真正跑起来过,搜「上个月那份复盘里
  // 提到的那家供应商」永远是空的。搜的是内容,不是文件名。
  // 名字按整个工作区搜(不是 outputFiles() 那最近 500 个、最深 3 层);新的在前,
  // 所以正文预算先花在最近的文件上,翻不完照旧报 capped
  const wsRoot = getWorkspaceDir();
  const wsWalk = await wsIndex.walk(wsRoot, { appDataDir: dataPath("data") });
  const wsAll = wsWalk.files;
  const ws = [];
  for (const f of wsAll) {
    let hit;
    try { hit = probe(f.name, safePathIn(wsRoot, f.name), f.size, f.mtime); } catch { continue; }
    if (hit) ws.push({ name: f.name, size: f.size, mtime: f.mtime, ...hit });
    if (ws.length >= 200) break;
  }
  ws.sort(byNameFirst);

  const notes = readNotes().filter((n) => String(n.text || "").toLowerCase().includes(needle))
    .slice(0, 50).map((n) => ({ id: n.id, text: n.text, at: n.at }));

  const wsMeta = new Map(wsAll.map((f) => [f.name, f])); // 上面已经遍历过一次,别再走一趟全树
  // 全量那趟撞了线(两万个、十二层)或者文件被收进了「以前的文件_」,不在 wsMeta 里≠没了:跟 outputs 一样再 stat 一眼
  const statOf = statLookup(knownRoots(req).slice(0, 12));
  const taskFile = (row, n) => {
    const f = wsMeta.get(n);
    if (f) return { name: n, size: f.size, mtime: f.mtime, gone: false };
    const st = statOf(n);
    if (st) return { name: st.at || n, size: st.size, mtime: st.mtime, gone: false };
    const old = st === false ? taskSpotStat(req, row, n) : null;
    return old ? { name: n, size: old.size, mtime: old.mtime, gone: false } : { name: n, size: 0, mtime: "", gone: st === false };
  };
  const tasks = [];
  for (const row of listTaskOutputs()) {
    if (!ownSession(req.user, row)) continue;
    const titleHit = hitName(row.title);
    const files = row.names.filter((n) => titleHit || hitName(n));
    if (!titleHit && !files.length) continue;
    tasks.push({
      id: row.id, title: row.title, at: row.at, project: row.project, lane: row.lane,
      by: titleHit ? "title" : "file",
      files: files.slice(0, 20).map((n) => taskFile(row, n)),
    });
    if (tasks.length >= 50) break;
  }

  // ws_capped:工作区文件多到全量那趟没走完,名字也没搜全。跟 capped(正文预算用完)是两回事,分开报
  res.json({ q, lib, ws, notes, tasks, scanned: budget.scanned, capped: budget.capped, ws_capped: wsWalk.capped });
} catch (e) { next(e); } });

// ---------- 长期记忆 ----------
// 手写区(memory.md,全局共享)+ 条目区(agent 用 remember 自己记的,按账号隔离)
/**
 * 记忆能管到哪一格,跟审批那套是同一个判法。
 *
 * 返回 undefined = 不限定(平台管理员,以及双击打开的桌面版——屏幕前就一个人)。
 * 返回登录名 = 只限定他自己那个作用域:共享区(memory.SHARED)会原样进**所有人**的系统提示词,
 * 谁都能往里塞、谁都能删,那就不是「我的记忆」而是一块公告板了。
 */
const memScope = (req) =>
  admin.isSoloDesktop() || admin.platformAdmin(req.user) ? undefined : (req.user && req.user.username) || "";
app.get("/api/memory", (req, res) => {
  const u = req.user ? req.user.username : undefined;
  const scopeTo = memScope(req);
  res.json({
    content: memory.manual(), items: memory.list(u), shared_tag: memory.SHARED,
    limits: { max_text: memory.MAX_TEXT, max_items: memory.MAX_PER_SCOPE }, vectors: memory.vectorStatus(),
    // 界面照这两个决定要不要画那颗按钮:会 403 的按钮不该摆在那儿
    can_share: scopeTo === undefined,   // 能不能往共享区写、能不能删共享区的
    can_edit_manual: scopeTo === undefined, // 背景说明是全局一份,仍旧归平台管理员
    // 嵌入接口是整台服务器一份的,只有平台管理员看得见那张表单
    embedding: isPlatformOwner(req) ? embeddingView(req) : null,
  });
});

// 记忆面板「测一下」:拿表单里的选择真打一次。点名渠道的用那条渠道的地址和 Key;
// 单独填的那种 Key 传八颗星 = 用已存的那把(地址得没换)
app.post("/api/embedding/test", async (req, res) => {
  if (!isPlatformOwner(req)) return res.status(403).json({ ok: false, error: "嵌入接口是整台服务器一份的,归平台管理员配", platform_only: true });
  const b = req.body || {};
  const provider = String(b.provider || "").trim();
  let base = String(b.base_url || "").trim().replace(/\/+$/, "");
  const model = String(b.model || "").trim();
  if (!model) return res.status(400).json({ ok: false, error: "模型名要填,比如 text-embedding-3-small" });
  let key = String(b.api_key == null ? "" : b.api_key).trim();
  if (provider) {
    const p = (config.providers || []).find((x) => x && String(x.id) === provider);
    if (!p) return res.status(400).json({ ok: false, error: "没有这条渠道,先去 设置 → 模型 把它加上" });
    if (!embedHostable(p.kind)) return res.status(400).json({ ok: false, error: `「${p.name || p.id}」算不了向量,换一条渠道或单独填接口` });
    // 跟真用的时候同一个取法,测的就是记忆会走的那条路
    const c = embedCandidates({ providers: config.providers, embedding: { provider, model } })[0];
    if (!c) return res.status(400).json({ ok: false, error: "这条渠道没有接口地址,先去 设置 → 模型 补上" });
    base = c.base_url; key = c.api_key;
  } else {
    if (!/^https?:\/\/[^\s/]+/i.test(base)) return res.status(400).json({ ok: false, error: "接口地址要以 http:// 或 https:// 开头" });
    if (/^\*+$/.test(key)) {
      const old = config.embedding || {};
      if (old.provider || String(old.base_url || "").replace(/\/+$/, "") !== base) return res.status(400).json({ ok: false, error: "换了接口地址,Key 要重新填一遍" });
      key = String(old.api_key || "");
    }
  }
  const r = await probeEmbedding({ base_url: base, api_key: key, model, provider });
  if (r.ok) {
    // 通了就把死渠道记号擦了、重建一次:先前全挂停用的 embedder 不会自己活过来
    memory.setEmbedder(createEmbedder(config));
    setSessEmbedder(createEmbedder(config));
    memory.ensureVectors().catch(() => {});
  }
  res.json(r);
});
app.post("/api/memory", (req, res) => {
  memory.saveManual((req.body || {}).content || "");
  res.json({ ok: true });
});
app.post("/api/memory/item", (req, res) => {
  const b = req.body || {};
  // 受限的人勾了「所有账号共用」:降成他自己的,并且照实说出来,不许悄悄换个作用域还报「已记住」
  const wantShared = !!b.shared;
  const downgraded = wantShared && memScope(req) !== undefined;
  const r = memory.add({
    text: b.text, user: req.user ? req.user.username : undefined,
    shared: wantShared && !downgraded, source: "user",
  });
  if (r.ok && downgraded) r.note = (r.note || "记住了") + "。共享给这台机器上所有账号要平台管理员来做,这条先记成你自己的";
  res.status(r.ok ? 200 : 400).json({ ...r, downgraded });
});
app.delete("/api/memory/item/:id", (req, res) => {
  const r = memory.remove(req.params.id, memScope(req));
  // 越权跟「本来就没有」不能返回同一种结果:前者要报出来,后者是正常竞态(别处已经删过)
  if (r.forbidden) return res.status(403).json({ ok: false, removed: 0, error: "这条不是你记的,删不了" });
  res.json({ ok: true, removed: r.removed });
});

// ---------- 自进化:反馈 → 信号 → 提案 → 人审 → 复盘 ----------
// 👍👎 以前点了只换个高亮色,一个字节都没往外送。反馈得在干活的地方零摩擦地收,
// 收不到就没有后面这一整条链——这是整套自进化的第一环。
app.post("/api/feedback", (req, res) => {
  try {
    const b = req.body || {};
    res.json({ ok: true, item: evolve.recordFeedback({ ...b, user: req.user ? req.user.username : "" }) });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// 反馈汇总:👍👎 总数/好评率/按模型/按模式/最近的 👎 清单。管理员看全员,成员只看自己
app.get("/api/feedback/summary", (req, res) => {
  const days = Math.max(1, Math.min(365, Number(req.query.days) || 30));
  const user = req.user && !account.canAdmin(req.user) ? req.user.username : "";
  res.json(evolve.feedbackSummary({ days, user }));
});

// 体检:这段时间它都败在哪儿、各多少次。不调模型,纯数数,随时能看
app.get("/api/evolve/signals", (req, res) => {
  try {
    res.json(evolve.mineSignals({ days: Math.min(365, +req.query.days || evolve.CAPS.window) }));
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.get("/api/evolve/state", (_req, res) => {
  res.json({
    caps: evolve.CAPS,
    rules: evolve.activeRules(),
    proposals: evolve.listProposals().slice(-50).reverse(),
    scored: evolve.scoreRules(),
    runs: evolve.listRuns(5),
    auto: config.evolve || {},
  });
});

// 跑一轮复盘:数信号 → 让模型提最小改动 → 过闸门 → 落盘等人审。**永远不自动生效**
app.post("/api/evolve/review", async (req, res) => {
  try {
    const r = await evolve.runReview({ llm, days: +((req.body || {}).days) || undefined, promptExcerpt: (req.body || {}).prompt_excerpt || "" });
    res.json({ ok: true, turns: r.mined.turns, signals: r.mined.signals.slice(0, 10), added: r.added, gated: r.gated, notes: r.notes, scored: r.scored });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.post("/api/evolve/proposal/:id", (req, res) => {
  try {
    const b = req.body || {};
    const p = evolve.decideProposal(req.params.id, b.decision, { by: req.user ? req.user.username : "", reason: b.reason });
    res.json({ ok: true, proposal: p, rules: evolve.activeRules() });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

app.post("/api/evolve/rule/:id/retire", (req, res) => {
  try {
    res.json({ ok: true, ...evolve.retireRule(req.params.id, (req.body || {}).why || "人工下架"), rules: evolve.activeRules() });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// ---------- 工作空间:原生文件夹选择(桌面版)与打开文件夹 ----------
app.post("/api/pick-folder", async (_req, res) => {
  const bridge = require("./src/platform/electron-bridge");
  if (bridge.isRemote()) {
    // 独立服务进程里(2026-09-29 起桌面版默认)没有 dialog:弹框归主进程,人选文件夹给足 15 分钟
    try {
      const result = await bridge.call("dialog.openDirectory", { title: "选择工作空间文件夹" }, { timeoutMs: 15 * 60 * 1000 });
      if (!result || result.canceled || !result.filePaths || !result.filePaths.length) return res.json({ canceled: true });
      return res.json({ path: result.filePaths[0] });
    } catch (e) {
      return res.status(502).json({ error: `没能打开文件夹选择框:${e.message}` });
    }
  }
  try {
    const { dialog } = require("electron");
    const result = await dialog.showOpenDialog({ properties: ["openDirectory"], title: "选择工作空间文件夹" });
    if (result.canceled || !result.filePaths.length) return res.json({ canceled: true });
    res.json({ path: result.filePaths[0] });
  } catch {
    res.status(501).json({ error: "仅桌面版支持系统文件夹选择,Web 版请直接输入路径" });
  }
});
/**
 * 用系统程序打开文件/文件夹/URL(跨平台:macOS open / Windows explorer / Linux xdg-open)。
 *
 * 必须是 execFile,不能是 exec。exec 把整条命令交给 /bin/sh,而 target 里有用户能控制的文件名:
 * 一个叫 `a";id>pwn.txt;"b.txt` 的文件(macOS/Linux 上完全合法,上传接口也没拦过这些字符)
 * 就能把老写法 exec(`${opener} "${target}"`) 里的引号闭合掉,分号后面那段以服务端权限执行。
 * execFile 不起 shell,参数原样递给程序,这类解析从根上就没有了。
 * 回调吞掉错误是有意的:explorer 打开成功也常回非 0 退出码,看它的退出码只会误报。
 *
 * Windows 上本地文件不再首选 explorer:execFile 只给带空格的参数加引号,`报告,终稿.docx` 原样递过去,
 * explorer 把逗号当开关分隔符,打开的是默认文件夹而不是这个文件,还一声不吭。桌面版交给主进程的
 * shell.openPath(走系统的「打开」,没有命令行解析这一层),它回来的报错原样带给用户;
 * 没有桌面主进程(命令行、Web 部署)才退回 explorer,并且自己加引号。网址照旧交给 explorer。
 * macOS / Linux 跟以前一模一样。
 *
 * 不抛错:返回 { ok, error },调用方决定要不要把 error 说给用户。
 * @param {string} target
 * @param {{platform?:string, bridge?:any, electron?:any, execFile?:Function}} [deps] 只给测试用
 * @returns {Promise<{ok:boolean, error:string}>}
 */
async function openWithSystem(target, deps = {}) {
  const t = String(target);
  try {
    const platform = deps.platform || process.platform;
    const execFile = deps.execFile || require("child_process").execFile;
    if (platform !== "win32") {
      execFile(platform === "darwin" ? "open" : "xdg-open", [t], () => {});
      return { ok: true, error: "" };
    }
    const isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(t);
    if (!isUrl) {
      // 约定:成功回空串,失败回系统给的那句话。只认非空字符串是失败,别把别的返回值当报错念给用户
      const said = (err) => (typeof err === "string" && err ? { ok: false, error: `系统打开失败:${err}` } : { ok: true, error: "" });
      const bridge = deps.bridge || require("./src/platform/electron-bridge");
      if (bridge.isRemote()) {
        try {
          // 打开大文件时主进程要等 Word / WPS 起来才回话,给足 15 秒,别把正常的慢当成失败
          return said(await bridge.call("shell.openPath", { path: t }, { timeoutMs: 15000 }));
        } catch (e) {
          // 主进程没这个操作(版本对不上)、没了、连不上:它那边什么都没开,退回 explorer。
          // 超时就不退了——主进程可能只是慢,过一会儿自己打开,再起一个 explorer 就是同一个文件开两遍
          const code = e && e.code;
          if (code !== "NO_OP" && code !== "NO_SHELL" && code !== "SHELL_GONE") return { ok: false, error: String((e && e.message) || e) };
        }
      } else if (bridge.mode() === "inproc") {
        try {
          const { shell } = deps.electron || require("electron");
          if (shell && shell.openPath) return said(await shell.openPath(t));
        } catch {}
      }
    }
    // 不加 windowsHide:要的就是打开一个给人看的窗口,加了资源管理器那扇窗会被藏掉。
    // 本地路径整个包一层引号、原样递(Windows 路径里不可能有 "),逗号就不会被拆开
    if (isUrl) execFile("explorer", [t], () => {});
    else execFile("explorer", [`"${t}"`], { windowsVerbatimArguments: true }, () => {});
    return { ok: true, error: "" };
  } catch (e) {
    // 起进程同步就抛的那种(参数不合法之类):以前是同步抛给路由,现在照样变成一句报错,别成了没人接的 rejection
    return { ok: false, error: String((e && e.message) || e) };
  }
}
app.post("/api/open-workspace", async (_req, res) => {
  const r = await openWithSystem(getWorkspaceDir());
  if (!r.ok) return res.status(500).json({ error: r.error });
  res.json({ ok: true });
});

// ================= 飞书:扫码授权(借本机 lark-cli 的设备码流程,larksuite/cli,MIT) =================
// 说明一句免得误解:机器人「收消息」必须有应用的 app_id + app_secret,这是飞书的设计,扫码替代不了。
// 扫码解决的是另一半——把「你本人」的身份授权出来,之后读日历/文档/邮件是以你的身份调的。
const LARK_TMP = path.join(require("os").tmpdir(), "openworkbuddy-lark");
/**
 * lark-cli 在 Windows 上是 npm 装的 .cmd 垫片:按名字直接 execFile 找不到它(只认 .exe),
 * 找到了新版 Node 也不许不经 shell 起批处理。先认出全路径,再走引擎那套起法(src/platform/win.js,顺带不弹黑窗)
 */
function larkBin() {
  if (process.platform !== "win32") return "lark-cli";
  return require("./src/platform/which").findIn(shellPath().split(path.delimiter), "lark-cli") || "lark-cli";
}
/** 同 execFile;起不来时回调里给 err,返回 null */
function larkExec(args, opts, cb) {
  return require("./src/platform/win").execFile(larkBin(), args, opts, cb);
}
function larkRun(args, { timeout = 60000, cwd } = {}) {
  return new Promise((resolve) => {
    const child = larkExec(
      args,
      { timeout, cwd: cwd || LARK_TMP, env: { ...process.env, PATH: shellPath() }, maxBuffer: 4 << 20 },
      (err, stdout, stderr) => resolve({ ok: !err, code: err ? err.code : 0, stdout: stdout || "", stderr: stderr || "" }),
    );
    if (child) child.on("error", () => {});
  });
}
function larkJson(s) { try { return JSON.parse(String(s).trim()); } catch { return null; } }
const larkCli = require("./src/util/lark-cli");
/** 读 lark-cli 当前绑定的应用凭证;读不到返回 null */
async function larkConfig() {
  return larkCli.parseConfigShow((await larkRun(["config", "show"], { timeout: 15000 })).stdout);
}
/**
 * 把 lark-cli 里的凭证搬进飞书通道并重启长连接(新建和导入两条路都走这里)。
 * 守卫:macOS 上 lark-cli 把 secret 锁在钥匙串里,`config show` 只回 `****`。
 * 那串掩码要是写进去,用户原来能用的凭证就被顶掉了 —— 宁可不搬,也不搬一个假的。
 * app_id 也一并不动:单搬 app_id 会让它跟旧 secret 配成一对错的,连上去只会报 10014。
 */
function adoptLarkCreds(cfg) {
  const appId = (cfg && cfg.appId) || "";
  if (!appId || !larkCli.usableSecret(cfg && cfg.appSecret)) {
    return { ok: false, app_id: appId, error: larkCli.SECRET_LOCKED_HINT, console_url: larkCli.appConsoleUrl(appId, cfg && cfg.brand) };
  }
  config.im = config.im || {};
  config.im.feishu = Object.assign(config.im.feishu || {}, { app_id: cfg.appId, app_secret: cfg.appSecret });
  saveConfig();
  if (imBridge) imBridge.startFeishuWs(true).catch((e) => console.warn("[飞书] 长连接重启失败:", e.message));
  return { ok: true, app_id: cfg.appId };
}

app.get("/api/feishu/lark-cli", async (_req, res) => {
  fs.mkdirSync(LARK_TMP, { recursive: true });
  const v = await larkRun(["--version"], { timeout: 15000 });
  if (!v.ok) return res.json({ installed: false, install_cmd: "npx @larksuite/cli@latest install" });
  const version = (v.stdout.match(/[\d.]+/) || [""])[0];
  const cfg = await larkConfig();
  // 只回布尔和非敏感字段,app_secret 一个字节都不出后端
  res.json({
    installed: true, version,
    configured: !!(cfg && cfg.appId),
    app_id: (cfg && cfg.appId) || "",
    brand: (cfg && cfg.brand) || "",
    has_secret: larkCli.usableSecret(cfg && cfg.appSecret),
    // 读不出来跟没有是两回事:钥匙串锁着的时候得让前端说清楚,而不是装作「没配」
    secret_locked: !!(cfg && cfg.appId && !larkCli.usableSecret(cfg.appSecret)),
    users: (cfg && cfg.users) || "",
  });
});

// 把 lark-cli 里已经配好的应用凭证搬进 OpenWorkBuddy 的飞书通道,省掉手动复制两串东西
app.post("/api/feishu/lark-cli/import", async (_req, res) => {
  fs.mkdirSync(LARK_TMP, { recursive: true });
  const cfg = await larkConfig();
  if (!cfg || !cfg.appId) {
    return res.status(400).json({ error: "lark-cli 还没配置应用凭证,先「扫码新建应用」,或自己跑 lark-cli config init" });
  }
  const r = adoptLarkCreds(cfg);
  if (!r.ok) return res.status(400).json({ error: r.error, app_id: r.app_id, secret_locked: true, console_url: r.console_url });
  res.json({ ok: true, app_id: cfg.appId }); // secret 不回前端
});

// 反向:把 OpenWorkBuddy 里填好的凭证写进 lark-cli,这样才能开始扫码(设备码流程需要一个已绑定的应用)
app.post("/api/feishu/lark-cli/bind", (_req, res) => {
  const f = (config.im || {}).feishu || {};
  if (!f.app_id || !f.app_secret) return res.status(400).json({ error: "先在上面填好 App ID / App Secret 并保存" });
  fs.mkdirSync(LARK_TMP, { recursive: true });
  // secret 走 stdin,不进进程参数表(ps 能看到 argv)
  const child = larkExec(
    ["config", "init", "--app-id", f.app_id, "--app-secret-stdin", "--brand", "feishu", "--lang", "zh"],
    { timeout: 60000, cwd: LARK_TMP, env: { ...process.env, PATH: shellPath() } },
    (err, stdout, stderr) => {
      if (res.headersSent) return;
      if (err) return res.status(400).json({ error: ((stderr || stdout || err.message) + "").slice(0, 300) });
      res.json({ ok: true });
    },
  );
  if (!child) return; // 起不来:回调里已经回过话了
  child.on("error", (e) => { if (!res.headersSent) res.status(400).json({ error: "lark-cli 没装或调不起来:" + e.message }); });
  try { child.stdin.end(f.app_secret + "\n"); } catch {}
});

// ---- 一键新建飞书应用:全程不用手打 app_id / app_secret ----
// 用户问过两次「不能扫码连机器人吗」。直答是:机器人在飞书这边就是一个「应用」,平台只认
// app_id/app_secret,扫码换不来这两串。但 lark-cli 的 `config init --new` 能**替你新建一个应用**——
// 它阻塞着打印一条验证链接,用户在浏览器里点完,凭证就落进 ~/.lark-cli/config.json。
// 我们把链接变成二维码给用户扫,建完直接导进飞书通道 → 一个字都不用手打。
let larkNew = null; // { url, state, error, app_id, child, startedAt }
app.post("/api/feishu/app/create", async (_req, res) => {
  fs.mkdirSync(LARK_TMP, { recursive: true });
  if (larkNew && larkNew.child) { try { larkNew.child.kill(); } catch {} }
  const before = await larkConfig();
  // lark-cli 在 Agent 环境里会拒绝新建,把那两个标记摘掉;同时避免继承本进程的杂环境
  const env = { ...process.env, PATH: shellPath() };
  delete env.OPENCLAW_HOME; delete env.HERMES_HOME;
  let out = "";
  // Windows 上经 launchPlan 起(理由见 larkBin),windowsHide 由它给;别的系统跟以前一样直接起
  const plan = require("./src/platform/win").launchPlan(larkBin(), ["config", "init", "--new", "--brand", "feishu", "--lang", "zh"]);
  const child = require("child_process").spawn(
    plan.bin, plan.args,
    { cwd: LARK_TMP, env: { ...env, ...plan.env }, stdio: ["ignore", "pipe", "pipe"], ...(process.platform === "win32" ? plan.opts : {}) },
  );
  child.stdout.on("data", (c) => (out += c));
  child.stderr.on("data", (c) => (out += c));
  child.on("error", (e) => { if (larkNew) { larkNew.state = "error"; larkNew.error = larkCli.explainLarkError(e.message); } });
  child.on("close", async (code) => {
    if (!larkNew || larkNew.child !== child) return; // 已被新的一轮顶掉
    const cfg = await larkConfig();
    const born = larkCli.appReady(before, cfg, code); // 选了原来那个已有应用也算,见 appReady
    if (born) {
      const r = adoptLarkCreds(cfg);
      larkNew.app_id = cfg.appId;
      // 应用是真建出来了,只是 secret 被钥匙串锁着 —— 这不是失败,是「还差最后一步」
      larkNew.state = r.ok ? "ok" : "need_secret";
      larkNew.error = r.ok ? "" : r.error;
      larkNew.console_url = r.console_url || "";
    } else if (larkNew.state === "pending") {
      larkNew.state = "error"; larkNew.error = larkCli.explainLarkError(out) || "应用没建成,凭证没变化";
    }
  });
  larkNew = { url: "", state: "pending", error: "", app_id: "", console_url: "", child, startedAt: Date.now() };

  // 等它把验证链接打出来(一般 1~3 秒);拿到就出二维码,拿不到就如实说
  const url = await new Promise((resolve) => {
    const t0 = Date.now();
    const tick = setInterval(() => {
      const u = larkCli.verifyUrlOf(out);
      if (u || child.exitCode !== null || Date.now() - t0 > 30000) { clearInterval(tick); resolve(u); }
    }, 200);
  });
  if (!url) {
    try { child.kill(); } catch {}
    if (larkNew) larkNew.state = "error";
    return res.status(400).json({ error: larkCli.explainLarkError(out) });
  }
  larkNew.url = url;
  const png = "newapp-" + Date.now() + ".png";
  await larkRun(["auth", "qrcode", url, "-o", png, "--size", "256"], { timeout: 20000 });
  let qr = null;
  try {
    qr = "data:image/png;base64," + fs.readFileSync(path.join(LARK_TMP, png)).toString("base64");
    fs.unlinkSync(path.join(LARK_TMP, png));
  } catch {}
  res.json({ ok: true, url, qr });
});

app.get("/api/feishu/app/create/status", (_req, res) => {
  if (!larkNew) return res.json({ state: "idle" });
  if (larkNew.state === "pending" && Date.now() - larkNew.startedAt > 15 * 60 * 1000) {
    try { larkNew.child.kill(); } catch {}
    larkNew.state = "error"; larkNew.error = "等了 15 分钟没完成,重新点一次";
  }
  // app_id 不敏感(前端本来就要显示),app_secret 一个字节都不出后端
  res.json({ state: larkNew.state, error: larkNew.error || null, app_id: larkNew.app_id || "", url: larkNew.url || "", console_url: larkNew.console_url || "" });
});

// 设备码流程:start 拿二维码 → 用户在飞书里扫 → 后台那条 --device-code 自己会跑完 → status 变 ok
let larkQr = null; // { device_code, url, expires_at, state, error, child }
let larkQrEpoch = 0, larkLoggingOut = false, larkQrStop = null;
function stopLarkQr() {
  if (!larkQrStop) larkQrStop = stopLarkQrNow().finally(() => { larkQrStop = null; });
  return larkQrStop;
}
/** 先收掉整棵扫码进程树,避免 npm 包装器退出后,原生 CLI 仍在后台完成登录。 */
async function stopLarkQrNow() {
  const qr = larkQr, child = qr && qr.child;
  larkQr = null;
  if (!child || !child.pid || child.exitCode !== null || child.signalCode !== null) return;
  let timer, onClose;
  const closed = new Promise((resolve) => { onClose = resolve; child.once("close", onClose); });
  try {
    const win = require("./src/platform/win");
    await win.treeWatch(child.pid).kill("SIGKILL");
    win.killTree(child, "SIGKILL");
    await Promise.race([closed, new Promise((_, reject) => {
      timer = setTimeout(() => reject(new Error("扫码进程尚未退出,请稍后重试")), 3000);
    })]);
  } catch (e) {
    // 没停下来就保留句柄供重试,不能把仍在运行的授权进程遗忘。
    if (!larkQr && child.exitCode === null && child.signalCode === null) larkQr = qr;
    throw e;
  } finally { clearTimeout(timer); child.removeListener("close", onClose); }
}

app.post("/api/feishu/lark-cli/logout", async (req, res) => {
  if (!isPlatformOwner(req)) return res.status(403).json({ error: "飞书本人身份连接归本机主人管理", platform_only: true });
  if (larkLoggingOut) return res.status(409).json({ error: "正在取消连接,请稍候" });
  larkLoggingOut = true;
  ++larkQrEpoch;
  try {
    await stopLarkQr();
    fs.mkdirSync(LARK_TMP, { recursive: true });
    const r = await larkRun(["auth", "logout", "--json"], { timeout: 15000 });
    const j = larkJson(r.stdout);
    if (!r.ok || !j || j.loggedOut !== true) return res.status(400).json({ error: "未能确认本机登录态已清除,请重试。" });
    res.json({ ok: true, logged_out: true });
  } catch {
    res.status(400).json({ error: "取消连接失败,请稍后重试。" });
  } finally { larkLoggingOut = false; }
});

app.post("/api/feishu/qr/start", async (req, res) => {
  if (larkLoggingOut) return res.status(409).json({ error: "正在取消连接,请稍候再扫码" });
  const epoch = ++larkQrEpoch;
  fs.mkdirSync(LARK_TMP, { recursive: true });
  try { await stopLarkQr(); } catch { return res.status(400).json({ error: "上一轮扫码尚未停止,请重试" }); }
  if (epoch !== larkQrEpoch) return res.status(409).json({ error: "这次扫码已取消" });
  const domains = String((req.body && req.body.domains) || "im,docs,drive,calendar,task");
  const r = await larkRun(["auth", "login", "--no-wait", "--json", "--domain", domains], { timeout: 60000 });
  if (epoch !== larkQrEpoch) return res.status(409).json({ error: "这次扫码已取消" });
  const j = larkJson(r.stdout);
  if (!j || !j.verification_url || !j.device_code) {
    const msg = (r.stderr || r.stdout || "").slice(0, 300);
    return res.status(400).json({ error: /config init|app.?id/i.test(msg)
      ? "lark-cli 还没绑定应用:先在上面填好 App ID / App Secret 并「写入 lark-cli」,或自己跑 lark-cli config init"
      : "拿不到授权链接:" + (msg || "lark-cli 没有返回内容") });
  }
  // 出二维码:写进临时目录再读成 data URI,前端直接 <img>,不落工作区
  const png = "qr-" + Date.now() + ".png";
  await larkRun(["auth", "qrcode", j.verification_url, "-o", png, "--size", "256"], { timeout: 20000 });
  let dataUri = null;
  try {
    dataUri = "data:image/png;base64," + fs.readFileSync(path.join(LARK_TMP, png)).toString("base64");
    fs.unlinkSync(path.join(LARK_TMP, png));
  } catch {}
  if (epoch !== larkQrEpoch) return res.status(409).json({ error: "这次扫码已取消" });

  // 阻塞式轮询交给后台子进程,前端只问我们自己的 status
  const child = larkExec(
    ["auth", "login", "--device-code", j.device_code, "--json"],
    { timeout: (j.expires_in || 600) * 1000 + 15000, cwd: LARK_TMP, env: { ...process.env, PATH: shellPath() } },
    (err, stdout, stderr) => {
      if (!larkQr || larkQr.device_code !== j.device_code) return; // 已被新的一轮顶掉
      if (err) { larkQr.state = "error"; larkQr.error = ((stderr || stdout || err.message) + "").slice(0, 300); }
      else { larkQr.state = "ok"; larkQr.result = larkJson(stdout) || {}; }
    },
  );
  if (child) child.on("error", (e) => { if (larkQr && epoch === larkQrEpoch) { larkQr.state = "error"; larkQr.error = e.message; } });
  larkQr = { device_code: j.device_code, url: j.verification_url, expires_at: Date.now() + (j.expires_in || 600) * 1000, state: "pending", child };
  res.json({ ok: true, url: j.verification_url, qr: dataUri, expires_in: j.expires_in || 600 });
});

app.get("/api/feishu/qr/status", async (_req, res) => {
  if (!larkQr) return res.json({ state: "idle" });
  if (larkQr.state === "pending" && Date.now() > larkQr.expires_at) {
    larkQr.state = "error"; larkQr.error = "二维码超时失效,重新点一次生成";
  }
  if (larkQr.state !== "ok") return res.json({ state: larkQr.state, error: larkQr.error || null });
  const qr = larkQr;
  const who = larkJson((await larkRun(["whoami"], { timeout: 15000 })).stdout) || {};
  if (qr !== larkQr) return res.json({ state: "idle" });
  res.json({ state: "ok", identity: who.identity || "", app_id: who.appId || "", user: qr.result && qr.result.user_name || "" });
});

app.post("/api/feishu/qr/cancel", async (_req, res) => {
  ++larkQrEpoch;
  try { await stopLarkQr(); res.json({ ok: true }); }
  catch { res.status(400).json({ error: "扫码进程尚未停止,请重试" }); }
});

// ---- 缓存清理:界面缓存(Electron chromium) + 各项目 .tmp 临时脚本;不动会话记录/工作区文件/登录态 ----
const CHROMIUM_CACHE_DIRS = ["Cache", "Code Cache", "GPUCache", "DawnGraphiteCache", "DawnWebGPUCache", "blob_storage", "Shared Dictionary"];
function appUserDataDir() {
  // 独立服务进程里没有 electron.app:主进程把 userData 放在 OWB_USER_DATA 里递过来
  if (require("./src/platform/electron-bridge").isRemote() && process.env.OWB_USER_DATA) return process.env.OWB_USER_DATA;
  if (process.versions.electron) {
    try { return require("electron").app.getPath("userData"); } catch {}
  }
  const home = require("os").homedir();
  if (process.platform === "darwin") return path.join(home, "Library", "Application Support", "openworkbuddy");
  if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "openworkbuddy");
  return path.join(home, ".config", "openworkbuddy");
}
function dirSize(p) {
  let n = 0;
  try {
    for (const e of fs.readdirSync(p)) {
      const fp = path.join(p, e);
      try {
        const st = fs.lstatSync(fp); // lstat:.tmp/node_modules 软链绝不能跟进去统计/删除
        if (st.isSymbolicLink()) continue;
        n += st.isDirectory() ? dirSize(fp) : st.size;
      } catch {}
    }
  } catch {}
  return n;
}
function cacheTmpDirs() {
  const set = new Set([path.join(getWorkspaceDir(), ".tmp")]);
  for (const p of config.projects || []) if (p && p.dir) set.add(path.join(p.dir, ".tmp"));
  // 每个成果文件夹自己的 .tmp/:提示词让模型把调试脚本、自检截图放这儿,不摊在成品旁边
  try {
    for (const e of fs.readdirSync(getWorkspaceDir(), { withFileTypes: true })) {
      if (!e.isDirectory() || !e.name.startsWith("任务_")) continue;
      const d = path.join(getWorkspaceDir(), e.name, ".tmp");
      if (fs.existsSync(d)) set.add(d);
    }
  } catch {}
  return [...set];
}
/**
 * 成果文件夹里的 .tmp/ 是给模型的草稿区。一轮一轮只进不出的话,截图攒多了一样占地方——
 * 收尾时把三天没动过的清掉。三天:续跑、第二天接着改都还在,一周前那次调试就没人要了
 *
 * 子目录「动没动过」按它里面最新的那一项算,不看目录自己的 mtime:往已有的文件里接着写、
 * 在更深一层加东西,目录自己的 mtime 都纹丝不动。2026-09-29 复审:三天前建的 .tmp/venv、
 * .tmp/调试/ 今天还在往里写,按目录 mtime 算整棵被删,下一步 import 直接找不到。
 * 翻目录全走异步、翻到一个新的就停——收尾这一刻在 Electron 主进程上,同步翻一棵 node_modules 整个应用陪着卡。
 * 翻了 TASK_TMP_SCAN_MAX 项还没翻完就当它还在用:宁可留着一棵大的,不拿半截证据去删。
 */
const TASK_TMP_SCAN_MAX = 20000;
async function pruneTaskTmp(taskAbs) {
  const dir = path.join(taskAbs, ".tmp");
  const dead = Date.now() - 3 * 24 * 3600 * 1000;
  let names = [];
  try { names = await fs.promises.readdir(dir); } catch { return; }
  for (const n of names) {
    const fp = path.join(dir, n);
    try {
      const st = await fs.promises.lstat(fp); // 软链只解链,绝不跟进去
      if (st.mtimeMs >= dead) continue;
      if (st.isSymbolicLink()) { await fs.promises.unlink(fp); continue; }
      if (st.isDirectory() && await touchedSince(fp, dead, { left: TASK_TMP_SCAN_MAX })) continue;
      await fs.promises.rm(fp, { recursive: true, force: true });
    } catch {}
  }
}
/** 目录里有没有 dead 之后动过的(软链只看它自己,不跟进去)。读不了、翻不完一律按「动过」算 */
async function touchedSince(dir, dead, budget) {
  let ents;
  try { ents = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return true; }
  for (const e of ents) {
    if (--budget.left < 0) return true;
    const fp = path.join(dir, e.name);
    let st;
    try { st = await fs.promises.lstat(fp); } catch { continue; } // 翻的这一下被删了:它不算数
    if (st.mtimeMs >= dead) return true;
    if (st.isDirectory() && await touchedSince(fp, dead, budget)) return true;
  }
  return false;
}
function cacheStats() {
  const ud = appUserDataDir();
  const ui = CHROMIUM_CACHE_DIRS.reduce((n, d) => n + dirSize(path.join(ud, d)), 0);
  const tmp = cacheTmpDirs().reduce((n, d) => n + dirSize(d), 0);
  return { ui, tmp, total: ui + tmp };
}
// ---------- 数据备份与恢复 ----------
// 打包 data/(会话/记忆/账号/用量/审计)+ config.json + schedules.json + experts.json。
// 工作空间成果文件不进备份(可能巨大,且用户自己看得见摸得着)。备份放项目根 backups/,
// 用系统 tar(mac/linux 自带,win10+ 也有),不为这事拖第三方压缩依赖。
const BACKUP_DIR = dataPath("backups");
// prefs/ 在 data/ 外面(prefs.js 直接落 DATA_DIR/prefs),得单列一项——
// 漏了它的话搬完家宠物、快捷指令、上次挑的模型全要重设一遍
const BACKUP_ENTRIES = ["data", "prefs", "config.json", "schedules.json", "experts.json"];
// 包里允许出现的顶层条目。比 BACKUP_ENTRIES 多一个 skills/——自己写的技能是跟着备份走的
// (见 userSkillEntries),所以列清单和解包的时候也得认它。
const BACKUP_TOP = [...BACKUP_ENTRIES, "skills"];
/**
 * 能重新生成的东西不进包。2026-09-28 量过一台用了一个月的机器:data/ 里缩略图、每份会话旁边
 * 那个孪生 .bak、压缩前的原文归档、codex 的下载缓存、运维指标加起来占了一大半,
 * 每天一个自动备份把它们原样再背一遍。
 * - 缩略图/codex 缓存:看一眼就重新生成、重新下载。
 * - *.json.bak:正本坏了才用得上的那一份,打包那一刻正本是好的。
 * - compact-archive / metrics:前者是压缩前的原文(会话里留着压缩后的版本),后者是运维曲线。
 * 恢复不会删这几样:恢复是 tar -x 盖回去、不清目录,盘上现有的原样留着。
 * 必须摆在文件参数前面:GNU tar 规定 --exclude 只管它后面的参数。
 * 模式都从包里的开头算(只管顶层那个 data/):两家 tar 默认都不锚定,不锚的话用户技能
 * skills/x/data/ 底下同名的东西会一起漏打。怎么锚按 tar 是哪一家定,见 backup-auto.js 的 tarExcludeArgs。
 */
const BACKUP_EXCLUDES = ["data/thumbs", "data/*.json.bak", "data/compact-archive", "data/runtime/codex/cache", "data/metrics"];
let tarVersionText = null; // 本机 tar 是哪一家:一个进程里问一次就够
function backupExcludeArgs() {
  if (!tarVersionText) {
    tarVersionText = new Promise((resolve) => {
      require("child_process").execFile("tar", ["--version"], { timeout: 10000, windowsHide: true }, (e, out) => {
        if (e) tarVersionText = null; // 没问成下回再问;这一回按认不出处理,一个都不排除
        resolve(e ? "" : String(out || ""));
      });
    });
  }
  return tarVersionText.then((v) => require("./src/server/backup-auto").tarExcludeArgs(v, BACKUP_EXCLUDES));
}

/**
 * 自己写的技能也得跟着搬家。
 *
 * 随包出厂的那一堆不进包:光 ppt-master 一个就 170M,装机时 seedDataDir 本来就会把它们
 * 铺到数据目录,新机器上一样有;每次备份都背一遍只会把包撑到没人敢点「立即备份」。
 * 所以这儿只挑「出厂列表里没有的」——用户自己建的、或者从别处装进来的那些。
 * 开发态(DATA_DIR === APP_DIR)下 skills/ 就是仓库里那份,git 管着,整个跳过。
 * 代价说在明处:出厂技能被改过的话,那份改动不进包,到新机器上会是出厂那一版。
 */
function userSkillEntries() {
  if (DATA_DIR === APP_DIR) return [];
  const dirs = (d) => {
    try { return fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); }
    catch { return []; }
  };
  const shipped = new Set(dirs(appPath("skills")));
  return dirs(dataPath("skills")).filter((n) => !shipped.has(n)).map((n) => path.join("skills", n));
}

// 备份里有 config.json(含 API Key)和全部账号数据——只有管理员能碰。
// 本地单人用没登录态时视同管理员(和任务归属的口径一致)
function backupAllowed(req, res) {
  if (!req.user || account.isAdmin(req.user)) return true;
  res.status(403).json({ error: "只有管理员能操作备份" });
  return false;
}

function listBackups() {
  try {
    return fs.readdirSync(BACKUP_DIR)
      // wb- 那个前缀随改名退休了,但**认还得认**:老备份包就叫那个名字,
      // 只认新名字的话,升上来这一刻磁盘上的备份会从列表里整个消失(文件还在,人以为丢了)
      .filter((f) => /^(?:openworkbuddy|wb)-backup-[\w.-]+\.tar\.gz$/.test(f))
      .map((f) => {
        const st = fs.statSync(path.join(BACKUP_DIR, f));
        return { name: f, size: st.size, at: st.mtime.toISOString() };
      })
      .sort((a, b) => b.at.localeCompare(a.at));
  } catch { return []; }
}

function makeBackup(tag) {
  return new Promise((resolve, reject) => {
    fs.mkdirSync(BACKUP_DIR, { recursive: true });
    const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
    const name = `openworkbuddy-backup-${stamp}${tag ? "-" + tag : ""}.tar.gz`;
    const entries = [...BACKUP_ENTRIES.filter((e) => fs.existsSync(dataPath(e))), ...userSkillEntries()];
    if (!entries.length) return reject(new Error("没有可备份的数据"));
    backupExcludeArgs().then((excludes) => require("child_process").execFile(
      "tar", ["-czf", path.join(BACKUP_DIR, name), ...excludes, "-C", DATA_DIR, ...entries],
      { timeout: 300000, windowsHide: true },
      (err) => {
        if (err) return reject(new Error(err.code === "ENOENT" ? "系统里没有 tar 命令(macOS/Linux/Windows 10 1803+ 都自带;更老的 Windows 请先升级系统)" : "tar 打包失败:" + err.message));
        // 包里整整齐齐装着 config.json(九把 Key)、账号表、积分账本、审计流水。
        // 下载接口是管理员专属的,可这个文件本身就躺在 data/backups/ 下——tar 默认落 0644,
        // 同机器上任何一个别的账号都能整包拿走,等于绕开了上面所有的权限判断
        store.tighten(path.join(BACKUP_DIR, name), store.SECRET_MODE);
        resolve(name);
      }
    )).catch(reject);
  });
}

// 定时自动备份(默认关)。周期存在 config.backup.every_days:0 关 / 1 每天 / 7 每周。
// 细节和规矩见 backup-auto.js
let restoring = false;
const autoBackup = require("./src/server/backup-auto").createAutoBackup({
  everyDays: () => (config.backup || {}).every_days,
  list: listBackups,
  make: makeBackup,
  remove: (name) => { const p = backupFile(name); if (p) fs.unlinkSync(p); },
  busy: () => restoring,
  audit: (msg, verdict) => security.audit("数据备份", msg, verdict),
});
autoBackup.start();

/** 校验名字必须来自现有备份列表,杜绝路径注入 */
function backupFile(name) {
  const hit = listBackups().find((b) => b.name === name);
  return hit ? path.join(BACKUP_DIR, hit.name) : null;
}

// 备份包里允许出现的顶层条目,和 BACKUP_ENTRIES 一一对应(data/ 是目录,其余三个是文件)。
const BACKUP_SAFE_ENTRY = new RegExp(
  "^(?:\\./)?(?:" + BACKUP_TOP.map((e) => e.replace(/\./g, "\\.") + (/\.json$/.test(e) ? "$" : "(?:/|$)")).join("|") + ")"
);

/**
 * 把一个 .tar.gz 拆开看一遍,看不过就不收。
 *
 * 这是整套备份里唯一一个「外面来的文件」会经过的地方。restore 那一步是 `tar -xzf 包 -C DATA_DIR`,
 * 在有这个接口之前,它解的每一个包都是本机自己 makeBackup() 打出来的,所以怎么解都安全;
 * 现在包可能是从另一台机器、甚至从别人手里拿来的,那就得按不可信的东西对待:
 *   · 绝对路径(/etc/...)和带 .. 的路径能写到 DATA_DIR 外面去;
 *   · 软链接/硬链接更狠——先塞一个 data/x -> /Users/xxx/.ssh,后面往 data/x 里写就落到链接指的地方了;
 *   · 顶层只认 data/ 和那三个 json,别的形状根本不是这套系统的备份,收进来也只会解出一地垃圾。
 * 这里只看不解,验完才落盘。
 */
function inspectBackup(p) {
  const execFile = require("child_process").execFile;
  // 32MB 的清单大概装得下四十万条;再多的包不是正常备份,让它在这儿失败比解到一半失败强
  const opt = { timeout: 300000, maxBuffer: 32 * 1024 * 1024, windowsHide: true };
  const run = (args) =>
    new Promise((resolve, reject) =>
      execFile("tar", args, opt, (err, out) => { // windowsHide 在 opt 里
        if (!err) return resolve(String(out || ""));
        if (err.code === "ENOENT") return reject(new Error("系统里没有 tar 命令(macOS/Linux/Windows 10 1803+ 都自带)"));
        if (err.code === "ENOBUFS") return reject(new Error("这个包里的文件太多了,不像是 OpenWorkBuddy 的备份"));
        reject(new Error("这个文件打不开,可能不是完整的 .tar.gz:" + (err.message || "").split("\n")[0]));
      })
    );

  return (async () => {
    const names = (await run(["-tzf", p])).split("\n").map((x) => x.trim()).filter(Boolean);
    if (!names.length) throw new Error("这个包是空的");
    for (const n of names) {
      if (n.startsWith("/")) throw new Error(`包里有绝对路径:${n}`);
      if (n.split("/").includes("..")) throw new Error(`包里有跳出目录的路径:${n}`);
      if (!BACKUP_SAFE_ENTRY.test(n)) throw new Error(`包里有不属于备份范围的东西:${n}`);
    }
    // 详细清单第一个字符就是条目类型:- 文件、d 目录、l 软链接、h 硬链接。只收前两种。
    for (const line of (await run(["-tzvf", p])).split("\n")) {
      const t = line.trim()[0];
      if (!t) continue;
      if (t !== "-" && t !== "d") throw new Error("包里有链接文件(软链接/硬链接),正常备份不会有,不收");
    }
    return names.length;
  })();
}

app.get("/api/backup", (req, res) => {
  if (!backupAllowed(req, res)) return;
  const days = require("./src/server/backup-auto").normalizeDays((config.backup || {}).every_days);
  res.json({
    list: listBackups(), covers: BACKUP_ENTRIES, skills: userSkillEntries().length,
    auto: { every_days: days, keep: require("./src/server/backup-auto").KEEP, last_error: autoBackup.lastError() },
  });
});
app.post("/api/backup/auto", (req, res) => {
  if (!backupAllowed(req, res)) return;
  const raw = (req.body || {}).every_days;
  const days = require("./src/server/backup-auto").normalizeDays(raw);
  if (String(raw) !== String(days)) return res.status(400).json({ error: "周期只能是 0(关)、1(每天)或 7(每周)" });
  config.backup = { ...(config.backup || {}), every_days: days };
  saveConfig();
  security.audit("数据备份", days ? `自动备份已开:每 ${days} 天一份` : "自动备份已关", "放行");
  // 刚打开、一份自动备份都还没有:现在就备一份,不用等下一个整点
  if (days) autoBackup.tick().catch(() => {});
  res.json({ ok: true, every_days: days });
});
app.post("/api/backup", async (req, res) => {
  if (!backupAllowed(req, res)) return;
  try {
    const name = await makeBackup("");
    security.audit("数据备份", `已创建备份 ${name}`, "放行");
    res.json({ ok: true, name });
  } catch (e) { res.status(500).json({ error: e.message }); }
});
app.get("/api/backup/download/:name", (req, res) => {
  if (!backupAllowed(req, res)) return;
  const p = backupFile(req.params.name);
  if (!p) return res.status(404).json({ error: "备份不存在" });
  res.download(p);
});
app.delete("/api/backup/:name", (req, res) => {
  if (!backupAllowed(req, res)) return;
  const p = backupFile(req.params.name);
  if (!p) return res.status(404).json({ error: "备份不存在" });
  fs.unlinkSync(p);
  res.json({ ok: true });
});
// 上传导入:换电脑、换服务器、从旧机器搬家时,把下载下来的那个包送回来。
// 没有这一步的话「下载备份带走」就是句空话——新机器上没有任何地方能把它放进去。
// 只有这里用 raw body:备份是二进制,走 JSON 得先 base64(凭空胖三分之一),
// 而全局那个 express.json 的上限是给接口留的,不该为了搬家把它撑到 GB 级。
app.post(
  "/api/backup/upload",
  express.raw({ type: () => true, limit: "2gb" }),
  async (req, res) => {
    if (!backupAllowed(req, res)) return;
    const buf = req.body;
    if (!Buffer.isBuffer(buf) || !buf.length) return res.status(400).json({ error: "没收到文件" });
    // gzip 的前两个字节永远是 1f 8b。先拦一道,省得把一个 .zip / 半截文件写到磁盘上再去解
    if (buf[0] !== 0x1f || buf[1] !== 0x8b) return res.status(400).json({ error: "这不是 .tar.gz 备份文件" });

    fs.mkdirSync(BACKUP_DIR, { recursive: true });
    // 点开头的临时名不会被 listBackups() 的 openworkbuddy-backup-*.tar.gz 捞到,验不过的包不会在列表里露脸
    const tmp = path.join(BACKUP_DIR, `.incoming-${Date.now()}.tar.gz`);
    try {
      fs.writeFileSync(tmp, buf);
      const entries = await inspectBackup(tmp);
      const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
      let name = `openworkbuddy-backup-${stamp}-imported.tar.gz`;
      for (let i = 2; fs.existsSync(path.join(BACKUP_DIR, name)); i++) name = `openworkbuddy-backup-${stamp}-imported-${i}.tar.gz`;
      fs.renameSync(tmp, path.join(BACKUP_DIR, name));
      security.audit("数据备份", `已导入外部备份 ${name}(${(buf.length / 1048576).toFixed(1)} MB,${entries} 个条目)`, "放行");
      res.json({ ok: true, name, entries, size: buf.length });
    } catch (e) {
      try { fs.unlinkSync(tmp); } catch {}
      security.audit("数据备份", `导入备份被拒:${e.message}`, "拦截");
      res.status(400).json({ error: e.message });
    }
  }
);
app.post("/api/backup/restore", async (req, res) => {
  if (!backupAllowed(req, res)) return;
  const p = backupFile(String((req.body || {}).name || ""));
  if (!p) return res.status(404).json({ error: "备份不存在" });
  // 自动备份正打着包时去解包,两边读写同一批文件,打出来的和恢复出来的都可能是半新半旧
  if (autoBackup.isRunning()) return res.status(409).json({ error: "自动备份正在打包,等一分钟再恢复" });
  restoring = true;
  try {
    // 恢复前先把现状自动备一份——恢复错了还能回来,这一步绝不省
    const safety = await makeBackup("before-restore");
    await new Promise((resolve, reject) =>
      // --no-same-owner:包可能是从另一台机器导进来的,里面记的 uid 跟这儿对不上,
      // 不加这个的话以 root 跑的服务会把文件的属主改成那台机器上的号,恢复完自己反而读不了
      require("child_process").execFile("tar", ["-xzf", p, "--no-same-owner", "-C", DATA_DIR], { timeout: 300000, windowsHide: true },
        (err) => (err ? reject(new Error(err.code === "ENOENT" ? "系统里没有 tar 命令(macOS/Linux/Windows 10 1803+ 都自带)" : "tar 解包失败:" + err.message)) : resolve()))
    );
    security.audit("数据恢复", `已从 ${path.basename(p)} 恢复(恢复前现状已存为 ${safety})`, "放行");
    res.json({ ok: true, safety, restart_required: true, note: "已恢复到磁盘。内存里还是旧数据,重启应用后完全生效。" });
  } catch (e) { res.status(500).json({ error: e.message }); }
  finally { restoring = false; }
});
app.post("/api/backup/restart", (req, res) => {
  if (!backupAllowed(req, res)) return;
  if (!process.versions.electron) return res.status(400).json({ error: "非桌面版:请手动重启服务进程" });
  res.json({ ok: true });
  // 先把响应发出去再重启,不然前端只看到断线
  setTimeout(() => {
    try {
      // 独立服务进程里没有 app:让主进程先收掉服务进程,再整个重启
      const bridge = require("./src/platform/electron-bridge");
      if (bridge.isRemote()) { bridge.notify("app.relaunch"); return; }
      const { app: eApp } = require("electron");
      eApp.relaunch();
      eApp.exit(0);
    } catch (e) { console.warn("[备份] 重启失败:", e.message); }
  }, 600);
});

// ---------- 记忆搬家:导出 / 从其它 agent 导入 ----------
// 导出成一份人能读的 Markdown(手写区 + 条目区),到哪都能用。
// 导入支持两路:① 扫描本机已知的其它 agent 记忆文件(Claude Code / Codex / Claude Cowork),
// 只读扫描白名单里的路径,绝不接受任意路径;② 粘贴任意文本(记忆不落在固定文件里的工具,
// 从它界面里复制出来贴进来就行)。解析是确定性的,不烧 token。
app.get("/api/memory/export", (req, res) => {
  const u = req.user ? req.user.username : undefined;
  const items = memory.list(u);
  const lines = [
    "# OpenWorkBuddy 记忆导出",
    "",
    `导出时间:${new Date().toLocaleString("zh-CN")}${req.user ? ` · 账号:${req.user.username}` : ""}`,
    "",
    "## 背景说明(手写区,全局共享)",
    "",
    memory.manual() || "(空)",
    "",
    "## 记忆条目",
    "",
    ...(items.length
      ? items.map((it) => `- [${it.scope === memory.SHARED ? "共享" : it.scope}] ${it.text}`)
      : ["(还没有条目)"]),
    "",
  ];
  res.setHeader("Content-Type", "text/markdown; charset=utf-8");
  res.setHeader("Content-Disposition", `attachment; filename="openworkbuddy-memory-${new Date().toISOString().slice(0, 10)}.md"`);
  res.send(lines.join("\n"));
});

/** 本机其它 agent 的记忆文件白名单扫描(找得到才列出来,路径不存在就静默跳过) */
function memoryImportSources() {
  const home = require("os").homedir();
  const out = [];
  const push = (label, p, mode) => {
    try {
      const st = fs.statSync(p);
      if (st.isFile() && st.size > 0 && st.size < 2 * 1024 * 1024) out.push({ label, path: p, size: st.size, mode });
    } catch {}
  };
  push("Claude Code 全局记忆(~/.claude/CLAUDE.md)", path.join(home, ".claude", "CLAUDE.md"), "manual");
  try {
    for (const d of fs.readdirSync(path.join(home, ".claude", "projects"))) {
      push(`Claude Code 项目记忆(${d.replace(/^-/, "").slice(0, 48)})`, path.join(home, ".claude", "projects", d, "memory", "MEMORY.md"), "items");
    }
  } catch {}
  push("Codex 全局记忆(~/.codex/AGENTS.md)", path.join(home, ".codex", "AGENTS.md"), "manual");
  push("Claude Cowork 记忆(~/.cowork/CLAUDE.md)", path.join(home, ".cowork", "CLAUDE.md"), "manual");
  push("Claude Cowork 记忆(应用目录)", path.join(home, "Library", "Application Support", "Claude Cowork", "CLAUDE.md"), "manual");
  if (process.env.APPDATA) push("Claude Cowork 记忆(应用目录)", path.join(process.env.APPDATA, "Claude Cowork", "CLAUDE.md"), "manual");
  return out;
}

app.get("/api/memory/import/scan", (_req, res) => res.json({ sources: memoryImportSources() }));

app.post("/api/memory/import", (req, res) => {
  const b = req.body || {};
  let content = "", label = "粘贴的内容";
  if (b.path) {
    const hit = memoryImportSources().find((s) => s.path === String(b.path));
    if (!hit) return res.status(400).json({ error: "只能导入扫描列表里的文件(防任意路径读取)" });
    try { content = fs.readFileSync(hit.path, "utf8"); } catch (e) { return res.status(500).json({ error: "读取失败:" + e.message }); }
    label = hit.label;
  } else {
    content = String(b.text || "");
  }
  content = content.trim();
  if (!content) return res.status(400).json({ error: "没有可导入的内容" });
  const mode = b.mode === "manual" ? "manual" : "items";
  if (mode === "manual") {
    // 成段的背景/规范:整段并入手写区,加来源标头,去重靠人眼(手写区本来就是人编辑的)
    const cur = memory.manual();
    if (cur.includes(content.slice(0, 200))) return res.json({ ok: true, added: 0, skipped: 1, note: "内容已在背景说明里,跳过" });
    memory.saveManual((cur ? cur + "\n\n" : "") + `## 导入自 ${label}(${new Date().toISOString().slice(0, 10)})\n\n` + content);
    return res.json({ ok: true, added: 1, mode });
  }
  // 条目模式:逐行解析 markdown 列表(- / * / 数字.),[标题](链接) 压成 标题,跳过标题行和短行
  const user = req.user ? req.user.username : undefined;
  let added = 0, skipped = 0;
  const lines = content.split(/\r?\n/).slice(0, 500);
  for (const raw of lines) {
    let t = raw.trim();
    if (!t || /^#{1,6}\s/.test(t) || /^[-*_]{3,}$/.test(t)) continue; // 标题、分隔线
    t = t.replace(/^[-*+]\s+/, "").replace(/^\d+[.)]\s+/, "");
    t = t.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); // markdown 链接压成文字
    t = t.replace(/\*\*/g, "").trim();
    if (t.length < 4) continue;
    const r = memory.add({ text: t, user, shared: !!b.shared, source: "user" });
    if (r.ok) added++; else skipped++;
  }
  security.audit("记忆导入", `${label}:导入 ${added} 条,跳过 ${skipped} 条`, "放行");
  res.json({ ok: true, added, skipped, mode });
});

// 两份缓存,口径完全不同,所以一起报但分开说:上面那份是可再生的磁盘垃圾(清了只是下次慢一点),
// gen 那份是「这一格已经买过了」的账(清了下次要重新花钱)。清理按钮只动前者。
app.get("/api/cache", (_req, res) => res.json({ ...cacheStats(), gen: genCache.stats() }));
app.post("/api/cache/clear", async (_req, res) => {
  const before = cacheStats();
  if (process.versions.electron) {
    // 桌面版走官方 API:HTTP 缓存/代码缓存/着色器缓存;Cookie 与 localStorage(登录态、主题)不动
    try {
      // 独立服务进程里没有 session:界面那份由主进程清(同样三样,同样不动 Cookie)
      const bridge = require("./src/platform/electron-bridge");
      const ses = bridge.isRemote() ? null : require("electron").session.defaultSession;
      if (!ses) await bridge.call("session.clearCaches", null, { timeoutMs: 30000 });
      else {
        await ses.clearCache();
        try { await ses.clearCodeCaches({}); } catch {}
        try { await ses.clearStorageData({ storages: ["shadercache", "cachestorage"] }); } catch {}
      }
    } catch (e) { console.warn("[缓存] Electron 清理失败:", e.message); }
  }
  const ud = appUserDataDir();
  for (const d of CHROMIUM_CACHE_DIRS) {
    const dir = path.join(ud, d);
    try {
      for (const e of fs.readdirSync(dir)) {
        try { fs.rmSync(path.join(dir, e), { recursive: true, force: true }); } catch {} // 被占用就跳过
      }
    } catch {}
  }
  for (const dir of cacheTmpDirs()) {
    try {
      for (const e of fs.readdirSync(dir)) {
        const fp = path.join(dir, e);
        try {
          if (fs.lstatSync(fp).isSymbolicLink()) fs.unlinkSync(fp); // node_modules 软链只解链,下次 runNode 自动重建
          else fs.rmSync(fp, { recursive: true, force: true });
        } catch {}
      }
    } catch {}
  }
  const after = cacheStats();
  res.json({ ok: true, freed: Math.max(0, before.total - after.total), before: before.total, after: after.total });
});

app.get("/api/skills", (req, res) =>
  // plugin 字段标出技能来自哪个 Agent Plugins 插件(本地技能没有这个字段),界面据此禁用编辑/删除
  res.json((runtime ? runtime.getSkills(req.user ? req.user.username : undefined) : []).map((s) => ({ name: s.name, description: s.description, plugin: s.plugin || "" })))
);
// 技能管理:getSkills 每次现读磁盘,增删改/安装即热生效,无需重启
const skillsMgr = require("./src/core/ext/skills");
const actorOf = (req) => ({ user: (req && req.user && (req.user.username || req.user.name)) || "" });
/**
 * 被安装检查拦下的错误,要比普通 400 多带两样东西:
 *   needs   前端据此决定画「我知道了,还是装」还是「只有平台管理员能强装」
 *   scan    那份清单本身,好让界面把命中的文件和行号摊开,而不是只弹一句红字
 * 普通错误(名字空、链接格式不对)照旧只有 message。
 */
const skillErr = (e) => (e && e.skillScan
  ? { error: e.message, needs: e.needs, scan: { level: e.skillScan.level, hosts: e.skillScan.hosts, findings: e.skillScan.findings } }
  : { error: e.message });
app.get("/api/skills/:name", (req, res) => {
  const s = skillsMgr.getSkillFull(req.params.name);
  if (!s) return res.status(404).json({ error: "技能不存在" });
  res.json(s);
});
// 增 / 改 / 删 / 装技能都归平台管理员,闸不在这儿——admin.js 的 PLATFORM_WRITE 表里
// 有 "/api/skills" 前缀,app.use(admin.platformGuard) 排在所有路由前面,四条路一次拦完。
// 这里不再写第二道:两张表迟早对不上,而对不上的那次一定是放行的那次。
app.post("/api/skills", (req, res) => {
  try {
    const { name, description, content, original_name, confirm, force } = req.body || {};
    if (!String(name || "").trim()) throw new Error("技能名不能为空");
    if (!String(content || "").trim()) throw new Error("技能内容不能为空");
    res.json(skillsMgr.saveSkill({ name, description, content, original_name, confirm: !!confirm, force: !!force, actor: actorOf(req) }));
  } catch (e) {
    res.status(400).json(skillErr(e));
  }
});
app.delete("/api/skills/:name", (req, res) => {
  try {
    res.json({ ok: skillsMgr.deleteSkill(req.params.name) });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});
app.post("/api/skills/install", async (req, res) => {
  try {
    const { url, confirm, force } = req.body || {};
    const installed = await skillsMgr.installFromGitHub(url, { confirm: !!confirm, force: !!force, actor: actorOf(req) });
    res.json({ ok: true, installed });
  } catch (e) {
    res.status(400).json(skillErr(e));
  }
});

// ---- 默认技能:不随仓库打包,点一下从上游装 ----
app.get("/api/skills/defaults/list", (_req, res) => res.json(skillsMgr.listDefaultSkills()));
app.post("/api/skills/defaults/install", async (req, res) => {
  try {
    const { names, force } = req.body || {};
    const results = await skillsMgr.ensureDefaultSkills({
      only: Array.isArray(names) && names.length ? names : null,
      force: !!force, // 「重新下载」要真的重下,不然点了没反应
    });
    res.json({ ok: true, results });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// ---- Agent Plugins 1.0.0 插件 ----
const pluginsMgr = require("./src/core/ext/plugins");
app.get("/api/plugins", (_req, res) => {
  const list = pluginsMgr.loadPlugins().map((p) => ({
    ok: p.ok,
    name: p.name,
    error: p.error || "",
    warnings: p.warnings || [],
    version: p.manifest?.version || "",
    description: p.manifest?.description || "",
    license: p.manifest?.license || "",
    author: p.manifest?.author?.name || "",
    homepage: p.manifest?.homepage || "",
    repository: p.manifest?.repository || "",
    skills: (p.skills || []).map((s) => ({ name: s.name, description: s.description })),
    mcp_servers: (p.mcpServers || []).map((s) => ({ name: s.name, transport: s.transport })),
    bytes: p.ok ? skillsMgr.dirSize(p.dir) : 0,
    source: pluginsMgr.pluginSource(p.name), // 有来源才给「更新」按钮
  }));
  res.json({ spec: pluginsMgr.SPEC_VERSION, plugins: list, mcp: mcpManager.status() });
});
app.post("/api/plugins/install", async (req, res) => {
  try {
    const info = await pluginsMgr.installPluginFromGitHub((req.body || {}).url);
    // 插件带的 MCP 服务器要现起才能用;技能是每次任务现读磁盘的,不用管
    const started = await startPluginMcp(info.name);
    res.json({ ok: true, installed: info, mcp_started: started });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});
app.post("/api/plugins/:name/update", async (req, res) => {
  try {
    const name = req.params.name;
    // 旧的先停干净:重装会把目录整个换掉,老进程留着还指着已经删掉的文件
    mcpManager.stopPlugin(name);
    const info = await pluginsMgr.updatePlugin(name);
    const started = await startPluginMcp(info.name);
    res.json({ ok: true, updated: info, mcp_started: started });
  } catch (e) {
    // 更新失败也得把停掉的服务器捞回来,不然用户点一下「更新」反而把能用的搞没了
    try { await startPluginMcp(req.params.name); } catch { /* 插件目录可能已经没了 */ }
    res.status(400).json({ error: e.message });
  }
});
app.delete("/api/plugins/:name", (req, res) => {
  try {
    // 先停进程再删目录:目录一删就查不出它带过哪些服务器,子进程会一直挂到重启应用
    const stopped = mcpManager.stopPlugin(req.params.name);
    const ok = pluginsMgr.removePlugin(req.params.name);
    if (!ok) return res.json({ ok: false, note: "没有这个插件" });
    res.json({
      ok: true,
      mcp_stopped: stopped,
      note: stopped.length ? `插件已卸载,同时停掉了它带的 ${stopped.length} 个 MCP 服务器` : "插件已卸载",
    });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

/** 只起某个插件里还没连上的 MCP 服务器(装完立刻可用,不用重启) */
async function startPluginMcp(pluginName) {
  // 排进连接器那条队:跟页面保存、agent 加连接器同时来时别交错着起停
  return mcpApply(async () => {
    const want = pluginsMgr.pluginMcpServers().filter((s) => s.plugin === pluginName && !mcpManager.clients.has(s.name));
    if (!want.length) return [];
    await mcpManager.startAll(want);
    return want.filter((s) => mcpManager.clients.has(s.name)).map((s) => s.name);
  });
}

// 文件上传到工作空间(输入框 + 按钮)
// 用户传进来的文件要和这轮的产出待在一起。以前一律落工作空间根目录,于是
// 「我传的素材」和「它做出来的成果」分了家,根目录越堆越乱(真实数据里躺了 22 个)。
// 时序坑:用户是**先传文件再发消息**,而成果文件夹要等第一条消息才建得出名字。
// 所以没文件夹时先落根目录并记账,等文件夹一建好,settlePendingUploads 再把它们搬进去。
//
// 两种请求体:
// - 原始字节(Content-Type: application/octet-stream):输入框、画布现在都走这条。边收边写盘,多大的视频都不过内存;
//   名字、会话、replace 放在请求头里,URI 编码过(请求头只认 ASCII)
// - JSON + base64:老页面、脚本还在用,照收。这条受全局 express.json 60MB 的限制,大文件别走它
// Only the desktop shell can supply source paths. The HTTP caller supplies a
// one-use opaque grant or asks for a native picker, never an arbitrary path.
app.post("/api/desktop/open-files", taskFileWrite, taskFileHandler(async (req, res) => {
  const bridge = require("./src/platform/electron-bridge");
  if (bridge.mode() === "none" || !isPlatformOwner(req)) return res.status(403).json({ error: "请在本机桌面版打开文件" });
  try {
    const b = req.body || {};
    const sess = b.session ? getSession(String(b.session)) : null;
    if (!sess || !sessionAllowed(req.user, sess)) return res.status(403).json({ error: "这条对话不可用" });
    const call = async (op, args) => {
      if (bridge.isRemote()) return op === "files.takeOpened"
        ? bridge.call("files.takeOpened", args, { timeoutMs: 15 * 60 * 1000 })
        : bridge.call("dialog.openFiles", args, { timeoutMs: 15 * 60 * 1000 });
      if (op === "files.takeOpened") return require("./src/desktop/open-files").take(String(args.token || ""));
      const { dialog } = require("electron");
      return dialog.showOpenDialog({ properties: ["openFile", "multiSelections"], title: "打开文件并对话" });
    };
    const picked = b.token ? await call("files.takeOpened", { token: String(b.token) }) : b.pick === true ? await call("dialog.openFiles", {}) : [];
    const sources = Array.isArray(picked) ? picked : picked.canceled ? [] : picked.filePaths || [];
    const files = [], errors = [];
    // A new file conversation starts in the active project's default folder,
    // even if the previous conversation temporarily used a different workspace.
    const importRoot = (admin.keepsSharedSpace(req.user) && activeProject()?.dir) || getWorkspaceDir();
    await withWorkspace(importRoot, async () => {
    for (const source of sources.slice(0, 20)) {
      let tmp = "";
      try {
        const st = await fs.promises.stat(source);
        if (!st.isFile() || st.size > 200 * 1024 * 1024) throw Error("请选择 200 MB 以内的文件");
        const base = uploadName.uploadBaseName(source);
        uploadName.checkUploadContent(base, st.size);
        const own = perChatHere() ? sessDirOf(sess) : null;
        const dir = path.dirname(safePath(own ? path.join(own, base) : base));
        await fs.promises.mkdir(dir, { recursive: true });
        tmp = path.join(dir, ".open-" + require("crypto").randomBytes(12).toString("hex"));
        await fs.promises.copyFile(source, tmp, fs.constants.COPYFILE_EXCL);
        const saved = uploadName.writeUploadFresh(dir, base, { from: tmp }); tmp = "";
        const rel = own ? path.join(own, saved) : saved;
        noteUserInput(rel);
        if (!own) sess.pending_uploads = (sess.pending_uploads || []).concat({ name: saved, root: getWorkspaceDir(), by: req.user?.username || "" });