From 9e126db84c23335a83fd213f00b67d693ece6a63 Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 02:14:51 +0800 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20setup-mirror.?= =?UTF-8?q?mjs=20=E9=95=9C=E5=83=8F=E9=85=8D=E7=BD=AE=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E4=B8=8E=20.npmrc=20=E7=B4=AF=E7=A7=AF=E8=86=A8=E8=83=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 1. generateNpmrc() 仅写入 registry,漏配 electron/puppeteer/sharp/better-sqlite3 的二进制镜像,导致这些包的 postinstall 走默认源(GitHub/Google),国内网络 下极易卡死或失败 2. 过滤逻辑无法正确识别自动生成的键值行,每次 preinstall 执行都会将已存在的 配置误判为「用户自定义」并重复追加,导致 .npmrc 不断膨胀(实测累积 100+ 行 重复注释段) 3. 缺少 fetch-timeout,npm 包下载遇到不稳定网络时无限等待 修复: - generateNpmrc() 补齐 electron_mirror/electron_builder_binaries_mirror/ puppeteer_download_base_url/sharp_binary_host/better_sqlite3_binary_host 及 fetch-timeout=60000/fetch-retries=3 - 用 begin/end 标记包裹自动生成段,过滤时整体移除,保证幂等 - 新增 .puppeteerrc.cjs(puppeteer 24.x 读取的镜像配置文件) --- .npmrc | 103 ++++++--------------------------------- .puppeteerrc.cjs | 3 ++ scripts/setup-mirror.mjs | 67 +++++++++++++++++++------ 3 files changed, 68 insertions(+), 105 deletions(-) create mode 100644 .puppeteerrc.cjs diff --git a/.npmrc b/.npmrc index 601e9c1..8bacbbc 100644 --- a/.npmrc +++ b/.npmrc @@ -1,101 +1,26 @@ -# 自动生成的镜像配置 (by setup-mirror.mjs) -# 生成时间: 2026-06-07T08:50:30.745Z +# --- begin auto-generated by setup-mirror.mjs --- +# 生成时间: 2026-06-24T18:11:12.403Z # npm registry -registry=https://mirrors.huaweicloud.com/repository/npm/ +registry=https://registry.npmmirror.com -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 +# 网络超时与重试(避免 GitHub 不稳定时无限等待) +fetch-timeout=60000 +fetch-retries=3 -# better-sqlite3 二进制镜像 +# electron 二进制镜像 +electron_mirror=https://registry.npmmirror.com/-/binary/electron/ +electron_builder_binaries_mirror=https://registry.npmmirror.com/-/binary/electron-builder-binaries/ # puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry +puppeteer_download_base_url=https://cdn.npmmirror.com/binaries/chrome-for-testing # sharp 二进制镜像 +sharp_binary_host=https://registry.npmmirror.com/-/binary/sharp +sharp_libvips_binary_host=https://registry.npmmirror.com/-/binary/sharp-libvips # better-sqlite3 二进制镜像 +better_sqlite3_binary_host=https://registry.npmmirror.com/-/binary/better-sqlite3 -# puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 - -# better-sqlite3 二进制镜像 - -# puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 - -# better-sqlite3 二进制镜像 - -# puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 - -# better-sqlite3 二进制镜像 - -# puppeteer chromium 镜像 - -# 用户自定义配置 -# npm registry - -# sharp 二进制镜像 - -# better-sqlite3 二进制镜像 - -# puppeteer chromium 镜像 +# --- end auto-generated by setup-mirror.mjs --- -# 用户自定义配置 -# 强制 pnpm 生成 npm 兼容的扁平 node_modules(避免 .pnpm/ 符号链接结构)。 -# 原因:Electron 打包后 backend 以 ELECTRON_RUN_AS_NODE 模式从 resources/app/ 下运行, -# 通过 extraResources 拷贝的 node_modules 必须是扁平结构才能被 Node 的模块解析器正常向上查找; -# pnpm 的默认 isolated 链接器会让传递依赖丢失,导致 Cannot find module 报错。 diff --git a/.puppeteerrc.cjs b/.puppeteerrc.cjs new file mode 100644 index 0000000..15d49eb --- /dev/null +++ b/.puppeteerrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + chromeDownloadBaseUrl: 'https://cdn.npmmirror.com/binaries/chrome-for-testing', +}; diff --git a/scripts/setup-mirror.mjs b/scripts/setup-mirror.mjs index 355337a..551fe3d 100644 --- a/scripts/setup-mirror.mjs +++ b/scripts/setup-mirror.mjs @@ -83,12 +83,33 @@ async function selectBestMirror(mirrors) { * 生成 .npmrc 内容 */ function generateNpmrc(selected) { + const baseUrl = selected.registry.url.replace(/\/$/, ''); const lines = [ - '# 自动生成的镜像配置 (by setup-mirror.mjs)', + '# --- begin auto-generated by setup-mirror.mjs ---', `# 生成时间: ${new Date().toISOString()}`, '', '# npm registry', - `registry=${selected.registry.url}` + `registry=${selected.registry.url}`, + '', + '# 网络超时与重试(避免 GitHub 不稳定时无限等待)', + 'fetch-timeout=60000', + 'fetch-retries=3', + '', + '# electron 二进制镜像', + `electron_mirror=${baseUrl}/-/binary/electron/`, + `electron_builder_binaries_mirror=${baseUrl}/-/binary/electron-builder-binaries/`, + '', + '# puppeteer chromium 镜像', + `puppeteer_download_base_url=https://cdn.npmmirror.com/binaries/chrome-for-testing`, + '', + '# sharp 二进制镜像', + `sharp_binary_host=${baseUrl}/-/binary/sharp`, + `sharp_libvips_binary_host=${baseUrl}/-/binary/sharp-libvips`, + '', + '# better-sqlite3 二进制镜像', + `better_sqlite3_binary_host=${baseUrl}/-/binary/better-sqlite3`, + '', + '# --- end auto-generated by setup-mirror.mjs ---', ]; return lines.join('\n'); @@ -133,23 +154,37 @@ async function main() { console.log('\n📝 生成 .npmrc...'); const npmrcContent = generateNpmrc(selected); - // 保留用户自定义配置 + // 保留用户自定义配置(自动生成段之外的内容) const npmrcPath = '.npmrc'; if (existsSync(npmrcPath)) { - // 读取现有配置,移除自动生成的部分 const existing = readFileSync(npmrcPath, 'utf-8'); - const lines = existing.split('\n').filter(line => - !line.includes('# 自动生成的镜像配置') && - !line.includes('# 生成时间:') && - !line.includes('registry=') && - !line.includes('sharp_binary_host=') && - !line.includes('sharp_libvips_binary_host=') && - !line.includes('better_sqlite3_binary_host=') && - !line.includes('puppeteer_download_host=') && - !line.includes('node-linker=') - ); - - const customConfig = lines.join('\n').trim(); + const autoKeys = [ + 'registry=', 'fetch-timeout=', 'fetch-retries=', + 'electron_mirror=', 'electron_builder_binaries_mirror=', + 'puppeteer_download_base_url=', 'puppeteer_download_host=', + 'sharp_binary_host=', 'sharp_libvips_binary_host=', + 'better_sqlite3_binary_host=', 'node-linker=', + ]; + // 移除自动生成段落(新旧两种格式)和自动生成的键值行 + const customConfig = existing + .replace(/^# --- begin auto-generated[\s\S]*?# --- end auto-generated[^\n]*\n?/gm, '') + .replace(/^# 自动生成的镜像配置[\s\S]*?(?=\n\S|$)/gm, '') + .split('\n') + .filter(line => + !line.includes('# 生成时间:') && + !line.includes('# 用户自定义配置') && + !line.includes('# npm registry') && + !line.includes('# 网络超时') && + !line.includes('# electron 二进制镜像') && + !line.includes('# puppeteer chromium 镜像') && + !line.includes('# sharp 二进制镜像') && + !line.includes('# better-sqlite3 二进制镜像') && + !autoKeys.some(key => line.startsWith(key)) && + line.trim() !== '' + ) + .join('\n') + .trim(); + if (customConfig) { writeFileSync(npmrcPath, npmrcContent + '\n\n# 用户自定义配置\n' + customConfig + '\n'); } else { From ad33759b270bc657a72de4c0eeb7b749ff49a34a Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 02:14:59 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20npm=20install=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E4=BF=9D=E6=8A=A4=E4=B8=8E=20pnpm=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC=E8=AE=B8=E5=8F=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy.mjs: npm install 超时从 5 分钟提到 30 分钟,失败时打印常见原因与解决方案 - deploy.sh: 修正文件可执行权限 (644 -> 755) - web/pnpm-workspace.yaml: pnpm 11 要求显式许可 esbuild/vue-demi 的 build scripts --- scripts/deploy.mjs | 8 +++++++- scripts/deploy.sh | 0 web/pnpm-workspace.yaml | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) mode change 100644 => 100755 scripts/deploy.sh create mode 100644 web/pnpm-workspace.yaml diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index 761c69d..d0417e4 100644 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -848,7 +848,13 @@ async function deployProject(options = {}) { } try { - run('npm', ['install', '--include=dev'], '安装后端依赖'); + run('npm', ['install', '--include=dev'], '安装后端依赖', { timeout: 1800000 }); + } catch (error) { + console.error('[deploy] npm install 失败,常见原因:'); + console.error('[deploy] 1. 网络不稳定导致 electron/puppeteer 二进制下载卡死'); + console.error('[deploy] 2. GitHub 在当前网络下不可达'); + console.error('[deploy] 可尝试:配置代理后重试,或手动执行 npm install --ignore-scripts 后单独安装二进制'); + throw error; } finally { if (puppeteerHostSet) { delete process.env.PUPPETEER_DOWNLOAD_HOST; diff --git a/scripts/deploy.sh b/scripts/deploy.sh old mode 100644 new mode 100755 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..a23eae0 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true + vue-demi: true From ddc528b3dc4617b76a6e237757a9e19eabbf4b96 Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 03:21:49 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Linux=20syste?= =?UTF-8?q?md=20=E9=83=A8=E7=BD=B2=E4=B8=8B=20opencode=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=A7=A3=E6=9E=90=E4=B8=8E=20PATH=20=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 1. resolveOpenCodeExecutable() Linux 硬编码 {type:'shell',cmd:'opencode'}, 不做路径查找,不读 OPENCODE_AUTO_START_CMD,systemd 环境找不到 opencode 2. buildServiceContent() 不设 Environment=PATH,systemd 默认 PATH 不含 ~/.opencode/bin 和 ~/.local/bin 3. installSystemdService() 的 sudo -u 子进程被 secure_path 覆盖, 丢失用户 PATH 导致用错 Node 版本(v18 而非 v22)且找不到 npm 4. ExecStart 用 dist/index.js 而非 dist/admin/index.js, 导致 Web 面板重启 API 返回 Bridge 管理器未初始化 修复: - resolveOpenCodeExecutable() Linux 分支补齐常见路径查找 (~/.opencode/bin, ~/.local/bin, /usr/local/bin, /usr/bin) + 读取 OPENCODE_AUTO_START_CMD 环境变量 - buildServiceContent() 加 Environment=PATH 和 HOME - installSystemdService() 用 env 命令显式注入 PATH 绕过 secure_path - ExecStart 改用 dist/admin/index.js(正确初始化 bridgeManager) --- .npmrc | 15 +++++++-------- scripts/deploy.mjs | 32 ++++++++++++++++++++++++++++++-- scripts/process-manager.mjs | 28 +++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/.npmrc b/.npmrc index 8bacbbc..92fde96 100644 --- a/.npmrc +++ b/.npmrc @@ -1,26 +1,25 @@ # --- begin auto-generated by setup-mirror.mjs --- -# 生成时间: 2026-06-24T18:11:12.403Z +# 生成时间: 2026-06-24T19:18:59.425Z # npm registry -registry=https://registry.npmmirror.com +registry=https://mirrors.huaweicloud.com/repository/npm/ # 网络超时与重试(避免 GitHub 不稳定时无限等待) fetch-timeout=60000 fetch-retries=3 # electron 二进制镜像 -electron_mirror=https://registry.npmmirror.com/-/binary/electron/ -electron_builder_binaries_mirror=https://registry.npmmirror.com/-/binary/electron-builder-binaries/ +electron_mirror=https://mirrors.huaweicloud.com/repository/npm/-/binary/electron/ +electron_builder_binaries_mirror=https://mirrors.huaweicloud.com/repository/npm/-/binary/electron-builder-binaries/ # puppeteer chromium 镜像 puppeteer_download_base_url=https://cdn.npmmirror.com/binaries/chrome-for-testing # sharp 二进制镜像 -sharp_binary_host=https://registry.npmmirror.com/-/binary/sharp -sharp_libvips_binary_host=https://registry.npmmirror.com/-/binary/sharp-libvips +sharp_binary_host=https://mirrors.huaweicloud.com/repository/npm/-/binary/sharp +sharp_libvips_binary_host=https://mirrors.huaweicloud.com/repository/npm/-/binary/sharp-libvips # better-sqlite3 二进制镜像 -better_sqlite3_binary_host=https://registry.npmmirror.com/-/binary/better-sqlite3 +better_sqlite3_binary_host=https://mirrors.huaweicloud.com/repository/npm/-/binary/better-sqlite3 # --- end auto-generated by setup-mirror.mjs --- - diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index d0417e4..1cb0252 100644 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -1136,6 +1136,15 @@ function getServiceRunUser() { function buildServiceContent() { const serviceUser = getServiceRunUser(); + // 构建 PATH:用户 bin 目录 + systemd 默认目录,确保 systemd 环境能找到 opencode/node + const userHome = resolveHomeDirForUser(serviceUser); + const pathDirs = [ + path.join(userHome, '.local', 'bin'), + path.join(userHome, '.opencode', 'bin'), + '/usr/local/bin', + '/usr/bin', + '/bin', + ]; return [ '[Unit]', 'Description=Feishu OpenCode Bridge', @@ -1145,10 +1154,12 @@ function buildServiceContent() { 'Type=simple', `User=${serviceUser}`, `WorkingDirectory=${rootDir}`, - `ExecStart=${process.execPath} dist/index.js`, + `ExecStart=${process.execPath} dist/admin/index.js`, 'Restart=always', 'RestartSec=3', `EnvironmentFile=-${envPath}`, + `Environment=PATH=${pathDirs.join(':')}`, + `Environment=HOME=${userHome}`, `StandardOutput=append:${outLog}`, `StandardError=append:${errLog}`, '', @@ -1167,13 +1178,30 @@ async function installSystemdService() { console.log(`[deploy] 检测到 sudo 环境,将以 ${sudoUser} 身份执行部署...`); const sudoUserHome = resolveHomeDirForUser(sudoUser); + // 构建普通用户的 PATH(sudo secure_path 会覆盖,需显式恢复) + const userPathDirs = [ + path.join(sudoUserHome, '.local', 'bin'), + path.join(sudoUserHome, '.opencode', 'bin'), + '/usr/local/bin', + '/usr/bin', + '/bin', + ]; + const userPath = userPathDirs.join(':'); + // 以普通用户身份执行部署 - const deployResult = spawnSync('sudo', ['-u', sudoUser, 'node', path.join(scriptDir, 'deploy.mjs'), 'deploy'], { + // 用 env 命令显式注入 PATH,绕过 sudo secure_path 覆盖 + // 用绝对路径的 node,避免 PATH 里仍找不到 + const deployResult = spawnSync('sudo', [ + '-u', sudoUser, + 'env', `PATH=${userPath}`, `HOME=${sudoUserHome}`, + process.execPath, path.join(scriptDir, 'deploy.mjs'), 'deploy', + ], { cwd: rootDir, stdio: 'inherit', env: { ...process.env, HOME: sudoUserHome, + PATH: userPath, BRIDGE_SKIP_SUDO_WARNING: '1', }, }); diff --git a/scripts/process-manager.mjs b/scripts/process-manager.mjs index e49ca0e..bcd69ba 100644 --- a/scripts/process-manager.mjs +++ b/scripts/process-manager.mjs @@ -13,6 +13,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -536,7 +537,32 @@ function main() { * 返回 { type: 'node-script', nodeExe, script } 或 { type: 'shell', cmd: 'opencode' } */ function resolveOpenCodeExecutable() { + // 优先读 OPENCODE_AUTO_START_CMD(用户自定义绝对路径) + const customCmd = process.env.OPENCODE_AUTO_START_CMD?.trim(); + if (customCmd) { + const parts = customCmd.split(/\s+/); + const exePath = parts[0]; + const restArgs = parts.slice(1); + if (fs.existsSync(exePath)) { + return { type: 'direct', exe: exePath, extraArgs: restArgs }; + } + return { type: 'shell', cmd: customCmd }; + } + if (!isWindows()) { + // Linux/macOS: 检查常见安装路径 + const homeDir = os.homedir(); + const candidates = [ + path.join(homeDir, '.opencode', 'bin', 'opencode'), // 官方脚本安装 + path.join(homeDir, '.local', 'bin', 'opencode'), // npm i -g (用户级 prefix) + '/usr/local/bin/opencode', // npm i -g (root) + '/usr/bin/opencode', // 包管理器安装 + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return { type: 'direct', exe: candidate }; + } + } return { type: 'shell', cmd: 'opencode' }; } @@ -727,7 +753,7 @@ function startOpenCodeServe(options = {}) { fs.closeSync(stdoutFd); fs.closeSync(stderrFd); } else if (exe.type === 'direct') { - const directArgs = ['serve']; + const directArgs = [...(exe.extraArgs || []), 'serve']; if (servePort !== null) directArgs.push('--port', String(servePort)); child = spawn(exe.exe, directArgs, { detached: true, From 05ca8149d57af346cffa0fa8574fbc4ca93083e7 Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 03:22:00 +0800 Subject: [PATCH 04/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=86=85?= =?UTF-8?q?=E5=B5=8C=E6=A8=A1=E5=BC=8F=E4=B8=8B=20Web=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: startEmbedded() 动态 import index.js 触发 main(), 但未设置 BRIDGE_SPAWNED_BY_ADMIN,导致 main() 重复创建 不含 bridgeManager 的 admin server,HTTP 请求命中错误实例, restart API 返回 Bridge 管理器未初始化 修复: startEmbedded() 启动前设置 process.env.BRIDGE_SPAWNED_BY_ADMIN='1', 与子进程模式(startChildProcess 的 env)保持一致 --- src/admin/bridge-manager.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/admin/bridge-manager.ts b/src/admin/bridge-manager.ts index 9f7278f..1e8062a 100644 --- a/src/admin/bridge-manager.ts +++ b/src/admin/bridge-manager.ts @@ -62,6 +62,9 @@ export class BridgeManager { try { console.log('[BridgeManager] 内嵌模式启动 Bridge...'); + // 标记为由 Admin 启动,防止 main() 重复创建 Admin Server + process.env.BRIDGE_SPAWNED_BY_ADMIN = '1'; + // 动态导入 Bridge 模块 const { startBridge } = await import('../index.js'); From 381f6731fe86c2effeafb1152d9780f85ad8084d Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 03:35:13 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20opencode=20se?= =?UTF-8?q?rve=20=E5=B9=82=E7=AD=89=E6=A3=80=E6=9F=A5=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=BC=8F=20opencode=20=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: findOpenCodeProcesses() 用 isOpenCodeCommand() 匹配所有 opencode 命令 (含交互式 opencode、opencode debug 等),导致 startOpenCodeServe 的幂等检查 误判交互式 opencode 进程为 serve 已运行,跳过启动,实际 4096 端口未监听。 修复: - 新增 isOpencodeServeCommand() 精确匹配 opencode serve 进程 - findOpenCodeProcesses() 新增 serveOnly 参数 - startOpenCodeServe 的进程扫描幂等检查改用 serveOnly=true --- scripts/process-manager.mjs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/scripts/process-manager.mjs b/scripts/process-manager.mjs index bcd69ba..8b95ab9 100644 --- a/scripts/process-manager.mjs +++ b/scripts/process-manager.mjs @@ -113,7 +113,7 @@ function findBridgeProcesses(excludeSelf = false, excludePid = null) { * @param {number} excludePid - 要排除的指定 PID * @returns {number[]} 进程 PID 列表 */ -function findOpenCodeProcesses(excludeSelf = false, excludePid = null) { +function findOpenCodeProcesses(excludeSelf = false, excludePid = null, serveOnly = false) { const pids = []; const currentPid = process.pid; @@ -138,7 +138,11 @@ function findOpenCodeProcesses(excludeSelf = false, excludePid = null) { } // opencode.exe 直接就是目标进程 if (match[1] === 'opencode.exe') { - pids.push(pid); + // Windows 下 tasklist 不含命令行参数,serveOnly 无法精确判断 + // 保守起见:serveOnly 时不通过 tasklist 匹配,改由 PID 文件判断 + if (!serveOnly) { + pids.push(pid); + } } else if (isOpenCodeProcessByCommand(pid)) { pids.push(pid); } @@ -163,7 +167,11 @@ function findOpenCodeProcesses(excludeSelf = false, excludePid = null) { const command = parts.slice(10).join(' '); // 匹配 OpenCode 进程 - if (isOpenCodeCommand(command)) { + if (serveOnly) { + if (isOpencodeServeCommand(command)) { + pids.push(pid); + } + } else if (isOpenCodeCommand(command)) { pids.push(pid); } } @@ -201,6 +209,22 @@ function isOpenCodeCommand(command) { return /\bopencode\b/.test(normalizedCmd) || normalizedCmd.includes('opencode-cli'); } +/** + * 精确匹配 `opencode serve` 进程(用于 serve 幂等检查) + * 排除交互式 opencode、opencode debug、opencode attach 等非 serve 命令 + */ +function isOpencodeServeCommand(command) { + const normalizedCmd = command.replace(/\\/g, '/'); + if (isBridgeCommand(normalizedCmd)) { + return false; + } + if (normalizedCmd.includes('opencode-bridge')) { + return false; + } + // 匹配 opencode serve(允许前面有路径,后面有 --port 等参数) + return /\bopencode\b\s+serve\b/.test(normalizedCmd); +} + function getProcessCommandLine(pid) { if (!isWindows()) { return null; @@ -693,7 +717,8 @@ function startOpenCodeServe(options = {}) { } // 也通过进程扫描检查(防止 PID 文件丢失但进程还在的情况) - const scanPids = findOpenCodeProcesses(); + // 仅匹配 `opencode serve` 进程,排除交互式 opencode 避免误判 + const scanPids = findOpenCodeProcesses(false, null, true); if (scanPids.length > 0) { // 补写 PID 文件 try { From 5cf2a8682be1f5a898c889fd814f3f3ce862de87 Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 03:37:46 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20status-opencode=20=E4=B9=9F?= =?UTF-8?q?=E7=94=A8=20serveOnly=20=E9=81=BF=E5=85=8D=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=BC=8F=20opencode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 startOpenCodeServe 的修复保持一致: status-opencode 的进程扫描也应只匹配 opencode serve 进程, 否则会把交互式 opencode 误报为 serve 运行中。 所有调用点安全性分析: - kill-opencode: serveOnly=false (杀所有opencode,设计如此) - list-opencode: serveOnly=false (列所有opencode,设计如此) - status-opencode: serveOnly=true (只检查serve,已修复) - startOpenCodeServe: serveOnly=true (幂等检查,已修复) --- scripts/process-manager.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/process-manager.mjs b/scripts/process-manager.mjs index 8b95ab9..979ec95 100644 --- a/scripts/process-manager.mjs +++ b/scripts/process-manager.mjs @@ -535,7 +535,7 @@ function main() { if (alivePid !== null) { console.log(`[process-manager] opencode serve 运行中 (PID: ${alivePid})`); } else { - const scanPids = findOpenCodeProcesses(); + const scanPids = findOpenCodeProcesses(false, null, true); if (scanPids.length > 0) { console.log(`[process-manager] opencode serve 运行中(扫描到 PID: ${scanPids.join(', ')},但 PID 文件缺失)`); } else { From 2f2c6678a3382b82161efb5400e1d4e3b3c2dbd0 Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 04:01:18 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20reliability=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E4=BA=A4=E4=BA=92=E5=BC=8F=20opencode=20?= =?UTF-8?q?=E4=B8=BA=20single-instance=20=E8=BF=9D=E8=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: process-guard.ts 的 matchesProcessKeywords 用 command.includes('opencode') 匹配所有含 opencode 的进程,不区分 serve 和交互式。当用户开着交互式 opencode 时,runningPids.length > 1 → single-instance-violation → rescue 失败 → bridge 自动退出。 修复: 新增 matchesServeProcess(),只匹配命令行含 'serve' 的进程。 命令行信息不足时(Windows tasklist 只有进程名)保守匹配(保持原行为)。 回归测试: - list-opencode: 列出所有(含交互式) ✅ - status-opencode: 只报告 serve ✅ - start-opencode 幂等: 跳过(serve 已运行) ✅ - Web 面板重启: ok ✅ - 飞书+opencode 重启后正常 ✅ - bridge 存活 90s 无 violation ✅ --- src/reliability/process-guard.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/reliability/process-guard.ts b/src/reliability/process-guard.ts index 9dabca2..3ebb25e 100644 --- a/src/reliability/process-guard.ts +++ b/src/reliability/process-guard.ts @@ -284,7 +284,34 @@ export async function probeTcpPort( async function listOpenCodeProcesses(processKeywords: string[]): Promise { const snapshot = await listAllProcesses(); - return snapshot.filter(item => matchesProcessKeywords(item, processKeywords)); + return snapshot.filter(item => matchesServeProcess(item, processKeywords)); +} + +/** + * 匹配 `opencode serve` 进程,排除交互式 opencode / opencode debug 等 + * + * 策略: + * - 命令行含 `serve` 关键字 → 匹配(精确识别 serve 进程) + * - 命令行不含 `serve` 但也无其他子命令(纯 `opencode`)→ 不匹配(交互式) + * - 命令行信息不可用(Windows tasklist 只有进程名)→ 保守匹配(保持原行为) + */ +function matchesServeProcess(item: OpenCodeProcessInfo, processKeywords: string[]): boolean { + const command = item.command.toLowerCase(); + const hasKeyword = processKeywords.some(keyword => command.includes(keyword.toLowerCase())); + if (!hasKeyword) { + return false; + } + // 命令行含 serve → 精确匹配 serve 进程 + if (/\bserve\b/.test(command)) { + return true; + } + // 命令行信息不足(只有进程名如 "opencode.exe",无参数)→ 保守匹配 + // 这种情况通常出现在 Windows tasklist fallback,无法区分 serve 和交互式 + if (!command.includes(' ')) { + return true; + } + // 命令行有参数但不含 serve → 交互式或其他子命令,排除 + return false; } function matchesProcessKeywords(item: OpenCodeProcessInfo, processKeywords: string[]): boolean { From ca80fa26d4e0c072216f01373c13e6c473889a94 Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 04:17:23 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20reliability=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=20opencode=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8E=E4=BF=A1=E5=8F=B7=E5=A4=84=E7=90=86=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 修复: 1. opencode-restart.ts: spawn('opencode') 改用 resolveOpencodeExecutable() - reliability 自动重启 opencode serve 时不再硬编码命令 2. rescue-orchestrator.ts: spawn('opencode', []) 改用 resolveOpencodeExecutable() + 补 'serve' 参数(原来无参数启动的是交互式 opencode) 3. 新增 src/utils/resolve-opencode.ts 共享路径解析工具 - 优先读 OPENCODE_AUTO_START_CMD - 检查 ~/.opencode/bin, ~/.local/bin, /usr/local/bin, /usr/bin - 与 process-manager.mjs 逻辑一致 P1 修复: 4. environment-doctor.ts: which opencode 失败时检查常见安装路径 - systemd 环境 PATH 不全时不再误报'未安装' 5. main.ts: 移除重复的 SIGTERM/SIGINT 注册 - 原来注册了两套: 一套直接 process.exit(0) 跳过异步清理, 一套走 gracefulShutdown。移除前者,避免飞书未断开/PID 未清理 回归测试: - 编译通过, vitest 全部通过 - bridge 存活 90s 无 violation - Web 面板重启正常 - 飞书+opencode 连接正常 --- src/lifecycle/main.ts | 11 ++---- src/reliability/environment-doctor.ts | 16 +++++++-- src/reliability/opencode-restart.ts | 5 ++- src/reliability/rescue-orchestrator.ts | 4 ++- src/utils/resolve-opencode.ts | 46 ++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 src/utils/resolve-opencode.ts diff --git a/src/lifecycle/main.ts b/src/lifecycle/main.ts index 918987b..225f2a5 100644 --- a/src/lifecycle/main.ts +++ b/src/lifecycle/main.ts @@ -198,16 +198,9 @@ export async function main( // 已迁移到 process-manager 管理,此处保留钩子供将来扩展 }; - // 监听主进程退出事件 process.on('exit', cleanupChildProcess); - process.on('SIGINT', () => { - cleanupChildProcess(); - process.exit(0); - }); - process.on('SIGTERM', () => { - cleanupChildProcess(); - process.exit(0); - }); + // SIGINT/SIGTERM 由下方 gracefulShutdown 统一处理,不在此重复注册 + // (重复注册会导致 process.exit(0) 跳过异步清理) // 3. 验证配置 try { diff --git a/src/reliability/environment-doctor.ts b/src/reliability/environment-doctor.ts index c21bbb5..223cc32 100644 --- a/src/reliability/environment-doctor.ts +++ b/src/reliability/environment-doctor.ts @@ -1,10 +1,12 @@ import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { resolveOpencodeExecutable } from '../utils/resolve-opencode.js'; const execFileAsync = promisify(execFile); @@ -216,11 +218,21 @@ async function hasCommandInPath(command: string): Promise { timeout: 1200, maxBuffer: 1024 * 1024, }); - return (result.stdout || '').trim().length > 0; + if ((result.stdout || '').trim().length > 0) { + return true; + } } catch (error) { console.error('[EnvironmentDoctor] command probe failed:', error instanceof Error ? error.message : String(error)); - return false; } + // which/where 失败时(如 systemd 环境 PATH 不全),检查常见安装路径 + if (command === 'opencode') { + const { exe } = resolveOpencodeExecutable(); + if (exe !== 'opencode') { + // 找到了绝对路径,说明已安装只是不在当前 PATH 里 + return true; + } + } + return false; } function getInstallHint(command: string, runtimeOs: EnvironmentOs): string { diff --git a/src/reliability/opencode-restart.ts b/src/reliability/opencode-restart.ts index 3475d31..3870d6f 100644 --- a/src/reliability/opencode-restart.ts +++ b/src/reliability/opencode-restart.ts @@ -5,6 +5,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { opencodeConfig, reliabilityConfig } from '../config.js'; import { probeOpenCodeHealth } from './opencode-probe.js'; import { checkOpenCodeSingleInstance, type ProcessGuardResult } from './process-guard.js'; +import { resolveOpencodeExecutable } from '../utils/resolve-opencode.js'; type ProbeHealthFn = (options: { host: string; port: number }) => Promise<{ ok: boolean }>; type CheckSingleInstanceFn = (options: { @@ -198,7 +199,9 @@ async function defaultStartProcess(pidFilePath = './logs/opencode.pid'): Promise // 避免 Node 的 CREATE_NO_WINDOW 导致孙进程 opencode-windows-x64\bin\opencode.exe 弹黑窗。 pid = startOpencodeWindowsHidden(); } else { - const child = spawn('opencode', ['serve'], { + // Linux/macOS: 用路径查找,避免 systemd 等环境 PATH 找不到 opencode + const { exe, args: exeArgs } = resolveOpencodeExecutable(); + const child = spawn(exe, [...exeArgs, 'serve'], { detached: true, stdio: 'ignore', }); diff --git a/src/reliability/rescue-orchestrator.ts b/src/reliability/rescue-orchestrator.ts index fb4f959..7721f99 100644 --- a/src/reliability/rescue-orchestrator.ts +++ b/src/reliability/rescue-orchestrator.ts @@ -8,6 +8,7 @@ import { spawn } from 'node:child_process'; import { opencodeConfig, reliabilityConfig } from '../config.js'; import { probeOpenCodeHealth } from './opencode-probe.js'; import { decideRescuePolicy } from './rescue-policy.js'; +import { resolveOpencodeExecutable } from '../utils/resolve-opencode.js'; import { executeRescuePipeline } from './rescue-executor.js'; import { reportRecoveryContext } from './recovery-reporter.js'; import { FailureType, RescueState } from './types.js'; @@ -102,7 +103,8 @@ export const createRescueOrchestrator = ( return new Promise((resolve, reject) => { try { const isWindows = process.platform === 'win32'; - const child = spawn('opencode', [], { + const { exe, args: exeArgs } = resolveOpencodeExecutable(); + const child = spawn(exe, [...exeArgs, 'serve'], { detached: true, stdio: 'ignore', shell: isWindows, diff --git a/src/utils/resolve-opencode.ts b/src/utils/resolve-opencode.ts new file mode 100644 index 0000000..869e9fe --- /dev/null +++ b/src/utils/resolve-opencode.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +/** + * 解析 opencode 可执行文件路径(跨平台) + * + * 优先级: + * 1. OPENCODE_AUTO_START_CMD 环境变量(用户自定义绝对路径) + * 2. 常见安装路径检查 + * 3. 回退到 'opencode'(依赖 PATH) + * + * 与 scripts/process-manager.mjs 的 resolveOpenCodeExecutable 保持一致逻辑。 + */ +export function resolveOpencodeExecutable(): { exe: string; args: string[] } { + // 1. 优先读 OPENCODE_AUTO_START_CMD(用户自定义绝对路径,可能含参数) + const customCmd = process.env.OPENCODE_AUTO_START_CMD?.trim(); + if (customCmd) { + const parts = customCmd.split(/\s+/); + const exePath = parts[0]; + const extraArgs = parts.slice(1); + if (fs.existsSync(exePath)) { + return { exe: exePath, args: extraArgs }; + } + // 路径不存在也返回,让 shell 尝试解析 + return { exe: customCmd, args: [] }; + } + + // 2. 检查常见安装路径 + const homeDir = os.homedir(); + const candidates = [ + path.join(homeDir, '.opencode', 'bin', 'opencode'), // 官方脚本安装 + path.join(homeDir, '.local', 'bin', 'opencode'), // npm i -g (用户级 prefix) + '/usr/local/bin/opencode', // npm i -g (root) + '/usr/bin/opencode', // 包管理器安装 + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return { exe: candidate, args: [] }; + } + } + + // 3. 回退:让 shell 自行解析 + return { exe: 'opencode', args: [] }; +} From f6fedec4fcd573133462ee6a492ed0c95f0efc9a Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 05:14:12 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20process-guard=20=E4=BA=8C=E6=AC=A1?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E4=BB=8D=E7=94=A8=E6=97=A7=E7=9A=84=E5=AE=BD?= =?UTF-8?q?=E6=B3=9B=E5=8C=B9=E9=85=8D=EF=BC=8C=E8=A1=A5=E5=85=85=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发现新 bug: checkOpenCodeSingleInstance() 的 processListProvider 路径 对返回结果用 matchesProcessKeywords(匹配所有含 opencode 的进程)做二次 过滤,而不是 matchesServeProcess(只匹配 serve)。导致即使 listOpenCodeProcesses 已改用 matchesServeProcess,通过 processListProvider mock 注入的进程列表仍被旧逻辑误判。 修复: 二次过滤也改用 matchesServeProcess。 补充回归测试: - serve + 交互式 opencode 共存 → 不报 violation - 仅交互式 opencode 无 serve → 返回 not-running - 两个 serve 进程 → 报 violation (原有测试保持) --- src/reliability/process-guard.ts | 16 ++----------- tests/process-guard.test.ts | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/reliability/process-guard.ts b/src/reliability/process-guard.ts index 3ebb25e..97e6e03 100644 --- a/src/reliability/process-guard.ts +++ b/src/reliability/process-guard.ts @@ -81,7 +81,7 @@ export async function checkOpenCodeSingleInstance( ? await options.processListProvider() : await listOpenCodeProcesses(processKeywords); - const keywordMatched = processList.filter(item => matchesProcessKeywords(item, processKeywords)); + const keywordMatched = processList.filter(item => matchesServeProcess(item, processKeywords)); const runningPidSet = new Set(keywordMatched.map(item => item.pid)); if (pidFromFile !== null && pidAlive) { @@ -287,30 +287,18 @@ async function listOpenCodeProcesses(processKeywords: string[]): Promise matchesServeProcess(item, processKeywords)); } -/** - * 匹配 `opencode serve` 进程,排除交互式 opencode / opencode debug 等 - * - * 策略: - * - 命令行含 `serve` 关键字 → 匹配(精确识别 serve 进程) - * - 命令行不含 `serve` 但也无其他子命令(纯 `opencode`)→ 不匹配(交互式) - * - 命令行信息不可用(Windows tasklist 只有进程名)→ 保守匹配(保持原行为) - */ function matchesServeProcess(item: OpenCodeProcessInfo, processKeywords: string[]): boolean { const command = item.command.toLowerCase(); const hasKeyword = processKeywords.some(keyword => command.includes(keyword.toLowerCase())); if (!hasKeyword) { return false; } - // 命令行含 serve → 精确匹配 serve 进程 if (/\bserve\b/.test(command)) { return true; } - // 命令行信息不足(只有进程名如 "opencode.exe",无参数)→ 保守匹配 - // 这种情况通常出现在 Windows tasklist fallback,无法区分 serve 和交互式 - if (!command.includes(' ')) { + if (process.platform === 'win32' && !command.includes(' ')) { return true; } - // 命令行有参数但不含 serve → 交互式或其他子命令,排除 return false; } diff --git a/tests/process-guard.test.ts b/tests/process-guard.test.ts index 2350143..e99202a 100644 --- a/tests/process-guard.test.ts +++ b/tests/process-guard.test.ts @@ -58,6 +58,47 @@ describe('process-guard', () => { expect(result.conflictPids).toEqual([202]); }); + it('交互式 opencode 与 serve 共存时不应误报 single-instance-violation', async () => { + const pidFilePath = path.join(tempDir, 'bridge.pid'); + await fs.writeFile(pidFilePath, '303', 'utf-8'); + + const result = await checkOpenCodeSingleInstance({ + pidFilePath, + host: '127.0.0.1', + port: 4096, + processAliveChecker: async pid => pid === 303, + processListProvider: async (): Promise => [ + { pid: 303, command: '/home/user/.opencode/bin/opencode serve --port 4096' }, + { pid: 404, command: 'opencode' }, + { pid: 505, command: 'opencode debug skill --print-logs' }, + ], + portProbe: async () => ({ isOpen: true, reason: 'connected' }), + }); + + expect(result.status).toBe('ok'); + expect(result.runningPids).toEqual([303]); + expect(result.conflictPids).toEqual([]); + }); + + it('仅有交互式 opencode(无 serve)时应返回 not-running', async () => { + const pidFilePath = path.join(tempDir, 'bridge.pid'); + + const result = await checkOpenCodeSingleInstance({ + pidFilePath, + host: '127.0.0.1', + port: 4096, + processAliveChecker: async () => false, + processListProvider: async (): Promise => [ + { pid: 606, command: 'opencode' }, + { pid: 707, command: 'opencode debug skill' }, + ], + portProbe: async () => ({ isOpen: false, reason: 'ECONNREFUSED' }), + }); + + expect(result.status).toBe('not-running'); + expect(result.runningPids).toEqual([]); + }); + it('并发触发救援锁时第二个请求应返回 lock-busy', async () => { const lockTargetPath = path.join(tempDir, 'rescue-mutex'); From 8fe661401207651f0c716c2a1a7bed5e0469900f Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 05:41:04 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E5=8E=BB=E9=99=A4=E7=A1=AC=E7=BC=96=E7=A0=81=E7=A7=81?= =?UTF-8?q?=E4=BA=BA=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/process-guard.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/process-guard.test.ts b/tests/process-guard.test.ts index e99202a..5f186d2 100644 --- a/tests/process-guard.test.ts +++ b/tests/process-guard.test.ts @@ -68,7 +68,7 @@ describe('process-guard', () => { port: 4096, processAliveChecker: async pid => pid === 303, processListProvider: async (): Promise => [ - { pid: 303, command: '/home/user/.opencode/bin/opencode serve --port 4096' }, + { pid: 303, command: '/home/testuser/.opencode/bin/opencode serve --port 4096' }, { pid: 404, command: 'opencode' }, { pid: 505, command: 'opencode debug skill --print-logs' }, ], From b9270daada2bc35b16e1c7d80bf80c592d900a4c Mon Sep 17 00:00:00 2001 From: cy1223710788 Date: Thu, 25 Jun 2026 18:39:56 +0800 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20=E9=A3=9E=E4=B9=A6=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=87=8D=E5=A4=8D=E5=A4=84=E7=90=86=20+=20main.ts=20?= =?UTF-8?q?=E4=BC=A0=20bridgeManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. feishu/client.ts: handleMessage 加 message_id 去重 - 飞书 SDK 长连接模式可能对同一条消息发送两次事件 - 用 Set 缓存已处理的 message_id,避免重复处理 - 缓存上限 500 条,自动淘汰最早条目 2. feishu/client.ts: start() 前重建 eventDispatcher - 避免多次 start() 导致事件处理器重复注册 3. lifecycle/main.ts: createAdminServer 传 bridgeManager - dist/index.js 入口下 Web 面板重启功能恢复 --- src/feishu/client.ts | 17 +++++++++++++++-- src/lifecycle/main.ts | 4 +++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/feishu/client.ts b/src/feishu/client.ts index b561ede..ea3e8c0 100644 --- a/src/feishu/client.ts +++ b/src/feishu/client.ts @@ -30,6 +30,7 @@ class FeishuClient extends EventEmitter { private eventDispatcher: lark.EventDispatcher; private cardActionHandler?: (event: FeishuCardActionEvent) => Promise; private cardUpdateQueue: Map> = new Map(); + private processedMessageIds: Set = new Set(); // 连接状态和心跳检测 private connectionState: ConnectionState = 'disconnected'; @@ -156,7 +157,8 @@ class FeishuClient extends EventEmitter { console.log('[飞书] 正在启动长连接...'); this.connectionState = 'connecting'; - // 注册消息接收事件 + this.eventDispatcher = this.createEventDispatcher(); + this.eventDispatcher.register({ 'im.message.receive_v1': (data) => { this.handleMessage(data as FeishuEventData); @@ -238,11 +240,22 @@ class FeishuClient extends EventEmitter { const message = data.message; const sender = data.sender; - // 忽略机器人自己发的消息 if (sender.sender_type === 'bot') { return; } + const msgId = message.message_id; + if (msgId) { + if (this.processedMessageIds.has(msgId)) { + return; + } + this.processedMessageIds.add(msgId); + if (this.processedMessageIds.size > 500) { + const first = this.processedMessageIds.values().next().value; + if (first) this.processedMessageIds.delete(first); + } + } + const msgType = message.message_type; let content = ''; let parsedContent: Record | null = null; diff --git a/src/lifecycle/main.ts b/src/lifecycle/main.ts index 225f2a5..296d7f9 100644 --- a/src/lifecycle/main.ts +++ b/src/lifecycle/main.ts @@ -2,6 +2,7 @@ import { VERSION } from '../utils/version.js'; import { initLogger } from '../utils/logger.js'; import { logStore } from '../store/log-store.js'; import { createAdminServer } from '../admin/admin-server.js'; +import { bridgeManager } from '../admin/bridge-manager.js'; import { feishuClient } from '../feishu/client.js'; import { loadAllConfigured, getSenderByPlatform, getCachedAdapter, getConfiguredPlatforms, clearCache } from '../platform/loader.js'; import { opencodeClient, type PermissionRequestEvent } from '../opencode/client.js'; @@ -234,9 +235,10 @@ export async function main( const adminPort = parseInt(process.env.ADMIN_PORT ?? _cs.get().ADMIN_PORT ?? '4098', 10); const adminServer = createAdminServer({ port: adminPort, - cronManager: undefined, // cronManager 在后面初始化 + cronManager: undefined, startedAt: new Date(), version: VERSION, + bridgeManager, }); adminServer.start(); console.log(`[Admin] 管理面板已启动: http://localhost:${adminPort}`);