Ctrl K
ComunidadGuías…

desktop/electron/main.cjs

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

Archivo original ↗Preguntar a KiBot
const { app, BrowserWindow, ipcMain, shell, dialog, Menu, nativeImage, Tray, globalShortcut, Notification } = require('electron')
const { join } = require('path')
const { exec, spawn } = require('child_process')
const fs = require('fs')
const path = require('path')
const os = require('os')
const { autoUpdater } = require('electron-updater')
const { TerminalManager } = require('./terminalManager.cjs')
const { createLocalShellRuntime, resolveShellProtocol } = require('./localShell.cjs')
const promptImageResizer = require('./promptImageResizer.cjs')
const serverConfigLib = require('./serverConfig.cjs')
const { resolveShellMaoBaseUrl } = require('./shellEnv.cjs')
const chatAttachment = require('./chatAttachment.cjs')
const desktopPrefsLib = require('./desktopPrefs.cjs')
const { createTray } = require('./tray.cjs')
const { readFileBytes } = require('./localFileBytes.cjs')


app.setName('Mao')
// Windows 系统通知的前提:appUserModelId 必须与 electron-builder 的 appId 一致(package.json)。
// 收件箱的 Electron 系统通知(P2)依赖它才能正常弹出并被任务栏正确归组。
app.setAppUserModelId('cn.etarch.mao.desktop')

// 单实例锁(P2 前置):托盘常驻 / 开机自启 / dock 复活都要求同一时刻只有一个进程,
// 否则多实例会争用 auth.json 等 userData 资源。第二个实例启动后唤起主窗再退出。
if (!app.requestSingleInstanceLock()) {
  app.quit()
} else {
  app.on('second-instance', () => {
    // app ready 前禁止创建 BrowserWindow(真实 Electron 会抛未捕获异常):未就绪时直接返回,
    // whenReady 链会把主窗建出来,不会漏掉「唤起」语义
    if (!app.isReady()) return
    const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow()
    if (!win || win.isDestroyed()) return
    if (win.isMinimized()) win.restore()
    win.show()
    win.focus()
  })
}

let mainWindow = null
/** 系统托盘实例(P2;app ready 后创建,退出时随进程销毁) */
let appTray = null
/** before-quit 已触发:close 事件不再拦截为隐藏,保证退出路径全量清理 */
let quitting = false
/** 当前已注册的全局快捷键(换键时精确注销旧键;app 退出时随进程销毁) */
let currentShortcutAccel = null
let currentWorkspace = ''
/** @type {Map<string, (approved: boolean) => void>} */
const pendingApprovals = new Map()
/**
 * 多端审批(技术方案 2026-11-06):决策可能早于本地弹卡到达(手机先点 / 本机还没走到
 * requestToolApproval)。命中即直接放行、不再弹一张只能撤掉的卡。
 * TTL / 上限只为兜住无人认领的迟到决策。
 */
const EARLY_APPROVAL_TTL_MS = 10 * 60 * 1000
const EARLY_APPROVAL_LIMIT = 100
/** @type {Map<string, { approved: boolean, at: number }>} */
const earlyApprovalDecisions = new Map()
/**
 * 待派发的通知点击深链:点击时刻渲染层监听可能尚未注册(应用刚启动、
 * 页面加载中、窗口重建),立即 send 会丢。这里总是留一份,由渲染层桥接
 * 就绪后主动拉取(consume),收到后回执(ack)清除,防止页面重载后
 * 拿陈旧点击重复跳转。did-finish-load 补发仅在「尚未被拉取」时执行。
 */
let pendingNotificationClick = null
const terminalManager = new TerminalManager()
terminalManager.setEnvProvider(buildShellEnv)

const PRIVATE_DIFF_FIELD = 'file_change_diff'
const SNAPSHOT_LIMIT_BYTES = 512 * 1024
const PATCH_LIMIT_CHARS = 256 * 1024
const PATCH_CONTEXT_LINES = 3
const LCS_CELL_LIMIT = 2_000_000

let updaterInitialized = false
let updateDownloaded = false
let updateCheckPromise = null
let updaterStatus = 'idle'
let updaterProgressPercent = null

function utf8Bytes(text) {
  return Buffer.byteLength(text || '', 'utf-8')
}

function isBinaryContent(text) {
  const value = text || ''
  const checkLen = Math.min(value.length, 8192)
  for (let i = 0; i < checkLen; i++) {
    if (value.charCodeAt(i) === 0) return true
  }
  return false
}

function splitLines(text) {
  if (!text) return []
  return text.split(/\r\n|\r|\n/)
}

function lcsLength(oldLines, oldStart, oldEnd, newLines, newStart, newEnd) {
  const newLen = newEnd - newStart + 1
  let previous = new Array(newLen + 1).fill(0)
  let current = new Array(newLen + 1).fill(0)

  for (let i = oldStart; i <= oldEnd; i++) {
    for (let j = 1; j <= newLen; j++) {
      if (oldLines[i] === newLines[newStart + j - 1]) {
        current[j] = previous[j - 1] + 1
      } else {
        current[j] = Math.max(previous[j], current[j - 1])
      }
    }
    const temp = previous
    previous = current
    current = temp
    current.fill(0)
  }
  return previous[newLen]
}

function computeLineDelta(beforeContent, afterContent) {
  const oldLines = splitLines(beforeContent)
  const newLines = splitLines(afterContent)
  if (oldLines.length === 0) return { linesAdded: newLines.length, linesDeleted: 0 }
  if (newLines.length === 0) return { linesAdded: 0, linesDeleted: oldLines.length }

  let prefix = 0
  while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) {
    prefix++
  }

  let oldSuffix = oldLines.length - 1
  let newSuffix = newLines.length - 1
  while (oldSuffix >= prefix && newSuffix >= prefix && oldLines[oldSuffix] === newLines[newSuffix]) {
    oldSuffix--
    newSuffix--
  }

  const oldChanged = oldSuffix - prefix + 1
  const newChanged = newSuffix - prefix + 1
  if (oldChanged <= 0) return { linesAdded: Math.max(0, newChanged), linesDeleted: 0 }
  if (newChanged <= 0) return { linesAdded: 0, linesDeleted: Math.max(0, oldChanged) }

  if (oldChanged * newChanged > LCS_CELL_LIMIT) {
    return { linesAdded: newChanged, linesDeleted: oldChanged }
  }

  const lcs = lcsLength(oldLines, prefix, oldSuffix, newLines, prefix, newSuffix)
  return { linesAdded: newChanged - lcs, linesDeleted: oldChanged - lcs }
}

function appendBounded(parts, state, text) {
  if (state.length >= PATCH_LIMIT_CHARS) {
    state.truncated = true
    return
  }
  const remaining = PATCH_LIMIT_CHARS - state.length
  if (text.length <= remaining) {
    parts.push(text)
    state.length += text.length
  } else {
    parts.push(text.slice(0, remaining))
    state.length += remaining
    state.truncated = true
  }
}

function buildUnifiedPatch(filePath, before, after) {
  const oldLines = splitLines(before)
  const newLines = splitLines(after)
  let prefix = 0
  while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) {
    prefix++
  }

  let oldSuffix = oldLines.length - 1
  let newSuffix = newLines.length - 1
  while (oldSuffix >= prefix && newSuffix >= prefix && oldLines[oldSuffix] === newLines[newSuffix]) {
    oldSuffix--
    newSuffix--
  }

  const contextStart = Math.max(0, prefix - PATCH_CONTEXT_LINES)
  const oldContextEnd = Math.min(oldLines.length - 1, oldSuffix + PATCH_CONTEXT_LINES)
  const newContextEnd = Math.min(newLines.length - 1, newSuffix + PATCH_CONTEXT_LINES)
  const oldStartLine = contextStart + 1
  const newStartLine = contextStart + 1
  const oldCount = Math.max(0, oldContextEnd - contextStart + 1)
  const newCount = Math.max(0, newContextEnd - contextStart + 1)

  const parts = []
  const state = { length: 0, truncated: false }
  appendBounded(parts, state, `--- a/${filePath}\n`)
  appendBounded(parts, state, `+++ b/${filePath}\n`)
  appendBounded(parts, state, `@@ -${oldStartLine},${oldCount} +${newStartLine},${newCount} @@\n`)
  for (let i = contextStart; i < prefix && i < oldLines.length; i++) {
    appendBounded(parts, state, ` ${oldLines[i]}\n`)
  }
  for (let i = prefix; i <= oldSuffix && i < oldLines.length; i++) {
    appendBounded(parts, state, `-${oldLines[i]}\n`)
  }
  for (let i = prefix; i <= newSuffix && i < newLines.length; i++) {
    appendBounded(parts, state, `+${newLines[i]}\n`)
  }
  const sharedTailStart = Math.max(prefix, Math.max(oldSuffix + 1, newSuffix + 1))
  const sharedTailEnd = Math.min(oldContextEnd, oldLines.length - 1)
  for (let i = sharedTailStart; i <= sharedTailEnd; i++) {
    appendBounded(parts, state, ` ${oldLines[i]}\n`)
  }
  let content = parts.join('')
  if (state.truncated) {
    const suffix = '\n...[diff truncated]\n'
    content = content.slice(0, Math.max(0, PATCH_LIMIT_CHARS - suffix.length)) + suffix
  }
  return { content, truncated: state.truncated }
}

function buildFileChangeDiff(filePath, beforeContent, afterContent) {
  const before = beforeContent || ''
  const after = afterContent || ''
  if (isBinaryContent(before) || isBinaryContent(after)) {
    return {
      diff_mode: 'UNSUPPORTED',
      diff_unavailable_reason: '二进制文件无法生成文本 diff',
      patch_truncated: false
    }
  }
  if (utf8Bytes(before) <= SNAPSHOT_LIMIT_BYTES && utf8Bytes(after) <= SNAPSHOT_LIMIT_BYTES) {
    return {
      diff_mode: 'SNAPSHOT',
      before_content: before,
      after_content: after,
      patch_truncated: false
    }
  }
  const patch = buildUnifiedPatch(filePath, before, after)
  return {
    diff_mode: 'PATCH',
    patch_content: patch.content,
    patch_truncated: patch.truncated
  }
}

function asBoolArg(value, fallback = false) {
  if (typeof value === 'boolean') return value
  if (typeof value === 'number' && Number.isFinite(value)) return value !== 0
  if (typeof value === 'string') {
    const s = value.trim().toLowerCase()
    if (s === 'true' || s === '1' || s === 'yes') return true
    if (s === 'false' || s === '0' || s === 'no' || s === '') return false
  }
  return fallback
}

function lineAtIndex(content, index) {
  let lineNumber = 1
  let lineStart = 0
  for (let i = 0; i < index; i++) {
    if (content[i] === '\n') {
      lineNumber++
      lineStart = i + 1
    }
  }
  let lineEnd = content.indexOf('\n', index)
  if (lineEnd === -1) lineEnd = content.length
  let lineText = content.slice(lineStart, lineEnd)
  if (lineText.endsWith('\r')) lineText = lineText.slice(0, -1)
  return { lineNumber, lineText }
}

function applyEditMatch(content, oldString, newString, replaceAll) {
  if (oldString === '') {
    return { ok: false, replacements: 0, error: 'old_string 不能为空' }
  }
  const starts = []
  let idx = 0
  while (idx < content.length) {
    const found = content.indexOf(oldString, idx)
    if (found === -1) break
    starts.push(found)
    idx = found + oldString.length
  }
  const count = starts.length
  if (count === 0) {
    return { ok: false, replacements: 0, error: '文件中未找到 old_string' }
  }
  if (count > 1 && !replaceAll) {
    const maxLines = 20
    const maxPreviews = 8
    const occurrenceLines = starts.slice(0, maxLines).map((start) => lineAtIndex(content, start).lineNumber)
    const previewCount = Math.min(maxPreviews, starts.length)
    const previews = []
    for (let i = 0; i < previewCount; i++) {
      const { lineNumber, lineText } = lineAtIndex(content, starts[i])
      const preview = lineText.length > 120 ? lineText.slice(0, 120) + '…' : lineText
      previews.push(`  第 ${lineNumber} 行: ${preview}`)
    }
    const listed = count > maxLines ? `(仅列出前 ${maxLines} 处)` : ''
    return {
      ok: false,
      replacements: 0,
      occurrences: count,
      occurrence_lines: occurrenceLines,
      error: `old_string 在文件中出现 ${count} 次,默认只替换唯一匹配,未执行编辑。`
        + '请在 old_string 中补充更多上下文使其只出现一次,或传入 replace_all=true 以替换全部出现。'
        + `出现位置(行号从 1 起)${listed}:\n`
        + previews.join('\n')
    }
  }
  return {
    ok: true,
    updated: content.split(oldString).join(newString),
    replacements: count
  }
}

function writeEditedFile(resolvedPath, filePathArg, oldString, newString, replaceAll) {
  const content = fs.readFileSync(resolvedPath, 'utf-8')
  const match = applyEditMatch(content, oldString ?? '', newString ?? '', replaceAll)
  if (!match.ok) {
    const out = { success: false, replacements: 0, error: match.error }
    if (match.occurrences != null) {
      out.occurrences = match.occurrences
      out.occurrence_lines = match.occurrence_lines
    }
    return out
  }
  fs.writeFileSync(resolvedPath, match.updated, 'utf-8')
  const lineDelta = computeLineDelta(content, match.updated)
  return {
    success: true,
    replacements: match.replacements,
    file_change: {
      path: filePathArg,
      type: 'MODIFIED',
      lines_added: lineDelta.linesAdded,
      lines_deleted: lineDelta.linesDeleted
    },
    [PRIVATE_DIFF_FIELD]: buildFileChangeDiff(filePathArg, content, match.updated)
  }
}

/**
 * Get the full user PATH by executing a login shell.
 * macOS GUI apps don't load shell configs, so we need to manually resolve the PATH
 * that includes all user-configured tools (nvm, brew, cargo, etc.)
 */
let cachedUserPath = null
async function resolveUserPath() {
  if (cachedUserPath) return cachedUserPath

  const shell = process.env.SHELL || '/bin/zsh'
  console.log('[env] Resolving user PATH, SHELL:', process.env.SHELL, 'Using shell:', shell)
  console.log('[env] Current PATH:', process.env.PATH)

  return new Promise((resolve) => {
    // Use login shell to get full user environment
    const cmd = `${shell} -l -c 'echo $PATH'`
    console.log('[env] Executing:', cmd)

    exec(cmd, {
      timeout: 10000,
      env: { ...process.env, TERM: 'dumb' } // Ensure basic env
    }, (error, stdout, stderr) => {
      if (error) {
        console.error('[env] Failed to resolve user PATH via login shell:', error.message)
        if (stderr) console.error('[env] stderr:', stderr)
        resolve(process.env.PATH)
      } else {
        const userPath = stdout.trim()
        if (userPath && userPath !== process.env.PATH) {
          cachedUserPath = userPath
          console.log('[env] Resolved user PATH via login shell:', userPath.substring(0, 100) + '...')
          resolve(userPath)
        } else {
          console.log('[env] Login shell PATH same as current, using current PATH')
          resolve(process.env.PATH)
        }
      }
    })
  })
}

/**
 * Build environment with user's full PATH and current MAO_TOKEN from local auth store.
 * 同时注入 MAO_BASE_URL(当前连接的服务器),shell 里的 mao / mao-agent 不再依赖内置默认地址。
 */
async function buildShellEnv() {
  const userPath = await resolveUserPath()
  const { token } = readAuthStore()
  const env = {
    ...process.env,
    PATH: userPath,
    TERM: 'dumb',
    PS1: ''
  }
  if (token) {
    env.MAO_TOKEN = token
  } else {
    delete env.MAO_TOKEN
  }
  const maoBaseUrl = resolveShellMaoBaseUrl({
    configured: isServerConfigured(),
    serverBaseUrl: getEffectiveServerBaseUrl()
  })
  if (maoBaseUrl) {
    env.MAO_BASE_URL = maoBaseUrl
  } else {
    delete env.MAO_BASE_URL
  }
  return env
}

/**
 * 持久 shell 会话的 env 在 spawn 后不会自动更新;每次执行命令前重新写入。
 * bash 用 export,PowerShell 用 $env:。
 */
function refreshMaoTokenInShellSession(session) {
  if (!session?.writeStdin) return
  const protocol = resolveShellProtocol()
  const { token } = readAuthStore()
  if (token) {
    session.writeStdin(protocol.envSet('MAO_TOKEN', token) + '\n')
  } else {
    session.writeStdin(protocol.envUnset('MAO_TOKEN') + '\n')
  }
  const maoBaseUrl = resolveShellMaoBaseUrl({
    configured: isServerConfigured(),
    serverBaseUrl: getEffectiveServerBaseUrl()
  })
  if (maoBaseUrl) {
    session.writeStdin(protocol.envSet('MAO_BASE_URL', maoBaseUrl) + '\n')
  } else {
    session.writeStdin(protocol.envUnset('MAO_BASE_URL') + '\n')
  }
}

function sendToRenderer(channel, data) {
  if (mainWindow && !mainWindow.isDestroyed()) {
    mainWindow.webContents.send(channel, data)
  }
}

function setUpdaterStatus(status, progressPercent = updaterProgressPercent) {
  updaterStatus = status
  updaterProgressPercent = progressPercent
  buildApplicationMenu()
}

function installDownloadedUpdate() {
  if (!updateDownloaded) {
    return { success: false, error: 'Update has not been downloaded yet.' }
  }
  // macOS 的 quitAndInstall 先关窗、后发 before-quit。默认「关窗收托盘」会在
  // quitting 仍为 false 时把 close 变成 hide,退出被取消,安装不会开始。
  quitting = true
  autoUpdater.quitAndInstall(false, true)
  return { success: true }
}

function showUpdateMessage(type, title, message, buttons = ['确定']) {
  const options = {
    type,
    title,
    message,
    buttons,
    defaultId: 0,
    cancelId: buttons.length - 1
  }
  if (mainWindow && !mainWindow.isDestroyed()) {
    return dialog.showMessageBox(mainWindow, options)
  }
  return dialog.showMessageBox(options)
}

async function showMenuUpdateCheckResult() {
  if (updateDownloaded) {
    const { response } = await showUpdateMessage(
      'info',
      '更新已就绪',
      '客户端新版本已下载完成,可以重启 Mao 完成安装。',
      ['重启完成更新', '稍后']
    )
    if (response === 0) installDownloadedUpdate()
    return
  }

  if (updaterStatus === 'available' || updaterStatus === 'downloading') {
    const progressLabel = updaterProgressPercent == null ? '' : `(${Math.round(updaterProgressPercent)}%)`
    await showUpdateMessage('info', '正在下载新版本', `已发现客户端新版本,正在后台下载${progressLabel}。`)
    return
  }

  if (updaterStatus === 'not-available') {
    await showUpdateMessage('info', '已是最新版本', `当前 Mao ${app.getVersion()} 已是最新版本。`)
    return
  }

  if (updaterStatus === 'unsupported') {
    await showUpdateMessage('warning', '无法检查更新', '自动更新仅在打包后的客户端中可用。')
  }
}

async function checkForAppUpdateFromMenu() {
  try {
    await checkForAppUpdate()
    await showMenuUpdateCheckResult()
  } catch (error) {
    const message = error?.message || '检查更新失败'
    console.error('[updater] menu check failed:', error)
    await showUpdateMessage('error', '检查更新失败', message)
  }
}

function buildUpdateMenuItem() {
  const isDownloading = updaterStatus === 'available' || updaterStatus === 'downloading'
  const progressLabel = updaterProgressPercent == null ? '' : ` ${Math.round(updaterProgressPercent)}%`

  if (updateDownloaded) {
    return {
      label: '重启完成更新',
      enabled: true,
      click: () => {
        installDownloadedUpdate()
      }
    }
  } else if (isDownloading) {
    return {
      label: `正在下载新版本${progressLabel}`,
      enabled: false
    }
  }

  return {
    label: updaterStatus === 'checking' ? '正在检查更新' : '检查更新',
    enabled: app.isPackaged && !updateCheckPromise,
    click: () => {
      checkForAppUpdateFromMenu()
    }
  }
}

function buildServerMenuItem() {
  return {
    label: '服务器设置…',
    click: () => openServerPickerWindow(),
  }
}

function buildApplicationMenu() {
  if (!app.isReady()) return

  const template = []

  if (process.platform === 'darwin') {
    template.push({
      label: 'Mao',
      submenu: [
        { role: 'about', label: '关于 Mao' },
        { type: 'separator' },
        buildServerMenuItem(),
        { type: 'separator' },
        buildUpdateMenuItem(),
        { type: 'separator' },
        { role: 'services', label: '服务' },
        { type: 'separator' },
        { role: 'hide', label: '隐藏 Mao' },
        { role: 'hideOthers', label: '隐藏其他' },
        { role: 'unhide', label: '全部显示' },
        { type: 'separator' },
        { role: 'quit', label: '退出 Mao' }
      ]
    })
  } else {
    template.push({
      label: 'Mao',
      submenu: [
        buildServerMenuItem(),
        { type: 'separator' },
        buildUpdateMenuItem(),
        { type: 'separator' },
        { role: 'quit', label: '退出 Mao' }
      ]
    })
  }

  template.push(
    {
      label: '编辑',
      submenu: [
        { role: 'undo', label: '撤销' },
        { role: 'redo', label: '重做' },
        { type: 'separator' },
        { role: 'cut', label: '剪切' },
        { role: 'copy', label: '复制' },
        { role: 'paste', label: '粘贴' },
        { role: 'selectAll', label: '全选' }
      ]
    },
    {
      label: '显示',
      submenu: [
        { role: 'reload', label: '重新加载' },
        { role: 'forceReload', label: '强制重新加载' },
        { role: 'toggleDevTools', label: '开发者工具' },
        { type: 'separator' },
        { role: 'resetZoom', label: '实际大小' },
        { role: 'zoomIn', label: '放大' },
        { role: 'zoomOut', label: '缩小' },
        { type: 'separator' },
        { role: 'togglefullscreen', label: '切换全屏' }
      ]
    },
    {
      label: '窗口',
      submenu: [
        { role: 'minimize', label: '最小化' },
        { role: 'close', label: '关闭' }
      ]
    }
  )

  Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}

function serializeUpdaterError(error) {
  if (!error) return { message: 'Unknown updater error' }
  return {
    message: error.message || String(error),
    stack: error.stack
  }
}

function getUpdateFeedUrlOverride() {
  return process.env.MAO_DESKTOP_UPDATE_URL || ''
}

function applyUpdateFeedUrl() {
  const feed = serverConfigLib.resolveUpdateFeedUrl(getServerConfig(), getUpdateFeedUrlOverride())
  if (!feed.url) return { mode: feed.mode, url: null }
  try {
    autoUpdater.setFeedURL({
      provider: 'generic',
      url: feed.url,
    })
    return feed
  } catch (e) {
    console.warn('[updater] setFeedURL failed:', e?.message)
    return { mode: feed.mode, url: feed.url, error: e?.message }
  }
}

function setupAutoUpdater() {
  if (updaterInitialized) return
  updaterInitialized = true

  autoUpdater.autoDownload = true
  autoUpdater.autoInstallOnAppQuit = true

  applyUpdateFeedUrl()

  autoUpdater.on('checking-for-update', () => {
    setUpdaterStatus('checking', null)
    const feed = serverConfigLib.resolveUpdateFeedUrl(getServerConfig(), getUpdateFeedUrlOverride())
    sendToRenderer('update-checking', { feedUrl: feed.url || null })
  })

  autoUpdater.on('update-available', (info) => {
    updateDownloaded = false
    setUpdaterStatus('available', 0)
    sendToRenderer('update-available', info)
  })

  autoUpdater.on('update-not-available', (info) => {
    setUpdaterStatus('not-available', null)
    sendToRenderer('update-not-available', info)
  })

  autoUpdater.on('download-progress', (progress) => {
    setUpdaterStatus('downloading', Math.max(0, Math.min(100, progress?.percent || 0)))
    sendToRenderer('download-progress', progress)
  })

  autoUpdater.on('update-downloaded', (info) => {
    updateDownloaded = true
    setUpdaterStatus('downloaded', 100)
    sendToRenderer('update-downloaded', info)
  })

  autoUpdater.on('error', (error) => {
    console.error('[updater] error:', error)
    setUpdaterStatus('error', null)
    sendToRenderer('update-error', serializeUpdaterError(error))
  })
}

async function checkForAppUpdate() {
  if (!app.isPackaged) {
    setUpdaterStatus('unsupported', null)
    return { skipped: true, reason: 'Auto update is only available in packaged builds.' }
  }
  const feed = serverConfigLib.resolveUpdateFeedUrl(getServerConfig(), getUpdateFeedUrlOverride())
  if (feed.mode === 'disabled') {
    setUpdaterStatus('idle', null)
    return { skipped: true, reason: 'Auto update is disabled by server config.' }
  }
  setupAutoUpdater()
  applyUpdateFeedUrl()
  if (!updateCheckPromise) {
    setUpdaterStatus('checking', null)
    updateCheckPromise = autoUpdater.checkForUpdates()
      .then((result) => ({
        skipped: false,
        updateInfo: result?.updateInfo || null
      }))
      .catch((error) => {
        sendToRenderer('update-error', serializeUpdaterError(error))
        throw error
      })
      .finally(() => {
        updateCheckPromise = null
        buildApplicationMenu()
      })
  }
  return updateCheckPromise
}

function requestToolApproval(toolName, description, sessionId, dangerReason, backendRequestId, approvalHint) {
  return new Promise((resolve) => {
    const requestId = backendRequestId || `approval_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`

    // 决策已先行到达(多端审批的早到窗口):直接按它放行,不弹本地卡
    const early = earlyApprovalDecisions.get(requestId)
    if (early) {
      earlyApprovalDecisions.delete(requestId)
      resolve(!!early.approved)
      return
    }

    pendingApprovals.set(requestId, (approved) => {
      pendingApprovals.delete(requestId)
      resolve(approved)
    })

    const payload = { requestId, toolName, description, sessionId }
    if (dangerReason) payload.dangerReason = dangerReason
    // 「总是允许」hint(V135):服务端随 tool_execute 下发的模式提示,透传给渲染层审批卡片
    if (approvalHint) payload.approvalHint = approvalHint
    sendToRenderer('tool-approval-request', payload)
  })
}

/** Persist shell reuse / write_stdin must re-check approval — first approve must not unlock later commands. */
async function ensureShellApproval(needApproval, description, sessionId, dangerReason, backendRequestId, approvalHint) {
  if (!needApproval) return true
  return requestToolApproval('shell', description, sessionId, dangerReason, backendRequestId, approvalHint)
}

function pruneEarlyApprovalDecisions(now = Date.now()) {
  for (const [requestId, entry] of earlyApprovalDecisions) {
    if (now - entry.at > EARLY_APPROVAL_TTL_MS) earlyApprovalDecisions.delete(requestId)
  }
  while (earlyApprovalDecisions.size > EARLY_APPROVAL_LIMIT) {
    const oldest = earlyApprovalDecisions.keys().next()
    if (oldest.done) break
    earlyApprovalDecisions.delete(oldest.value)
  }
}

/**
 * 多端审批的唯一解锁入口(技术方案 2026-11-06 §4.5-10):
 * 本地 IPC 卡点击、远端 resolved 回声、切会话清理都走这里。
 * 有 pending 就直接 resolve;没有就写入早到缓冲(不读 locallyReleased,那个由渲染层决定)。
 */
ipcMain.handle('tool-approval-remote-response', (event, payload = {}) => {
  const requestId = payload?.requestId
  if (!requestId) return false
  const resolve = pendingApprovals.get(requestId)
  if (resolve) {
    pendingApprovals.delete(requestId)
    resolve(!!payload.approved)
    return true
  }
  earlyApprovalDecisions.set(requestId, { approved: !!payload.approved, at: Date.now() })
  pruneEarlyApprovalDecisions()
  return false
})

// ========== Multi-server baseUrl config ==========

/** @type {{ config: any, fromFile: boolean } | null} */
let serverConfigCache = null
let serverPickerWindow = null

function getServerConfigStorePath() {
  return serverConfigLib.getServerConfigPath(app.getPath('userData'))
}

function loadServerConfig(force = false) {
  if (serverConfigCache && !force) return serverConfigCache
  serverConfigCache = serverConfigLib.readServerConfig(app.getPath('userData'))
  return serverConfigCache
}

function getServerConfig() {
  return loadServerConfig().config
}

function getServerBaseUrlEnvOverride() {
  return (process.env.MAO_DESKTOP_SERVER_URL || '').trim()
}

function getEffectiveServerBaseUrl() {
  const envBase = getServerBaseUrlEnvOverride()
  if (envBase) {
    const normalized = serverConfigLib.normalizeServerBaseUrl(envBase, { allowHttp: true })
    if (normalized.ok) return normalized.baseUrl
  }
  return getServerConfig().serverBaseUrl || serverConfigLib.DEFAULT_SERVER_BASE_URL
}

function isServerConfigured() {
  if (getServerBaseUrlEnvOverride()) return true
  return getServerConfig().configured === true
}

function getApiBaseUrl() {
  if (process.env.NODE_ENV === 'development' && !app.isPackaged) {
    return 'http://localhost:9080/api'
  }
  return serverConfigLib.deriveApiBase(getEffectiveServerBaseUrl())
}

function getServerHostKey() {
  return serverConfigLib.serverHostKey(getEffectiveServerBaseUrl())
}

function getServerPickerHtmlPath() {
  return path.join(__dirname, 'server-picker', 'index.html')
}

function loadServerPicker() {
  if (!mainWindow || mainWindow.isDestroyed()) return
  mainWindow.loadFile(getServerPickerHtmlPath())
}

const LOCAL_RUNTIME_ROOT = path.join(os.homedir(), '.mao', 'runtime')

function resolveLocalRuntimeDir(sessionId) {
  return path.join(LOCAL_RUNTIME_ROOT, getServerHostKey(), String(sessionId))
}

function resolveLocalSkillsDir(sessionId) {
  return path.join(resolveLocalRuntimeDir(sessionId), 'skills')
}

function resolveLocalShellOutputDir(sessionId) {
  return path.join(resolveLocalRuntimeDir(sessionId), 'shellOutput')
}

function formatLocalRuntimePath(sessionId, ...segments) {
  return ['~/.mao/runtime', getServerHostKey(), String(sessionId), ...segments].join('/')
}

const localShell = createLocalShellRuntime({
  buildEnv: buildShellEnv,
  refreshToken: refreshMaoTokenInShellSession,
  resolveOutput: (maoSessionId, shellId) => {
    const fileName = `${shellId}.out`
    return {
      absPath: path.join(resolveLocalShellOutputDir(maoSessionId || 0), fileName),
      displayPath: formatLocalRuntimePath(maoSessionId || 0, 'shellOutput', fileName),
    }
  },
})

function expandHomePath(filePath) {
  if (!filePath) return filePath
  if (filePath === '~') return os.homedir()
  if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2))
  return filePath
}

function isUnderLocalRuntime(absolutePath, maoSessionId) {
  if (!maoSessionId) return false
  const runtimeDir = resolveLocalRuntimeDir(maoSessionId)
  const resolved = path.resolve(absolutePath)
  return resolved === runtimeDir || resolved.startsWith(runtimeDir + path.sep)
}

// ========== Skill sync IPC handler ==========

function preferIpv4Loopback(url) {
  // Electron/undici 解析 localhost 会先走 ::1;TS 后端 listen 0.0.0.0 只收 IPv4。
  return String(url || '').replace('://localhost', '://127.0.0.1')
}

function resolveSkillSyncBaseUrl(apiBase) {
  const raw = typeof apiBase === 'string' && apiBase.trim() !== '' ? apiBase.trim() : getApiBaseUrl()
  return preferIpv4Loopback(raw.replace(/\/v1\/?$/, '').replace(/\/$/, ''))
}

ipcMain.handle('skill-sync', async (event, { sessionId, syncUrl, token, apiBase }) => {
  if (!sessionId) {
    const err = 'No sessionId provided for skill sync.'
    sendToRenderer('skill-sync-complete', { sessionId, success: false, error: err })
    return { success: false, error: err }
  }
  try {
    const AdmZip = require('adm-zip')
    const skillsDir = resolveLocalSkillsDir(sessionId)

    const origin = resolveSkillSyncBaseUrl(apiBase)
    const fullUrl = preferIpv4Loopback(/^https?:\/\//.test(syncUrl) ? syncUrl : `${origin}${syncUrl}`)
    const headers = token ? { 'Authorization': `Bearer ${token}` } : {}
    const response = await fetch(fullUrl, { method: 'POST', headers })
    if (!response.ok) {
      const body = await response.text().catch(() => '')
      throw new Error(`Skill sync download failed: ${response.status} ${response.statusText} - ${body}`)
    }

    const zipBuffer = Buffer.from(await response.arrayBuffer())
    fs.mkdirSync(skillsDir, { recursive: true })
    const zip = new AdmZip(zipBuffer)
    zip.extractAllTo(skillsDir, true)
    sendToRenderer('skill-sync-complete', { sessionId, success: true })
    return { success: true }
  } catch (e) {
    console.error('Skill sync failed:', e)
    sendToRenderer('skill-sync-complete', { sessionId, success: false, error: e.message })
    return { success: false, error: e.message }
  }
})

// ========== Local skills (~/.agents/skills) ==========

const LOCAL_SKILLS_DIR = path.join(os.homedir(), '.agents', 'skills')

function parseSkillMd(content) {
  if (!content || !content.startsWith('---')) return null
  const secondDelimiter = content.indexOf('---', 3)
  if (secondDelimiter === -1) return null

  const frontmatter = content.substring(3, secondDelimiter).trim()
  const body = content.substring(secondDelimiter + 3).trim()

  let name = null
  let description = null
  for (const line of frontmatter.split('\n')) {
    const nameMatch = line.match(/^name:\s*(.+)$/)
    if (nameMatch) {
      name = nameMatch[1].trim().replace(/^["']|["']$/g, '')
    }
    const descMatch = line.match(/^description:\s*(.+)$/)
    if (descMatch) {
      description = descMatch[1].trim().replace(/^["']|["']$/g, '')
    }
  }

  if (!name) return null
  return { name, description: description || '', body }
}

function resolveLocalSkillFolder(folderName) {
  if (!folderName || typeof folderName !== 'string') return null
  if (folderName.includes('/') || folderName.includes('\\') || folderName.startsWith('.')) {
    return null
  }
  const skillFolder = path.resolve(LOCAL_SKILLS_DIR, folderName)
  const normalizedRoot = path.resolve(LOCAL_SKILLS_DIR)
  if (skillFolder !== normalizedRoot && !skillFolder.startsWith(normalizedRoot + path.sep)) {
    return null
  }
  if (!fs.existsSync(skillFolder) || !fs.statSync(skillFolder).isDirectory()) {
    return null
  }
  return skillFolder
}

function collectSkillFiles(dir, baseDir, files = []) {
  const entries = fs.readdirSync(dir, { withFileTypes: true })
  for (const entry of entries) {
    if (entry.name.startsWith('.')) continue
    const fullPath = path.join(dir, entry.name)
    if (entry.isDirectory()) {
      collectSkillFiles(fullPath, baseDir, files)
    } else if (entry.isFile()) {
      const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/')
      if (!relativePath || relativePath.includes('/.')) continue
      files.push({ relativePath, fullPath })
    }
  }
  return files
}

ipcMain.handle('list-local-skills', async () => {
  try {
    if (!fs.existsSync(LOCAL_SKILLS_DIR)) {
      return { skills: [], skillsDir: LOCAL_SKILLS_DIR }
    }

    const skills = []
    const entries = fs.readdirSync(LOCAL_SKILLS_DIR, { withFileTypes: true })
    for (const entry of entries) {
      if (!entry.isDirectory() || entry.name.startsWith('.')) continue

      const folderPath = path.join(LOCAL_SKILLS_DIR, entry.name)
      const skillMdPath = path.join(folderPath, 'SKILL.md')
      if (!fs.existsSync(skillMdPath)) continue

      try {
        const content = fs.readFileSync(skillMdPath, 'utf-8')
        const parsed = parseSkillMd(content)
        if (!parsed) continue
        skills.push({
          folderName: entry.name,
          name: parsed.name,
          description: parsed.description,
          folderPath,
        })
      } catch (e) {
        console.warn('[local-skills] Failed to parse:', entry.name, e.message)
      }
    }

    skills.sort((a, b) => a.name.localeCompare(b.name))
    return { skills, skillsDir: LOCAL_SKILLS_DIR }
  } catch (e) {
    console.error('[local-skills] list failed:', e)
    return { error: e.message, skills: [], skillsDir: LOCAL_SKILLS_DIR }
  }
})

ipcMain.handle('get-local-skill-detail', async (event, { folderName }) => {
  try {
    const skillFolder = resolveLocalSkillFolder(folderName)
    if (!skillFolder) {
      return { error: 'Skill not found: ' + folderName }
    }

    const skillMdPath = path.join(skillFolder, 'SKILL.md')
    const content = fs.readFileSync(skillMdPath, 'utf-8')
    const parsed = parseSkillMd(content)
    if (!parsed) {
      return { error: 'Invalid SKILL.md in skill: ' + folderName }
    }

    return {
      name: parsed.name,
      description: parsed.description,
      body: parsed.body,
      folderPath: skillFolder,
      filePath: skillMdPath,
      folderName,
    }
  } catch (e) {
    return { error: e.message }
  }
})

ipcMain.handle('read-local-skill-files', async (event, { folderName }) => {
  try {
    const skillFolder = resolveLocalSkillFolder(folderName)
    if (!skillFolder) {
      return { error: 'Skill not found: ' + folderName }
    }

    const fileEntries = collectSkillFiles(skillFolder, skillFolder)
    const files = fileEntries.map(({ relativePath, fullPath }) => ({
      relativePath,
      base64: fs.readFileSync(fullPath).toString('base64'),
    }))

    return { folderName, files }
  } catch (e) {
    return { error: e.message }
  }
})

ipcMain.handle('read-agents-md', async (event, { workspace }) => {
  try {
    if (!workspace) {
      return { content: null }
    }

    const agentsMdPath = path.join(workspace, 'AGENTS.md')
    if (!fs.existsSync(agentsMdPath)) {
      return { content: null }
    }

    const stat = fs.statSync(agentsMdPath)
    if (!stat.isFile()) {
      return { content: null }
    }

    const content = fs.readFileSync(agentsMdPath, 'utf-8')
    return { content: content || null }
  } catch (e) {
    console.error('[read-agents-md] Failed:', e.message)
    return { error: e.message, content: null }
  }
})

function resolveWorkspacePath(filePath, workspace, maoSessionId) {
  const effectiveWorkspace = workspace || currentWorkspace
  if (!filePath) return filePath

  const expanded = expandHomePath(filePath)
  if (path.isAbsolute(expanded)) {
    if (maoSessionId && isUnderLocalRuntime(expanded, maoSessionId)) {
      return expanded
    }
    return expanded
  }
  if (!effectiveWorkspace) return expanded
  return path.join(effectiveWorkspace, expanded)
}

function detectShell() {
  return process.env.SHELL || process.env.ComSpec || process.env.COMSPEC || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh')
}

function buildOsVersion() {
  if (process.platform === 'win32') {
    return `${os.version()} ${os.release()}`
  }
  return `${os.type()} ${os.release()}`
}

function isGitWorkspace(workspace) {
  if (!workspace) return false
  let current = path.resolve(workspace)
  while (current && current !== path.dirname(current)) {
    if (fs.existsSync(path.join(current, '.git'))) {
      return true
    }
    current = path.dirname(current)
  }
  return false
}

const { getGitStatus, refreshGitStatus, getGitFileDiff, listGitRepos, getGitLog, getGitCommitFiles, getGitCommitFileDiff, getGitBranchCompare, getGitBranchCompareFileDiff, getGitBranches, getGitStashes, getGitRemotes } = require('./gitStatus.cjs')
const { buildCommitInput, commit: gitCommit, pull: gitPull, push: gitPush, fetch: gitFetch, switchBranch: gitSwitchBranch, stashOp: gitStashOp } = require('./gitOperations.cjs')

function loadMainContent() {
  if (process.env.NODE_ENV === 'development' && !app.isPackaged) {
    mainWindow.loadURL('http://localhost:5201')
    mainWindow.webContents.openDevTools()
    return
  }

  // 生产:远程 SPA;未配置服务器时先展示本地配置页
  if (!isServerConfigured()) {
    loadServerPicker()
    return
  }

  const target = getEffectiveServerBaseUrl()
  mainWindow.loadURL(target).catch((err) => {
    console.error('[server] loadURL failed:', target, err)
    loadServerPicker()
  })
}

async function cleanupSiteScopedResources() {
  try {
    terminalManager.killAll()
  } catch (e) {
    console.warn('[server] terminal cleanup failed:', e?.message)
  }
  try {
    localShell.closeAll()
  } catch (e) {
    console.warn('[server] local shell cleanup failed:', e?.message)
  }
  const sessionIds = Array.from(mcpClients.keys())
  await Promise.all(
    sessionIds.map((sid) =>
      closeMcpSession(sid).catch((e) => console.warn('[server] mcp cleanup failed:', e?.message))
    )
  )
}

/**
 * 切换服务器:校验 → 可选探测 → host 变化时清 auth/本地资源 → 写配置 → 加载目标站。
 * @param {{ serverBaseUrl: string, updateFeedMode?: string, allowHttp?: boolean, skipProbe?: boolean }} payload
 */
async function applyServerConfig(payload = {}) {
  const allowHttp = payload.allowHttp === true
  const normalized = serverConfigLib.normalizeServerBaseUrl(payload.serverBaseUrl, { allowHttp })
  if (!normalized.ok) {
    return { ok: false, error: normalized.error }
  }

  const targetBase = normalized.baseUrl
  const previousBase = getEffectiveServerBaseUrl()
  const previousHost = serverConfigLib.serverHostKey(previousBase)
  const nextHost = serverConfigLib.serverHostKey(targetBase)
  const hostChanged = previousHost !== nextHost

  if (payload.skipProbe !== true) {
    const probe = await serverConfigLib.probeServer(targetBase)
    if (!probe.ok) {
      return { ok: false, error: probe.error || '服务器检测失败', probe }
    }
  }

  if (hostChanged) {
    writeAuthStore({ token: null, refreshToken: null })
    await cleanupSiteScopedResources()
  }

  const writeResult = serverConfigLib.writeServerConfig(app.getPath('userData'), {
    serverBaseUrl: targetBase,
    updateFeedMode: payload.updateFeedMode || getServerConfig().updateFeedMode || 'follow-site',
    allowHttp,
    configured: true,
  })
  if (!writeResult.ok) {
    return { ok: false, error: writeResult.error }
  }

  serverConfigCache = null
  loadServerConfig(true)

  // 历史只记「连接成功」过的地址,置顶去重供快速切换
  try {
    serverConfigLib.recordServerHistory(app.getPath('userData'), targetBase, allowHttp)
  } catch (e) {
    console.warn('[server] history record failed:', e?.message)
  }
  const feed = serverConfigLib.resolveUpdateFeedUrl(getServerConfig(), getUpdateFeedUrlOverride())
  if (feed.url) {
    try {
      autoUpdater.setFeedURL({ provider: 'generic', url: feed.url })
    } catch (e) {
      console.warn('[updater] setFeedURL failed:', e?.message)
    }
  }

  if (mainWindow && !mainWindow.isDestroyed()) {
    loadMainContent()
  }
  return {
    ok: true,
    serverBaseUrl: targetBase,
    hostChanged,
    updateFeedMode: writeResult.config.updateFeedMode,
  }
}

function openServerPickerWindow() {
  if (serverPickerWindow && !serverPickerWindow.isDestroyed()) {
    serverPickerWindow.focus()
    return
  }
  serverPickerWindow = new BrowserWindow({
    width: 560,
    useContentSize: true,
    resizable: false,
    minimizable: false,
    maximizable: false,
    fullscreenable: false,
    title: 'Mao · 服务器设置',
    parent: mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined,
    modal: false,
    autoHideMenuBar: true,
    webPreferences: {
      preload: join(__dirname, 'preload.cjs'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
    },
  })
  serverPickerWindow.loadFile(getServerPickerHtmlPath())
  serverPickerWindow.on('closed', () => {
    serverPickerWindow = null
  })
}

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    minWidth: 800,
    minHeight: 600,
    show: false,
    title: 'Mao',
    titleBarStyle: 'hiddenInset',
    trafficLightPosition: { x: 16, y: 12 },
    backgroundColor: '#f5f5f7',
    webPreferences: {
      preload: join(__dirname, 'preload.cjs'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true
    }
  })

  mainWindow.once('ready-to-show', () => {
    if (mainWindow && !mainWindow.isDestroyed()) {
      mainWindow.show()
    }
  })

  loadMainContent()

  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
    shell.openExternal(url)
    return { action: 'deny' }
  })

  // 关窗拦截(P2,默认托盘化):quitting 旗标未置位且偏好为托盘时,红点/X/窗口菜单「关闭」
  // 一律 hide 而非销毁——渲染层与 WS 存活,任务继续跑、通知照常可达。
  // 退出路径(托盘菜单 / 应用菜单 role:quit / app.quit())都先走 before-quit 置
  // quitting=true,本拦截随之失效,窗口正常关闭并全量清理。
  mainWindow.on('close', (e) => {
    if (quitting) return
    const prefs = desktopPrefsLib.readDesktopPrefs(app.getPath('userData'))
    if (prefs.closeToTray !== 'tray') return
    e.preventDefault()
    mainWindow.hide()
    if (!prefs.trayHintShown) {
      sendToRenderer('tray-first-close-hint', {})
      desktopPrefsLib.writeDesktopPrefs(app.getPath('userData'), { trayHintShown: true })
    }
  })

  mainWindow.on('closed', () => {
    mainWindow = null
  })

  // 托盘菜单文案(显示/隐藏主窗)随窗口显示态刷新。挂在 createWindow 里而非 initTray:
  // 退出模式下关窗会销毁窗口,activate/second-instance 重建的新窗口同样需要这些监听,
  // 否则文案停留在旧状态,用户按文案点击会做出与意图相反的动作。
  // closed 也必须监听:退出模式关窗走销毁,此时 isWindowVisible() 因 mainWindow 置 null
  // 返回 false,文案应翻为「显示主窗」(点击即重建),停在「隐藏主窗」会语义相反。
  const refreshTray = () => appTray?.refresh()
  mainWindow.on('show', refreshTray)
  mainWindow.on('hide', refreshTray)
  mainWindow.on('minimize', refreshTray)
  mainWindow.on('restore', refreshTray)
  mainWindow.on('closed', refreshTray)

  // 返回实例:调用方(second-instance / 通知 click 重建路径)需要拿到它来
  // restore/show/focus 或挂加载回调,不能只靠 ready-to-show 兜底
  return mainWindow
}

// ========== P2/P3 桌面壳:托盘 / 通知 / 角标 / 偏好 / 快捷键 ==========

function readDesktopPrefs() {
  return desktopPrefsLib.readDesktopPrefs(app.getPath('userData'))
}

function initTray() {
  if (appTray) return
  appTray = createTray({
    isWindowVisible: () => !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(),
    onShowHide: () => {
      const win = mainWindow
      if (!win || win.isDestroyed()) {
        createWindow()
        return
      }
      if (win.isVisible()) {
        win.hide()
      } else {
        if (win.isMinimized()) win.restore()
        win.show()
        win.focus()
      }
      appTray?.refresh()
    },
    onNewTask: () => {
      // 渲染层复用「新建任务」既有入口(Home + newTask=1);窗口不可见时先唤起
      const win = mainWindow
      if (win && !win.isDestroyed()) {
        if (win.isMinimized()) win.restore()
        win.show()
        win.focus()
      }
      sendToRenderer('tray-new-task', {})
    },
    onQuit: () => {
      // before-quit 置 quitting → close 不再拦截 → 全量清理后退出
      app.quit()
    },
  })
}

/**
 * 全局快捷键(P3,默认 Alt+Shift+M,可改可关):触发时窗口可见且聚焦则隐藏,否则唤起。
 * 换键顺序必须是「先注册新键、成功后再摘旧键」:反过来会在新键注册失败时把旧键也弄丢
 * (unregisterAll 先清空),而偏好文件与设置页仍显示旧键,快捷键静默失效到重启。
 * @param {string | null} accel
 * @returns {{ ok: true } | { ok: false, error: string }}
 */
function applyGlobalShortcut(accel) {
  if (!accel) {
    // 关闭:只摘当前已注册的那个键,不影响(也不存在的)其他注册项
    try {
      if (currentShortcutAccel) globalShortcut.unregister(currentShortcutAccel)
    } catch (e) {
      console.warn('[shortcut] unregister failed:', e?.message)
    }
    currentShortcutAccel = null
    return { ok: true }
  }
  // 与当前键相同:直接视为成功。Electron 对「重复注册同一 accelerator」的返回值有版本
  // 差异,早退可消掉这层不确定,也避免误导性的「被其他程序占用」提示
  if (currentShortcutAccel === accel) return { ok: true }
  try {
    const registered = globalShortcut.register(accel, () => {
      const win = mainWindow
      // 无窗口(退出模式关窗销毁后应用仍驻留)时重建:快捷键语义是「否则唤起」,
      // 直接 return 会让仍显示「已启用」的快捷键完全失效
      if (!win || win.isDestroyed()) {
        createWindow()
        appTray?.refresh()
        return
      }
      if (win.isVisible() && !win.isMinimized() && win.isFocused()) {
        win.hide()
      } else {
        if (win.isMinimized()) win.restore()
        win.show()
        win.focus()
      }
      appTray?.refresh()
    })
    if (!registered) {
      // 新键没注册上:旧键原样保持生效,偏好不写,UI 与实际状态一致
      return { ok: false, error: '快捷键注册失败,可能已被其他程序占用' }
    }
    if (currentShortcutAccel && currentShortcutAccel !== accel) {
      try {
        globalShortcut.unregister(currentShortcutAccel)
      } catch (e) {
        console.warn('[shortcut] unregister old accel failed:', e?.message)
      }
    }
    currentShortcutAccel = accel
    return { ok: true }
  } catch (e) {
    return { ok: false, error: `快捷键格式无效:${e?.message || ''}` }
  }
}

/**
 * 开机自启(P3):仅打包后客户端可写登录项(防 dev 的 electron 二进制误入登录项,决策 8)。
 * openAsHidden 配合托盘模式:自启后驻留托盘不弹窗。
 */
function applyAutoStart(enabled) {
  if (!app.isPackaged) {
    return { ok: false, error: '开发模式下不支持开机自启,请使用打包后的客户端' }
  }
  try {
    app.setLoginItemSettings({ openAtLogin: enabled === true, openAsHidden: true })
    return { ok: true }
  } catch (e) {
    return { ok: false, error: `设置开机自启失败:${e?.message || ''}` }
  }
}

ipcMain.handle('local-copy-attachment', async (event, payload) => {
  // 落点由主进程推导(home + server host + sessionId),渲染层无法指定。
  // 异步拷贝:大文件的 copyFile 不阻塞主进程事件循环(曾用同步 copyFileSync,
  // 拖入接近上限的大附件会把整个应用卡死数十秒)。
  return chatAttachment.copyLocalAttachmentAsync({
    sourcePath: payload?.sourcePath,
    sessionId: payload?.sessionId,
    maxSizeMb: payload?.maxSizeMb,
    homeDir: os.homedir(),
    hostKey: getServerHostKey(),
  })
})

ipcMain.handle('local-write-text-file', async (event, payload) => {
  // 渲染层粘贴附件入口:同名避重,不覆盖已有附件。
  // handleLocalWriteTextFile 回的是工具协议 { success, path, bytes_written } / { error },
  // 渲染层与 local-copy-attachment 对齐,只认 { ok, absolutePath, error }。
  // 直接把工具形态透传出去时,写盘成功也会被当成失败(error 为空 → 「写入失败」),
  // 粘贴超长文本发送会被整单取消。
  const result = await handleLocalWriteTextFile(payload, payload?.sessionId, true)
  if (result?.success && result.path) {
    return { ok: true, absolutePath: result.path }
  }
  return { ok: false, error: result?.error || '写入失败' }
})

ipcMain.handle('desktop-notify', (event, payload) => {
  const title = String(payload?.title ?? '')
  const body = String(payload?.body ?? '')
  const tag = payload?.tag != null ? String(payload.tag) : undefined
  const itemId = payload?.itemId ?? null
  const sessionId = payload?.sessionId ?? null
  if (!title) return { ok: false, error: '通知标题为空' }
  try {
    const notification = new Notification({ title, body, tag })
    notification.on('click', () => {
      // 窗口隐藏到托盘时点击通知先唤起主窗,再把跳转事件交给渲染层。
      // 窗口已销毁(退出模式 + macOS 关窗后点旧通知)时重建:否则 sendToRenderer
      // 因 mainWindow 为 null 静默丢弃,用户点了通知却什么都没有。
      // 无论走哪条路径都先入待派发队列:渲染层监听可能尚未注册(应用刚启动、
      // 页面加载中、重建窗口的渲染进程是新的),立即 send 会丢,由渲染层桥接
      // 就绪后主动 consume 兜底;did-finish-load 补发仅在尚未被拉取时执行。
      const clickPayload = { itemId, sessionId }
      pendingNotificationClick = clickPayload
      const win = mainWindow
      if (win && !win.isDestroyed()) {
        if (win.isMinimized()) win.restore()
        win.show()
        win.focus()
        sendToRenderer('desktop-notification-click', clickPayload)
        return
      }
      const created = createWindow()
      // 新窗口的渲染层(Vue 应用 + ipcRenderer.on)要等页面加载完才就绪:同步发
      // 跳转事件会丢(窗口能出来但不跳会话),等 did-finish-load 再补发。
      // 读队列与 send 同步完成,与 consume 不会交错:已被渲染层拉取时队列已空,跳过。
      if (created && !created.isDestroyed() && created.webContents) {
        created.webContents.once('did-finish-load', () => {
          const pending = pendingNotificationClick
          if (!pending) return
          sendToRenderer('desktop-notification-click', pending)
        })
      }
    })
    notification.on('failed', () => {
      console.warn('[notify] display failed:', title)
      sendToRenderer('desktop-notify-result', { ok: false, error: '系统通知展示失败', tag })
    })
    notification.show()
    return { ok: true }
  } catch (e) {
    const error = e?.message || '通知发送失败'
    sendToRenderer('desktop-notify-result', { ok: false, error, tag })
    return { ok: false, error }
  }
})

ipcMain.handle('desktop-notification-click-consume', () => {
  // 渲染层桥接就绪后主动拉取:点击时监听尚未注册(应用刚启动/页面加载中/窗口
  // 重建)导致的丢失由这里兜底。取出即清空——拉取过的点击不重复派发。
  const payload = pendingNotificationClick
  pendingNotificationClick = null
  return payload
})

ipcMain.handle('desktop-notification-click-ack', () => {
  // 渲染层已收到立即派发的点击深链:清除待派发队列,防止页面重载后
  // 桥接重新就绪时拿陈旧点击重复跳转。
  pendingNotificationClick = null
  return { ok: true }
})

ipcMain.handle('desktop-badge', (event, payload) => {
  const count = Math.max(0, Math.floor(Number(payload?.count) || 0))
  if (process.platform === 'darwin' && app.dock && typeof app.dock.setBadgeCount === 'function') {
    app.dock.setBadgeCount(count)
  }
  // win/linux no-op(overlay 角标列为后续可选)
  return { ok: true }
})

ipcMain.handle('desktop-prefs-get', () => {
  return { ok: true, prefs: readDesktopPrefs(), packaged: app.isPackaged, platform: process.platform }
})

// 本机设备身份(技术方案 2026-11-05 §4.2):deviceId 首次取用时生成并落盘(ensure),
// 名字用主机名、平台用 process.platform——渲染层 WS auth 帧与建 LOCAL 会话时携带。
ipcMain.handle('device-info-get', () => {
  return {
    id: desktopPrefsLib.ensureDesktopDeviceId(app.getPath('userData')),
    name: os.hostname(),
    platform: process.platform,
  }
})

ipcMain.handle('desktop-prefs-set', (event, patch) => {
  const next = patch && typeof patch === 'object' ? patch : {}
  // dev 模式禁止开启自启:直接拒绝,不落盘(防打包前的误开启在打包后生效)
  if (next.autoStart === true && !app.isPackaged) {
    return { ok: false, error: '开发模式下不支持开机自启,请使用打包后的客户端' }
  }
  const result = desktopPrefsLib.writeDesktopPrefs(app.getPath('userData'), next)
  let sideError = null
  if ('autoStart' in next) {
    const applied = applyAutoStart(result.prefs.autoStart)
    if (!applied.ok) sideError = applied.error
  }
  return { ok: true, prefs: result.prefs, error: sideError || undefined }
})

ipcMain.handle('desktop-shortcut-set', (event, accel) => {
  const next = accel == null ? null : String(accel).trim()
  if (next === '') {
    return { ok: false, error: '快捷键不能为空(关闭请传 null)' }
  }
  const applied = applyGlobalShortcut(next)
  if (!applied.ok) {
    sendToRenderer('desktop-shortcut-failed', { accel: next, error: applied.error })
    return { ok: false, error: applied.error }
  }
  const result = desktopPrefsLib.writeDesktopPrefs(app.getPath('userData'), { globalShortcut: next })
  return { ok: true, prefs: result.prefs }
})

app.whenReady().then(() => {
  buildApplicationMenu()
  createWindow()
  initTray()
  // 快捷键按偏好注册(默认 Alt+Shift+M);失败时回传提示
  const shortcut = readDesktopPrefs().globalShortcut
  const registered = applyGlobalShortcut(shortcut)
  if (!registered.ok) {
    sendToRenderer('desktop-shortcut-failed', { accel: shortcut, error: registered.error })
  }
  if (app.isPackaged) {
    setTimeout(() => {
      checkForAppUpdate().catch((error) => {
        console.error('[updater] initial check failed:', error)
      })
    }, 3000)
  }
})

app.on('window-all-closed', () => {
  // 托盘模式下窗口是 hide 而非 close,不会触发本事件;仅「退出模式」下走原逻辑
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  } else if (mainWindow && !mainWindow.isDestroyed()) {
    // 托盘化后红点关闭 = 隐藏:dock 点击应恢复而非新建窗口
    if (mainWindow.isMinimized()) mainWindow.restore()
    mainWindow.show()
    appTray?.refresh()
  }
})

app.on('before-quit', () => {
  quitting = true
  // 快捷键全部注销后同步清掉跟踪值:否则退出若被中止,之后「重设同一个键」会命中
  // 同键早退而跳过 register,键保持未注册但 UI/偏好显示已启用
  currentShortcutAccel = null
  try {
    globalShortcut.unregisterAll()
  } catch (e) {
    console.warn('[shortcut] unregisterAll on quit failed:', e?.message)
  }
  terminalManager.killAll()
  localShell.closeAll()
  localShell.stopCleanup()
  // 关闭全部 MCP 连接(stdio 子进程 / HTTP 会话)
  const sessionIds = Array.from(mcpClients.keys())
  sessionIds.forEach((sid) => {
    closeMcpSession(sid).catch((e) => console.warn('[mcp] quit cleanup failed:', e.message))
  })
})

// ========== Auth token persistence (file:// localStorage is not reliable) ==========

function getAuthStorePath() {
  return path.join(app.getPath('userData'), 'auth.json')
}

function readAuthStore() {
  try {
    const authPath = getAuthStorePath()
    if (fs.existsSync(authPath)) {
      const data = JSON.parse(fs.readFileSync(authPath, 'utf8'))
      // 服务端 host 变化后旧 token 不可用,避免串站
      const currentHost = getServerHostKey()
      if (data.serverHost && data.serverHost !== currentHost) {
        return { token: null, refreshToken: null }
      }
      return {
        token: data.token || null,
        refreshToken: data.refreshToken || null
      }
    }
  } catch (e) {
    console.error('[auth] Failed to read auth store:', e.message)
  }
  return { token: null, refreshToken: null }
}

function writeAuthStore(data) {
  try {
    const authPath = getAuthStorePath()
    fs.mkdirSync(path.dirname(authPath), { recursive: true })
    const payload = {
      token: data.token || null,
      refreshToken: data.refreshToken || null,
      serverHost: data.token || data.refreshToken ? getServerHostKey() : null,
    }
    fs.writeFileSync(authPath, JSON.stringify(payload), 'utf8')
  } catch (e) {
    console.error('[auth] Failed to write auth store:', e.message)
  }
}

ipcMain.handle('auth-get-tokens', () => readAuthStore())

ipcMain.handle('auth-set-tokens', (_event, { token, refreshToken }) => {
  writeAuthStore({ token: token || null, refreshToken: refreshToken || null })
})

ipcMain.handle('auth-clear-tokens', () => {
  writeAuthStore({ token: null, refreshToken: null })
})

// ========== Server baseUrl config IPC ==========

ipcMain.handle('server-config-get', () => {
  const config = getServerConfig()
  const envOverride = getServerBaseUrlEnvOverride()
  const feed = serverConfigLib.resolveUpdateFeedUrl(config, getUpdateFeedUrlOverride())
  return {
    serverBaseUrl: getEffectiveServerBaseUrl(),
    updateFeedMode: config.updateFeedMode || 'follow-site',
    allowHttp: config.allowHttp === true,
    configured: isServerConfigured(),
    envOverride: Boolean(envOverride),
    appVersion: app.getVersion(),
    hostKey: getServerHostKey(),
    apiBase: getApiBaseUrl(),
    updateFeed: feed,
    history: serverConfigLib.readServerHistory(app.getPath('userData')),
  }
})

ipcMain.handle('server-config-probe', async (_event, payload = {}) => {
  const allowHttp = payload.allowHttp === true
  const normalized = serverConfigLib.normalizeServerBaseUrl(payload.serverBaseUrl, { allowHttp })
  if (!normalized.ok) return { ok: false, error: normalized.error }
  return serverConfigLib.probeServer(normalized.baseUrl)
})

ipcMain.handle('server-config-set', async (_event, payload = {}) => {
  return applyServerConfig(payload || {})
})

ipcMain.handle('server-config-open-picker', () => {
  openServerPickerWindow()
  return { ok: true }
})

ipcMain.handle('server-picker-close', () => {
  if (serverPickerWindow && !serverPickerWindow.isDestroyed()) {
    serverPickerWindow.close()
  }
  return { ok: true }
})

ipcMain.handle('server-config-remove-history', (_event, payload = {}) => {
  const target = String(payload?.serverBaseUrl || '').trim()
  if (!target) return { ok: false, error: '缺少地址', history: serverConfigLib.readServerHistory(app.getPath('userData')) }
  const history = serverConfigLib.removeServerHistory(app.getPath('userData'), target)
  return { ok: true, history }
})

// ========== Window control IPC handlers ==========

ipcMain.handle('window-minimize', () => {
  if (mainWindow && !mainWindow.isDestroyed()) {
    mainWindow.minimize()
  }
})

ipcMain.handle('window-maximize', () => {
  if (mainWindow && !mainWindow.isDestroyed()) {
    if (mainWindow.isMaximized()) {
      mainWindow.unmaximize()
    } else {
      mainWindow.maximize()
    }
  }
})

ipcMain.handle('window-close', () => {
  if (mainWindow && !mainWindow.isDestroyed()) {
    mainWindow.close()
  }
})

// ========== Original IPC handlers ==========

ipcMain.handle('get-app-version', () => {
  return app.getVersion()
})

ipcMain.handle('get-platform', () => {
  return process.platform
})

ipcMain.handle('check-for-update', () => {
  return checkForAppUpdate()
})

ipcMain.handle('install-update', () => {
  return installDownloadedUpdate()
})

ipcMain.handle('get-environment-info', (event, { workspace } = {}) => {
  return {
    isGit: isGitWorkspace(workspace || currentWorkspace),
    platform: process.platform,
    shell: detectShell(),
    osVersion: buildOsVersion()
  }
})

ipcMain.handle('git-repos', async (event, { workspace } = {}) => {
  try {
    return await listGitRepos(workspace || currentWorkspace)
  } catch (e) {
    return { isRootGit: false, repos: [], error: e.message || '扫描 Git 仓库失败' }
  }
})

ipcMain.handle('git-status', async (event, { workspace, repoPath } = {}) => {
  try {
    return await getGitStatus(workspace || currentWorkspace, repoPath)
  } catch (e) {
    return { isGit: false, remoteStatusAvailable: false, error: e.message || '读取 Git 状态失败' }
  }
})

ipcMain.handle('git-status-refresh', async (event, { workspace, repoPath } = {}) => {
  try {
    return await refreshGitStatus(workspace || currentWorkspace, repoPath)
  } catch (e) {
    return { isGit: false, remoteStatusAvailable: false, error: e.message || '刷新 Git 状态失败' }
  }
})

ipcMain.handle('git-file-diff', async (event, { workspace, repoPath, path: filePath } = {}) => {
  try {
    return await getGitFileDiff(workspace || currentWorkspace, repoPath, filePath)
  } catch (e) {
    return {
      path: filePath || '',
      changeType: 'MODIFIED',
      beforeContent: '',
      afterContent: '',
      unavailableReason: e.message || '读取 Git diff 失败',
    }
  }
})

ipcMain.handle('git-commit-input', async (_event, { workspace, repoPath } = {}) => {
  return buildCommitInput(workspace || currentWorkspace, repoPath)
})

ipcMain.handle('git-commit', async (_event, { workspace, repoPath, message } = {}) => {
  return gitCommit(workspace || currentWorkspace, repoPath, message)
})

ipcMain.handle('git-pull', async (_event, { workspace, repoPath } = {}) => {
  return gitPull(workspace || currentWorkspace, repoPath)
})

ipcMain.handle('git-push', async (_event, { workspace, repoPath } = {}) => {
  return gitPush(workspace || currentWorkspace, repoPath)
})

ipcMain.handle('git-log', async (_event, { workspace, repoPath, options } = {}) => {
  try {
    return await getGitLog(workspace || currentWorkspace, repoPath, options || {})
  } catch (e) {
    return { error: e.message || '读取提交历史失败' }
  }
})

ipcMain.handle('git-commit-files', async (_event, { workspace, repoPath, hash } = {}) => {
  try {
    return await getGitCommitFiles(workspace || currentWorkspace, repoPath, hash)
  } catch (e) {
    return { error: e.message || '读取提交文件清单失败' }
  }
})

ipcMain.handle('git-commit-file-diff', async (_event, { workspace, repoPath, hash, path: filePath, oldPath } = {}) => {
  try {
    return await getGitCommitFileDiff(workspace || currentWorkspace, repoPath, hash, filePath, oldPath)
  } catch (e) {
    return {
      path: filePath || '',
      changeType: 'MODIFIED',
      beforeContent: '',
      afterContent: '',
      unavailableReason: e.message || '读取提交 diff 失败',
    }
  }
})

// 分支对比:失败形状同 git-commit-files(对象形态的 { error },由 provider 侧解包后抛错),
// 因为「ref 不存在 / 无共同祖先 / 对比失败」都必须与「两分支无差异(files: [])」可区分
ipcMain.handle('git-branch-compare', async (_event, { workspace, repoPath, base, target } = {}) => {
  try {
    return await getGitBranchCompare(workspace || currentWorkspace, repoPath, base, target)
  } catch (e) {
    return { error: e.message || '读取分支对比失败' }
  }
})

// 分支对比单文件 diff:失败形状同 git-commit-file-diff(降级 diff 对象,不抛)
ipcMain.handle('git-branch-compare-file-diff', async (_event, { workspace, repoPath, base, target, path: filePath, oldPath } = {}) => {
  try {
    return await getGitBranchCompareFileDiff(workspace || currentWorkspace, repoPath, base, target, filePath, oldPath)
  } catch (e) {
    return {
      path: filePath || '',
      changeType: 'MODIFIED',
      beforeContent: '',
      afterContent: '',
      unavailableReason: e.message || '读取分支 diff 失败',
    }
  }
})

ipcMain.handle('git-branches', async (_event, { workspace, repoPath } = {}) => {
  try {
    return await getGitBranches(workspace || currentWorkspace, repoPath)
  } catch (e) {
    return { error: e.message || '读取分支列表失败' }
  }
})

ipcMain.handle('git-stashes', async (_event, { workspace, repoPath } = {}) => {
  try {
    return await getGitStashes(workspace || currentWorkspace, repoPath)
  } catch (e) {
    return { error: e.message || '读取 stash 列表失败' }
  }
})

ipcMain.handle('git-remotes', async (_event, { workspace, repoPath } = {}) => {
  try {
    return await getGitRemotes(workspace || currentWorkspace, repoPath)
  } catch (e) {
    return { error: e.message || '读取远端列表失败' }
  }
})

ipcMain.handle('git-fetch', async (_event, { workspace, repoPath } = {}) => {
  return gitFetch(workspace || currentWorkspace, repoPath)
})

ipcMain.handle('git-switch-branch', async (_event, { workspace, repoPath, branch, create } = {}) => {
  return gitSwitchBranch(workspace || currentWorkspace, repoPath, branch, create === true)
})

ipcMain.handle('git-stash-op', async (_event, { workspace, repoPath, action, ref } = {}) => {
  return gitStashOp(workspace || currentWorkspace, repoPath, action, ref)
})

ipcMain.handle('open-external', async (event, url) => {
  if (typeof url !== 'string' || !url.trim()) {
    throw new Error('URL is required')
  }
  const parsed = new URL(url)
  if (!['http:', 'https:'].includes(parsed.protocol)) {
    throw new Error('Unsupported external URL protocol')
  }
  await shell.openExternal(parsed.toString())
})

ipcMain.handle('open-feishu-auth-window', async (event, payload) => {
  const authUrl = typeof payload === 'string' ? payload : payload?.authUrl
  const callbackPath = typeof payload === 'object' && payload?.callbackPath
    ? payload.callbackPath
    : '/api/v1/auth/feishu/callback'
  return new Promise((resolve) => {
    const authWindow = new BrowserWindow({
      width: 800,
      height: 700,
      title: '飞书登录',
      autoHideMenuBar: true,
      webPreferences: {
        nodeIntegration: false,
        contextIsolation: true,
        sandbox: true,
      },
    })

    authWindow.loadURL(authUrl)

    let resolved = false

    function checkUrl(url) {
      if (resolved) return
      try {
        const parsed = new URL(url)
        if (parsed.pathname === callbackPath || parsed.pathname.endsWith(callbackPath)) {
          resolved = true
          const state = parsed.searchParams.get('state') || ''
          const code = parsed.searchParams.get('code') || ''
          if (!authWindow.isDestroyed()) {
            authWindow.close()
          }
          resolve({ state, code })
        }
      } catch {
        // ignore invalid URLs
      }
    }

    authWindow.webContents.on('did-navigate', (_, url) => checkUrl(url))
    authWindow.webContents.on('did-navigate-in-page', (_, url) => checkUrl(url))

    authWindow.on('closed', () => {
      if (!resolved) {
        resolved = true
        resolve({ state: '' })
      }
    })
  })
})

ipcMain.handle('select-directory', async () => {
  const result = await dialog.showOpenDialog(mainWindow, {
    properties: ['openDirectory'],
    title: '选择工作目录'
  })
  if (result.canceled || result.filePaths.length === 0) {
    return null
  }
  return result.filePaths[0]
})

ipcMain.handle('open-folder', (event, folderPath) => {
  if (folderPath) shell.openPath(folderPath)
})

ipcMain.handle('show-item-in-folder', (event, fullPath) => {
  if (fullPath) shell.showItemInFolder(fullPath)
})

ipcMain.handle('open-terminal', (event, folderPath) => {
  if (!folderPath) return
  spawn('open', ['-a', 'Terminal', folderPath], { detached: true, stdio: 'ignore' }).unref()
})

// ========== Terminal IPC handlers (node-pty) ==========

ipcMain.handle('terminal:create', async (event, options) => {
  const result = await terminalManager.create(options)
  const session = terminalManager.sessions.get(result.id)

  session.pty.onData((data) => {
    if (mainWindow && !mainWindow.isDestroyed()) {
      mainWindow.webContents.send('terminal:data', { id: result.id, data })
    }
  })

  session.pty.onExit(({ exitCode }) => {
    if (mainWindow && !mainWindow.isDestroyed()) {
      mainWindow.webContents.send('terminal:exit', { id: result.id, exitCode })
    }
    terminalManager.sessions.delete(result.id)
  })

  return result
})

ipcMain.on('terminal:data', (event, { id, data }) => {
  terminalManager.write(id, data)
})

ipcMain.on('terminal:resize', (event, { id, cols, rows }) => {
  terminalManager.resize(id, cols, rows)
})

ipcMain.handle('terminal:kill', (event, { id }) => {
  terminalManager.kill(id)
})

ipcMain.handle('terminal:list', () => {
  return terminalManager.list()
})

// ========== Local tool execution IPC handlers ==========

ipcMain.handle('local-read-file', async (event, { path: filePath, offset, limit }) => {
  try {
    const resolvedPath = resolveWorkspacePath(filePath)
    if (mimeFromPath(filePath)) {
      return readLocalImage(resolvedPath, filePath)
    }
    if (filePath.toLowerCase().endsWith('.pdf')) {
      // PDF 预览:按字节读取并以 base64 data_uri 返回,由渲染进程转为 Blob 交给 pdf.js
      const buffer = fs.readFileSync(resolvedPath)
      return {
        content: `PDF 文件:${filePath}`,
        total_lines: 0,
        media_type: 'pdf',
        mime: 'application/pdf',
        data_uri: `data:application/pdf;base64,${buffer.toString('base64')}`,
      }
    }
    const content = fs.readFileSync(resolvedPath, 'utf-8')
    const lines = content.split('\n')
    const totalLines = lines.length
    const start = offset || 0
    const end = limit ? Math.min(start + limit, totalLines) : totalLines
    const sliced = lines.slice(start, end).join('\n')
    return { content: sliced, total_lines: totalLines }
  } catch (e) {
    return { error: e.message }
  }
})

// LOCAL 二进制字节读取(技术方案 §5.2):zip / xlsx / docx / 媒体预览的取数通道。
// 路径解析与 local-read-file 共用 resolveWorkspacePath;类型校验与大小上限在
// localFileBytes.cjs(可单测)。文件全程不出本机,无任何上传语义。
ipcMain.handle('local-read-bytes', async (event, { path: filePath }) => {
  try {
    const resolvedPath = resolveWorkspacePath(filePath)
    return readFileBytes(resolvedPath)
  } catch (e) {
    return { error: e.message }
  }
})

ipcMain.handle('local-write-file', async (event, { path: filePath, content }) => {
  const approved = await requestToolApproval('write_file', filePath)
  if (!approved) {
    return { success: false, error: 'User denied file write.' }
  }
  try {
    const resolvedPath = resolveWorkspacePath(filePath)
    const fileExisted = fs.existsSync(resolvedPath)
    const beforeContent = fileExisted ? fs.readFileSync(resolvedPath, 'utf-8') : ''
    const dir = path.dirname(resolvedPath)
    fs.mkdirSync(dir, { recursive: true })
    fs.writeFileSync(resolvedPath, content, 'utf-8')
    const linesAdded = content.length === 0 ? 0 : content.split('\n').length
    const lineDelta = fileExisted
      ? computeLineDelta(beforeContent, content)
      : { linesAdded, linesDeleted: 0 }
    return {
      success: true,
      bytes_written: Buffer.byteLength(content, 'utf-8'),
      file_change: {
        path: filePath,
        type: fileExisted ? 'MODIFIED' : 'CREATED',
        lines_added: lineDelta.linesAdded,
        lines_deleted: lineDelta.linesDeleted
      },
      [PRIVATE_DIFF_FIELD]: buildFileChangeDiff(filePath, beforeContent, content)
    }
  } catch (e) {
    return { error: e.message }
  }
})

ipcMain.handle('local-edit-file', async (event, { path: filePath, old_string, new_string, replace_all }) => {
  const approved = await requestToolApproval('edit_file', filePath)
  if (!approved) {
    return { success: false, error: 'User denied file edit.' }
  }
  try {
    return writeEditedFile(
      resolveWorkspacePath(filePath),
      filePath,
      old_string,
      new_string,
      asBoolArg(replace_all),
    )
  } catch (e) {
    return { error: e.message }
  }
})

const IGNORED_DIRS = new Set(['node_modules', '__pycache__', '.git', 'target', 'dist', 'build', '.next', '.nuxt', '.venv', 'venv', '.idea', '.vscode'])

ipcMain.handle('list-workspace-files', async (event, { workspace, filter, limit }) => {
  try {
    if (!workspace || !fs.existsSync(workspace)) return []
    const maxResults = limit || 20
    const results = []

    function walkDir(dir, relativeBase) {
      if (results.length >= maxResults) return
      let entries
      try {
        entries = fs.readdirSync(dir, { withFileTypes: true })
      } catch {
        return
      }
      // Sort entries: files first, then dirs, alphabetically
      entries.sort((a, b) => {
        if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? 1 : -1
        return a.name.localeCompare(b.name)
      })
      for (const entry of entries) {
        if (results.length >= maxResults) return
        if (entry.name.startsWith('.')) continue
        if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue
        const fullPath = path.join(dir, entry.name)
        const relPath = relativeBase ? relativeBase + '/' + entry.name : entry.name
        if (entry.isDirectory()) {
          walkDir(fullPath, relPath)
        } else {
          try {
            const stat = fs.statSync(fullPath)
            if (!filter || entry.name.toLowerCase().includes(filter.toLowerCase()) || relPath.toLowerCase().includes(filter.toLowerCase())) {
              results.push({ path: relPath, name: entry.name, size: stat.size, mtime: stat.mtimeMs })
            }
          } catch {
            // skip files we can't stat
          }
        }
      }
    }

    walkDir(workspace, '')
    // Sort by modification time descending
    results.sort((a, b) => b.mtime - a.mtime)
    // Remove mtime from response
    return results.map(({ mtime, ...rest }) => rest)
  } catch (e) {
    return []
  }
})

ipcMain.handle('list-directory', async (event, { dirPath, workspace }) => {
  try {
    if (!dirPath || !workspace) return { error: 'Missing dirPath or workspace' }
    const resolvedDir = path.resolve(dirPath)
    const resolvedWorkspace = path.resolve(workspace)
    if (!resolvedDir.startsWith(resolvedWorkspace + path.sep) && resolvedDir !== resolvedWorkspace) {
      return { error: 'Access denied: path outside workspace' }
    }
    if (!fs.existsSync(resolvedDir)) return { error: 'Directory does not exist' }

    const entries = fs.readdirSync(resolvedDir, { withFileTypes: true })
    const results = []
    const MAX_ENTRIES = 500

    for (const entry of entries) {
      if (results.length >= MAX_ENTRIES) break

      const fullPath = path.join(resolvedDir, entry.name)
      const relPath = path.relative(resolvedWorkspace, fullPath)
      let isSymlink = false
      let size = 0
      let mtime = 0

      try {
        const lstat = fs.lstatSync(fullPath)
        isSymlink = lstat.isSymbolicLink()
        size = lstat.size
        mtime = lstat.mtimeMs
      } catch {
        // skip entries we can't stat
        continue
      }

      results.push({
        name: entry.name,
        path: relPath,
        isDirectory: entry.isDirectory() && !isSymlink,
        size,
        mtime,
        isSymlink
      })
    }

    // Sort: folders first alphabetically, then files alphabetically
    results.sort((a, b) => {
      if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
      return a.name.localeCompare(b.name)
    })

    return { entries: results, truncated: entries.length > MAX_ENTRIES }
  } catch (e) {
    return { error: e.message }
  }
})

// ========== MCP (Model Context Protocol) support ==========
// LOCAL 模式下桌面端作为 MCP 客户端代理:服务端下发 mcp_sync_required,
// 主进程用官方 Node SDK 建立连接并上报工具清单;工具执行走 tool-execute 委托。

const { Client: McpClient } = require('@modelcontextprotocol/sdk/client')
const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js')
const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js')
const { getDefaultEnvironment } = require('@modelcontextprotocol/sdk/client/stdio.js')

const MCP_TOOL_PREFIX = 'mcp__'
const MCP_CALL_TIMEOUT_MS = 120000

/**
 * sessionId -> serverName -> { client, transport, tools }
 * @type {Map<number, Map<string, {client: import('@modelcontextprotocol/sdk/client').Client, transport: any, tools: Array}>>}
 */
const mcpClients = new Map()

function mcpServerKey(serverName) {
  return String(serverName || '')
}

/** 解析 mcp__{serverName}__{toolName},非法名返回 null */
function parseMcpToolName(toolName) {
  if (!toolName || !toolName.startsWith(MCP_TOOL_PREFIX)) return null
  const rest = toolName.slice(MCP_TOOL_PREFIX.length)
  const sep = rest.indexOf('__')
  if (sep <= 0) return null
  const serverName = rest.slice(0, sep)
  const innerName = rest.slice(sep + 2)
  if (!serverName || !innerName) return null
  return { serverName, toolName: innerName }
}

async function connectMcpServer(sessionId, server) {
  const { name, type, command, args, url, env } = server || {}
  if (!name) throw new Error('MCP server missing name')

  let transport
  if (type === 'STDIO') {
    if (!command) throw new Error(`MCP server "${name}": STDIO requires command`)
    // 合并默认安全环境(PATH/HOME 等),再叠加管理员配置的环境变量
    const mergedEnv = { ...getDefaultEnvironment() }
    if (env && typeof env === 'object') {
      for (const [k, v] of Object.entries(env)) {
        if (v != null) mergedEnv[k] = String(v)
      }
    }
    transport = new StdioClientTransport({
      command,
      args: Array.isArray(args) ? args.map(String) : [],
      env: mergedEnv,
      stderr: 'pipe'
    })
  } else if (type === 'HTTP') {
    if (!url) throw new Error(`MCP server "${name}": HTTP requires url`)
    transport = new StreamableHTTPClientTransport(new URL(url))
  } else {
    throw new Error(`MCP server "${name}": unsupported type "${type}"`)
  }

  const client = new McpClient({ name: 'mao-desktop', version: require('../package.json').version || '1.0.0' })
  await client.connect(transport)
  const list = await client.listTools()
  const tools = (list.tools || []).map((t) => ({
    name: t.name,
    description: t.description || '',
    schema: t.inputSchema || { type: 'object', properties: {} },
    // 透传 MCP 标准注解:服务端工作集用它标注 [只读]、审批据此对明确只读的工具免审。
    annotations: t.annotations || null
  }))
  return { client, transport, tools }
}

async function closeMcpSession(sessionId) {
  const connections = mcpClients.get(Number(sessionId))
  if (!connections) return
  mcpClients.delete(Number(sessionId))
  const names = Array.from(connections.keys())
  await Promise.allSettled(names.map(async (serverName) => {
    try {
      const conn = connections.get(serverName)
      if (conn?.client) {
        await conn.client.close()
      }
    } catch (e) {
      console.warn(`[mcp] close failed for ${serverName}:`, e.message)
    }
  }))
  console.log(`[mcp] closed ${names.length} connection(s) for session ${sessionId}`)
}

async function syncMcpServers(sessionId, servers) {
  const sid = Number(sessionId)
  // 先清理旧连接,避免重复同步时泄漏
  await closeMcpSession(sid)

  const connections = new Map()
  mcpClients.set(sid, connections)

  const reports = []
  for (const server of servers || []) {
    const name = server?.name
    if (!name) continue
    try {
      const conn = await connectMcpServer(sid, server)
      connections.set(mcpServerKey(name), conn)
      reports.push({ name, connected: true, tools: conn.tools, error: null })
      console.log(`[mcp] connected server "${name}" for session ${sid}, ${conn.tools.length} tool(s)`)
    } catch (e) {
      console.warn(`[mcp] connect failed for "${name}" (session ${sid}):`, e.message)
      reports.push({ name, connected: false, tools: [], error: e.message })
    }
  }
  return reports
}

async function callMcpTool(sessionId, serverName, toolName, args) {
  const sid = Number(sessionId)
  const connections = mcpClients.get(sid)
  const conn = connections && connections.get(mcpServerKey(serverName))
  if (!conn?.client) {
    throw new Error(`MCP server "${serverName}" is not connected (session ${sid}). Re-run the session to re-sync MCP servers.`)
  }
  const timer = setTimeout(() => {
    console.warn(`[mcp] call ${serverName}.${toolName} timed out after ${MCP_CALL_TIMEOUT_MS}ms`)
  }, MCP_CALL_TIMEOUT_MS)
  try {
    const result = await conn.client.callTool({ name: toolName, arguments: args }, undefined, {
      timeout: MCP_CALL_TIMEOUT_MS
    })
    if (result?.isError) {
      throw new Error(formatCallToolResult(result) || `MCP tool ${serverName}.${toolName} returned an error`)
    }
    // 传入完整 result,保留顶层 structuredContent(部分服务器主要或仅返回该字段)
    return formatCallToolResult(result)
  } finally {
    clearTimeout(timer)
  }
}

/**
 * 将 MCP CallToolResult 转为文本(与后端 Java McpClientManager.formatResult 语义一致):
 * - 顶层 structuredContent 非空时优先序列化输出(结构化数据更利于模型理解);
 * - 否则拼接 content 中的文本/图片/资源内容。
 */
function formatCallToolResult(result) {
  if (!result) return ''
  if (result.structuredContent != null) {
    try { return JSON.stringify(result.structuredContent, null, 2) } catch { /* fallthrough */ }
  }
  return formatMcpContent(result.content)
}

function formatMcpContent(content) {
  if (content == null) return ''
  if (typeof content === 'string') return content
  if (!Array.isArray(content)) {
    try { return JSON.stringify(content) } catch { return String(content) }
  }
  const parts = []
  for (const item of content) {
    if (!item) continue
    if (item.type === 'text' && typeof item.text === 'string') {
      parts.push(item.text)
    } else if (item.type === 'image' && item.data) {
      parts.push(`[image data: ${item.mimeType || 'unknown'}, ${item.data.length} chars base64]`)
    } else if (item.type === 'resource' && item.resource) {
      parts.push(`[resource: ${item.resource.uri || ''}]\n${item.resource.text || ''}`)
    } else if (item.structuredContent != null) {
      try { parts.push(JSON.stringify(item.structuredContent, null, 2)) } catch { /* ignore */ }
    } else {
      try { parts.push(JSON.stringify(item)) } catch { /* ignore */ }
    }
  }
  return parts.filter(Boolean).join('\n')
}

ipcMain.handle('mcp-sync', async (event, { sessionId, servers }) => {
  if (!sessionId) return { success: false, error: 'No sessionId provided for mcp-sync.' }
  try {
    const reports = await syncMcpServers(sessionId, servers || [])
    return { success: true, reports }
  } catch (e) {
    console.error('[mcp] mcp-sync failed:', e)
    return { success: false, error: e.message, reports: [] }
  }
})

ipcMain.handle('mcp-close', async (event, { sessionId }) => {
  try {
    await closeMcpSession(Number(sessionId))
    return { success: true }
  } catch (e) {
    return { success: false, error: e.message }
  }
})

// ========== Tool execution via Streaming WS ==========

async function executeToolByName(toolName, parsedArgs, sessionId, workspace, needApproval, dangerReason, requestId, approvalHint) {
  switch (toolName) {
    case 'shell':
      return await handleShellFromWebSocket(parsedArgs, sessionId, workspace, needApproval, dangerReason, requestId, approvalHint)
    case 'read_file':
      return await handleLocalReadFile(parsedArgs, workspace, sessionId)
    case 'write_file':
      return await handleLocalWriteFile(parsedArgs, workspace, sessionId, needApproval, requestId)
    case 'edit_file':
      return await handleLocalEditFile(parsedArgs, workspace, sessionId, needApproval, requestId)
    case 'glob_search':
      return await handleLocalGlobSearch(parsedArgs, workspace, sessionId)
    case 'grep_search':
      return await handleLocalGrepSearch(parsedArgs, workspace, sessionId)
    case 'write_local_text_file':
      // dump_user_messages 委托:文件名由消息 id 决定,重复导出覆盖同一文件(幂等,dedupe=false)。
      return await handleLocalWriteTextFile(parsedArgs, sessionId, false)
    default: {
      const mcp = parseMcpToolName(toolName)
      if (mcp) {
        return await handleMcpToolFromWebSocket(mcp, parsedArgs, sessionId, needApproval, dangerReason, requestId, approvalHint)
      }
      return { error: `Unknown tool: ${toolName}` }
    }
  }
}

async function handleMcpToolFromWebSocket(mcp, parsedArgs, sessionId, needApproval, dangerReason, requestId, approvalHint) {
  // 与 shell/write_file 等一致:needApproval 时先征求用户审批再执行
  if (needApproval) {
    const description = `调用 MCP 工具 ${mcp.serverName}.${mcp.toolName}`
    const approved = await requestToolApproval(`mcp:${mcp.serverName}`, description, sessionId, dangerReason, requestId, approvalHint)
    if (!approved) {
      return { error: 'User denied MCP tool execution.' }
    }
  }
  try {
    const result = await callMcpTool(sessionId, mcp.serverName, mcp.toolName, parsedArgs)
    return { result }
  } catch (e) {
    console.error(`[mcp] tool ${mcp.serverName}.${mcp.toolName} execution failed:`, e)
    return { error: e.message || 'MCP tool execution failed' }
  }
}

ipcMain.handle('tool-execute', async (event, { toolName, args, requestId, workspace, sessionId, needApproval, dangerReason, approvalHint }) => {
  // Use the workspace from this specific tool call, falling back to currentWorkspace
  const effectiveWorkspace = workspace || currentWorkspace
  console.log('[tool-execute] received workspace:', workspace, ', effectiveWorkspace:', effectiveWorkspace, ', currentWorkspace before:', currentWorkspace, ', needApproval:', needApproval, ', dangerReason:', dangerReason)
  if (workspace) {
    console.log('[tool-execute] setting currentWorkspace:', workspace, '(was:', currentWorkspace, ')')
    currentWorkspace = workspace
  }

  let parsedArgs
  try {
    parsedArgs = typeof args === 'string' ? JSON.parse(args) : args
  } catch {
    parsedArgs = {}
  }

  try {
    const result = await executeToolByName(toolName, parsedArgs, sessionId, effectiveWorkspace, !!needApproval, dangerReason, requestId, approvalHint)
    return { requestId, result: JSON.stringify(result), error: null }
  } catch (e) {
    console.error(`Tool ${toolName} execution failed:`, e)
    return { requestId, result: null, error: e.message }
  }
})

async function handleShellFromWebSocket(args, sessionId, workspace, needApproval, dangerReason, requestId, approvalHint) {
  return localShell.handle(args, {
    conversationId: sessionId,
    workspace,
    needApproval,
    approve: (description) => ensureShellApproval(needApproval, description, sessionId, dangerReason, requestId, approvalHint),
  })
}

function extractFilePath(args) {
  return args.path || args.file || args.filePath || args.file_path || args.target_file
}

const IMAGE_EXTENSIONS = {
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.webp': 'image/webp'
}
const MAX_IMAGE_BYTES = 5 * 1024 * 1024

function mimeFromPath(filePath) {
  const lower = filePath.toLowerCase()
  for (const [ext, mime] of Object.entries(IMAGE_EXTENSIONS)) {
    if (lower.endsWith(ext)) return mime
  }
  return null
}

function detectMimeFromBytes(buffer) {
  if (!buffer || buffer.length < 12) return null
  if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png'
  if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
  if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return 'image/gif'
  if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46
      && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) return 'image/webp'
  return null
}

function formatImageSize(bytes) {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

function readLocalImage(resolvedPath, filePath) {
  const sizeBytes = fs.statSync(resolvedPath).size
  if (sizeBytes > MAX_IMAGE_BYTES) {
    return {
      content: `错误:文件过大(${formatImageSize(sizeBytes)}),图片读取上限为 ${formatImageSize(MAX_IMAGE_BYTES)}:${filePath}`,
      total_lines: 0
    }
  }
  const buffer = fs.readFileSync(resolvedPath)
  const mime = detectMimeFromBytes(buffer)
  if (!mime) {
    return {
      content: `错误:不支持的图片格式或文件内容无效:${filePath}`,
      total_lines: 0
    }
  }

  let outBuffer = buffer
  let outMime = mime
  let width = null
  let height = null
  let dimNote = ''

  const resized = promptImageResizer.resizeForPrompt(buffer, mime, nativeImage)
  if (resized) {
    outBuffer = resized.buffer
    outMime = resized.mime
    width = resized.width
    height = resized.height
    if (resized.resized) {
      try {
        const orig = nativeImage.createFromBuffer(buffer)
        if (!orig.isEmpty()) {
          const { width: ow, height: oh } = orig.getSize()
          if (ow && oh && (ow !== width || oh !== height)) {
            dimNote = `, ${ow}×${oh}→${width}×${height}`
          }
        }
      } catch { /* ignore */ }
      if (!dimNote && width && height) {
        dimNote = `, ${width}×${height}`
      }
    } else if (width && height) {
      dimNote = `, ${width}×${height}`
    }
  }

  const outSize = outBuffer.length
  const dataUri = `data:${outMime};base64,${outBuffer.toString('base64')}`
  return {
    content: `图片读取成功:${filePath} (${outMime}, ${formatImageSize(outSize)}${dimNote})`,
    total_lines: 0,
    media_type: 'image',
    mime: outMime,
    path: filePath,
    size_bytes: outSize,
    ...(width != null ? { width } : {}),
    ...(height != null ? { height } : {}),
    data_uri: dataUri
  }
}

async function handleLocalReadFile(args, workspace, maoSessionId) {
  try {
    const filePath = extractFilePath(args)
    if (!filePath) {
      return { content: '错误:缺少必填参数 path', total_lines: 0 }
    }
    const resolvedPath = resolveWorkspacePath(filePath, workspace, maoSessionId)
    if (!fs.existsSync(resolvedPath)) {
      return { content: `错误:文件不存在:${filePath}`, total_lines: 0 }
    }
    if (!fs.statSync(resolvedPath).isFile()) {
      return { content: `错误:不是普通文件:${filePath}`, total_lines: 0 }
    }
    if (mimeFromPath(filePath)) {
      return readLocalImage(resolvedPath, filePath)
    }
    const content = fs.readFileSync(resolvedPath, 'utf-8')
    const lines = content.split('\n')
    const start = args.offset || 0
    const end = args.limit ? Math.min(start + args.limit, lines.length) : lines.length
    return { content: lines.slice(start, end).join('\n'), total_lines: lines.length }
  } catch (e) {
    return { content: `错误:${e.message}`, total_lines: 0 }
  }
}

async function handleLocalWriteFile(args, workspace, sessionId, needApproval, requestId) {
  if (needApproval) {
    const approved = await requestToolApproval('write_file', args.path, sessionId, null, requestId)
    if (!approved) {
      return { success: false, error: 'User denied file write.' }
    }
  }
  try {
    const resolvedPath = resolveWorkspacePath(args.path, workspace, sessionId)
    // Snapshot before write for change tracking
    const fileExisted = fs.existsSync(resolvedPath)
    let beforeContent = ''
    if (fileExisted) {
      beforeContent = fs.readFileSync(resolvedPath, 'utf-8')
    }
    const dir = path.dirname(resolvedPath)
    fs.mkdirSync(dir, { recursive: true })
    fs.writeFileSync(resolvedPath, args.content, 'utf-8')
    // Compute file change stats
    const newLineCount = args.content.length === 0 ? 0 : args.content.split('\n').length
    const lineDelta = fileExisted
      ? computeLineDelta(beforeContent, args.content)
      : { linesAdded: newLineCount, linesDeleted: 0 }
    return {
      success: true,
      bytes_written: Buffer.byteLength(args.content, 'utf-8'),
      file_change: {
        path: args.path,
        type: fileExisted ? 'MODIFIED' : 'CREATED',
        lines_added: lineDelta.linesAdded,
        lines_deleted: lineDelta.linesDeleted
      },
      [PRIVATE_DIFF_FIELD]: buildFileChangeDiff(args.path, beforeContent, args.content)
    }
  } catch (e) {
    return { error: e.message }
  }
}

async function handleLocalEditFile(args, workspace, sessionId, needApproval, requestId) {
  if (needApproval) {
    const approved = await requestToolApproval('edit_file', args.path, sessionId, null, requestId)
    if (!approved) {
      return { success: false, error: 'User denied file edit.' }
    }
  }
  try {
    const resolvedPath = resolveWorkspacePath(args.path, workspace, sessionId)
    return writeEditedFile(
      resolvedPath,
      args.path,
      args.old_string,
      args.new_string,
      asBoolArg(args.replace_all),
    )
  } catch (e) {
    return { error: e.message }
  }
}

/**
 * LOCAL 文本落盘(dump_user_messages 的委托入口 与 渲染层粘贴附件两个来源):
 * 把文本写到 `~/.mao/attachments/<hostKey>/<sessionId>/<fileName>`,返回本机绝对路径。
 * 落点由主进程推导(home + server host + sessionId),渲染层/服务端无法指定;
 * 写入失败不抛异常,以 { error } 形态回传(与其它 LOCAL 工具一致)。
 *
 * @param {object} args { fileName, content, maxSizeMb }
 * @param {number|string} sessionId 会话 ID
 * @param {boolean} dedupe 同名避重:true 追加 -1/-2 副本(粘贴附件),false 覆盖(dump 幂等)
 */
async function handleLocalWriteTextFile(args, sessionId, dedupe) {
  const result = await chatAttachment.writeLocalTextFileAsync({
    fileName: args?.fileName,
    content: args?.content,
    maxSizeMb: args?.maxSizeMb,
    sessionId,
    dedupe,
    homeDir: os.homedir(),
    hostKey: getServerHostKey(),
  })
  if (!result.ok) return { error: result.error }
  return { success: true, path: result.absolutePath, bytes_written: result.bytes }
}

// --- glob_search / grep_search ---

let rgAvailable = null
async function isRgAvailable() {
  if (rgAvailable !== null) return rgAvailable
  try {
    const { execFile } = require('child_process')
    await new Promise((resolve, reject) => {
      execFile('rg', ['--version'], (err) => err ? reject(err) : resolve())
    })
    rgAvailable = true
  } catch {
    rgAvailable = false
  }
  return rgAvailable
}

async function handleLocalGlobSearch(args, workspace, maoSessionId) {
  const pattern = args.pattern
  if (!pattern) return { files: [], error: 'pattern is required' }

  const headLimit = args.head_limit || 100
  const resolvedPath = args.path
    ? resolveWorkspacePath(args.path, workspace, maoSessionId)
    : (workspace || currentWorkspace || '.')

  try {
    const scope = resolveSearchScope(resolvedPath)
    let files = []
    if (await isRgAvailable()) {
      files = await globWithRg(pattern, scope, headLimit)
    } else {
      files = globWithNode(pattern, scope, headLimit)
    }

    const truncated = files.length >= headLimit
    return {
      files,
      search_root: scope.cwd,
      truncated,
      total_matched: files.length
    }
  } catch (e) {
    return { files: [], error: e.message }
  }
}

function resolveSearchScope(resolvedPath) {
  let stat
  try {
    stat = fs.statSync(resolvedPath)
  } catch {
    throw new Error(`Search path does not exist or is not accessible: ${resolvedPath}`)
  }
  if (stat.isFile()) {
    const cwd = path.dirname(resolvedPath)
    return { cwd, rgTarget: path.basename(resolvedPath), singleFile: resolvedPath }
  }
  return { cwd: resolvedPath, rgTarget: '.', singleFile: null }
}

function outputFilePath(scope, rgFilePath, workspaceRoot) {
  if (scope.singleFile) {
    const normalized = path.normalize(scope.singleFile)
    if (workspaceRoot && normalized.startsWith(path.normalize(workspaceRoot))) {
      return path.relative(workspaceRoot, normalized)
    }
    return path.basename(normalized)
  }
  return relativizeRgPath(rgFilePath, scope.cwd)
}

function relativizeRgPath(filePath, searchRoot) {
  const trimmed = filePath.startsWith('./') ? filePath.slice(2) : filePath
  if (path.isAbsolute(trimmed)) {
    const normalized = path.normalize(trimmed)
    return normalized.startsWith(searchRoot) ? path.relative(searchRoot, normalized) : normalized
  }
  return path.normalize(trimmed)
}

function globWithRg(pattern, scope, headLimit) {
  return new Promise((resolve, reject) => {
    const { execFile } = require('child_process')
    execFile('rg', ['--files', '--glob', pattern, scope.rgTarget], { cwd: scope.cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
      if (err && !stdout) return reject(err)
      const lines = (stdout || '').split('\n').filter(Boolean).slice(0, headLimit)
      resolve(lines.map(l => relativizeRgPath(l, scope.cwd)))
    })
  })
}

function globWithNode(pattern, scope, headLimit) {
  const { minimatch } = require('minimatch')
  const files = []
  const searchRoot = scope.cwd

  function walk(dir) {
    if (files.length >= headLimit) return
    let entries
    try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
    for (const entry of entries) {
      if (files.length >= headLimit) return
      const fullPath = path.join(dir, entry.name)
      if (entry.isDirectory()) {
        if (entry.name === 'node_modules' || entry.name === '.git') continue
        walk(fullPath)
      } else if (entry.isFile()) {
        const relative = path.relative(searchRoot, fullPath)
        if (minimatch(relative, pattern) || minimatch(entry.name, pattern)) {
          files.push(relative)
        }
      }
    }
  }

  walk(searchRoot)
  return files
}

async function handleLocalGrepSearch(args, workspace, maoSessionId) {
  const pattern = args.pattern
  if (!pattern) return { matches: [], error: 'pattern is required' }

  const glob = args.glob || null
  const ignoreCase = args.ignore_case || false
  const contextLines = args.context_lines || 0
  const maxOutputChars = args.max_output_chars || 10000
  const resolvedPath = args.path
    ? resolveWorkspacePath(args.path, workspace, maoSessionId)
    : (workspace || currentWorkspace || '.')
  const workspaceRoot = workspace || currentWorkspace || '.'

  try {
    const scope = resolveSearchScope(resolvedPath)
    if (await isRgAvailable()) {
      return await grepWithRg(pattern, scope, workspaceRoot, glob, ignoreCase, contextLines, maxOutputChars)
    } else {
      return grepWithNode(pattern, scope, workspaceRoot, glob, ignoreCase, contextLines, maxOutputChars)
    }
  } catch (e) {
    return { matches: [], error: e.message }
  }
}

function grepWithRg(pattern, scope, workspaceRoot, glob, ignoreCase, contextLines, maxOutputChars) {
  return new Promise((resolve, reject) => {
    const { execFile } = require('child_process')
    const cmd = ['--line-number', '--no-heading']
    if (ignoreCase) cmd.push('--ignore-case')
    if (contextLines > 0) { cmd.push('--context'); cmd.push(String(contextLines)) }
    if (glob) { cmd.push('--glob'); cmd.push(glob) }
    cmd.push(pattern)
    cmd.push(scope.rgTarget)

    execFile('rg', cmd, { cwd: scope.cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
      if (err && !stdout) return resolve({ matches: [], truncated: false, total_matches: 0 })

      const lines = (stdout || '').split('\n').filter(Boolean)
      const matches = []
      let totalMatches = 0
      let charsUsed = 0
      let truncated = false

      for (const line of lines) {
        if (charsUsed + line.length + 1 > maxOutputChars) {
          truncated = true
          break
        }
        const parsed = parseRgLine(line, scope, workspaceRoot)
        if (parsed) {
          matches.push(parsed)
          totalMatches++
          charsUsed += line.length + 1
        }
      }

      resolve({ matches, truncated, total_matches: totalMatches })
    })
  })
}

function parseRgLine(line, scope, workspaceRoot) {
  if (scope.singleFile) {
    return parseRgSingleFileLine(line, scope, workspaceRoot)
  }

  const firstColon = line.indexOf(':')
  if (firstColon < 0) return null
  const secondColon = line.indexOf(':', firstColon + 1)
  if (secondColon < 0) return null

  const filePath = line.substring(0, firstColon)
  const lineNumStr = line.substring(firstColon + 1, secondColon)
  const content = line.substring(secondColon + 1)

  const lineNum = parseInt(lineNumStr, 10)
  if (isNaN(lineNum)) {
    const firstDash = line.indexOf('-')
    if (firstDash < 0) return null
    const secondDash = line.indexOf('-', firstDash + 1)
    if (secondDash < 0) return null
    const dashLineNum = parseInt(line.substring(firstDash + 1, secondDash), 10)
    if (isNaN(dashLineNum)) return null
    return { file: outputFilePath(scope, line.substring(0, firstDash), workspaceRoot), line: dashLineNum, content: line.substring(secondDash + 1) }
  }

  return { file: outputFilePath(scope, filePath, workspaceRoot), line: lineNum, content }
}

function parseRgSingleFileLine(line, scope, workspaceRoot) {
  const colon = line.indexOf(':')
  const dash = line.indexOf('-')
  let sepIdx
  if (colon > 0 && (dash < 0 || colon < dash)) {
    sepIdx = colon
  } else if (dash > 0) {
    sepIdx = dash
  } else {
    return null
  }

  const lineNum = parseInt(line.substring(0, sepIdx), 10)
  if (isNaN(lineNum)) return null

  return { file: outputFilePath(scope, '', workspaceRoot), line: lineNum, content: line.substring(sepIdx + 1) }
}

function grepWithNode(pattern, scope, workspaceRoot, glob, ignoreCase, contextLines, maxOutputChars) {
  const { minimatch } = require('minimatch')
  const flags = ignoreCase ? 'i' : ''
  let regex
  try {
    regex = new RegExp(pattern, flags)
  } catch {
    return { matches: [], error: 'Invalid regex pattern: ' + pattern }
  }

  const matches = []
  let totalMatches = 0
  let charsUsed = 0
  let truncated = false

  function scanFile(fullPath) {
    if (truncated) return
    if (glob && !minimatch(path.basename(fullPath), glob)) return

    let lines
    try { lines = fs.readFileSync(fullPath, 'utf-8').split('\n') } catch { return }

    const relative = outputFilePath(scope, fullPath, workspaceRoot)

    for (let i = 0; i < lines.length; i++) {
      if (regex.test(lines[i])) {
        const match = { file: relative, line: i + 1, content: lines[i] }

        if (contextLines > 0) {
          const before = []
          for (let c = Math.max(0, i - contextLines); c < i; c++) before.push(lines[c])
          const after = []
          for (let c = i + 1; c <= Math.min(lines.length - 1, i + contextLines); c++) after.push(lines[c])
          if (before.length > 0) match.context_before = before
          if (after.length > 0) match.context_after = after
        }

        const entrySize = JSON.stringify(match).length
        if (charsUsed + entrySize > maxOutputChars) {
          truncated = true
          break
        }

        matches.push(match)
        totalMatches++
        charsUsed += entrySize
      }
    }
  }

  function walk(dir) {
    if (truncated) return
    let entries
    try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }

    for (const entry of entries) {
      if (truncated) return
      const fullPath = path.join(dir, entry.name)
      if (entry.isDirectory()) {
        if (entry.name === 'node_modules' || entry.name === '.git') continue
        walk(fullPath)
      } else if (entry.isFile()) {
        scanFile(fullPath)
      }
    }
  }

  if (scope.singleFile) {
    scanFile(scope.singleFile)
  } else {
    walk(scope.cwd)
  }
  return { matches, truncated, total_matches: totalMatches }
}