没有工作区时,不会自动获得某个项目的文件树和 Git 上下文。桌面端可以显式引用文件或目录,路径在没有项目根目录时通常显示为绝对路径。需要持续修改某个代码库时,新建对应工作区中的任务更容易确认操作范围。
'''),
section("organize", "恢复、搜索、置顶与归档",
- '''模型页通过保存模型或保存修改提交;个性化文本和 MCP JSON 等控件会在离开编辑区时保存,并显示保存状态。TUI 设置中心的 General、Appearance 开关通常即时保存,配置与模型表单按底部提示使用 Ctrl+S。
不同配置有不同生效边界。任务模型和权限使用专门的切换入口;后台连接与运行服务以界面的应用结果为准。出现“重启 daemon 后生效”时,保存正在进行的工作后重启。手动改动任意 JSON 文件,并不等于所有运行中的模块都已经重新加载。
''',
note("配置片段需要合并", "文档中的 JSON 示例只展示相关字段。请合并到现有对象中,不要用片段替换整份配置,否则可能丢失已经保存的模型和其他设置。")),
+ section("tui-ask-user-question", "TUI 问答配置",
+ '''这两个字段会在读取配置时限制在有效范围内。超出范围的整数会自动限制到边界,并记录警告;非整数值会被忽略并继续使用默认值。省略字段时使用默认值,配置保存采用稀疏写入,不会强制写出默认值。
'''),
+ section("ask-user-question", "AskUserQuestion 跨端配置",
+ '''按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档自身的 Ctrl+K 搜索快捷键不代表 ACECode 产品的同名操作。
'''),
+ table(["入口", "按键", "作用"], [["桌面 / Web", "按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档站自己的 Ctrl+K 只搜索文档内容,与 ACECode 产品内的 Ctrl+K 全局搜索是两个独立功能。
'''),
section("commands", "斜杠命令",
table(["命令", "用途与范围"], [["不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。
'''),
diff --git a/docs/help-source/sources.json b/docs/help-source/sources.json
index b38047f4f..e55ccd794 100644
--- a/docs/help-source/sources.json
+++ b/docs/help-source/sources.json
@@ -90,7 +90,7 @@
"docs/user-manual.md",
"web/src/components/QueueCardList.jsx",
"web/src/lib/inputBarState.js",
- "web/src/components/SideQuestionComposer.jsx",
+ "web/src/components/SideChatWindow.jsx",
"web/src/components/ChatView.jsx",
"docs/daemon-api.md"
]
diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js
index a24a87f37..100007daa 100644
--- a/docs/help/assets/search-index.js
+++ b/docs/help/assets/search-index.js
@@ -30,7 +30,7 @@ window.ACECODE_HELP_INDEX = [
"title": "安装",
"url": "installation.html",
"description": "选择适合系统和处理器的安装方式,确认程序能启动后,再配置第一个模型。",
- "text": "安装前准备 先确定你使用的操作系统和处理器架构。Windows、macOS 和 Linux 的安装包不能混用;Apple 芯片与 Intel Mac 也需要选择不同的包。 模型访问方式: 准备服务商提供的 API Key,或使用支持的账号登录方式。安装完成后再配置即可。 开发环境: 若任务需要运行项目,请先准备项目依赖,例如 Git、Node.js、Python 或相应编译工具。 npm 安装: 需要电脑上已有 Node.js 和 npm。直接下载原生安装包时,不需要为了启动 ACECode 安装 Node.js。 所有手动下载入口都指向 ACECode 官方发布页 。在 Assets 中选择对应平台的产物,具体可用文件以该版本发布内容为准。 通过 npm 安装 如果你已经使用 npm,可以直接安装桌面端或终端版。安装时会按系统和处理器选择对应的原生程序。 桌面端 终端 · 安装并启动桌面端 复制 npm install -g @aceagent/desktop acecode-desktop 终端版 终端 · 安装、配置并启动 TUI 复制 npm install -g @aceagent/acecode acecode configure acecode 保留可选依赖 安装时请不要添加 --omit=optional 或 --no-optional ,平台所需的原生程序通过可选依赖安装。桌面端与 CLI 提供不同的启动命令,需要两个入口时可分别安装。 终端版与桌面端分别提供 acecode 与 acecode-desktop 命令。安装后重新打开终端,用 acecode --version 检查终端版是否可用。 Windows 桌面端可使用 Windows x64 安装器,也可以使用上方的 npm 方式。 打开官方发布页,找到该版本提供的 Windows 安装器,例如 ACECode-<版本>-windows-x64-setup.exe 。 双击安装器,按向导完成安装。默认安装到当前用户的 %LOCALAPPDATA%\\Programs\\ACECode 。 从开始菜单启动 ACECode,然后前往 配置第一个模型 。 Windows 安装向导的欢迎页和目标位置页。画面来自本机已有的 v0.9.6 安装包,仅展示向导流程,下载时请以发布页为准。本次查看后已退出向导,未执行安装。 Windows 实际界面 · 点击图片查看原图 Windows 桌面界面需要 Microsoft Edge WebView2 Runtime。若当前系统没有该运行时,请按安装提示补齐。使用便携包时,请保留解压后的完整目录,桌面程序需要同目录中的后台程序和资源文件。 macOS 先在苹果菜单的“关于本机”中确认芯片类型,再选择对应的安装包。 你的 Mac 选择的架构 Apple 芯片(M 系列) macos-arm64 Intel 处理器 macos-x64 若当前发布版本提供 .pkg ,下载与架构匹配的包并双击打开。 按照系统安装器提示完成当前用户安装。 在 ~/Applications/ACECode.app 打开 ACECode,继续配置模型。 GS-03 截图占位 macOS 安装与首次打开 此处将补充实际界面截图 16 : 9 macOS:展示 PKG 安装完成后,当前用户“应用程序”目录中的 ACECode。 若该版本没有 PKG,可使用 npm,或按发行说明使用对应架构的压缩包。ACECode 的 macOS 发布产物不使用 DMG 安装流程。 Linux Linux 可以使用 npm 安装,也可以下载 acecode-linux-x64.tar.gz 或 acecode-linux-arm64.tar.gz 。下面以 x64 压缩包为例,在下载目录执行: Shell · 从压缩包启动 复制 tar -xzf acecode-linux-x64.tar.gz cd acecode-linux-x64 ./acecode configure ./acecode ARM64 用户替换文件名和目录名中的 x64 为 arm64 。直接解压运行时使用 ./acecode ;将程序目录加入 PATH 后,才可在其他目录使用 acecode 。 使用 Linux 桌面端 桌面端需要 WebKitGTK 4.1。采用 apt 的发行版可安装对应运行库,然后在程序目录中启动: Shell · 桌面运行库与启动 复制 sudo apt install libwebkit2gtk-4.1-0 ./acecode-desktop 其他发行版请使用对应的软件包管理器安装 WebKitGTK 4.1。较旧的 Linux 系统若出现 glibc 版本不满足的问题,请查看发布页中的 acecode-linux-old-* 兼容包。 通过浏览器使用 安装终端版后,也可以启动后台服务。npm 安装时执行: 终端 · 启动 Web 服务 复制 acecode daemon 未覆盖端口配置时,在本机浏览器打开 http://127.0.0.1:12399 。便携版请在程序目录执行 ./acecode daemon 。配置了端口或认证时,按服务启动信息访问。 本机 Web 与远程访问 这里的地址用于本机访问。远程访问需要额外配置访问方式与认证;首次使用建议先在本机完成模型配置和任务验证。 请优先打开启动输出中的 Web UI: 地址;已保存的端口配置可能与默认值不同。完整操作见 Web 。"
+ "text": "安装前准备 先确定你使用的操作系统和处理器架构。Windows、macOS 和 Linux 的安装包不能混用;Apple 芯片与 Intel Mac 也需要选择不同的包。 模型访问方式: 准备服务商提供的 API Key,或使用支持的账号登录方式。安装完成后再配置即可。 开发环境: 若任务需要运行项目,请先准备项目依赖,例如 Git、Node.js、Python 或相应编译工具。 npm 安装: 需要电脑上已有 Node.js 和 npm。直接下载原生安装包时,不需要为了启动 ACECode 安装 Node.js。 所有手动下载入口都指向 ACECode 官方发布页 。在 Assets 中选择对应平台的产物,具体可用文件以该版本发布内容为准。 通过 npm 安装 如果你已经使用 npm,可以直接安装桌面端或终端版。安装时会按系统和处理器选择对应的原生程序。 桌面端 终端 · 安装并启动桌面端 复制 npm install -g @aceagent/desktop acecode-desktop 终端版 终端 · 安装、配置并启动 TUI 复制 npm install -g @aceagent/acecode acecode configure acecode 保留可选依赖 安装时请不要添加 --omit=optional 或 --no-optional ,平台所需的原生程序通过可选依赖安装。桌面端与 CLI 提供不同的启动命令,需要两个入口时可分别安装。 终端版与桌面端分别提供 acecode 与 acecode-desktop 命令。安装后重新打开终端,用 acecode --version 检查终端版是否可用。 Windows 桌面端可使用 Windows x64 安装器,也可以使用上方的 npm 方式。 打开官方发布页,找到该版本提供的 Windows 安装器,例如 ACECode-<版本>-windows-x64-setup.exe 。 双击安装器,按向导完成安装。默认安装到当前用户的 %LOCALAPPDATA%\\Programs\\ACECode 。 从开始菜单启动 ACECode,然后前往 配置第一个模型 。 Windows 安装向导的欢迎页和目标位置页。画面来自本机已有的 v0.9.6 安装包,仅展示向导流程,下载时请以发布页为准。本次查看后已退出向导,未执行安装。 Windows 实际界面 · 点击图片查看原图 Windows 桌面界面需要 Microsoft Edge WebView2 Runtime。若当前系统没有该运行时,请按安装提示补齐。使用便携包时,请保留解压后的完整目录,桌面程序需要同目录中的后台程序和资源文件。 macOS 先在苹果菜单的“关于本机”中确认芯片类型,再选择对应的安装包。 你的 Mac 选择的架构 Apple 芯片(M 系列) macos-arm64 Intel 处理器 macos-x64 若当前发布版本提供 .pkg ,下载与架构匹配的包并双击打开。 按照系统安装器提示完成当前用户安装。 在 ~/Applications/ACECode.app 打开 ACECode,继续配置模型。 GS-03 截图占位 macOS 安装与首次打开 此处将补充实际界面截图 16 : 9 macOS:展示 PKG 安装完成后,当前用户“应用程序”目录中的 ACECode。 若该版本没有 PKG,可使用 npm,或按发行说明使用对应架构的压缩包。ACECode 的 macOS 发布产物不使用 DMG 安装流程。 Linux Linux 可以使用 npm 安装,也可以下载 acecode-linux-x64.tar.gz 或 acecode-linux-arm64.tar.gz 。下面以 x64 压缩包为例,在下载目录执行: Shell · 从压缩包启动 复制 tar -xzf acecode-linux-x64.tar.gz cd acecode-linux-x64 ./acecode configure ./acecode ARM64 用户替换文件名和目录名中的 x64 为 arm64 。直接解压运行时使用 ./acecode ;将程序目录加入 PATH 后,才可在其他目录使用 acecode 。 使用 Linux 桌面端 桌面端需要 WebKitGTK 4.1。采用 apt 的发行版可安装对应运行库,然后在程序目录中启动: Shell · 桌面运行库与启动 复制 sudo apt install libwebkit2gtk-4.1-0 ./acecode-desktop 其他发行版请使用对应的软件包管理器安装 WebKitGTK 4.1。UOS / Deepin 用户请下载 acecode-linux-deepin-* 专用包;桌面端使用系统 WebKitGTK 4.0 和 DTK 5,终端 ARMv7 包不包含桌面端。 通过浏览器使用 安装终端版后,也可以启动后台服务。npm 安装时执行: 终端 · 启动 Web 服务 复制 acecode daemon 未覆盖端口配置时,在本机浏览器打开 http://127.0.0.1:12399 。便携版请在程序目录执行 ./acecode daemon 。配置了端口或认证时,按服务启动信息访问。 本机 Web 与远程访问 这里的地址用于本机访问。远程访问需要额外配置访问方式与认证;首次使用建议先在本机完成模型配置和任务验证。 请优先打开启动输出中的 Web UI: 地址;已保存的端口配置可能与默认值不同。完整操作见 Web 。"
},
{
"page": "安装",
@@ -54,7 +54,7 @@ window.ACECODE_HELP_INDEX = [
"title": "Linux",
"url": "installation.html#linux",
"description": "Linux Linux 可以使用 npm 安装,也可以下载 acecode-linux-x64.tar.gz 或 acecode-linux-arm64.tar.gz 。下面以 x64 压缩包为例,在下载目录执行: Shell · 从压缩包启动 复制",
- "text": "Linux Linux 可以使用 npm 安装,也可以下载 acecode-linux-x64.tar.gz 或 acecode-linux-arm64.tar.gz 。下面以 x64 压缩包为例,在下载目录执行: Shell · 从压缩包启动 复制 tar -xzf acecode-linux-x64.tar.gz cd acecode-linux-x64 ./acecode configure ./acecode ARM64 用户替换文件名和目录名中的 x64 为 arm64 。直接解压运行时使用 ./acecode ;将程序目录加入 PATH 后,才可在其他目录使用 acecode 。 使用 Linux 桌面端 桌面端需要 WebKitGTK 4.1。采用 apt 的发行版可安装对应运行库,然后在程序目录中启动: Shell · 桌面运行库与启动 复制 sudo apt install libwebkit2gtk-4.1-0 ./acecode-desktop 其他发行版请使用对应的软件包管理器安装 WebKitGTK 4.1。较旧的 Linux 系统若出现 glibc 版本不满足的问题,请查看发布页中的 acecode-linux-old-* 兼容包。 通过浏览器使用 安装终端版后,也可以启动后台服务。npm 安装时执行: 终端 · 启动 Web 服务 复制 acecode daemon 未覆盖端口配置时,在本机浏览器打开 http://127.0.0.1:12399 。便携版请在程序目录执行 ./acecode daemon 。配置了端口或认证时,按服务启动信息访问。 本机 Web 与远程访问 这里的地址用于本机访问。远程访问需要额外配置访问方式与认证;首次使用建议先在本机完成模型配置和任务验证。 请优先打开启动输出中的 Web UI: 地址;已保存的端口配置可能与默认值不同。完整操作见 Web 。"
+ "text": "Linux Linux 可以使用 npm 安装,也可以下载 acecode-linux-x64.tar.gz 或 acecode-linux-arm64.tar.gz 。下面以 x64 压缩包为例,在下载目录执行: Shell · 从压缩包启动 复制 tar -xzf acecode-linux-x64.tar.gz cd acecode-linux-x64 ./acecode configure ./acecode ARM64 用户替换文件名和目录名中的 x64 为 arm64 。直接解压运行时使用 ./acecode ;将程序目录加入 PATH 后,才可在其他目录使用 acecode 。 使用 Linux 桌面端 桌面端需要 WebKitGTK 4.1。采用 apt 的发行版可安装对应运行库,然后在程序目录中启动: Shell · 桌面运行库与启动 复制 sudo apt install libwebkit2gtk-4.1-0 ./acecode-desktop 其他发行版请使用对应的软件包管理器安装 WebKitGTK 4.1。UOS / Deepin 用户请下载 acecode-linux-deepin-* 专用包;桌面端使用系统 WebKitGTK 4.0 和 DTK 5,终端 ARMv7 包不包含桌面端。 通过浏览器使用 安装终端版后,也可以启动后台服务。npm 安装时执行: 终端 · 启动 Web 服务 复制 acecode daemon 未覆盖端口配置时,在本机浏览器打开 http://127.0.0.1:12399 。便携版请在程序目录执行 ./acecode daemon 。配置了端口或认证时,按服务启动信息访问。 本机 Web 与远程访问 这里的地址用于本机访问。远程访问需要额外配置访问方式与认证;首次使用建议先在本机完成模型配置和任务验证。 请优先打开启动输出中的 Web UI: 地址;已保存的端口配置可能与默认值不同。完整操作见 Web 。"
},
{
"page": "快速开始",
@@ -310,7 +310,7 @@ window.ACECODE_HELP_INDEX = [
"title": "工作区与任务",
"url": "workspaces.html",
"description": "工作区确定要操作的项目位置,任务保存一次持续工作的上下文。先选对范围,再组织和恢复任务。",
- "text": "添加与管理工作区 在侧边栏 工作区 旁点击添加入口,选择本地项目目录。项目显示在列表中后,点击其新建任务按钮即可在该目录开始工作。一个工作区可以保存多个任务。 工作区菜单支持重命名项目、复制项目路径和从项目列表移除。移除列表项不会删除磁盘上的项目文件。远程 Web 的工作区路径属于后台计算机,不能填写另一台设备才有的目录。 侧边栏的工作区分组列出各项目及其任务。右键项目名打开菜单,提供切换到项目、折叠项目、新建会话、重命名项目、复制项目路径、打开文件夹和从项目列表移除;移除项以红色显示,只从列表移除,不删除磁盘上的项目文件。 Windows 实际界面 · 点击图片查看原图 脱离工作区的任务 在新建任务时选择 不使用工作区 ,适合通用问答、文字处理或先讨论方案。此类记录集中在侧边栏 任务 分组,仍可置顶和归档。 没有工作区时,不会自动获得某个项目的文件树和 Git 上下文。桌面端可以显式引用文件或目录,路径在没有项目根目录时通常显示为绝对路径。需要持续修改某个代码库时,新建对应工作区中的任务更容易确认操作范围。 恢复、搜索、置顶与归档 恢复: 点击侧边栏中的任务,阅读最近结果后继续发送;TUI 使用 /resume 或启动参数。 搜索: 点击搜索任务,按任务标题或消息线索查找,选择结果回到相应记录。 置顶: 使用任务旁的置顶按钮或右键菜单;不使用工作区的任务也可以置顶。 归档: 将暂时不用的任务从日常列表收起。到设置中的已归档会话查找并恢复。 归档保留记录。已归档页面中的彻底删除会移除本地会话数据,操作前先核对对象;长期保存或分享阅读内容可先导出。 分叉、导入与导出会话 在支持的消息右键菜单选择 从这里分叉 ,会复制截至该消息的对话前缀并打开新任务。新任务不会自动开始执行;输入下一条消息后才继续。原任务保留,磁盘文件不会因为分叉回到过去。 桌面端任务菜单中的 导出 会保存可见对话为 Markdown。导出文件适合阅读和分享,不等于可直接导入恢复的完整任务备份。 项目菜单提供 从opencode导入会话 :先预览识别出的记录,勾选需要的会话,再启动导入并查看结果。导入入口针对识别到的 OpenCode 历史,不能把任意 Markdown 文档当作会话包导入。 第一张:右键任务中的某条消息,菜单提供复制消息、从这里分叉和全选。第二张:项目菜单中的从opencode导入会话,只在该项目目录识别到 OpenCode 历史时出现,其他项目的菜单没有这一项。 Windows 实际界面 · 红色标注为文档说明添加 · 点击图片查看原图"
+ "text": "添加与管理工作区 在侧边栏 工作区 旁点击添加入口,选择本地项目目录。项目显示在列表中后,点击其新建任务按钮即可在该目录开始工作。一个工作区可以保存多个任务。 工作区菜单支持重命名项目、复制项目路径和从项目列表移除。移除列表项不会删除磁盘上的项目文件。远程 Web 的工作区路径属于后台计算机,不能填写另一台设备才有的目录。 侧边栏的工作区分组列出各项目及其任务。右键项目名打开菜单,提供切换到项目、折叠项目、新建会话、重命名项目、复制项目路径、打开文件夹和从项目列表移除;移除项以红色显示,只从列表移除,不删除磁盘上的项目文件。 Windows 实际界面 · 点击图片查看原图 脱离工作区的任务 在新建任务时选择 不使用工作区 ,适合通用问答、文字处理或先讨论方案。此类记录集中在侧边栏 任务 分组,仍可置顶和归档。 没有工作区时,不会自动获得某个项目的文件树和 Git 上下文。桌面端可以显式引用文件或目录,路径在没有项目根目录时通常显示为绝对路径。需要持续修改某个代码库时,新建对应工作区中的任务更容易确认操作范围。 恢复、搜索、置顶与归档 恢复: 点击侧边栏中的任务,阅读最近结果后继续发送;TUI 使用 /resume 或启动参数。 搜索: 点击搜索任务或按 Ctrl+K (macOS ⌘K ),按任务标题或消息线索查找,选择结果回到相应记录;同一个搜索框也能查找项目和设置项,选中设置项会打开设置并定位到该项。 置顶: 使用任务旁的置顶按钮或右键菜单;不使用工作区的任务也可以置顶。 归档: 将暂时不用的任务从日常列表收起。到设置中的已归档会话查找并恢复。 归档保留记录。已归档页面中的彻底删除会移除本地会话数据,操作前先核对对象;长期保存或分享阅读内容可先导出。 分叉、导入与导出会话 在支持的消息右键菜单选择 从这里分叉 ,会复制截至该消息的对话前缀并打开新任务。新任务不会自动开始执行;输入下一条消息后才继续。原任务保留,磁盘文件不会因为分叉回到过去。 桌面端任务菜单中的 导出 会保存可见对话为 Markdown。导出文件适合阅读和分享,不等于可直接导入恢复的完整任务备份。 项目菜单提供 从opencode导入会话 :先预览识别出的记录,勾选需要的会话,再启动导入并查看结果。导入入口针对识别到的 OpenCode 历史,不能把任意 Markdown 文档当作会话包导入。 第一张:右键任务中的某条消息,菜单提供复制消息、从这里分叉和全选。第二张:项目菜单中的从opencode导入会话,只在该项目目录识别到 OpenCode 历史时出现,其他项目的菜单没有这一项。 Windows 实际界面 · 红色标注为文档说明添加 · 点击图片查看原图"
},
{
"page": "工作区与任务",
@@ -333,8 +333,8 @@ window.ACECODE_HELP_INDEX = [
"group": "使用 ACECode",
"title": "恢复、搜索、置顶与归档",
"url": "workspaces.html#organize",
- "description": "恢复、搜索、置顶与归档 恢复: 点击侧边栏中的任务,阅读最近结果后继续发送;TUI 使用 /resume 或启动参数。 搜索: 点击搜索任务,按任务标题或消息线索查找,选择结果回到相应记录。 置顶: 使用任务旁的置顶按钮或右键菜单;不使用工作区的任务",
- "text": "恢复、搜索、置顶与归档 恢复: 点击侧边栏中的任务,阅读最近结果后继续发送;TUI 使用 /resume 或启动参数。 搜索: 点击搜索任务,按任务标题或消息线索查找,选择结果回到相应记录。 置顶: 使用任务旁的置顶按钮或右键菜单;不使用工作区的任务也可以置顶。 归档: 将暂时不用的任务从日常列表收起。到设置中的已归档会话查找并恢复。 归档保留记录。已归档页面中的彻底删除会移除本地会话数据,操作前先核对对象;长期保存或分享阅读内容可先导出。"
+ "description": "恢复、搜索、置顶与归档 恢复: 点击侧边栏中的任务,阅读最近结果后继续发送;TUI 使用 /resume 或启动参数。 搜索: 点击搜索任务或按 Ctrl+K (macOS ⌘K ),按任务标题或消息线索查找,选择结果回到相应记录;同一个搜索框也能查",
+ "text": "恢复、搜索、置顶与归档 恢复: 点击侧边栏中的任务,阅读最近结果后继续发送;TUI 使用 /resume 或启动参数。 搜索: 点击搜索任务或按 Ctrl+K (macOS ⌘K ),按任务标题或消息线索查找,选择结果回到相应记录;同一个搜索框也能查找项目和设置项,选中设置项会打开设置并定位到该项。 置顶: 使用任务旁的置顶按钮或右键菜单;不使用工作区的任务也可以置顶。 归档: 将暂时不用的任务从日常列表收起。到设置中的已归档会话查找并恢复。 归档保留记录。已归档页面中的彻底删除会移除本地会话数据,操作前先核对对象;长期保存或分享阅读内容可先导出。"
},
{
"page": "工作区与任务",
@@ -566,7 +566,7 @@ window.ACECODE_HELP_INDEX = [
"title": "配置文件与生效范围",
"url": "configuration.html",
"description": "优先通过界面修改对应设置;需要手动编辑时,先确认数据目录、字段范围和保存方式。",
- "text": "配置保存在哪里 个人安装的主配置是 ~/.acecode/config.json ,Windows 对应 %USERPROFILE%\\.acecode\\config.json 。模型预设、默认模型以及网络、技能、MCP 等全局选项保存在这套用户配置中。 Windows 服务模式使用 %PROGRAMDATA%\\acecode ,与个人安装的数据目录分开。连接远端后台时,配置属于远端用户或服务身份;编辑本机文件不会自动修改远端配置。 配置层 适合保存的内容 全局配置 服务商连接、默认值、扩展连接与运行选项。 工作目录覆盖 例如 TUI 用 /model --cwd 保存的项目模型选择。 项目文件 项目规则、项目技能和项目 Hooks。 当前任务 任务选择的模型、权限与对话上下文。 具体位置见 本地配置和数据 。复制配置到其他计算机前,检查其中的绝对路径、可执行文件位置和认证信息。 修改与生效 模型页通过保存模型或保存修改提交;个性化文本和 MCP JSON 等控件会在离开编辑区时保存,并显示保存状态。TUI 设置中心的 General、Appearance 开关通常即时保存,配置与模型表单按底部提示使用 Ctrl+S 。 不同配置有不同生效边界。任务模型和权限使用专门的切换入口;后台连接与运行服务以界面的应用结果为准。出现“重启 daemon 后生效”时,保存正在进行的工作后重启。手动改动任意 JSON 文件,并不等于所有运行中的模块都已经重新加载。 配置片段需要合并 文档中的 JSON 示例只展示相关字段。请合并到现有对象中,不要用片段替换整份配置,否则可能丢失已经保存的模型和其他设置。 手动编辑与错误恢复 先备份当前有效配置,使用支持 UTF-8 的编辑器打开。 只修改目标字段,保持正确的 JSON 类型,不加入注释或尾随逗号。 重新加载相关功能,或按该功能要求重启。 检查界面实际值和一次小操作,确认修改已生效。 当前版本会保存有效配置快照。配置损坏且存在有效快照时,会备份错误文件并尝试自动恢复;Web/Desktop 会显示一次 配置已自动回滚 提示。没有可用快照时仍会报告配置错误。按提示查看备份位置并修复目标字段,备份可能含密钥,不要直接公开。"
+ "text": "配置保存在哪里 个人安装的主配置是 ~/.acecode/config.json ,Windows 对应 %USERPROFILE%\\.acecode\\config.json 。模型预设、默认模型以及网络、技能、MCP 等全局选项保存在这套用户配置中。 Windows 服务模式使用 %PROGRAMDATA%\\acecode ,与个人安装的数据目录分开。连接远端后台时,配置属于远端用户或服务身份;编辑本机文件不会自动修改远端配置。 配置层 适合保存的内容 全局配置 服务商连接、默认值、扩展连接与运行选项。 工作目录覆盖 例如 TUI 用 /model --cwd 保存的项目模型选择。 项目文件 项目规则、项目技能和项目 Hooks。 当前任务 任务选择的模型、权限与对话上下文。 具体位置见 本地配置和数据 。复制配置到其他计算机前,检查其中的绝对路径、可执行文件位置和认证信息。 修改与生效 模型页通过保存模型或保存修改提交;个性化文本和 MCP JSON 等控件会在离开编辑区时保存,并显示保存状态。TUI 设置中心的 General、Appearance 开关通常即时保存,配置与模型表单按底部提示使用 Ctrl+S 。 不同配置有不同生效边界。任务模型和权限使用专门的切换入口;后台连接与运行服务以界面的应用结果为准。出现“重启 daemon 后生效”时,保存正在进行的工作后重启。手动改动任意 JSON 文件,并不等于所有运行中的模块都已经重新加载。 配置片段需要合并 文档中的 JSON 示例只展示相关字段。请合并到现有对象中,不要用片段替换整份配置,否则可能丢失已经保存的模型和其他设置。 TUI 问答配置 TUI 中的 AskUserQuestion 会在选项较多或说明较长时使用可滚动内容区。以下字段位于配置文件的 tui 对象中: 字段 默认值 有效范围 说明 question_min_visible_rows 4 2–12 AskUserQuestion 内容区的最小可见行数。内容超出视口后,可使用鼠标滚轮或滚动条查看。 question_selection_feedback_ms 200 0–1000 预设选项提交后保留选中视觉反馈的时长,单位为毫秒。设置为 0 可关闭反馈延迟。 例如: { \"tui\": { \"question_min_visible_rows\": 4, \"question_selection_feedback_ms\": 200 } } 这两个字段会在读取配置时限制在有效范围内。超出范围的整数会自动限制到边界,并记录警告;非整数值会被忽略并继续使用默认值。省略字段时使用默认值,配置保存采用稀疏写入,不会强制写出默认值。 AskUserQuestion 跨端配置 TUI 与 Web/Desktop 使用同一个 AskUserQuestion 工具,题目数量和单个问题的选项数量上限由 ask 对象统一控制,对所有运行端生效。以下字段位于配置文件的 ask 对象中: 字段 默认值 有效范围 说明 max_questions 10 1–50 单次 AskUserQuestion 调用允许的题目数量。 max_options 6 4–8 单个问题允许的选项数量上限。默认 6,可在 4 到 8 之间调整。 例如: { \"ask\": { \"max_questions\": 10, \"max_options\": 6 } } 超出有效范围的整数会自动限制到边界,并记录警告;非整数值会被忽略并继续使用默认值。省略字段时使用默认值,配置保存采用稀疏写入,不会强制写出默认值。 手动编辑与错误恢复 先备份当前有效配置,使用支持 UTF-8 的编辑器打开。 只修改目标字段,保持正确的 JSON 类型,不加入注释或尾随逗号。 重新加载相关功能,或按该功能要求重启。 检查界面实际值和一次小操作,确认修改已生效。 当前版本会保存有效配置快照。配置损坏且存在有效快照时,会备份错误文件并尝试自动恢复;Web/Desktop 会显示一次 配置已自动回滚 提示。没有可用快照时仍会报告配置错误。按提示查看备份位置并修复目标字段,备份可能含密钥,不要直接公开。"
},
{
"page": "配置文件与生效范围",
@@ -584,6 +584,22 @@ window.ACECODE_HELP_INDEX = [
"description": "修改与生效 模型页通过保存模型或保存修改提交;个性化文本和 MCP JSON 等控件会在离开编辑区时保存,并显示保存状态。TUI 设置中心的 General、Appearance 开关通常即时保存,配置与模型表单按底部提示使用 Ctrl+S 。 不同",
"text": "修改与生效 模型页通过保存模型或保存修改提交;个性化文本和 MCP JSON 等控件会在离开编辑区时保存,并显示保存状态。TUI 设置中心的 General、Appearance 开关通常即时保存,配置与模型表单按底部提示使用 Ctrl+S 。 不同配置有不同生效边界。任务模型和权限使用专门的切换入口;后台连接与运行服务以界面的应用结果为准。出现“重启 daemon 后生效”时,保存正在进行的工作后重启。手动改动任意 JSON 文件,并不等于所有运行中的模块都已经重新加载。 配置片段需要合并 文档中的 JSON 示例只展示相关字段。请合并到现有对象中,不要用片段替换整份配置,否则可能丢失已经保存的模型和其他设置。"
},
+ {
+ "page": "配置文件与生效范围",
+ "group": "配置与扩展",
+ "title": "TUI 问答配置",
+ "url": "configuration.html#tui-ask-user-question",
+ "description": "TUI 问答配置 TUI 中的 AskUserQuestion 会在选项较多或说明较长时使用可滚动内容区。以下字段位于配置文件的 tui 对象中: 字段 默认值 有效范围 说明 question_min_visible_rows 4 2–12 Ask",
+ "text": "TUI 问答配置 TUI 中的 AskUserQuestion 会在选项较多或说明较长时使用可滚动内容区。以下字段位于配置文件的 tui 对象中: 字段 默认值 有效范围 说明 question_min_visible_rows 4 2–12 AskUserQuestion 内容区的最小可见行数。内容超出视口后,可使用鼠标滚轮或滚动条查看。 question_selection_feedback_ms 200 0–1000 预设选项提交后保留选中视觉反馈的时长,单位为毫秒。设置为 0 可关闭反馈延迟。 例如: { \"tui\": { \"question_min_visible_rows\": 4, \"question_selection_feedback_ms\": 200 } } 这两个字段会在读取配置时限制在有效范围内。超出范围的整数会自动限制到边界,并记录警告;非整数值会被忽略并继续使用默认值。省略字段时使用默认值,配置保存采用稀疏写入,不会强制写出默认值。"
+ },
+ {
+ "page": "配置文件与生效范围",
+ "group": "配置与扩展",
+ "title": "AskUserQuestion 跨端配置",
+ "url": "configuration.html#ask-user-question",
+ "description": "AskUserQuestion 跨端配置 TUI 与 Web/Desktop 使用同一个 AskUserQuestion 工具,题目数量和单个问题的选项数量上限由 ask 对象统一控制,对所有运行端生效。以下字段位于配置文件的 ask 对象中: 字段",
+ "text": "AskUserQuestion 跨端配置 TUI 与 Web/Desktop 使用同一个 AskUserQuestion 工具,题目数量和单个问题的选项数量上限由 ask 对象统一控制,对所有运行端生效。以下字段位于配置文件的 ask 对象中: 字段 默认值 有效范围 说明 max_questions 10 1–50 单次 AskUserQuestion 调用允许的题目数量。 max_options 6 4–8 单个问题允许的选项数量上限。默认 6,可在 4 到 8 之间调整。 例如: { \"ask\": { \"max_questions\": 10, \"max_options\": 6 } } 超出有效范围的整数会自动限制到边界,并记录警告;非整数值会被忽略并继续使用默认值。省略字段时使用默认值,配置保存采用稀疏写入,不会强制写出默认值。"
+ },
{
"page": "配置文件与生效范围",
"group": "配置与扩展",
@@ -1374,15 +1390,15 @@ window.ACECODE_HELP_INDEX = [
"title": "速查手册",
"url": "reference.html",
"description": "按使用入口查找常用快捷键、命令与数据位置。具体可用项以当前安装的菜单和 /help 为准。",
- "text": "快捷键 入口 按键 作用 桌面 / Web 输入框 Enter 空闲时发送;运行时按当前状态排队。 桌面 / Web 输入框 Shift+Enter 换行。 桌面文件编辑器 Ctrl+S 保存当前可编辑文件。 桌面终端停靠区 Ctrl+` 显示或隐藏底部终端。 TUI Esc 等待时中断、退出 Shell 模式或拒绝当前确认。 TUI Ctrl+C 第一次清空输入并提示,1 秒内再次按下退出。 TUI Ctrl+O 全局展开或折叠工具结果。 TUI Ctrl+E 聚焦工具时切换该结果展开;否则移动到输入末尾。 TUI Ctrl+P 空闲普通输入状态切换权限;菜单或历史状态按对应导航处理。 @ 候选菜单 ↑ / ↓ 、 Enter 选择并引用文件或文件夹。 @ 候选菜单 Tab / → 进入选中的文件夹。 按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档自身的 Ctrl+K 搜索快捷键不代表 ACECode 产品的同名操作。 斜杠命令 命令 用途与范围 /help 查看当前注册的可用命令。 /model 模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。 /mode 查看或切换权限模式。 /config 打开设置;/config show 查看文本概要。 /tokens 查看会话 token 用量。 /compact 调用模型压缩当前上下文。 /init 分析项目并生成或改进项目规则。 /resume TUI 恢复历史会话。 /rewind 、 /checkpoint TUI 打开回退选择器;代码恢复取决于检查点可用性。 /fork TUI 从用户回合选择分叉,按选择器确认范围。 /archive 归档当前会话并开始新的对话状态。 /clear 、 /new 清理当前对话状态,开始新一段工作。 /side 问题 、 /btw 问题 独立的一次旁支问答,不调用工具。 /turn 补充说明 中断当前轮并发送指导;与排队卡片的插话时机不同。 /skills 、 /mcp 查看或管理技能与 MCP;带参数执行相应子命令。 /tools 、 /hooks 、 /connectors 打开对应能力管理入口。 /lsp 查看语言服务器状态。 /memory 列出、查看、编辑、遗忘或重载持久记忆。 /tasks TUI 管理后台子任务。 /proxy 检查有效代理及临时覆盖状态。 /rc 、 /remote-control 激活已配置的默认渠道;show 查看,off 解绑。 /exit 退出 TUI。 不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。 命令行参数 命令或参数 用途 acecode 在当前目录打开交互式 TUI。 acecode configure 初次配置或重新配置。 acecode --version 显示版本。 acecode -r 打开 TUI 历史会话选择。 acecode --resume [ID] TUI 恢复最近会话或指定会话;print 模式必须带 ID。 acecode -p \"提示\" 执行无界面单轮任务。 --output-format text|json|stream-json 选择 print 模式输出格式。 --model NAME 选择已保存预设。 --permission-mode MODE print 模式支持 default、accept-edits、plan、yolo。 --max-turns N print 模式的智能体循环上限。 --session-id ID 指定新会话 ID。 -c 、 --continue print 模式继续当前目录最近会话。 --list-tools 、 --list-skills 、 --list-mcp 只列出可发现能力。 --enable-skills 名称 、 --enable-mcp 名称 为 print 模式显式开启扩展。 --disable-tools 名称 为 print 模式禁用指定工具。 acecode daemon [start|status|stop] 独立后台管理。 acecode daemon --foreground 前台运行后台服务。 acecode daemon --cwd=路径 --port=端口 指定独立后台的起始工作目录与端口。 acecode upgrade 按已配置更新源执行自更新。 完整 print 参数运行 acecode -p --help 。危险启动参数 --yolo / --dangerous 会绕过权限与路径检查,不作为排错默认步骤。查看脚本示例及退出码,参见 CLI 。 本地配置和数据 下面的路径相对于个人数据目录 ~/.acecode/ ;Windows 为 %USERPROFILE%/.acecode/ 。Windows 服务另用 %PROGRAMDATA%/acecode/ ,远端进程使用远端身份的目录。 路径 内容 config.json 全局配置、模型连接与扩展定义,可能包含凭据。 config-backups/ 有效配置快照和损坏配置备份,也可能含凭据。 projects/ 按项目范围保存的会话、元数据和输入历史等记录。 skills/ 用户技能;还可读取其他已启用根目录。 memory/ MEMORY.md 索引与记忆条目。 hooks.json 用户级 Hooks 定义。 logs/ 按运行方式与日期保存的日志。 run/ 独立后台的 PID、端口、GUID、心跳和 Token。 run/desktop-shared/ 桌面托管后台的独立运行身份。 项目规则和项目技能可能保存在仓库本身。TUI 的 acecode.log 位于启动工作目录,不在上述 logs 目录。Git 工作树路径以任务实际记录为准。 备份前先结束相关写入,并同时考虑会话数据与实际项目文件。不要只备份 Markdown 导出就假定能够完整恢复,也不要在运行时随意删除身份文件或数据库。卸载与保留数据见 更新与卸载 。"
+ "text": "快捷键 入口 按键 作用 桌面 / Web Ctrl+K (macOS ⌘K ) 打开或关闭全局搜索;输入关键词可同时查找任务、项目和设置项,选中设置项会直接打开设置并定位到该项。 桌面 / Web 输入框 Enter 空闲时发送;运行时按当前状态排队。 桌面 / Web 输入框 Shift+Enter 换行。 桌面文件编辑器 Ctrl+S 保存当前可编辑文件。 桌面终端停靠区 Ctrl+` 显示或隐藏底部终端。 TUI Esc 等待时中断、退出 Shell 模式或拒绝当前确认。 TUI Ctrl+C 第一次清空输入并提示,1 秒内再次按下退出。 TUI Ctrl+O 全局展开或折叠工具结果。 TUI Ctrl+E 聚焦工具时切换该结果展开;否则移动到输入末尾。 TUI Ctrl+P 空闲普通输入状态切换权限;菜单或历史状态按对应导航处理。 @ 候选菜单 ↑ / ↓ 、 Enter 选择并引用文件或文件夹。 @ 候选菜单 Tab / → 进入选中的文件夹。 按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档站自己的 Ctrl+K 只搜索文档内容,与 ACECode 产品内的 Ctrl+K 全局搜索是两个独立功能。 斜杠命令 命令 用途与范围 /help 查看当前注册的可用命令。 /model 模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。 /mode 查看或切换权限模式。 /config 打开设置;/config show 查看文本概要。 /tokens 查看会话 token 用量。 /compact 调用模型压缩当前上下文。 /init 分析项目并生成或改进项目规则。 /resume TUI 恢复历史会话。 /rewind 、 /checkpoint TUI 打开回退选择器;代码恢复取决于检查点可用性。 /fork TUI 从用户回合选择分叉,按选择器确认范围。 /archive 归档当前会话并开始新的对话状态。 /clear 、 /new 清理当前对话状态,开始新一段工作。 /side 问题 、 /btw 问题 独立的一次旁支问答,不调用工具。 /turn 补充说明 中断当前轮并发送指导;与排队卡片的插话时机不同。 /skills 、 /mcp 查看或管理技能与 MCP;带参数执行相应子命令。 /tools 、 /hooks 、 /connectors 打开对应能力管理入口。 /lsp 查看语言服务器状态。 /memory 列出、查看、编辑、遗忘或重载持久记忆。 /tasks TUI 管理后台子任务。 /proxy 检查有效代理及临时覆盖状态。 /rc 、 /remote-control 激活已配置的默认渠道;show 查看,off 解绑。 /exit 退出 TUI。 不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。 命令行参数 命令或参数 用途 acecode 在当前目录打开交互式 TUI。 acecode configure 初次配置或重新配置。 acecode --version 显示版本。 acecode -r 打开 TUI 历史会话选择。 acecode --resume [ID] TUI 恢复最近会话或指定会话;print 模式必须带 ID。 acecode -p \"提示\" 执行无界面单轮任务。 --output-format text|json|stream-json 选择 print 模式输出格式。 --model NAME 选择已保存预设。 --permission-mode MODE print 模式支持 default、accept-edits、plan、yolo。 --max-turns N print 模式的智能体循环上限。 --session-id ID 指定新会话 ID。 -c 、 --continue print 模式继续当前目录最近会话。 --list-tools 、 --list-skills 、 --list-mcp 只列出可发现能力。 --enable-skills 名称 、 --enable-mcp 名称 为 print 模式显式开启扩展。 --disable-tools 名称 为 print 模式禁用指定工具。 acecode daemon [start|status|stop] 独立后台管理。 acecode daemon --foreground 前台运行后台服务。 acecode daemon --cwd=路径 --port=端口 指定独立后台的起始工作目录与端口。 acecode upgrade 按已配置更新源执行自更新。 完整 print 参数运行 acecode -p --help 。危险启动参数 --yolo / --dangerous 会绕过权限与路径检查,不作为排错默认步骤。查看脚本示例及退出码,参见 CLI 。 本地配置和数据 下面的路径相对于个人数据目录 ~/.acecode/ ;Windows 为 %USERPROFILE%/.acecode/ 。Windows 服务另用 %PROGRAMDATA%/acecode/ ,远端进程使用远端身份的目录。 路径 内容 config.json 全局配置、模型连接与扩展定义,可能包含凭据。 config-backups/ 有效配置快照和损坏配置备份,也可能含凭据。 projects/ 按项目范围保存的会话、元数据和输入历史等记录。 skills/ 用户技能;还可读取其他已启用根目录。 memory/ MEMORY.md 索引与记忆条目。 hooks.json 用户级 Hooks 定义。 logs/ 按运行方式与日期保存的日志。 run/ 独立后台的 PID、端口、GUID、心跳和 Token。 run/desktop-shared/ 桌面托管后台的独立运行身份。 项目规则和项目技能可能保存在仓库本身。TUI 的 acecode.log 位于启动工作目录,不在上述 logs 目录。Git 工作树路径以任务实际记录为准。 备份前先结束相关写入,并同时考虑会话数据与实际项目文件。不要只备份 Markdown 导出就假定能够完整恢复,也不要在运行时随意删除身份文件或数据库。卸载与保留数据见 更新与卸载 。"
},
{
"page": "速查手册",
"group": "帮助与排错",
"title": "快捷键",
"url": "reference.html#shortcuts",
- "description": "快捷键 入口 按键 作用 桌面 / Web 输入框 Enter 空闲时发送;运行时按当前状态排队。 桌面 / Web 输入框 Shift+Enter 换行。 桌面文件编辑器 Ctrl+S 保存当前可编辑文件。 桌面终端停靠区 Ctrl+` 显示或隐藏",
- "text": "快捷键 入口 按键 作用 桌面 / Web 输入框 Enter 空闲时发送;运行时按当前状态排队。 桌面 / Web 输入框 Shift+Enter 换行。 桌面文件编辑器 Ctrl+S 保存当前可编辑文件。 桌面终端停靠区 Ctrl+` 显示或隐藏底部终端。 TUI Esc 等待时中断、退出 Shell 模式或拒绝当前确认。 TUI Ctrl+C 第一次清空输入并提示,1 秒内再次按下退出。 TUI Ctrl+O 全局展开或折叠工具结果。 TUI Ctrl+E 聚焦工具时切换该结果展开;否则移动到输入末尾。 TUI Ctrl+P 空闲普通输入状态切换权限;菜单或历史状态按对应导航处理。 @ 候选菜单 ↑ / ↓ 、 Enter 选择并引用文件或文件夹。 @ 候选菜单 Tab / → 进入选中的文件夹。 按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档自身的 Ctrl+K 搜索快捷键不代表 ACECode 产品的同名操作。"
+ "description": "快捷键 入口 按键 作用 桌面 / Web Ctrl+K (macOS ⌘K ) 打开或关闭全局搜索;输入关键词可同时查找任务、项目和设置项,选中设置项会直接打开设置并定位到该项。 桌面 / Web 输入框 Enter 空闲时发送;运行时按当前状态排",
+ "text": "快捷键 入口 按键 作用 桌面 / Web Ctrl+K (macOS ⌘K ) 打开或关闭全局搜索;输入关键词可同时查找任务、项目和设置项,选中设置项会直接打开设置并定位到该项。 桌面 / Web 输入框 Enter 空闲时发送;运行时按当前状态排队。 桌面 / Web 输入框 Shift+Enter 换行。 桌面文件编辑器 Ctrl+S 保存当前可编辑文件。 桌面终端停靠区 Ctrl+` 显示或隐藏底部终端。 TUI Esc 等待时中断、退出 Shell 模式或拒绝当前确认。 TUI Ctrl+C 第一次清空输入并提示,1 秒内再次按下退出。 TUI Ctrl+O 全局展开或折叠工具结果。 TUI Ctrl+E 聚焦工具时切换该结果展开;否则移动到输入末尾。 TUI Ctrl+P 空闲普通输入状态切换权限;菜单或历史状态按对应导航处理。 @ 候选菜单 ↑ / ↓ 、 Enter 选择并引用文件或文件夹。 @ 候选菜单 Tab / → 进入选中的文件夹。 按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档站自己的 Ctrl+K 只搜索文档内容,与 ACECode 产品内的 Ctrl+K 全局搜索是两个独立功能。"
},
{
"page": "速查手册",
diff --git a/docs/help/configuration.html b/docs/help/configuration.html
index 52ad447ec..78ee81dac 100644
--- a/docs/help/configuration.html
+++ b/docs/help/configuration.html
@@ -42,13 +42,17 @@
TUI 问答配置
TUI 中的 AskUserQuestion 会在选项较多或说明较长时使用可滚动内容区。以下字段位于配置文件的 tui 对象中:
例如:
{
+TUI 问答配置
TUI 中的 AskUserQuestion 会在选项较多或说明较长时使用可滚动内容区。以下字段位于配置文件的 tui 对象中:
+
+例如:
{
"tui": {
"question_min_visible_rows": 4,
"question_selection_feedback_ms": 200
}
}
这两个字段会在读取配置时限制在有效范围内。超出范围的整数会自动限制到边界,并记录警告;非整数值会被忽略并继续使用默认值。省略字段时使用默认值,配置保存采用稀疏写入,不会强制写出默认值。
-AskUserQuestion 跨端配置
TUI 与 Web/Desktop 使用同一个 AskUserQuestion 工具,题目数量和单个问题的选项数量上限由 ask 对象统一控制,对所有运行端生效。以下字段位于配置文件的 ask 对象中:
例如:
{
+AskUserQuestion 跨端配置
TUI 与 Web/Desktop 使用同一个 AskUserQuestion 工具,题目数量和单个问题的选项数量上限由 ask 对象统一控制,对所有运行端生效。以下字段位于配置文件的 ask 对象中:
+
+例如:
{
"ask": {
"max_questions": 10,
"max_options": 6
diff --git a/docs/help/installation.html b/docs/help/installation.html
index fd08973a2..d1dc2c278 100644
--- a/docs/help/installation.html
+++ b/docs/help/installation.html
@@ -66,7 +66,7 @@ 终端版
使用 Linux 桌面端
桌面端需要 WebKitGTK 4.1。采用 apt 的发行版可安装对应运行库,然后在程序目录中启动:
sudo apt install libwebkit2gtk-4.1-0
./acecode-desktop
-
其他发行版请使用对应的软件包管理器安装 WebKitGTK 4.1。较旧的 Linux 系统若出现 glibc 版本不满足的问题,请查看发布页中的 acecode-linux-old-* 兼容包。
+
其他发行版请使用对应的软件包管理器安装 WebKitGTK 4.1。UOS / Deepin 用户请下载 acecode-linux-deepin-* 专用包;桌面端使用系统 WebKitGTK 4.0 和 DTK 5,终端 ARMv7 包不包含桌面端。
通过浏览器使用
安装终端版后,也可以启动后台服务。npm 安装时执行:
未覆盖端口配置时,在本机浏览器打开 http://127.0.0.1:12399。便携版请在程序目录执行 ./acecode daemon。配置了端口或认证时,按服务启动信息访问。
diff --git a/docs/help/reference.html b/docs/help/reference.html
index ff0318902..f244a4f25 100644
--- a/docs/help/reference.html
+++ b/docs/help/reference.html
@@ -37,8 +37,8 @@
速查手册
按使用入口查找常用快捷键、命令与数据位置。具体可用项以当前安装的菜单和 /help 为准。
本页内容
-
快捷键
-按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档自身的 Ctrl+K 搜索快捷键不代表 ACECode 产品的同名操作。
+
快捷键
+按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档站自己的 Ctrl+K 只搜索文档内容,与 ACECode 产品内的 Ctrl+K 全局搜索是两个独立功能。
斜杠命令
不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。
命令行参数
diff --git a/docs/help/workspaces.html b/docs/help/workspaces.html
index 1ee39e3b8..b3e96451e 100644
--- a/docs/help/workspaces.html
+++ b/docs/help/workspaces.html
@@ -40,7 +40,7 @@
添加与管理工作区
在侧边栏工作区旁点击添加入口,选择本地项目目录。项目显示在列表中后,点击其新建任务按钮即可在该目录开始工作。一个工作区可以保存多个任务。
工作区菜单支持重命名项目、复制项目路径和从项目列表移除。移除列表项不会删除磁盘上的项目文件。远程 Web 的工作区路径属于后台计算机,不能填写另一台设备才有的目录。
侧边栏的工作区分组列出各项目及其任务。右键项目名打开菜单,提供切换到项目、折叠项目、新建会话、重命名项目、复制项目路径、打开文件夹和从项目列表移除;移除项以红色显示,只从列表移除,不删除磁盘上的项目文件。Windows 实际界面 · 点击图片查看原图
脱离工作区的任务
在新建任务时选择不使用工作区,适合通用问答、文字处理或先讨论方案。此类记录集中在侧边栏任务分组,仍可置顶和归档。
没有工作区时,不会自动获得某个项目的文件树和 Git 上下文。桌面端可以显式引用文件或目录,路径在没有项目根目录时通常显示为绝对路径。需要持续修改某个代码库时,新建对应工作区中的任务更容易确认操作范围。
-恢复、搜索、置顶与归档
- 恢复:点击侧边栏中的任务,阅读最近结果后继续发送;TUI 使用
/resume 或启动参数。 - 搜索:点击搜索任务,按任务标题或消息线索查找,选择结果回到相应记录。
- 置顶:使用任务旁的置顶按钮或右键菜单;不使用工作区的任务也可以置顶。
- 归档:将暂时不用的任务从日常列表收起。到设置中的已归档会话查找并恢复。
归档保留记录。已归档页面中的彻底删除会移除本地会话数据,操作前先核对对象;长期保存或分享阅读内容可先导出。
+恢复、搜索、置顶与归档
- 恢复:点击侧边栏中的任务,阅读最近结果后继续发送;TUI 使用
/resume 或启动参数。 - 搜索:点击搜索任务或按 Ctrl+K(macOS ⌘K),按任务标题或消息线索查找,选择结果回到相应记录;同一个搜索框也能查找项目和设置项,选中设置项会打开设置并定位到该项。
- 置顶:使用任务旁的置顶按钮或右键菜单;不使用工作区的任务也可以置顶。
- 归档:将暂时不用的任务从日常列表收起。到设置中的已归档会话查找并恢复。
归档保留记录。已归档页面中的彻底删除会移除本地会话数据,操作前先核对对象;长期保存或分享阅读内容可先导出。
分叉、导入与导出会话
在支持的消息右键菜单选择从这里分叉,会复制截至该消息的对话前缀并打开新任务。新任务不会自动开始执行;输入下一条消息后才继续。原任务保留,磁盘文件不会因为分叉回到过去。
桌面端任务菜单中的导出会保存可见对话为 Markdown。导出文件适合阅读和分享,不等于可直接导入恢复的完整任务备份。
项目菜单提供从opencode导入会话:先预览识别出的记录,勾选需要的会话,再启动导入并查看结果。导入入口针对识别到的 OpenCode 历史,不能把任意 Markdown 文档当作会话包导入。
第一张:右键任务中的某条消息,菜单提供复制消息、从这里分叉和全选。第二张:项目菜单中的从opencode导入会话,只在该项目目录识别到 OpenCode 历史时出现,其他项目的菜单没有这一项。Windows 实际界面 · 红色标注为文档说明添加 · 点击图片查看原图
diff --git a/docs/macos-release.md b/docs/macos-release.md
index 4e71a3afc..da9f1949a 100644
--- a/docs/macos-release.md
+++ b/docs/macos-release.md
@@ -1,7 +1,8 @@
-# macOS PKG Release Guide
+# macOS Installer And PKG Release Guide
ACECode's `package` GitHub Actions workflow builds separate Intel
-(`macos-x64`) and Apple silicon (`macos-arm64`) self-update ZIPs and archives.
+(`macos-x64`) and Apple silicon (`macos-arm64`) installer DMGs, self-update ZIPs,
+and archives.
When the separate Developer ID Installer credentials are configured, it also
builds PKG installers for both architectures. Tagged releases always fail
instead of publishing an unsigned or unnotarized macOS application artifact;
@@ -9,6 +10,18 @@ when only the Installer credentials are absent, they publish no PKG at all.
## What Users Install
+Download `ACECode--macos-.dmg`, open it, and launch
+**ACECode Installer.app**. The v6 native installer offers personal installation
+(normally `~/Applications`, falling back to `~/Apps`), system `/Applications`,
+and a custom writable folder. System/custom installation uses the current
+account's actual permissions; it never requests elevation. Quit the destination
+ACECode before replacement. Replacement requires confirmation, stages and
+validates a copy, and attempts rollback on failure without deleting user data.
+If rollback or old-file cleanup fails, the installer reports the retained path.
+The graphical installer and in-app updater share `.ACECode.update.lock`, so
+they cannot replace the same installation concurrently. Both reject unsafe lock
+files without blocking or changing the installed application.
+
When the Release contains PKGs, download the one matching the Mac architecture,
double-click it, and confirm the normal steps in Apple's Installer application.
The package offers only the current-user installation domain, so Installer
@@ -35,15 +48,14 @@ same user-level application component. Users can uninstall ACECode by quitting
it and deleting `~/Applications/ACECode.app`; no privileged uninstaller is
required. Installer receipts may remain in the user's receipt database.
-ACECode no longer builds or publishes a DMG. There is no fake Applications
-folder and no Finder drag-install path. A Release without PKGs still provides
-signed and notarized macOS archives and trusted self-update ZIPs; existing
-installations can update normally, while first-time graphical installation is
-not represented as an unsigned substitute installer.
+The DMG contains only the signed, notarized, stapled native installer. There is
+no fake Applications folder or Finder drag-install target. PKGs remain an
+optional alternative; absence of Developer ID Installer credentials does not
+disable DMGs, which use the Developer ID Application identity.
## Self-Update Trust And Replacement
-The graphical PKG is distinct from the self-update archive. The update service
+The graphical DMG and PKG are distinct from the self-update archive. The update service
continues using `ACECode--macos--update.zip`, `aceupdate.json`,
package size, and SHA-256. Native preflight additionally enforces:
@@ -52,21 +64,33 @@ package size, and SHA-256. Native preflight additionally enforces:
- Strict Apple signatures for the app and nested code.
- Bundle identifier `dev.acecode.desktop`, the manifest version, and the same
non-empty Apple Developer Team ID as the installed copy.
-- An exact, non-symlinked `~/Applications/ACECode.app` or legacy
- `/Applications/ACECode.app` destination.
+- An absolute, canonical, real `ACECode.app` destination with a writable parent,
+ outside App Translocation and other app bundles. Custom directories are
+ supported alongside `~/Applications` and `/Applications`.
-The updater stages a hidden sibling, validates it, retains one previous bundle,
+The updater stages a hidden sibling, validates it, retains a uniquely named previous bundle,
and rolls back when replacement validation fails. New PKG installations always
use `~/Applications`; existing `/Applications` installations remain supported
but are neither relocated nor deleted automatically.
+Custom installer locations (including the `~/Apps` fallback) update in place
+without elevation when the current account has permission. Unwritable locations
+require a manual installation or moving the app to a writable folder. Retained
+`.ACECode-.previous.app` backups require manual cleanup when no longer
+needed; the updater does not delete unrelated or existing backups.
+
+Older ACECode versions with the two-directory restriction need one manual
+installation of the new version before custom-location self-update is available.
+The new installer cannot change the updater policy inside an old binary.
+
## One-Time Apple And GitHub Setup
### Export both Developer ID identities
-The release needs two different identities with their private keys:
+The full release can use two different identities with their private keys:
-- `Developer ID Application` signs ACECode executables and `ACECode.app`.
+- `Developer ID Application` signs ACECode executables, `ACECode.app`, the
+ hardened-runtime installer wrapper, and the DMG.
- `Developer ID Installer` signs the outer `.pkg` product archive.
Export each identity from Keychain Access as its own password-protected `.p12`.
@@ -135,12 +159,13 @@ no effect while npm publication is disabled.
- With all seven macOS secrets configured, the app and two architecture PKGs
are signed, notarized, stapled, Gatekeeper-checked, and uploaded alongside
- trusted update ZIPs.
+ trusted update ZIPs and two installer DMGs.
- With complete Application/notarization secrets but neither Installer secret,
the app is still signed, notarized, and stapled and the workflow uploads
- trusted update ZIPs and archives, but creates no PKG artifact.
+ trusted update ZIPs, installer DMGs, and archives, but creates no PKG artifact.
- Without complete Application/notarization credentials, a manual run may emit
a clearly suffixed `-update-unsigned.zip` for structural inspection only.
+ No installer DMG is built or uploaded in this mode.
- A `v*` tag never permits unsigned fallback, and a partial Installer pair is
always rejected.
@@ -149,6 +174,10 @@ non-admin account before tagging. Confirm that Installer does not show an
administrator authentication sheet and that the result exists only at
`~/Applications/ACECode.app`. When PKGs are intentionally omitted, confirm the
dry run has no PKG artifact and that both trusted update ZIPs completed.
+For each architecture, also test the downloaded, quarantined DMG on a clean
+standard account: personal install, denied system location, writable custom
+location, running-app refusal, replacement confirmation, launch, and reveal.
+Test on macOS 11 as well as a current OS before claiming runtime compatibility.
## Tagged Release
@@ -162,7 +191,10 @@ git push origin v0.8.16
```
After all platform builds succeed, GitHub creates a Release containing matching
-self-update ZIPs, platform archives, debug symbols, and `SHA256SUMS.txt`. When
+self-update ZIPs, platform archives, debug symbols, and `SHA256SUMS.txt`. It must
+contain exactly `ACECode--macos-x64.dmg` and
+`ACECode--macos-arm64.dmg`; missing, extra, duplicate, misversioned, or
+unsigned-suffixed DMGs fail release collection. When
Installer credentials are available it also contains exactly the x64 and arm64
PKGs; otherwise it contains zero PKGs. Do not reuse a failed public tag; fix the
cause, increment the version, and create a new tag.
@@ -170,7 +202,7 @@ cause, increment the version, and create a new tag.
## Publish To The Update Service
Upload only the final `*-update.zip` files beside `aceupdate.json`. Do not point
-update records at the PKG, npm archive, or an unsigned dry-run ZIP. Calculate
+update records at a DMG, PKG, npm archive, or an unsigned dry-run ZIP. Calculate
the exact digest and size from uploaded files:
```bash
@@ -182,6 +214,77 @@ Publish ZIPs before changing the manifest so clients never observe a release
whose package is missing. Bundled-app updates also pin the Developer ID Team;
standalone CLI updates retain the manifest, size, and checksum trust contract.
+Use `python3 scripts/select_macos_update_assets.py release-assets ` to
+select the exact pair of macOS updater ZIPs. Release collection uses this same
+fail-closed selector; DMGs and PKGs cannot become updater assets through broad
+architecture-name matching. The workflow does not publish `aceupdate.json` or
+upload files to the update service automatically.
+
+## Native Installer Production Pipeline
+
+The repository owns the v6 sources in
+[`packaging/macos-installer`](../packaging/macos-installer). The Swift UI, engine,
+and standalone tests are maintained with the updater's shared lock contract;
+the build script is adapted for production. No local release binaries or
+prebuilt payloads are used.
+
+After the existing `build/ACECode.app` signing, notarization, and stapling step,
+CI invokes [`macos_create_installer_dmg.sh`](../scripts/macos_create_installer_dmg.sh)
+with the explicit matrix `x64` or `arm64` architecture, imported Application
+certificate fingerprint, and temporary keychain. It:
+
+1. Verifies the payload's architecture, bundle ID, Developer ID signature,
+ pinned team `T52GZCH73Y`, stapled ticket, and Gatekeeper trust.
+2. Compiles the wrapper with an explicit `x86_64-apple-macosx11.0` or
+ `arm64-apple-macosx11.0` target and `LSMinimumSystemVersion=11.0`.
+ Versions and icon come from the exact payload. This does not lower the
+ embedded ACECode application's own minimum OS requirement.
+3. Copies the payload with `ditto`, signs only the wrapper with hardened runtime
+ and timestamp (never `codesign --deep` signing), and checks payload equality.
+4. Submits the wrapper ZIP, requires `Accepted`, staples and validates the
+ installer, then creates a compressed HFS+ DMG containing that installer only.
+ A transient `hdiutil` `Resource busy` error is retried at most twice with
+ separate image paths; other errors stop immediately.
+5. Signs the DMG with the same Application identity, notarizes/staples it,
+ validates its signature/ticket and Gatekeeper assessment, and publishes the
+ output only after all checks succeed. Rejected submissions fetch the notary
+ log when available. Existing output paths are refused, not overwritten.
+
+Both stages reuse `APPLE_ID`, `APPLE_TEAM_ID`, and
+`APPLE_APP_SPECIFIC_PASSWORD`, or `--keychain-profile` / `NOTARYTOOL_PROFILE`
+locally. No additional certificate is required. All DMG build/upload steps use
+`macos-release.enabled`; only PKG steps use `pkg_enabled`. Signing-material
+cleanup remains an `always()` step after all packaging operations.
+CI creates, notarizes, validates, and uploads the PKG before building the DMG,
+so a later DMG failure leaves the verified PKG available as a workflow artifact.
+Tagged releases still require the complete successful packaging matrix.
+
+After preparing a trusted payload, an authorized release operator can run:
+
+```bash
+bash scripts/macos_create_installer_dmg.sh \
+ --app build/ACECode.app --arch arm64 \
+ --output dist/ACECode-local-macos-arm64.dmg \
+ --identity "$app_identity" --keychain-profile ACECode-notary
+```
+
+Offline verification (no signing identities or live notarization):
+
+```bash
+bash tests/scripts/macos_installer_test.sh
+python3 tests/scripts/macos_installer_release_test.py
+bash tests/scripts/macos_release_scripts_test.sh
+```
+
+The PR test workflow runs these checks on macOS without signing credentials.
+The first command runs engine fault/rollback and unsafe-lock tests with assertions
+enabled and cross-compiles both UI architectures for macOS 11. It also launches
+a test bundle and captures personal/system installation windows without
+installing an application. The second uses mocked Apple tools to test sequencing,
+architecture rejection, unchanged payloads, rejected
+notarization, release DMG naming/count, and updater asset selection. These do
+not replace live release signing, notarization, or clean-machine smoke tests.
+
## Local Build And PKG Check
Given an already configured macOS build directory:
diff --git a/docs/plan/analysis/agent-loop-decomposition.md b/docs/plan/analysis/agent-loop-decomposition.md
new file mode 100644
index 000000000..b12fbea37
--- /dev/null
+++ b/docs/plan/analysis/agent-loop-decomposition.md
@@ -0,0 +1,162 @@
+# AgentLoop 拆分分析
+
+## 目标
+
+将 `src/agent_loop.cpp` 的多回合运行时职责拆为可定位、可独立验证的实现单元,降低单文件耦合;不改变 `AgentLoop` 的公开 API、会话 JSONL、事件协议、工具权限语义或 provider 请求语义。
+
+本分析的范围是代码组织和运行时内部边界,不引入产品行为。任何行为变更必须另建 OpenSpec change。
+
+## 已核实的现状
+
+- `src/agent_loop.cpp` 目前约 6.7k 行,`src/agent_loop.hpp` 约 1k 行。
+- `AgentLoop` 同时拥有:worker 队列、canonical transcript、turn steering、工作区/安全边界、请求构建、压缩、provider streaming/recovery、工具生命周期、目标运行时、hooks、事件和 side-chat。
+- `CMakeLists.txt` 通过 `file(GLOB_RECURSE ... src/*.cpp)` 收录生产和 `acecode_testable` 源码;新增 `src/agent/*.cpp` 不需要手工修改 source list。
+- `src/loop/` 已是 scheduled-loop 子系统,不能承载 agent turn runtime。
+- 关键回归套件已按职责分布在 `tests/agent_loop/`;拆分应保留测试路径和测试名,避免将「文件移动」伪装为行为变化。
+
+## 目录决策
+
+新增 `src/agent/`,但**第一阶段不移动** `src/agent_loop.hpp`:
+
+```text
+src/
+ agent_loop.hpp // 暂时保留:公开 API、私有状态、组合根
+ agent_loop.cpp // 逐批收敛为构造/公共入口/worker/turn 编排
+ agent/
+ agent_runtime_types.hpp // 仅跨实现单元共享的内部 DTO
+ agent_request_builder.cpp // AgentLoop 成员函数定义
+ agent_compaction.cpp // AgentLoop 成员函数定义
+ agent_tool_runner.cpp // AgentLoop 成员函数定义
+ agent_turn_runtime.cpp // queue、steering、goal、side-chat
+ agent_provider_runner.cpp // streaming、retry、PA rescue、preamble
+ agent_events.cpp // 仅无业务决策的 event/persistence helpers
+ agent_workspace_runtime.cpp // cwd、folders、write root、sandbox/audit
+```
+
+第一阶段的 `.cpp` 均直接包含 `../agent_loop.hpp`,以 `AgentLoop::method` 形式迁移定义。这样仍可访问私有成员,避免为了「拆文件」而引入一个暴露所有状态的 `AgentLoop&` 服务定位器。
+
+当至少三个职责已稳定迁移后,才评估将头文件移动为 `src/agent/agent_loop.hpp` 并在 `src/agent_loop.hpp` 留兼容 forwarding header。该动作会波及大量 include,应是单独任务,不与行为迁移混做。
+
+## 不可破坏的契约
+
+| 契约 | 证据与保护方式 |
+|---|---|
+| prompt-cache 前缀逐字节稳定 | `build_api_request_messages()` 每个模型步骤执行;运行 `agent_loop_termination_test` 与 `system_prompt_test` 的稳定性用例。 |
+| canonical transcript 只有一个 owner | `messages_` 继续仅由 `AgentLoop` 持有和提交给 `SessionManager`;模块不得维护第二份模型历史。 |
+| worker 顺序与锁协议不变 | `queue_mu_`、`active_turn_mu_`、`active_provider_mu_` 的持有顺序和 worker dispatch 保持原样;先搬定义,后抽组件。 |
+| 工具结果先持久化再以替换后的内容发出 | 保留 delivery replacement、attachments、task_complete 的 deferred ToolEnd 顺序。 |
+| 写边界不随 Yolo 失效 | worktree / LOOP / inherited write root 和附加工作区目录继续同时参与校验。 |
+| TUI callbacks 与 daemon events 同步兼容 | 不改变 `AgentCallbacks` 或 `SessionEventKind` payload;所有原有事件顺序由现有测试锁定。 |
+| compact checkpoint 可 resume | 迁移后仍由同一 `SessionManager` 写 checkpoint、replacement history、notice 和 task suggestion。 |
+
+## 状态所有权
+
+```text
+AgentLoop (唯一组合根与状态机 owner)
+ owns canonical state
+ messages_, session_manager_, permissions_, callbacks_, events_
+ busy_, abort_requested_, active_turn_*, worker queues
+
+ owns runtime configuration
+ cwd_, PathValidator, sandbox runtime/rules, workspace folders
+ model/context/prompt caches, tool preamble configuration
+
+ delegates algorithms, never state ownership
+ request builder -> provider request bundle
+ compaction -> compact/repair outcome
+ provider runner -> streamed response / retry classification
+ tool runner -> canonical tool results / terminal outcome
+ goal runtime -> goal accounting and steering decisions
+```
+
+跨模块返回值应是窄 DTO;不得让新组件持有 `AgentLoop&` 后自行修改多份生命周期状态。第一阶段用成员函数移动;第二阶段若抽类,应按下列边界注入 callbacks。
+
+| 边界 | 输入 | 输出 | 禁止持有 |
+|---|---|---|---|
+| 请求构建 | transcript 快照、稳定配置、工具定义 | `ApiRequestBundle` | provider 流、工具执行、队列锁 |
+| 压缩 | transcript、initial context、provider snapshot | `CompactOutcome` / replacement history | 用户提交队列、完整 tool context |
+| provider 执行 | immutable request bundle、stream callbacks、abort flag | `ProviderStepResult` | `messages_` 的写权限 |
+| 工具执行 | tool calls、构造好的 `ToolContext`、lifecycle callbacks | `ToolRunOutcome` | provider retry、worker queue |
+| turn steering | expected turn id、input | receipt / drained input | provider 或 session I/O |
+
+## 迁移批次
+
+### 0. 基线与搬迁规则
+
+先建立本分析和任务链。每一迁移 commit 只允许:移动成员函数定义、最小 include 调整、对应测试补强;不得顺带格式化其他区域或重写事件 payload。
+
+### 1. Request 与 workspace runtime
+
+迁移 `set_cwd`、workspace folders、write root、sandbox/audit 以及 `build_api_request_messages` 的纯构建路径。两者必须分文件,但同一批串行完成,因为 request builder 读取 workspace/sandbox 派生状态。
+
+保留在 `AgentLoop`:配置 setter、缓存字段、最终调用时机。
+
+### 2. Compaction
+
+迁移 context estimate、initial context、checkpoint、auto/manual compact、mechanical fallback 和 PA window observation。`run_compact()` 可以暂留编排层,具体 compact 调用转入文件。
+
+### 3. Tool runner
+
+迁移 `build_tool_context()` 和 `execute_tool_calls()`,包括 read-only 并发、serial writes、permission/sandbox/audit、hooks、streaming progress、attachment materialization、canonical result storage 与 terminal tool 后处理。
+
+这是行数收益最大的一批,但只在 workspace runtime 已稳定后进行,防止复制路径校验。
+
+### 4. Turn runtime
+
+迁移 worker queue、submit/retry/control、turn steering、goal runtime、side-chat。`worker_main()` 可以先留在根文件,只将数据结构和 helper 定义移走;避免第一轮触碰任务调度和锁顺序。
+
+### 5. Provider runner 与 events
+
+迁移 streaming 收集、usage、retry、preamble、provider-error/PA rescue;随后迁移不含业务判断的 event 与 persistence encoding helpers。`run_agent_with_input()` 最终保留为唯一模型步骤编排器。
+
+## 最终组合根
+
+拆分后 `run_agent_with_input()` 只保留:
+
+```text
+prepare turn
+ -> compact if required
+ -> build request
+ -> run provider step
+ -> classify recovery / retry
+ -> persist text reply OR run tools
+ -> apply turn termination / steering
+ -> emit terminal lifecycle
+```
+
+`AgentLoop` 的理想规模是约 1,000–1,500 行,但行数不是验收条件;每批验收标准是行为和协议不变。
+
+## 集成枚举
+
+必须以真实实现验证下列连接,不能只测试新 helper:
+
+1. workspace 更新 -> sandbox writable roots -> request Environment -> tool path validation;
+2. request builder -> context usage estimate -> provider stream Usage -> goal accounting;
+3. provider tool call -> preamble -> assistant tool-call persistence -> ToolStart/ToolEnd -> canonical tool result;
+4. context overflow -> PA observation/repair -> rebuilt request -> transcript checkpoint/replacement;
+5. active-turn steer/interject -> tool question resolution -> tool result -> next provider request;
+6. task_complete / session terminal action -> final ToolEnd -> BusyChanged -> Done -> post-turn action。
+
+## 任务顺序与并行性
+
+所有实现任务都修改 `src/agent_loop.cpp` 或 `src/agent_loop.hpp`,路径重叠且有严格状态依赖,因此必须串行。禁止为了并行创建会产生冲突的 worktree 分支。
+
+| 顺序 | 任务 | 前置 |
+|---|---|---|
+| 1 | `agent-loop-split-001`:workspace/request 定义迁移 | 无 |
+| 2 | `agent-loop-split-002`:compaction 定义迁移 | 001 |
+| 3 | `agent-loop-split-003`:tool runner 定义迁移 | 001、002 |
+| 4 | `agent-loop-split-004`:queue/steering/goal/side-chat 迁移 | 003 |
+| 5 | `agent-loop-split-005`:provider/events 与编排收敛 | 004 |
+
+## 验证矩阵
+
+| 批次 | 最小回归 |
+|---|---|
+| 001 | `agent_loop_metadata_injection_test`、`agent_loop_plan_mode_test`、`agent_loop_workspace_folders_test`、`shell_write_guard_test`、请求前缀稳定性测试 |
+| 002 | `agent_loop_compact_events_test`、`agent_loop_pa_rescue_test`、tool result storage 的预算交界用例 |
+| 003 | `agent_loop_tool_lifecycle_events_test`、`agent_loop_ask_user_question_parallel_test`、`agent_loop_computer_use_scheduling_test`、`shell_write_guard_test` |
+| 004 | `agent_loop_turn_steering_test`、`agent_loop_question_interjection_test`、`agent_loop_goal_test`、`agent_loop_worker_recovery_test` |
+| 005 | `agent_loop_termination_test`、`agent_loop_empty_response_integration_test`、`agent_loop_tool_preamble_test`、`agent_loop_trajectory_test`,加前四批的完整 AgentLoop suite |
+
+所有批次还必须运行 `git diff --check`;首次新增 `src/agent/*.cpp` 后额外确认其进入 `acecode_testable` 编译单元。
diff --git a/docs/release-artifacts.md b/docs/release-artifacts.md
new file mode 100644
index 000000000..9d7bc8f50
--- /dev/null
+++ b/docs/release-artifacts.md
@@ -0,0 +1,23 @@
+# 正式发布产物约定
+
+`.github/workflows/package.yml` 的 `v*` 标签构建必须生成下列 18 个用户下载包。发布任务通过 `scripts/verify_release_assets.py` 检查每个文件只有一个非空副本,并拒绝不属于当前版本的包、未签名包或重名文件;检查失败时不创建 GitHub Release。
+
+各平台打包可以与测试并行。发布任务还必须等待同一提交在 master 上的 `test.yml` 检查全部成功;测试缺失、失败或超时都不能发布。
+
+| 平台 | 架构 | 必需文件 |
+| --- | --- | --- |
+| Windows | x64、arm64 | `acecode-windows-.zip` |
+| Linux | x64、arm64、armv7 | `acecode-linux-.tar.gz` |
+| UOS / Deepin | x64、arm64、armv7 | `acecode-linux-deepin-.tar.gz` |
+| Linux 自动更新 | x64、arm64 | `acecode--linux--update.zip` |
+| macOS | x64、arm64 | `acecode-macos-.tar.gz` |
+| macOS 安装器 | x64、arm64 | `ACECode--macos-.dmg` 和 `.pkg` |
+| macOS 自动更新 | x64、arm64 | `ACECode--macos--update.zip` |
+
+Deepin 构建是发布任务的必需依赖,不能因已有普通 Linux 包而跳过。它替代原 Linux old 流水线,保持 GLIBC 2.28 基线;x64/arm64 开启 `ACECODE_DEEPIN`,使用 WebKitGTK 4.0 和 DTK 5,ARMv7 保持 CLI-only。依赖及升级方式见 [UOS / Deepin 专用构建](deepin-desktop.md)。macOS 正式发布同时要求 Developer ID Application、Developer ID Installer 证书及公证凭据;安装器证书或密码缺失必须失败,不允许发布没有 PKG 的正式版。手动触发的开发构建仍可在缺少签名凭据时生成非正式产物,不创建 Release。
+
+调试符号使用 `dev_only.` 文件名前缀;避免方括号等会被 GitHub 改写的字符,使 `SHA256SUMS.txt` 中的文件名与下载名一致。
+
+完整正式版同步到 aupdate 时,验证六类更新 ZIP 和两个 PKG 的版本、大小、哈希及公网下载。Linux 更新 ZIP 使用 `linux-x64-updater-v1`、`linux-arm64-updater-v1` manifest target。PKG 是独立手动下载项,不写入自动更新 manifest。
+
+本地可运行 `python tests/scripts/verify_release_assets_test.py` 验证缺包、空包、重复包和错误版本的阻断逻辑;macOS CI 另验证签名、公证、安装器布局与打包脚本。
diff --git a/docs/sandbox.md b/docs/sandbox.md
index f63553fc8..08b6df386 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -201,8 +201,13 @@ forbidden > prompt > allow 合并;多段命令须每段都获 allow。
}
```
-除 `filesystem.deny` 示例外均为默认值,默认配置保存时不产生 sandbox 段。额外根必须为绝对路径
-或记号。`windows_backend` 取 `restricted-token`(默认)或 `mxc`。
+首次运行包含一次性沙盒迁移的版本时(包括新安装),应用会将 `sandbox.enabled` 设为
+`false`,并同时保存 `migrations.disable_sandbox_once: true`。此迁移只执行一次:之后在
+安全中心重新开启沙盒,重启、保存其他设置及后续升级都会保留开启状态。
+
+上面的示例展示重新开启后的配置。除 `filesystem.deny` 示例外,其余为字段默认值;
+保存时默认字段可省略,迁移完成标记始终保留。迁移不修改网络策略、路径清单或权限模式。
+额外根必须为绝对路径或记号。`windows_backend` 取 `restricted-token`(默认)或 `mxc`。
`/sandbox` 显示后端、原因、模式、可写/只读/可读路径、deny 名单、会话授权和网络能力。
`/sandbox off` / `/sandbox on` 只改变当前会话,不写配置;关闭后 Auto 未知命令仍需确认。
后端准备或启动失败会把本会话的沙盒标记为不可用(状态里显示原因);修好环境后
diff --git a/docs/specs/2026-09-20-dev-launcher-multiinstance-optimization-requirements.md b/docs/specs/2026-09-20-dev-launcher-multiinstance-optimization-requirements.md
new file mode 100644
index 000000000..a33eb9df9
--- /dev/null
+++ b/docs/specs/2026-09-20-dev-launcher-multiinstance-optimization-requirements.md
@@ -0,0 +1,180 @@
+# 开发环境启动器多实例优化需求规格
+
+**日期:** 2026-09-20
+**状态:** 需求与技术方案均已确认(§7 决策已落定);后续按波次分别走 OpenSpec 提议
+**来源:** 多实例特性提交 `42912f75`(`plan_instance_startup` / `desktop-instances` 模型)落地后的开发启动器排查与交接记录(分支 `fix/dev-launcher-runtime-identity` 上的实测复现)
+
+## 1. 目标
+
+多实例(主仓库 + 多个 worktree 并行开发)成为常态后,`dev_environment.py` 系启动脚本的三个旧假设失效:28080 端口基本空闲、run-dir 天然可复用、失败可人工排查。本规格让 web / desktop 两条开发启动链路做到:
+
+- **快**:端口决策秒级完成,不再为被占的 28080 白等 15 秒。
+- **稳**:陈旧运行目录自愈,重启即可恢复,不需要手动 `daemon stop`。
+- **可诊断**:启动失败时能在 run-dir 内直接读到真实退出原因。
+- **吃到多实例红利**:desktop 开发场景获得稳定实例身份,同一 worktree 同一 commit 重启复用同一 daemon,不再每次产生新的随机实例目录。
+
+## 2. 范围
+
+### 本轮范围(A + B + C + D + E + G)
+
+| 项 | 一句话 | 主要落点 |
+|---|---|---|
+| A 陈旧自愈 | 死 pid 的 run-dir 自动清理并重试启动 | `scripts/dev_environment.py` |
+| B 固定端口 | 端口由 worktree 身份确定性派生,被占立即换 | `scripts/dev_environment.py` |
+| C 失败可诊断 | daemon worker 输出落盘到 run-dir 内日志 | `scripts/dev_environment.py` |
+| D 死目录清理 | 启动自动清 + 手动全量清理命令 | `scripts/dev_environment.py` |
+| E 实例身份 | desktop 接受外部指定实例身份,dev 脚本注入稳定身份 | `src/desktop/main.cpp`、`scripts/dev_desktop.py` |
+| G 进程级多开 | desktop 支持进程级"允许本实例多开"覆盖,不污染全局配置 | `src/desktop/main.cpp`、`scripts/dev_desktop.py` |
+
+每项均需同步更新 `.agents/skills/development-environment/SKILL.md` 与对应 Python 单测(`tests/scripts/`,unittest 风格)。
+
+### 不在本轮范围
+
+- **F** run-dir 统一到 `~/.acecode/run/`(脚本与产品两套运行时目录世界的合并,涉及 `daemon_pool` 复用判定,另立议题)。
+- **H** `token.tmp` 回退读取移除(3 行小改,建议后续与 `atomic_file.hpp` 修复合并处理)。
+
+## 3. 需求明细
+
+### 3.1 A:陈旧运行目录自愈
+
+启动时 run-dir 健康检查未通过(现状:直接硬失败),改为按 pid 死活分支:
+
+| 检查结果 | 行为 |
+|---|---|
+| run-dir 健康(pid / port / token 与 `/api/health` 身份匹配) | 现状保持:复用 daemon |
+| 不健康,且记录的 pid **已死** | 自动清理该 run-dir 的运行时文件,**重试一次**启动;重试仍失败则报错并指向 C 的日志文件 |
+| 不健康,且记录的 pid **活着**(真 daemon,只是身份不匹配) | 不干预、不终止,保留现有"请手动停止"类提示 |
+
+**安全红线:任何自动清理路径都绝不对存活进程发出终止。**
+
+### 3.2 B:worktree 派生固定端口
+
+- 默认端口不再固定 28080,改由 worktree 身份(目录名 + commit)**确定性派生**,落在 28080–28280 区间内。
+- 同一 worktree 每次启动得到同一端口;不同 worktree 天然错开,互不抢占。
+- 派生端口被占时**立即**换下一个候选,不等待健康检查超时(消灭 15 秒白等)。
+- 日常通过 Vite 代理访问不受影响;直接访问 daemon 端口时以启动输出为准。
+- 若用户显式指定端口,显式值优先于派生值。
+
+### 3.3 C:失败可诊断
+
+- 启动器拉起的 daemon worker 的输出(stdout/stderr)落盘到 run-dir 内的日志文件(目录已被 `.gitignore` 覆盖,不污染仓库)。
+- 启动失败时,错误输出明确指向该日志文件路径。
+- 目标:定位一次失败不再依赖 heartbeat + netstat + `daemon status` 反推。
+
+### 3.4 D:死目录清理
+
+- **启动时自动清理**:每次启动顺带清理「目录名匹配当前 worktree 前缀 **且** pid 已死」的 run-dir,日常零堆积。
+- **手动全量清理命令**:一次性清理所有可判定为死(pid 已死)的 run-dir,包括:已删除 worktree 的遗留、现存存量(约 20 个目录 / 19 个死 pid)、以及旧版本遗留的 `.acecode-dev-run/` 目录。
+- 清理动作只作用于「pid 已死」可判定的目录;判定不了的(缺 pid 文件等)跳过并报告,不猜。
+
+### 3.5 E:desktop 实例身份稳定化
+
+- desktop 新增**外部指定实例身份**的覆盖入口(环境变量或 CLI,形态见开放决策);不指定时行为与现状完全一致(随机 uuid)。
+- dev 启动路径(`dev_desktop.py` / `dev_environment.py desktop`)自动注入身份 = `-`,与 web 启动器现有 run-dir 命名策略对齐。
+- 效果:同一 worktree 同一 commit 重启 desktop 复用同一实例目录与 daemon;切 commit 后新开实例,旧实例目录由 D 回收。
+- 身份字符串的可用字符约束与 web 侧命名规则一致(避免路径非法字符)。
+
+### 3.6 G:进程级"允许本实例多开"覆盖
+
+- 背景:desktop 的单例锁是机器级命名互斥体、锁名编译期固定,**安装版与开发版、不同 worktree 的 dev desktop 之间竞争同一把锁**。默认 `allow_multiple_instances=false` 时,只要机器上已有任何 desktop 在跑(如安装版常驻 dogfood),dev desktop 会直接不启动并聚焦已有实例——E 的稳定身份走不到生效分支(instance_id 仅在允许多开时使用)。
+- 需求:desktop 支持进程级覆盖"本实例允许多开"(入口形态见开放决策),dev 启动路径默认注入;**不读写全局用户配置**。
+- 效果:安装版 desktop 常驻时可直接并行启动 dev desktop;安装版的日常行为(二次启动聚焦已有窗口)不受影响。
+- 覆盖未使用时,行为与现状完全一致。
+
+## 4. 约束
+
+- **进程安全**:任何自动清理/自愈不得终止存活进程(A、D 共同红线)。
+- **token 安全**:daemon 认证 token 只经进程环境传递,不落日志、不进命令行参数(维持现状)。
+- **不改 daemon 端口语义**:不引入 `--port=0` 之类新语义,端口选择完全在脚本侧解决(`daemon status --json` 属可选的输出格式增强,见开放决策 3)。
+- **向后兼容**:E/G 的覆盖入口未使用时,desktop 产品行为与全局配置语义完全不变;`--run-dir` 显式指定的用户路径行为不变。
+- **仓库流程**:非平凡行为变更,两波各自先建 OpenSpec change 再实现;实现后同步 `development-environment` SKILL 文档。
+- **测试**:Python 侧用 unittest(本机无 pytest);C++ 侧改动(E/G)须编译 `acecode_unit_tests` 并跑 `ctest`。
+- **输出风格**:脚本控制台输出沿用现有 ASCII 风格提示语,不引入 emoji。
+
+## 5. 验收标准
+
+1. 28080 被其他实例占用时启动 web:端口探测立即完成(毫秒级 bind 探测),全程无 15 秒健康检查等待。
+2. 同一 worktree 连续两次启动 web 得到同一端口;两个不同 worktree 并行启动互不冲突。
+3. 手动杀掉 daemon 后重跑同一启动命令:自动清理死 run-dir 并成功恢复,全程无需 `daemon stop`。
+4. run-dir 指向一个存活的异名 daemon(pid 活、身份不匹配):启动器不终止它,输出保留"请手动停止"类提示。
+5. 人为制造启动失败:run-dir 内日志文件包含 worker 真实输出,错误信息指明该文件路径。
+6. 同一 worktree 同一 commit 两次启动 desktop:落在同一实例目录、复用同一 daemon;切 commit 后落到新目录。
+7. 不指定身份与多开覆盖直接运行 `acecode-desktop`:行为与现状一致(随机实例目录、遵循全局配置)。
+8. 安装版 desktop 常驻时启动 dev desktop:不改动全局配置即可启动为独立实例;全局配置文件未被写入,安装版二次启动仍表现为聚焦已有窗口。
+9. 手动全量清理命令一次清掉现存死目录与 `.acecode-dev-run/` 遗留;日常启动后死目录不再堆积。
+10. `tests/scripts/` 单测全绿;Wave 2 的 C++ 编译与 `ctest` 通过。
+
+## 6. 分波交付
+
+| 波次 | 内容 | 交付物 |
+|---|---|---|
+| Wave 1(纯 Python,先行) | C(先落日志拿到诊断能力)→ A(已验证修复路径)→ B → D | 一个 OpenSpec change + 单测 |
+| Wave 2(含 C++) | E + G:desktop 身份与多开两个进程级覆盖 + dev 脚本注入 | 一个 OpenSpec change + 单测 + ctest |
+
+Wave 2 建议在 `fix/dev-launcher-runtime-identity`(含未验证的 `atomic_file.hpp` 改动)验证合并后再动 C++,避免两笔未验证的 C++ 改动叠加。
+
+## 7. 已定技术决策(grilling 两轮落定,2026-09-20/21)
+
+以下为技术方案阶段全部决策,作为 Wave 1 / Wave 2 OpenSpec 提议的直接输入。
+
+### 7.1 端口派生(B)
+
+- 散列:`zlib.crc32(safe_identity) % 201 + 28080`,`safe_identity` 复用 `worktree_runtime_dir` 的 `-` 字符串——端口与 run-dir 同源同变。禁用 Python 内置 `hash()`(每进程随机加盐)。
+- 被占后线性 +1 顺延(区间内回绕),每候选毫秒级 bind 探测;全区间占满才报错。
+- 已知副作用:identity 含 commit12,切 commit 后端口随 run-dir 一起变;Vite 代理目标不可写死,验收 2 仅在 commit 不变时成立。
+
+### 7.2 显式端口(B)
+
+- 新增 `--port`(与 `--run-dir` 同级,走 extra parser)。
+- 显式端口被占 → 立即报错退出,不顺延(显式 = 外部有确切依赖,静默换端口更难查)。
+
+### 7.3 A 自愈载体
+
+- 脚本内联 `pid_is_alive(pid)`(Windows ctypes `OpenProcess` / POSIX `os.kill(pid, 0)`);死 → `daemon stop --run-dir=...`(复用其 `cleanup_runtime_files`,已实测验证路径);活 → 维持"请手动停止"类提示。
+- 不用 `daemon status` 退出码判死活(rc=1 含"pid 活但不可复用",误用会踩红线);不给 status 加 `--json`(保持 Wave 1 纯 Python)。
+- **保守假设(Q10)**:pid 号被 OS 回收给无关进程时判"活"、不自愈、走跳过报告;不做进程身份校验,红线优先于自愈覆盖率。
+
+### 7.4 C 日志形态
+
+- 脚本写 `daemon-worker.log`:Popen stdout/stderr 重定向,append + 每次拉起写分隔头(时间戳 + 参数摘要),>1MB 滚动保留 1 代(`.log.1`)。
+- 与 daemon 自带 `daemon-startup.log`(`startup_diagnostics.cpp`)并存;启动失败时报错同时指向两个路径。
+- 不做多代保留(与 D 的"零堆积"目标一致)。
+
+### 7.5 D 命令形态
+
+- `prune` 作为与 `web/desktop/tui` 平级的 positional target。
+- 输出三段:已清理(目录 + 死 pid)、跳过(目录 + 原因)、总计;存在跳过项退出码仍为 0。
+- 结构不认识的目录(含 `.acecode-dev-run/` 遗留)一律只报告不删除。
+
+### 7.6 D 自动清理时点
+
+- 放在"健康检查判不可复用 → 准备新拉起"路径上、端口探测之前;健康复用路径不扫目录(零开销)。
+
+### 7.7 E/G 覆盖入口(Wave 2)
+
+- 只用环境变量,dev 专用定位,C++ 不加 CLI:
+ - `ACECODE_DESKTOP_INSTANCE_ID=-`;空串视为未设置 → 回退随机 uuid。
+ - `ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES`:白名单解析,大小写不敏感 `1`/`true`/`yes`/`on` 为真,其余一切(含 `0`、空串)为假;未设置不进入覆盖分支。
+- 由 `dev_desktop.py` 注入子进程环境;文档写进 SKILL 与 dev 文档,不进对外产品文档。
+
+### 7.8 E 身份字符串责任划分
+
+- Python 生成时 sanitize(`worktree_runtime_dir` 正则提为共享 helper 供 `dev_desktop.py` 复用)。
+- C++ 只校验不清洗:`^[A-Za-z0-9_.-]{1,64}$`,不合法 → 忽略注入值、回退随机 uuid(行为退化为现状,绝不产生非法路径)。
+
+### 7.9 测试切面
+
+- Python:三个外部依赖做成模块级小函数供 `patch.object` 打桩——`pid_is_alive()`、`port_is_available(port)`、集中封装的 worker Popen 调用(断言重定向到 `daemon-worker.log`)。另加:crc32 派生端口固定向量测试(防换成不稳定散列)、线性顺延顺序断言(含回绕)。
+- C++(Wave 2):`is_valid_instance_id()` 与 `parse_allow_multiple_instances()` 做成 `instance_startup.hpp` 的 header-only 纯函数;`main.cpp` 只 `getenv` + 透传,env 读取不进被测代码;单测与 `plan_instance_startup` 现有测试同文件扩展。
+
+### 7.10 worker 提前退出快速失败(C 的延伸)
+
+- 健康等待循环中加 `Popen.poll()`:worker 进程已退出 → 立即失败并指向日志文件,不再等满健康检查超时。至此"启动失败秒级可见"覆盖全部场景。
+
+## 8. 变更记录
+
+| 日期 | 变更 | 原因 |
+|---|---|---|
+| 2026-09-20 | 初版 | brainstorming 澄清产出:范围定为 A–E(F/G/H 出局),端口策略取 worktree 派生、清理取"自动 + 手动全量"、实例身份取 worktree+commit |
+| 2026-09-20 | G 拉回范围并入 Wave 2;新增 3.6 与验收 8 | 用户确认 desktop 会并行跑(安装版 dogfood / 多 worktree);单例锁为机器级固定锁名,E 在默认配置下走不到生效分支,G 与 E 同代码缝成本极低 |
+| 2026-09-21 | §7 由"开放决策"改为"已定技术决策"(12 项);状态推进为方案已确认 | grilling 两轮:第一轮 8 项(端口派生、显式端口、自愈载体、日志形态、prune 形态、E/G 入口、sanitize 划分、清理时点)全按推荐;第二轮 4 项(测试切面、pid 复用保守边界、bool env 白名单解析、worker 提前退出快速失败)全按推荐 |
diff --git a/docs/themes.md b/docs/themes.md
index 36ca880a8..038a83ac6 100644
--- a/docs/themes.md
+++ b/docs/themes.md
@@ -81,13 +81,10 @@ controls are preserved, including the original definition's `mode: dark`.
Only the 86,886-byte preview is bundled; the full package is downloaded from
the configured aupdate server. The original local AI theme remains unchanged.
-The next application's first authenticated entry attempts this theme once,
-including for users upgrading from an earlier release. A durable atomic marker
-beside the installed themes coordinates multiple windows and survives restarts.
-The current skin stays active until resources are ready. Discovery, download,
-validation, image-loading and preference-save failures are silent; an attempted
-startup is never automatically retried. Users can still download manually, and
-a later manual theme choice takes priority over an unfinished automatic download.
+从下个版本起,Web/Desktop 启动时不再自动下载或应用国庆节主题。新配置使用
+蓝色并跟随系统明暗模式;升级后沿用已保存的外观,包括此前保存的国庆节主题。
+国庆节主题仍可在「设置 → 外观」中手动下载和选择,沿用下载确认、进度与失败提示。
+旧版留下的首次尝试标记不影响新的启动行为,也无需清除。
The approved definition is `assets/themes/national-day-2026/theme.json`. Repackage
the matching artwork without modifying its PNG bytes:
diff --git a/docs/user-manual.md b/docs/user-manual.md
index efbb01e26..00297bc12 100644
--- a/docs/user-manual.md
+++ b/docs/user-manual.md
@@ -597,12 +597,12 @@ acecode 支持通过 MCP(Model Context Protocol)接入外部工具服务器
```json
{
"mcp_servers": {
- "": {
+ "local-server": {
"command": "<可执行程序路径>",
"args": ["<参数1>"],
"env": { "ENV_VAR": "value" }
},
- "<远端 SSE 服务器>": {
+ "remote-server": {
"transport": "sse",
"url": "https://mcp.example.com",
"sse_endpoint": "/sse",
@@ -630,11 +630,40 @@ acecode 支持通过 MCP(Model Context Protocol)接入外部工具服务器
| `sse_endpoint` | string | 否 | 端点路径。`sse` 传输默认 `/sse`;`http` 传输默认 `/mcp`(字段名复用,未来若冲突严重可能拆分) |
| `headers` | object | 否 | 额外请求头(敏感值不会出现在日志里) |
| `auth_token` | string | 否 | Bearer 令牌(日志仅输出 `auth: present`) |
-| `timeout_seconds` | int | 否 | 请求超时,默认 30 |
+| `timeout_seconds` | int | 否 | 请求超时,默认 30,允许 1–3600 秒 |
-> 未含 `transport` 字段的旧版条目仍作为 stdio 处理,行为与先前版本完全一致。
+> 未含 `transport` 字段的旧版条目仍作为 stdio 处理,需要有效的 `command`。
>
-> 服务器名称会被清理(非字母数字字符替换为 `_`)后作为工具名前缀。例如名称为 `my-tools`,其工具 `search` 会被注册为 `mcp_my_tools_search`。
+> 服务器名称不能为空,也不能包含空白、控制字符或 `/`。公共服务器名称中的非字母数字字符按 `_HH` 编码,项目服务器使用独立的工具名前缀。模型使用工具清单中返回的名称,不应自行拼接名称。
+
+### 公共配置与项目配置
+
+公共配置仍保存在 `~/.acecode/config.json` 的 `mcp_servers` 字段中。项目可在自己的 `.acecode/mcp.json` 中保存以下内容:
+
+```json
+{
+ "mcp_servers": {
+ "project-search": {
+ "command": "node",
+ "args": ["/absolute/path/to/project-search.js"]
+ }
+ }
+}
+```
+
+项目条目与公共配置叠加,同名条目由项目配置完整覆盖。项目条目设为 `"disabled": true` 时,也会覆盖同名公共服务器;删除项目条目后恢复继承公共配置。不同项目的同名服务器分别连接,工具调用不会跨项目串用。
+
+从子目录启动时,ACECode 向上查找已有的项目 MCP 文件,并以最近的 Git 仓库或 worktree 根目录为边界;查找不会越入用户主目录。未找到项目文件时使用 Git 根目录,没有 Git 根目录则使用工作目录。设置页面的 MCP 区域可通过“配置范围”选择公共配置或已登记的项目,JSON 编辑器中只填写服务器名称到配置的映射,不包含文件外层的 `mcp_servers`。
+
+### 校验与自动回退
+
+ACECode 使用内置的配置 JSON Schema 校验原始 JSON。它描述客户端服务器配置;[MCP 2026-07-28 Schema](https://modelcontextprotocol.io/specification/2026-07-28/schema) 描述协议消息。本功能不变更当前 MCP 通信协议版本。
+
+设置编辑器、HTTP API、TUI 配置编辑、通用配置保存,以及内置 Write/Edit/Patch 和网页文件编辑入口都会在写入已知 MCP 配置文件前校验。错误的字段类型、未知字段、传输必填项缺失等会拒绝整次修改,并返回字段路径和完整 Schema。设置页面可展开“查看配置 Schema”;校验失败时保留编辑草稿和已保存的有效配置。
+
+每个配置文件旁保存独立的 `.mcp-last-good` 快照,例如 `.acecode/mcp.json.mcp-last-good`。启动或重新加载前会再次校验;外部编辑导致配置无效时,先重新校验快照,再恢复该范围上次有效的配置。公共配置仅恢复 MCP 部分,其他设置保持原值;无效原文归档到相邻的 `config-backups/invalid/`。没有有效快照时返回校验错误,不启动无效配置中的服务器。快照也可能包含认证信息,不要提交到版本库。
+
+外部程序或 shell 直接写文件无法在写入前由 ACECode 拦截,这类修改由启动或重新加载校验处理。`/mcp enable`、`disable`、`reconnect` 是当前项目的运行时操作;需要持久保存启用状态时使用设置或 MCP 管理界面。
### 配置示例
@@ -720,7 +749,7 @@ acecode 启动时按 transport 连接每个配置的 MCP 服务器:
[MCP] Connected 2 server(s), registered 8 external tool(s).
```
-若某个服务器连接失败,可在当前工作目录的 `acecode.log` 中查看详细错误信息。
+若某个服务器连接失败,可在 `<数据目录>/logs/tui-YYYY-MM-DD.log` 中查看详细错误信息(默认数据目录为 `~/.acecode`)。
### 管理 MCP 连接
@@ -856,9 +885,14 @@ OpenAI 兼容网关或 Anthropic 模型如果推理时间很长、连接容易
### Q: 日志文件在哪里?
-acecode 运行时会在当前工作目录生成 `acecode.log` 文件,记录详细的调试信息。
-Daemon 模式下不再写 `acecode.log`,而是按日期滚动写到
-`<数据目录>/logs/daemon-{YYYY-MM-DD}.log`(详见第 16 章)。
+普通 TUI 运行时会按本地日期滚动写入
+`<数据目录>/logs/tui-YYYY-MM-DD.log`,记录详细的调试信息。默认数据目录为
+`~/.acecode`;若已配置数据目录重定向,则使用重定向后的目录。已有工作区中的
+`acecode.log` 会被保留,但新的 TUI 不再创建或追加该文件。启用 TUI 输入追踪的
+调试构建会将高频鼠标和选区记录另写到
+`<数据目录>/logs/tui-input-trace-YYYY-MM-DD.log`,同样不会写入工作区。
+
+Daemon 模式按日期滚动写到 `<数据目录>/logs/daemon-YYYY-MM-DD.log`(详见第 16 章)。
---
diff --git a/external/ftxui b/external/ftxui
index f98588b47..62e7471b1 160000
--- a/external/ftxui
+++ b/external/ftxui
@@ -1 +1 @@
-Subproject commit f98588b47b097674724100c7a35a3c9b8ec055e3
+Subproject commit 62e7471b1e4155a33ee9e3caa0921afc8f7326e6
diff --git a/installer/windows/acecode.iss b/installer/windows/acecode.iss
index 0c77ae598..fbcc84100 100644
--- a/installer/windows/acecode.iss
+++ b/installer/windows/acecode.iss
@@ -57,6 +57,7 @@ Name: "addtopath"; Description: "将 ACECode 命令行加入用户 PATH"; GroupD
[Files]
Source: "staging\acecode.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "staging\acecode-desktop.exe"; DestDir: "{app}"; Flags: ignoreversion
+Source: "staging\acecode-computer-use.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "staging\README.md"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist
Source: "staging\README_CN.md"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist
Source: "staging\share\*"; DestDir: "{app}\share"; Flags: ignoreversion recursesubdirs createallsubdirs
diff --git a/installer/windows/build.ps1 b/installer/windows/build.ps1
index 4d1f265a2..66b113155 100644
--- a/installer/windows/build.ps1
+++ b/installer/windows/build.ps1
@@ -5,7 +5,8 @@ param(
[string]$Version = "",
[string]$Iscc = "",
[string]$DesktopExe = "",
- [string]$CliExe = ""
+ [string]$CliExe = "",
+ [string]$ComputerUseExe = ""
)
$ErrorActionPreference = "Stop"
@@ -64,10 +65,16 @@ $desktop = Find-ExistingFile @(
)
if (-not $cli) { throw "Missing acecode.exe. Build the Release desktop/CLI binaries first." }
if (-not $desktop) { throw "Missing acecode-desktop.exe. Build the Release desktop binary first." }
+$computerUse = Find-ExistingFile @(
+ $ComputerUseExe,
+ (Join-Path (Split-Path -Parent $cli) "acecode-computer-use.exe")
+)
+if (-not $computerUse) { throw "Missing acecode-computer-use.exe beside the CLI. Build the matching Computer Use runtime first." }
Write-Host "Staging installer payload from:"
Write-Host " CLI $cli"
Write-Host " Desktop $desktop"
+Write-Host " Computer Use $computerUse"
if (Test-Path -LiteralPath $stagingDir) {
Remove-Item -LiteralPath $stagingDir -Recurse -Force
@@ -75,6 +82,7 @@ if (Test-Path -LiteralPath $stagingDir) {
New-Item -ItemType Directory -Path $stagingDir | Out-Null
Copy-Item -LiteralPath $cli -Destination (Join-Path $stagingDir "acecode.exe")
Copy-Item -LiteralPath $desktop -Destination (Join-Path $stagingDir "acecode-desktop.exe")
+Copy-Item -LiteralPath $computerUse -Destination (Join-Path $stagingDir "acecode-computer-use.exe")
foreach ($name in @("README.md", "README_CN.md")) {
$source = Join-Path $RepoRoot $name
if (Test-Path -LiteralPath $source) {
diff --git a/openspec/changes/add-development-environment-launcher/.openspec.yaml b/openspec/changes/add-development-environment-launcher/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/add-development-environment-launcher/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/add-development-environment-launcher/design.md b/openspec/changes/add-development-environment-launcher/design.md
new file mode 100644
index 000000000..056d78fcf
--- /dev/null
+++ b/openspec/changes/add-development-environment-launcher/design.md
@@ -0,0 +1,94 @@
+# Design
+
+## Context
+
+See `proposal.md` for motivation and `specs/development-environment-launcher/spec.md` for behavioral requirements. The repository already has Python-backed Web and Desktop launchers with thin platform wrappers. Web currently accepts an explicit build directory and runtime directory, while Desktop accepts an explicit build directory and rebuilds `web/dist` when its inputs are newer. TUI has no comparable wrapper.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Provide one shared Python orchestration entry point and thin Windows/POSIX wrappers.
+- Reuse existing Web and Desktop launch scripts rather than duplicating their surface-specific behavior.
+- Reliably identify compatible builds from Git-registered worktrees before proposing a local build.
+- Keep development daemons isolated per worktree and make start outcomes visible.
+
+**Non-Goals:**
+
+- Change production daemon, Desktop, TUI, or CMake behavior.
+- Copy build artifacts, DLLs, or resources between worktrees.
+- Search arbitrary user directories outside worktrees registered by the current Git repository.
+- Automatically compile without interactive confirmation.
+
+## Decisions
+
+### Use a Python orchestration layer with thin platform wrappers
+
+A new `scripts/dev_environment.py` will parse the selected target and coordinate discovery, confirmation, incremental building, and launch. Dedicated target wrappers will select Python and a fixed target: `dev_web.bat` / `dev_web.sh`, `dev_desktop.bat` / `dev_desktop.sh`, and `dev_tui.bat` / `dev_tui.sh`. Python matches the existing launcher implementation and is portable across Windows, macOS, and Linux.
+
+Alternatives considered:
+
+- Separate shell implementations would duplicate platform logic and Git/CMake parsing.
+- Extending `dev_web.py` or `dev_desktop.py` would couple target-independent discovery to a single surface.
+
+### Discover builds only from Git worktree registrations
+
+The orchestration layer will inspect only the current worktree's candidate build directories and read `CMakeCache.txt` to confirm that each build was configured from the current source directory. It validates platform, generator architecture clues, selected target configuration, and executable presence. Registered peer worktrees are used only for safe compiler-cache and frontend-artifact acceleration; their CMake/Ninja directories are never built or launched for the current worktree.
+
+Alternatives considered:
+
+- Scan sibling or home directories: this can find unrelated repositories and is slower.
+- Compare only executable timestamps: timestamps do not prove the source revision or Desktop capability.
+
+### Delegate surface-specific startup
+
+The shared launcher performs the target's incremental CMake build after it validates or configures a build directory. For Web and Desktop, it then invokes the existing Python surface launchers directly, forwarding the validated `--build-dir`; this avoids recursing through target wrappers. The Desktop surface launcher continues to refresh frontend assets, and the shared launcher adds the equivalent freshness check before Web launch. Web receives a deterministic runtime directory below the current worktree's ignored development state. TUI is started from the validated `acecode` binary in a new terminal window using platform-specific process invocation.
+
+Alternatives considered:
+
+- Reimplement Web and Desktop startup in the new tool: would create two sources of truth for Web assets and Desktop development-mode behavior.
+- Run TUI in the current terminal: conflicts with the agreed ability to continue launcher work after opening TUI.
+
+### Auto-configure missing builds from Windows direct entry points
+
+Double-clicked batch files do not provide a reliable input stream for the shared launcher's confirmation prompt. Each Windows target entry point will therefore append `--yes` when it calls `dev_environment.py`, approving only the missing-build configuration path. The shared Python launcher remains conservative for callers that invoke it directly, and POSIX wrappers retain their interactive confirmation behavior.
+
+### Initialize the Windows C++ toolchain in batch entry points
+
+Windows 包装脚本先选择 Python,公共启动器识别候选构建后才决定是否需要 MSVC。MinGW 构建直接使用已有编译器;MSVC 路径通过共享 `dev_windows_env.bat` 查询匹配 x64 或 ARM64 的 VS 组件并初始化环境。Python 只在子进程内捕获环境变量,再传递给后续编译,不输出环境内容。帮助、列表和 dry-run 不触发工具链初始化。Windows ARM64 对应的两个默认 configure presets 与 x64 使用相同的基础配置。
+
+Alternatives considered:
+
+- Require callers to use a Developer Command Prompt: contradicts direct double-click and normal-shell entry point behavior.
+- Duplicate Visual Studio path discovery in all three wrappers: risks inconsistent architecture and error handling.
+
+### Require interactive confirmation only for a new configuration
+
+When discovery cannot produce a compatible result, the tool calculates the native CMake preset for the selected target and prints configure/build commands. It asks a yes/no question only when stdin is interactive; non-interactive invocations fail with the same instructions rather than implicitly configuring. Once a build directory has been validated or configured, the target's incremental build runs without another confirmation so each launch reflects current sources. First-time development configurations pass `-DBUILD_TESTING=OFF`, because unit-test dependencies are optional in the vcpkg manifest and are not needed to run a development surface.
+
+Alternatives considered:
+
+- Auto-build: potentially expensive and unexpected.
+- Always fail: forces developers to reconstruct platform-specific presets manually.
+
+## Risks / Trade-offs
+
+### 发布审查收敛
+
+- 多配置构建记录实际产物的配置名称,并通过 `cmake --build --config` 编译同一配置;仅修改编译缓存 launcher 时保留已有 `CMAKE_BUILD_TYPE` 和 `BUILD_TESTING`。独立的嵌套 preset 不属于父级 CMake 构建。
+- 公共启动器向 Desktop 传递已验证的具体产物,向 Web 传递该可执行文件所在目录,避免二次发现选到其他配置。`--list` 只列举产物;`--rebuild` 强制刷新前端。
+- Windows TUI 直接创建新控制台进程;macOS 用 Terminal 的 AppleScript 入口执行经过逐参数 shell 引用的 `cd` 和 `exec`,确保工作目录与参数一致。
+- Web 重建前遇到既存 PID 记录时,复用原有 `daemon status` 身份校验并明确失败。此次不引入跨平台进程管理框架,也不调用现有仅按 PID 终止的 `daemon stop`。仅在核验成功后显示停止命令供开发者操作;无法确认身份时只显示检查命令。默认目录检查包含当前工作树旧提交的 runtime,显式目录只检查自身。
+- 回归使用临时目录与替身命令;Windows 另验证真实 VS 环境初始化。macOS/Linux 图形终端和 ARM64 原生编译不在本轮 Windows 主机验证范围内。
+
+- [A valid external build is configured with an unusual directory layout] → inspect standard build directories plus explicit `--build-dir`; require an exact `CMakeCache.txt` source match.
+- [CMake does not expose a fully portable architecture field] → require a runnable platform-native executable and compare configured generator/platform fields when present; reject uncertain configurations.
+- [A worktree-specific runtime path is untracked] → create it under the repository's ignored `.acecode` development state and document it in the launcher output.
+- [A platform lacks a supported graphical terminal launcher] → report the exact TUI executable command instead of silently starting TUI in the caller's terminal.
+
+## Migration Plan
+
+1. Add the shared launcher and thin wrappers without changing existing Web or Desktop launch commands.
+2. Add the repository-local skill, directing requests through the shared policy.
+3. Validate no-target selection, verified reuse, rejected reuse, declined rebuild, and each platform wrapper through focused tests or script help checks.
+4. Roll back by removing the new launcher, wrappers, and skill; existing Web and Desktop launchers remain unchanged.
diff --git a/openspec/changes/add-development-environment-launcher/proposal.md b/openspec/changes/add-development-environment-launcher/proposal.md
new file mode 100644
index 000000000..0d8951488
--- /dev/null
+++ b/openspec/changes/add-development-environment-launcher/proposal.md
@@ -0,0 +1,33 @@
+# Proposal
+
+## Why
+
+Developers currently need to choose and invoke separate Web or Desktop launchers manually, while TUI startup and reuse of compatible build outputs require repository-specific knowledge. A single cross-platform entry point should guide the target selection and safely reuse an existing build from a related worktree when possible.
+
+## What Changes
+
+- Add a cross-platform development-environment launcher that starts the Web daemon, Desktop shell, or terminal UI using existing repository launchers and build outputs.
+- Add dedicated Windows and POSIX entry points for Web, Desktop, and TUI so developers can start the desired target without providing a target argument or using an agent skill.
+- Validate reusable builds from the current worktree against its source directory, platform, architecture, and requested target; use related worktrees only for safe cache and frontend-artifact acceleration.
+- Incrementally rebuild every verified build before launch so source changes are incorporated; require confirmation only before configuring a missing or incompatible build.
+- Initialize the Windows Visual Studio C++ developer environment from the direct launchers so incremental builds work from a normal shell or double-clicked batch file.
+- Allow Windows direct launchers to configure a missing target build automatically, so double-clicked entry points do not wait for unavailable confirmation input.
+- Give each Web development workspace an isolated daemon runtime directory and open the selected development surface after a successful start.
+- Add a repository-local skill that asks for a target when omitted and applies the same validation and launch policy.
+
+## Capabilities
+
+### New Capabilities
+
+- `development-environment-launcher`: Guided, cross-platform selection, validation, and startup of ACECode Web, Desktop, and TUI development environments.
+
+### Modified Capabilities
+
+- None.
+
+## Impact
+
+- New shared launcher logic and thin `.bat` and `.sh` entry points under `scripts/`.
+- Existing `scripts/dev_web.*` and `scripts/dev_desktop.*` remain the surface-specific launch mechanisms invoked by the new launcher.
+- New repository-local skill under `.agents/skills/`.
+- CMake preset selection and Git worktree metadata are used for build discovery and validation; no application protocol or production runtime behavior changes.
diff --git a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md
new file mode 100644
index 000000000..ad6e820d5
--- /dev/null
+++ b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md
@@ -0,0 +1,111 @@
+# Spec Delta
+
+## Purpose
+
+Provide a safe, consistent way to start ACECode development surfaces across supported platforms without manually locating compatible build artifacts.
+
+## ADDED Requirements
+
+### Requirement: Development target selection
+The development-environment launcher SHALL start exactly one selected development target: Web, Desktop, or TUI. When no target is provided to an interactive launcher invocation, it SHALL prompt the developer to select one. A repository-local assistant skill SHALL ask the developer to select one of those targets when a request to run the development environment does not name a target.
+
+#### Scenario: Interactive target selection
+- **WHEN** a developer starts the launcher without a target in an interactive terminal
+- **THEN** the launcher prompts for Web, Desktop, or TUI and starts only the selected target
+
+#### Scenario: Skill target selection
+- **WHEN** a developer asks the repository-local skill to run the development environment without naming a target
+- **THEN** the skill asks whether to run Web, Desktop, or TUI before starting work
+
+### Requirement: Compatible build reuse
+Before requesting a new configuration, the launcher and skill SHALL search the current worktree for a compatible existing build. A build is reusable only when its configured source directory is the current worktree, its executable is runnable on the current platform and architecture, and it contains the executable required by the selected target. A Desktop target additionally requires a Desktop-enabled build and Desktop executable. Other registered worktrees MAY provide content-addressed compiler cache entries and verified frontend artifacts, but their path-bound build directories and executables SHALL NOT be used for the current worktree.
+
+#### Scenario: Reuse a matching Web build
+- **WHEN** the current worktree has a compatible configured `acecode` build
+- **THEN** the launcher incrementally builds and starts the Web target with that build directory
+
+#### Scenario: Reject an incompatible Desktop build
+- **WHEN** a matching build lacks Desktop support or the Desktop executable
+- **THEN** the launcher does not use it for the Desktop target
+
+#### Scenario: 多配置构建保持产物一致
+- **WHEN** 同一 CMake 构建包含 Debug、Release 等多个配置
+- **THEN** 增量编译 SHALL 明确指定所选产物对应的配置,Web 与 Desktop SHALL 启动该产物,不能再次选择另一配置的旧程序
+
+#### Scenario: 排除嵌套的独立构建
+- **WHEN** 某个候选目录的子目录存在独立 CMakeCache.txt
+- **THEN** 该子目录的产物 SHALL 按子目录自己的配置与源码路径验证,不能归入父级构建
+
+### Requirement: Fresh incremental builds and configuration confirmation
+Before starting a selected target from a compatible build directory, the launcher SHALL run that target's incremental CMake build so changed source files are incorporated. Before starting Web or Desktop, it SHALL also ensure the development frontend assets are current. When no compatible configured build exists, the launcher and skill SHALL report the missing requirement and the CMake preset selected for the current platform, then configure the development target with testing disabled so optional unit-test dependencies do not block startup. They SHALL obtain explicit developer confirmation before configuring a new build unless a Windows direct entry point supplies its automatic approval. A declined confirmation SHALL leave source and build files unchanged and SHALL not start a development target.
+
+#### Scenario: Refresh a compatible build
+- **WHEN** a compatible build exists and source files have changed
+- **THEN** the launcher runs the selected target's incremental build before starting it
+
+#### Scenario: Confirm a required configuration
+- **WHEN** the selected target has no compatible build and the developer confirms the proposed configuration
+- **THEN** the launcher configures and incrementally builds the required target before starting it
+
+#### Scenario: Decline a required configuration
+- **WHEN** the selected target has no compatible build and the developer declines the proposed configuration
+- **THEN** the launcher exits without configuring, compiling, or starting a target
+
+### Requirement: Windows direct-launch configuration
+Windows target-specific batch entry points SHALL pass automatic configuration approval to the shared launcher. When no compatible configured build exists, those direct entry points SHALL configure and build it without requiring console input. The shared Python launcher and POSIX direct entry points SHALL retain explicit confirmation requirements for a missing build.
+
+#### Scenario: Double-clicked Web launcher requires a first build
+- **WHEN** a developer starts the Windows Web batch entry point and no compatible configured build exists
+- **THEN** it configures, builds, and starts the Web target without waiting for confirmation input
+
+### Requirement: Windows compiler environment initialization
+Windows 启动器 SHALL 在需要 MSVC 编译时初始化与本机及目标架构匹配的 Visual Studio C++ 环境;x64 与 ARM64 默认配置预设 SHALL 实际存在。已验证的 MinGW 构建 SHALL 可以使用现有工具链,不要求安装 Visual Studio。缺少所需 MSVC 工具时 SHALL 在开始编译前明确报错,并说明 Build Tools C++ 工作负载要求。帮助、产物列表和 dry-run SHALL 不要求初始化编译环境。
+
+#### Scenario: Start from a normal Windows shell
+- **WHEN** a developer starts a Windows target-specific entry point from a shell without Visual Studio compiler variables
+- **THEN** the entry point initializes the developer environment and the incremental build receives the C++ standard-library include paths
+
+#### Scenario: Missing Visual Studio C++ tools
+- **WHEN** Windows 启动器需要 MSVC,但找不到适配架构的 Visual Studio C++ 开发环境
+- **THEN** 启动器报告所需 Build Tools 组件,并且不开始编译
+
+#### Scenario: 纯 MinGW 环境
+- **WHEN** 开发者提供当前工作树内有效的 x64-mingw-static 构建,且未安装 Visual Studio
+- **THEN** 启动器使用该构建的 MinGW 工具链继续增量编译
+
+### Requirement: Target-specific startup
+The launcher SHALL reuse the repository's existing Web and Desktop launch scripts for those targets. It SHALL start TUI in a new terminal window. Web startup SHALL use a runtime directory isolated to the current worktree and SHALL open the resulting local Web URL after successful startup. Desktop startup SHALL open the Desktop application after successful startup.
+
+#### Scenario: Isolated Web startup
+- **WHEN** a developer starts the Web target for a worktree
+- **THEN** its daemon uses a worktree-specific runtime directory and opens that daemon's URL
+
+#### Scenario: TUI startup
+- **WHEN** a developer starts the TUI target
+- **THEN** the launcher opens the TUI executable in a new terminal window
+
+#### Scenario: TUI 保留工作目录和参数
+- **WHEN** 工作树路径或 TUI 参数包含空格、引号及 shell 特殊字符
+- **THEN** 新终端 SHALL 在当前工作树目录执行 TUI,并将每个参数按原值传递;Windows SHALL 不使用 cmd/start 重新解释这些参数
+
+### Requirement: Web 开发实例占用检查
+Web 启动器 SHALL 在增量编译前检查所选 runtime 是否存在 daemon PID 记录。对于默认 runtime,检查 SHALL 包括当前工作树其他提交遗留的启动器目录。存在记录时 SHALL 使用现有 daemon status 机制有界核验并明确失败,避免编译后继续复用旧 worker 或覆盖正在运行的 Windows 可执行文件。核验成功时 SHALL 输出该实例的精确停止命令,核验失败时 SHALL 仅输出检查建议;启动器 SHALL NOT 自动终止进程或删除 runtime 文件。显式指定 runtime 时 SHALL 仅检查该目录。
+
+#### Scenario: 已存在开发 daemon
+- **WHEN** 所选或本工作树旧的默认 runtime 仍有 PID 记录
+- **THEN** 启动器在编译前退出,报告该目录和检查结果,不编译、不启动、不终止任何进程
+
+#### Scenario: 显式指定 runtime
+- **WHEN** 开发者传入 --run-dir
+- **THEN** 启动器只检查该目录,不检查、终止或清理默认目录内的 daemon
+
+### Requirement: Target-specific cross-platform entry points
+The repository SHALL provide dedicated thin Windows and POSIX entry points for Web, Desktop, and TUI. Each entry point SHALL select its target without requiring a target argument, delegate to the shared development-environment launcher, pass supported command-line arguments through unchanged, and provide a clear error when no supported Python interpreter is available.
+
+#### Scenario: Windows Web launch
+- **WHEN** a Windows developer runs the Web batch entry point
+- **THEN** it delegates to the shared launcher with the Web target selected
+
+#### Scenario: POSIX TUI launch
+- **WHEN** a macOS or Linux developer runs the TUI shell entry point
+- **THEN** it delegates to the shared launcher with the TUI target selected
diff --git a/openspec/changes/add-development-environment-launcher/tasks.md b/openspec/changes/add-development-environment-launcher/tasks.md
new file mode 100644
index 000000000..f9a154675
--- /dev/null
+++ b/openspec/changes/add-development-environment-launcher/tasks.md
@@ -0,0 +1,29 @@
+# Tasks
+
+## 1. Shared launcher
+
+- [x] 1.1 Add the cross-platform Python launcher with interactive target selection, target parsing, and platform-specific CMake preset selection; verify its help output and target-validation behavior.
+- [x] 1.2 Implement Git worktree build discovery and compatibility validation for source revision, executable, platform/architecture, and Desktop configuration; verify focused tests cover accepted and rejected candidates.
+- [x] 1.3 Implement automatic incremental builds for verified configured builds, confirmation only for missing/incompatible configuration, and explicit non-interactive or declined-configuration failures; verify no configure command runs without confirmation.
+
+## 2. Target startup and entry points
+
+- [x] 2.1 Delegate Web and Desktop starts to the existing Python surface launchers after refreshing frontend assets, use an isolated Web runtime directory, and report launch outcomes; verify forwarded arguments with stub launchers.
+- [x] 2.2 Start TUI from the verified executable in a new terminal window on supported platforms and report unsupported terminal-launch behavior; verify command construction with focused tests.
+- [x] 2.3 Provide target-specific `dev_web`, `dev_desktop`, and `dev_tui` Windows/POSIX entry points that select a fixed target and forward supported arguments unchanged; verify syntax and `--help` delegation on available platforms.
+
+## 3. Windows toolchain and verification
+
+- [x] 3.1 Add a shared Windows Visual Studio developer-environment helper and invoke it from each target-specific batch entry point; verify a normal shell receives C++ compiler include paths.
+- [x] 3.2 Make Windows target-specific entry points automatically approve a missing-build configuration, while preserving explicit confirmation for Python and POSIX callers; verify wrapper argument forwarding and focused launcher tests.
+- [x] 3.3 Run Python compilation checks and `openspec validate add-development-environment-launcher --strict`.
+
+## 4. 发布前审查修复
+
+- [x] 4.1 保留现有构建类型,并让多配置构建、产物选择与实际启动使用同一配置;补充 Debug/Release 并存回归。
+- [x] 4.2 完整传递 TUI 参数,Windows 直接创建新控制台以避免 shell 转义错误,macOS 显式设置工作目录;验证特殊字符参数与启动错误传播。
+- [x] 4.3 正确识别 Windows MinGW 构建,按本机架构初始化 MSVC,仅需要 MSVC 时要求安装 Visual Studio。
+- [x] 4.4 Web 重编译前有界核验既存 runtime 并明确失败;提供身份已验证实例的停止命令,身份不明时仅提示检查,不自动终止进程或删除状态文件。
+- [x] 4.5 运行隔离 Python 回归、语法检查及 OpenSpec 严格验证,记录三平台实际验证边界。
+
+验证记录(2026-09-20):54 项 `dev_*test.py` 测试通过;9 个 Python 文件通过 Python 3.8 语法解析;3 个 POSIX 包装脚本通过 Bash 语法检查;两个 OpenSpec change 严格验证通过。Windows 实际验证 MSVC x64 环境初始化与包含空格、`&`、`%PATH%`、`!` 的辅助脚本路径。macOS/Linux 验证命令构造及参数保留,未启动图形界面;未运行 ARM64 原生构建。
diff --git a/openspec/changes/add-macos-computer-use/.openspec.yaml b/openspec/changes/add-macos-computer-use/.openspec.yaml
new file mode 100644
index 000000000..cbd245e43
--- /dev/null
+++ b/openspec/changes/add-macos-computer-use/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-20
diff --git a/openspec/changes/add-macos-computer-use/design.md b/openspec/changes/add-macos-computer-use/design.md
new file mode 100644
index 000000000..b8ea806cb
--- /dev/null
+++ b/openspec/changes/add-macos-computer-use/design.md
@@ -0,0 +1,38 @@
+## Context
+
+The existing Windows implementation has a process-wide broker, a native worker, bounded JSON Lines, serial tools, one-action observations, screenshot metadata and model image delivery. Its process management, native implementation, tool language and support checks are Windows-specific. The desktop bundle currently declares macOS 11 compatibility and packages the daemon but no Computer Use worker.
+
+## Goals / Non-Goals
+
+**Goals:** Implement the existing Computer Use tool family on macOS 14+, preserve Windows behavior, make permission and installation failures actionable, and validate the production helper using disposable native fixtures.
+
+**Non-Goals:** Linux desktop control, reproducing Codex's private runtime, bypassing TCC or protected/locked desktops, and changing the application's macOS 11 minimum. OS-denied operations fail explicitly rather than pretending to succeed.
+
+## Decisions
+
+1. Keep the C++ tool/protocol boundary. Give NativeBackend a platform-neutral header and implement the Mac backend in focused Objective-C++ modules for windows, AX, capture, input and pointer rendering. Do not embed AX calls into the daemon or desktop UI process.
+2. The Mac broker uses posix_spawn with an isolated private socket connected to helper stdin/stdout, nonblocking bounded I/O, reaping and cancellation. A user-session lock arbitrates between ACECode instances. Parent death and normal shutdown revoke the helper; launched applications are not terminated with it.
+3. The helper owns an AppKit main run loop; protocol dispatch and potentially blocking AX calls run off the main thread. AX calls have timeouts; the broker remains the final deadline. Input releases and overlay cleanup are explicit on normal stop and parent loss.
+4. Use public APIs: NSWorkspace and bundle IDs for apps, CG/SC window identifiers and AX relationships for windows, ScreenCaptureKit single-frame capture, AXUIElement for controls and CGEvent for input. Bind AX windows to screenshot windows using verified process and geometry evidence; reject ambiguous matches. Do not use private AX window-number functions.
+5. Keep screenshot_pixels as the public coordinate space. Store each surface's screen-point rectangle, captured pixel size and transforms, excluding capture shadows where possible. Map using actual image sizes, never a global Retina multiplier. Verify identity, geometry, focus, related surfaces and hit target again before action. Consume observations on attempted actions and require reobservation after errors.
+6. Discover related sheets/popovers/menus through AX ownership and hierarchy rather than assuming that every window of one process is related. Report independent screenshot identity and bounds for captured surfaces. Missing or unsupported surfaces are explicit; avoid blind input. Secure AX values are omitted and tree traversal has node, depth, text and time budgets.
+7. Preserve tool names and image attachment format. Use platform-aware key/chord examples, application identity language, scroll units and advertised accessibility actions. Implement Cmd/Option/Control/Shift, Unicode typing, clicks, drag, scroll, writable AX values and advertised secondary actions.
+8. Settings expose stored enabled intent separately from supported OS, helper availability, accessibility/screen-recording permission and ready status. Reads never request permission. Authenticated explicit user actions request one named permission; no model tool can open authorization prompts. Probe with the same helper binary that performs operations so the reported identity matches the execution path.
+9. A nonactivating mouse-transparent native pointer follows configured ACE/plain style and theme color. Exclude the overlay from target enumeration/hit tests and composite it at most once into returned images. Rendering uses the AppKit main thread.
+10. Build the worker separately from the macOS 11 host targets and only start it on compatible OS versions. Include it alongside the desktop daemon and standalone executable; sign nested code before the outer bundle. Add package presence/architecture checks and document permission ownership for desktop and TUI launches.
+
+## Risks / Trade-offs
+
+- TCC attribution can depend on launch path and signing: verify both installed app and terminal paths, record actual grant behavior; never claim a read-only probe proves successful capture/input.
+- A target can change between observation and action: retain one-use observations and fail on changed identity, geometry, focus or hit-testing evidence.
+- Third-party AX calls can hang: messaging timeout plus cancellable helper; do not hold daemon configuration locks during probes.
+- Mac keyboard layouts, mixed-DPI screens and transient windows vary: pure mapping tests plus native fixture assertions; document hardware combinations actually exercised.
+- New capture APIs exclude macOS 11-13: disable this capability there while preserving all existing host functionality.
+
+## Migration Plan
+
+Keep existing default-disabled configuration and Windows behavior. Add response fields compatibly and preserve legacy clients. Ship helper with every Mac artifact; update tool/UI descriptions and daemon API docs. Reverting the feature leaves existing configurations readable.
+
+## Open Questions
+
+Native fixture verification passed on the authorized Intel/Retina desktop; see `verification.md`. Permission attribution for a Developer ID-signed installed bundle and standalone terminal launch still needs verification when those release artifacts are available. Missing OS authorization never counts as a passing test.
diff --git a/openspec/changes/add-macos-computer-use/proposal.md b/openspec/changes/add-macos-computer-use/proposal.md
new file mode 100644
index 000000000..6abbab00d
--- /dev/null
+++ b/openspec/changes/add-macos-computer-use/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+ACECode already provides Windows Computer Use, but macOS users cannot observe or operate native applications. Add the same observation/action loop on macOS with explicit system authorization and a cancellable native helper.
+
+## What Changes
+
+- Implement a macOS 14+ Objective-C++ helper for application/window discovery, screenshots, accessibility, input, control actions and the themed pointer.
+- Preserve the existing computer_* tools, one-use observations, screenshot attachments, session ownership and cancellation semantics; make platform-specific descriptions explicit.
+- Add non-prompting permission diagnostics and user-triggered authorization in Settings. Keep enabled intent distinct from runtime readiness.
+- Deliver the helper in desktop and standalone packages with signing and installation checks. Older macOS versions retain the existing application features and report Computer Use as unsupported.
+- Add deterministic broker/geometry tests and opt-in native fixtures that operate only on disposable test windows; verify the actual helper path and packaged layout.
+
+## Capabilities
+
+### New Capabilities
+- `macos-computer-use`: macOS native observation, input, authorization and helper lifecycle.
+
+### Modified Capabilities
+
+None. The Windows Computer Use change remains a compatibility baseline.
+
+## Impact
+
+Changes cover src/computer_use, tool registration/descriptions, settings API and UI, CMake, macOS packaging, daemon API documentation and tests. Apple frameworks are linked only to appropriate macOS targets. No Codex private runtime or new service is required.
diff --git a/openspec/changes/add-macos-computer-use/specs/macos-computer-use/spec.md b/openspec/changes/add-macos-computer-use/specs/macos-computer-use/spec.md
new file mode 100644
index 000000000..9ce018660
--- /dev/null
+++ b/openspec/changes/add-macos-computer-use/specs/macos-computer-use/spec.md
@@ -0,0 +1,51 @@
+## ADDED Requirements
+
+### Requirement: Explicit capability and system authorization
+The system SHALL support Computer Use on macOS 14+ and distinguish enabled intent, supported OS, helper availability, accessibility permission, screen recording permission and readiness. Reading settings MUST NOT request OS permission. Only an authenticated, explicit user request SHALL trigger authorization for a validated permission name.
+
+#### Scenario: Missing permission
+- **WHEN** Computer Use is enabled and an OS permission is absent
+- **THEN** Settings identifies the missing permission, native operations return an actionable error, and neither settings reads nor model tools open a permission prompt
+
+#### Scenario: Unsupported older macOS
+- **WHEN** the host runs macOS earlier than 14
+- **THEN** it reports this capability unsupported without loading an incompatible helper or breaking other application features
+
+### Requirement: Owned cancellable helper
+The system SHALL use a private helper channel with bounded framing, one session owner and interprocess desktop exclusion. Disable, release, cancellation, parent exit, helper failure and timeout SHALL revoke observations and release ownership. Applications launched by the helper SHALL survive control release.
+
+#### Scenario: Target application stops responding
+- **WHEN** the helper stalls during a request and the user cancels or disables Computer Use
+- **THEN** the call terminates promptly and another session can acquire a fresh worker without reusing the old observation
+
+### Requirement: Native observation and identity
+The system SHALL discover installed and running apps, verify window identities, and return window state with bounded accessibility data, focus/selection context and screenshots. Every screenshot SHALL carry its own identity, provenance and coordinate transform. Related transient surfaces SHALL be tied to verified ownership rather than process equality alone. Password values SHALL be omitted.
+
+#### Scenario: Window is replaced or a menu changes
+- **WHEN** the target window, process identity, geometry, focus or related surface changes after observation
+- **THEN** the next action rejects stale state and requires a new observation
+
+### Requirement: Complete existing action family
+The system SHALL implement app launch, window activation, coordinate/element clicks, Unicode typing, native key chords, two-axis scrolling, drag, writable accessibility values and advertised secondary accessibility actions. Each observed action SHALL consume its observation, validate its target and release injected keys/buttons. Unsupported controls SHALL return explicit errors.
+
+#### Scenario: Mixed coordinate scales
+- **WHEN** a screenshot is resized or its display uses a different pixel scale
+- **THEN** actions and accessible bounds refer to the actual returned image and map to the intended screen-point target without a fixed Retina multiplier
+
+#### Scenario: Keyboard and control input
+- **WHEN** a fresh observation targets a writable test control
+- **THEN** Unicode text, Cmd/Option/Control/Shift chords and advertised control actions change the expected control and subsequent observation confirms the result
+
+### Requirement: Themed pointer and model feedback
+The system SHALL preserve ACE/plain themed pointer appearance, render a nonactivating input-transparent overlay, and include at most one correctly placed pointer in each screenshot. Existing model attachment ordering and screenshot labels SHALL remain intact.
+
+#### Scenario: Appearance changes during control
+- **WHEN** the user changes pointer style or theme color
+- **THEN** the next render uses the new appearance without enabling control or unnecessarily destroying the current observation
+
+### Requirement: Packaged and verified execution
+The system SHALL include the helper in macOS desktop and standalone distributions, sign nested executables appropriately, and resolve the delivered helper without PATH lookup. Validation SHALL cover deterministic transport and coordinate behavior, actual native fixture interactions and package contents. Unexecuted permission-dependent tests SHALL remain explicitly unverified.
+
+#### Scenario: Installed helper missing
+- **WHEN** the delivered helper is missing or cannot start
+- **THEN** status and execution identify the installation problem instead of reporting the feature ready
diff --git a/openspec/changes/add-macos-computer-use/tasks.md b/openspec/changes/add-macos-computer-use/tasks.md
new file mode 100644
index 000000000..ed8b5c80c
--- /dev/null
+++ b/openspec/changes/add-macos-computer-use/tasks.md
@@ -0,0 +1,22 @@
+## 1. Helper and platform boundary
+- [x] 1.1 Introduce a shared NativeBackend declaration and macOS nonblocking helper transport with cancellation, reaping, framing and parent lifetime handling.
+- [x] 1.2 Build the macOS helper with an AppKit main loop and an interprocess desktop lease; verify the broker fixture through the production transport.
+
+## 2. Native macOS behavior
+- [x] 2.1 Implement application/window discovery, launch, verified AX/window mapping and activation.
+- [x] 2.2 Implement bounded AX observations including focus, selection, secure-field filtering and related transient surfaces.
+- [x] 2.3 Implement ScreenCaptureKit screenshots, per-surface pixel/point mapping and attachment metadata; test scaling and invalid coordinates.
+- [x] 2.4 Implement all existing input/control actions with one-use observation, identity, geometry, focus and hit-target checks.
+- [x] 2.5 Implement ACE/plain themed pointer overlay and screenshot composition with cleanup and no focus/input interception.
+
+## 3. Integration and distribution
+- [x] 3.1 Add non-prompting helper readiness diagnostics and explicit user-triggered permission requests; update API, settings UI, translations and documentation.
+- [x] 3.2 Make tool descriptions, support gates and keyboard/scroll semantics platform-aware without changing Windows behavior.
+- [x] 3.3 Package and sign the helper for desktop and standalone macOS artifacts; add package verification.
+
+## 4. Validation
+- [x] 4.1 Pass focused native-independent C++ tests and broker lifecycle/transport tests, including malformed input, cancellation, large responses and ownership.
+- [x] 4.2 Pass native fixture checks through the actual helper for capture, AX, Unicode/key input, clicks, scroll, drag, control actions, transient UI and pointer behavior; record real permission and display coverage.
+- [x] 4.3 Run frontend tests/build, affected package checks, C++ build and relevant regression tests; review final diff and record remaining verification limits honestly.
+
+Native interaction acceptance passed on the authorized Intel Mac on 2026-09-21. See `verification.md` for executed checks and permission/hardware limits.
diff --git a/openspec/changes/add-macos-computer-use/verification.md b/openspec/changes/add-macos-computer-use/verification.md
new file mode 100644
index 000000000..47b71a0f7
--- /dev/null
+++ b/openspec/changes/add-macos-computer-use/verification.md
@@ -0,0 +1,48 @@
+# Verification record
+
+## Environment
+
+2026-09-20–21; macOS 26.5.2, Intel x86_64, SDK 26.5. CMake build tree: `build/macos-x64-agent-browser-test`. Native helper has Mach-O minimum OS 14.0. An arm64 helper also compiled and linked against the same SDK; it was not executed on this Intel host. Native interaction ran on the built-in display at 1440×900 screen points, AppKit backing scale 2.0; the current keyboard-layout preference was `com.apple.keylayout.PinyinKeyboard`.
+
+## Passed
+
+- CMake targets: `acecode-desktop`, `acecode_unit_tests`, `acecode-computer-use`, `computer_use_broker_smoke`, and `computer_use_native_mac_smoke`.
+- 70 focused GoogleTests: filter `ComputerUse*:*ComputerUse*:*ToolImage*`. Includes coordinate limits/scaling, action/schema gates, settings persistence, authenticated HTTP permission validation and model image delivery.
+- Production POSIX broker with its isolated fixture: ownership, appearance continuity, 8 MiB response, cancellation, disable/re-enable, crash, invalid JSON reply, 20-second timeout, and daemon startup with stdin/stdout closed.
+- Actual helper lifecycle suite: malformed/oversize/versioned requests, forged observation, exclusive interprocess lease, parent death, and permission probes that do not take the control lease. Registered CTest `computer_use_macos_helper_lifecycle` passed.
+- `pnpm test` and `pnpm build`; production regex compatibility check passed. A temporary component preview using the real generated CSS confirmed the permission rows, pointer cards and simulated permission/enable state transitions. This was UI verification with stubbed API responses, not an OS permission grant.
+- Seven npm packaging tests cover all six platform artifacts and missing/empty Computer Use sidecars. Five affected package CTests passed: verification script units, portable ZIP units, release-script contracts, PKG installation contracts, and verification flow contracts.
+- `verify-package --skip-build --target all` passed all 20 checks on the actual built desktop/CLI payload: standalone/bundled helper adjacency, models.dev and seed resources, CLI version/resource lookup, and isolated desktop launch/termination.
+- Actual update ZIP created and extracted with `macos_create_update_zip.sh`; both helper copies are executable and identical. App-bundle helper also matches the CMake output byte-for-byte.
+- OpenSpec strict validation and `git diff --check`.
+- Before native acceptance, the locked desktop was explicitly rejected by the helper with `desktop_locked`; public NSWorkspace metadata identified `com.apple.loginwindow` as frontmost.
+
+## Native acceptance — task 4.2 passed
+
+On 2026-09-21, the user unlocked the desktop and granted Accessibility. The actual bundled helper reports both Accessibility and Screen Recording granted, with `ready: true`. The CMake-built native fixture then completed with exit **0**:
+
+```sh
+build/macos-x64-agent-browser-test/tests/computer_use_native_mac_smoke.app/Contents/MacOS/computer_use_native_mac_smoke --run-owned-window
+```
+
+The fixture operates only on its own disposable windows. The passing native run exercised real screenshot pixels against AX coordinates; protected-field filtering; element and coordinate clicks; Unicode typing; Command, Option, Control and Shift chords; AX value/invoke/toggle; horizontal and vertical scroll; drag and mouse-button release; one-use observations; stale geometry and changed-focus rejection including another window of the same process; sheet and context-menu capture/input; ACE/plain pointer pixels and cleanup; and exact app discovery/launch.
+
+This run exposed and fixed a real occlusion bug: the system Dock can own a screen-sized transparent backing window. The helper now ignores that specific system-owned backing rectangle while retaining the system-wide AX hit test. The native fixture also verifies that an actual opaque input panel still blocks the click. Fixture improvements include refreshing the bundled helper when its binary changes, checking bitmap samples in their actual color profile, providing the standard Edit menu for Cmd+A, reobserving explicitly reported AX/WindowServer transitions, and waiting for the menu action callback rather than assuming its animation finishes in 150 ms. Input actions are never retried by the fixture.
+
+Optional `ACECODE_MAC_FIXTURE_ARTIFACTS` saves only the fixture's own screenshots. Main-window and independent context-menu PNGs were inspected during this run. Prior permission-denied exit 77 and locked-desktop rejection remain evidence of the failure paths, not native acceptance.
+
+## Final artifact refresh
+
+After the fixes, the final fixture passed again with exit 0. The rebuilt production targets, all 70 focused GoogleTests, the actual-helper lifecycle CTest, and all 20 package checks also passed. The Intel helper has SHA-256 `0b564c4fb514bb4b855407f284afbab6536253a9f9d22a9e97062d3ad003c821`, identical in the build root, fixture bundle, desktop bundle, and both staged package locations. The final arm64 helper compiled and linked successfully; `lipo` confirmed arm64 and `vtool` confirmed minimum macOS 14.0 for both architectures.
+
+Local evidence from this run:
+
+- Native acceptance: `/tmp/acecode-macos-computer-use-native-final.log`.
+- Focused regression results: `/tmp/acecode-macos-computer-use-regression-final.log`.
+- Package verification: `/tmp/acecode-macos-computer-use-package-final.log`.
+- Refreshed staging: `/tmp/acecode-macos-computer-use-package-final`.
+- Created and extraction-verified update ZIP: `/tmp/acecode-macos-computer-use-update-final.zip`.
+
+## Coverage limits
+
+Real mixed-display interactions, additional keyboard layouts, Apple Silicon execution and Developer ID-signed installed-app/TUI permission attribution remain unverified. Single-display Retina interactions passed; negative origins and differing image scales have deterministic tests. Release signing now includes the standalone helper and existing nested-code signing covers its bundled copy; no release signing, notarization or publishing was performed here. Existing Windows native code was preserved, but Windows runtime tests were not executed on this host.
diff --git a/openspec/changes/add-project-mcp-config-validation/.openspec.yaml b/openspec/changes/add-project-mcp-config-validation/.openspec.yaml
new file mode 100644
index 000000000..d28e909f6
--- /dev/null
+++ b/openspec/changes/add-project-mcp-config-validation/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-17
diff --git a/openspec/changes/add-project-mcp-config-validation/design.md b/openspec/changes/add-project-mcp-config-validation/design.md
new file mode 100644
index 000000000..fcb171892
--- /dev/null
+++ b/openspec/changes/add-project-mcp-config-validation/design.md
@@ -0,0 +1,32 @@
+## Context
+
+daemon 会话共享 `McpManager` 与 `ToolExecutor`。现有公共配置支持原子写入和完整文档恢复,但 MCP 的启动、HTTP 和 TUI 解析各自独立。用户提供 https://modelcontextprotocol.io/specification/2026-07-28/schema 并已确认本次使用 ACECode 配置 Schema。
+
+## Goals / Non-Goals
+
+**目标:** 统一 Schema,按范围保存与恢复有效配置,写入失败不发布候选状态,在共享 daemon 中隔离项目工具。
+
+**非目标:** 升级 MCP 协议、OAuth 或拦截外部程序写文件。外部修改在加载、启动前校验。
+
+## Decisions
+
+1. 项目文件为 `/.acecode/mcp.json`,使用 `{ "mcp_servers": { ... } }`。从工作目录向上查找,以最近 Git 仓库/worktree 为边界,复用项目目录工具并在用户主目录前停止;没有已有文件则使用 Git 根目录,没有 Git 根目录则使用工作目录。
+2. `AppConfig.mcp_servers` 只保存公共配置。项目覆盖独立解析,规范化项目路径生成内部 owner;能力策略将可见名称映射到确切 owner,disabled 项目条目也不得回退到同名公共服务器。
+3. 内置离线配置 Schema,集中校验其使用的关键词。先校验原始 JSON,再构造 C++ 结构,避免转换和忽略字段掩盖错误。API 与诊断返回同一规则。Windows 的带边界正则使用完整匹配,拒绝字段中的嵌入或结尾换行。
+4. 每个文件保存受保护的 `.mcp-last-good` 快照,恢复前重新校验。公共恢复仅替换 MCP 段,项目恢复专用文件;归档无效原文,不允许部分成功加载。
+5. 持久化成功后再发布 AppConfig 和运行时。快照保存失败须撤销活动文件变更。读取时隐藏的认证字段在省略时保留,空字符串显式清除。通用配置保存、受管理文本编辑经过相同校验。
+6. 设置选择公共或已登记项目,API 默认仍为公共。按 owner 调整注册项,经会话串行控制入口刷新能力策略,以单调递增 generation 丢弃过期异步连接结果。
+7. 显式专家选择允许使用默认禁用的服务器。保留其连接时,运行时仍保存真实的 disabled 定义,后续刷新不应重复拆除连接。
+8. `/mcp` 命令保持运行时控制语义,按当前项目解析名称;持久化编辑由设置和管理界面完成。
+
+## Risks / Trade-offs
+
+- 原先被忽略的字段错误现在被拒绝;保留已废弃 TLS 字段的类型兼容并继续忽略。
+- 快照可能包含认证信息,沿用受保护写入,诊断不回显字段值。
+- 无有效快照时报告 Schema 错误,不猜测配置。
+- 测试须显式建立独立 Git 边界,避免用户主目录属于 Git 仓库时修改真实配置。
+- Windows 正则行为与其他平台不同,保留换行、NUL 等回归用例。
+
+## Migration Plan
+
+公共配置不搬迁,首次有效加载建立 MCP 快照;已有完整配置备份重新验证后可用于首次恢复。项目通过独立文件或设置界面启用。回退软件版本仍可读取公共配置,项目文件保持独立。
diff --git a/openspec/changes/add-project-mcp-config-validation/proposal.md b/openspec/changes/add-project-mcp-config-validation/proposal.md
new file mode 100644
index 000000000..c32a55f82
--- /dev/null
+++ b/openspec/changes/add-project-mcp-config-validation/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+MCP 当前只有公共配置,启动、HTTP 与 TUI 分别解析,错误字段可能被静默忽略。需要每个项目独立配置,拒绝无效修改,并防止共享 daemon 中的工具跨项目串用。
+
+## What Changes
+
+- 新增项目 `.acecode/mcp.json`,叠加公共配置,同名条目仅在本项目覆盖公共定义。
+- 统一使用 ACECode 客户端配置 JSON Schema。用户已确认校验配置文件;提供的 MCP 2026-07-28 Schema 描述协议消息,本次不升级协议。
+- 应用管理的写入先校验,再持久化并更新运行时;失败返回 JSON Pointer 诊断和完整 Schema。
+- 启动前校验,各配置文件保存独立有效快照;无效配置恢复该范围的上次有效配置,无快照则报告错误。
+- 设置支持项目选择和 Schema 查看,TUI 与通用文本编辑接入相同校验。
+
+## Capabilities
+
+### New Capabilities
+
+- `scoped-mcp-configuration`:公共/项目配置、运行时隔离、统一校验与恢复。
+
+### Modified Capabilities
+
+无。本 checkout 没有已有主规格。
+
+## Impact
+
+配置读写、MCP 运行时、会话能力策略、HTTP 与 TUI、通用文件编辑、网页设置、测试和文档。已有公共 API 保持兼容,无第三方代码变更。
diff --git a/openspec/changes/add-project-mcp-config-validation/specs/scoped-mcp-configuration/spec.md b/openspec/changes/add-project-mcp-config-validation/specs/scoped-mcp-configuration/spec.md
new file mode 100644
index 000000000..0fd0a4c86
--- /dev/null
+++ b/openspec/changes/add-project-mcp-config-validation/specs/scoped-mcp-configuration/spec.md
@@ -0,0 +1,53 @@
+## Purpose
+
+支持公共与项目 MCP 配置、统一校验、隔离项目工具,并恢复外部无效编辑之前的有效配置。
+
+## ADDED Requirements
+
+### Requirement: 项目配置叠加公共配置
+系统 SHALL 独立加载项目 `.acecode/mcp.json`。项目条目 SHALL 仅在本项目替换同名公共条目,包括 disabled 条目;其他公共服务器保持可用。共享 daemon 的项目工具 SHALL 不可由其他项目使用。
+
+#### Scenario: 两个项目覆盖同名服务器
+- **WHEN** 项目 A、B 为同名服务器配置不同命令
+- **THEN** 各自发现并调用对应服务器,公共配置和另一项目保持不变
+
+#### Scenario: 项目禁用继承的服务器
+- **WHEN** 项目定义与公共服务器同名的 disabled 条目
+- **THEN** 该项目默认会话不能通过此名称回退调用公共服务器
+
+### Requirement: 受管理编辑在修改前校验
+系统 SHALL 对 MCP API 写入、开关、TUI 编辑、通用配置保存和受管理文本编辑使用同一配置 JSON Schema。无效 JSON 或 Schema 错误 SHALL 保持配置、快照与运行时注册不变;错误 SHALL 包含字段路径和完整 Schema,不回显认证信息。
+
+#### Scenario: 任一范围的替换内容无效
+- **WHEN** 写入含错误传输方式、字段类型、缺失必填字段、无效 URL 或超时值
+- **THEN** 拒绝整次修改,返回错误和 Schema,保留之前的配置
+
+#### Scenario: 编辑隐藏认证字段
+- **WHEN** 保存时省略读取 API 隐藏的认证字段
+- **THEN** 保留同名服务器的原认证值,显式空字符串仍可清除该值
+
+#### Scenario: 有效快照持久化失败
+- **WHEN** 合法配置已写入,但快照无法保存
+- **THEN** 活动文件恢复写入前内容,不发布候选状态到内存和运行时
+
+### Requirement: 启动只恢复无效的 MCP 范围
+系统 SHALL 在启动服务器前校验并按范围保留上次有效配置。校验失败 SHALL 再次验证快照、归档无效原文并恢复该范围,保留无关设置;不得启动无效配置或只接纳其中的合法条目。
+
+#### Scenario: 外部编辑破坏一个项目
+- **WHEN** 已有有效配置的项目文件变为无效
+- **THEN** 启动恢复该项目上次有效配置,公共配置和其他项目不变
+
+#### Scenario: 公共 MCP 段无效且其他设置已变更
+- **WHEN** 公共 MCP 段无效,同时存在合法的其他设置修改
+- **THEN** 仅恢复 MCP 段,保留其他修改
+
+#### Scenario: 不存在有效回退配置
+- **WHEN** 当前配置无效且快照缺失或无效
+- **THEN** 返回 Schema 诊断,不启动无效范围中的服务器
+
+### Requirement: 区分配置 Schema 与协议 Schema
+系统 SHALL 将其校验标为 ACECode 客户端配置 Schema,并引用 MCP 规范,不得将配置规则表述为官方协议消息 Schema。
+
+#### Scenario: 编辑器收到校验错误
+- **WHEN** 设置修改未通过校验
+- **THEN** 显示字段错误,保留草稿,并允许查看完整配置 Schema
diff --git a/openspec/changes/add-project-mcp-config-validation/tasks.md b/openspec/changes/add-project-mcp-config-validation/tasks.md
new file mode 100644
index 000000000..caa487be5
--- /dev/null
+++ b/openspec/changes/add-project-mcp-config-validation/tasks.md
@@ -0,0 +1,16 @@
+## 1. 配置契约
+
+- [x] 1.1 实现统一配置 Schema、诊断、严格解析/序列化与项目覆盖;通过 Schema 和覆盖规则单元测试。
+- [x] 1.2 按范围保存有效快照与启动恢复,接入通用配置读写;验证无效写入、快照失败撤销与仅恢复 MCP 段。
+
+## 2. 运行时与编辑入口
+
+- [x] 2.1 接入项目运行时 owner、会话/headless/TUI 能力解析;验证同名隔离、专家保留与过期连接丢弃。
+- [x] 2.2 MCP HTTP 读写、开关、重新加载接入统一服务,提供 Schema 发现;验证认证、校验、凭证保留、保存失败和项目 API。
+- [x] 2.3 TUI 和通用文件编辑在修改前校验;验证 Write/Edit/Patch 拒绝,并编译 TUI 集成。
+- [x] 2.4 设置增加公共/项目选择与 Schema 诊断,沿用样式与国际化;前端完整测试、目录生成和生产构建通过。
+
+## 3. 交付验证
+
+- [x] 3.1 文档说明覆盖、Schema、恢复及 API 响应;通过严格 OpenSpec 校验与差异检查。
+- [x] 3.2 运行 C++ 定向集成测试和前端检查,审查最终变更,记录验证范围和限制;详见 validation.md。
diff --git a/openspec/changes/add-project-mcp-config-validation/validation.md b/openspec/changes/add-project-mcp-config-validation/validation.md
new file mode 100644
index 000000000..29c3944a9
--- /dev/null
+++ b/openspec/changes/add-project-mcp-config-validation/validation.md
@@ -0,0 +1,36 @@
+# 验证记录
+
+验证环境为 Windows、MSVC 19.39、Release 构建。worktree 使用独立 build 和 web/node_modules;CMake 复用主 checkout 已安装的 vcpkg 依赖库,不复用其构建输出。
+
+## 编译与单元测试
+
+- `cmake --build build --config Release --target acecode_unit_tests acecode --parallel 4` 通过,已嵌入本次生产前端构建。
+- 以下定向回归共 251 项,全部通过:
+
+```text
+build/tests/Release/acecode_unit_tests.exe --gtest_filter=*Mcp*:*MCP*:*ConfigRecovery*:*ConfigMutation*:*ConfigFirstInit*:*Expert*:*TextFileBuffer*:*ApplyPatch*:*FileWrite*:*FileEdit*:*ManagementCenterRender*
+```
+
+覆盖严格字段/传输校验、Windows 换行与 NUL、整数数值语义、隐藏凭证保留、同名项目覆盖和工具调用隔离、专家连接保留、过期连接结果、无效写入不落盘、快照写入失败撤销、项目 JSON 损坏恢复、仅恢复公共 MCP 段、主目录仓库边界、迁移数据目录、API 编辑与 TUI 渲染。
+
+## 前端与浏览器
+
+- `pnpm i18n:catalog`、`pnpm test`、`pnpm build` 全部通过,生成目录由脚本更新。
+- 本地 Chrome + 隔离 Vite 测试页面,使用模拟 API:390px/1280px × 中文/英文,四组均设置减少动画并通过。
+- 验证项目切换、请求范围、无效草稿保留、阻止无效草稿切换范围、完整 Schema 展开、修正后保存、公共配置保持独立、无效 JSON 也可查看 Schema;未出现页面异常或页面横向溢出。
+- 使用 Ajv 独立编译发布的配置 Schema,对合法配置、错误参数类型、嵌入/结尾换行等样例验证一致。
+
+## 实际进程验证
+
+使用本 worktree 的 acecode.exe、两个测试 MCP 子进程、独立临时用户数据目录及项目目录运行 daemon,没有连接已安装客户端或现有 daemon。
+
+- 所有 MCP 路由在跨 loopback origin 且缺少认证令牌时均拒绝请求。
+- 公共配置与两个项目的同名覆盖均可保存并热应用,项目能力目录显示正确实例与工具数。
+- 无效写入返回完整 Schema,活动文件和公共配置保持原值。
+- 项目文件损坏后 reload 恢复有效快照;项目开关不影响另一项目。
+- daemon 重启时恢复无效公共 MCP 段,保留同时修改的其他设置。
+- headless 清单使用项目配置;无有效回退的错误项目以非零退出码和结构化 Schema 诊断结束。
+
+## 范围
+
+OpenSpec 严格校验与 `git diff --check` 通过。此次运行的是定向 C++ 测试、完整前端测试和 Windows 构建/进程验证;未运行全部 C++ 测试套件,也未验证 macOS/Linux 或已安装 Desktop 包。变更保留在 codex/mcp-core worktree 中供审查。
diff --git a/openspec/changes/add-saved-model-drag-reorder/.openspec.yaml b/openspec/changes/add-saved-model-drag-reorder/.openspec.yaml
new file mode 100644
index 000000000..d28e909f6
--- /dev/null
+++ b/openspec/changes/add-saved-model-drag-reorder/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-17
diff --git a/openspec/changes/add-task-suggestions-and-session-continuation/design.md b/openspec/changes/add-task-suggestions-and-session-continuation/design.md
index 938c54a80..40e9167d4 100644
--- a/openspec/changes/add-task-suggestions-and-session-continuation/design.md
+++ b/openspec/changes/add-task-suggestions-and-session-continuation/design.md
@@ -15,6 +15,15 @@ The domain service owns suggestion state, execution location and startup receipt
5. GET session suggestions returns persisted records, source/workspace busy and worktree availability. Accept/dismiss POST returns a suggestion envelope. Host lifecycle work handles queued acceptance independently of tab focus; reconnect also recovers interrupted phases. Expected failures are shown on the card with retry against the same target.
6. A compact non-modal card is attached to the chat's upper right, uses theme tokens, supports keyboard and narrow screens, and never steals focus. Apply the theme's large and ordinary shadow tokens to each card, and reserve padding inside the scrolling container so overflow does not clip the shadow while preserving the visible card position. Side tasks expose a split location action and stay in the source view; continuation starts in the same directory and navigates to its successor. Polling is bounded/single-flight and source-scoped, with stale responses ignored. Unsupported old backends silently omit this optional surface.
+### 2026-09-19:建议卡片自动关闭
+
+- 控制器为首次展示的 `pending` 建议记录绝对截止时间(30 秒),使用可注入时钟和定时器;轮询刷新不得重置倒计时,多张卡片各自计时,销毁控制器时清理所有定时器。
+- 到期沿用既有 dismiss 请求持久化关闭,不触发 accept。用户开始任务、手动关闭或建议进入排队/启动/失败状态后停止本次计时,防止到期取消已接受的任务;关闭失败保留卡片和重试操作,不自动反复请求。
+- 倒计时文字位于底部细线左上方,中文严格为“xx秒后关闭”,英文为“Closes in xx s”。进度由满到空,右端以小火苗随剩余长度左移,最后 5 秒略微提亮;火苗保持约 6×9px,以柔和外焰和微亮内芯轻微摆动、伸缩及明暗变化,不添加粒子或扩大光晕。沿用主题 token、原有标题、按钮和关闭图标。减少动态效果时按秒更新长度,关闭平滑移动、火苗闪动与光晕。
+- 倒计时只负责当前展示周期;切换会话销毁旧控制器,重新进入尚未关闭的建议时开始新的 30 秒展示。已成功关闭的建议仍由服务端过滤。
+- 任务位置菜单通过 `AnchoredMenu` 挂载至 `document.body`;调用方必须显式提供主题实底、边框、圆角、内边距、阴影和高于建议卡片的层级,避免选项透底或被卡片遮挡。保持既有定位、键盘选择及焦点恢复行为。
+
+
## Risks / Trade-offs
- A shared directory can also be modified by external editors or processes. Serialize known session work and tell the user the location is shared; existing file read guards still apply.
diff --git a/openspec/changes/add-task-suggestions-and-session-continuation/proposal.md b/openspec/changes/add-task-suggestions-and-session-continuation/proposal.md
index 3f665f569..694e9460f 100644
--- a/openspec/changes/add-task-suggestions-and-session-continuation/proposal.md
+++ b/openspec/changes/add-task-suggestions-and-session-continuation/proposal.md
@@ -9,6 +9,7 @@ Side findings currently interrupt the main conversation or remain unactionable p
- Offer a continuation after three successful summary compactions by default, with a configurable threshold and once-per-session dismissal.
- Continue in a fresh conversation using bounded handoff context, a real source-session reference, the actual working directory, and inherited execution configuration.
- Persist acceptance phases and the target session identity so retries do not create duplicate tasks. Keep old conversations available and link both directions.
+- 待处理的两类建议卡片显示 30 秒自动关闭倒计时;文案位于底部进度条左上方,使用“xx秒后关闭”,进度条以细线和微光端点表现剩余时间。
## Capabilities
diff --git a/openspec/changes/add-task-suggestions-and-session-continuation/specs/task-suggestions/spec.md b/openspec/changes/add-task-suggestions-and-session-continuation/specs/task-suggestions/spec.md
index 84e9aed58..5f7513ecf 100644
--- a/openspec/changes/add-task-suggestions-and-session-continuation/specs/task-suggestions/spec.md
+++ b/openspec/changes/add-task-suggestions-and-session-continuation/specs/task-suggestions/spec.md
@@ -44,3 +44,19 @@ Web and desktop SHALL present suggestions using the existing theme, readable Chi
#### Scenario: Source changes during a request
- **WHEN** a user changes sessions while a suggestion response is in flight
- **THEN** that response cannot replace suggestions for the newly selected session
+
+### Requirement: 建议卡片自动关闭倒计时
+Web 和桌面端 SHALL 为首次展示且尚未接受的支线任务建议和上下文续接建议提供 30 秒倒计时,并在到期后通过既有关闭接口关闭建议。文案 SHALL 位于底部进度条左上方,中文为“xx秒后关闭”。进度条 SHALL 为逐渐缩短的细线及随端点移动、轻微闪动的小火苗,最后 5 秒轻微提亮,并适配主题、中英文、窄屏和减少动态效果偏好。
+
+#### Scenario: 倒计时到期且轮询更新
+- **WHEN** 待处理建议首次展示且用户未操作,期间收到同一建议的轮询结果
+- **THEN** 30 秒截止时间保持不变,到期仅发起一次关闭请求,不启动任务,成功关闭后刷新不再展示
+
+#### Scenario: 用户已接受或关闭失败
+- **WHEN** 用户接受建议、主动关闭,或建议变为排队、启动或失败状态
+- **THEN** 停止自动关闭倒计时,不因到期取消排队中的任务
+- **AND** 关闭请求失败时保留错误提示和手动重试能力,不自动循环重试
+
+#### Scenario: 多卡片及会话切换
+- **WHEN** 多张建议先后出现,或用户离开当前会话
+- **THEN** 每张卡片独立计时,旧会话定时器在销毁时清理,不关闭其他会话的建议
diff --git a/openspec/changes/add-task-suggestions-and-session-continuation/tasks.md b/openspec/changes/add-task-suggestions-and-session-continuation/tasks.md
index b6d2ccb32..20bf52cda 100644
--- a/openspec/changes/add-task-suggestions-and-session-continuation/tasks.md
+++ b/openspec/changes/add-task-suggestions-and-session-continuation/tasks.md
@@ -26,7 +26,24 @@
- [x] 5.1 Build native changes and execute focused native tests plus appropriate broader checks; review failures and document any platform limitations.
- [x] 5.2 Review end-to-end behavior and final diff, run strict OpenSpec validation and git diff --check, and reconcile the completed tasks with actual evidence.
-## Validation evidence
+## 6. 建议卡片 30 秒自动关闭
+
+- [x] 6.1 控制器维护独立截止时间,复用关闭接口,处理轮询、接受、失败及销毁竞态,并补充可控时钟测试。
+- [x] 6.2 按确认视觉实现进度条左上方“xx秒后关闭”、细线与微光端点,适配减少动态效果和中英文。
+- [x] 6.3 验证桌面及 390px 中英文/明暗/减少动态效果,运行前端测试、构建、严格 OpenSpec 校验及差异检查。
+- [x] 6.4 将端点微光调整为克制的小火苗闪动,验证普通/减少动态效果、明暗主题及前端测试构建。
+- [x] 6.5 修复任务位置下拉菜单缺失背景和层级,验证明暗主题、窄屏、中英文以及选择和 Escape 焦点恢复。
+
+### 本次追加验证(2026-09-19)
+
+- 20 项建议控制器测试通过,新增覆盖两类建议到期关闭、轮询不重置、独立截止时间、接受与到期竞态、关闭失败、状态变化、重复关闭、延迟调度及销毁。
+- 完整 `pnpm test` 通过(2559 条通过记录),`pnpm build`、中英文目录生成及严格 OpenSpec 校验通过,`git diff --check` 无错误。
+- 真实组件与生产样式搭配模拟 API 的 Chromium 检查共 19 项通过:1100px/390px、中英文、明暗主题、普通/减少动态效果的 16 种组合,以及临界接受、关闭失败、切换会话 3 项交互。已核对真实截图中文案位于进度条左上方。
+- 本次仅验证前端与浏览器,未重建桌面安装包,未运行原生平台打包验证。
+- 小火苗追加验证:8 项 Chromium 组合检查通过(1100px/390px、明暗主题、普通/减少动态效果),实测外焰形变和双层明暗变化、减少动态效果时静止且无光晕,无横向溢出;已核对实际动画帧,完整 `pnpm test` 和 `pnpm build` 再次通过。
+- 下拉菜单追加验证:修复前实际计算样式为透明背景、无边框/圆角/阴影且层级为 auto,菜单项目被底层卡片遮挡。补齐调用方容器样式后,8 项 Chromium 组合检查通过(1100px/390px、明暗主题、中英文),验证实底、边框、阴影、层级、点击命中、切换位置不启动任务及 Escape 恢复焦点;完整前端测试、构建通过。
+
+## 原有实现验证记录
- Windows Release builds passed for `acecode`, `acecode-desktop`, and `acecode_unit_tests` in `build/task-suggestions`; the latest Web bundle is embedded.
- 496 native tests passed across suggestion, handoff, compaction, worktree, configuration, model binding, session persistence, and HTTP integration suites. The production dispatch path runs a fake provider through the real AgentLoop; no live provider account was used.
diff --git a/openspec/changes/add-tool-preamble/design.md b/openspec/changes/add-tool-preamble/design.md
new file mode 100644
index 000000000..b818a1409
--- /dev/null
+++ b/openspec/changes/add-tool-preamble/design.md
@@ -0,0 +1,63 @@
+# Design: add-tool-preamble
+
+## 业界怎么做(调研结论)
+
+| 产品 / 资料 | 状态行文案来源 | 机制 |
+|---|---|---|
+| Codex(TUI / 桌面) | OpenAI Responses `reasoning.summary` 的首行 `**加粗**` | `chatwidget.rs::extract_first_bold` 抠出当状态行;拿不到就是 "Working" |
+| Codex / OpenAI 提示指南 | 模型写的 preamble | 工具调用前一两句**普通用户可见消息**,按阶段而不是按调用,留在对话里 |
+| agentpatterns.ai「Tool Preamble」 | 同上 | 两种落地:系统提示(模型负载大时会忽略)/ harness 注入(确定性但通用);界面已经可视化工具执行时不需要 |
+| Gemini CLI | thought summary 的 `**Subject**` | `parseThought`,优先级 shell 等待 > subject > 阶段短语 |
+| Cursor / Copilot | 模板 | "Explored (x) tools" 从工具名拼 |
+
+结论:过程说明(preamble 消息)与状态行是两层。状态行要短,来源应当是确定性的(推理摘要标题 / 工具模板),不能指望模型额外输出。
+
+## 四版的取舍
+
+| 版本 | 形态 | 为什么被否 |
+|---|---|---|
+| v1 | 含工具调用的消息先写一句前言,抠出当批次标题 | 文本先流成气泡、参数再流、批次开始才搬进 loading;按批次拆成一摞标题行 |
+| v2 | 每个工具定义注入必填 `preamble` 参数 | 强迫模型每次调用都写一句,体验差;grok 对可选参数几乎不填 |
+| v3 | 正文 `` 标签,阶段变化时写 | grok-4.7 不照做(用户会话 20260925-053811-55cc:12 个工具步零标签,全写裸文本);与训练分布相悖 |
+| v4(现行) | daemon 生成:推理加粗标题 > 工具现在进行时模板 > 场景文案 | 不依赖模型;loading 短、具体、不带参数 |
+
+## 数据流
+
+```
+run_agent_with_input
+ reset_activity_for_turn()
+ loop:
+ call_provider_and_collect
+ reset_activity_for_step() ← 本步标题 / 已流出工具 / 执行中批次清空
+ emit model_waiting ─┐
+ reasoning delta ────┼─ 加粗标题 → publish_phase_preamble → emit preamble(force)
+ ToolCallDelta ──────┼─ note_planned_tool(index, name) → emit tool_planning
+ visible text ───────┘─ 第一次 → emit responding(force)
+ resolve_tool_preamble_for_step → {title|template, source, kind}
+ 记 last_batch_tools_ / current_batch_activity_
+ execute_tool_calls → metadata.tool_preamble、tool_start.preamble*、emit tool_running
+ reset_activity_for_turn()
+
+emit_agent_progress(phase, label, detail, …)
+ concrete = concrete_activity_for_phase(phase) ← 关闭 / 不替换的 phase 返回空
+ if concrete: label = concrete.title; detail = ""; payload.preamble = concrete
+ announce_activity(concrete.title) → on_thinking_title(TUI,去重)
+```
+
+- **只在发射口替换**:各发射点仍传原来的文案,开关关闭时原样发出,开启时统一换掉,保证没有漏网的「正在推理」。
+- **phase → 文案**:`tool_running` 用执行中批次;`tool_planning` 用本步标题,否则按已流出的工具拼模板,都没有就退到场景文案;`model_waiting` / `reasoning` / `preamble` 用本步标题,否则场景文案(回合开头「正在分析你的请求」,之后按上一批工具);`responding` 固定「正在撰写回复」。
+- **推理加粗标题只管本步**:每次 provider 调用(含重试)开头清空。推理首句兜底删除:grok 这类只给原始思维链的模型,首句永远是 "The user wants me to…"。
+- **模板只看原生工具名**,不看参数:同类计数(读取 / 运行 / 修改 / 写入 / 网页 / 子任务 / 图片可数,搜索 / 查找不计数),两类用「并」,三类及以上前两类加「等」;MCP 与未列出的工具归「调用工具」。
+- **kind** 由工具类型定:有写类工具为 write,全是读类为 read,否则空;透传给界面,效果留空。
+- **工具行不变**:`tool_start` 的参数、Web `ToolBlock` 运行中的行、TUI 进度头都照常显示参数。
+- **系统提示不变**:与开关无关,「# Sharing progress updates」一节保持 v1 之前的原文。
+
+## 工作模式入口
+
+`web/src/lib/workMode.js`:`workModeFromToolPreamble(state)`(enabled → 适合日常工作)、`toolPreambleUpdateForWorkMode(mode)`(→ `{enabled}`)。设置页打开时读一次,点选时 PUT、以响应为准,失败回滚并提示;读取完成前两个卡片禁用。工作模式不单独存,避免界面与 daemon 漂移。
+
+## 已知限制
+
+- 推理摘要不带加粗标题的模型(grok、DeepSeek、Claude)只能用模板与场景文案,不会有模型写的具体描述。
+- 模板文案是中文,英文界面显示的也是中文(与原来 daemon 下发的动态阶段文案一致)。
+- headless `-p` 模式下开关照常生效,但 stdout 只出最终文本,看不到 loading。
diff --git a/openspec/changes/add-tool-preamble/proposal.md b/openspec/changes/add-tool-preamble/proposal.md
new file mode 100644
index 000000000..b36bfb4c0
--- /dev/null
+++ b/openspec/changes/add-tool-preamble/proposal.md
@@ -0,0 +1,33 @@
+# Proposal: add-tool-preamble
+
+## Why
+
+Codex / Claude Code 这一代 agent 在等待期都会显示一句有指向性的短状态(截图里红框的 "Reading registry sections"),用户看到的是「正在读注册表段落」而不是笼统的「正在处理」,焦虑感明显更低。ACECode 原来的 loading 只有阶段文案(「正在推理」「正在等待模型响应」「正在调用工具 bash」)外加命令预览、字节数这类技术细节。
+
+前三版都试过让模型自己写这句话(先说一句话再调工具 / 每次调用必填 `preamble` 参数 / 正文里的 `` 标签),都依赖模型配合:grok-4.7 在标签版里 12 个工具步零标签。调研(agentpatterns.ai 的 tool preamble 模式、OpenAI GPT-5 prompting guide)后结论是:业界的 tool preamble 是**普通的用户可见消息**,留在对话里;loading 状态行是另一层,Codex 用推理摘要的加粗标题,拿不到就显示通用文案。loading 应当尽量短,不该塞那句长的过程说明,也不该依赖模型额外输出。
+
+## What Changes
+
+- **入口**:设置 > 常规 > 工作模式。「适合日常工作」= 开启具体进度提示,「用于编程」= 关闭(默认)。开发者模式里的「工具前言」行卡片与配置弹窗删除。配置仍是 `config.agent_loop.tool_preamble.enabled`,旧的 `mode` / `sidecar_*` 键加载时忽略;工作模式以 daemon 配置为准,不单独存。
+- **文案全部由 daemon 生成**,优先级:本模型步推理摘要的第一对 `**加粗**`(只对本步有效,不再有推理首句兜底)> 按本批次原生工具名拼的现在进行时模板(「正在读取 3 个文件并搜索代码」,不带任何参数)> 场景文案(回合开头「正在分析你的请求」;一批工具跑完后按这批工具类型,如「正在分析文件内容」「正在分析命令输出」;正文开始流出「正在撰写回复」)。
+- **只换 loading,不动工具行**:`model_waiting` / `reasoning` / `preamble` / `responding` / `tool_planning` / `tool_running` 帧的 label 换成上述文案、detail 清空,权限 / 提问 / 压缩 / 重试保持原样;`tool_start` 带 `preamble` / `preamble_source` / `preamble_kind`,工具自己的参数不变;Web 工具行与 TUI 进度头照常显示参数。TUI 的等待短语经 `on_thinking_title` 显示同样的文案。
+- **系统提示与此无关**:删除标签版的「# Progress preamble」段,标签版期间删掉的「# Sharing progress updates」一节逐字恢复;`build_system_prompt` 不再有前言开关。
+- **历史标签只剥不用**:标签版落盘的 `` 仍从 token 流、Message 帧、TUI 行、回放、Web 历史里剥掉,不再当文案。
+- **REST** `GET/PUT /api/config/tool-preamble` 只剩 `{enabled}`。
+
+## Capabilities
+
+### New Capabilities
+
+- `tool-preamble`:具体进度提示的文案来源、替换范围、协议字段、工作模式入口与三端渲染。
+
+### Modified Capabilities
+
+- `web-transcript-projection`:实时行可显示 daemon 生成的批次文案;落定后与关闭时投影一致。
+
+## Non-goals
+
+- 不要求模型输出任何额外内容(不改系统提示、不注入工具参数、不识别新标签)。
+- 不改 provider 协议层(不主动向 OpenAI Responses 请求 `reasoning.summary`)。
+- `kind = read / write` 的界面效果(写入时的书写效果、读取时的放大镜效果)留空,本次只透传。
+- 过程说明(模型写在对话里的那句普通文本)不进 loading。
diff --git a/openspec/changes/add-tool-preamble/specs/tool-preamble/spec.md b/openspec/changes/add-tool-preamble/specs/tool-preamble/spec.md
new file mode 100644
index 000000000..fc37ac786
--- /dev/null
+++ b/openspec/changes/add-tool-preamble/specs/tool-preamble/spec.md
@@ -0,0 +1,56 @@
+# tool-preamble Spec (delta)
+
+## ADDED Requirements
+
+### Requirement: 配置与工作模式入口
+`config.agent_loop.tool_preamble` SHALL 只含 `enabled`(默认 false);旧配置里的 `mode` / `sidecar_model` / `sidecar_wait_ms` MUST 在加载时忽略,保存 MUST 稀疏。设置 > 常规 > 工作模式 SHALL 是唯一入口:「适合日常工作」对应 `enabled=true`,「用于编程」对应 `enabled=false`;工作模式 MUST 以 daemon 配置为准,不单独持久化。开发者模式里 MUST NOT 再有该设置。
+
+#### Scenario: 旧配置升级
+- **WHEN** 配置为 `{enabled:true, mode:"prompt", sidecar_model:"x"}`
+- **THEN** 加载后开启,设置页显示「适合日常工作」,再次保存只写出 `enabled`
+
+#### Scenario: 切换工作模式
+- **WHEN** 用户在设置页从「适合日常工作」切到「用于编程」
+- **THEN** 发 `PUT {enabled:false}`,活跃会话立即恢复旧文案;请求失败时选择回滚并提示
+
+### Requirement: 文案来源
+开启后 loading 文案 SHALL 全部由 daemon 生成,不要求模型额外输出,优先级为:
+1. 本模型步推理摘要里第一对闭合 `**…**` 的内文(规整后截到 60 个 code point),只对本步有效,每次 provider 调用(含重试)开头清空;MUST NOT 用推理首句兜底;
+2. 本批次原生工具名拼成的现在进行时模板,MUST NOT 含任何工具参数:同类计数,两类用「并」,三类及以上取前两类加「等」,MCP 与未列出的工具为「正在调用工具」;
+3. 场景文案:回合开头「正在分析你的请求」,一批工具跑完后按这批第一类工具(如「正在分析文件内容」「正在分析命令输出」「正在检查修改结果」),正文开始流出「正在撰写回复」。
+
+#### Scenario: 模板
+- **WHEN** 一批调用为 file_read、grep、file_read
+- **THEN** 文案为「正在读取 2 个文件并搜索代码」,kind 为 read
+
+#### Scenario: 没有推理标题的模型
+- **WHEN** 推理内容是 "The user wants me to look at the loader."
+- **THEN** 等待期文案为「正在分析你的请求」,任何帧都不出现 "The user wants" 与「正在推理」
+
+#### Scenario: 加粗标题只管本步
+- **WHEN** 第一步推理带 `**Reading registry sections**`,第二步没有
+- **THEN** 第一步的 loading 与 tool_start.preamble 为该标题(source=reasoning),第二步等待时回到场景文案
+
+### Requirement: 替换范围与协议
+开启时 `agent_progress` 的 `model_waiting` / `reasoning` / `preamble` / `responding` / `tool_planning` / `tool_running` 帧 MUST 以上述文案为 label、detail 为空,并带 `preamble:{title, source, kind}`(source ∈ reasoning / template / context);`permission_waiting` / `question_waiting` / `compacting` / `model_retry` MUST 保持原文案。每个批次的 `tool_start` MUST 带 `preamble` / `preamble_source` / `preamble_kind`,其参数与 `display_override` 不变;assistant(tool_calls) 消息 MUST 落盘 `metadata.tool_preamble = {source, title, kind}` 作记录。关闭时所有帧 MUST 与改动前一致(不带 preamble 字段,不发 responding 帧)。
+
+#### Scenario: 执行中不带参数
+- **WHEN** 开启时执行一条 bash 命令
+- **THEN** tool_running 的 label 为「正在运行命令」、detail 为空;tool_start 的 args 与 Web 工具行仍显示完整命令
+
+### Requirement: 历史标签
+前一版要求模型输出的 `` 标签 MUST 无论开关与否都从 token 帧、message 帧、TUI 行、回放与 Web 历史渲染里剥掉,且 MUST NOT 当作 loading 文案;落盘正文保持原样。
+
+#### Scenario: 历史里的标签
+- **WHEN** 模型沿用历史习惯输出 `Reading the loader` 后调用工具
+- **THEN** 界面看不到该标签,也没有 phase=preamble 的帧,tool_start.preamble 为工具模板
+
+### Requirement: 三端渲染
+Web 投影 MUST NOT 按批次拆分活动段;实时行标题 SHALL 为正在运行工具的 `preamble`(此时不再追加并行计数与 activity.detail),工具都完成后退回 `activity.label`;运行中与落定后的工具行 MUST 照常显示参数。TUI SHALL 用 `on_thinking_title` 收到的文案替换等待短语,同一句不重复回调;写工具进度头 MUST 保持工具行(前言参数为空)。
+
+#### Scenario: 实时行
+- **WHEN** 批次文案为「正在读取 3 个文件」且三个调用在并行运行
+- **THEN** 实时行显示「正在读取 3 个文件」,不再显示「正在运行 3 个工具」
+
+### Requirement: REST
+`GET /api/config/tool-preamble` SHALL 返回 `{enabled}`;`PUT` 为 patch 语义,`enabled` 非布尔返回 400 `BAD_REQUEST`,旧的 `mode` / `sidecar_*` 键忽略;成功写 `config.json` 并下发到全部活跃会话。
diff --git a/openspec/changes/add-tool-preamble/tasks.md b/openspec/changes/add-tool-preamble/tasks.md
new file mode 100644
index 000000000..912545971
--- /dev/null
+++ b/openspec/changes/add-tool-preamble/tasks.md
@@ -0,0 +1,36 @@
+# Tasks: add-tool-preamble
+
+## 1. 配置与纯逻辑
+
+- [x] 1.1 `ToolPreambleConfig{enabled}`(旧 mode / sidecar_* 键加载时忽略,稀疏保存)
+- [x] 1.2 `src/tool_preamble/tool_preamble.{hpp,cpp}`:`batch_activity_label` / `after_batch_activity_label` / `batch_activity_kind` 现在进行时模板;加粗抠取与标题规整;删除推理首句兜底与模式判定;标签扫描器只用来剥历史标签
+- [x] 1.3 删除旁路摘要模型、工具参数注入、ToolCallDelta 参数前缀(前几版遗留)
+
+## 2. AgentLoop 与协议
+
+- [x] 2.1 `build_system_prompt` 删除前言开关与「# Progress preamble」段,「# Sharing progress updates」逐字恢复
+- [x] 2.2 `emit_agent_progress` 统一替换:`concrete_activity_for_phase` + `announce_activity`(TUI 去重回调);权限 / 提问 / 压缩 / 重试不换
+- [x] 2.3 本步状态:推理加粗标题、`note_planned_tool`、`reset_activity_for_step` / `reset_activity_for_turn`;正文开始流出发 `responding`
+- [x] 2.4 `resolve_tool_preamble_for_step`:批次文案(标题 > 模板),`metadata.tool_preamble` 与 `tool_start.preamble*`;TUI 进度头不再带前言
+
+## 3. REST 与设置页
+
+- [x] 3.1 `handlers/tool_preamble_handler` + `routes_tool_preamble.cpp`:只剩 `{enabled}`
+- [x] 3.2 删除开发者模式里的 `ToolPreambleSettings.jsx`;`lib/workMode.js` + 设置 > 常规 > 工作模式接 daemon 开关;搜索索引
+- [x] 3.3 i18n 覆盖与目录重生成
+
+## 4. Web / TUI 渲染
+
+- [x] 4.1 `ToolBlock` 运行中的工具行恢复显示参数
+- [x] 4.2 实时活动行:有批次文案时不再追加并行计数与 activity.detail
+- [x] 4.3 TUI:`on_thinking_title` 显示具体文案
+
+## 5. 测试与文档
+
+- [x] 5.1 C++:`tool_preamble_test`、`agent_loop_tool_preamble_test`、`config_tool_preamble_test`、`tool_preamble_handler_test`;删除 `system_prompt_tool_preamble_test`
+- [x] 5.2 Web:`toolPreamble.test.js`、`workMode.test.js`,投影 / reducer 两个前言测试
+- [x] 5.3 `docs/daemon-api.md`、CLAUDE.md
+
+## 6. 留空待做
+
+- [ ] 6.1 `kind = read|write` 的界面效果(写入时的书写效果、读取时的放大镜效果)
diff --git a/openspec/changes/add-windows-computer-use/.openspec.yaml b/openspec/changes/add-windows-computer-use/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/add-windows-computer-use/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/add-windows-computer-use/codex-parity.md b/openspec/changes/add-windows-computer-use/codex-parity.md
new file mode 100644
index 000000000..e346e84eb
--- /dev/null
+++ b/openspec/changes/add-windows-computer-use/codex-parity.md
@@ -0,0 +1,22 @@
+# Windows Computer Use 对照
+
+参考来源为本机 Codex 仓库的 MCP/回合结束接入,以及本机已安装的 `openai-bundled/computer-use/26.915.31945/docs/api.md` 与 `docs/guidance.md`。后两份文档描述 `@oai/sky` 的 Windows API;插件发布物没有原生实现源码,因此以公开接口行为和可验证的运行结果为对照,不能声称原生算法完全相同。
+
+| Codex 行为 | ACECode 实现与尚需验证的内容 |
+|---|---|
+| list_apps / launch_app / list_windows | 已实现并统一已安装应用与运行窗口身份;AUMID/唯一可执行路径匹配测试通过,歧义路径不猜测。 |
+| get_window | 已实现;自有窗口验证按已知标识恢复、核对应用身份及拒绝错误应用。 |
+| get_window_state | 已验证 WGC、遮挡捕获、缩放与控件树;主窗加最多三个有明确归属的弹层具备独立截图/坐标,owned popup、标准菜单及 ComboBox 真实捕获通过。 |
+| focused_element / selected_text / selected_elements / document_text | 已实现焦点、选中文字、选中控件及可见相关文档;真实 ListBox 选中项和文档优先级测试通过。 |
+| click | 已有坐标/元素、双击和三种按钮;真实坐标与元素点击、弹层项目选择、UIA 可点击点及同窗口浮层遮挡拒绝实测通过。 |
+| press_key / type_text | 已有组合键、Unicode、文档所列标点与数字键盘别名;键码/扩展标志测试和真实组合键、中文、补充平面字符输入通过。 |
+| scroll / drag | 真实滚轮改变自有画布内容位置、拖拽对象到目标坐标、OLE 目标接收到预期 payload 均通过。 |
+| set_value / perform_secondary_action | 已有 Value、Invoke、Toggle、Select、Expand/Collapse、Scroll、Focus;真实赋值、焦点、切换、选择、展开/收起和调用均核对控件结果。 |
+| activate_window | 使用原生激活并核对实际前台,真实 fixture 激活通过;系统拒绝焦点或存在按住的输入时明确失败。 |
+| 多显示器/截图坐标 | 8 项映射测试覆盖负原点、逐图独立缩放、边界和溢出;本机全部可用显示器(1 台)及 3000 像素窗口缩放后的实际坐标点击通过,多屏硬件组合未实测。 |
+| 回合结束释放 | 独立执行器和会话租约已接入,超时/取消/关闭/并发测试通过。 |
+| 截图反馈模型 | OpenAI 兼容与 Anthropic 多图来源标签测试通过;大控件树落盘后保留各图关键操作字段,中间图片失效不会使后续图片错配。 |
+
+ACECode 使用内置 C++ 工具与私有 helper 管道,接入自身开关、权限模式和模型协议。此次范围为 Windows,macOS/Linux 不开启这项能力。
+
+同一套原生 fixture 已通过直连与生产 broker/helper 两条路径;helper 模式不允许回退为进程内调用。测试用自有可丢弃窗口,验证实际控件状态、画布坐标和 OLE 传输结果。当前工具参数仍有明确边界:点击次数为 1 或 2,每次动作必须使用新观察,多图坐标必须明确目标。
diff --git a/openspec/changes/add-windows-computer-use/design.md b/openspec/changes/add-windows-computer-use/design.md
new file mode 100644
index 000000000..9d3336125
--- /dev/null
+++ b/openspec/changes/add-windows-computer-use/design.md
@@ -0,0 +1,35 @@
+## Context
+
+动机见 proposal.md。现有 ToolResult 已提供图片附件,但 OpenAI 工具结果丢弃附件,Anthropic 将其转为省略文本。内置工具共享注册器服务于 daemon 和 TUI;现有普通工具审批仍适用。Codex 开源部分负责 MCP 接入,实际 Windows 原生插件为专有发布物。
+
+## Goals / Non-Goals
+
+**Goals:** 在 Windows 本机交互桌面形成可取消的观察、操作、再观察闭环;开关是实际执行边界;兼容现有模型和聊天记录。
+
+**Non-Goals:** 本次不实现 macOS/Linux 原生控制,不复制 Codex 专有运行时,不引入 JavaScript REPL 或绕过 Windows 完整性级别限制。
+
+## Decisions
+
+1. 使用 C++17 原生 sidecar `acecode-computer-use`,由 daemon/TUI 通过私有 stdin/stdout JSON Lines 通信。与内嵌 UIA 相比,阻塞第三方应用可以被超时或取消终止;执行器随安装包交付。
+2. 后端采用 UI Automation、SendInput 与 Windows.Graphics.Capture;截图必须携带来源、原点和尺寸。WGC 不可用时仅允许明确标注的可见屏幕回退,并检查目标无遮挡,避免将别的窗口像素当作目标截图。
+3. 工具组使用观察结果的 observation_id 绑定窗口进程、几何、元素索引和截图坐标。截图最长边最多 2560 像素,避免后续附件归一化再次缩放;返回原生尺寸、截图尺寸和缩放比例。动作坐标及控件 bounds 使用实际返回的截图像素,由后端转换为屏幕物理像素。每次写操作消耗观察;过期状态拒绝执行。密码控件值不进入树文本。
+4. 一个会话持有执行器及桌面控制租约;Windows 命名互斥量阻止多个 ACECode 实例同时控制同一交互桌面。子代理也不能并发抢占。输入批次包含释放事件;操作有超时、取消探针。结束回合、关闭开关、进程退出均释放 worker。
+5. 开关默认 false。配置更新同步刷新注册表并撤销运行时权限;工具执行前再次检查启用状态,不能依赖模型侧 schema 隐藏。保持现有权限模式审批,Plan 模式不允许变更桌面。
+6. OpenAI 兼容请求将工具截图转成整批工具结果之后的用户图片消息,Anthropic 使用 tool_result 内 image block;图片仍保留为工具来源的规范记录。非视觉模型保留文字观察与明确图片不可读提示。
+7. 相关瞬态界面属于同一个一次性观察,但每个界面保存独立 HWND/进程身份、归属关系、几何和截图标识。采用明确 owner 链、标准菜单所属窗口或 ComboBox 列表句柄验证关系;同进程/线程不足以构成归属证据。输入按指定截图或控件所属界面换算及命中检查,菜单状态变化使观察失效。
+8. 多图来源随图片附件持久化,并在模型消息中紧邻图片标注真实 screenshot_id 和尺寸。关键操作信息置于工具预览开头;图片失效或省略不能使其他图片错配。
+9. helper 在独立 UI 线程绘制不抢焦点、鼠标穿透的指针层,显示最后成功动作的位置、移动和点击反馈;UIA 控件操作也产生可见反馈。只记录本进程实际创建的 overlay HWND,避免其进入窗口列表、遮挡判断和观察关系。输入命中检查及屏幕回退抓取期间同步临时隐藏,release、取消或 helper 退出时清理,不替换全局系统光标。
+10. WGC 显式关闭内嵌光标,再与屏幕回退共用指针合成器;不支持关闭内嵌光标的旧版本使用已验证无遮挡的回退。优先合成操控指针,否则读取系统光标;只有热点实际属于当前窗口或子控件才绘制。先按物理像素合成,再随整张截图缩放,同时返回截图像素的 cursor 元数据,处理热点、透明度、裁切和负屏幕原点,防止重复光标或坐标漂移。
+11. 设置页沿用完整 SectionTools 的统一工具行结构,图标、文案、操作区和分隔线在同一个平面列表中对齐;验证完整页面,不能仅凭独立组件通过判断布局正确。
+12. 电脑操控以“(实验性)”标注,行内配置展开两张同尺寸预览单选卡;默认带 ACE,另一款纯色。选择和运行时使用同一持久化 pointer_style;解析后的主题强调色通过既有配置 API 同步到 pointer_color,由 broker 注入可信 helper 请求,不允许模型覆盖。前端读取实际 --ace-accent,兼容内置、下载和自定义主题及系统明暗变化,避免在原生端复制主题色表。样式与颜色更新不改变启用状态、不销毁活动观察,后续操作/截图应用最新外观。
+
+## Risks / Trade-offs
+
+- UIA 应用响应慢或死锁 → 独立 worker 与请求截止时间,失败后重新观察。
+- 系统窗口、UAC 安全桌面、受保护内容或高完整性应用不能操作 → 返回明确能力错误,不重试盲点。
+- 用户移动窗口或多个会话竞争输入 → 观察时绑定身份和几何,操作前确认,租约拒绝并发。
+- Windows 不同工具链可用的 WinRT 头不同 → 条件编译、明确回退来源和打包说明,验证当前 MSVC 构建的 WGC 路径。
+
+## Migration Plan
+
+旧配置缺省保持关闭;开启无需重启;关闭立即撤销。安装时附带匹配版本 helper,缺失时返回安装错误。回退版本时未知配置字段由既有配置兼容机制处理。
diff --git a/openspec/changes/add-windows-computer-use/proposal.md b/openspec/changes/add-windows-computer-use/proposal.md
new file mode 100644
index 000000000..59fc98692
--- /dev/null
+++ b/openspec/changes/add-windows-computer-use/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+ACECode 目前不能读取或操作 Windows 桌面应用。用户需要在设置的内置工具中明确开启“电脑操控 / Computer Use”,然后由模型观察窗口并执行操作,关闭时撤销能力。
+
+## What Changes
+
+- 增加默认关闭的持久化开关、设置 API 与中英文设置行,第一阶段仅 Windows 可开启。
+- 增加 Windows 原生工具:发现/启动应用、窗口观察、截图、UI Automation 控件树、窗口激活、点击、文字输入、快捷键、滚动、拖拽、控件赋值及辅助动作。
+- 使用独立执行器隔离 UI Automation 阻塞,绑定会话、串行操作桌面,取消、关闭开关和回合结束释放执行器。
+- 截图通过附件进入支持视觉的模型请求,保留工具调用与结果配对顺序。
+- 参考本地 Codex 的工具协议和 Windows 能力边界,自主实现原生后端;不依赖其未开源的插件运行时。
+
+## Capabilities
+
+### New Capabilities
+- `windows-computer-use`: 显式启用的 Windows 桌面观察与操作闭环。
+- `tool-image-model-feedback`: 工具图片附件进入模型请求并保留调用顺序。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+涉及配置、Web API、内置工具注册、AgentLoop 生命周期、Windows 原生执行器、OpenAI/Anthropic 请求序列化、设置 UI、构建安装与测试。macOS/Linux 保持可编译,界面明确当前不支持开启。
diff --git a/openspec/changes/add-windows-computer-use/specs/tool-image-model-feedback/spec.md b/openspec/changes/add-windows-computer-use/specs/tool-image-model-feedback/spec.md
new file mode 100644
index 000000000..094aeb1ba
--- /dev/null
+++ b/openspec/changes/add-windows-computer-use/specs/tool-image-model-feedback/spec.md
@@ -0,0 +1,28 @@
+## Purpose
+
+将工具生成的截图作为真实图片反馈给支持视觉的模型,保留附件在会话中的工具来源和调用结果顺序,使电脑操控能够可靠完成观察与动作循环,同时兼容不支持视觉的模型。
+
+## ADDED Requirements
+
+### Requirement: Vision tool feedback
+系统 SHALL 将工具图片附件编码为目标 provider 支持的图片内容,不能只显示在 UI 或将 base64 放入普通工具文字。
+
+#### Scenario: Multiple tool results
+- **WHEN** OpenAI 兼容模型一次产生多个工具调用,其中结果包含截图
+- **THEN** 所有工具结果先完成配对,之后发送可读图片内容,不在同批结果中间插入用户消息。
+
+#### Scenario: Anthropic tool image
+- **WHEN** 支持视觉的 Anthropic 模型收到工具截图
+- **THEN** 对应 tool_result 包含合法 image block。
+
+#### Scenario: Nonvisual model
+- **WHEN** 当前模型不支持视觉或附件无法读取
+- **THEN** 保留工具文字,并明确提示图片不可读,不发送无效图片 payload。
+
+#### Scenario: Large accessibility output
+- **WHEN** 大型控件树触发通用工具结果持久化与摘要预览
+- **THEN** 模型仍能直接读取 observation_id、目标窗口、截图标识和坐标信息,完整观察保存在既有工具结果文件中。
+
+#### Scenario: Multiple related screenshots
+- **WHEN** 一次观察带有主窗和弹层的多个图片附件,且其中一张无法读取或被省略
+- **THEN** 每张成功反馈的图片紧邻其真实 screenshot_id 和尺寸信息,附件落盘与 provider 转换均保留来源,不因图片位置变化而错配。
diff --git a/openspec/changes/add-windows-computer-use/specs/windows-computer-use/spec.md b/openspec/changes/add-windows-computer-use/specs/windows-computer-use/spec.md
new file mode 100644
index 000000000..8a447c868
--- /dev/null
+++ b/openspec/changes/add-windows-computer-use/specs/windows-computer-use/spec.md
@@ -0,0 +1,73 @@
+## Purpose
+
+为 ACECode 提供由用户明确开启的 Windows 桌面操控能力,让模型能够发现和观察真实应用窗口,再基于最新观察执行可取消的鼠标、键盘和控件操作,同时保持关闭状态的实际能力隔离。
+
+## ADDED Requirements
+
+### Requirement: Explicit feature switch
+系统 SHALL 在内置工具设置中显示“电脑操控(实验性)”,英文为“Computer Use (Experimental)”,默认关闭并持久化;当前仅 Windows 支持开启。
+
+#### Scenario: Default disabled and stale calls
+- **WHEN** 配置未开启或用户关闭开关
+- **THEN** 后续模型请求不包含电脑操控工具,历史或伪造调用被拒绝,活动执行器被撤销。
+
+#### Scenario: Supported settings
+- **WHEN** Windows 用户开启开关后重启应用
+- **THEN** 开关仍开启且工具可用,其他平台显示不支持且不能开启。
+
+#### Scenario: Built-in tool row alignment
+- **WHEN** 设置页面同时显示浏览器、图像生成、电脑操控和摘要生成
+- **THEN** 电脑操控与相邻工具使用相同的图标、文字起点、列表分隔及右侧操作边界,不能因嵌套容器产生额外缩进。
+
+### Requirement: Configurable themed pointer
+系统 SHALL 在电脑操控行提供“配置”按钮,展开两种带预览的指针样式:主题色且右下角显示 ACE 的指针,以及不带文字的纯主题色指针。默认选择带 ACE 的样式;样式持久化且可在工具关闭时配置,不会因此启用工具。
+
+#### Scenario: Select and restore a pointer style
+- **WHEN** 用户选择任一样式并重新打开设置或重启应用
+- **THEN** 保持已保存的选择,实际 Windows 指针和截图使用相同样式,切换不会改变既有开关状态。
+
+#### Scenario: Follow the current theme
+- **WHEN** 当前主题的强调色变化
+- **THEN** 两种预览和后续实际操控指针使用相同主题色,默认蓝色主题显示蓝色;配置失败时不能假装保存成功。
+
+### Requirement: Observe and operate Windows applications
+系统 SHALL 提供应用与窗口发现、应用启动、窗口状态和截图、可访问性控件、激活、点击、文字输入、按键、滚动、拖拽、控件赋值和辅助动作;不可用能力 SHALL 返回明确错误。
+
+#### Scenario: Observe and act
+- **WHEN** 模型获取窗口状态后以 observation_id 对该窗口执行动作
+- **THEN** 动作使用该观察返回的截图像素或控件索引,并正确映射到屏幕物理位置,成功后要求重新观察。
+
+#### Scenario: Stale or sensitive state
+- **WHEN** 窗口已变化、句柄身份变化、观察已消耗或控件为密码字段
+- **THEN** 系统拒绝过期动作,且不会把密码值写入观察文本。
+
+#### Scenario: Screenshot fidelity
+- **WHEN** 请求窗口截图
+- **THEN** 返回实际尺寸、原点和捕获来源,不能把其他窗口遮挡像素伪装为目标窗口内容。
+
+#### Scenario: Visible computer-use pointer
+- **WHEN** 电脑操控定位或操作目标界面
+- **THEN** 用户能够看到操作指针;返回截图中的指针与相应截图坐标、缩放和窗口归属一致,不绘制属于其他窗口的指针,也不使显示反馈抢占输入。
+
+#### Scenario: Application identity and input compatibility
+- **WHEN** 通过发现的应用标识启动后重新列出应用,或使用 Codex 文档中的按键名称及控件索引
+- **THEN** 运行窗口关联到可验证的同一应用标识,支持约定的 keysym 和数字键盘别名,并在点击前验证目标元素的可点击点没有被其他控件遮挡。
+
+#### Scenario: Related transient surfaces
+- **WHEN** 主窗口打开可验证归属的菜单、下拉框或拥有的弹层
+- **THEN** 同一次观察提供带独立截图标识、尺寸和坐标映射的相关画面,动作根据选定截图或控件所属界面路由;不能只因同一进程或线程而纳入无关窗口。
+
+#### Scenario: Transient state changes before input
+- **WHEN** 观察后的菜单、弹层归属、前台窗口或键盘菜单状态发生变化
+- **THEN** 拒绝使用过期状态输入并要求重新观察;保持已观察弹层时不能因重复激活主窗口而关闭菜单。
+
+### Requirement: Owned and cancellable desktop execution
+系统 SHALL 将桌面执行绑定到调用会话,串行化同一桌面的操控,并在取消、回合结束、关闭开关和进程退出后释放资源。写操作 SHALL 遵守现有权限审批,Plan 模式不能执行写操作。
+
+#### Scenario: Concurrent sessions
+- **WHEN** 另一会话已持有桌面控制
+- **THEN** 新会话收到占用提示而不会交错发送输入。
+
+#### Scenario: Interrupted or hung execution
+- **WHEN** 操作被取消或原生执行器超时
+- **THEN** 停止执行器,返回取消或超时结果,后续调用不能继续使用旧观察。
diff --git a/openspec/changes/add-windows-computer-use/tasks.md b/openspec/changes/add-windows-computer-use/tasks.md
new file mode 100644
index 000000000..9665d0e2e
--- /dev/null
+++ b/openspec/changes/add-windows-computer-use/tasks.md
@@ -0,0 +1,74 @@
+## 1. 设置与能力开关
+
+- [x] 1.1 增加默认关闭配置和 Windows 能力 API,配置解析、持久化及路由测试通过。
+- [x] 1.2 增加中英文内置工具设置行,验证加载、切换、失败、非 Windows 禁用与窄屏显示。
+- [x] 1.3 接通动态工具注册与执行时开关校验,测试关闭后的旧调用被拒绝。
+
+## 2. Windows 原生执行
+
+- [x] 2.1 实现窗口/应用发现、启动、UIA 观察和窗口截图,通过原生编译与受控窗口截图验证。
+- [x] 2.2 实现全部输入和控件动作、观察过期校验,通过参数与受控窗口动作验证。
+ - 前台限制已解除。原生直连及生产 broker/helper 链路均通过真实赋值、点击、焦点、组合键、中文与补充平面字符、滚动、画布对象拖动和 OLE 拖放验证;Toggle、Select、Expand、Collapse、Invoke 均核对实际控件变化。原生 EDIT 不支持 Ctrl+A,fixture 使用其支持的导航组合键并先验证选择范围。
+- [x] 2.3 实现独立执行器、会话租约、超时取消和回合释放,通过协议、撤销及资源释放测试。
+- [x] 2.4 接入构建与安装,验证 helper 与主程序产物及安装路径。
+ - MSVC MinSizeRel 的主程序、桌面端和 helper 已构建;独立安装组件交付路径及 helper SHA256 一致性已核对。
+
+## 3. 模型观察闭环
+
+- [x] 3.1 接通 OpenAI 兼容与 Anthropic 图片工具结果,测试多结果顺序、图片数据和非视觉模型分支。
+- [x] 3.2 为模型提供动作约束和再观察指引,验证工具 schema、附件及权限模式集成。
+
+## 4. 集成验证与说明
+
+- [x] 4.1 更新 API 与功能说明,记录 Windows 权限、截图回退和模型能力边界。
+- [x] 4.2 运行相关原生测试、pnpm test、pnpm build、OpenSpec strict 验证和 git diff --check,审查无关工作保留情况。
+ - 最新 117 项关联测试通过,涵盖多图回传、坐标映射和实时开关;此前 246 项 AgentLoop/权限回归、原生观察与 broker 集成测试、前端测试/构建、中英文窄屏交互检查、打包脚本测试均通过。
+ - 原有无关改动按基线保留,共用文件仅合并本功能必要变更。真实输入和实际 helper 链路已通过;本地 MSVC 构建通过,WinLibs 构建因既有损坏头文件未完成。本任务没有发布安装包或替换已安装客户端。
+
+## 5. Codex 能力对照补齐
+
+- [x] 5.1 合并已安装应用与运行窗口身份,保证同一 app id 启动后可查到窗口,并使可聚焦控件正确广告 focus/raise。
+- [x] 5.2 补齐 Codex 支持的按键别名和数字键盘 Enter 语义,验证键码、扩展标记与无效组合。
+- [x] 5.3 超大观察输出经实际工具结果落盘后仍保留观察标识、窗口与截图坐标信息。
+ - 新增身份、按键和真实工具结果持久化回归;本轮 51 项相关测试全部通过。
+- [x] 5.4 元素点击验证真实可点击点及命中元素,避免同一窗口浮层遮挡时点击错误控件。
+ - 原生受控窗口实测:正常按钮解析可点击点成功,同窗口兄弟控件完全覆盖后返回 obscured;测试不发送输入。
+- [x] 5.5 补齐 get_window 窗口恢复与相关瞬态界面截图,验证对应截图坐标和窗口归属。
+ - get_window、最多四个 surface、逐图元数据、输入路由和部分截图失败处理已实现;最新 117 项集成回归通过。原生 fixture 已验证同 app 恢复/不同 app 拒绝、owned popup、标准菜单与 ComboBox 画面、PNG 尺寸、同线程无关窗口排除、移动/关闭失效、多图拒绝隐式目标。8 项坐标变换测试通过;真实 screenshot_id 坐标点击已成功选择菜单和 ComboBox 项目。
+- [x] 5.6 补齐 selected_elements 和非焦点文档的 document_text,保留密码与大小边界。
+ - 文档优先级/密码规则单测通过;原生 fixture 的真实 ListBox 选中条目出现在 selected_elements。
+- [x] 5.7 验证拖拽对实际拖放控件和画布的效果、按键/鼠标动作及显示器坐标映射;不能仅以收到鼠标移动事件判定拖放完成。
+ - 真实 helper 链路已验证画布对象最终位置、滚轮改变内容位置、DoDragDrop/IDropTarget 的 DRAGDROP_S_DROP 与目标 payload,以及菜单/ComboBox 项目选择。全部可用显示器(本机 1 台)和 3000 像素窗口缩放后的实际坐标点击通过;负原点、逐图缩放、边界和溢出由 8 项坐标单测覆盖,未声称多显示器硬件实测。
+
+## 6. 设置对齐与指针可见性修正
+
+- [x] 6.1 移除电脑操控行多余的缩进,与完整内置工具列表中的图标、文字、右侧操作及分隔线对齐,验证中英文、明暗色和窄屏。
+ - 真实 SectionTools 组合检查通过:390/1280px、中英文、明暗主题共 8 组,包含保存失败、重试、禁用和非 Windows 状态;已复现并移除原先额外的 14px 缩进。
+- [x] 6.2 对照 Codex 的可见操控反馈,补齐 Windows 指针显示;截图路径统一处理指针位置、热点和缩放,不能影响目标命中或截取无关窗口。
+ - 已实现 helper 原生可见指针、点击/移动反馈、统一截图合成及附件元数据保留;真实 broker/helper 完整输入检查通过,覆盖本机两个可用显示器、3000 像素窗口缩放与指针热点一致性。已查看实际 PNG,确认不是仅返回坐标或工具成功状态。
+- [x] 6.3 补充真实指针可见性与完整设置列表验证,运行相关测试、前端构建、原生构建和 OpenSpec 检查。
+ - 最终 131 项关联测试通过,包含 8 项像素合成、4 项真实指针窗口、2 项附件落盘/模型回传新回归;完整生产 broker/helper 输入实测通过,确认指针可见、坐标一致、不抢焦点、不进入窗口列表及 release 后隐藏。
+ - pnpm test、pnpm build、MSVC MinSizeRel 的主程序/Desktop/helper/原生 fixture 构建、OpenSpec strict 和 git diff --check 均通过。8 组完整设置列表截图已核对实际字体和几何。当前运行的 build/Release 进程未替换,本次新版位于 build/MinSizeRel。
+
+## 7. 实验性标注与主题指针配置
+
+- [x] 7.1 增加实验性标注、行内配置展开、两种主题色指针预览和持久化选择,维持完整工具列表对齐及中英文窄屏可用性。
+ - 390/1280px、中英文、明暗主题共 8 组完整 SectionTools 验证通过,涵盖键盘选择、关闭时配置、保存失败保留、重试及连续主题变更;图标、文字与最右开关对齐,无横向溢出。
+- [x] 7.2 扩展配置/API/runtime 外观传递,跟随当前主题色且不隐式启用工具,覆盖保存失败、旧配置和非法样式。
+ - 配置与真实 HTTP 路由验证部分更新、关闭时保存、旧配置默认、颜色规范化和保存失败不改变运行时;broker fixture 验证模型无法覆盖外观、helper PID/租约/观察连续性。应用外层同步实时主题,重连/回到前台刷新,首次读取失败后的重试保留最新主题色。
+- [x] 7.3 原生绘制主题色箭头及可选右下角 ACE,保持点击反馈、热点、DPI、截图与穿透行为一致,验证实际图片和 helper 操控。
+ - 生产 broker/helper 的完整操控实测通过,包含默认蓝色、指定橙色、ACE/plain 的 PNG 像素与相同热点;已查看实际截图。本次可用的一台显示器及 3000px 缩放窗口坐标点击通过。
+- [x] 7.4 更新功能/API说明,完成关联测试、整页视觉验证、前后端构建及 OpenSpec strict 检查。
+ - 139 项 C++ 关联测试、原生 helper 完整操控与 broker 集成测试、pnpm test/build、8 组完整页面验证全部通过。MSVC MinSizeRel 的主程序、Desktop、helper 已构建,CMake 在最终前端构建后重新配置并嵌入最新页面。OpenSpec strict 与 git diff --check 通过;当前运行的 build/Release 进程未替换,本次新版位于 build/MinSizeRel。
+
+## 8. Windows 执行器全量发布集成
+
+- [x] 8.1 验证 GitHub Windows x64/ARM64 构建、ZIP、安装器与 npm 包均交付独立执行器,非 Windows 不要求该文件。
+ - Windows CMake 依赖构建并安装独立组件;GitHub ZIP 解压后核对 helper SHA256,ARM64 PE 架构包含 helper。5 项 npm 包测试、10 项本地包结构测试通过,涵盖缺失、空文件及非 Windows 不交付。
+- [x] 8.2 将全平台更新镜像门禁同步到仓库发布脚本,验证 Windows 执行器缺失或空文件时在更新发布前失败,保留六平台 ZIP 与双 macOS PKG 门禁。
+ - 仓库发布脚本补齐全平台 mirror,0.9.22 起 Windows 更新包强制要求非空 helper;本地 Windows 包同时交付 WhatsApp 桥接资源。快速发布脚本与完整镜像脚本回归通过,缺失或空 helper 不改更新清单/公共文件,历史旧版本仍可镜像修复。
+- [x] 8.3 汇总已结束会话的可用本地成果,完成远端 PR 审核、修复、测试与合并,精确提交并推送。
+ - 已保留并整合完成会话的本地成果,PR #62/#63/#64 修复后合并。启动器 54 项脚本测试、侧栏 14 项真实回调时序回归、合并后前端全量测试及构建通过;Windows 主程序/Desktop/helper 构建、138 项日志与电脑操控测试及真实多进程日志追加 CTest 通过。发布脚本补齐 ZIP/PKG MIME 与公共清单逐包校验,PowerShell 5.1 的快速/全量镜像回归通过。
+- [x] 8.4 发布下一全量版本,核对最终提交、GitHub 产物、六平台更新清单、双 macOS PKG 及公共下载哈希。
+ - v0.9.22 发布提交为 362a7ccd68177985afe81c23e41e038a700fc38c;GitHub package run 35468311496 成功,正式 Release 交付 37 个资产。发布提交 test run 35468309485 全部成功,其中 Linux CTest 4681 项零失败,包含真实多进程日志追加回归。
+ - 全量镜像脚本成功退出,aupdate latest=0.9.22;六平台更新 ZIP、两个 macOS PKG 及 PKG 固定别名完成公网 MIME、长度和 SHA256 校验,清单逐包字段及升级说明一致。正式 Windows x64/ARM64 ZIP 内三个可执行文件均通过 PE 架构检查,独立 helper 完整交付,x64 包内 CLI 实际报告 acecode v0.9.22。
diff --git a/openspec/changes/add-workspace-folder-drag-reorder/.openspec.yaml b/openspec/changes/add-workspace-folder-drag-reorder/.openspec.yaml
new file mode 100644
index 000000000..cbd245e43
--- /dev/null
+++ b/openspec/changes/add-workspace-folder-drag-reorder/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-20
diff --git a/openspec/changes/add-workspace-folder-drag-reorder/design.md b/openspec/changes/add-workspace-folder-drag-reorder/design.md
new file mode 100644
index 000000000..a1cfc22f7
--- /dev/null
+++ b/openspec/changes/add-workspace-folder-drag-reorder/design.md
@@ -0,0 +1,29 @@
+## Context
+
+动机见 proposal.md。`Sidebar.jsx` 每轮刷新以服务端数组覆盖工作区;现有 `workspaceDragRef` 仅负责工作区内会话。`WorkspaceRegistry::list()` 当前来自无序映射,服务端和 Desktop bridge 共用它。`usePreference` 仅依赖 origin 内 localStorage,而桌面 daemon 使用动态端口,不能满足重启恢复。
+
+## Goals / Non-Goals
+
+**Goals:** 文件夹排序独立于会话排序;共享注册表成为持久化顺序的唯一来源;拖动事务拥有完整取消、点击抑制和竞态处理。
+
+**Non-Goals:** 不修改会话归属、置顶顺序、工作区激活和点击展开策略;不迁移其他 UI 偏好。
+
+## Decisions
+
+1. 在 projects 根目录单独原子保存工作区 hash 顺序,注册表在锁内验证可见工作区完整排列后提交。保留隐藏 hash 的原有位置,新 hash 追加;列表和 Desktop bridge 统一读取该顺序。避免逐个改 workspace.json 引入部分写入,也避免 localStorage 因端口变化失效。
+2. 新增 `PUT /api/workspaces/order`,请求 `{hashes: string[]}`,成功返回确认的 `{hashes}`。格式错误返回 400,非当前可见集合排列返回 409,保存失败返回 500。前端发送期间拒绝重复拖动,失败回滚并刷新。
+3. 拖动逻辑放入专用 hook;以标题行为起点、整个分组边界为目标,Pointer Events 跨过小位移阈值才启动,排除按钮、输入和非主指针。独立引用与现有会话拖动互斥。
+4. 浮层使用 portal、fixed 和 translate3d,复用文件夹图标、名称、字体和表面/阴影 token,保留抓取点;源分组淡化占位。插入线与端点放在整个目标分组边界。即时跟随指针,不增加过渡动画。
+5. 使用 requestAnimationFrame 处理边缘滚动与动态几何。Escape、blur、pointercancel、lostpointercapture、卸载、列表身份顺序变化或侧栏收起统一清理;拦截拖动后点击,普通点击继续展开收起。
+6. 前端排序纯函数保留对象身份;乐观提交用修订号标记,避免旧轮询响应覆盖新顺序。键盘 Alt+方向键复用同一提交路径。
+
+## Risks / Trade-offs
+
+- [风险] 后端和 Desktop 各自持有注册表快照 → 扫描时读取同一原子排序文件。
+- [风险] 工作区在拖动期间增删 → 前端取消变化中的拖动,后端校验排列并在冲突后刷新。
+- [风险] 原生菜单或任务按钮触发拖动 → 排除交互控件,保留原行为并在浏览器中验证。
+- [风险] 当前工作区有无关未提交修改 → 精确增量编辑并对比修改前快照。
+
+## Migration Plan
+
+首次没有排序文件时保留现有列表;首次成功排序才写入。损坏文件按无排序处理,不影响工作区可见性。回滚本次代码后工作区元数据和会话仍完整。
diff --git a/openspec/changes/add-workspace-folder-drag-reorder/proposal.md b/openspec/changes/add-workspace-folder-drag-reorder/proposal.md
new file mode 100644
index 000000000..e2112cf8e
--- /dev/null
+++ b/openspec/changes/add-workspace-folder-drag-reorder/proposal.md
@@ -0,0 +1,23 @@
+## Why
+
+侧栏工作区文件夹目前直接使用服务端返回顺序,用户无法安排常用项目的位置。需要像参考图一样在文件夹分组之间拖动排序,并在刷新或重新打开界面后保留顺序。
+
+## What Changes
+
+- 从文件夹标题行拖动整个工作区分组,显示跟随指针的文件夹图标和名称浮层、淡化的原分组,以及分组之间的插入线。
+- 按工作区 hash 在共享工作区注册表中持久化顺序,轮询、重命名、新增工作区以及桌面重启换端口不会覆盖已有自定义顺序。
+- 提供键盘调整顺序、边缘自动滚动、取消和拖动后点击抑制,保留普通点击展开收起与菜单、新建任务的行为。
+
+## Capabilities
+
+### New Capabilities
+- `sidebar-workspace-folder-order`: 工作区文件夹分组的拖动、键盘排序及顺序持久化。
+
+### Modified Capabilities
+无。
+
+## Impact
+
+- 修改 `web/src/components/Sidebar.jsx`、侧栏样式,增加排序与拖动辅助模块和定向测试。
+- 工作区注册表保存顺序,新增 `PUT /api/workspaces/order`,现有列表接口和桌面桥接返回已排序的工作区;不新增依赖。
+- 工作区身份、会话归属、工作区内会话及置顶任务的排序规则保持现有行为。
diff --git a/openspec/changes/add-workspace-folder-drag-reorder/specs/sidebar-workspace-folder-order/spec.md b/openspec/changes/add-workspace-folder-drag-reorder/specs/sidebar-workspace-folder-order/spec.md
new file mode 100644
index 000000000..6e3a6e796
--- /dev/null
+++ b/openspec/changes/add-workspace-folder-drag-reorder/specs/sidebar-workspace-folder-order/spec.md
@@ -0,0 +1,47 @@
+## Purpose
+
+让用户通过拖动或键盘安排侧栏工作区文件夹的位置,并清楚看到正在移动的分组及目标位置。排序以工作区身份持久化,保证刷新、重新打开桌面界面和工作区元数据变化后仍可恢复用户安排。
+
+## ADDED Requirements
+
+### Requirement: 文件夹分组可以拖动排序
+侧栏 SHALL 允许从工作区标题行拖动文件夹,在其他工作区分组之前或之后放下;任务所属工作区及任务内部顺序 MUST 保持不变。
+
+#### Scenario: 跨展开分组移动文件夹
+- **WHEN** 用户拖动一个文件夹经过其他已展开或已收起的工作区
+- **THEN** 图标和名称浮层跟随指针,原分组淡化并保留占位,插入线仅显示在工作区分组之间
+- **THEN** 放下后整个源分组移动到指示位置,保留各组展开状态和会话归属
+
+#### Scenario: 取消与普通点击
+- **WHEN** 用户按 Escape、丢失窗口焦点、取消指针或在列表外释放拖动
+- **THEN** 不提交排序,移除浮层与插入线,并恢复指针状态
+- **WHEN** 用户仅点击文件夹或其菜单、新建任务、重命名输入框
+- **THEN** 执行现有控件行为,拖放结束的合成点击不会展开收起文件夹
+
+### Requirement: 工作区顺序可靠持久化
+系统 SHALL 按工作区身份保存排序并从列表接口恢复;保存失败 MUST 恢复已确认顺序并提示错误,排序请求 MUST 是当前可见工作区的完整唯一排列。
+
+#### Scenario: 刷新和桌面重启
+- **WHEN** 用户成功保存顺序后刷新或重新启动桌面端,或工作区被重命名
+- **THEN** 文件夹恢复保存的相对顺序,桌面端口变化不影响恢复
+
+#### Scenario: 工作区集合变化
+- **WHEN** 保存后新增、隐藏或重新显示工作区
+- **THEN** 已排序的可见工作区保持相对顺序,新工作区追加,隐藏工作区不显示,重新显示的工作区保留其已保存位置
+- **WHEN** 提交的排列含重复或未知身份,或遗漏当前可见工作区
+- **THEN** 拒绝请求且不改变已保存顺序
+
+#### Scenario: 保存失败或刷新竞争
+- **WHEN** 保存失败,或旧轮询请求在拖放提交期间完成
+- **THEN** 失败会恢复排序并显示错误,旧轮询不会覆盖待保存或刚保存的排序
+
+### Requirement: 排序支持键盘和滚动
+侧栏 SHALL 支持聚焦文件夹后使用 Alt+上箭头或 Alt+下箭头调整位置,并在拖动接近滚动区域边缘时自动滚动;主题、窄窗口和减少动态效果设置下反馈 MUST 可辨识。
+
+#### Scenario: 键盘调整
+- **WHEN** 聚焦文件夹后按 Alt+上箭头或 Alt+下箭头
+- **THEN** 文件夹向对应方向移动一位并持久化,焦点仍保留在该文件夹
+
+#### Scenario: 长列表拖动
+- **WHEN** 用户在滚动区域顶部或底部附近拖动
+- **THEN** 列表自动滚动且插入位置随布局更新,减少动态效果时仍提供即时浮层和插入线反馈
diff --git a/openspec/changes/add-workspace-folder-drag-reorder/tasks.md b/openspec/changes/add-workspace-folder-drag-reorder/tasks.md
new file mode 100644
index 000000000..b3f782ef3
--- /dev/null
+++ b/openspec/changes/add-workspace-folder-drag-reorder/tasks.md
@@ -0,0 +1,22 @@
+## 1. 顺序持久化
+
+- [x] 1.1 注册表原子保存并恢复工作区顺序,支持隐藏/新增工作区;通过注册表测试验证重建恢复、非法排列及写失败。
+- [x] 1.2 接入排序 API 并更新 daemon-api 文档;通过路由验证覆盖成功、格式错误和冲突。
+
+## 2. 侧栏交互
+
+- [x] 2.1 增加排序纯函数、乐观保存和轮询竞争保护;定向测试验证边界、刷新及回滚。
+- [x] 2.2 接入标题拖动、文件夹浮层、分组插入线、自动滚动和取消/点击抑制;浏览器验证展开与收起分组、控件隔离和列表外释放。
+- [x] 2.3 接入 Alt+方向键、主题 token 和中英文文案;浏览器验证键盘、390px、中英文及减少动态效果。
+
+## 3. 综合验证
+
+- [x] 3.1 运行前端全量测试、构建、相关后端测试、OpenSpec strict 校验和差异检查,核对无关原有修改未被覆盖并记录验证边界。
+
+## 验证记录
+
+- `pnpm test`、`pnpm build` 和构建产物正则兼容检查通过;新增文案由 `pnpm i18n:catalog` 生成。
+- Release 单测目标构建通过,44 项工作区相关测试通过;统一 API 错误字段后再次增量构建并运行新增真实 HTTP 测试,400/409/500 错误码和持久化行为通过。
+- 真实 Sidebar 组件在 Chromium 中 14 组检查通过,覆盖分组拖动、浮层位置与字体、占位、刷新恢复、取消、按钮隔离、键盘、自动滚动、保存失败和冲突即时刷新,以及 390px、中英文、深浅主题和减少动态效果。浏览器网络使用隔离模拟数据,后端接口另由真实 HTTP 单测验证。
+- OpenSpec strict 校验、定向 `git diff --check` 通过。无关原有文件按哈希核对;`ComputerUseSettings.jsx` 在任务期间另有更新,本变更未编辑该文件,其余无关文件与快照一致。
+- 尚未重建或安装 Desktop/daemon 可执行文件;本次验证不代表安装包已更新。
diff --git a/openspec/changes/add-worktree-build-acceleration/.openspec.yaml b/openspec/changes/add-worktree-build-acceleration/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/add-worktree-build-acceleration/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/add-worktree-build-acceleration/design.md b/openspec/changes/add-worktree-build-acceleration/design.md
new file mode 100644
index 000000000..ed3aa8296
--- /dev/null
+++ b/openspec/changes/add-worktree-build-acceleration/design.md
@@ -0,0 +1,60 @@
+# Design
+
+## Context
+
+See `proposal.md` for motivation and `specs/worktree-build-acceleration/spec.md` for requirements. `scripts/dev_environment.py` currently validates builds against Git worktrees, keeps build directories independent, incrementally builds the selected target, and calls the Desktop launcher module to refresh frontend assets. Its first-worktree build compiles all ACECode sources even when a related worktree has already compiled identical content.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Preserve one CMake/Ninja build directory per worktree and selected preset.
+- Reuse content-addressed compiler results through the user's existing `sccache` cache.
+- Reuse a verified frontend production output only when Git and working-tree state make it safe.
+- Make all acceleration paths optional and self-healing through ordinary local builds.
+
+**Non-Goals:**
+
+- Copy, link, or share CMake caches, Ninja files, object files, executables, or `node_modules` between worktrees.
+- Download or install `sccache` automatically.
+- Change production builds or require compiler caching for any developer.
+
+## Decisions
+
+### Configure sccache through CMake compiler launchers
+
+The launcher finds `sccache` from `PATH` plus conventional platform-specific package-manager installation directories. When executable, it passes `CMAKE_C_COMPILER_LAUNCHER` and `CMAKE_CXX_COMPILER_LAUNCHER` only during CMake configuration. A launcher-owned cache-state marker in the build directory records whether caching was successfully configured, allowing the next launch to decide whether it must reconfigure when availability changes.
+
+Alternatives considered:
+
+- Set process-wide compiler environment variables: harder to audit and can affect unrelated commands.
+- Use `clcache`: works for MSVC but needs a separate implementation for other supported platforms.
+- Share the build directory: unsafe because generated build metadata stores absolute worktree paths.
+
+### Treat cache instrumentation as best effort
+
+The launcher invokes `sccache --show-stats --stats-format=json` before and after building when supported, computes a delta for cache hits, misses, and errors, and prints it. Parsing or command failures disable only reporting. Cache configuration failures or cache-enabled build failures trigger a CMake reconfiguration without launchers and one ordinary-build retry; only a failed retry stops the requested launch.
+
+### Copy frontend artifacts, never link them
+
+The launcher considers registered Git worktrees at the same commit. It excludes a worktree if `git status --porcelain -- web` has output. It selects the newest valid `web/dist/index.html`, copies the entire `web/dist` tree into the current worktree, and invokes the existing freshness predicate. Linking would let a build in one worktree mutate another's assets; copying preserves ownership after the seed step.
+
+Alternatives considered:
+
+- Trust commit equality alone: local frontend modifications could make a copied artifact stale.
+- Hash all frontend input files: stricter but slower and unnecessary once both trees are clean at the same commit.
+- Require `node_modules`: not needed to serve a verified copied output.
+
+## Risks / Trade-offs
+
+- [A cache tool installs in an unrecognized path] → use `PATH` first, document the optional install hint, and safely fall back.
+- [Other builds change global sccache statistics] → label figures as per-launch observed deltas rather than exact attribution.
+- [A copied artifact becomes stale while copying] → compare frontend input modification times after the copy; local Vite build remains the fallback.
+- [Cache configuration fails after a previously successful configuration] → reconfigure without launchers and continue the requested build.
+
+## Migration Plan
+
+1. Add pure helpers for cache discovery/state and frontend seed eligibility, with focused tests.
+2. Integrate helpers into first-time and existing-build configuration flows.
+3. Run the Web launcher in a clean worktree with and without `sccache`, confirming both fallback and accelerated paths.
+4. Roll back by removing launcher cache settings and seed logic; all build directories and frontend output remain usable through the existing local build flow.
diff --git a/openspec/changes/add-worktree-build-acceleration/proposal.md b/openspec/changes/add-worktree-build-acceleration/proposal.md
new file mode 100644
index 000000000..49b5d0451
--- /dev/null
+++ b/openspec/changes/add-worktree-build-acceleration/proposal.md
@@ -0,0 +1,30 @@
+# Proposal
+
+## Why
+
+New Git worktrees require independent CMake build directories for correctness, but compiling every unchanged ACECode source file and rebuilding identical frontend output makes their first development launch unnecessarily slow. The development launcher should safely reuse cross-worktree caches and verified frontend output without sharing path-bound build directories.
+
+## What Changes
+
+- Detect and configure `sccache` as an optional cross-worktree C/C++ compiler cache for development builds.
+- Report concise per-launch cache activity and fall back to normal compilation when cache discovery, configuration, or status collection fails.
+- Detect cache-tool configuration changes and reconfigure the affected build directory before its next incremental build.
+- Seed a new worktree's missing or stale `web/dist` from the newest eligible, clean, same-commit worktree and validate it before skipping Vite.
+- Fall back to the existing local frontend build process if frontend artifact reuse is unavailable or fails.
+
+## Capabilities
+
+### New Capabilities
+
+- `worktree-build-acceleration`: Safe cross-worktree compiler-cache and frontend-artifact reuse for development launcher startup.
+
+### Modified Capabilities
+
+- None.
+
+## Impact
+
+- `scripts/dev_environment.py` gains compiler-cache detection, CMake configuration management, and frontend seed selection.
+- `scripts/dev_desktop.py` exposes or shares frontend freshness checks used by the launcher.
+- Launcher tests cover cache discovery/configuration transitions and frontend-copy eligibility/fallback.
+- No production application runtime, daemon API, or shared CMake build directory behavior changes.
diff --git a/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md b/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md
new file mode 100644
index 000000000..e4eb190ef
--- /dev/null
+++ b/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md
@@ -0,0 +1,55 @@
+# Spec Delta
+
+## Purpose
+
+Accelerate first development launches in new worktrees without sharing build directories that encode worktree-specific source paths.
+
+## ADDED Requirements
+
+### Requirement: Optional shared compiler cache
+The development launcher SHALL detect an available `sccache` executable using the system path and supported platform-specific installation locations. When available, it SHALL configure C and C++ compiler launchers for a development build directory so worktrees may reuse the user's default `sccache` cache. When unavailable or unusable, the launcher SHALL continue with an ordinary local compilation and provide a concise status message with an installation suggestion when the cache is absent.
+
+#### Scenario: Configure an available compiler cache
+- **WHEN** a launcher prepares a configured development build and finds a usable `sccache` executable
+- **THEN** it configures C and C++ compiler launchers to use `sccache` before the target's incremental build
+
+#### Scenario: Cache tool unavailable
+- **WHEN** a launcher cannot find `sccache`
+- **THEN** it reports an optional installation suggestion and continues with normal compilation
+
+#### Scenario: Cache tool failure
+- **WHEN** compiler-cache configuration, execution, statistics collection, or a cache-enabled build fails
+- **THEN** the launcher reports a concise reason, reconfigures without compiler launchers when necessary, and retries ordinary compilation without blocking target startup
+
+### Requirement: Cache configuration transitions
+The development launcher SHALL detect when an existing build directory's configured compiler-cache state differs from the currently available cache state. It SHALL reconfigure that build directory before its next incremental build to add or remove compiler launcher settings as required.
+
+#### Scenario: Install cache after initial configuration
+- **WHEN** `sccache` becomes available after a build directory was configured without it
+- **THEN** the next development launch reconfigures the build directory before building
+
+#### Scenario: Remove cache after configuration
+- **WHEN** `sccache` is no longer available for a build directory configured to use it
+- **THEN** the next development launch reconfigures the build directory without compiler launcher settings before building
+
+### Requirement: Per-launch compiler-cache status
+When `sccache` is used successfully, the development launcher SHALL report a concise summary of the cache hits, misses, and errors observed during that launch's build operation. It SHALL not require cache statistics to start a development target.
+
+#### Scenario: Report build cache activity
+- **WHEN** the cache statistics can be read before and after a development build
+- **THEN** the launcher reports the per-launch differences for hits, misses, and errors
+
+### Requirement: Verified frontend artifact seed
+Before a Web or Desktop development launch builds frontend assets, the launcher SHALL look for a seed `web/dist` from registered Git worktrees. It MAY copy a seed only when the source and current worktrees are at the same commit, both have no uncommitted changes under `web/`, the source has `web/dist/index.html`, and the current output is missing or stale. If more than one eligible source exists, it SHALL select the source with the newest `web/dist/index.html`. It SHALL validate copied output against current frontend input timestamps before skipping the local frontend build.
+
+#### Scenario: Seed missing frontend output
+- **WHEN** a new worktree has no `web/dist`, and an eligible same-commit worktree has current frontend output
+- **THEN** the launcher copies and validates the newest eligible output, then skips `pnpm build`
+
+#### Scenario: Dirty frontend worktree
+- **WHEN** either the source or current worktree has uncommitted changes under `web/`
+- **THEN** the launcher does not reuse that source's frontend output
+
+#### Scenario: Seed copy failure
+- **WHEN** frontend artifact copying or validation fails
+- **THEN** the launcher reports the fallback and runs the ordinary local frontend build
diff --git a/openspec/changes/add-worktree-build-acceleration/tasks.md b/openspec/changes/add-worktree-build-acceleration/tasks.md
new file mode 100644
index 000000000..b0dd70619
--- /dev/null
+++ b/openspec/changes/add-worktree-build-acceleration/tasks.md
@@ -0,0 +1,17 @@
+# Tasks
+
+## 1. Compiler-cache integration
+
+- [x] 1.1 Add cross-platform `sccache` discovery, installation guidance, and cache-state helpers; verify focused unit tests cover PATH, conventional paths, and absent-cache fallback.
+- [x] 1.2 Configure or remove CMake compiler launchers when cache availability changes, with ordinary-build fallback on cache configuration or cache-enabled build errors; verify subprocess command tests cover both transitions.
+- [x] 1.3 Collect and display best-effort per-launch `sccache` hit, miss, and error deltas; verify statistic parsing and failure fallback with focused tests.
+
+## 2. Frontend artifact reuse
+
+- [x] 2.1 Implement clean same-commit worktree eligibility and newest `web/dist` source selection; verify dirty, mismatched-commit, missing-output, and newest-source cases.
+- [x] 2.2 Copy and validate a frontend seed before the local Vite build, with local-build fallback on copy or freshness failure; verify focused tests cover successful skip and fallback.
+
+## 3. Integration and verification
+
+- [x] 3.1 Wire compiler-cache and frontend-seed behavior into Web and Desktop launcher flows while preserving isolated build directories; verify focused launcher tests pass.
+- [x] 3.2 Run Python compilation, OpenSpec strict validation, and an end-to-end Web startup with cache absent or present; verify daemon page reachability and report the observed acceleration path.
diff --git a/openspec/changes/centralize-tui-logs/.openspec.yaml b/openspec/changes/centralize-tui-logs/.openspec.yaml
new file mode 100644
index 000000000..f2cbbe6a6
--- /dev/null
+++ b/openspec/changes/centralize-tui-logs/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-18
diff --git a/openspec/changes/centralize-tui-logs/design.md b/openspec/changes/centralize-tui-logs/design.md
new file mode 100644
index 000000000..1c3d3b3c1
--- /dev/null
+++ b/openspec/changes/centralize-tui-logs/design.md
@@ -0,0 +1,97 @@
+# Design: centralize-tui-logs
+
+## Context
+
+The normal interactive startup path calls a file-static `initialize_logger_for_working_dir()` in `src/main.cpp`. It selects `Logger::init(working_dir + "/acecode.log")`, the legacy single-file mode. That explicit choice, rather than a logger fallback, causes project-local logs. `src/tui/tui_init.cpp` contains a duplicate helper with the same legacy behavior, although the current main path uses the file-static copy.
+
+`Logger::init_with_rotation(get_logs_dir(), base_name, mirror_stderr)` is already used by desktop (`desktop`), daemon (`daemon`), and headless (`headless`). It creates the directory, names files `-YYYY-MM-DD.log`, and reopens a new file when the local date changes. `get_logs_dir()` resolves from `get_acecode_dir()`, preserving the application-wide user/service data-root behavior.
+
+TUI `/feedback` separately adds `ctx.cwd/acecode.log`, while the shared runtime-log collector finds desktop and daemon logs for GUI/Desktop feedback. The selected feedback boundary is origin-aware: TUI feedback must attach the centralized TUI log and must not attach Desktop logs; GUI/Desktop feedback must retain Desktop and daemon logs but must not attach TUI logs. This must move with the primary logger so TUI diagnostics remain attached after centralization.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Make normal interactive TUI runtime logs follow the same centralized data-directory convention as other runtime surfaces.
+- Keep TUI records separate from desktop, daemon, and headless records via a `tui` filename prefix.
+- Preserve legacy workspace files without filesystem migration or deletion.
+- Ensure each feedback package contains its own surface log but not an unrelated interface's surface log.
+- Keep the change localized, testable, and independent of frontend surfaces.
+
+**Non-Goals:**
+
+- Do not change the logger's legacy `init(file)` API; tests or specialized callers may still use it.
+- Do not change which input events the opt-in `ACECODE_TUI_INPUT_TRACE` instrumentation records or its trace message format.
+- Do not add retention, compression, or a user-configurable logging directory.
+- Do not merge TUI logs with desktop or daemon logs.
+
+## Decisions
+
+### D1. Use the existing rotated logger with the `tui` base name
+
+Both TUI logger initialization helpers will use:
+
+```cpp
+Logger::instance().init_with_rotation(get_logs_dir(), "tui", false);
+```
+
+This gives TUI logs the path `/logs/tui-YYYY-MM-DD.log`, automatically creates the directory, rotates at local midnight, and retains the current behavior of not writing log records to stderr. `working_dir` remains useful for the startup log message and later workspace setup, but no longer determines the log file path.
+
+**Alternative considered:** keep a fixed `/logs/acecode.log`. Rejected because the existing runtime convention is per-surface dated files, and a TUI-specific prefix keeps simultaneous surfaces diagnosable.
+
+### D2. Reconcile the duplicated TUI initialization helpers
+
+The implementation must prevent `src/main.cpp` and `src/tui/tui_init.cpp` from continuing to encode divergent logger destinations. Prefer one shared TUI initialization helper when its existing dependency boundaries permit it; otherwise update both helpers to the same centralized rotated initialization and retain a focused regression test for the active startup seam.
+
+The work must not leave a future path capable of silently restoring workspace-local primary logs.
+
+### D3. Make runtime-log discovery feedback-origin-aware
+
+Keep the existing GUI/Desktop runtime-log collector limited to `desktop` and `daemon`, preserving its documented REST contract. Add a TUI-specific collector (or an equivalent explicit source selector) that returns the latest `tui-YYYY-MM-DD.log` as `logs/tui.log.tail.txt` together with the daemon log, but excludes the Desktop log. Remove the TUI command's bespoke `ctx.cwd/acecode.log` source.
+
+Both selectors use `latest_rotated_log_path` so matching and newest-file rules remain centralized. Missing logs remain non-fatal. This yields two explicit source sets:
+
+- TUI `/feedback`: TUI + daemon + recent upgrade diagnostics.
+- GUI/Desktop feedback: Desktop + daemon + recent upgrade diagnostics.
+
+**Alternative considered:** add `tui` to `collect_runtime_log_sources(logs_dir)`. Rejected because that shared function backs the GUI/Desktop REST endpoint; it would silently broaden GUI diagnostic collection with unrelated terminal logs. Another alternative was a bespoke TUI path lookup in the command; rejected because it duplicates date-file matching and latest-file selection.
+
+### D4. Configure FTXUI tracing through the process environment
+
+FTXUI is built as a vcpkg dependency and cannot depend directly on ACECode's C++ configuration helpers. Add a dedicated environment variable, set by interactive TUI startup before FTXUI processes input, whose value is the UTF-8 absolute logs directory. FTXUI's trace writer reads that directory and creates/appends `tui-input-trace-YYYY-MM-DD.log` for the current local date on each trace write. If the variable is absent or the target file cannot be opened, tracing remains best-effort and creates no relative fallback file.
+
+This preserves a clear dependency boundary: ACECode owns effective data-directory resolution, while FTXUI owns only local-date filename generation and appending trace records below the explicitly supplied directory. The trace writer should create the target directory before opening its file. An explicit absolute directory prevents worktree cwd changes from affecting the destination.
+
+**Alternative considered:** link FTXUI against ACECode's logger/configuration utilities. Rejected because the dependency direction reverses the build boundary and would make the reusable FTXUI target depend on the application. Another alternative was a compile-time path definition; rejected because the effective data directory may be redirected at runtime and the date changes while a TUI process remains open.
+
+### D5. Bump the FTXUI overlay port version
+
+The overlay port builds `external/ftxui` from `SOURCE_PATH`, but vcpkg's ABI does not hash that source tree. Increment `ports/ftxui/vcpkg.json` `port-version` whenever the FTXUI source changes, ensuring existing local builds and CI rebuild the patched static library rather than reusing one that still writes `acecode.log` in the cwd.
+
+### D6. Document the effective data-directory placeholder
+
+Update the user manual's FAQ and MCP troubleshooting text to use `<数据目录>/logs/tui-YYYY-MM-DD.log`. Document the input-trace file separately as an opt-in debug artifact. Both descriptions use the effective data directory, which is accurate across supported platforms and data-root redirection, unlike hardcoding a Windows-only home path.
+
+## Risks / Trade-offs
+
+### PR 审核补充:Unicode 路径与多进程写入
+
+Windows 创建目录与打开日志统一从 UTF-8 转换到原生路径,避免首次启动在中文数据目录创建乱码目录。集中日志允许多个工作区的 TUI 同时写入,因此不能用进程内互斥锁保护 CRT 的 seek-to-end + write:主日志使用独立的低层追加文件模块,Windows 以 `FILE_APPEND_DATA` 打开并整条 `WriteFile`,POSIX 以 `O_APPEND` 打开并整条 `write`。FTXUI 在其自身依赖边界内使用同样的系统语义,保持追踪记录格式。拒绝相对追踪目录;打开失败时维持静默 best-effort,不写工作区备用文件。
+
+回归验证包含独立进程同时写同一文件、中文路径首次创建及无法打开的目标。操作系统报告短写或磁盘错误时不把残余片段另行追加,以免把一条记录拆散穿插到其他进程记录中;这些错误仍按既有日志 best-effort 语义处理。
+
+- **Feedback archive name change:** consumers that recognize `logs/acecode.log.tail.txt` will no longer see a TUI legacy entry. The old file was workspace-specific and is no longer a valid runtime source; a distinct `logs/tui.log.tail.txt` accurately identifies the new source. The high-volume FTXUI input trace remains a local diagnostic artifact and is not attached by either feedback origin.
+- **Startup ordering:** TUI logger initialization remains before normal configuration loading, as it is today. `get_logs_dir()` only uses existing data-directory resolution and does not require loaded application configuration.
+- **Old docs and external scripts:** workspace-local `acecode.log` may be referenced externally. The selected migration policy intentionally preserves old files but does not maintain an active compatibility copy, preventing further workspace pollution.
+
+## Migration Plan
+
+On the first upgraded interactive TUI launch, ACECode writes to the centralized dated TUI file. Existing workspace `acecode.log` files are untouched. `/feedback` begins attaching the latest centralized TUI log when available. No configuration or session migration is required.
+
+## Validation
+
+- Unit-test logger rotation behavior using the established `Logger` test fixture and add a targeted assertion for `tui` file naming.
+- Unit-test feedback runtime source discovery with TUI, desktop, and daemon files, including latest-file selection and missing-TUI behavior.
+- Add or adjust a focused TUI startup helper test so the production initialization chooses `get_logs_dir()` and the `tui` base name without needing an interactive terminal.
+- Run the focused native tests and the repository's relevant build target.
+- Verify documentation contains no remaining normal-TUI instructions to inspect workspace-local `acecode.log`.
diff --git a/openspec/changes/centralize-tui-logs/proposal.md b/openspec/changes/centralize-tui-logs/proposal.md
new file mode 100644
index 000000000..d0b14a46c
--- /dev/null
+++ b/openspec/changes/centralize-tui-logs/proposal.md
@@ -0,0 +1,34 @@
+# Proposal: centralize-tui-logs
+
+## Why
+
+Interactive TUI startup currently writes a non-rotating `acecode.log` into the active workspace. This pollutes project directories, including startup worktrees, and differs from Desktop, daemon, and headless modes, which write dated logs in the ACECode data directory. The TUI feedback command also packages the workspace-local file, so it would omit the relevant diagnostics after the logging destination changes unless its source is updated together.
+
+## What Changes
+
+- Move the interactive TUI primary logger from `/acecode.log` to `/logs/tui-YYYY-MM-DD.log`.
+- Reuse the existing rotating logger behavior: create the shared logs directory as needed, rotate at local midnight, and do not mirror TUI records to stderr.
+- Preserve existing workspace `acecode.log` files without migrating or deleting them; future normal TUI runs no longer create or append to them.
+- Change TUI `/feedback` to attach the latest rotated TUI log as `logs/tui.log.tail.txt` instead of attaching `/acecode.log`; it continues to attach the daemon and recent upgrade diagnostics but no longer attaches Desktop logs.
+- Keep GUI/Desktop feedback limited to Desktop, daemon, and recent upgrade diagnostics; it must not attach TUI logs.
+- Move opt-in FTXUI input tracing from a relative `acecode.log` in the active workspace to an independent centralized daily file, `/logs/tui-input-trace-YYYY-MM-DD.log`.
+- Update user documentation and test coverage for the centralized TUI location and feedback source.
+
+## Capabilities
+
+### New Capabilities
+
+- `tui-runtime-logging`: Centralized, dated TUI runtime logs and their feedback-package inclusion.
+
+### Modified Capabilities
+
+(None.)
+
+## Impact
+
+- **TUI startup:** `src/main.cpp` and the duplicate TUI logger initialization helper in `src/tui/tui_init.cpp`.
+- **FTXUI input tracing:** `external/ftxui/src/ftxui/component/app.cpp`, its CMake definitions, and `ports/ftxui/` overlay-version metadata.
+- **Feedback:** `src/commands/builtin_commands.cpp` and the rotated-log discovery helpers in `src/feedback/feedback_upload.*`.
+- **Tests:** logger, feedback-package, and a focused TUI initialization seam if needed for testability.
+- **Documentation:** `docs/user-manual.md`; update `docs/daemon-api.md` if its feedback attachment contract is expanded to name the TUI runtime log.
+- **Compatibility:** existing FTXUI trace message format remains unchanged; only its output destination and daily rotation behavior change.
diff --git a/openspec/changes/centralize-tui-logs/specs/tui-runtime-logging/spec.md b/openspec/changes/centralize-tui-logs/specs/tui-runtime-logging/spec.md
new file mode 100644
index 000000000..50854786b
--- /dev/null
+++ b/openspec/changes/centralize-tui-logs/specs/tui-runtime-logging/spec.md
@@ -0,0 +1,111 @@
+# tui-runtime-logging Spec
+
+## ADDED Requirements
+
+### Requirement: Interactive TUI logs use the ACECode data directory
+
+The interactive TUI SHALL initialize its primary logger with the effective ACECode data directory's `logs` subdirectory and the `tui` base name. It MUST create or append to a local-date file named `tui-YYYY-MM-DD.log`, rotate to a new file after local midnight, and MUST NOT mirror normal TUI log records to stderr.
+
+The effective data directory MUST follow the existing `get_acecode_dir()` run-mode resolution, so normal user launches use the user ACECode data directory rather than the active workspace or startup worktree.
+
+#### Scenario: Normal TUI startup does not pollute the workspace
+
+- **WHEN** a user starts the interactive TUI in a workspace that has no `acecode.log`
+- **THEN** the TUI creates or appends `/logs/tui-YYYY-MM-DD.log`
+- **AND THEN** it does not create `/acecode.log`
+
+#### Scenario: Startup worktree does not become the log destination
+
+- **WHEN** a user starts the interactive TUI with `--worktree `
+- **THEN** the TUI log is written below the effective ACECode data directory's `logs` directory
+- **AND THEN** no primary TUI log is created inside the newly selected worktree
+
+#### Scenario: Existing workspace log is preserved
+
+- **WHEN** `/acecode.log` already exists before interactive TUI startup
+- **THEN** ACECode does not move, delete, or append to that file as part of logger initialization
+
+### Requirement: Opt-in FTXUI input tracing uses a separate centralized log
+
+When `ACECODE_TUI_INPUT_TRACE` is enabled, FTXUI SHALL write its input-event trace to `/logs/tui-input-trace-YYYY-MM-DD.log`. It MUST create the logs directory when needed and MUST rotate to the local-date file after local midnight. It MUST NOT create or append a relative `acecode.log` in the active workspace or startup worktree.
+
+The input-trace file SHALL remain distinct from `tui-YYYY-MM-DD.log`, so high-frequency mouse and selection diagnostics do not obscure normal TUI runtime logs. It SHALL NOT be attached to TUI or GUI/Desktop feedback packages. Existing trace record text and its `DBG [ftxui-app]` prefix SHALL remain unchanged.
+
+#### Scenario: Input tracing does not write inside a normal workspace
+
+- **WHEN** TUI input tracing is enabled and a user starts the TUI in a workspace
+- **THEN** trace records are written to `/logs/tui-input-trace-YYYY-MM-DD.log`
+- **AND THEN** no trace-created `/acecode.log` exists
+
+#### Scenario: Input tracing does not write inside a startup worktree
+
+- **WHEN** TUI input tracing is enabled and a user starts the TUI with `--worktree `
+- **THEN** trace records are written to the effective ACECode data directory's `logs` subdirectory
+- **AND THEN** no trace-created `acecode.log` exists within the selected worktree
+
+#### Scenario: Input tracing remains separate from primary TUI logs
+
+- **WHEN** TUI input tracing and normal TUI logging both emit records
+- **THEN** FTXUI trace records appear only in `tui-input-trace-YYYY-MM-DD.log`
+- **AND THEN** primary runtime records continue to appear in `tui-YYYY-MM-DD.log`
+
+### Requirement: TUI feedback includes the centralized TUI log
+
+The TUI `/feedback` workflow SHALL find the most recently modified rotated TUI log in the effective ACECode data directory's `logs` subdirectory and include it in the feedback package as `logs/tui.log.tail.txt` when available. It MUST NOT add `/acecode.log` as a normal TUI feedback source.
+
+Failure to find a rotated TUI log MUST remain non-fatal and MUST NOT prevent collection of daemon or upgrade logs. TUI feedback MUST NOT attach a Desktop surface log, and GUI/Desktop feedback MUST NOT attach a TUI surface log.
+
+#### Scenario: Feedback package includes the latest TUI log
+
+- **WHEN** the logs directory contains multiple `tui-YYYY-MM-DD.log` files
+- **AND WHEN** a user runs `/feedback`
+- **THEN** the package includes the most recently modified matching file under `logs/tui.log.tail.txt`
+
+#### Scenario: Legacy workspace log is not attached
+
+- **WHEN** the workspace contains an old `acecode.log`
+- **AND WHEN** a rotated TUI log is available in the data directory
+- **THEN** the feedback package attaches the rotated TUI log
+- **AND THEN** it does not attach the workspace-local legacy log
+
+#### Scenario: TUI feedback excludes Desktop logs
+
+- **WHEN** a TUI user runs `/feedback`
+- **AND WHEN** a `desktop-YYYY-MM-DD.log` file is available
+- **THEN** the package does not attach `logs/desktop.log.tail.txt`
+
+#### Scenario: Desktop feedback excludes TUI logs
+
+- **WHEN** a GUI/Desktop user submits feedback
+- **AND WHEN** a `tui-YYYY-MM-DD.log` file is available
+- **THEN** the package does not attach `logs/tui.log.tail.txt`
+
+#### Scenario: Missing TUI log does not block feedback
+
+- **WHEN** no `tui-YYYY-MM-DD.log` exists in the logs directory
+- **THEN** feedback packaging continues without `logs/tui.log.tail.txt`
+- **AND THEN** other available daemon or upgrade log sources continue to be collected
+
+### Requirement: 集中日志支持 Unicode 路径和多进程追加
+
+主日志和 FTXUI 输入追踪 MUST 正确创建中文数据目录下的日志目录。多个进程向同一日期文件写入时 MUST 使用操作系统原子追加定位,不能因并发 seek/write 覆盖其他进程已写记录。追踪目录缺失、为相对路径或不可写时 MUST 静默跳过,不能回退到工作区文件。
+
+#### Scenario: 多工作区同时写日志
+
+- **WHEN** 多个 TUI 进程同时向相同数据目录写入正常长度的日志或输入追踪记录
+- **THEN** 所有成功写入的完整记录各出现一次,互不覆盖
+
+#### Scenario: 首次创建中文日志目录
+
+- **WHEN** 有效数据目录含中文且 logs 子目录尚不存在
+- **THEN** 初始化创建正确 Unicode 路径并写入对应日期日志
+
+### Requirement: User documentation describes the unified runtime log location
+
+User-facing documentation SHALL describe the interactive TUI log as `/logs/tui-YYYY-MM-DD.log` and direct TUI troubleshooting, including MCP connection troubleshooting, to the centralized runtime logs directory. It MUST NOT describe normal TUI runtime logging as writing `/acecode.log`.
+
+#### Scenario: Log-location documentation is consistent
+
+- **WHEN** a user reads the logging FAQ or MCP troubleshooting instructions
+- **THEN** both identify the centralized TUI dated log location
+- **AND THEN** neither instructs the user to inspect a workspace-local `acecode.log` for normal TUI runtime logs
diff --git a/openspec/changes/centralize-tui-logs/tasks.md b/openspec/changes/centralize-tui-logs/tasks.md
new file mode 100644
index 000000000..3bb0861e5
--- /dev/null
+++ b/openspec/changes/centralize-tui-logs/tasks.md
@@ -0,0 +1,43 @@
+# Tasks: centralize-tui-logs
+
+## 1. TUI logger initialization
+
+- [x] 1.1 Replace the interactive TUI startup use of workspace-local `Logger::init(.../acecode.log)` with `Logger::init_with_rotation(get_logs_dir(), "tui", false)` while preserving debug log level and startup diagnostics.
+- [x] 1.2 Reconcile the duplicate initialization helper in `src/tui/tui_init.*` so it cannot retain or reintroduce workspace-local primary logging.
+- [x] 1.3 Add focused native coverage proving the TUI logger uses the shared logs directory, `tui-YYYY-MM-DD.log` naming, no stderr mirror, and does not select the workspace as the primary log destination.
+
+## 2. FTXUI input-trace destination
+
+- [x] 2.1 Add a TUI-startup environment bridge that supplies FTXUI the effective ACECode logs directory before input processing starts.
+- [x] 2.2 Change the opt-in FTXUI trace writer to create and append `tui-input-trace-YYYY-MM-DD.log` below that directory, with local-date rollover and no relative-path fallback.
+- [x] 2.3 Bump the FTXUI overlay port version so vcpkg rebuilds the source-backed dependency.
+- [ ] 2.4 Add focused coverage for trace-path selection and local-date filename generation without requiring an interactive terminal; verify an input-trace-enabled build does not create workspace/worktree `acecode.log`.
+
+## 3. Feedback runtime-log collection
+
+- [x] 3.1 Preserve the GUI/Desktop runtime-log selector as Desktop + daemon only; add a TUI feedback selector that returns latest TUI + daemon logs and excludes Desktop logs.
+- [x] 3.2 Remove the TUI `/feedback` command's explicit `/acecode.log` source and use the TUI centralized selector.
+- [x] 3.3 Add feedback-package tests covering latest TUI log selection, TUI exclusion of Desktop logs, GUI/Desktop exclusion of TUI logs, missing TUI logs, and exclusion of a workspace-local legacy `acecode.log`.
+
+## 4. Documentation
+
+- [x] 4.1 Update the user-manual logging FAQ to describe `<数据目录>/logs/tui-YYYY-MM-DD.log`, the opt-in `tui-input-trace-YYYY-MM-DD.log`, and the preservation of legacy workspace files.
+- [x] 4.2 Update MCP failure troubleshooting text to direct users to the centralized TUI dated log.
+- [x] 4.3 Update daemon API feedback-package documentation if its runtime-log attachment listing needs to name `logs/tui.log.tail.txt`.
+
+## 5. Verification
+
+- [x] 5.1 Run the focused logger, FTXUI trace-path, and feedback native tests.
+- [ ] 5.2 Configure and build an input-trace-enabled TUI executable with the bumped FTXUI port, then verify normal-workspace and `--worktree` runs leave no trace-created `acecode.log`.
+- [x] 5.3 Review the documentation diff and verify normal-TUI and FTXUI input-trace documentation no longer directs users to workspace-local `acecode.log`.
+
+## 6. PR 审核修复
+
+- [x] 6.1 修复 Windows UTF-8 日志目录首次创建,覆盖中文用户目录及重定向目录。
+- [x] 6.2 主日志与 FTXUI 输入追踪使用操作系统追加语义,避免多进程 seek/write 竞争覆盖记录。
+- [x] 6.3 增加并运行中文路径、多进程完整记录、追踪路径与不可写目标的独立回归,记录验证边界。
+- [x] 6.4 将 PR 新增根目录 CONTEXT.md 的术语移入已有反馈 ADR,保持根目录规范;现存同名引用均指 OpenCode 竞品文档。
+
+审核验证:Windows MSVC 独立 fixture 的 4 项 Python 回归和 10 项 LoggerRotationTest 通过;主日志、低层追加和 FTXUI 输入追踪各 4 进程 × 2000 条,均收到 8000 条完整唯一记录。中文新目录、追踪本地午夜、空/相对/不可打开目录均通过;开启 `ACECODE_TUI_INPUT_TRACE=1` 的真实 FTXUI app.cpp 定向编译通过。完整交互式 TUI 与非 Windows 实机验证仍由 5.2 和后续构建覆盖,不将 fixture 测试记作完整 TUI 运行。
+
+合并验证:canonical 的 MSVC MinSizeRel 主程序、Desktop 与测试程序构建通过;138 项日志、反馈、电脑操控相关测试和真实 `runtime_log_append` ctest 通过。
diff --git a/openspec/changes/configure-message-auto-collapse/.openspec.yaml b/openspec/changes/configure-message-auto-collapse/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/configure-message-auto-collapse/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/configure-message-auto-collapse/design.md b/openspec/changes/configure-message-auto-collapse/design.md
new file mode 100644
index 000000000..95308f34b
--- /dev/null
+++ b/openspec/changes/configure-message-auto-collapse/design.md
@@ -0,0 +1,21 @@
+## Context
+
+`projectCollapsedTranscriptItems` 负责工具配对、特殊分组、工具段与完成轮次折叠;子代理通过 `projectSubagentTranscriptItems` 复用。`TranscriptItems` 递归渲染两种会话。外观保存已有顺序写入、回滚和 Desktop 首帧注入机制。
+
+## Goals / Non-Goals
+
+- 新开关默认 true,旧配置缺字段保持现有效果;false 在刷新、重启后保留。
+- 主会话与子代理使用同一投影策略;切换后立即重投影,历史与实时消息一致。
+- 关闭只保留工具详情的折叠,所有非工具正文和分组内容直接显示。
+- 不更改后端历史、工具执行、上下文压缩行为及长会话渐进加载。
+
+## Decisions
+
+1. 以 `web_ui.message_auto_collapse` 为持久化字段,前端使用 `messageAutoCollapse`,复用现有完整外观快照的保存/失败回滚。
+2. 在共享投影函数分支跳过完成轮次与工具段汇总,继续使用工具标准化与完成总结生成;实时空状态仍保留不可折叠的状态行。
+3. 向主会话、子代理和共享渲染组件传递同一布尔值。图片/子代理组保留语义与跳转入口,但关闭折叠交互;系统通知正文直接展开。
+4. 设置复用主题 token 与现有 Toggle。一级标题“会话”可加粗,选项标题字重 400;补齐英文文案。
+
+## Risks / Trade-offs
+
+关闭后同一轮可见行数增加,保留既有 transcript 窗口避免一次渲染全部历史。切换不修改原始消息,重新开启恢复已有折叠路径。测试覆盖工具包装去重、完成总结、运行提示及子代理只读过滤,防止简单绕过投影造成语义退化。
diff --git a/openspec/changes/configure-message-auto-collapse/proposal.md b/openspec/changes/configure-message-auto-collapse/proposal.md
new file mode 100644
index 000000000..46782f8cd
--- /dev/null
+++ b/openspec/changes/configure-message-auto-collapse/proposal.md
@@ -0,0 +1,21 @@
+## Why
+
+当前会话自动把过程消息、工具段和完成轮次收进摘要,用户无法选择始终展开消息。外观设置需要提供持久化开关,主会话与子代理一致遵守。
+
+## What Changes
+
+- 在外观页新增一级分组“会话”,提供默认开启的“消息自动折叠”。
+- 开启沿用现有折叠;关闭后消息及整段活动直接显示,仅保留单个工具调用自身的折叠。
+- 图片与子代理分组直接展开,系统通知直接显示;完成总结、工具配对、子代理入口和运行提示继续可用。
+- 新增 `web_ui.message_auto_collapse`,经配置文件、偏好 API、Desktop 启动注入和前端共享偏好持久化。
+
+## Capabilities
+
+### New Capabilities
+- `message-auto-collapse-preference`: 可持久化、即时生效的会话折叠策略。
+
+### Modified Capabilities
+
+## Impact
+
+涉及配置解析与序列化、UI preferences API、Desktop bootstrap、外观页及主会话/子代理共享 transcript 投影和渲染。默认值保持兼容,不修改会话历史数据。
diff --git a/openspec/changes/configure-message-auto-collapse/specs/message-auto-collapse-preference/spec.md b/openspec/changes/configure-message-auto-collapse/specs/message-auto-collapse-preference/spec.md
new file mode 100644
index 000000000..358e4fc29
--- /dev/null
+++ b/openspec/changes/configure-message-auto-collapse/specs/message-auto-collapse-preference/spec.md
@@ -0,0 +1,31 @@
+## ADDED Requirements
+
+### Requirement: 会话样式设置与持久化
+系统 SHALL 在外观设置的一级分组“会话”中提供“消息自动折叠”,默认开启,选项标题使用普通字重。选择 SHALL 经现有外观偏好机制持久化并立即生效。
+
+#### Scenario: 旧配置与首次打开
+- **WHEN** 配置中没有 `message_auto_collapse`
+- **THEN** 开关开启,现有自动折叠行为保持不变
+
+#### Scenario: 保存关闭状态并重启
+- **WHEN** 用户关闭消息自动折叠后重新打开应用
+- **THEN** API、Desktop 首帧与界面均恢复关闭状态,修改其它外观项不重置该值
+
+#### Scenario: 无效输入及保存失败
+- **WHEN** 偏好 API 收到非布尔值或持久化失败
+- **THEN** 无效输入被原子拒绝,保存失败回滚至上次确认的偏好
+
+### Requirement: 主会话与子代理共享折叠策略
+主会话与子代理 SHALL 共用同一投影和显示策略。关闭自动折叠时 SHALL 显示所有消息正文,不生成可折叠的工具段或完成轮次摘要,只保留单个工具调用自身的折叠交互。
+
+#### Scenario: 完成轮次与历史消息
+- **WHEN** 关闭状态下展示包含过程回复、多个工具调用与完成总结的轮次
+- **THEN** 过程回复、工具行及完成总结按序显示,不出现“已处理”大折叠,工具配对去重仍生效
+
+#### Scenario: 实时会话及特殊分组
+- **WHEN** 关闭状态下工具正在运行或会话含图片、子代理、系统通知
+- **THEN** 运行状态持续可见,图片与子代理内容直接显示,子代理入口可用,系统通知正文展开
+
+#### Scenario: 来回切换
+- **WHEN** 用户从关闭切回开启
+- **THEN** 当前消息立即恢复原有折叠路径,原始消息数据不被修改
diff --git a/openspec/changes/configure-message-auto-collapse/tasks.md b/openspec/changes/configure-message-auto-collapse/tasks.md
new file mode 100644
index 000000000..e2885730f
--- /dev/null
+++ b/openspec/changes/configure-message-auto-collapse/tasks.md
@@ -0,0 +1,15 @@
+## 1. 外观配置
+- [x] 1.1 新增默认开启的配置字段、API 校验与落盘、Desktop 注入,补齐 API 文档及配置/API 测试。
+- [x] 1.2 接入前端偏好保存与回滚,在外观添加“会话 / 消息自动折叠”,补齐英文与默认值/持久化测试。
+
+## 2. 共享消息显示
+- [x] 2.1 主会话和子代理接入共享投影开关,关闭非工具折叠并保留工具配对、总结、运行状态与特殊分组入口,覆盖投影测试。
+
+## 3. 验证
+- [x] 3.1 完成定向后端验证、前端全量测试/构建、浏览器检查默认/关闭/恢复、中文/英文/窄屏,严格规范验证与差异检查。
+
+验证记录:
+- C++ `acecode_unit_tests` MinSizeRel 构建通过;配置、UI preferences API 及启动主题并发保护共 31 项定向测试通过。
+- `pnpm test` 2566 项通过,`pnpm build` 通过,`openspec validate configure-message-auto-collapse --strict` 与 `git diff --check` 通过。
+- 浏览器使用真实 SettingsPage、共享投影/TranscriptItems、SubagentPanel 和外观保存控制器,API 使用隔离数据。确认默认折叠、关闭后正文展开、单个工具展开/收起、图片显示、子代理导航、系统通知正文、运行状态、重新开启、保存失败回滚及刷新恢复关闭值。
+- 中英文、明暗主题、390px 窄屏及 reduced-motion 检查通过;选项字重 400、一级标题 600,无设置面板横向溢出。未重建或安装 Desktop 包。
diff --git a/openspec/changes/disable-national-day-startup-theme/.openspec.yaml b/openspec/changes/disable-national-day-startup-theme/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/disable-national-day-startup-theme/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/disable-national-day-startup-theme/design.md b/openspec/changes/disable-national-day-startup-theme/design.md
new file mode 100644
index 000000000..c7d9bc332
--- /dev/null
+++ b/openspec/changes/disable-national-day-startup-theme/design.md
@@ -0,0 +1,23 @@
+## Context
+
+默认配置已经是 `theme: system` 与 `color_theme: blue`。节日主题来自 `App.jsx` 在恢复 daemon 外观后调用 `applyStartupTheme`,而非默认配置值。动机见 proposal.md。
+
+## Goals / Non-Goals
+
+仅调整客户端启动入口;保留已保存外观、资源缓存及手动主题管理。此次不迁移用户配置,不修改 daemon 协议或主题发布资源。
+
+## Decisions
+
+- 从认证后的外观恢复 effect 移除 `applyStartupTheme` 调用及下载控制器依赖,使该入口只读取并恢复配置。
+- 保留下载控制器与首次领取接口的兼容实现:关闭唯一产品启动入口即可满足行为要求,避免扩大到无关 API 清理。架构回归检查约束入口不会重新调用首次领取流程。
+- 不把现有 `national-day-2026` 配置重置为蓝色;现有数据无法区分历史自动应用和手动选择,强制重置会覆盖用户选择。
+- 复用现有测试验证默认外观与持久化恢复,并补充国庆节的手动安装及已有主题恢复覆盖。
+
+## Risks / Trade-offs
+
+- 历史自动应用的国庆节仍会作为已保存主题恢复 → 用户仍可手动切换;本次取消后续版本的默认自动应用。
+- 旧版客户端仍可能调用保留的领取接口 → 本次行为由新版本客户端入口控制,文档区分历史兼容协议与当前启动策略。
+
+## Migration Plan
+
+随下次常规 Web/Desktop 构建生效,无需迁移配置或清除旧标记。恢复旧启动调用即可回退代码策略。
diff --git a/openspec/changes/disable-national-day-startup-theme/proposal.md b/openspec/changes/disable-national-day-startup-theme/proposal.md
new file mode 100644
index 000000000..6d0faaf76
--- /dev/null
+++ b/openspec/changes/disable-national-day-startup-theme/proposal.md
@@ -0,0 +1,23 @@
+## Why
+
+从下个版本起,取消首次启动时自动下载并切换到国庆节主题的行为,让常规默认外观和已保存的用户选择直接生效。
+
+## What Changes
+
+- Web/Desktop 认证后只恢复已保存的外观,不再发起国庆节主题首次启动流程。
+- 新配置继续使用蓝色及跟随系统的明暗模式;已有配置保留当前主题,包括已保存的国庆节主题。
+- 国庆节主题仍可在外观设置中手动下载和选择。
+
+## Capabilities
+
+### New Capabilities
+
+- `startup-theme-policy`: 规定新版本启动时保留默认或已保存主题,不自动应用节日主题。
+
+### Modified Capabilities
+
+无。此前 `add-national-day-builtin-theme` 的首次自动应用要求尚未归档到主规范,本变更替代其客户端启动行为。
+
+## Impact
+
+影响 `web/src/App.jsx` 的外观恢复入口、相关回归检查和主题文档。保留既有主题资源、下载控制器与 daemon 兼容接口,不涉及发布或用户配置迁移。
diff --git a/openspec/changes/disable-national-day-startup-theme/specs/startup-theme-policy/spec.md b/openspec/changes/disable-national-day-startup-theme/specs/startup-theme-policy/spec.md
new file mode 100644
index 000000000..533438cda
--- /dev/null
+++ b/openspec/changes/disable-national-day-startup-theme/specs/startup-theme-policy/spec.md
@@ -0,0 +1,25 @@
+## Purpose
+
+规定 ACECode 后续版本在新安装与升级启动时如何确定外观,保证常规默认主题及已保存的用户主题直接生效,同时保留通过设置主动下载和使用节日主题的能力。
+
+## ADDED Requirements
+
+### Requirement: 启动保留常规默认与已保存外观
+
+Web/Desktop 客户端 SHALL 在启动认证后恢复已保存的外观,SHALL NOT 自动领取国庆节主题首次启动尝试、自动下载或自动切换到该主题。没有保存主题的新配置 SHALL 使用蓝色和跟随系统的明暗模式。
+
+#### Scenario: 新安装首次启动
+- **WHEN** 用户首次启动且没有已保存的外观选择
+- **THEN** 使用蓝色并跟随系统明暗模式,启动过程不发起国庆节主题领取或下载请求
+
+#### Scenario: 升级用户保留已有主题
+- **WHEN** 用户升级并启动,已有蓝色、橙色、国庆节、EVA 或自定义主题配置
+- **THEN** 恢复已有主题,不因升级改写外观配置,是否存在旧版首次尝试标记不影响该行为
+
+### Requirement: 国庆节主题保留手动选择能力
+
+客户端 SHALL 保留设置中的国庆节主题卡片及手动下载、安装和应用流程。
+
+#### Scenario: 主动下载或选择国庆节主题
+- **WHEN** 用户在设置中主动下载或选择国庆节主题
+- **THEN** 沿用原有下载确认、进度、失败提示和应用行为
diff --git a/openspec/changes/disable-national-day-startup-theme/tasks.md b/openspec/changes/disable-national-day-startup-theme/tasks.md
new file mode 100644
index 000000000..310e93c2b
--- /dev/null
+++ b/openspec/changes/disable-national-day-startup-theme/tasks.md
@@ -0,0 +1,16 @@
+## 1. 启动策略
+
+- [x] 1.1 移除认证恢复后的节日主题启动调用,补充入口、已保存外观与手动国庆节下载回归检查,运行相关前端测试验证。
+- [x] 1.2 更新主题与接口文档,核对当前启动行为及旧协议兼容说明一致。
+
+## 2. 集成验证
+
+- [x] 2.1 运行 `pnpm test`、`pnpm build`、严格 OpenSpec 验证与 `git diff --check`,记录结果及验证边界。
+
+## 验证结果
+
+- 外观恢复、默认主题、下载控制器及主题卡片定向测试通过。
+- `pnpm test` 通过,输出 2582 条 `[pass]`。
+- `pnpm build` 通过,产物正则兼容检查通过。
+- `openspec validate disable-national-day-startup-theme --strict` 与 `git diff --check` 通过。
+- 仅完成源码和 Web 构建验证,未重新打包、安装或发布 Desktop。
diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/.openspec.yaml b/openspec/changes/disable-sandbox-once-on-upgrade/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/disable-sandbox-once-on-upgrade/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/design.md b/openspec/changes/disable-sandbox-once-on-upgrade/design.md
new file mode 100644
index 000000000..a14cac6b9
--- /dev/null
+++ b/openspec/changes/disable-sandbox-once-on-upgrade/design.md
@@ -0,0 +1,25 @@
+## Context
+
+`SandboxConfig.enabled` 的结构默认值为 true,普通保存会省略默认值。Desktop、TUI、daemon、headless 均调用 `load_config()`;`load_config_from_path()` 同时用于配置编辑和恢复候选校验。`config_mutation.cpp` 已有进程内锁与跨进程文件锁。
+
+## Goals / Non-Goals
+
+目标是按用户配置执行一次迁移,之后保留用户选择。不改权限模式、会话级 `/sandbox on/off` 语义、后端隔离策略、配置恢复校验或发布版本号。
+
+## Decisions
+
+- 在共享 `load_config()` 启动入口检测迁移,保留显式路径读取的原有语义,避免配置恢复候选校验产生迁移副作用。首次正常加载仍负责创建用户目录,保留首次初始化标志。
+- 使用顶层 `migrations.disable_sandbox_once` 布尔标记,作为 `AppConfig` 内独立字段参与读取和保存。标记不属于可被安全设置更新替换的 `SandboxConfig`。完成后固定使用此标记,不按版本反复重置。
+- 迁移函数复用 `config_mutation.cpp` 的锁,锁内重新读取并校验配置,补丁修改原 JSON 的开关和标记,用已有敏感文件原子写入工具一起提交。保留未知字段,不经整份配置重新序列化。
+- 成功后推进 last-good 备份;迁移失败向启动调用者报告错误,磁盘配置保持不变。启动重新加载时才应用环境变量,避免持久化进程环境覆盖。
+- 保留结构默认值 true。首次启动(包括新安装)由迁移设置为 false;用户后续启用即使保存时省略 enabled,标记仍可阻止再次关闭。
+
+## Risks / Trade-offs
+
+- 标记被用户删除或旧版本保存时丢弃会被视为未迁移;普通新版本设置保存必须保留标记,并测试此路径。
+- 原子写入成功但备份写入失败沿用现有配置保存策略:记录日志,不谎报主配置写入失败。
+- 进程锁和文件锁复用现有配置变更边界;不扩展为全仓配置并发重构。
+
+## Migration Plan
+
+随下个版本代码发布生效;当前运行的安装包不直接修改。用户需要恢复沙盒时可使用安全设置中的全局开关。回归覆盖缺省、显式开启/关闭、重新启用、普通保存、并发执行、写入失败和真实启动入口。
diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/proposal.md b/openspec/changes/disable-sandbox-once-on-upgrade/proposal.md
new file mode 100644
index 000000000..2701d4f33
--- /dev/null
+++ b/openspec/changes/disable-sandbox-once-on-upgrade/proposal.md
@@ -0,0 +1,23 @@
+## Why
+
+下个版本需要为用户关闭一次沙盒,同时尊重用户之后主动重新启用的选择。仅修改默认值无法覆盖旧配置中的显式开启值,每次启动强制关闭则会覆盖用户后续选择。
+
+## What Changes
+
+- 首次运行包含本变更的版本时,为当前用户配置关闭沙盒,并持久化固定的一次性迁移标记。
+- 已完成迁移后,正常启动、保存其他设置及后续升级均保留用户选择。
+- 保留其他沙盒策略、权限模式以及原配置中的其他字段。
+
+## Capabilities
+
+### New Capabilities
+
+- `sandbox-once-migration`:一次性关闭沙盒及后续用户选择的持久化保护。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+涉及共享启动配置入口、配置读写及已有配置变更锁,覆盖 Desktop、TUI、daemon 和 headless。增加配置回归测试并更新 `docs/sandbox.md`;不修改 UI、权限模式、版本号或发布流程。
diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/specs/sandbox-once-migration/spec.md b/openspec/changes/disable-sandbox-once-on-upgrade/specs/sandbox-once-migration/spec.md
new file mode 100644
index 000000000..3fe31ce51
--- /dev/null
+++ b/openspec/changes/disable-sandbox-once-on-upgrade/specs/sandbox-once-migration/spec.md
@@ -0,0 +1,42 @@
+## Purpose
+
+让用户首次运行包含本变更的版本时获得关闭沙盒的配置,并在后续启动、修改设置和升级中持续尊重用户手动重新启用的选择。通过持久化的一次性状态区分首次迁移与正常配置读取。
+
+## ADDED Requirements
+
+### Requirement: 首次启动只迁移一次
+
+系统 SHALL 在尚未完成本次迁移的用户配置上关闭沙盒,并将完成状态与关闭值一起持久化;迁移 MUST 保留其他配置内容。
+
+#### Scenario: 旧配置首次升级
+- **WHEN** 用户首次运行包含本变更的版本,配置尚无有效的迁移完成标记
+- **THEN** 无论原配置显式开启、关闭或缺少沙盒段,沙盒均处于关闭状态,且记录迁移完成
+- **THEN** 网络策略、路径清单、权限模式及其他字段保持原有值
+
+#### Scenario: 新用户首次启动
+- **WHEN** 用户配置尚不存在
+- **THEN** 正常初始化配置并完成同一迁移,沙盒关闭
+
+### Requirement: 持续保留后续用户选择
+
+迁移完成后系统 MUST 尊重用户持久化的沙盒开关值,普通设置保存 SHALL 保留迁移标记,后续版本 SHALL 复用同一个标记而不是再次重置。
+
+#### Scenario: 用户重新启用
+- **WHEN** 已迁移用户在设置中重新启用沙盒,然后保存其他设置并再次启动
+- **THEN** 沙盒仍然开启,迁移不再写入关闭值
+
+#### Scenario: 重复启动
+- **WHEN** 已迁移配置再次启动且用户未修改设置
+- **THEN** 本次迁移不重写配置
+
+### Requirement: 迁移持久化一致性
+
+系统 MUST 将迁移与现有配置变更串行化,并在持久化失败时报告失败而不记录成功;重试时 SHALL 重新读取磁盘配置。
+
+#### Scenario: 写入失败后重试
+- **WHEN** 首次迁移因文件写入失败而无法完成
+- **THEN** 原配置保持完整,迁移不被视为成功,解除故障后可再次迁移
+
+#### Scenario: 并发迁移
+- **WHEN** 多个进程或线程同时尝试执行本次迁移
+- **THEN** 只有首次成功者修改配置,后续调用识别已完成状态
diff --git a/openspec/changes/disable-sandbox-once-on-upgrade/tasks.md b/openspec/changes/disable-sandbox-once-on-upgrade/tasks.md
new file mode 100644
index 000000000..afec7a2f0
--- /dev/null
+++ b/openspec/changes/disable-sandbox-once-on-upgrade/tasks.md
@@ -0,0 +1,19 @@
+## 1. 配置迁移
+
+- [x] 1.1 实现迁移标记读取与保存、串行化原子迁移及共享启动入口集成,审查确认标记与开关一起写入且环境变量不被写回。
+- [x] 1.2 添加迁移、重新启用后重复启动、其他设置保存、未知字段保留、并发及失败重试测试,运行相关 C++ 测试通过。
+
+## 2. 文档与验证
+
+- [x] 2.1 更新沙盒文档说明首次关闭及用户重新启用的持久行为,核对与实现一致。
+- [x] 2.2 运行配置与安全相关回归、OpenSpec 严格校验及 git diff --check,并记录验证范围。
+
+## 验证记录
+
+- [x] 发布集成:将共享启动迁移依赖加入 `acecode_native_bridge_support`,验证 Desktop 与 CLI 完整链接及配置迁移回归。
+
+- Windows MSVC MinSizeRel:`cmake --build build --config MinSizeRel --target acecode_unit_tests --parallel 4` 通过。
+- `tests/config/` 全部测试套件及 `SecurityHandler`:57 个套件、336 项测试全部通过。覆盖真实 `load_config()` 启动入口及环境变量不落盘。
+- 旧测试 `RuntimeSkillAllowlistIsNeverPersisted` 在再次启动加载前关闭文件读取句柄,以允许 Windows 原子替换;原有业务断言保持不变。
+- `openspec validate disable-sandbox-once-on-upgrade --strict`、`git diff --check` 通过。
+- 本次完成源码与原生测试验证,未打包或发布安装包。
diff --git a/openspec/changes/expose-turn-token-usage/.openspec.yaml b/openspec/changes/expose-turn-token-usage/.openspec.yaml
new file mode 100644
index 000000000..563fab5ab
--- /dev/null
+++ b/openspec/changes/expose-turn-token-usage/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-21
diff --git a/openspec/changes/expose-turn-token-usage/design.md b/openspec/changes/expose-turn-token-usage/design.md
new file mode 100644
index 000000000..68f7c07d1
--- /dev/null
+++ b/openspec/changes/expose-turn-token-usage/design.md
@@ -0,0 +1,25 @@
+## Context
+
+OpenAI-compatible provider 已把 Codex2API 流中的 `usage` 解析为 `TokenUsage`。`AgentLoop::call_provider_and_collect` 在一次成功 model step 后发出逐 step `usage`,而 `run_agent_with_input` 负责一个用户 turn 内的多次 model step 和最终 `busy_changed` / `done` 边界。
+
+## Goals / Non-Goals
+
+目标是在 daemon API 的 terminal turn event 上提供可直接消费的本 turn 汇总,并保持逐 step 事件兼容。非目标包括计费金额换算、修改 Codex2API、重算历史 turn、增加独立 REST endpoint 或改变 session 累计统计。
+
+## Decisions
+
+在 `run_agent_with_input` 内维护局部 `TokenUsage` accumulator,只在 model step 已通过 provider error/abort 判定并完成现有 usage 入账后累加。这样内部 provider retry 的 provisional usage、失败请求和未入账中止请求不会重复计数。
+
+计数字段按 model step 求和:`prompt_tokens`、`completion_tokens`、`total_tokens`、cache read/write 与 reasoning。`context_breakdown` 同样按类别求和,代表本 turn 所有请求的输入构成总和。
+
+`has_data` 表示汇总是否完全来自 provider 上报:至少有一个已入账 step 且所有已入账 step 的 `has_data=true` 时为 true;混入 ACECode 估算或完全没有模型请求时为 false。
+
+regular turn 的 terminal `busy_changed` 和紧随其后的 `done` 都携带同一 `turn_id` 与 `usage`。重复是有意的:状态型客户端可只监听 `busy_changed`,完成型客户端可只监听 `done`。原有逐 step `usage` / `model_step_finish` 不变。
+
+## Risks / Trade-offs
+
+同一汇总出现在两个 terminal frame 会增加少量 payload,但避免客户端必须耦合某一种终止事件。客户端若自行累加逐 step `usage`,不得再把 terminal `usage` 叠加;文档会明确 terminal usage 是摘要。
+
+## Validation
+
+用脚本 provider 构造同一 turn 的两次模型请求,分别返回 usage,断言 terminal `busy_changed` 与 `done` 的 `turn_id` 一致、字段求和正确且逐 step usage 仍保持两条。再覆盖 provider usage 与估算混合时 `has_data=false`。运行对应 `acecode_unit_tests` 过滤用例、OpenSpec strict validate、`git diff --check`;按用户要求不进行完整编译。
diff --git a/openspec/changes/expose-turn-token-usage/proposal.md b/openspec/changes/expose-turn-token-usage/proposal.md
new file mode 100644
index 000000000..360375aee
--- /dev/null
+++ b/openspec/changes/expose-turn-token-usage/proposal.md
@@ -0,0 +1,21 @@
+## Why
+
+ACEModel(Codex2API)已经在每次模型请求的流式响应中返回 token usage,但一个 ACECode turn 可能包含多次模型请求和工具往返。现有 daemon API 只逐 step 推送 `usage`,turn 结束的 `busy_changed` / `done` 事件没有本 turn 汇总,API 客户端无法在明确的 turn 边界直接读取本轮总消耗。
+
+## What Changes
+
+- 在一个 regular agent turn 内累加所有已入账 model step 的 token usage。
+- turn 结束时,在 terminal `busy_changed` 和 `done` API 事件中返回同一份 `usage` 汇总及 `turn_id`。
+- 保留现有逐 step `usage` 和 `model_step_finish` 事件,避免破坏现有消费者。
+- provider 未返回 usage 时沿用 ACECode 估算值;只要本 turn 有任一步为估算,汇总的 `has_data` 为 `false`。
+
+## Capabilities
+
+### New Capabilities
+- `turn-token-usage-api`: daemon session event 在 turn 边界暴露本 turn token 消耗。
+
+### Modified Capabilities
+
+## Impact
+
+影响 `AgentLoop` 的 turn 级计量聚合、daemon WebSocket session event 契约、对应单元测试与 `docs/daemon-api.md`;不改变 ACEModel/Codex2API 请求格式,不新增 HTTP 请求,也不改变持久化 session 累计用量。
diff --git a/openspec/changes/expose-turn-token-usage/specs/turn-token-usage-api/spec.md b/openspec/changes/expose-turn-token-usage/specs/turn-token-usage-api/spec.md
new file mode 100644
index 000000000..6f9e93daa
--- /dev/null
+++ b/openspec/changes/expose-turn-token-usage/specs/turn-token-usage-api/spec.md
@@ -0,0 +1,32 @@
+## ADDED Requirements
+
+### Requirement: Terminal turn events expose aggregate usage
+For every regular agent turn, the daemon session event stream SHALL include a `usage` object on both the terminal `busy_changed` event and the following `done` event. Both events SHALL include the same `turn_id`, `outcome`, and aggregate usage values.
+
+#### Scenario: Multi-step ACEModel turn completes
+- **WHEN** one user turn performs multiple successful model steps because tools are called
+- **THEN** terminal `busy_changed` and `done` each contain the stable turn id
+- **AND** each event's `usage` equals the sum of all accounted model-step token fields in that turn
+- **AND** existing per-step `usage` and `model_step_finish` events remain available
+
+#### Scenario: Turn uses only provider-reported usage
+- **WHEN** every accounted model step includes provider-reported usage
+- **THEN** terminal `usage.has_data` is `true`
+
+#### Scenario: Turn includes estimated usage
+- **WHEN** at least one accounted model step lacks provider-reported usage and ACECode estimates it
+- **THEN** terminal token counts include the estimate
+- **AND** terminal `usage.has_data` is `false`
+
+#### Scenario: Turn ends before a model request is accounted
+- **WHEN** a regular turn is blocked or fails before any model step is accounted
+- **THEN** terminal usage contains zero counts
+- **AND** terminal `usage.has_data` is `false`
+
+### Requirement: Aggregate usage is a summary, not an incremental delta
+Terminal turn usage SHALL summarize the whole turn and SHALL NOT replace or alter the semantics of incremental per-step usage events.
+
+#### Scenario: Client already consumes step usage
+- **WHEN** a client receives per-step `usage` events followed by terminal turn usage
+- **THEN** the terminal value can be used as the authoritative turn summary
+- **AND** it is not an additional usage delta to add to the preceding step events
diff --git a/openspec/changes/expose-turn-token-usage/tasks.md b/openspec/changes/expose-turn-token-usage/tasks.md
new file mode 100644
index 000000000..6d6aca3bc
--- /dev/null
+++ b/openspec/changes/expose-turn-token-usage/tasks.md
@@ -0,0 +1,12 @@
+## 1. 规范与测试
+- [x] 1.1 定义 regular turn terminal event 的 usage 聚合契约。
+- [x] 1.2 添加同 turn 多 model step usage 聚合回归测试。
+
+## 2. 实现
+- [x] 2.1 在 AgentLoop turn 生命周期内累加已入账 step usage。
+- [x] 2.2 在 terminal busy_changed 与 done payload 暴露 turn_id 和 usage。
+- [x] 2.3 更新 daemon API 文档。
+
+## 3. 验证
+- [x] 3.1 按用户要求不编译;完成 OpenSpec 与静态差异校验,并提交目标回归测试供 CI 执行。
+- [x] 3.2 运行 OpenSpec strict validate、git diff --check 和提交前差异审查。
diff --git a/openspec/changes/fallback-workspace-picker-to-web/.openspec.yaml b/openspec/changes/fallback-workspace-picker-to-web/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/fallback-workspace-picker-to-web/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/fallback-workspace-picker-to-web/design.md b/openspec/changes/fallback-workspace-picker-to-web/design.md
new file mode 100644
index 000000000..984a80d1a
--- /dev/null
+++ b/openspec/changes/fallback-workspace-picker-to-web/design.md
@@ -0,0 +1,27 @@
+## 背景
+
+`ChatView.handleOpenExistingDirectory` 与 `Sidebar.onAddWorkspace` 都使用 `pickExistingWorkspace`。该函数在存在 `aceDesktop_addWorkspace` 时直接返回或抛错,只有没有 bridge 时才调用 `requestPathPick`。`PathPickerHost` 已在 App 顶层挂载,Desktop 也能复用它。
+
+`add-web-path-picker` 的未归档规范要求 Desktop 始终使用原生对话框;本变更按当前用户要求增加工作区目录选择失败后的例外,原生正常成功或取消时仍保持原行为。
+
+## 目标与边界
+
+回退由共享的工作区选择函数唯一负责,覆盖首页和侧栏。文件预览、设置页的目录浏览、原生协议与目录浏览 API 保持各自现有职责。
+
+## 决策
+
+- 将原生调用及 JSON 解析放入恢复边界;原生空结果直接返回 `null`,错误或缺少 `hash` 的非空结果继续进入已有 Web 分支。使用结构化结果判断,不匹配平台错误文案。
+- 成功原生结果继续尝试补注册,补注册失败仍返回原生工作区;不会因此弹出第二个选择器。
+- 共享原有 `mode: 'folder'`、`purpose: 'workspace'` 和 API 参数。Web 确认后的 `registerWorkspace` 留在原生错误恢复边界外,让最终错误沿现有 toast 链路传播。
+- 不在两个 React 调用方分别添加回退,避免分叉;不在 C++ 层启动 Web 弹窗,因为现有宿主及 Promise 生命周期已由前端管理。
+
+## 风险与验证
+
+- 取消被误认为失败会产生第二个弹窗:覆盖原生对象空值、JSON null、空字符串和 Web 取消。
+- 恢复边界过宽可能吞掉最终注册错误:覆盖 Web 选择器异常、注册异常及错误响应,并检查调用次数。
+- 原生结果格式异常也应恢复:覆盖拒绝、同步抛错、非法 JSON 和缺少工作区标识。
+- 本次以共享逻辑回归、路径选择宿主测试、完整 Web 测试、构建和严格 OpenSpec 校验验证;不把 Web 验证等同于已重打包 Desktop 实机验证。
+
+## 交付
+
+变更随重新构建的 Web 资源交付;Desktop 安装包需要按既有流程重新嵌入资源后生效。回滚只需恢复共享函数与相应测试,不涉及数据迁移。
diff --git a/openspec/changes/fallback-workspace-picker-to-web/proposal.md b/openspec/changes/fallback-workspace-picker-to-web/proposal.md
new file mode 100644
index 000000000..05a784ac2
--- /dev/null
+++ b/openspec/changes/fallback-workspace-picker-to-web/proposal.md
@@ -0,0 +1,24 @@
+## 为什么
+
+Desktop 原生目录选择器不可用时(例如 Linux 缺少 zenity/kdialog),首页“打开现有目录”和侧栏“添加项目”目前直接报错。已有的 Web 目录选择器可以完成同一操作,应作为原生选择失败后的自动回退路径。
+
+## 变更内容
+
+- 保持原生目录选择优先;原生调用失败、返回错误或无法使用的结果时,自动打开现有 Web 文件夹选择器。
+- 原生选择被用户取消时直接结束;成功选择仍按现有逻辑返回工作区。
+- Web 回退继续通过现有工作区注册接口完成打开目录,取消无副作用,选择器或注册失败仍交给调用方提示。
+
+## Capabilities
+
+### New Capabilities
+
+- `workspace-picker-fallback`: 工作区原生目录选择失败后的 Web 回退、取消与错误传播契约。
+
+### Modified Capabilities
+
+无。现有主规范尚未覆盖目录选择器回退;本变更新增对 `add-web-path-picker` 中 Desktop 始终走原生路径的失败恢复规则。
+
+## 影响范围
+
+- `web/src/lib/workspacePicker.js` 及现有回归测试。
+- 首页与侧栏通过共享函数自动获得回退能力,不需要修改组件、弹窗样式、Desktop bridge 或后端接口。
diff --git a/openspec/changes/fallback-workspace-picker-to-web/specs/workspace-picker-fallback/spec.md b/openspec/changes/fallback-workspace-picker-to-web/specs/workspace-picker-fallback/spec.md
new file mode 100644
index 000000000..13d93b385
--- /dev/null
+++ b/openspec/changes/fallback-workspace-picker-to-web/specs/workspace-picker-fallback/spec.md
@@ -0,0 +1,45 @@
+## Purpose
+
+让 Desktop 用户在原生目录选择器不可用或调用失败时,仍能通过现有 Web 目录选择器打开工作区,并明确原生与 Web 选择的取消、成功和后续失败行为,避免重复弹窗或静默中断。
+
+## ADDED Requirements
+
+### Requirement: 工作区原生目录选择失败自动回退
+
+首页“打开现有目录”和侧栏“添加项目” SHALL 优先调用可用的原生目录选择器。原生调用抛出异常、返回错误或返回非取消且无法使用的结果时,系统 SHALL 自动打开一次 Web 文件夹选择器,让用户继续选择 daemon 所在机器的目录,不把已恢复的原生错误作为操作失败提示。没有原生入口时 SHALL 直接使用同一 Web 选择流程。
+
+#### Scenario: 原生目录工具缺失
+- **WHEN** 原生目录选择报告 zenity/kdialog 等目录选择工具不可用
+- **THEN** 自动打开 Web 文件夹选择器,确认后注册所选目录并返回工作区
+
+#### Scenario: 原生调用或结果无效
+- **WHEN** 原生调用抛出异常,或者返回无法解析的 JSON、缺少工作区标识的非空结果
+- **THEN** 自动打开一次 Web 文件夹选择器
+
+#### Scenario: 原生成功
+- **WHEN** 原生选择返回有效工作区
+- **THEN** 返回该工作区,不打开 Web 选择器;原有补注册失败不影响已经有效的选择结果
+
+#### Scenario: 没有原生入口
+- **WHEN** 普通浏览器页面打开现有目录
+- **THEN** 直接通过 Web 文件夹选择器选择并注册目录,不调用原生 REST 对话框
+
+### Requirement: 保留取消与最终失败语义
+
+用户取消原生或 Web 选择时,系统 SHALL 结束当前打开目录操作,不注册目录,不再次打开其他选择器。Web 选择器或后续目录注册失败时 SHALL 保留该错误供现有调用方提示,不再次尝试原生或 Web 选择。
+
+#### Scenario: 取消原生选择
+- **WHEN** 原生选择返回空结果(包括 JSON null 或空字符串)
+- **THEN** 操作结束,不打开 Web 选择器,不注册工作区
+
+#### Scenario: 取消回退选择
+- **WHEN** 原生失败后用户取消 Web 文件夹选择
+- **THEN** 操作结束,不注册工作区,不提示已经恢复的原生错误
+
+#### Scenario: Web 选择器失败
+- **WHEN** 原生失败后 Web 选择器本身不可用或调用失败
+- **THEN** 调用方收到 Web 选择器错误,且不会再次弹出选择器
+
+#### Scenario: 回退后的目录注册失败
+- **WHEN** Web 选择确认后的注册请求抛出异常或返回错误
+- **THEN** 调用方收到注册错误,且不会重复选择或注册
diff --git a/openspec/changes/fallback-workspace-picker-to-web/tasks.md b/openspec/changes/fallback-workspace-picker-to-web/tasks.md
new file mode 100644
index 000000000..a80c33138
--- /dev/null
+++ b/openspec/changes/fallback-workspace-picker-to-web/tasks.md
@@ -0,0 +1,16 @@
+## 1. 工作区目录选择回退
+
+- [x] 1.1 在共享工作区选择函数中实现原生失败后的 Web 回退,保留原生成功、取消及补注册语义;通过工作区选择定向测试验证。
+- [x] 1.2 增加失败、无效结果、取消、最终错误传播和真实路径选择宿主接线的回归覆盖;运行 `node src/lib/workspacePicker.test.js` 与路径选择相关测试验证。
+
+## 2. 整体验证
+
+- [x] 2.1 运行完整 `pnpm test`、`pnpm build`、严格 OpenSpec 校验与 `git diff --check`,核对初始无关改动哈希并记录验证边界。
+
+## 验证记录
+
+- 工作区选择的 18 个测试通过;新增用例已先在修改前复现原生错误直接抛出的问题。
+- 路径选择纯逻辑、路径选择宿主、文件预览选择器及环境设置的定向测试通过。
+- 完整 `pnpm test`、`pnpm build`(含正则兼容检查)、`openspec validate fallback-workspace-picker-to-web --strict`、`git diff --check` 通过。
+- 初始 47 个无关修改文件的 SHA-256 保持一致;当前分支仍为 `master`。
+- 验证覆盖源码、真实 Promise 宿主接线和 Web 构建;未重新构建或安装 Desktop,也未在 Linux 实机操作原生对话框。
diff --git a/openspec/changes/fix-composer-tag-selection/.openspec.yaml b/openspec/changes/fix-composer-tag-selection/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/fix-composer-tag-selection/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/fix-composer-tag-selection/design.md b/openspec/changes/fix-composer-tag-selection/design.md
new file mode 100644
index 000000000..0991d885a
--- /dev/null
+++ b/openspec/changes/fix-composer-tag-selection/design.md
@@ -0,0 +1,42 @@
+## Context
+
+参见 proposal.md。现有 RichComposer 使用 Slate inline void 表示命令、技能、路径、会话及附件;附件在纯文本中的长度为零。删除已使用实际 Slate range,但单个 void 的折叠选区和普通文字的折叠光标仍需区分。附件根节点阻止 mousedown,所有 tag 设为 user-select:none。命令同步通过 withoutSaving 替换整棵树,同时保留旧 history,已经用真实 Slate 重现撤销失效。
+
+## Goals / Non-Goals
+
+**Goals:** 维持一个 Slate 选区作为编辑操作依据;通过真实 Windows 浏览器键鼠操作验证原子 tag 和混合选择。现有外观、草稿格式、附件注册表和 IME 保护继续适用。
+
+**Non-Goals:** 更换编辑器、重做聊天框视觉、调整后端附件协议、发布安装包或修改当前 master 的未提交工作。
+
+## Decisions
+
+- 原生浏览器负责文字起手拖选与 Shift 选区扩展;tag 起手由 RichComposer 的 mousemove/mouseup 适配器负责,根据真实 DOM 命中更新 Slate range。起止 tag 整体纳入,不用纯文本偏移重建混合选区,避免零长度附件丢失。
+- 明确区分 tag 原子选中与普通折叠光标,剪贴板、剪切和删除共享语义;纯文本复制保留可读 token,应用内结构化复制保留引用。
+- 图片单击选择、双击预览,移除按钮保留独立事件,拖选不触发预览。
+- 键盘进入 void 时即展开到前后可编辑边界,避免 Chromium 在 contenteditable=false 内不产生 beforeinput/paste 事件;编辑器插入方法也共享原子 tag 替换保护。
+- 下拉菜单只消费无修饰键,组合键与 IME 交还输入框;对父层发布真实选区 collapsed 标志,让零文本长度的附件选择关闭候选。
+- 命令自动转换使用局部 Slate transform,并与触发转换的编辑合并历史;避免重建 root 后保留失效路径。
+- 浏览器 harness 直接加载生产组件和样式,使用真实鼠标与键盘;记录基线故障及修复后的结果。纯模型测试补充历史与选区边界。
+- 2026-09-20 用户确认的视觉增补:矩形属于选区,tag 本体保留原有圆角、底色、边框及图标颜色。选区背景独立绘制在 tag 后方,与周围文字连通,取消选择仅撤掉这一层;不得再用选中态覆盖 tag 本体颜色或圆角。
+- 前置图标随文字字号缩放,修正左右不对称的外间距及内边距。这些调整仅作用于输入框,已发送消息使用的共用徽标样式保持不变。
+
+### 拖入文件后恢复系统键盘焦点
+
+Windows 进入拖放区域时的前置只是一次尝试。若该尝试没有成功,文件物化后 `restorePathCaret` 只能恢复 Slate/DOM 光标;WebView2 甚至仍报告 `document.hasFocus()` 为真,但系统按键继续投递给来源窗口。隔离窗口通过拒绝第一次激活请求、保留真实 OLE 文件拖放及系统按键,稳定复现此状态。
+
+在有效文件释放的接收边界调用 `requestDesktopFileDropFocus` 专用桥接:浏览器/URI 路径在 `drop` 接受后调用,Windows/macOS 原生路径在带有效 hover 的原生回调中调用。该调用先于路径物化,不放在 Promise 完成、附件上传或布局重试中。Windows 短暂连接当前线程与前台线程的输入队列,恢复前台后立即断开,再通过 WebView2 `MoveFocus` 恢复浏览器的键盘焦点。专用桥接不使用通知激活中的 TOPMOST 兜底;旧版壳没有新桥接时回退为已有拖入激活尝试。文件全路径、tag 样式及编辑器选区恢复逻辑保持不变。此处补充之前“仅进入时尝试前置”的策略,允许释放接收时再恢复一次键盘焦点。
+
+## Risks / Trade-offs
+
+### 统一文件输入
+
+拖放、剪贴板和选择器仅负责收集来源数据,随后进入同一个文件解析与插入入口。可信原生路径优先,桌面文件无路径时通过原生桥接保存在数据目录的 `composer-files//` 下,再使用同一种 path 节点;纯 Web 的 File 才进入附件上传。原文件路径引用不读字节、不限制原文件大小,落盘数据沿用现有 25 MiB 内存数据上限。保存的文件不随窗口退出删除,确保草稿和历史引用有效。Windows CF_HDROP、macOS 文件 URL 和 Linux URI 列表保持平台适配边界,不能从普通文本猜测文件路径。
+
+RichComposer 创建持有 Slate rangeRef 的一次插入事务,保留零文本长度附件的准确选择边界。路径批次一次替换选区、一次撤销;等待期间范围随编辑移动,用户移动光标后完成操作不夺回光标。输入框卸载、任务切换或外部草稿重置会使事务失效。同一输入框按手势顺序提交已解析结果,跨手势允许再次引用同一文件。应用内结构化剪贴板保持最高优先级;系统文件格式优先于同次粘贴中的文本,空文件结果才继续处理文本,读取失败必须报错而非偷偷上传。
+
+焦点只在接收拖入或选择器返回时恢复,Promise 完成后不再请求窗口前置或 DOM focus。所有来源使用现有 path tag 组件,文件名显示与完整路径传递契约不变。
+
+- 浏览器原生选区可能进入不可编辑节点内部 → 用键鼠复现、DOM/Slate 选区和操作后内容一起判断,不能仅看高亮。
+- 图片 tag 的点击同时承担预览 → 保持明确的预览操作入口,并覆盖拖选不会误触。
+- 自动转换与撤销可能互相触发 → 测试转换后 undo/redo 及含附件的草稿。
+- Windows Chromium 测试不等于已安装 WebView2 客户端验证 → 在验证记录与交付说明中写明边界。
diff --git a/openspec/changes/fix-composer-tag-selection/proposal.md b/openspec/changes/fix-composer-tag-selection/proposal.md
new file mode 100644
index 000000000..2a9e082d9
--- /dev/null
+++ b/openspec/changes/fix-composer-tag-selection/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+Windows 聊天输入框的 tag 与文字共用 Slate 选区,但附件鼠标事件、原生选择和命令自动转换使用了不一致的处理路径,容易出现选不中、混选操作丢失 tag 或撤销失效。需要通过真实键鼠探索性测试明确边界并直接修复。
+
+## What Changes
+
+- 统一命令、技能、路径、会话和附件 tag 的原子选择行为,覆盖单击、鼠标双向拖选、Shift 加方向键及整段选择。
+- 保证混合选区的复制、剪切、替换、删除、撤销和重做保持文字与引用顺序。
+- 修复自动命令 tag 转换破坏撤销历史的问题。
+- 增加 Windows Chromium 浏览器回归脚本与探索性验证记录,保留现有界面样式和输入法保护。
+- 修复 Windows 拖入文件后仅有 DOM 光标、系统键盘焦点仍在来源窗口的问题;接收文件时恢复一次窗口焦点,异步完成时不抢焦点。
+- 统一拖入、粘贴与文件选择的接收和插入逻辑;桌面文件默认生成路径 tag,无源路径的数据先保存到本地,纯 Web 才上传。粘贴的文件与文本格式只消费一种。
+
+## Capabilities
+
+### New Capabilities
+- `composer-tag-selection`: 输入框内 tag 与文字的选择、编辑和剪贴板交互契约。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+涉及 `web/src/components/RichComposer.jsx`、输入框样式、Slate 辅助函数及回归测试。保持现有草稿与附件协议,不改变后端,不引入编辑器依赖。
diff --git a/openspec/changes/fix-composer-tag-selection/specs/composer-tag-selection/spec.md b/openspec/changes/fix-composer-tag-selection/specs/composer-tag-selection/spec.md
new file mode 100644
index 000000000..ece6ef9a5
--- /dev/null
+++ b/openspec/changes/fix-composer-tag-selection/specs/composer-tag-selection/spec.md
@@ -0,0 +1,100 @@
+## Purpose
+
+为聊天输入框中的文本与命令、技能、路径、会话及附件 tag 定义一致的选择和编辑行为,使用户在 Windows 上可以通过常规鼠标及键盘操作组合上下文引用,并可靠地复制、删除、替换和撤销这些输入内容。
+
+## ADDED Requirements
+
+### Requirement: 文件输入共享路径与编辑行为
+系统 MUST 让拖入、粘贴和选择器取得的同一本地文件使用相同路径 tag、完整引用路径及编辑行为。桌面无源路径数据 MUST 先保存在本地再引用,只有纯 Web 文件输入 SHALL 使用上传。
+
+#### Scenario: 文件同时携带文本格式
+- **WHEN** 用户粘贴包含原生文件和文本表示的剪贴板
+- **THEN** 系统仅插入文件路径 tag,不重复插入文本或上传附件
+
+#### Scenario: 选择文字与 tag 后插入多个文件
+- **WHEN** 用户选中混合内容后拖入或粘贴多个文件
+- **THEN** 相同事务按来源顺序替换准确选区,一次撤销恢复原内容,随后可以直接输入
+
+#### Scenario: 原生剪贴板读取失败
+- **WHEN** 桌面剪贴板桥接读取失败
+- **THEN** 系统显示错误,保留草稿,不将读取失败视为空剪贴板或改走上传
+
+#### Scenario: 桌面截图没有原文件路径
+- **WHEN** 用户在桌面端粘贴仅包含图片数据的截图
+- **THEN** 系统将数据保存到本地并插入路径 tag,不调用上传接口
+
+#### Scenario: 纯 Web 文件传入
+- **WHEN** 普通浏览器中通过拖入或粘贴添加 File
+- **THEN** 两种入口共享附件上传流程,不把浏览器文本中看似本地的路径当作服务器文件
+
+#### Scenario: 异步期间继续编辑或切换任务
+- **WHEN** 文件解析完成前用户继续输入、移动光标或切换任务
+- **THEN** 插入位置随原草稿编辑移动,完成后不覆盖新输入或抢回光标,旧任务结果不写入新任务
+
+### Requirement: 拖入文件后可直接继续输入
+系统 MUST 在接收有效外部文件释放时恢复当前 composer 所属窗口的键盘焦点,并 MUST 保留完整引用路径和 tag 后的合法光标。
+
+#### Scenario: 进入时窗口激活未成功
+- **WHEN** Windows 拖放进入时的前置尝试没有成功,用户随后在 composer 接收范围内释放文件
+- **THEN** 系统在接收文件时再次使用文件拖放专用桥接恢复窗口焦点
+- **AND** 用户无需额外点击即可继续输入文字、移动光标或使用中文输入法
+
+#### Scenario: 解析期间用户切换窗口
+- **WHEN** 有效文件已经释放,用户在异步路径解析或上传期间切换到其他窗口
+- **THEN** 后续异步完成与布局刷新不再次请求原生窗口前置
+
+#### Scenario: 无效或禁用的原生拖放
+- **WHEN** composer 已禁用或原生回调没有有效的最近拖放落点
+- **THEN** 系统不因该回调抢占窗口焦点
+
+### Requirement: tag 参与原子选择与混合选择
+
+输入框 SHALL 允许单独选中 tag,以及通过鼠标拖选和 Shift 加方向键选择文本与多个 tag,选中样式与后续操作范围一致。
+
+#### Scenario: 单独选中 tag
+- **WHEN** 用户单击一个 tag 的主体
+- **THEN** tag 作为完整的原子项选中,可以复制、剪切、删除或用文字替换
+
+#### Scenario: 双向混合选择
+- **WHEN** 用户从文字或 tag 开始向前或向后拖选,或使用 Shift 加方向键扩展选区跨越多个 tag
+- **THEN** 系统保留选区方向及文字边界,跨过的 tag 整体纳入选区
+
+### Requirement: 矩形选区独立于 tag 本体
+
+输入框 SHALL 在选中 tag 时新增与文字选区连续的矩形背景,保留 tag 原有圆角、底色、边框和图标颜色。前置图标 SHALL 与文字具有相称的可见尺寸,tag 左右间距 SHALL 对称。
+
+#### Scenario: 拖选与键盘混选的视觉一致
+- **WHEN** 用户拖选或通过 Shift 加方向键选择文字、空格和多个 tag
+- **THEN** 这些内容背后的矩形选区连续且上下对齐,tag 本体保持原样,换行时各行选区仍然完整
+
+#### Scenario: 取消选区
+- **WHEN** 用户将选区折叠成光标或输入框失去焦点
+- **THEN** tag 后方的新增选区背景消失,tag 本体不发生外观变化或布局跳动
+
+### Requirement: 混合选区编辑保持引用与历史
+
+输入框 SHALL 对选中的文字和 tag 执行一致的复制、剪切、删除和替换,撤销与重做保持引用顺序和周围未选内容。
+
+#### Scenario: 复制与剪切
+- **WHEN** 用户复制或剪切单个 tag 或混合选区并粘贴回可解析引用的输入框
+- **THEN** 文本与 tag 的内容和顺序保留,剪切只删除选中的内容
+
+#### Scenario: 删除及撤销
+- **WHEN** 用户删除或替换选区后撤销并重做
+- **THEN** 原始文本和 tag 可恢复,未选中的内容不丢失
+
+### Requirement: 自动命令转换可撤销
+
+输入框 SHALL 保留命令从文字自动转换为 tag 时的撤销历史。
+
+#### Scenario: 输入命令并转换
+- **WHEN** 用户输入已知命令及确认空格后使用 Ctrl+Z 和 Ctrl+Y
+- **THEN** 撤销恢复此前编辑状态,重做恢复命令及 tag,不发生无效撤销或节点路径错误
+
+### Requirement: 选择手势不触发附件动作
+
+输入框 SHALL 保留附件预览与移除能力,选择或拖选 tag 不得误触这些动作。
+
+#### Scenario: 从图片 tag 拖选
+- **WHEN** 用户从图片 tag 主体开始拖选到周围文字
+- **THEN** 形成混合选区,图片预览不自动打开,附件不被移除
diff --git a/openspec/changes/fix-composer-tag-selection/tasks.md b/openspec/changes/fix-composer-tag-selection/tasks.md
new file mode 100644
index 000000000..5a92e7a64
--- /dev/null
+++ b/openspec/changes/fix-composer-tag-selection/tasks.md
@@ -0,0 +1,32 @@
+## 1. 探索与复现
+
+- [x] 1.1 完成 Windows Chromium 键鼠基线探索,记录 tag 单选、双向拖选、Shift 选择、剪贴板与历史问题和可复现步骤。
+
+## 2. 修复与回归
+
+- [x] 2.1 统一 tag 选择和混合编辑边界,浏览器验证文字与全部 tag 类型的选中、剪切、替换、删除以及附件动作隔离。
+- [x] 2.2 修复自动命令转换历史,定向测试证明 Ctrl+Z/Ctrl+Y 有效且附件与选区不丢失。
+- [x] 2.3 添加可重复执行的浏览器回归脚本,覆盖鼠标、键盘、剪贴板、多行及中英文窄屏样式。
+
+## 3. 验证与交付
+
+- [x] 3.1 运行 pnpm test、pnpm build、OpenSpec 严格验证、git diff --check 和 impeccable 检测;记录测试结果与 Windows 客户端验证边界。
+
+## 4. 按确认稿修正选择视觉
+
+- [x] 4.1 保留 tag 本体的圆角、底色、边框和图标颜色,以独立矩形选区背景连接文字与 tag;取消选中只移除选区背景。
+- [x] 4.2 放大前置图标以匹配文字,修正 tag 左右间距,检查各类 tag 和字号下的对齐。
+- [x] 4.3 验证真实拖选、Shift 多选、跨行与取消选择,检查明暗主题和 390px 截图,完成前端测试、构建和 OpenSpec 检查。
+
+## 5. 拖入文件后继续输入
+
+- [x] 5.1 在 Windows WebView2 隔离窗口复现进入时激活失败后,文件 tag 和 DOM 光标正常但系统按键仍送往来源窗口的问题。
+- [x] 5.2 文件释放并被接收时恢复一次原生窗口焦点,保留现有光标与完整路径,不在异步解析或上传完成时抢焦点。
+- [x] 5.3 验证原生拖放后直接输入、方向键与中文输入,覆盖无效拖放和异步处理期间切换窗口,完成前端测试与构建。
+
+## 6. 统一拖入与粘贴文件
+
+- [x] 6.1 建立共享文件接收入口,桌面原路径与无路径本地保存统一为 path tag;完善原生剪贴板空结果和失败的区分。
+- [x] 6.2 使用同一 Slate 插入事务处理选区替换、一次撤销、异步编辑及任务切换;文件粘贴与文本格式互斥,保留结构化复制粘贴。
+- [x] 6.3 成对验证拖入与粘贴的外观、完整路径、顺序、混合选择、撤销与连续输入,覆盖截图、目录、纯 Web 和异步失败;完成前端与原生定向检查。
+- [x] 6.4 复核改动范围,提交并推送远端,确认远端包含提交。
diff --git a/openspec/changes/fix-composer-tag-selection/validation.md b/openspec/changes/fix-composer-tag-selection/validation.md
new file mode 100644
index 000000000..0ba665e85
--- /dev/null
+++ b/openspec/changes/fix-composer-tag-selection/validation.md
@@ -0,0 +1,47 @@
+# 验证记录
+
+2026-09-19,Windows,基线 b973acec。详细矩阵和复跑命令见 `docs/composer-tag-selection-testing.md`。
+
+- RichComposer 浏览器键鼠回归:78/78 通过;浏览器异常与 console errors 均为零。
+- InputBar 候选菜单浏览器回归:16/16 通过;旧版同组 12 项失败。
+- 纯 Slate 命令同步 11 项、选区与指针 14 项,以及已有键盘/IME、草稿、生命周期测试通过。
+- `pnpm test` 通过。SVG 测试对 Git 的 CRLF checkout 归一化后可直接在 Windows 重跑,无 SVG 资源变更。
+- `pnpm build` 通过,3077 模块,4450 个正则字面量兼容性扫描通过。
+- `openspec validate fix-composer-tag-selection --strict` 与 `git diff --check` 通过。
+- impeccable detector 完成,12 条提醒均在 globals.css 未变更的既有代码;本次修改行无提醒。
+- 明暗主题、中英文、390px 与 reduced-motion 四种截图已检查,选择填充完整,无横向溢出。
+
+原始浏览器结果和截图保存在 `C:/Users/shao/.codex/visualizations/2026/09/19/01a0b9c0-da2f-7001-bc01-1fb359fafdaf/composer-selection/final/`。
+
+测试使用生产组件和真实 Windows Chromium 鼠标/键盘事件。未重新构建已安装 Desktop/WebView2,未操作微软拼音实际候选窗或用户正在运行的 Codex 草稿;IME 为合成事件及模型回归。
+
+## 2026-09-20:按确认稿增加矩形选区背景
+
+基于 `47d20596`,在独立 worktree `composer-selection-visuals` / 分支 `codex/fix-composer-selection-visuals` 中实现。
+
+- 原始圆角徽标与选区层分离,选中只在后方新增矩形,保留底色、边框、文件类型图标颜色和上传状态。未选中与选中时的徽标几何完全一致。
+- 前置图标采用与字体对应的尺寸;Seti 字形只放大实际绘制区域,不扩大布局和点击范围。内边距左右相等,移除单边外间距与附件移除按钮的负间距。
+- 原有 78 项 Windows Chromium 选择与编辑检查全部通过;新增长路径窄屏检查通过。新增视觉检查覆盖明暗主题、13/14/16/20px 字号、390px、上传状态、取消选区及失焦;首次发现图标选区颜色继承问题,修正后定向复测通过。合计 80 个用例已通过,无浏览器异常。
+- `pnpm test` 与 `pnpm build` 通过;构建 3077 模块,4450 个正则字面量兼容性扫描通过。
+- `openspec validate fix-composer-tag-selection --strict` 与 `git diff --check` 通过。
+- impeccable detector:11 条提醒、1 条 advisory,均位于未修改的既有样式,本次改动行无提醒。
+
+原始完整回归与视觉复测结果分别保存在前述可视化目录的 `additive-final/results.json` 和 `additive-final/visual-regression/results.json`;真实组件截图位于 `additive-confirmed/`。验证范围为生产组件的 Windows Chromium 渲染与交互,本轮没有重新构建或切换用户正在运行的 Desktop 客户端。
+
+## 2026-09-20:拖入文件后继续输入
+
+基线 `9dfd0e11`。修复范围为文件释放时的原生键盘焦点恢复,完整路径、文件名标签展示与选择样式保持原有行为。
+
+- 新增生产 InputBar 浏览器回归 7 项通过;基线同组 6 项失败。覆盖平台入口、直接输入与方向键、重复/无效回调、禁用状态以及异步完成不抢前台。
+- 使用当前生产 `WebHost` 编译 Windows 隔离窗口,以真实 OLE 文件拖放和系统键盘验证。基线在拒绝第一次拖入激活后仍显示光标,但按键留在来源窗口;新桥接恢复窗口和 WebView2 焦点后,中文输入法候选提交、英文输入、左方向键插入均通过,无需额外点击。
+- `pnpm test`、`pnpm build`、`openspec validate fix-composer-tag-selection --strict` 与 `git diff --check` 通过。没有新增编辑器依赖或修改文件引用协议。
+- Windows Release `acecode-desktop` 及其 daemon 依赖增量构建通过,客户端已通过仓库 Desktop 开发启动器重新运行。原生交互验证在隔离窗口完成,实际客户端验证启动与开发前端资源一致性。
+
+## 2026-09-20:统一拖入与粘贴文件
+
+- 基线 `8a7bf7c5`。桌面原文件、剪贴板文件和选择器共享文件分类与 Slate 插入事务;无源路径数据先保存本地,纯 Web 才上传。原生剪贴板错误不再降级为空结果,普通文本不被猜测为本地文件。
+- 文件输入浏览器回归 19 项通过,覆盖同文件拖入/粘贴结构与样式相等、Windows/UNC/POSIX 路径、文件夹、图片、多文件、混合选区、零长度附件、一次撤销、连续输入、异步乱序、焦点、清空草稿及任务/工作目录切换。
+- 原有 tag 选择回归 80 项通过;原生文件处理 GoogleTest 7 项通过,当前 Windows WebHost 编译通过;`pnpm test`、`pnpm build`、OpenSpec 严格验证、差异检查及本次组件的 impeccable 检查通过。
+- 配对截图在临时目录 `acecode-file-intake-shots/drop.png`、`paste.png`,逐项样式与结构比较通过。macOS/Linux 尚未实机验证;浏览器中的平台分支使用桥接 fixture。
+- Windows Release 原生支持库与 Desktop 编译、链接通过,使用当前工作树的 Desktop 启动器重新运行;HTTP 返回的开发前端 SHA-256 与 `web/dist/index.html` 一致。相邻 daemon 的 API 无变更,复用现有可运行产物。
+- 功能提交 `9f1a847c`,合入远端 macOS 窗口按钮调整后,以 `b5aee5ba` 推送到 `origin/master`,远端提交哈希回读一致。
diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/.openspec.yaml b/openspec/changes/fix-native-select-dropdown-dismissal/.openspec.yaml
new file mode 100644
index 000000000..2b596d13d
--- /dev/null
+++ b/openspec/changes/fix-native-select-dropdown-dismissal/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-12
diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/design.md b/openspec/changes/fix-native-select-dropdown-dismissal/design.md
new file mode 100644
index 000000000..f153eec9b
--- /dev/null
+++ b/openspec/changes/fix-native-select-dropdown-dismissal/design.md
@@ -0,0 +1,25 @@
+## Context
+
+See proposal.md. Language and feedback use ordinary HTML selects with independent value handlers. Windows WebView2 152 reproduced transient native popup dismissal in a standalone page. Rendering the same controls with `appearance: base-select` kept all three test pickers open under physical mouse input. The exact internal runtime dismissal trigger remains unproven; changing host position notifications did not reliably resolve it.
+
+## Goals / Non-Goals
+
+Keep selection and form ownership in existing controls. Avoid per-page replacement components or synthetic mouse/selection events. Native host changes and runtime upgrades are outside this fix.
+
+## Decisions
+
+- Enable shared picker styles only for Windows Desktop and only when both base-select appearance and the picker selector are supported. Other browsers/platforms and older engines retain their existing native controls.
+- Apply the rules to single-choice dropdowns, excluding multiple and multirow selects. Use existing theme tokens, bounded scrollable menus, and readable wrapping for long feedback labels.
+- Preserve the real select DOM. The browser owns opening, focus, selection, accessibility, and `change` events; React continues owning the value.
+- 菜单使用内容固有宽度,并以触发控件宽度作为最小值;最小和最大宽度都受视口限制。“每次询问”“最小化到托盘”“退出应用”等常规选项保持单行,超长标题或路径仅在菜单达到视口上限后换行。沿用浏览器的锚点定位和边缘回退,所有选项保持常规字重。
+- Intercept propagation of Escape only while a browser-rendered select picker is open. Do not prevent its default action: the browser closes the picker, while the containing Modal stays open.
+
+## Risks / Trade-offs
+
+- Picker keyboard behavior follows the engine's customizable-select behavior. Validate mouse selection, keyboard selection, cancellation, and disabled options against actual controls.
+- Unsupported older engines retain native rendering. This keeps them usable without introducing an untested JavaScript combobox fallback.
+- Automated browser clicks did not consistently expose the native popup failure. Physical Windows mouse input is the regression acceptance check.
+
+## Migration Plan
+
+Ship through the normal Web asset build. Rollback consists of removing the shared opt-in and stylesheet; persisted configuration and select values require no migration.
diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/proposal.md b/openspec/changes/fix-native-select-dropdown-dismissal/proposal.md
new file mode 100644
index 000000000..b826028c8
--- /dev/null
+++ b/openspec/changes/fix-native-select-dropdown-dismissal/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+Windows Desktop single-choice dropdowns can open and immediately close on a physical mouse click, including language, document controls, and feedback sessions. A standalone WebView2 page reproduces the problem without React; browser-rendered select pickers remain open in the same runtime.
+
+## What Changes
+
+- Opt Windows Desktop single-choice selects into browser-rendered pickers when the engine supports them, through shared startup guards and styles.
+- Keep existing select elements, labels, options, change handlers, and form values.
+- Keep Escape inside an open picker so it does not dismiss its containing dialog.
+- Retain platform rendering in other environments, unsupported engines, and list or multiple selects.
+- 下拉菜单按选项内容自然展开,至少与触发控件等宽,并限制在视口内;修复“关闭窗口时”等紧凑控件把短选项挤成多行的问题。
+
+## Capabilities
+
+### New Capabilities
+
+- `web-select-interaction`: Shared select opening, dismissal, keyboard, theme, and compatibility behavior.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+Shared Web startup guards and select styling; no daemon protocol, C++ host, or dependency changes. Validation includes actual Windows mouse input, language and feedback surfaces, keyboard dismissal, and existing Web checks.
diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/specs/web-select-interaction/spec.md b/openspec/changes/fix-native-select-dropdown-dismissal/specs/web-select-interaction/spec.md
new file mode 100644
index 000000000..32ea80343
--- /dev/null
+++ b/openspec/changes/fix-native-select-dropdown-dismissal/specs/web-select-interaction/spec.md
@@ -0,0 +1,51 @@
+## Purpose
+
+Keep shared single-choice dropdowns usable across ACECode settings, documents, and feedback while preserving form values, keyboard control, and platform compatibility.
+
+## ADDED Requirements
+
+### Requirement: A single click opens a stable dropdown
+
+On supported Windows Desktop engines, enabled single-choice dropdowns SHALL stay open after a primary mouse click until the user chooses an option or dismisses the picker.
+
+#### Scenario: User opens language or feedback options
+- **WHEN** the user clicks the interface-language or feedback-session dropdown once
+- **THEN** the available options SHALL remain visible and selectable
+- **AND** opening the dropdown SHALL NOT change its value or submit a form
+
+#### Scenario: User selects an option
+- **WHEN** the user selects an enabled option with the mouse or keyboard
+- **THEN** the dropdown SHALL close and deliver the selected value once to the existing change handler
+
+### Requirement: Picker dismissal respects its containing surface
+
+An open picker SHALL handle Escape without closing its containing settings or feedback dialog.
+
+#### Scenario: User cancels a selection
+- **WHEN** the user presses Escape while the picker is open
+- **THEN** the picker SHALL close without changing its value
+- **AND** its containing dialog SHALL remain open
+
+### Requirement: Shared pickers respect compatibility and appearance
+
+Shared dropdown styling SHALL follow active theme colors and keep long menus within the viewport. Multiple selections, multirow lists, unsupported engines, and non-Windows-Desktop environments SHALL retain their existing control rendering.
+
+#### Scenario: Feedback labels are long
+- **WHEN** feedback options contain long session titles or paths
+- **THEN** the menu SHALL remain readable and scrollable without extending beyond the viewport
+
+#### Scenario: Enhanced rendering is unavailable
+- **WHEN** the engine lacks the required picker rendering support or is not Windows Desktop
+- **THEN** the original select rendering and keyboard event propagation SHALL remain available
+
+### Requirement: 菜单宽度适应选项内容
+
+增强下拉菜单 SHALL 按选项内容展开,并在视口允许时至少与触发控件等宽;菜单及选项 SHALL 保留既有主题颜色、常规字重、选中标记和键盘交互。
+
+#### Scenario: 紧凑控件包含较长选项
+- **WHEN** 用户展开“关闭窗口时”等紧凑下拉控件
+- **THEN** 菜单可宽于控件,“每次询问”“最小化到托盘”“退出应用”等普通选项保持单行
+
+#### Scenario: 菜单靠近视口边缘或包含超长内容
+- **WHEN** 控件靠近视口边缘,或选项包含超长会话标题、路径
+- **THEN** 菜单不超出视口,长文本可换行、长列表可滚动,禁用选项不可选择
diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/tasks.md b/openspec/changes/fix-native-select-dropdown-dismissal/tasks.md
new file mode 100644
index 000000000..8717b92a7
--- /dev/null
+++ b/openspec/changes/fix-native-select-dropdown-dismissal/tasks.md
@@ -0,0 +1,17 @@
+## 1. Shared select behavior
+
+- [x] 1.1 Add Windows Desktop feature-gated select styling and Escape handling; verify focused guard tests and physical mouse opening in WebView2.
+- [x] 1.2 Verify real language and feedback controls, option selection, Escape, disabled/list controls, and long labels in light/dark themes with a native-host integration check.
+
+## 2. Validation
+
+- [x] 2.1 Run Web tests and build, strict OpenSpec validation, and scoped diff checks; record results and runtime limitations.
+
+## 3. 下拉菜单内容宽度
+
+- [x] 3.1 修复共享下拉菜单被触发器宽度压窄的问题,保留视口限制、正常字重和长文本换行能力。
+- [x] 3.2 浏览器验证关闭行为、语言等实际控件及右侧边缘、明暗主题、中英文、大字号、长标签、禁用项和键盘操作;运行 Web 测试、构建与严格 OpenSpec 校验。
+
+2026-09-19 验证:真实 SettingsPage 配合模拟桌面桥在 Chromium 复现旧宽度:89.08px 菜单内三个中文选项均为两行;新样式菜单自然展开到 120.25px,三项均为一行,触发器宽度不变。10 组场景包含修复前后对照、界面语言、暗色、中英文大字号、390px 窄屏、禁用托盘项、右下角翻转,以及超长中文标题和无空格路径。超长菜单限制在 374px 宽度并纵向滚动,选项无横向溢出、全部为 400 字重。鼠标选择与键盘选择各只写入一次模拟值,Escape 仅关闭菜单,禁用项被跳过,无浏览器异常。
+
+`pnpm test` 通过(2,559 条 pass 记录),`pnpm build`、产物正则兼容检查、严格 OpenSpec 校验和 `git diff --check` 通过;select.css 设计扫描无发现。验证预览使用独立 Vite 缓存,避免共享缓存导致重复 React;临时页面、临时配置及预览进程已清理。截图与测量数据保存在本任务可视化目录的 `settings-select/`。本次未重新打包 Desktop 或重复原生 WebView2 物理鼠标验证。
diff --git a/openspec/changes/fix-native-select-dropdown-dismissal/verification.md b/openspec/changes/fix-native-select-dropdown-dismissal/verification.md
new file mode 100644
index 000000000..3ab2c6186
--- /dev/null
+++ b/openspec/changes/fix-native-select-dropdown-dismissal/verification.md
@@ -0,0 +1,22 @@
+## Results
+
+Validated on Windows with WebView2 152 using an isolated native window built from the repository's unchanged WebHost source and the production Web bundle. API reads were proxied to the current daemon; configuration changes were mocked, and all other writes were rejected. The user's desktop process was not restarted or replaced.
+
+- `pnpm test`: passed, including all 13 browser-default guard tests.
+- `pnpm build`: passed; generated-bundle regex compatibility check passed.
+- `openspec validate fix-native-select-dropdown-dismissal --strict`: passed.
+- Scoped `git diff --check`: passed.
+- Physical Windows mouse input opened actual Settings language, inline Settings feedback, and standalone feedback dropdowns. Each remained open for at least 600 ms without changing its value.
+- Mouse selection committed language once, with one mocked API write. Mouse and keyboard selection worked in feedback without uploading or submitting the form.
+- Escape dismissed the picker while preserving its dialog and feedback draft. A second Escape closed the standalone feedback dialog. Clicking explanatory text outside the picker dismissed it without changing the selection.
+- Light/dark theme screenshots were inspected. Long options wrap inside a menu bounded to its control width and viewport height; scrolling to the final option worked. Closed controls retain one line and a visible arrow for long selected labels.
+- Disabled controls did not open; keyboard navigation skipped disabled options. Multiple and multirow controls retained native appearance. Removing the opt-in restored native rendering.
+- The running desktop daemon's served HTML contains the new select opt-in. Reloading its window loads the fix; a new desktop executable is not required for this local runtime.
+
+## Reproduction and scope
+
+The native-popup dismissal also reproduced with three ordinary selects (language, document, feedback) in a standalone WebView page with no React handlers. Exact internal WebView2 dismissal cause remains unproven. Automated browser clicks did not consistently reproduce it; physical mouse opening is the acceptance check.
+
+The user's specific document dropdown entry has not yet been identified. The shared styles cover single-choice selects in the main ACECode document on supported Windows Desktop engines; no claim is made for native file dialogs or third-party embedded documents.
+
+The isolated native check and screenshots are in the session's temporary `ace-select-host-probe` directory, outside the repository. Other browsers, platforms, and older engines retain their existing select behavior.
diff --git a/openspec/changes/fix-question-picker-rerender-state/.openspec.yaml b/openspec/changes/fix-question-picker-rerender-state/.openspec.yaml
new file mode 100644
index 000000000..abd7c5ae1
--- /dev/null
+++ b/openspec/changes/fix-question-picker-rerender-state/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-25
diff --git a/openspec/changes/fix-question-picker-rerender-state/design.md b/openspec/changes/fix-question-picker-rerender-state/design.md
new file mode 100644
index 000000000..daa0acc5c
--- /dev/null
+++ b/openspec/changes/fix-question-picker-rerender-state/design.md
@@ -0,0 +1,20 @@
+## Context
+
+见 proposal.md。后台请求 ID 由 UUID 生成,同一请求的重放保持身份;原 PR 的 requestId 重置保护与该协议一致。App 的认证分支会提前返回,所有 Hook 必须位于这些分支之前。
+
+## Goals / Non-Goals
+
+**Goals:** 保留同请求回答状态,同时确保所有认证路径调用相同的 Hook 序列。
+
+**Non-Goals:** 不修改权限优先级、提问协议或侧栏布局;不增加依赖。
+
+## Decisions
+
+- 保留 QuestionPicker 的请求 ID 保护。仅依靠父组件 memo 无法覆盖订阅重放产生的新对象。
+- 将 visibleQuestionReq 的 useMemo 移到依赖计算之后、认证提前返回之前。删除 memo 会牺牲已实现的引用稳定性,移动它可同时保留优化和 Hook 约束。
+- 使用生产 App 的语法树检查组件本层 Hook 与提前返回的顺序,避免仅测试 QuestionPicker 而漏掉启动路径;另做真实 React 认证切换验证。
+
+## Risks / Trade-offs
+
+- 静态源码模式可能漏掉嵌套控制流 → 使用语法树检查组件本层语句,并以真实 React 启动验证补充。
+- 当前 master 有已知侧栏间距断言失败 → 比较基线确认来源后,将旧 gap-0 断言对齐到已提交的 gap-2 布局,验证合并结果的完整测试。
diff --git a/openspec/changes/fix-question-picker-rerender-state/proposal.md b/openspec/changes/fix-question-picker-rerender-state/proposal.md
new file mode 100644
index 000000000..8772d4e0a
--- /dev/null
+++ b/openspec/changes/fix-question-picker-rerender-state/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+同一次 AskUserQuestion 因后台事件重新渲染时会清空答案并跳回第一题。PR #72 修复了请求识别,但新增的 useMemo 位于认证提前返回之后,认证完成时会改变 Hook 数量并导致界面崩溃,需要在合并前修正并补齐回归覆盖。
+
+## What Changes
+
+- 保留 PR 中按请求 UUID 重置、同请求重新渲染保留答案的修复。
+- 将可见问题的 useMemo 放到所有认证提前返回之前,确保启动、输入 token 和认证成功使用一致的 Hook 顺序。
+- 增加认证切换的回归检查,验证问题让位于权限请求和回答状态隔离。
+- 同步最新 master,并将原有侧栏间距断言对齐到已提交的 gap-2 布局,解除合并后 CI 的基线阻塞。
+
+## Capabilities
+
+### New Capabilities
+
+- `desktop-ask-user-question-ui`: 明确同请求状态保留、新请求重置及认证状态切换时的可用性;当前主规范尚未归档该能力,沿用现有变更中的能力名称。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+影响 web/src/App.jsx、QuestionPicker 现有回归测试及 PR 审查记录;不改变 daemon 协议、不增加依赖。
diff --git a/openspec/changes/fix-question-picker-rerender-state/review.md b/openspec/changes/fix-question-picker-rerender-state/review.md
new file mode 100644
index 000000000..d7eb24487
--- /dev/null
+++ b/openspec/changes/fix-question-picker-rerender-state/review.md
@@ -0,0 +1,18 @@
+## 审查结论
+
+PR #72 原始修复按请求 UUID 保留回答的方向正确;审查发现并修复一项 P1 启动回归。
+
+## 问题与修复
+
+原 PR 将 App 的 visibleQuestionReq useMemo 放在 authState 为 checking / need-token 的提前返回之后。从启动检查或 token 提示进入已认证状态时,React 发现本次调用的 Hook 数量增加并抛出 Rendered more hooks than during the previous render。
+
+修复将 memo 移到依赖计算之后、所有认证提前返回之前,保持权限优先级和引用复用。新增基于生产 App 语法树的 Hook 顺序回归,并扩展同请求重新渲染保留自定义草稿的回归。
+
+## 验证
+
+- 新增 Hook 顺序检查在原 PR 上失败,在修复后通过。
+- 从生产 App 提取认证分支与 memo 顺序,在 Chromium 和真实 React 18 中验证:修复前复现 Hook 数量错误;修复后 5 次认证状态切换和 2 次权限优先级检查通过,无页面错误。此验证聚焦真实 React 的认证控制流,不代表完整桌面壳验证。
+- PR 快照 pnpm test 通过,日志含 2784 条 pass;pnpm build 及 4478 个正则兼容检查通过。
+- 初次与 master c0f212ed 合并后的构建通过;318 个测试文件中仅 sidebarAlignmentArchitecture.test.js:22 失败,与未合并的 master 完全一致(旧断言 gap-0,已提交界面 gap-2)。远端 CI 也确认同一失败,因此同步 master 并将这条断言对齐到 gap-2;最终 pnpm test 全部通过(2877 条 pass 记录);构建使用相同运行时代码的合并快照,测试和文档调整不影响已通过的构建。
+- 原 PR 的 C++ 测试改动用于验证不存在的 workspace 会话返回 404;生产路由的归属检查与此一致。本次追加修复不修改 C++;原 head 4111ad0f 的远端 unit-tests 已通过。
+- OpenSpec strict 通过;修复提交与远端最终 CI、合并状态另见 PR 评论。
diff --git a/openspec/changes/fix-question-picker-rerender-state/specs/desktop-ask-user-question-ui/spec.md b/openspec/changes/fix-question-picker-rerender-state/specs/desktop-ask-user-question-ui/spec.md
new file mode 100644
index 000000000..f80162e70
--- /dev/null
+++ b/openspec/changes/fix-question-picker-rerender-state/specs/desktop-ask-user-question-ui/spec.md
@@ -0,0 +1,33 @@
+## Purpose
+
+确保桌面和 Web 用户在后台状态更新或认证状态切换时仍能可靠完成多题问答。同一次提问的选择、草稿与题号应保持稳定,新提问应从初始状态开始,认证完成后主界面应正常显示。
+
+## ADDED Requirements
+
+### Requirement: 问答状态按请求身份保留
+
+系统 SHALL 在同一提问请求重新渲染时保留已选答案、自定义草稿和当前题号;收到新的提问请求时清空旧答案并回到第一题。
+
+#### Scenario: 后台事件引发同请求重新渲染
+- **WHEN** 用户已完成第一题并进入第二题,后台事件提供同一请求的新对象引用
+- **THEN** 已有答案、自定义草稿和第二题位置保持不变
+
+#### Scenario: 切换新请求
+- **WHEN** 用户正在回答一个请求并切换到不同请求 ID 的提问
+- **THEN** 新请求从第一题开始且没有沿用旧答案
+
+### Requirement: 认证切换保持问答界面可用
+
+系统 SHALL 在初始连接检查、需要 token 和认证成功之间切换时正常渲染界面;未解决的权限请求继续优先于问答显示。
+
+#### Scenario: 初次连接完成
+- **WHEN** 初始连接检查完成并进入已认证状态
+- **THEN** 主界面正常显示,存在待答请求时显示对应提问
+
+#### Scenario: 输入 token 后进入主界面
+- **WHEN** 用户在认证提示中提交有效 token 并完成认证
+- **THEN** 主界面正常显示,不因问答派生状态而崩溃
+
+#### Scenario: 权限优先
+- **WHEN** 当前会话同时存在未解决的权限请求和提问请求
+- **THEN** 先显示权限请求,解决权限后再显示提问
diff --git a/openspec/changes/fix-question-picker-rerender-state/tasks.md b/openspec/changes/fix-question-picker-rerender-state/tasks.md
new file mode 100644
index 000000000..5c7037846
--- /dev/null
+++ b/openspec/changes/fix-question-picker-rerender-state/tasks.md
@@ -0,0 +1,9 @@
+## 1. 审查修复
+
+- [x] 1.1 增加生产 App Hook 顺序回归,确认原 PR 在认证提前返回场景下失败。
+- [x] 1.2 移动问答 memo 至认证提前返回之前,验证同请求、新请求与自定义草稿行为,以及真实 React 认证切换。
+
+## 2. 合并验证
+
+- [x] 2.1 完成 PR 快照的 pnpm test、pnpm build、OpenSpec strict;同步最新 master 后修复过时侧栏断言并验证合并结果的完整前端测试。
+- [x] 2.2 完成修复差异审查,并在 PR 评论记录问题、修复和验证证据;远端 CI、合并与同步结果由 PR 后续记录追踪。
diff --git a/openspec/changes/fix-session-loading-overlay-position/.openspec.yaml b/openspec/changes/fix-session-loading-overlay-position/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/fix-session-loading-overlay-position/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/fix-session-loading-overlay-position/design.md b/openspec/changes/fix-session-loading-overlay-position/design.md
new file mode 100644
index 000000000..65c037f41
--- /dev/null
+++ b/openspec/changes/fix-session-loading-overlay-position/design.md
@@ -0,0 +1,22 @@
+## Context
+
+见 proposal.md。`SessionContentLoading` 使用绝对定位;恢复遮罩当前位于带 `position: relative` 的输入区内,记录遮罩位于会话主列内。9 月 17 日的旧设计为保留历史可读性而仅遮住输入区,这次按用户反馈统一加载位置。
+
+## Goals / Non-Goals
+
+**Goals:** 一个定位容器负责会话内容的加载反馈,两个异步阶段共同决定遮罩状态。
+
+**Non-Goals:** 不调整恢复请求、并发池、订阅状态机、侧栏导航或现有加载卡片视觉样式。
+
+## Decisions
+
+- 将 `ChatView` 中两个加载组件合为会话主列的一个直接子节点。继续复用已有 `absolute inset-0` 定位,不以固定坐标或全窗口遮罩修补问题。
+- 在现有 `sessionContentLoading.js` 中归并阶段:错误优先,其次记录读取,再次运行环境恢复,全部完成后不显示。只读外部会话不参与运行环境恢复判定。
+- 保留 `sessionRuntimeUnavailable` 的发送和队列保护;不清空已加载历史。`App` 在 `resumePending` 时仍抑制外层侧栏加载遮罩。
+- 结构回归检查实际 JSX 祖先和唯一挂载,避免仅检查组件名称而遗漏定位容器;浏览器验证窄屏、侧面板和输入区高度变化。
+
+## Risks / Trade-offs
+
+- 恢复期间会话主列统一被遮罩覆盖,历史数据仍保留,但需恢复完成后阅读和操作;侧栏仍可用于切换。
+- 并行的工作台改动已修改 `ChatView.jsx`,只在导入和加载组件所在局部编辑,并核对其余已有改动未丢失。
+- 源码和浏览器验证不等同于已安装 Desktop 客户端验证;本次不打包或发布。
diff --git a/openspec/changes/fix-session-loading-overlay-position/proposal.md b/openspec/changes/fix-session-loading-overlay-position/proposal.md
new file mode 100644
index 000000000..826c9e131
--- /dev/null
+++ b/openspec/changes/fix-session-loading-overlay-position/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+读取聊天记录和恢复会话运行环境使用了两个不同层级的加载遮罩。记录先读取完成时,运行环境恢复提示在底部输入区内居中,产生加载卡片突然下移的现象。
+
+## What Changes
+
+- 合并当前会话的聊天记录加载、运行环境恢复及失败反馈,只保留一个挂在会话主列中的遮罩。
+- 无论哪个异步阶段先完成,加载提示都以会话主列为定位范围;失败状态优先展示。
+- 保留恢复期间的发送、队列发送和实时订阅保护,以及已加载的历史数据。
+- 补充阶段完成顺序、遮罩挂载层级和浏览器布局验证。
+
+## Capabilities
+
+### New Capabilities
+
+无。
+
+### Modified Capabilities
+
+- `session-resume`:增加 Web 会话加载和恢复反馈始终在会话主列居中的要求。
+
+## Impact
+
+涉及 `ChatView.jsx`、现有 `sessionContentLoading.js` 及相关前端测试;复用现有加载卡片和主题样式,无后端 API 或依赖变更。
diff --git a/openspec/changes/fix-session-loading-overlay-position/specs/session-resume/spec.md b/openspec/changes/fix-session-loading-overlay-position/specs/session-resume/spec.md
new file mode 100644
index 000000000..6e21c6fa7
--- /dev/null
+++ b/openspec/changes/fix-session-loading-overlay-position/specs/session-resume/spec.md
@@ -0,0 +1,26 @@
+## ADDED Requirements
+
+### Requirement: Web 会话加载反馈始终以会话主列居中
+
+系统 SHALL 在聊天记录读取、会话运行环境恢复及其失败状态下,只展示一个位于当前会话主列中央的内容遮罩。加载阶段切换 MUST NOT 将提示移入底部输入区。侧栏 MUST 保持可操作,恢复期间 MUST 保留发送及实时订阅保护。
+
+#### Scenario: 历史记录先完成
+- **WHEN** 用户打开尚未恢复的会话,历史记录已经读取完成但运行环境仍在恢复
+- **THEN** 加载反馈继续显示在会话主列中央,直到运行环境恢复完成
+- **THEN** 已加载的历史数据保持不变,用户仍可通过侧栏切换会话
+
+#### Scenario: 运行环境先完成
+- **WHEN** 会话运行环境已经恢复但历史记录仍在读取
+- **THEN** 内容遮罩继续在会话主列中央显示,直到历史记录读取完成
+
+#### Scenario: 任一阶段失败
+- **WHEN** 历史记录读取或可写会话的运行环境恢复失败
+- **THEN** 同一会话主列遮罩显示失败反馈,不能被另一阶段的加载状态覆盖
+
+#### Scenario: 输入区高度和面板变化
+- **WHEN** 输入区被问题卡片替换、输入区高度变化,或显示右侧预览面板
+- **THEN** 加载反馈仍以可见会话主列居中,不能以输入区、侧栏或右侧面板为中心
+
+#### Scenario: 只读外部会话
+- **WHEN** 用户查看只读外部会话
+- **THEN** 遮罩仅受历史记录加载或失败状态控制,不额外等待可写运行环境恢复
diff --git a/openspec/changes/fix-session-loading-overlay-position/tasks.md b/openspec/changes/fix-session-loading-overlay-position/tasks.md
new file mode 100644
index 000000000..b7cde9a9a
--- /dev/null
+++ b/openspec/changes/fix-session-loading-overlay-position/tasks.md
@@ -0,0 +1,8 @@
+## 1. 合并加载反馈
+
+- [x] 1.1 归并记录加载和运行环境恢复状态,将唯一遮罩挂到会话主列;用定向测试验证完成顺序、失败优先级、只读会话和 JSX 定位祖先。
+
+## 2. 集成验证
+
+- [x] 2.1 使用真实加载组件及现有样式在浏览器验证宽窄屏、中英文、侧面板、输入区高度变化和阶段切换时居中,记录验证结果。
+- [x] 2.2 通过 `pnpm test`、`pnpm build`、OpenSpec 严格校验和 `git diff --check`,核对已有无关改动保留并记录验证边界。
diff --git a/openspec/changes/fix-session-loading-overlay-position/verification.md b/openspec/changes/fix-session-loading-overlay-position/verification.md
new file mode 100644
index 000000000..d6ae8b8d8
--- /dev/null
+++ b/openspec/changes/fix-session-loading-overlay-position/verification.md
@@ -0,0 +1,12 @@
+# 会话加载遮罩定位修复验证
+
+日期:2026-09-19。
+
+- 定向验证通过:`sessionContentLoading.test.js`、`sidebarSessionLoadingArchitecture.test.js`、`sessionTranscriptRecovery.test.js`、`questionPickerLayout.test.js`、`composerEditabilityArchitecture.test.js` 和 `composerSessionControlsArchitecture.test.js`。
+- `pnpm test` 与 `pnpm build` 通过;生产构建的 4450 个正则字面量兼容性检查通过。
+- `openspec validate fix-session-loading-overlay-position --strict` 和 `git diff --check` 通过。
+- 修改前已有 44 个变动文件;43 个无关文件的 SHA-256 完全一致。对 `ChatView.jsx` 反向移除本次两个局部修改后,内容哈希与修改前相符,原有工作台改动完整保留。
+- 浏览器夹具直接使用 `SessionContentLoading.jsx`、从当前 `ChatView.jsx` 提取的实际加载组件 JSX、阶段归并函数和本次构建的 CSS。验证 1400px 与 390px 会话布局、中英文、亮暗主题、360px 右侧面板、100/300/330px 输入区高度。
+- 5 种布局分别覆盖 7 个阶段组合;常规与减少动画模式共 70 个组合通过。检查提示中心误差小于 1px、遮罩覆盖整个会话主列、输入区被保护、侧栏和右侧面板仍可点击、完成后移除遮罩及失败状态优先。
+- 浏览器报告和截图位于 `C:/Users/shao/AppData/Local/Temp/acecode-loading-position-8pX8N5/`;复现脚本为 `C:/Users/shao/AppData/Local/Temp/acecode-session-loading-position-check.cjs`。
+- 验证范围为当前源码、构建和真实组件的浏览器夹具;未连接实际会话提供商,也未重建或安装 Desktop 包。
diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/.openspec.yaml b/openspec/changes/fix-sidebar-refresh-request-ownership/.openspec.yaml
new file mode 100644
index 000000000..cbd245e43
--- /dev/null
+++ b/openspec/changes/fix-sidebar-refresh-request-ownership/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-20
diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/design.md b/openspec/changes/fix-sidebar-refresh-request-ownership/design.md
new file mode 100644
index 000000000..48449c020
--- /dev/null
+++ b/openspec/changes/fix-sidebar-refresh-request-ownership/design.md
@@ -0,0 +1,23 @@
+## Context
+
+见 proposal.md。现有每工作区代次已能拒绝直接返回的旧请求,但后台刷新在检查后仍等待无工作区列表,且后台摘要请求无条件推进代次。
+
+## Goals / Non-Goals
+
+**Goals:** 明确完整请求的共享生命周期、最终提交检查和加载状态清理资格。
+
+**Non-Goals:** 不调整置顶数据、排序、展开批次或后端分页协议,也不移入主工作区的其他未提交改动。
+
+## Decisions
+
+- 保存正在进行的全量请求及代次;后台刷新复用对应 Promise。单纯比较代次无法阻止后台摘要抢占全量请求,因此在发起后台请求前处理全量优先级。
+- 无工作区列表等待结束后,再按代次筛选所有工作区结果;筛选与提交之间不再等待异步操作。
+- 请求清理以本轮仍有效的请求集合为依据,而非本轮新增加载指示的集合。这样已有空工作区的激活状态也会正确结束。
+- 测试解析并执行实际 Sidebar 回调,仅模拟 React 状态和 API 边界,用延迟 Promise 构造竞争顺序;不另抄一份请求算法。
+- 合入 44e0d894 时,早发摘要先按缓存置顶数增加请求名额;本轮置顶数增加且原请求仍有效、仍有更多历史时补请求。已被较新全量加载替代的摘要不得再发补请求。保留主分支的历史追加、Map 批次展开和真实目录排序控制器。
+
+## Risks / Trade-offs
+
+- 共享请求失败影响本轮后台刷新 → 保留缓存,在 finally 按请求身份移除记录,并测试后续重试。
+- 用户连续发起两次全量请求 → 旧请求清理必须检查记录身份,不能删除较新请求。
+- 单测不挂载浏览器 UI → 断言真实调用路径更新的列表、总数、完整标记和加载集合,视觉布局保持原样。
diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/proposal.md b/openspec/changes/fix-sidebar-refresh-request-ownership/proposal.md
new file mode 100644
index 000000000..103485ff2
--- /dev/null
+++ b/openspec/changes/fix-sidebar-refresh-request-ownership/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+PR #64 提前发起侧栏会话请求后,摘要请求仍可能跨越后续等待覆盖较新的全量列表;定时刷新也可能抢走用户展开请求的提交资格。需要在合并前明确每个工作区请求的所有权,保留早发请求带来的响应速度。
+
+## What Changes
+
+- 用户全量加载进行中时,后台刷新复用同一请求,不发起会使它失效的摘要请求。
+- 所有异步等待完成后才检查请求代次,旧结果不能更改会话、总数或全量标记。
+- 只有当前请求能清理对应加载状态,包括重新激活已加载的空工作区。
+- 用真实 Sidebar 回调和可控异步完成顺序添加回归测试。
+
+## Capabilities
+
+### New Capabilities
+
+- `sidebar-refresh-request-ownership`: 侧栏并发列表请求的提交和加载状态所有权。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+仅影响 WebUI Sidebar 的工作区会话加载和定向 JS 测试;不修改 API、置顶规则、会话排序或展开展示方式。
diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/specs/sidebar-refresh-request-ownership/spec.md b/openspec/changes/fix-sidebar-refresh-request-ownership/specs/sidebar-refresh-request-ownership/spec.md
new file mode 100644
index 000000000..60a8780df
--- /dev/null
+++ b/openspec/changes/fix-sidebar-refresh-request-ownership/specs/sidebar-refresh-request-ownership/spec.md
@@ -0,0 +1,37 @@
+## Purpose
+
+确保侧栏在用户展开会话列表、定时刷新和工作区切换交错时,始终保留最新有效的完整列表。请求结束后正确退出加载状态,同时保留摘要请求与辅助请求并行发起的响应速度。
+
+## ADDED Requirements
+
+### Requirement: 最新结果拥有提交资格
+
+侧栏 SHALL 在本轮所有等待结束后验证工作区请求是否仍有效;过时结果不得修改会话列表、总数、完整加载标记或较新请求的加载状态。
+
+#### Scenario: 摘要已返回但无工作区请求仍在等待
+
+- **WHEN** 摘要结果返回后仍在等待无工作区列表,用户的全量加载随后完成
+- **THEN** 后续摘要刷新完成时保留全量列表、总数和完整加载标记
+
+### Requirement: 后台刷新保留正在进行的全量加载
+
+侧栏 SHALL 复用同一工作区正在进行的全量加载,不用后台摘要请求使用户的全量加载失效;失败请求结束后允许后续请求正常重试。
+
+#### Scenario: 用户展开时遇到定时刷新
+
+- **WHEN** 用户已请求完整会话列表且结果尚未返回,定时刷新开始
+- **THEN** 不新增摘要请求,完整结果返回后两条调用路径均保留完整列表
+
+#### Scenario: 共享全量请求失败
+
+- **WHEN** 用户全量请求被后台刷新复用但请求失败
+- **THEN** 已有列表保持不变,后续全量加载可以重新请求并成功显示
+
+### Requirement: 当前请求负责结束加载状态
+
+侧栏 SHALL 在当前工作区请求成功或失败结束后清理对应的显式加载状态;过时请求结束不得清理较新请求的加载状态。
+
+#### Scenario: 重新激活已加载的空工作区
+
+- **WHEN** 用户重新激活已成功加载过且没有会话的工作区,本轮请求结束
+- **THEN** 工作区退出加载状态并展示空列表状态
diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/tasks.md b/openspec/changes/fix-sidebar-refresh-request-ownership/tasks.md
new file mode 100644
index 000000000..0484d2cb4
--- /dev/null
+++ b/openspec/changes/fix-sidebar-refresh-request-ownership/tasks.md
@@ -0,0 +1,14 @@
+## 1. 请求所有权修复
+
+- [x] 1.1 添加真实 Sidebar 回调时序回归,并确认原 PR 在旧摘要覆盖、全量请求被抢占、空工作区激活清理场景失败。
+- [x] 1.2 实现全量请求共享与身份清理,验证定时刷新不抢占、失败可重试及连续全量请求的清理隔离。
+- [x] 1.3 在全部等待之后筛选有效结果并按当前请求清理加载状态,验证列表、总数、完整标记及空工作区状态。
+
+## 2. 验证与交付
+
+- [x] 2.1 将新回归加入 JS 测试入口,运行相关定向测试、严格 OpenSpec 校验和 git diff --check,确认只提交本次修复文件。
+
+## 3. 合入最新主分支
+
+- [x] 3.1 合入 44e0d894,保留置顶补足五条、历史追加、分批展开和目录排序控制器,并用真实回调验证早发摘要后的置顶补请求不会抢占全量加载。
+- [x] 3.2 更新测试 fixture 使用真实目录排序控制器,运行隔离工作区完整 pnpm test、OpenSpec 严格校验和 diff --check,再提交 merge commit。
diff --git a/openspec/changes/fix-turn-file-open-session-root/.openspec.yaml b/openspec/changes/fix-turn-file-open-session-root/.openspec.yaml
new file mode 100644
index 000000000..75289e4b7
--- /dev/null
+++ b/openspec/changes/fix-turn-file-open-session-root/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-24
diff --git a/openspec/changes/fix-turn-file-open-session-root/design.md b/openspec/changes/fix-turn-file-open-session-root/design.md
new file mode 100644
index 000000000..db92e918c
--- /dev/null
+++ b/openspec/changes/fix-turn-file-open-session-root/design.md
@@ -0,0 +1,26 @@
+## Context
+
+会话 `20260924-072030-b1fe` 的元数据标记为 `no_workspace=true`,工作目录为 `C:/Users/shao/.acecode/cache/no-workspace/20260924-072030-b1fe`。轮次净差异使用 `pelican-bicycle.html` 等相对路径。日志显示该会话结束后被工作区 `f1ee51683484c65b` 的恢复接口两次报告成功,但该工作区存储中没有该会话元数据。`SessionRegistry::resume` 对已运行的 ID 直接返回成功,工作区路由未检查元数据是否存在,响应于是带回错误的工作区目录。`ChatView` 的文件预览使用导航 ref 的工作目录,差异预览只读会话变更记录。
+
+## Goals / Non-Goals
+
+**Goals:** 工作区路由验证会话归属;导航遇到错误工作区提示时恢复无工作区会话的真实目录;覆盖该相对文件打开场景。
+
+**Non-Goals:** 改变轮次差异格式、文件内容 API、列表布局或历史文件已删除时的预览错误语义。
+
+## Decisions
+
+- 工作区恢复路由先验证所选工作区内的会话元数据存在、不是无工作区会话,且同 ID 的运行中会话没有归属其他工作区,再调用会话客户端。不能把 `resume_session` 的成功值当归属证明,因为已运行 ID 会立即返回成功。
+- 桌面导航在工作区恢复返回 404 时尝试兼容恢复接口,仅当它明确返回 `no_workspace=true` 才接受并用返回的 `working_cwd` 生成会话 ref;其他失败保留原错误。正常工作区恢复仍只发原请求。
+- 跨工作区整页跳转的 URL 使用恢复后的规范会话 ref,避免把陈旧的工作区 hash 再写回页面。
+
+## Risks / Trade-offs
+
+- [旧 daemon 仍会错误地返回工作区恢复成功] → 修复需要前后端一起构建部署;回归测试验证新服务端的拒绝行为。
+- [404 也可能表示真正不存在的会话] → 仅在兼容接口明确证实无工作区归属时回退,否则保持 404。
+
+## Validation
+
+- `node web/src/lib/sessionJump.test.js`、`pnpm test`、`pnpm build` 通过。
+- `cmake --build build --config Release --target acecode_unit_tests` 通过;定向运行无工作区会话及正常工作区恢复的两个 Web smoke test,均通过。
+- `openspec validate fix-turn-file-open-session-root --strict` 和 `git diff --check` 通过。
diff --git a/openspec/changes/fix-turn-file-open-session-root/proposal.md b/openspec/changes/fix-turn-file-open-session-root/proposal.md
new file mode 100644
index 000000000..47ac98793
--- /dev/null
+++ b/openspec/changes/fix-turn-file-open-session-root/proposal.md
@@ -0,0 +1,23 @@
+## Why
+
+无工作区会话生成的文件保存在自己的缓存工作目录,但错误的工作区恢复请求可能把同一会话标记为其他工作区。此时“查看变更”仍能读取记录,“打开”却从错误目录读取文件并报不存在。
+
+## What Changes
+
+- 工作区范围内的恢复接口只接受确实属于该工作区的会话;已在内存中运行的其他会话也不能被误认领。
+- 桌面端收到错误工作区归属的会话跳转时,若会话实际属于无工作区,则恢复真实归属和工作目录。
+- 文件列表的“打开”继续使用会话真实工作目录;“查看变更”保留原有轮次差异行为。
+
+## Capabilities
+
+### New Capabilities
+
+无。
+
+### Modified Capabilities
+
+- `shared-daemon-workspaces`:恢复与跳转必须保持会话真实的工作区归属及工作目录。
+
+## Impact
+
+影响工作区会话恢复路由、桌面/Web 会话跳转及对应回归测试;不改变文件内容接口或列表外观。
diff --git a/openspec/changes/fix-turn-file-open-session-root/specs/shared-daemon-workspaces/spec.md b/openspec/changes/fix-turn-file-open-session-root/specs/shared-daemon-workspaces/spec.md
new file mode 100644
index 000000000..bc21b287e
--- /dev/null
+++ b/openspec/changes/fix-turn-file-open-session-root/specs/shared-daemon-workspaces/spec.md
@@ -0,0 +1,30 @@
+## MODIFIED Requirements
+
+### Requirement: Workspace-aware session lifecycle
+守护进程 SHALL 使用明确的工作区标识创建、列出、恢复和销毁会话。工作区范围内的恢复请求仅在会话确实属于该工作区时 SHALL 成功。桌面端收到陈旧的工作区标识、但目标实际为无工作区会话时 SHALL 保留会话真实归属及工作目录。
+
+#### Scenario: Create session in selected workspace
+- **WHEN** 客户端在工作区 `A` 创建会话
+- **THEN** 守护进程 MUST 使用工作区 `A` 的工作目录作为会话执行目录
+- **THEN** 响应 MUST 包含会话 ID 和工作区 hash
+
+#### Scenario: List sessions for selected workspace
+- **WHEN** 客户端列出工作区 `A` 的会话
+- **THEN** 守护进程 MUST 返回存储在工作区 `A` 的运行中及历史会话
+- **THEN** 其他工作区的会话 MUST NOT 出现在该响应中
+
+#### Scenario: Resume persisted session in selected workspace
+- **WHEN** 客户端在工作区 `A` 恢复历史会话 `S`
+- **THEN** 守护进程 MUST 从工作区 `A` 的项目存储恢复 `S`
+- **THEN** 该操作 MUST NOT 启动额外的守护进程
+
+#### Scenario: 工作区接口拒绝运行中的无工作区会话
+- **WHEN** 客户端通过工作区 `A` 的接口恢复运行中的无工作区会话 `S`
+- **THEN** 工作区范围内的请求 MUST 返回“会话不存在”
+- **AND** `S` MUST 保留无工作区归属和原工作目录
+
+#### Scenario: 修正桌面端陈旧的工作区提示
+- **WHEN** 桌面端带陈旧的工作区 hash 跳转到无工作区会话
+- **THEN** 界面 MUST 使用该会话真实的无工作区归属和工作目录
+- **AND** 轮次“已修改文件”列表中的相对文件路径 MUST 从该目录打开
+- **AND** “查看变更” MUST 继续展示该轮记录的差异
diff --git a/openspec/changes/fix-turn-file-open-session-root/tasks.md b/openspec/changes/fix-turn-file-open-session-root/tasks.md
new file mode 100644
index 000000000..b7466fa76
--- /dev/null
+++ b/openspec/changes/fix-turn-file-open-session-root/tasks.md
@@ -0,0 +1,11 @@
+## 1. 校正会话归属
+
+- [x] 1.1 工作区恢复接口拒绝元数据缺失及无工作区会话;扩展 Web smoke test,验证已运行的无工作区会话也返回 404 且原目录不变。
+
+## 2. 修正桌面跳转
+
+- [x] 2.1 错误工作区提示返回 404 时仅对真实无工作区会话回退恢复,并以规范 ref 构造跳转 URL;通过定向 JS 测试验证 `pelican-bicycle.html` 的预览根目录和其他错误分支。
+
+## 3. 集成检查
+
+- [x] 3.1 运行 Web 测试及构建、可用的定向 C++ smoke test、OpenSpec 严格验证和 `git diff --check`,复查变更范围与结果。
diff --git a/openspec/changes/fix-uos-desktop-fractional-scaling/.openspec.yaml b/openspec/changes/fix-uos-desktop-fractional-scaling/.openspec.yaml
new file mode 100644
index 000000000..abd7c5ae1
--- /dev/null
+++ b/openspec/changes/fix-uos-desktop-fractional-scaling/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-25
diff --git a/openspec/changes/fix-uos-desktop-fractional-scaling/design.md b/openspec/changes/fix-uos-desktop-fractional-scaling/design.md
new file mode 100644
index 000000000..c7bdaa6b1
--- /dev/null
+++ b/openspec/changes/fix-uos-desktop-fractional-scaling/design.md
@@ -0,0 +1,26 @@
+## Context
+
+The UOS session reports `gtk-xft-dpi=122880` (120 DPI in GTK's 1024 units) and GTK window scale 1. Deepin's `scale-factor` preference was initially 1.25 and later 1.0 while the effective font DPI remained 120. WebKitGTK 2.38.5 enlarges CSS text with font DPI while leaving CSS pixel dimensions at window scale 1. ACECode uses fixed CSS pixel dimensions throughout the Desktop page. Both a local probe and the real Desktop window confirm that normalizing this process's GTK font DPI to 96 and applying a 1.25 whole-page zoom makes text and control geometry scale together.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Correct UOS/Deepin X11 Desktop WebView proportions at fractional scale without touching the user's stored display settings.
+- Preserve 100% and non-Deepin behavior and avoid double scaling when GTK supplies an integer window scale.
+- Update the correction when UOS or GTK scale inputs change while the window is open.
+
+**Non-Goals:**
+- Change font preferences within ACECode or apply custom zoom to arbitrary web pages in the agent browser.
+- Replace the system WebKitGTK runtime or change system-wide UOS scaling settings.
+
+## Decisions
+
+1. Read the native `gtk-xft-dpi` with `gdk_screen_get_setting` and derive the effective scale from DPI / 96. The native value remains available beneath the process-local GtkSettings override. Do not gate on the Deepin `scale-factor` preference: the reproduced 1.0/120 DPI combination otherwise silently disables the fix. Only enable the workaround in a Deepin X11 session; absent or invalid native DPI retains WebKit defaults.
+2. Set this process's GTK font DPI to 96 DPI and set the main WebKitWebView's whole-page zoom to `effective_scale / gtk_widget_scale_factor`. This aligns fonts and CSS geometry; simply zooming the page would enlarge already-scaled text a second time. The change stays inside ACECode's Desktop process; it does not write GSettings or X resources. Since GTK font DPI is process-wide, apply a local CSS font-size percentage to ACECode's native tray menu labels to preserve their original text size.
+3. Observe native XSettings property/manager events, GTK font DPI, and widget scale changes. Defer native event reads to an idle callback so GDK can update its cache first. GtkSettings property notifications alone cannot observe external updates once an application override is active. Guard the process-local write against recursion, remove the pending callback and handlers on teardown, and use `gtk_settings_reset_property` to release the override. A pure policy helper covers normal, fractional, integer, and invalid DPI inputs.
+
+## Risks / Trade-offs
+
+- [GTK's font DPI is per process, not per WebView] → Limit the override to UOS/Deepin X11, release the override on teardown, and compensate the tray menu labels with local CSS. A GTK probe checks their rendered font metrics at 125%.
+- [UOS can retain a stale display preference] → Read the native value that GTK actually consumes, independently of the display preference and process-local override.
+- [WebKitGTK versions can interpret font DPI differently] → Verify the installed 2.38.5 runtime with actual Desktop screenshots. Dynamic native DPI changes are checked on an isolated X server so the user's display settings are not changed.
diff --git a/openspec/changes/fix-uos-desktop-fractional-scaling/proposal.md b/openspec/changes/fix-uos-desktop-fractional-scaling/proposal.md
new file mode 100644
index 000000000..3e9b9560a
--- /dev/null
+++ b/openspec/changes/fix-uos-desktop-fractional-scaling/proposal.md
@@ -0,0 +1,23 @@
+## Why
+
+On UOS/Deepin at fractional display scales, GTK reports a scaled text DPI while WebKitGTK still lays out CSS pixel dimensions at the unscaled window scale. ACECode Desktop then shows text larger than its controls and spacing, and some labels no longer fit. This is reproducible on the current 125% UOS session with WebKitGTK 2.38.5.
+
+## What Changes
+
+- Make the Linux Desktop WebView use one effective scale for text and the rest of the ACECode page when UOS/Deepin supplies fractional display scaling through its text DPI setting.
+- Keep the adjustment local to the Desktop process and WebView. Leave other Linux desktops, browser UI, Windows, and macOS behavior as they are.
+- Respond to UOS scale changes while the Desktop window remains open, and verify 100%, fractional, and integer scale cases.
+
+## Capabilities
+
+### New Capabilities
+
+- `linux-desktop-display-scaling`: Consistent Desktop WebView scaling on UOS/Deepin and safe behavior on other Linux environments.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+Linux Desktop WebView initialization and scale updates in `src/desktop/`, focused tests in `tests/desktop/`, and local development validation on UOS. No daemon protocol or user setting changes.
diff --git a/openspec/changes/fix-uos-desktop-fractional-scaling/specs/linux-desktop-display-scaling/spec.md b/openspec/changes/fix-uos-desktop-fractional-scaling/specs/linux-desktop-display-scaling/spec.md
new file mode 100644
index 000000000..24de3f0ed
--- /dev/null
+++ b/openspec/changes/fix-uos-desktop-fractional-scaling/specs/linux-desktop-display-scaling/spec.md
@@ -0,0 +1,46 @@
+## Purpose
+
+Keeps ACECode Desktop text, controls, and spacing in proportion when a Linux desktop environment represents display scaling through font DPI and window scaling separately.
+
+## ADDED Requirements
+
+### Requirement: Consistent UOS Desktop content scaling
+On UOS/Deepin X11, when native GTK text DPI represents a scale above 100%, ACECode Desktop SHALL render text and non-text WebView content at the same effective scale.
+
+#### Scenario: Fractional display scale
+- **WHEN** UOS/Deepin uses a 125% or 150% display scale with an unscaled GTK window
+- **THEN** text, controls, icons, and CSS pixel spacing in the Desktop page grow by the same scale factor
+
+#### Scenario: Integer window scale
+- **WHEN** UOS/Deepin uses an integer GTK window scale that already accounts for part or all of the display scale
+- **THEN** ACECode applies only the remaining page scale and avoids scaling the page twice
+
+### Requirement: Safe scale boundaries
+ACECode Desktop SHALL leave the WebView at its normal scale when native GTK DPI is 96, the native DPI signal is invalid or unavailable, or the desktop session is outside the UOS/Deepin X11 workaround.
+
+#### Scenario: Normal 100% scale
+- **WHEN** the effective native font DPI is 96 (100%)
+- **THEN** ACECode does not add page zoom or override text DPI
+
+#### Scenario: Other Linux desktop
+- **WHEN** ACECode runs outside a UOS/Deepin X11 session
+- **THEN** the Desktop WebView retains its existing GTK and WebKit scaling behavior
+
+#### Scenario: Stale Deepin preference
+- **WHEN** Deepin's scale-factor preference is 1.0 but native GTK font DPI remains 120
+- **THEN** ACECode applies a matching 125% scale to text and control geometry
+
+#### Scenario: Native tray menu
+- **WHEN** the UOS/Deepin WebView correction normalizes GTK font DPI
+- **THEN** ACECode's native tray menu labels retain the text size supplied by the original display scale
+
+### Requirement: Scale changes during a Desktop session
+ACECode Desktop SHALL update its WebView scale when the UOS/Deepin display scale changes while the window is open, without changing system-wide display settings.
+
+#### Scenario: Fractional scale changes while open
+- **WHEN** native GTK DPI changes from 120 to 144 while ACECode Desktop is open and the process-local GTK DPI is already normalized
+- **THEN** its text and non-text content update to the new matching scale
+
+#### Scenario: Return to 100%
+- **WHEN** native GTK DPI returns to 96 while ACECode Desktop is open
+- **THEN** its WebView returns to normal page zoom and text sizing
diff --git a/openspec/changes/fix-uos-desktop-fractional-scaling/tasks.md b/openspec/changes/fix-uos-desktop-fractional-scaling/tasks.md
new file mode 100644
index 000000000..d667e43bc
--- /dev/null
+++ b/openspec/changes/fix-uos-desktop-fractional-scaling/tasks.md
@@ -0,0 +1,21 @@
+## 1. Scaling policy
+
+- [x] 1.1 Add a pure UOS/GTK WebView scale policy with tests for 100%, 125%, 150%, integer GTK scale, invalid values, and unrelated text DPI; verify the focused tests pass.
+
+## 2. Linux Desktop integration
+
+- [x] 2.1 Apply the policy to the main Linux WebView before navigation, retaining normal behavior when the UOS schema is absent; verify an incremental Desktop build succeeds.
+- [x] 2.2 Observe UOS scale and GTK DPI/widget-scale changes, update the correction without recursion, and release signal handlers on teardown; verify with a focused runtime check and build.
+- [x] 2.3 Preserve native GTK tray menu label size after the process DPI normalization; verify the 125% font metrics with a GTK probe and rebuild Desktop.
+
+## 3. Validation
+
+- [x] 3.1 Validate OpenSpec, run focused tests, build Desktop, and check the 125% UOS window, its daemon connection, and native tray menu font metrics; verify `git diff --check` and review the final diff.
+
+## 4. Correct the inactive workaround on the user's session
+
+- [x] 4.1 Use the effective native XSettings font DPI instead of requiring the Deepin scale-factor key to agree; cover 120 DPI with a stale 1.0 display setting.
+- [x] 4.2 Observe native XSettings changes even while GtkSettings has an application DPI override, and restore normal settings on teardown.
+- [x] 4.3 Rebuild and launch the actual Desktop, capture its window, and compare text and control proportions against the failing screenshot.
+
+Follow-up validation: the real UOS window logged native DPI 120 and page zoom 1.25; home and Settings screenshots were inspected. The same Desktop binary on an isolated X server followed native DPI 120 -> 144 -> 96 -> 120 without restarting. Screenshots measured the sidebar at approximately 338, 405, 270, and 338 pixels respectively. The five focused policy tests passed. The earlier full-suite build limitation under GCC 8 remains unrelated to this correction.
diff --git a/openspec/changes/fix-windows-private-file-replacement/.openspec.yaml b/openspec/changes/fix-windows-private-file-replacement/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/fix-windows-private-file-replacement/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/fix-windows-private-file-replacement/design.md b/openspec/changes/fix-windows-private-file-replacement/design.md
new file mode 100644
index 000000000..af4686c56
--- /dev/null
+++ b/openspec/changes/fix-windows-private-file-replacement/design.md
@@ -0,0 +1,19 @@
+## Context
+
+现有文件 ACL 只有 GENERIC_READ 和 GENERIC_WRITE。Windows 重命名需要文件自身 DELETE 或父目录 FILE_DELETE_CHILD。发布验证使用仅有修改权限的目录,稳定复现写入返回 false;调试器确认异常来自 MCP 快照保存。
+
+## Goals / Non-Goals
+
+目标是保证私有文件在普通可修改目录下完成首次写入及后续原子替换,并维持仅当前用户访问。此次不改变并发串行化、临时文件命名及 POSIX 权限约定。
+
+## Decisions
+
+在当前用户的单一允许 ACE 中增加 DELETE。文件仍使用受保护 DACL,不继承其他主体的权限。测试给专属临时父目录设置排除 FILE_DELETE_CHILD 的权限,验证真实文件系统替换与文件 DACL。
+
+## Risks / Trade-offs
+
+当前用户获得删除自己的配置文件的权限,这是现有原子替换约定所需的最小补充。测试只修改自己的临时目录权限,并在析构时恢复后清理。
+
+## Validation
+
+先验证新回归在旧实现失败,再运行修复后的定向及完整原生测试、真实 MCP 守护进程检查和 Desktop/CLI 构建。
diff --git a/openspec/changes/fix-windows-private-file-replacement/proposal.md b/openspec/changes/fix-windows-private-file-replacement/proposal.md
new file mode 100644
index 000000000..ddf0cc145
--- /dev/null
+++ b/openspec/changes/fix-windows-private-file-replacement/proposal.md
@@ -0,0 +1,20 @@
+## Why
+
+正式发布前的真实守护进程验证发现:Windows 原子写入为敏感文件设置仅当前用户可读写的 ACL,但遗漏 DELETE 权限。在父目录允许修改却不授予 FILE_DELETE_CHILD 时,临时文件无法重命名,MCP 配置快照保存失败并阻止启动。
+
+## What Changes
+
+- 为当前用户保留敏感文件原子替换所需的 DELETE 权限,不向其他用户开放访问。
+- 添加父目录不授予 FILE_DELETE_CHILD 时的首次写入和重复替换回归。
+- 重新验证 CLI、Desktop、配置迁移与 MCP 真实启动。
+
+## Capabilities
+
+### New Capabilities
+- `windows-private-file-replacement`: Windows 私有文件在可修改目录中可可靠原子替换。
+
+### Modified Capabilities
+
+## Impact
+
+影响共享 `atomic_write_file` 的 Windows 限制权限分支及其测试;非 Windows 权限行为保持原有规则。
diff --git a/openspec/changes/fix-windows-private-file-replacement/specs/windows-private-file-replacement/spec.md b/openspec/changes/fix-windows-private-file-replacement/specs/windows-private-file-replacement/spec.md
new file mode 100644
index 000000000..c0b436415
--- /dev/null
+++ b/openspec/changes/fix-windows-private-file-replacement/specs/windows-private-file-replacement/spec.md
@@ -0,0 +1,10 @@
+## ADDED Requirements
+
+### Requirement: 私有文件支持原子替换
+
+Windows 上限制访问权限的原子文件写入 SHALL 允许当前用户在可修改父目录中完成写入与替换,且不向其他主体增加访问权限。
+
+#### Scenario: 父目录不授予删除子项权限
+- **WHEN** 当前用户可以创建文件,但父目录未授予 FILE_DELETE_CHILD
+- **THEN** 首次私有写入和后续私有替换均成功,读取结果为最近写入的完整内容
+- **AND** 文件受保护 DACL 仅允许当前用户读取、写入和删除
diff --git a/openspec/changes/fix-windows-private-file-replacement/tasks.md b/openspec/changes/fix-windows-private-file-replacement/tasks.md
new file mode 100644
index 000000000..4465f45db
--- /dev/null
+++ b/openspec/changes/fix-windows-private-file-replacement/tasks.md
@@ -0,0 +1,13 @@
+## 1. 修复与验证
+
+- [x] 1.1 添加受限父目录的真实 Windows 文件系统回归,确认旧实现失败。
+- [x] 1.2 为当前用户补齐文件 DELETE 权限,运行回归通过。
+- [x] 1.3 运行原生测试、MCP 守护进程检查和 CLI/Desktop 构建,严格校验变更。
+
+## 验证记录
+
+- 新增回归在旧实现的首次私有写入处失败,修复后通过;确认受保护 DACL 仅保留当前用户并含 DELETE。
+- MinSizeRel CLI、Desktop、原生测试构建通过。重发、插话、MCP、配置迁移和权限定向测试 31 项通过。
+- Windows 完整原生检查共 4,637 项通过、8 项按平台或显式网络/通知开关跳过,另有 1 项原有禁用测试。隔离测试使用短临时目录规避 Windows MAX_PATH;专门检查系统 TEMP ACL 的用例单独使用真实 TEMP 通过。
+- 真实守护进程验证鉴权、两项目独立 MCP 运行时、非法写入拒绝、项目/全局恢复、会话折叠设置重启持久化和 headless 配置边界全部通过。
+- `openspec validate fix-windows-private-file-replacement --strict` 和 `git diff --check` 通过。
diff --git a/openspec/changes/fix-worktree-session-file-preview-root/design.md b/openspec/changes/fix-worktree-session-file-preview-root/design.md
new file mode 100644
index 000000000..f4e962c22
--- /dev/null
+++ b/openspec/changes/fix-worktree-session-file-preview-root/design.md
@@ -0,0 +1,15 @@
+## Context
+
+会话元数据的 `cwd` 是项目存储及工作区归属目录;worktree 状态另存于 `worktree_session.worktree_path`。恢复时 AgentLoop 切入 worktree,但多处 Web 响应仍返回主工作区 `cwd`。`ChatView` 已能优先使用 `ref.worktree.path`,问题在于侧栏与跳转构造 ref 时漏传该字段。
+
+## Decisions
+
+- `cwd` 保持工作区归属语义;`working_cwd` 表示当前执行目录,优先使用有效 worktree 路径。会话存储路径继续从原 `cwd` 计算。
+- 列表和搜索结果携带 worktree 元数据;恢复响应返回当前 worktree 状态,明确用 `null` 表示已退出或恢复时清除的状态,以覆盖可能过期的侧栏记录。
+- 导航 ref 传递 worktree,并让恢复响应优先于旧列表记录。这样相对路径预览和工作树标签使用同一会话状态。
+
+## Validation
+
+- Web 路由测试验证工作树会话的列表、恢复与文件内容接口使用 worktree 目录。
+- JavaScript 测试验证跳转 ref 的 worktree 优先级及相对文件路径。
+- 运行 Web 测试、构建、定向 C++ 测试及 OpenSpec 严格验证。
diff --git a/openspec/changes/fix-worktree-session-file-preview-root/proposal.md b/openspec/changes/fix-worktree-session-file-preview-root/proposal.md
new file mode 100644
index 000000000..ee3875100
--- /dev/null
+++ b/openspec/changes/fix-worktree-session-file-preview-root/proposal.md
@@ -0,0 +1,19 @@
+## Why
+
+工作树会话把生成文件写入独立的 worktree,但会话列表和恢复响应仍把 `working_cwd` 指向原工作区。侧栏及跨工作区导航还会丢失 `worktree` 信息,因此轮次文件列表的“打开”把相对路径拼到原工作区,报文件不存在。
+
+## What Changes
+
+- 会话列表、全局搜索和恢复响应使用会话实际执行目录作为 `working_cwd`,保留 `cwd` 作为工作区归属目录。
+- 导航目标保留已生效的 worktree 信息;恢复后的状态以服务端当前会话状态为准。
+- 验证运行中、历史及重新恢复的工作树会话都能从正确目录打开相对文件。
+
+## Capabilities
+
+### Modified Capabilities
+
+- `shared-daemon-workspaces`:工作树会话的文件预览目录必须与实际执行目录一致。
+
+## Impact
+
+影响 Web 会话序列化、恢复响应、前端导航目标及相关测试;不修改“查看变更”的轮次差异行为。
diff --git a/openspec/changes/fix-worktree-session-file-preview-root/specs/shared-daemon-workspaces/spec.md b/openspec/changes/fix-worktree-session-file-preview-root/specs/shared-daemon-workspaces/spec.md
new file mode 100644
index 000000000..65807c6a2
--- /dev/null
+++ b/openspec/changes/fix-worktree-session-file-preview-root/specs/shared-daemon-workspaces/spec.md
@@ -0,0 +1,29 @@
+## MODIFIED Requirements
+
+### Requirement: Workspace-aware session lifecycle
+守护进程 SHALL 使用明确的工作区标识创建、列出、恢复和销毁会话。会话的 `cwd` SHALL 表示工作区归属目录;`working_cwd` SHALL 表示实际执行目录,进入工作树后为 worktree 路径。工作区范围内的恢复请求仅在会话确实属于该工作区时 SHALL 成功。
+
+#### Scenario: Create session in selected workspace
+- **WHEN** 客户端在工作区 `A` 创建会话
+- **THEN** 守护进程 MUST 使用工作区 `A` 的工作目录作为会话执行目录
+- **THEN** 响应 MUST 包含会话 ID 和工作区 hash
+
+#### Scenario: List sessions for selected workspace
+- **WHEN** 客户端列出工作区 `A` 的会话
+- **THEN** 守护进程 MUST 返回存储在工作区 `A` 的运行中及历史会话
+- **THEN** 其他工作区的会话 MUST NOT 出现在该响应中
+
+#### Scenario: Resume persisted session in selected workspace
+- **WHEN** 客户端在工作区 `A` 恢复历史会话 `S`
+- **THEN** 守护进程 MUST 从工作区 `A` 的项目存储恢复 `S`
+- **THEN** 该操作 MUST NOT 启动额外的守护进程
+
+#### Scenario: 工作树会话打开轮次文件
+- **WHEN** 工作树会话在 worktree 中生成文件,用户通过侧栏或会话跳转重新进入并点击轮次文件列表的“打开”
+- **THEN** 界面 MUST 从该会话的 worktree 目录解析相对路径
+- **AND** “查看变更” MUST 继续展示该轮记录的差异
+
+#### Scenario: 恢复历史工作树会话
+- **WHEN** 客户端恢复 worktree 目录仍存在的历史会话
+- **THEN** 响应 MUST 保留原工作区归属目录,并返回 worktree 路径作为实际执行目录
+- **AND** 若恢复时发现 worktree 已不存在,响应 MUST 清除过期的 worktree 状态
diff --git a/openspec/changes/fix-worktree-session-file-preview-root/tasks.md b/openspec/changes/fix-worktree-session-file-preview-root/tasks.md
new file mode 100644
index 000000000..77df11d07
--- /dev/null
+++ b/openspec/changes/fix-worktree-session-file-preview-root/tasks.md
@@ -0,0 +1,11 @@
+## 1. 修正服务端目录
+
+- [x] 1.1 列表、全局搜索及恢复响应区分工作区归属目录与 worktree 执行目录,并覆盖运行中及历史会话。
+
+## 2. 修正导航
+
+- [x] 2.1 侧栏和会话跳转保留服务端 worktree 状态,使轮次文件列表的相对路径从实际目录打开。
+
+## 3. 验证
+
+- [x] 3.1 添加回归测试,运行相关 Web/C++ 测试、Web 构建、OpenSpec 严格验证及差异检查。
diff --git a/openspec/changes/globalize-thread-discovery/.openspec.yaml b/openspec/changes/globalize-thread-discovery/.openspec.yaml
new file mode 100644
index 000000000..f2cbbe6a6
--- /dev/null
+++ b/openspec/changes/globalize-thread-discovery/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-18
diff --git a/openspec/changes/globalize-thread-discovery/design.md b/openspec/changes/globalize-thread-discovery/design.md
new file mode 100644
index 000000000..52b5d67c9
--- /dev/null
+++ b/openspec/changes/globalize-thread-discovery/design.md
@@ -0,0 +1,22 @@
+## Context
+
+`ThreadService::list` 当前仅扫描调用方项目,公共解析函数也按调用方 cwd 过滤活跃会话和磁盘目标。现有全局会话目录已处理隐藏项目、未注册项目和无工作区会话,但默认排除归档及子代理,会话工具原本允许列出子代理。
+
+## Goals / Non-Goals
+
+目标是复用已有目录合并与元数据读取能力,使查询工具可以访问完整 ACECode 数据范围,并保持结果有界。此次不扩大创建、续聊、重命名、置顶、归档、删除及修复的操作范围,不改动界面搜索行为。
+
+## Decisions
+
+1. 全局目录增加默认关闭的归档、子代理包含选项。工具列举包含子代理,并按 `includeArchived` 决定归档过滤;现有界面调用继续使用默认选项。
+2. `list_threads` 保留全部置顶结果,每个项目内部按已持久化 pin 顺序排列,项目之间按 hash 排序。普通会话按更新时间排序,`limit` 仍限制每页条数,通过 `cursor` / `nextCursor` 续读。工作区作为 `workspaceHash`、`cwd`、`noWorkspace` 等返回属性。
+3. 为读取及等待提供独立的全局只读定位入口,按目标 ID 读取精确元数据路径,不为单个目标重读全库 transcript。可选 `workspaceHash` 直接定位存储项目,消除跨项目同 ID 歧义。调用方及活跃目标复用已有 manager,不创建 writer。
+4. 保留现有写工具的当前工作区解析函数,避免改变与本次会话查找无关的操作语义。
+5. 测试使用临时用户数据目录,覆盖跨项目持久化与活跃会话、空调用 cwd、归档、子代理、无工作区、置顶分页及歧义定位,避免读取真实用户会话。
+
+## Risks / Trade-offs
+
+- 全局列举会访问更多元数据 → 复用有界并发目录扫描,不读取 transcript 回填摘要;单目标查询有 workspaceHash 时直接定位。
+- 列表翻页期间会话发生变化 → cursor 表示当前排序结果的偏移,保持与现有 turn 分页一致的简单语义,不承诺跨调用冻结快照。
+- 扫描部分项目失败 → 返回错误摘要,不把不完整扫描伪装成完整空结果。
+- 同 ID 误读 → 未明确 workspaceHash 且磁盘存在多个匹配时返回歧义错误。
diff --git a/openspec/changes/globalize-thread-discovery/proposal.md b/openspec/changes/globalize-thread-discovery/proposal.md
new file mode 100644
index 000000000..f3de26271
--- /dev/null
+++ b/openspec/changes/globalize-thread-discovery/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+内置 `list_threads` 的描述与实现都把当前工作区当作会话边界,`read_thread` 和 `wait_threads` 也因此无法访问其他工作区的会话。用户需要查找当前 ACECode 数据目录中的全部会话,工作区只是会话属性。
+
+## What Changes
+
+- 会话列举覆盖全部项目目录和活跃会话,包括隐藏、未注册及无工作区会话,并返回工作区属性。
+- 增加列表续页和包含归档参数,保持普通列表的默认归档过滤与置顶规则。
+- 读取和等待按全局会话定位;同 ID 分属多个项目时支持用返回的 `workspaceHash` 明确选择。
+- 同步模型工具描述、协议文档及跨工作区回归测试。
+
+## Capabilities
+
+### New Capabilities
+
+- `codex-thread-tools`:补充已有未归档变更中的会话工具契约,明确全局列举、读取和等待的范围。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+涉及 `ThreadService`、全局会话目录、内置工具 schema、C++ 测试及 `docs/daemon-api.md`。创建和修改类工具保持原有语义;无需修改 WebUI 或迁移会话文件。
diff --git a/openspec/changes/globalize-thread-discovery/specs/codex-thread-tools/spec.md b/openspec/changes/globalize-thread-discovery/specs/codex-thread-tools/spec.md
new file mode 100644
index 000000000..eada57149
--- /dev/null
+++ b/openspec/changes/globalize-thread-discovery/specs/codex-thread-tools/spec.md
@@ -0,0 +1,45 @@
+## Purpose
+
+让模型通过 ACECode 内置会话查询工具发现、读取和等待所有工作区中的会话,将工作区信息作为会话属性,同时维持有界输出、归档规则和准确的目标身份。
+
+## ADDED Requirements
+
+### Requirement: 跨全部 ACECode 项目列举会话
+
+`list_threads` SHALL 查询当前 ACECode 数据目录下全部项目中的持久化会话及当前运行时的活跃会话,不以调用方 cwd、工作区注册状态或界面可见性作为筛选条件。工具 SHALL 返回目标的 `workspaceHash`、`cwd` 和 `noWorkspace` 属性,并保留子代理的 `parentThreadId`。
+
+#### Scenario: 不同工作区与无工作区会话
+- **WHEN** 调用方位于一个工作区,其他会话位于隐藏项目、没有工作区注册文件的项目或无工作区缓存目录
+- **THEN** 这些会话均可以被列举,且没有 cwd 的调用方也可列举
+
+#### Scenario: 归档会话的显式查询
+- **WHEN** 调用方传入 `includeArchived=true`
+- **THEN** 归档会话也可被列举;省略此参数仍只返回未归档会话
+
+#### Scenario: 置顶顺序与普通会话翻页
+- **WHEN** 全局普通会话数量超过 `limit`
+- **THEN** 返回可续读的 `nextCursor`,普通会话每页不超过 limit,全部有效置顶会话仍按各项目 pin 顺序返回且不占普通名额
+
+### Requirement: 全局读取和等待会话
+
+`read_thread` 与 `wait_threads` SHALL 使用全局 ACECode 会话身份定位目标,支持跨工作区的磁盘及活跃会话,并保持原有 turn 分页、输出上限、事件游标和等待语义。工具 SHALL 接受列表返回的 `workspaceHash` 作为可选目标定位信息。
+
+#### Scenario: 读取另一个项目的历史
+- **WHEN** 传入另一个项目中会话的 threadId
+- **THEN** 返回该会话历史,且归档会话及子代理会话也能被明确读取
+
+#### Scenario: 等待另一个项目的活跃会话
+- **WHEN** `wait_threads` 指向另一个项目中的活跃会话
+- **THEN** 返回该目标的真实状态和事件游标,不报告为当前工作区不可用
+
+#### Scenario: 跨项目同 ID
+- **WHEN** 相同 threadId 存在于多个项目且未提供 workspaceHash
+- **THEN** 返回歧义错误;提供列表返回的 workspaceHash 后读取指定项目的会话
+
+### Requirement: 工具描述与实际查询范围一致
+
+查询工具描述 SHALL 明确支持全部 ACECode 工作区,不引导模型把当前工作区作为隐式查找边界,并说明列表续页及归档参数。
+
+#### Scenario: 模型发现查询工具
+- **WHEN** 运行时构造 list_threads、read_thread 与 wait_threads 的工具定义
+- **THEN** 描述声明跨工作区能力,参数中包含实现支持的续页和目标定位字段
diff --git a/openspec/changes/globalize-thread-discovery/tasks.md b/openspec/changes/globalize-thread-discovery/tasks.md
new file mode 100644
index 000000000..773c23f18
--- /dev/null
+++ b/openspec/changes/globalize-thread-discovery/tasks.md
@@ -0,0 +1,14 @@
+## 1. 全局查询实现
+
+- [x] 1.1 扩展全局目录的归档及子代理包含选项,并用目录测试验证默认过滤不变。
+- [x] 1.2 将列表、读取和等待接入全局查询,加入工作区属性、归档参数及列表分页,用隔离数据目录的跨工作区测试验证。
+- [x] 1.3 更新工具描述、schema 和协议文档,通过工具入口测试核对参数实际传递。
+
+## 2. 集成验证
+
+- [x] 2.1 完成 C++ 定向构建,运行 thread 工具和全局目录/搜索相关回归测试,记录结果。
+- [x] 2.2 运行 OpenSpec 严格校验及 git diff --check,核对原有无关改动未被修改。
+
+验证记录:`cmake --build build --config MinSizeRel --target acecode_unit_tests --parallel 4` 成功;`ThreadTools.*:GlobalSessionCatalog.*:GlobalSessionCatalogIndex.*:GlobalSessionSearchService.*` 共 21 项测试通过,结果记录在 `build/thread-discovery-tests.xml`。此次未重新打包或替换正在运行的 ACECode。
+
+`openspec validate globalize-thread-discovery --strict` 与 `git diff --check` 通过;原有 13 个修改或未跟踪文件的 SHA-256 均与实施前一致。
diff --git a/openspec/changes/guard-unsaved-file-navigation/.openspec.yaml b/openspec/changes/guard-unsaved-file-navigation/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/guard-unsaved-file-navigation/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/guard-unsaved-file-navigation/design.md b/openspec/changes/guard-unsaved-file-navigation/design.md
new file mode 100644
index 000000000..d4f1bc203
--- /dev/null
+++ b/openspec/changes/guard-unsaved-file-navigation/design.md
@@ -0,0 +1,28 @@
+## Context
+
+`ChatView` 持有预览页签和文件 `edit` 状态,现有关闭动作已经使用 `Modal` 与 `saveEditableFileDraftBatch`。文件切换直接更新页签,`App` 更新导航历史,`Sidebar` 还存在直接激活 Desktop 工作区并重定向的入口,单独监听切换后的 sessionId 无法实现“取消不切换”。
+
+## Goals / Non-Goals
+
+**Goals:** 在动作执行前统一决定,保存成功或丢弃后只执行一次原动作,失败与取消不离开当前编辑现场。
+
+**Non-Goals:** 本变更仅覆盖未保存确认,不能代表原始会话级缓存需求已完成;完整归属改造由后续 `isolate-session-workbench-state` 补齐。不修改聊天输入草稿,不接管浏览器或操作系统自身的窗口关闭/刷新行为。
+
+## Decisions
+
+1. 使用一个可注入保存/丢弃回调的确认控制器,管理单个待处理请求、保存中状态与错误。干净路径同步放行,存在修改时返回等待用户决定的 Promise。关闭、页签切换、会话切换共用控制器和弹窗。
+2. 预览状态通过唯一提交函数同步更新 ref 和 React state,确认读取最新文件版本,避免保存完成后第二层导航再次看到旧 dirty 标志。保存复用现有 read_id 校验通路,失败保留修改,不新增文件写入接口。
+3. 页签切换检查当前活动文件;会话/工作区导航检查当前可见页签中的全部脏文件。每次请求捕获原归属和相关页签键,保存/丢弃只更新这些文件。再次点击当前文件不提示。
+4. `ChatView` 向 `App` 注册离开检查,`App` 导航提交与 Desktop resume/重定向前调用,`Sidebar` 在本地选择状态和原生工作区激活前也调用。相同导航身份的元数据更新保持放行。
+5. 自动浏览器页签前置也经过预览选择入口;原生页面选中与面板展开必须等确认。已有确认期间拒绝额外请求,卸载时取消等待,保存失败可重试。
+
+## Risks / Trade-offs
+
+- [多层导航检查重复弹窗] → 确认成功前同步提交干净/丢弃状态,后续检查直接放行。
+- [保存期间异步回调串入其他会话] → 保存操作捕获原文件归属,未确认不能导航,卸载后不执行旧请求。
+- [部分文件保存成功后另一文件失败] → 保留全部页签,已成功文件更新基线,失败文件保留修改,再次保存仅处理仍未保存的文件。
+- [原生自动选页绕过确认] → 将 select 延后到确认后的回调,覆盖浏览器自动前置和手动新建入口。
+
+## Migration Plan
+
+无需持久化迁移。按确认控制器、预览入口、会话/工作区入口、交互验证顺序实施,整体回退本变更即可恢复旧行为。
diff --git a/openspec/changes/guard-unsaved-file-navigation/proposal.md b/openspec/changes/guard-unsaved-file-navigation/proposal.md
new file mode 100644
index 000000000..b8035e90f
--- /dev/null
+++ b/openspec/changes/guard-unsaved-file-navigation/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+右侧文件编辑器目前只在关闭页签时提示未保存修改,切换文件、预览类型或会话时会直接离开编辑现场。用户要求把离开前的决定交给自己,取消时留在原处,选择保存或不保存后才继续原操作。
+
+## What Changes
+
+- 复用关闭文件的确认与版本校验保存流程,在切换详情页签、打开另一文件、切换会话/工作区、返回首页及前进后退之前拦截未保存修改。
+- 统一提示用户存在未保存修改,按钮按“取消 / 不保存 / 保存”排列;取消不切换,不保存丢弃受影响修改后继续,保存成功后继续,失败保持原位置和修改。
+- 同一文件重复激活不提示;自动打开浏览器页签不能绕过正在编辑的文件或覆盖已有确认目标。
+- 保留聊天输入框已有的会话级草稿机制,本变更不迁移其他缓存。
+
+## Capabilities
+
+### New Capabilities
+
+无。
+
+### Modified Capabilities
+
+- `webui-side-panel`: 增加未保存文件修改的离开确认与取消导航约束。
+
+## Impact
+
+涉及 `ChatView` 的预览操作与关闭弹窗、`App` 和 `Sidebar` 的导航入口,以及可测试的未保存确认控制器。保存继续走现有 `saveEditableFileDraftBatch` 与文件版本校验 API;无新增后端协议或依赖。
diff --git a/openspec/changes/guard-unsaved-file-navigation/specs/webui-side-panel/spec.md b/openspec/changes/guard-unsaved-file-navigation/specs/webui-side-panel/spec.md
new file mode 100644
index 000000000..770b8533c
--- /dev/null
+++ b/openspec/changes/guard-unsaved-file-navigation/specs/webui-side-panel/spec.md
@@ -0,0 +1,37 @@
+## ADDED Requirements
+
+### Requirement: 离开未保存文件前确认
+
+系统 MUST 在用户离开含未保存修改的文件编辑现场前显示确认对话框,按钮按“取消”“不保存”“保存”排列。切换文件或详情页签时检查即将离开的文件,切换会话、工作区、返回首页及前进后退时检查当前预览区域的未保存文件。关闭文件 SHALL 复用相同决定流程。
+
+#### Scenario: 取消切换
+- **WHEN** 用户编辑文件后切换详情页签或会话,并选择“取消”或按 Esc
+- **THEN** 原文件、编辑内容、活动会话及导航历史保持不变
+
+#### Scenario: 不保存后切换
+- **WHEN** 用户选择“不保存”
+- **THEN** 系统丢弃即将离开范围内的未保存修改,再执行最初请求的切换,且不写入磁盘
+
+#### Scenario: 保存后切换
+- **WHEN** 用户选择“保存”且所有相关文件保存成功
+- **THEN** 系统完成原切换,文件的未保存标记清除,保存过程中禁止重复提交
+
+#### Scenario: 保存失败
+- **WHEN** 保存发生版本冲突、网络错误或其他失败
+- **THEN** 系统保留原位置与未成功保存的修改,在弹窗内显示错误并允许重新选择;不得继续切换
+
+#### Scenario: 相同目标与干净文件
+- **WHEN** 用户重复激活当前文件,或离开范围内没有未保存修改
+- **THEN** 系统不弹出未保存确认
+
+#### Scenario: 工作区导航尚未执行
+- **WHEN** 用户请求进入其他工作区且当前文件未保存
+- **THEN** 系统在激活目标工作区、重定向页面或修改会话导航状态之前等待决定,取消不触发这些动作
+
+#### Scenario: 自动页签切换与重复导航
+- **WHEN** 原生浏览器产生新的自动激活请求或确认期间再次收到导航请求
+- **THEN** 未保存文件不能被直接切走,已有确认目标不被替换或同时执行
+
+#### Scenario: 聊天输入草稿保持独立
+- **WHEN** 用户确认文件修改并切换会话
+- **THEN** 聊天输入框仍按照各自会话的草稿恢复,文件确认不会清空或覆盖聊天输入草稿
diff --git a/openspec/changes/guard-unsaved-file-navigation/tasks.md b/openspec/changes/guard-unsaved-file-navigation/tasks.md
new file mode 100644
index 000000000..4491ffef7
--- /dev/null
+++ b/openspec/changes/guard-unsaved-file-navigation/tasks.md
@@ -0,0 +1,14 @@
+## 1. 统一未保存确认
+
+- [x] 1.1 实现可测试的单请求确认控制器,覆盖取消、不保存、保存成功、保存失败重试、并发请求和卸载取消。
+- [x] 1.2 接入预览切换和现有关闭流程,复用文件版本校验保存,按钮统一为“取消 / 不保存 / 保存”;通过定向测试验证保存及丢弃只影响原文件。
+
+## 2. 导航联动
+
+- [x] 2.1 在会话、首页、前进后退及工作区导航执行前接入确认,取消不修改目标或导航历史;覆盖真实导航入口验证。
+- [x] 2.2 将浏览器自动前置、手动新建与隐藏详情接入相同检查,确认前保持原活动文件;通过定向验证检查取消与保存失败路径。
+
+## 3. 验证与文案
+
+- [x] 3.1 补齐英文覆盖并生成 i18n 目录,通过完整 `pnpm test`、`pnpm build`、OpenSpec 严格校验及 `git diff --check`。
+- [x] 3.2 在浏览器验证三按钮、文件/tab/会话切换、保存失败与关闭回归,检查中文/英文及 390px 展示,并记录实际覆盖范围。
diff --git a/openspec/changes/guard-unsaved-file-navigation/validation.md b/openspec/changes/guard-unsaved-file-navigation/validation.md
new file mode 100644
index 000000000..850e9369c
--- /dev/null
+++ b/openspec/changes/guard-unsaved-file-navigation/validation.md
@@ -0,0 +1,22 @@
+# 验证记录
+
+2026-09-19,本地源码验证。
+
+- `pnpm test`:完整前端测试通过,包含确认控制器的取消、丢弃、保存、失败重试、重复请求及卸载取消;已有关闭文件和 Modal 契约断言同步更新。
+- `pnpm build`:通过,生成单文件前端并通过旧 WebView 正则兼容性检查。
+- `pnpm i18n:catalog`:从英文覆盖生成语言目录,按钮为“取消 / 不保存 / 保存”和“Cancel / Don't save / Save”。
+- `openspec validate guard-unsaved-file-navigation --strict`、`git diff --check`:通过。
+
+使用独立 Chromium 测试实例加载实际 React 应用,所有 API 和 Desktop bridge 均为测试桩,不操作用户会话和真实文件。已验证:
+
+- 文件树切换:取消后编辑内容、活动页签及树选中项保持;保存完成后切换;丢弃不写磁盘,再次打开恢复基线。
+- 会话切换与历史后退:取消不改变原会话和历史目的地;保存失败留在原处;目标会话恢复其独立聊天输入草稿。
+- 关闭文件:取消保留文件,丢弃后关闭。
+- 工作区与新建会话入口:确认前不调用原生工作区激活;取消保留原会话,丢弃后进入首页。
+- 隐藏详情或整个右侧栏:取消后仍显示编辑器。
+- 浏览器页签:手动新建和自动前置均等待确认;取消不调用原生选页;保存失败后重试成功才选页。
+- 中文、英文与 390×844、减少动态效果:三按钮完整可见,弹窗宽 358px,无内部横向溢出,页面无未捕获异常。
+
+测试在会话导航后等待网络空闲再输入,并断言文字确实进入文件编辑器,避免现有聊天输入框切换后 80ms 自动聚焦影响测试目标。
+
+未重新构建或验证已安装 Desktop 原生包。原生 bridge 的时序通过测试桩验证,真实磁盘保存继续使用既有 read_id 校验接口。
diff --git a/openspec/changes/isolate-session-workbench-state/.openspec.yaml b/openspec/changes/isolate-session-workbench-state/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/isolate-session-workbench-state/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/isolate-session-workbench-state/design.md b/openspec/changes/isolate-session-workbench-state/design.md
new file mode 100644
index 000000000..1a53e8349
--- /dev/null
+++ b/openspec/changes/isolate-session-workbench-state/design.md
@@ -0,0 +1,29 @@
+## Context
+
+动机见 proposal.md。当前 ChatView 保持挂载,文件页签按 workspace/worktree 索引;SidePanel 的树按 cwd,面板布局和终端停靠参数为全局偏好,PTY 注册表也无会话归属。未保存确认已有单请求控制器。
+
+## Goals / Non-Goals
+
+**Goals:** 所有本次列出的工作状态归属统一、切换立即恢复、异步回包不得污染新会话;保留既有确认和文件版本校验;覆盖同工作区及跨工作区场景。
+
+**Non-Goals:** 主题、语言、字体、默认 Shell 等应用配置仍为用户设置;磁盘文件和仓库本身不复制;无需将原生浏览器登录 profile 变为新的账户系统。
+
+## Decisions
+
+1. 建立以会话 ID 为稳定归属的工作状态存储和 React 订阅适配。setter 捕获归属,读状态无需等待切换后的 effect 清空。工作目录继续作为会话内部资源标识,worktree 变化不改变会话身份。可序列化浏览状态保存在当前浏览窗口的 sessionStorage,Map/Set 保留类型;存储失败不影响内存操作。
+2. 文件页签集合改为会话与目录联合索引,删除工作区活动项回退。关闭/草稿/刷新使用相同索引;新建交接显式重映射页签键、顺序与活动项。仅在界面隐藏别人的 tab 不足以保证关闭和异步更新安全。
+3. 文件树、Git 基线、列表形式、滚动和详情显示通过同一存储分字段维护;每会话的 Git 导航与详情共享该会话缓存。目录变化使用会话内部 cwd 索引。Git/文件变更事件失效相关物理目录的数据,但不修改其他会话的交互状态。侧边栏、导航、预览和子代理面板的宽度及预览手动尺寸标记由 App 的单个 usePreference 保存到 `acecode.singleLayoutWidths.v1`,不绑定会话。恢复旧工作状态时丢弃已停用的 `layout` 字段,其他会话状态继续恢复。
+4. 未创建会话的入口获得独立临时 owner;正式创建后原子移交并轮换新建入口。迟到的操作仍对应原归属,不能写入下一个新建入口。聊天输入草稿使用现有接受回执机制,不复制给已有会话。
+5. PTY 创建与列表协议增加 owner,注册表提供临时 owner 到正式会话的移交;移交别名覆盖在途创建。前端保留各终端实例、输出与连接,按当前 owner 显示 tab,切换不删除进程。旧版无 owner 的终端不自动分给任意会话。
+6. 保留未保存确认,提交原动作前同步清除 dirty;错误、取消、双击和迟到回包均定向验证。文件保存继续走既有 read_id 接口。
+
+## Risks / Trade-offs
+
+- [切换后异步结果写错位置] → setter 捕获 owner,组件异步请求带归属或在挂载边界取消;同名文件和同 cwd 回包分别测试。
+- [新建交接期间丢终端] → 前后端明确移交并处理迟到创建;不以卸载组件关闭 PTY。
+- [全局宽度与会话状态混用] → 宽度只读写全局偏好,切换会话、新建入口和工作区不会覆盖;面板显示、最大化、页签及终端状态继续归属会话。
+- [浏览缓存序列化容量] → 写入合并、失败保留内存;原生终端输出不进入浏览存储,由 PTY 回放。
+
+## Migration Plan
+
+先完成纯状态与单测,再接入预览/面板/Git,再接入 PTY 与新建交接;最后用 A 五页签、B 两页签往返及真实 PTY 验证。旧工作区共享文件集合不能无依据复制给所有历史会话;新索引使用独立存储版本。原未保存确认保持可用。
diff --git a/openspec/changes/isolate-session-workbench-state/proposal.md b/openspec/changes/isolate-session-workbench-state/proposal.md
new file mode 100644
index 000000000..41d1d8751
--- /dev/null
+++ b/openspec/changes/isolate-session-workbench-state/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+同一工作区的 A 会话打开文件后,B 会话会看到相同页签;关闭或编辑也作用于共享文件状态。此前 `guard-unsaved-file-navigation` 只补齐了未保存确认,没有完成用户要求的全部会话级工作状态隔离。本变更补齐整个范围。
+
+## What Changes
+
+- 文件页签、顺序、激活项、编辑与浏览位置归属当前会话,所有打开、关闭、刷新和异步回调遵守该归属。
+- 文件树数据、展开、选中、滚动,以及右侧栏目、详情显示、最大化分别缓存到会话;侧边栏、导航、预览和子代理面板宽度使用全局共用的偏好。
+- Git 基线、列表和 diff 缓存按会话独立,同一会话的导航列表与详情仍共享一份结果。
+- 终端列表、激活项、显示与高度按会话恢复;PTY 后端记录所属会话,过滤列表并支持新建草稿的归属移交,切换保持进程运行。
+- 新建入口使用独立临时归属,创建正式会话时一次性交接给新会话;已有会话只恢复自己的状态。
+- 保留未保存修改的“取消 / 不保存 / 保存”,聊天草稿、变更页签和浏览器原有会话归属;修正此前将完整改造排除的范围说明。
+
+## Capabilities
+
+### New Capabilities
+- `session-console-ownership`: PTY 的会话归属、列表过滤和草稿交接。
+
+### Modified Capabilities
+- `webui-side-panel`: 工作区内不同会话恢复各自的完整工作状态,取代同 cwd 共用状态。
+
+## Impact
+
+影响 `App`、`ChatView`、预览、文件树、Git 与终端组件及相关纯状态模块;扩展 PTY 注册表与 API,并同步 `docs/daemon-api.md`。不修改实际工作区文件的共享磁盘语义或主题、语言、默认 Shell 等应用设置。
diff --git a/openspec/changes/isolate-session-workbench-state/specs/session-console-ownership/spec.md b/openspec/changes/isolate-session-workbench-state/specs/session-console-ownership/spec.md
new file mode 100644
index 000000000..b61f2f47f
--- /dev/null
+++ b/openspec/changes/isolate-session-workbench-state/specs/session-console-ownership/spec.md
@@ -0,0 +1,25 @@
+## Purpose
+
+让每个聊天会话可靠地持有自己的终端集合、当前终端和停靠状态,在会话切换和浏览器刷新时按归属恢复,并保证后台进程与输出继续运行。新建会话之前启动的终端可移交给正式会话,而不会被其他已有会话取得。
+
+## ADDED Requirements
+
+### Requirement: 终端会话归属
+
+PTY 创建结果和查询 SHALL 提供所属 owner,指定 owner 的查询 MUST 只返回该 owner 的终端。界面 SHALL 恢复当前会话的列表与选中项,切换时不得终止其他会话的进程或将旧版无归属终端自动分配给当前会话。
+
+#### Scenario: 同工作区终端隔离
+- **WHEN** A 与 B 分别创建终端并来回切换
+- **THEN** 各自只显示自己的终端,原进程保持运行且输出仍可恢复
+
+#### Scenario: 刷新恢复
+- **WHEN** 用户刷新当前会话的界面
+- **THEN** 仅恢复属于该会话的终端并回放现有输出
+
+#### Scenario: 临时终端移交与在途创建
+- **WHEN** 新建入口产生的终端移交给新会话,且仍有未完成的创建请求
+- **THEN** 已有与晚到终端均归属该新会话,后续新建入口及其他已有会话不获得这些终端
+
+#### Scenario: 关闭单个终端
+- **WHEN** 用户关闭当前会话的一个终端
+- **THEN** 仅该终端被终止和移除,其他会话的终端和激活状态不变
diff --git a/openspec/changes/isolate-session-workbench-state/specs/webui-side-panel/spec.md b/openspec/changes/isolate-session-workbench-state/specs/webui-side-panel/spec.md
new file mode 100644
index 000000000..f0c7deca2
--- /dev/null
+++ b/openspec/changes/isolate-session-workbench-state/specs/webui-side-panel/spec.md
@@ -0,0 +1,61 @@
+## MODIFIED Requirements
+
+### Requirement: Side panel layout in single-session view
+
+Web UI 单会话视图 SHALL 提供文件与变更导航及详情页签;其他布局按现有规则显示。系统 MUST 按会话保存文件页签、排序、选中项、编辑与浏览位置、文件树缓存和展开、列表栏目、比较基线、详情显示和最大化。同工作区或相同 cwd 不得共享这些会话工作状态。侧边栏、导航、预览及子代理面板的宽度与预览手动尺寸标记 MUST 使用全局共用的一份持久偏好,切换会话或工作区不得恢复各会话的旧宽度。
+
+#### Scenario: 单会话视图渲染右侧面板
+- **WHEN** 用户进入单会话视图并展开右侧面板
+- **THEN** 显示该会话的文件/变更导航和详情页签,宽度使用全局保存值或默认值
+
+#### Scenario: 切到 4 宫格隐藏面板
+- **WHEN** 用户从单会话视图切换至不显示侧面板的布局
+- **THEN** 面板不占用该布局空间,其会话工作状态保留供返回时恢复
+
+#### Scenario: tab 切换零延迟
+- **WHEN** 用户切换文件/变更栏目或已有详情页签
+- **THEN** 立即呈现该会话缓存的视图;需要补充数据时在该会话内加载,不借用其他会话的视图状态
+
+#### Scenario: 切 session 时面板状态保留
+- **WHEN** 用户在相同 cwd 的 A/B 会话之间切换
+- **THEN** 各自保留并恢复自己的缓存、展开、选中和栏目;B 不继承 A 的面板状态
+
+#### Scenario: cwd 真变时整面板 reset
+- **WHEN** 用户进入尚无缓存的另一 cwd
+- **THEN** 该目录从初始状态展示,不显示旧目录残留;原会话和目录的状态仍独立保存,再次返回可恢复
+
+#### Scenario: 同工作区会话独立恢复
+- **WHEN** A 打开五个页签,切到从未打开页签的 B,B 再打开两个页签并切回 A
+- **THEN** B 首次为空,A 恢复原五页签及其顺序与活动项;再次进入 B 只显示 B 的两个页签
+
+#### Scenario: 关闭和重排独立
+- **WHEN** A 与 B 都打开同一个文件,B 关闭、关闭其他、关闭全部或拖动排序
+- **THEN** A 的页签集合、顺序和活动项保持不变
+
+#### Scenario: 文件树和面板状态独立
+- **WHEN** A 展开目录、选中文件、滚动列表或最大化详情后切到 B
+- **THEN** B 恢复自己的状态,返回 A 恢复 A 的状态,跨工作区往返亦然
+
+#### Scenario: 面板宽度全局共享
+- **WHEN** 用户在 A 调整侧边栏或面板宽度,再切到 B、其他工作区或新建入口
+- **THEN** 所有入口使用最近一次全局宽度;返回 A 或刷新页面后仍保留该宽度,原有最小值、最大值和可用空间约束继续生效
+
+#### Scenario: 旧会话宽度迁移
+- **WHEN** 浏览存储包含各会话独立的旧 layout 记录
+- **THEN** 系统丢弃旧 layout 字段,使用全局宽度偏好或默认值;其余文件页签、缓存、面板显示和终端状态照常恢复
+
+#### Scenario: Git 缓存和基线独立
+- **WHEN** 同 cwd 的两个会话选择不同基线并分别加载列表与 diff
+- **THEN** 各会话恢复各自基线与缓存,异步回包不能覆盖另一个会话,同一会话的导航与详情使用一致结果
+
+#### Scenario: 切换未保存文件
+- **WHEN** 当前会话文件有未保存修改并请求离开
+- **THEN** 显示“取消 / 不保存 / 保存”;取消保持当前位置,不保存丢弃后切换,保存成功后切换,失败保留修改且不切换
+
+#### Scenario: 新建入口交接
+- **WHEN** 用户从新建入口创建正式会话
+- **THEN** 临时工作状态只交给该新会话,后续新建入口独立,已有会话不继承临时状态;聊天输入草稿遵守原会话恢复规则
+
+#### Scenario: 晚到异步回调
+- **WHEN** 会话 A 的文件、目录或 Git 请求在切到 B 后完成
+- **THEN** 回包只更新 A 的缓存或被取消,不修改 B 的展示和编辑状态
diff --git a/openspec/changes/isolate-session-workbench-state/tasks.md b/openspec/changes/isolate-session-workbench-state/tasks.md
new file mode 100644
index 000000000..4bdd1d2b6
--- /dev/null
+++ b/openspec/changes/isolate-session-workbench-state/tasks.md
@@ -0,0 +1,29 @@
+## 1. 会话归属与预览
+
+- [x] 1.1 实现会话工作状态存储、稳定身份和临时归属移交;单测验证隔离、恢复、序列化及迟到更新。
+- [x] 1.2 改造文件页签集合、所有页签动作和编辑状态归属;单测覆盖 A 五页签/B 两页签、同名文件、关闭与排序互不影响。
+- [x] 1.3 接入预览编辑/滚动恢复和既有三按钮确认;浏览器验证取消、保存失败与跨会话往返。
+
+## 2. 面板与缓存
+
+- [x] 2.1 改造文件树缓存、展开、选中、栏目、滚动与变更列表形式;测试同 cwd 独立恢复及在途请求。
+- [x] 2.2 将详情显示、最大化和终端停靠状态按会话维护;浏览器验证 A/B 互不影响。面板宽度按下方全局偏好修正。
+- [x] 2.3 隔离 Git 信息、列表、patch 与基线,保留会话内共享及目录变更失效;测试缓存身份与晚到回包。
+
+## 3. 终端与新建交接
+
+- [x] 3.1 PTY 注册表/API 增加 owner 创建、过滤与移交,并更新 daemon-api;C++ 定向测试验证进程保持、过滤和在途创建归属。
+- [x] 3.2 控制台接入会话列表和选中项、实例/连接保活与 owner 协议;浏览器及真实 PTY 验证切换不退出进程、关闭互不影响。
+- [x] 3.3 新建会话原子移交预览/面板/Git/终端状态,保留聊天草稿回执;验证已有会话不继承、后续新建独立。
+
+## 4. 完整验收
+
+- [x] 4.1 通过完整 pnpm test、pnpm build、C++ 定向构建测试、OpenSpec 严格校验与 git diff --check,记录实际覆盖。
+- [x] 4.2 浏览器验证同工作区 A/B、跨工作区、文件/变更/浏览器/终端、三按钮、中文英文及 390px,逐项审计上述范围。
+
+## 5. 全局宽度修正
+
+- [x] 5.1 将 App 的布局宽度恢复为单一全局持久偏好,清除旧会话 layout 记录,保留其他会话状态及现有尺寸约束。
+- [x] 5.2 验证会话、工作区、新建入口往返及刷新后宽度一致,旧会话宽度不会覆盖全局值;运行前端测试、构建、OpenSpec 严格校验及差异检查。
+
+2026-09-23 验证:`sessionWorkbench.test.js`、`singleLayout.test.js`、`usePreference.test.js`、完整 `pnpm test`、`pnpm build`、OpenSpec 严格校验及 `git diff --check` 均通过。Chromium 使用当前构建产物和模拟 API 验证了鼠标/键盘调宽、同工作区 A/B 往返、跨工作区、新建入口、刷新持久化及旧 layout 清理;文件页签仍按会话隔离。未重建原生 Desktop 安装包。
diff --git a/openspec/changes/isolate-session-workbench-state/verification.md b/openspec/changes/isolate-session-workbench-state/verification.md
new file mode 100644
index 000000000..cb0687980
--- /dev/null
+++ b/openspec/changes/isolate-session-workbench-state/verification.md
@@ -0,0 +1,32 @@
+# 验证记录
+
+本次在 `master` 上实现,起点为干净的 `b973acec`,没有发布或替换用户正在使用的 Desktop。
+
+## 自动化
+
+- `web`:`pnpm test` 完整通过,包括新增会话存储、A 五页签/B 两页签、同名文件编辑、重排、关闭/关闭其他/关闭右侧/全部关闭、外来页签动作、后台页签移除不抢激活、Git owner/迟到请求、终端过滤和恢复测试。
+- C++:`cmake --build build --config Release --target acecode_unit_tests -- /m:2 /nologo` 成功。
+- `PtySessionRegistryTest.*:PtyControlFrameTest.*`:11 项通过;真实 shell 验证 owner 过滤、移交 PID 不变、订阅输出继续送达、移交后迟到创建、独立关闭。
+- `WebServerHttp.PtyOwnerProtocolFiltersAndTransfersWithoutRestart`:真实本地 HTTP 服务和 shell 测试通过,覆盖创建、过滤、移交、迟到创建和拒绝无效移交。
+- 新增英文文案来自 `web/scripts/i18n-en-overrides.mjs`,通过 `pnpm i18n:catalog` 生成。
+- 最终 `pnpm build` 通过,生产 bundle 的正则兼容性检查通过。
+- `openspec validate isolate-session-workbench-state --strict --no-interactive` 与 `git diff --check` 通过。
+
+## 浏览器操作
+
+Chromium 加载实际 Vite/React 页面;API、原生浏览器桥和 WebSocket 使用隔离夹具,不访问用户真实文件或会话。
+
+- 同 cwd:A 五页签 → B 空 → B 两页签 → A 五页签;顺序、激活项、同名文件独立,B 关闭不影响 A。
+- 编辑:取消留在原位置;保存失败保留草稿与会话;保存成功再切换;不保存恢复磁盘基线;聊天草稿 A/B 独立。
+- 文件浏览:文件滚动/选区、树展开/滚动恢复;选中项位于可视区外时不覆盖已保存滚动;迟到目录响应不进入 B。
+- 面板:A/B 宽度、最大化、隐藏状态分别恢复。
+- Git:基线 A=dev/B=HEAD,栏目、目录折叠、全部 diff 折叠、单双栏与滚动分别恢复。
+- 终端:tab/选中项独立,切换保留同一个 xterm DOM 与连接;后台标题更新、迟到创建只进入原会话;关闭 B 不删除 A;刷新按 owner 恢复且不重复创建。
+- 新建:临时页签和终端只交给刚创建的会话,已有 A/B 保持原样;纯状态测试验证下一次新建轮换临时 owner。
+- 原生浏览器桥:手动/自动切换遵守未保存确认;后台 A 页面事件不进入 B;返回时保留用户选中的文件。
+- 跨工作区:另一 cwd 的会话独立从空页签开始,回到来源会话恢复原状态;取消工作区切换时没有调用原生激活。
+- 中文、英文以及 390px / reduced-motion 下三按钮文案和弹窗边界通过,没有页面异常。
+
+## 验证边界
+
+浏览器测试证明前端交互与桥接调用;真实进程和 HTTP 协议由 C++ 测试覆盖。本次没有重新打包、安装或启动新版原生 Desktop,因此不把上述结果表述为已安装客户端验收。
diff --git a/openspec/changes/persist-desktop-appearance-preferences/.openspec.yaml b/openspec/changes/persist-desktop-appearance-preferences/.openspec.yaml
new file mode 100644
index 000000000..878dc3156
--- /dev/null
+++ b/openspec/changes/persist-desktop-appearance-preferences/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-07
diff --git a/openspec/changes/persist-desktop-appearance-preferences/design.md b/openspec/changes/persist-desktop-appearance-preferences/design.md
new file mode 100644
index 000000000..2063da800
--- /dev/null
+++ b/openspec/changes/persist-desktop-appearance-preferences/design.md
@@ -0,0 +1,74 @@
+## Context
+
+The current appearance implementation has three independent React/localStorage owners: `ThemeProvider` stores `ace.theme` and `ace.colorTheme`, while `App` stores `fontSize` inside `acecode.uiPrefs.v1`. This works only while the WebUI keeps the same origin. Desktop-managed daemons are assigned available loopback ports, and the port participates in the browser origin, so a later Desktop process cannot see the previous origin's localStorage.
+
+ACECode already exposes authenticated `GET/PUT /api/config/ui-preferences`, backed by the stable user `config.json`, but that endpoint currently retains only a disabled avatar compatibility field. Desktop also loads the same user configuration before creating the WebView and already injects locale bootstrap state before frontend modules run. Those are the existing seams for durable and flash-free appearance restoration.
+
+The affected files already contain unrelated work, so implementation must use narrow patches and retain existing storage keys and public theme APIs.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Make light/dark mode, blue/orange color theme, and small/medium/large font size survive Desktop daemon port changes and process restarts.
+- Restore the configured appearance before the first Desktop React render and confirm it from the authenticated daemon after connection.
+- Keep every existing appearance control live, including the TopBar light/dark button and Settings controls.
+- Preserve old browser caches and older configuration files without migration failures.
+- Reject invalid API/config values and roll the visible UI back to the last confirmed state when a save fails.
+
+**Non-Goals:**
+
+- Persist unrelated panel/layout preferences in this change.
+- Add TUI theme persistence, new palettes, new font-size choices, or OS theme tracking after startup.
+- Synchronize preferences between different machines or user profiles.
+- Replace the existing CSS token/theme architecture.
+
+## Decisions
+
+### Use `web_ui` config as the canonical store
+
+`WebUiPreferencesConfig` will add `theme`, `color_theme`, and `font_size`. Canonical values are `system|light|dark`, `blue|orange`, and `small|medium|large`; defaults are `system`, `blue`, and `medium`. The existing `show_acecode_avatar` compatibility field remains normalized to `false`.
+
+This reuses the existing authenticated UI-preferences endpoint and stable user config instead of creating another file or a theme-specific route. `system` preserves the historical first-run system-color fallback until the user explicitly changes light/dark mode.
+
+### Keep localStorage as a cache, not the authority
+
+Existing keys remain unchanged. Their values provide a fast same-origin fallback and continue to support ordinary browser use if the daemon is temporarily unavailable. Once an authenticated GET returns a complete appearance payload, the frontend applies that payload and the existing hooks refresh their local caches.
+
+This avoids destructive key migration and permits rollback to older versions, which will continue to ignore the new config fields.
+
+### Bootstrap Desktop from native configuration
+
+Desktop will inject one JSON-safe `window.__ACECODE_APPEARANCE__` object in the existing pre-navigation init script. `ThemeProvider` and `App` use that object as their default before falling back to system/default values. Existing localStorage still wins when it is valid for the current origin, and the later authenticated GET resolves any stale cache.
+
+This follows the locale bootstrap pattern and avoids a visible default-theme frame on every new random port. A frontend-only GET would eventually restore the setting but would still render the wrong first frame.
+
+### Persist complete appearance snapshots with serialized writes
+
+All appearance entry points route through an App-owned mutation function. It applies the requested field immediately, serializes complete preference snapshots through the existing PUT endpoint, and tracks the latest confirmed snapshot. Writes are queued to prevent reordered network responses from overwriting a newer user choice. A failed latest write restores the last confirmed snapshot and shows an error toast.
+
+The TopBar receives the same persisted toggle callback as Settings. This prevents the top-level black/white shortcut from bypassing the new persistence path.
+
+### Extend the existing PUT endpoint compatibly
+
+PUT accepts one or more recognized UI preference fields, validates every supplied field, preserves omitted fields, and returns the complete normalized preference object. Legacy `{show_acecode_avatar:boolean}` calls remain valid and still normalize that field to `false`. Frontend appearance writes include the legacy field and require the response to contain the complete appearance contract before considering a save confirmed.
+
+## Risks / Trade-offs
+
+- [Multiple daemon processes can hold older in-memory config snapshots] -> Each newly started daemon loads the stable file, frontend sends complete appearance snapshots, and authenticated restore re-applies the canonical stored values after navigation.
+- [An older daemon implements only the avatar endpoint] -> Treat an incomplete response as unsupported persistence, keep the existing cache available, and roll back rather than falsely reporting success.
+- [Rapid clicks can reorder PUT completion] -> Serialize writes and only let the latest revision update or roll back visible state.
+- [Invalid legacy config values exist] -> Validate each field independently, log and retain the default for invalid values, and write only normalized values.
+- [Desktop bootstrap and daemon response can differ after external config edits] -> Use bootstrap for first paint, then let the authenticated GET become authoritative.
+
+## Migration Plan
+
+1. Add sparse config parsing/serialization and expand the existing endpoint while retaining old defaults and payload compatibility.
+2. Add Desktop bootstrap injection and frontend parsing with localStorage fallback.
+3. Route TopBar and Settings mutations through the persistent writer.
+4. Existing users need no explicit migration; missing fields resolve to system/blue/medium and are written only after a non-default choice or API mutation.
+5. Rollback is safe because older builds ignore the new `web_ui` fields and continue using the existing localStorage keys.
+
+## Open Questions
+
+(none)
diff --git a/openspec/changes/persist-desktop-appearance-preferences/proposal.md b/openspec/changes/persist-desktop-appearance-preferences/proposal.md
new file mode 100644
index 000000000..177b4188b
--- /dev/null
+++ b/openspec/changes/persist-desktop-appearance-preferences/proposal.md
@@ -0,0 +1,29 @@
+## Why
+
+Desktop appearance choices are currently written only to browser `localStorage`, but the Desktop shell connects to a newly selected loopback port when its managed daemon changes. Because browser storage is origin-scoped and the port is part of the origin, light/dark mode, color theme, and font size silently reset across Desktop restarts instead of behaving like real preferences.
+
+## What Changes
+
+- Persist the complete appearance preference set (light/dark mode, blue/orange color theme, and small/medium/large font size) in ACECode's stable user configuration through the authenticated UI-preferences API.
+- Restore the persisted appearance after authentication in ordinary WebUI mode and inject it before the first Desktop WebView render so Desktop startup does not depend on a previous loopback origin.
+- Keep the existing browser storage values as a same-origin paint/cache fallback and for compatibility, while treating the daemon-backed configuration as canonical once available.
+- Apply Settings changes immediately, save them asynchronously, and roll the visible choice back if persistence fails.
+- Extend configuration, API, frontend, and regression coverage without changing TUI themes or project-specific configuration.
+
+## Capabilities
+
+### New Capabilities
+
+- `desktop-appearance-persistence`: Stable storage, startup restoration, live updates, and failure handling for Desktop/WebUI appearance preferences.
+
+### Modified Capabilities
+
+(none)
+
+## Impact
+
+- **Configuration:** `WebUiPreferencesConfig` and `config.json` `web_ui` serialization gain validated appearance fields.
+- **Daemon API:** existing authenticated `GET/PUT /api/config/ui-preferences` reads and writes the appearance preference set while retaining the legacy avatar field.
+- **Desktop startup:** native WebView bootstrap injects the configured appearance independently of loopback origin.
+- **Frontend:** `theme.jsx`, `App.jsx`, appearance helpers, and Settings wiring load/save canonical preferences while preserving immediate CSS updates.
+- **Tests/docs:** config, HTTP smoke, frontend API/state tests, architecture checks, and daemon API documentation cover the durable contract.
diff --git a/openspec/changes/persist-desktop-appearance-preferences/specs/desktop-appearance-persistence/spec.md b/openspec/changes/persist-desktop-appearance-preferences/specs/desktop-appearance-persistence/spec.md
new file mode 100644
index 000000000..4c9c0fc44
--- /dev/null
+++ b/openspec/changes/persist-desktop-appearance-preferences/specs/desktop-appearance-persistence/spec.md
@@ -0,0 +1,67 @@
+## ADDED Requirements
+
+### Requirement: Appearance preferences use stable user storage
+The system SHALL persist the Desktop/WebUI light-dark preference, color theme, and font size in stable user configuration that is independent of the daemon loopback port.
+
+#### Scenario: Desktop restarts on a different port
+- **WHEN** a user selects an explicit light or dark mode, color theme, and font size and later starts Desktop on a different loopback port
+- **THEN** the new Desktop instance restores all three selected values from stable user configuration
+
+#### Scenario: Legacy configuration has no appearance fields
+- **WHEN** ACECode loads a configuration created before appearance persistence fields existed
+- **THEN** it uses system light-dark resolution, the blue color theme, and medium font size without rejecting the configuration
+
+### Requirement: Desktop restores appearance before frontend mount
+The Desktop shell SHALL make the validated configured appearance available to the WebUI before frontend modules execute.
+
+#### Scenario: First render on a new origin
+- **WHEN** Desktop opens a WebView at a loopback origin that has no prior localStorage
+- **THEN** the initial React theme and font-size state uses the injected configured appearance rather than unrelated defaults
+
+### Requirement: Authenticated restore is canonical
+The WebUI SHALL request the complete appearance preference set after daemon authentication and SHALL apply a valid complete response as the canonical state.
+
+#### Scenario: Browser cache differs from configuration
+- **WHEN** valid local browser cache values differ from the complete appearance values returned by the authenticated daemon
+- **THEN** the WebUI applies the daemon values and refreshes its local cache
+
+#### Scenario: Older daemon returns no appearance fields
+- **WHEN** the UI-preferences response contains only legacy fields
+- **THEN** the WebUI retains its current compatible cache/default state instead of replacing it with fabricated appearance values
+
+### Requirement: Every appearance entry point persists live changes
+The system SHALL route the TopBar light-dark toggle and all Settings Appearance controls through the same immediate and durable mutation path.
+
+#### Scenario: TopBar switches light-dark mode
+- **WHEN** the user clicks the TopBar light-dark button
+- **THEN** the interface updates immediately and the complete resulting appearance snapshot is submitted for durable persistence
+
+#### Scenario: Settings changes color theme or font size
+- **WHEN** the user selects a color theme or font size in Settings Appearance
+- **THEN** the interface updates immediately and the complete resulting appearance snapshot is submitted for durable persistence
+
+### Requirement: Persistence failures do not leave false state
+The WebUI SHALL serialize appearance writes, track the most recently confirmed snapshot, and restore that snapshot when the latest persistence attempt fails.
+
+#### Scenario: Latest save fails
+- **WHEN** an appearance change is shown optimistically but its canonical save fails
+- **THEN** the UI restores the last confirmed appearance and informs the user that the preference was not saved
+
+#### Scenario: User changes appearance rapidly
+- **WHEN** multiple appearance changes are requested before earlier saves complete
+- **THEN** saves occur in request order and an older response does not overwrite the newest visible selection
+
+### Requirement: UI preferences API remains backward compatible
+The authenticated UI-preferences API SHALL return the complete normalized appearance set, SHALL validate supplied appearance fields, and SHALL continue accepting the legacy avatar preference field.
+
+#### Scenario: Complete valid update
+- **WHEN** a client submits valid `theme`, `color_theme`, and `font_size` values
+- **THEN** the endpoint persists them and returns those normalized values together with `show_acecode_avatar:false`
+
+#### Scenario: Partial legacy update
+- **WHEN** an older client submits a boolean `show_acecode_avatar` field without appearance fields
+- **THEN** the endpoint succeeds, keeps existing appearance values unchanged, and returns `show_acecode_avatar:false`
+
+#### Scenario: Invalid appearance value
+- **WHEN** a client submits an unsupported theme, color theme, or font size
+- **THEN** the endpoint rejects the request without mutating or persisting any UI preference
diff --git a/openspec/changes/persist-desktop-appearance-preferences/tasks.md b/openspec/changes/persist-desktop-appearance-preferences/tasks.md
new file mode 100644
index 000000000..b033c97bc
--- /dev/null
+++ b/openspec/changes/persist-desktop-appearance-preferences/tasks.md
@@ -0,0 +1,31 @@
+## 1. Stable Configuration Model
+
+- [x] 1.1 Extend `WebUiPreferencesConfig` with validated theme, color-theme, and font-size defaults.
+- [x] 1.2 Load and sparsely serialize the appearance fields while preserving legacy configuration compatibility.
+- [x] 1.3 Expand focused configuration tests for defaults, valid round trips, and invalid fallback behavior.
+
+## 2. Authenticated UI Preferences API
+
+- [x] 2.1 Return the complete normalized appearance contract from `GET /api/config/ui-preferences`.
+- [x] 2.2 Make the PUT route validate and persist partial legacy or appearance updates atomically.
+- [x] 2.3 Expand HTTP smoke and frontend API tests for valid, legacy, invalid, and persistence-failure cases.
+- [x] 2.4 Document the expanded endpoint contract in `docs/daemon-api.md`.
+
+## 3. Startup Restoration And Frontend State
+
+- [x] 3.1 Inject validated appearance configuration in the Desktop pre-navigation bootstrap.
+- [x] 3.2 Add pure frontend helpers for bootstrap/API normalization, system fallback, and API serialization.
+- [x] 3.3 Initialize ThemeProvider and App font size from the bootstrap/cache path and restore canonical values after authentication.
+
+## 4. Durable Live Mutations
+
+- [x] 4.1 Add serialized optimistic appearance writes with latest-confirmed rollback and user-visible failure reporting.
+- [x] 4.2 Route both the TopBar light-dark toggle and every Settings Appearance control through the durable mutation path.
+- [x] 4.3 Add focused frontend unit and architecture coverage for restore, persistence routing, ordering, and rollback helpers.
+- [x] 4.4 Keep the long-lived appearance controller bound to the latest App state writer so a later `medium` choice applies after `small` or `large`.
+
+## 5. Verification
+
+- [x] 5.1 Run focused C++ configuration and Web server tests.
+- [x] 5.2 Run the Web unit suite and production build.
+- [x] 5.3 Run `openspec validate persist-desktop-appearance-preferences --strict` and `git diff --check`.
diff --git a/openspec/changes/present-shell-command-cards/.openspec.yaml b/openspec/changes/present-shell-command-cards/.openspec.yaml
new file mode 100644
index 000000000..eaa6b1cd4
--- /dev/null
+++ b/openspec/changes/present-shell-command-cards/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/present-shell-command-cards/design.md b/openspec/changes/present-shell-command-cards/design.md
new file mode 100644
index 000000000..2a466e52a
--- /dev/null
+++ b/openspec/changes/present-shell-command-cards/design.md
@@ -0,0 +1,22 @@
+## Context
+
+`ToolBlock` 被主会话与子代理的 `TranscriptItems` 复用。当前仅 `tool === 'bash'` 时把 args.command 放入展开文本,历史结构化结果的 args 为空,配对阶段丢弃工具调用包装导致命令缺失。
+
+## Goals / Non-Goals
+
+- 按参考图使用普通字重 Shell 标题、等宽命令与输出、主题色浅底和圆角边框。
+- 默认显示原有图标/运行状态、指令名、参数与指标;点击工具执行行后,在下方以 Shell 卡片展示完整命令和输出,长内容在卡片内滚动。
+- 运行、完成、失败、实时增量与历史恢复都共用同一卡片;复制不丢失已记录命令。
+- 不修改命令执行行为,不取消用户刚配置的消息自动折叠策略,不重排其它工具样式。
+
+## Decisions
+
+1. 规范 bash 工具与历史 Ran 摘要继续经过 ToolBlock 既有完成态/运行态 ActivityLine,完整命令优先作为行内参数展示。ShellCommandCard 仅替换展开详情的内容渲染,不得以提前返回绕过执行行;主会话与子代理共用此入口。
+2. 提取命令展示数据为纯函数,分开保存命令、输出与复制文本。旧调用/结果合成项额外保留结果原文,避免 Shell 输出重复包含调用包装。
+3. 配对时只为 Shell 调用补齐缺失的工具名和参数;按既有 call id 归属处理,并且不覆盖已有实时参数、不修改原始历史。
+4. 展开状态仍由 ToolBlock 唯一管理,键盘、标题点击与桌面右键菜单使用同一状态;审阅时沿用暂停滚动跟随回调。
+5. 采用主题 token,缩略区域按实际内容溢出显示渐隐;完整态仅内容区域滚动,适配窄屏与大字号。
+
+## Risks / Trade-offs
+
+部分旧记录没有完整请求,只能展示现有摘要或明确缺失提示,不推测命令。折叠态保持统一工具行的高度,卡片仅在展开后出现,继续接受外层消息折叠与历史窗口管理。
diff --git a/openspec/changes/present-shell-command-cards/proposal.md b/openspec/changes/present-shell-command-cards/proposal.md
new file mode 100644
index 000000000..29bd66b66
--- /dev/null
+++ b/openspec/changes/present-shell-command-cards/proposal.md
@@ -0,0 +1,20 @@
+## Why
+
+执行命令保留原有“图标、指令名、参数”的工具执行行,仅将展开后的命令和输出内容调整为参考图中的 Shell 卡片。历史记录展开时也必须保留已记录的完整命令。
+
+## What Changes
+
+- 命令调用默认保留统一工具执行行及折叠入口;展开后在行下方显示 Shell 卡片,卡片内排列完整命令与输出,长内容可滚动查看。
+- 复用现有复制、工具折叠状态、运行状态、失败信息与主会话/子代理共享入口。
+- 历史工具包装与结构化结果合并时恢复已记录的命令参数,避免只剩输出或把截断摘要当完整命令。
+
+## Capabilities
+
+### New Capabilities
+- `shell-command-card`: 实时与历史执行命令的统一卡片展示。
+
+### Modified Capabilities
+
+## Impact
+
+仅修改 WebUI 共享工具展示与历史投影,无后端协议变更。其它工具、文件 diff、提问结果及消息自动折叠偏好保持既有职责。
diff --git a/openspec/changes/present-shell-command-cards/specs/shell-command-card/spec.md b/openspec/changes/present-shell-command-cards/specs/shell-command-card/spec.md
new file mode 100644
index 000000000..1020d6b5e
--- /dev/null
+++ b/openspec/changes/present-shell-command-cards/specs/shell-command-card/spec.md
@@ -0,0 +1,31 @@
+## ADDED Requirements
+
+### Requirement: 统一 Shell 命令卡片
+执行命令调用 SHALL 在主会话与子代理中保留统一工具执行行,包含图标/运行状态、指令名、参数与指标。Shell 卡片 SHALL 仅作为该行展开后的命令及输出详情,不得替代或隐藏工具执行行。
+
+#### Scenario: 查看已完成命令
+- **WHEN** 显示已完成的 bash 调用或历史 Ran 记录
+- **THEN** 默认可见原有工具执行行;点击或键盘展开后,行仍保留,下方显示 Shell 标题、完整已记录命令和输出,复制获得完整内容
+
+#### Scenario: 实时运行与失败
+- **WHEN** 命令运行中、输出持续到达或执行失败
+- **THEN** 工具执行行保留运行/耗时及失败信息,详情继续使用 Shell 卡片,展开状态不被后续输出或完成事件重置
+
+#### Scenario: 收起命令详情
+- **WHEN** 用户点击工具执行行、Shell 卡片标题或桌面右键收起
+- **THEN** 隐藏详情卡片并保留图标、指令名、参数和指标,不渲染重复执行行
+
+#### Scenario: 历史工具配对
+- **WHEN** 结构化 Shell 结果同时有匹配的调用包装
+- **THEN** 优先恢复完整命令参数而非截断摘要,调用去重仍生效,多个并行调用不串命令
+
+#### Scenario: 缺失请求和其它工具
+- **WHEN** 旧记录没有完整命令,或记录属于其它工具
+- **THEN** 旧命令仅展示已有信息,其它工具继续使用原有专用展示
+
+### Requirement: 交互和主题一致性
+卡片 SHALL 使用现有主题与字体 token,支持键盘及桌面右键展开/收起,复制按钮 SHALL 不触发折叠。消息自动折叠的外层策略 SHALL 保持生效。
+
+#### Scenario: 窄屏与主题
+- **WHEN** 在明暗主题、中英文界面或 390px 窄屏查看长命令和长输出
+- **THEN** Shell 卡片无页面横向溢出,命令/输出清晰分层,所有内容可通过展开与滚动查看
diff --git a/openspec/changes/present-shell-command-cards/tasks.md b/openspec/changes/present-shell-command-cards/tasks.md
new file mode 100644
index 000000000..0b0a3ef9a
--- /dev/null
+++ b/openspec/changes/present-shell-command-cards/tasks.md
@@ -0,0 +1,27 @@
+## 1. 命令数据
+- [x] 1.1 提取 Shell 命令展示数据,补齐匹配历史调用的参数,覆盖实时/历史/旧包装/并行/缺失请求测试。
+
+## 2. 卡片
+- [x] 2.1 接入共享 Shell 卡片与主题样式,保留工具展开、复制、运行/失败状态和外层消息折叠。
+
+## 3. 验证
+- [x] 3.1 完成前端全量测试、构建、严格规范及差异检查;浏览器验证明暗主题、中英文、窄屏、运行/失败、复制、展开及主会话/子代理共享渲染。
+
+## 4. 恢复工具执行行
+- [x] 4.1 恢复 Shell 的共用 ActivityLine,仅替换展开详情,保留实时/完成/失败、完整参数、复制和右键展开路径。
+- [x] 4.2 验证默认折叠与展开后的执行行、主会话/子代理共享路径、中英文及 390px 布局,完成前端测试、构建和严格规范校验。
+
+## 恢复执行行的验证记录
+
+- 命令展示与工具活动行定向测试通过;完整 `pnpm test` 通过(2586 条通过记录),`pnpm build`、严格 OpenSpec 校验和 `git diff --check` 通过。
+- 浏览器覆盖 8 组明暗/中英文/1100px 与 390px 组合,每组验证主会话投影和子代理投影经真实只读 `TranscriptItems` 的共享渲染,共 16 个界面组合。
+- 验证默认仅一条带图标、名称、参数的执行行;展开后该行仍在,卡片与行同宽;复制不收起,键盘和桌面事件可展开收起;失败、历史缺参、普通 Read 工具正确;实时输出和完成事件保留展开状态。
+- 已核对实际截图。本次未重建或启动原生 Desktop 安装包。
+
+## 初版验证记录(不代表已验证本次修正)
+
+- `pnpm test`:2574 项通过;新增 8 项命令展示与历史配对测试。
+- `pnpm build`、`openspec validate present-shell-command-cards --strict`、`git diff --check` 通过;定向样式检测无问题。
+- 浏览器验证真实 ToolBlock、主会话与 SubagentPanel 渲染:完整命令恢复、流式更新保留展开状态、复制全文、键盘和桌面菜单事件、失败/空输出,以及原有消息自动折叠均通过。
+- 浅色/深色、中英文、390px 窄屏、大字号和减少动画状态通过,无页面横向溢出;截图等待主题过渡结束后保存。
+- 此次验证覆盖 Web 源码与构建产物,未重新打包或启动原生 Desktop 安装包。
diff --git a/openspec/changes/preserve-acemodel-reasoning-on-config-load/.openspec.yaml b/openspec/changes/preserve-acemodel-reasoning-on-config-load/.openspec.yaml
new file mode 100644
index 000000000..265da3d94
--- /dev/null
+++ b/openspec/changes/preserve-acemodel-reasoning-on-config-load/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-23
diff --git a/openspec/changes/preserve-acemodel-reasoning-on-config-load/design.md b/openspec/changes/preserve-acemodel-reasoning-on-config-load/design.md
new file mode 100644
index 000000000..1e7c588ac
--- /dev/null
+++ b/openspec/changes/preserve-acemodel-reasoning-on-config-load/design.md
@@ -0,0 +1,20 @@
+## Context
+
+The built-in ACEModel catalog supplies stable vision and tool tags. Upstream `/models` probing can additionally save explicit reasoning options for the same profile. `parse_one_entry` currently overwrites all capability tags from the built-in catalog, while `validate_saved_models` correctly requires a saved reasoning declaration and capability tag to agree. Both the active config and last-good candidate use this parse path.
+
+## Goals / Non-Goals
+
+**Goals:** Keep catalog defaults and an explicit saved upstream reasoning declaration coherent in memory during every config load.
+
+**Non-Goals:** Change reasoning discovery, API payloads, user-selected manual capabilities, or general validation rules.
+
+## Decisions
+
+- In the existing ACEModel catalog normalization branch, derive the effective tags from the built-in catalog and append `reasoning` only when the parsed, valid saved reasoning declaration says it is supported. The declaration is the only evidence for a dynamic reasoning tag; model names alone do not imply it.
+- Keep the repair in the shared saved-model parser. It covers TUI, daemon, Desktop, active config, and last-good validation without adding a special recovery bypass.
+- Leave the source JSON unchanged. A catalog-sourced profile is interpreted against the current built-in catalog on each load; any later normal config save writes the coherent effective tags. This also avoids rewriting a credentials-bearing config merely for derived metadata.
+
+## Risks / Trade-offs
+
+- [An upstream model later stops declaring reasoning] → A fresh probe can clear the saved declaration through the existing model editor flow; startup cannot infer a remote change without probing.
+- [Malformed saved options] → Parsing and validation remain strict, so genuine invalid data still takes the existing recovery path.
diff --git a/openspec/changes/preserve-acemodel-reasoning-on-config-load/proposal.md b/openspec/changes/preserve-acemodel-reasoning-on-config-load/proposal.md
new file mode 100644
index 000000000..3e2ac9342
--- /dev/null
+++ b/openspec/changes/preserve-acemodel-reasoning-on-config-load/proposal.md
@@ -0,0 +1,23 @@
+## Why
+
+A saved ACEModel profile can contain valid reasoning options discovered from the upstream `/models` response. Startup currently replaces its capability tags with the smaller built-in catalog list, then rejects the still-valid reasoning options. The last-good snapshot goes through the same path, so ACECode cannot start.
+
+## What Changes
+
+- Keep an explicit saved reasoning declaration in catalog-sourced ACEModel profiles when refreshing built-in capability tags at load time.
+- Continue rejecting malformed reasoning settings and unrelated invalid saved-model profiles.
+- Cover startup and last-good loading with regression tests.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `model-selection`: Loading an ACEModel saved profile with discovered reasoning metadata must preserve a coherent, selectable profile.
+
+## Impact
+
+`src/config/saved_models.cpp`, its tests, and config recovery tests. No API or file format changes.
diff --git a/openspec/changes/preserve-acemodel-reasoning-on-config-load/specs/model-selection/spec.md b/openspec/changes/preserve-acemodel-reasoning-on-config-load/specs/model-selection/spec.md
new file mode 100644
index 000000000..39ae62ae3
--- /dev/null
+++ b/openspec/changes/preserve-acemodel-reasoning-on-config-load/specs/model-selection/spec.md
@@ -0,0 +1,21 @@
+## ADDED Requirements
+
+### Requirement: ACEModel catalog profiles preserve declared reasoning during startup
+
+When loading a catalog-sourced ACEModel saved profile, ACECode SHALL retain a valid saved reasoning declaration while refreshing built-in capability tags. The effective `reasoning` capability SHALL agree with the declaration. The same rule SHALL apply when validating a last-good snapshot.
+
+#### Scenario: Saved upstream reasoning survives catalog refresh
+
+- **WHEN** a catalog-sourced ACEModel profile has valid saved reasoning options with `supported: true`, and the built-in catalog does not list reasoning
+- **THEN** configuration loading succeeds with the saved reasoning options and a `reasoning` capability tag
+- **AND** loading does not initiate automatic rollback
+
+#### Scenario: No declared reasoning uses built-in capabilities
+
+- **WHEN** a catalog-sourced ACEModel profile has no saved reasoning options
+- **THEN** configuration loading uses the built-in capability tags without inventing a reasoning declaration
+
+#### Scenario: Invalid reasoning remains invalid
+
+- **WHEN** saved reasoning options are malformed, or a non-ACEModel profile has contradictory declared reasoning and capability tags
+- **THEN** configuration loading rejects that invalid profile through the existing recovery behavior
diff --git a/openspec/changes/preserve-acemodel-reasoning-on-config-load/tasks.md b/openspec/changes/preserve-acemodel-reasoning-on-config-load/tasks.md
new file mode 100644
index 000000000..beec3d8ef
--- /dev/null
+++ b/openspec/changes/preserve-acemodel-reasoning-on-config-load/tasks.md
@@ -0,0 +1,9 @@
+## 1. Catalog profile loading
+
+- [x] 1.1 Preserve explicit supported reasoning when refreshing ACEModel catalog capability tags; verify the saved-model parser and validation tests pass.
+- [x] 1.2 Add regression cases for declared reasoning, absent reasoning, and unchanged strict behavior for other profiles; verify focused saved-model tests pass.
+
+## 2. Startup recovery
+
+- [x] 2.1 Add a config-load regression using a valid active config and matching last-good snapshot with discovered ACEModel reasoning; verify startup succeeds without a recovery notice.
+- [x] 2.2 Run focused C++ tests, strict OpenSpec validation, and `git diff --check`; review the resulting diff for unrelated changes.
diff --git a/openspec/changes/refine-settings-surface/.openspec.yaml b/openspec/changes/refine-settings-surface/.openspec.yaml
new file mode 100644
index 000000000..7a8e2be6f
--- /dev/null
+++ b/openspec/changes/refine-settings-surface/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-08
diff --git a/openspec/changes/refine-settings-surface/design.md b/openspec/changes/refine-settings-surface/design.md
new file mode 100644
index 000000000..06c49a1e6
--- /dev/null
+++ b/openspec/changes/refine-settings-surface/design.md
@@ -0,0 +1,22 @@
+## Context
+
+See proposal.md for the visual defects. SettingsPage currently wraps both columns below a 40px title bar. Scoped CSS already flattens legacy cards but adds row borders without removing the old group separators. The shared stylesheet contains unrelated local edits.
+
+## Goals / Non-Goals
+
+Keep the change within the Settings shell and its existing style adapter. Do not alter configuration logic, modal dimensions, font sizing, or application-wide colors.
+
+## Decisions
+
+- Render navigation beside a full-height main column. Place existing window actions at the upper right of that column and reserve space above the scroll area; retain a visually hidden dialog name.
+- Derive Settings surface tokens from the existing surface and foreground tokens. Use these for neutral navigation states and subtle dividers, and use the content surface for standard form fields. Avoid changing global theme tokens or hardcoding colors in components.
+- Turn the legacy my-5 group rule into whitespace inside Settings. Retain row separators and inner form boundaries rather than restoring card frames or suppressing all borders.
+- Preserve the current panel size and narrow-screen navigation behavior. Check the right column and actions at scaled desktop sizes, narrow widths, dark mode, and large fonts.
+- 字重按语义层级维护:主标题保留当前标题字重,一级分组标题保留半粗体;其余字段、选项、卡片、导航和统计值统一为 400,使用 `text-fg` 与 `text-fg-mute` 区分主次。直接修正设置组件及其专属 CSS,不使用全局强制覆盖。设置打开的模型表单和主题导入中的下级标题遵守同一规则,独立弹窗主标题可保留加粗。
+- 保留字号、字体栈、颜色体系、布局和交互;同步仓库前端样式规范,避免后续新增设置恢复旧的加粗字段样式。浏览器验证使用模拟配置,覆盖明暗主题、中英文、窄屏和设置子弹窗。
+
+## Risks / Trade-offs
+
+- Shared CSS may affect embedded settings sections: inspect general, appearance, config, and representative list/form sections.
+- Overlay actions could cover scrolled content: reserve their space in the right column instead of floating them over its scrollport.
+- Live configuration must remain unchanged during visual checks: use a temporary preview that renders the real component with mocked API responses.
diff --git a/openspec/changes/refine-settings-surface/proposal.md b/openspec/changes/refine-settings-surface/proposal.md
new file mode 100644
index 000000000..5ed2a167d
--- /dev/null
+++ b/openspec/changes/refine-settings-surface/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+Settings still has a title bar across both columns and reuses the workspace background, unlike the supplied Claude references. The recent card flattening also leaves row underlines beside old group dividers, producing duplicate lines.
+
+## What Changes
+
+- Extend navigation and content to the top of the dialog; retain accessible expand and close controls independently in the upper right.
+- Use a bright content surface, subtly contrasting navigation, neutral selection, and restrained separators in both themes.
+- Replace legacy group dividers with whitespace so flattened rows do not produce double rules.
+- Preserve configuration behavior, search, font preferences, independent scrolling, dialog dimensions, and expansion.
+- 统一设置页字重:仅页面主标题和一级分组标题保留加粗;字段名、卡片名称、选项、导航和更深层标题使用常规字重,需要强调时使用现有前景色 token。
+
+## Capabilities
+
+### New Capabilities
+
+- `settings-surface`: Continuous Settings columns with themed surfaces and single separators.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+SettingsPage, scoped Settings CSS, and existing dialog architecture checks. Browser visual checks and the Web test/build pipeline verify the result. No API or dependency changes.
diff --git a/openspec/changes/refine-settings-surface/specs/settings-surface/spec.md b/openspec/changes/refine-settings-surface/specs/settings-surface/spec.md
new file mode 100644
index 000000000..59a1ab340
--- /dev/null
+++ b/openspec/changes/refine-settings-surface/specs/settings-surface/spec.md
@@ -0,0 +1,41 @@
+## Purpose
+
+Provide a quiet, readable Settings dialog whose continuous columns and restrained separators match the supplied visual references.
+
+## ADDED Requirements
+
+### Requirement: Continuous columns with accessible window actions
+
+The Settings dialog SHALL extend navigation and the content surface to its top edge without a visible shared Settings title bar. The dialog SHALL retain an accessible name, independent scrolling, and working expand, restore, and close actions.
+
+#### Scenario: Open and resize Settings
+- **WHEN** the user opens Settings and expands or restores it
+- **THEN** the navigation divider reaches both vertical edges of the dialog and window actions remain reachable without covering content
+
+### Requirement: Theme-aware surface hierarchy
+
+Settings SHALL display a bright content area in light mode, subtly contrasting navigation, neutral navigation selection, and fine separators. Dark mode and the user's accent and font preferences SHALL remain usable.
+
+#### Scenario: Change appearance
+- **WHEN** the user changes between light and dark modes or the available accent themes
+- **THEN** Settings keeps legible text, distinct control states, and consistent column surfaces
+
+### Requirement: Single separators between settings
+
+Adjacent configuration groups SHALL NOT display a flattened row underline together with a legacy group rule at the same boundary. Group spacing SHALL preserve readable hierarchy while inputs retain visible boundaries.
+
+#### Scenario: Flattened row followed by a group
+- **WHEN** a configuration row precedes another settings group
+- **THEN** the boundary contains at most one divider and whitespace separates the groups
+
+### Requirement: 仅两级标题使用加粗字重
+
+设置界面 SHALL 仅对页面主标题和一级分组标题使用加粗字重。字段名、选项标题、卡片名称、列表项、导航文字和更深层标题 SHALL 使用 400 常规字重;强调 SHALL 通过现有前景色 token 表达。设置子弹窗 SHALL 遵守相同层级规则,保留其独立主标题的字重。
+
+#### Scenario: 查看配置字段
+- **WHEN** 用户打开配置页
+- **THEN** “配置”“升级服务”“工作空间依赖项”和“默认终端”等主标题或一级标题保留加粗,而“升级服务 URL”“Python 工具”“Node.js 工具”“C# 工具”“终端类型”和“终端程序路径”使用常规字重
+
+#### Scenario: 切换设置分区和主题
+- **WHEN** 用户查看常规、外观、技能、模型、工具、安全中心等设置分区,或打开设置子弹窗,并切换明暗主题、语言与字体大小
+- **THEN** 下级标题、选项和字段不因选中、默认状态或主题发生加粗,主次文字继续使用主题前景色区分,现有配置行为保持不变
diff --git a/openspec/changes/refine-settings-surface/tasks.md b/openspec/changes/refine-settings-surface/tasks.md
new file mode 100644
index 000000000..fbc581f25
--- /dev/null
+++ b/openspec/changes/refine-settings-surface/tasks.md
@@ -0,0 +1,19 @@
+## 1. Settings surface
+
+- [x] 1.1 Remove the shared title bar, retain an accessible dialog name and independent window actions, and verify the existing dialog architecture checks.
+- [x] 1.2 Apply scoped surface colors and single group boundaries, then inspect rendered light/dark, accent, scaled, and narrow layouts with input borders intact.
+
+## 2. Integration verification
+
+- [x] 2.1 Run Web tests/build, strict OpenSpec validation, the design detector, and browser checks for navigation, search, expansion, scrolling, and close behavior; remove the temporary preview page.
+
+Verification: The Web test runner passed (2,135 pass assertions), production build and regex compatibility checks passed, and strict OpenSpec validation passed. Chromium checked the real Settings component in ten states: General, Appearance, Config, restored dialog, light/dark with blue/orange accents, 1280x720 scaling, 600px width, and large English text. Column edges aligned, actions remained outside the scrollport, group rules were absent, input borders remained, and content had no horizontal overflow. Search, independent scrolling, exact 13px expansion, restore, close, and mask dismissal passed with no browser exceptions or API writes. The design detector found only pre-existing styles outside the changed Settings region. Temporary preview HTML was removed; screenshots and measurements are stored in the task visualization directory.
+
+## 3. 统一设置标题字重
+
+- [x] 3.1 保留主标题和一级标题字重,统一字段、选项、卡片、导航及设置子弹窗内下级标题为常规字重,并同步前端样式规范。
+- [x] 3.2 运行 Web 测试、构建和严格 OpenSpec 校验;在浏览器检查各设置分区的实际字重,以及明暗主题、中英文、窄屏和减少动态效果状态。
+
+2026-09-19 验证:`pnpm test` 通过(2,552 条 pass 记录),`pnpm build` 和产物正则兼容检查通过,严格 OpenSpec 校验及 `git diff --check` 通过。Chromium 在 24 个页面/状态检查实际字重,覆盖全部 15 个设置入口、模型编辑及拖动预览、安全中心各页签、主题导入、明暗主题、390px 中英文大字号与减少动态效果。所有字段、选项和下级标题为 400,保留加粗的文字均为主标题或一级分组标题;模型表单 label/legend/h4、拖动预览另有实际样式断言。验证使用模拟 API,无真实配置写入,无浏览器异常。设计检测器只报告 globals.css 其他区域的 12 项既有样式,不涉及本次修改。
+
+验证边界:390px 英文大字号下,未修改的配置页“标题 + 重新检测”单行布局仍有 68px 横向溢出;本次未扩展为响应式布局修复。其余桌面状态和 390px 中文配置页无内容横向溢出。尚未重新打包或启动原生 Desktop 验证。临时页面已清理,截图、模拟数据和实际字重记录保存在任务可视化目录的 `settings-typography/`。
diff --git a/openspec/changes/refine-sidebar-workspace-disclosure/.openspec.yaml b/openspec/changes/refine-sidebar-workspace-disclosure/.openspec.yaml
new file mode 100644
index 000000000..cbd245e43
--- /dev/null
+++ b/openspec/changes/refine-sidebar-workspace-disclosure/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-20
diff --git a/openspec/changes/refine-sidebar-workspace-disclosure/design.md b/openspec/changes/refine-sidebar-workspace-disclosure/design.md
new file mode 100644
index 000000000..978c33696
--- /dev/null
+++ b/openspec/changes/refine-sidebar-workspace-disclosure/design.md
@@ -0,0 +1,24 @@
+## Context
+
+动机见 proposal.md。`WorkspaceGroup` 的行点击根据 `ws.active` 选择折叠或激活,激活会进入首页;会话列表以 Set 表示全部展开。已有手动收起保护、定位当前会话和后台全量缓存逻辑必须保持协作。
+
+## Goals / Non-Goals
+
+目标是将文件夹 disclosure 与导航分离,用每列表的可见数量表达分批展开。沿用当前字体、间距和颜色 token。不修改后端分页协议、显式工作区导航请求或桌面菜单动作。
+
+## Decisions
+
+- 文件夹行统一调用已有 `onToggle`;删除依赖 `ws.active` 的视觉分支。保留工作区运行身份元数据和新建按钮的事件隔离。
+- `expandedSessionLists` 使用 Map 保存可见数量,缺省为五条,展开每次加五;全部展示后点击折叠删除该键。用单一计数源替代 boolean 与额外分页状态并存。
+- 延续现有后台加载完整列表的缓存策略,渲染始终受可见数量约束。点击时更新计数,异步响应只更新数据,避免响应覆盖后续收起操作。
+- 部分列表首次补齐完整历史时,将新读取的历史会话追加到现有顺序后,再进行常规元数据合并;避免历史记录被当作新建会话提升到顶部。普通刷新与真正的会话创建事件保留原有排序策略。
+- 手动增量展开也设置自动揭示抑制,防止当前会话在后面的批次导致一次点击跳过多批。新的会话导航清除抑制,自动揭示仅增加到目标所在批次。
+- 揭示索引使用与渲染一致的非置顶列表;完整加载后用非置顶会话数判断是否到末尾,防止置顶或不精确 total 造成无限“展开显示”。
+- 首批后台请求在五条基础上补偿已知置顶数量,确保过滤后仍能显示五条普通会话;这复用既有 limit 参数。
+- 两类列表的展开/折叠按钮都仅保留 `hover:text-fg` 和颜色过渡。
+
+## Risks / Trade-offs
+
+- 缓存加载期间用户继续点击可能累积多次五条请求;这是每次点击递增的语义,数据返回后仅展示累计数量。
+- 保留全量缓存加载意味着此次只改变展示数量;后端分页与缓存优化不在本次范围。
+- 现有未提交的 Sidebar 会话 hover 缓存改动通过修改前快照核对并保留。
diff --git a/openspec/changes/refine-sidebar-workspace-disclosure/proposal.md b/openspec/changes/refine-sidebar-workspace-disclosure/proposal.md
new file mode 100644
index 000000000..1f1d90291
--- /dev/null
+++ b/openspec/changes/refine-sidebar-workspace-disclosure/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+侧边栏文件夹点击当前会激活工作区并进入新会话页面,选中会话也会使其所属文件夹变蓝;“展开显示”一次展示全部会话且悬停出现背景。这些行为与用户要求的纯折叠导航和渐进浏览不符。
+
+## What Changes
+
+- 文件夹行点击仅展开或收起,不切换会话、工作区或进入新会话页面;行内右侧新建按钮保持新会话入口。
+- 文件夹行取消所有激活样式,仅保留现有悬停样式。
+- “展开显示”每次增加五条,末批不足五条时显示剩余会话;全部显示后保留“折叠显示”返回五条。
+- 展开/折叠文字按钮悬停仅改变文字颜色,无背景变化。
+- 保留手动收起、全部收起和重新展开默认五条,以及定位选中会话时展示目标所在批次的行为。
+
+## Capabilities
+
+### New Capabilities
+
+- `sidebar-workspace-disclosure`: 文件夹行只负责展开/收起,取消激活样式。
+
+### Modified Capabilities
+
+- `sidebar-session-collapse`: 将一次全部展开改为每次增加五条,并限制文字按钮的悬停样式。
+
+## Impact
+
+影响 `web/src/components/Sidebar.jsx`、`web/src/lib/sidebarSessions.js`、`web/src/lib/sidebarWorkspaceSessions.js` 及其回归测试。工作区和无工作区列表共用计数投影;保留现有后台缓存加载方式和显式新建、菜单动作。首批请求通过已有 limit 参数补偿置顶名额,无后端协议变更。
diff --git a/openspec/changes/refine-sidebar-workspace-disclosure/specs/sidebar-session-collapse/spec.md b/openspec/changes/refine-sidebar-workspace-disclosure/specs/sidebar-session-collapse/spec.md
new file mode 100644
index 000000000..7ee0fd97e
--- /dev/null
+++ b/openspec/changes/refine-sidebar-workspace-disclosure/specs/sidebar-session-collapse/spec.md
@@ -0,0 +1,45 @@
+## MODIFIED Requirements
+
+### Requirement: Sidebar collapses long workspace session lists
+
+侧边栏 SHALL 默认仅展示每个工作区和无工作区列表前五条非置顶、非归档会话,展开状态在列表间独立。
+
+#### Scenario: Workspace has five or fewer sessions
+- **WHEN** 列表可见会话不超过五条
+- **THEN** 侧边栏 MUST 显示全部且不显示展开或折叠控件
+
+#### Scenario: Workspace has more than five sessions
+- **WHEN** 列表超过五条且尚未手动展开
+- **THEN** 侧边栏 MUST 仅显示前五条并提供“展开显示”
+
+#### Scenario: 收起文件夹后重新打开
+- **WHEN** 用户收起任意文件夹或全部文件夹后再打开
+- **THEN** 该列表 MUST 回到前五条,当前会话自动定位不能覆盖此次手动选择
+
+### Requirement: Expanded workspace lists can be collapsed again
+
+侧边栏 SHALL 每次展开最多增加五条会话;仍有隐藏会话时保留“展开显示”,全部展示后提供“折叠显示”。
+
+#### Scenario: Expand long list
+- **WHEN** 十二条会话的列表从五条开始连续点击“展开显示”
+- **THEN** 侧边栏 MUST 依次展示十条、十二条,不能在第一次点击时展示全部
+
+#### Scenario: 补齐历史保留已展示顺序
+- **WHEN** 用户展开显示触发首次补齐后续历史会话
+- **THEN** 系统 MUST 保留此前展示的会话顺序并追加后续历史记录,不能把补齐的旧会话提升到原有列表前面
+
+#### Scenario: Collapse long list
+- **WHEN** 用户点击“折叠显示”
+- **THEN** 列表 MUST 回到五条并再次提供“展开显示”
+
+#### Scenario: 悬停文字控件
+- **WHEN** 用户悬停任意列表的展开或折叠控件
+- **THEN** 系统 MUST 仅加深文字颜色,不改变背景
+
+#### Scenario: 新导航目标位于隐藏批次
+- **WHEN** 用户明确导航到当前列表中隐藏的会话
+- **THEN** 侧边栏 MUST 展示到包含该会话的五条批次,保留后续批次隐藏
+
+#### Scenario: 置顶与延迟加载
+- **WHEN** 列表包含置顶会话,或数据加载完成前用户已收起文件夹
+- **THEN** 系统 MUST 按非置顶会话判断批次和末尾,且加载完成不能重新展开被收起的文件夹或列表
diff --git a/openspec/changes/refine-sidebar-workspace-disclosure/specs/sidebar-workspace-disclosure/spec.md b/openspec/changes/refine-sidebar-workspace-disclosure/specs/sidebar-workspace-disclosure/spec.md
new file mode 100644
index 000000000..2bf7fe504
--- /dev/null
+++ b/openspec/changes/refine-sidebar-workspace-disclosure/specs/sidebar-workspace-disclosure/spec.md
@@ -0,0 +1,25 @@
+## Purpose
+
+明确侧边栏工作区文件夹的展开收起职责以及它与当前会话、新建任务入口之间的边界,使浏览其他文件夹不会中断当前会话,并使文件夹视觉只表达悬停而不表达选中状态。
+
+## ADDED Requirements
+
+### Requirement: 文件夹行只切换展开状态
+
+侧边栏工作区文件夹行 SHALL 仅切换会话列表的展开或收起状态,不导航、不改变当前会话或工作区选择。
+
+#### Scenario: 点击任意文件夹
+- **WHEN** 用户点击当前会话所属或其他工作区的文件夹行
+- **THEN** 系统 MUST 仅切换该文件夹展开状态,并保留当前会话和新建页面状态
+
+#### Scenario: 点击右侧新建按钮
+- **WHEN** 用户点击文件夹右侧的新建任务按钮
+- **THEN** 系统 MUST 打开该工作区的新会话页面,且不冒泡触发文件夹收起
+
+### Requirement: 文件夹行没有选中样式
+
+文件夹行 SHALL 不因当前会话或新建任务工作区而呈现激活背景、激活字色或选中样式,只保留悬停视觉反馈。
+
+#### Scenario: 当前会话属于文件夹
+- **WHEN** 当前会话或新建任务属于该工作区
+- **THEN** 文件夹行 MUST 与其他未悬停文件夹保持相同基础样式,会话行仍可表达选中状态
diff --git a/openspec/changes/refine-sidebar-workspace-disclosure/tasks.md b/openspec/changes/refine-sidebar-workspace-disclosure/tasks.md
new file mode 100644
index 000000000..d70475838
--- /dev/null
+++ b/openspec/changes/refine-sidebar-workspace-disclosure/tasks.md
@@ -0,0 +1,16 @@
+## 1. 交互与状态
+
+- [x] 1.1 将列表展开状态改为独立可见数量,完善五条批次、末批、收起重置和目标批次揭示;通过 sidebarSessions 定向测试。
+- [x] 1.2 文件夹行只调用折叠操作、去掉激活样式,接入分批显示并删除控件 hover 背景;通过组件行为和架构回归检查。
+
+## 2. 集成验证
+
+- [x] 2.1 验证文件夹点击保持当前会话、右侧新建按钮、逐批展开及会话顺序、置顶和收起后迟到响应;检查亮暗主题与窄屏控件样式。
+- [x] 2.2 运行 pnpm test、pnpm build、OpenSpec strict 验证与 git diff --check,并核对未覆盖原有 Sidebar 改动。
+
+## 验证记录
+
+- 最终前端全量测试 2632 项通过,生产构建和旧 WebView 正则兼容检查通过,OpenSpec strict 与差异检查通过。
+- 真实 Sidebar 组件配合模拟 API 的 Chromium 检查覆盖亮暗主题、中英文、1000px/390px 与 reduced-motion;文件夹鼠标及键盘操作、新建按钮、分批展示和无背景 hover 均通过。
+- 置顶补偿首批请求、全部收起重开五条、无工作区 5→10→12→5,以及收起后迟到全量响应均通过;每批完整会话 ID 序列断言通过,浏览器未记录 pageerror 或 console error。
+- 现有 Sidebar 会话 hover 缓存改动已对照修改前快照保留。未重新打包或验证已安装 Desktop 客户端。
diff --git a/openspec/changes/replace-usage-trend-with-heatmap/design.md b/openspec/changes/replace-usage-trend-with-heatmap/design.md
index d8a70b2cd..49345710f 100644
--- a/openspec/changes/replace-usage-trend-with-heatmap/design.md
+++ b/openspec/changes/replace-usage-trend-with-heatmap/design.md
@@ -17,6 +17,7 @@ SectionUsage in SettingsPage.jsx owns a 30-day request and inline bars. The exis
- Use a body-portal tooltip with measured viewport placement, existing anchoredMenuPosition helper, native-overlay attribute, exact localized numbers, and hover/focus/tap dismissal. Roving keyboard focus avoids hundreds of Tab stops; arrow keys move through dates or weeks.
- Put calendar and usage-summary styles alongside the new component to avoid modifying the already-dirty global theme file.
- Match the corrected reference at a maximum content width of 732px. Render the three existing summary values above their labels in one 62px-high bordered strip, with two vertical dividers and no responsive stacking. Preserve auxiliary information in accessible labels and native tooltips.
+- 使用情况整页通过统一容器共用 Token 活动图的 732px 最大宽度与左右自动外边距,在设置内容区水平居中;标题与刷新按钮、三项摘要、活动图、六项 Token 统计、模型明细、工作区明细及提示状态都保持相同的左右边界。窄窗口下填满可用宽度,保留 Token 统计原有两列/六列断点。
- Use an SVG viewBox for the heading, mode controls, entire dot matrix, and month axis. Compute equal square cells at approximately 11px with 3px gaps and 3px corner radii at the maximum width; reduce all geometry uniformly at narrower widths. Keep every in-range date, including when the calendar has 53 columns rather than the reference's 52. Render the last twelve month names at twelve equal axis positions, as explicitly requested, rather than variable-width calendar-month boundaries. Exact dates remain available per cell. Remove the visible range and intensity legend, retaining the cumulative range in accessible descriptions and tooltips.
## Risks / Trade-offs
diff --git a/openspec/changes/replace-usage-trend-with-heatmap/specs/web-usage-heatmap/spec.md b/openspec/changes/replace-usage-trend-with-heatmap/specs/web-usage-heatmap/spec.md
index 6e984972f..f8f11cfab 100644
--- a/openspec/changes/replace-usage-trend-with-heatmap/specs/web-usage-heatmap/spec.md
+++ b/openspec/changes/replace-usage-trend-with-heatmap/specs/web-usage-heatmap/spec.md
@@ -36,6 +36,10 @@ Cell intensity SHALL use the current theme accent, empty cells SHALL use neutral
- **WHEN** the settings content becomes narrower or wider
- **THEN** the summary remains one row, all twelve month labels remain equally spaced, daily cells remain square, the chart fits the available width without horizontal scrolling, and increasing available width beyond 732 pixels does not enlarge the chart
+#### Scenario: 使用情况整页居中
+- **WHEN** 用户查看使用情况页面
+- **THEN** 使用情况整页 SHALL 在设置内容区水平居中,标题与刷新按钮、三项摘要、Token 活动图、六项 Token 统计、模型明细、工作区明细及加载、错误、空数据提示共用最大宽度 732 CSS 像素与一致的左右边界;窄窗口下填满可用宽度且无横向溢出
+
#### Scenario: Annual request fails
- **WHEN** the annual request fails while the 30-day summary succeeds
- **THEN** the summary remains available and the calendar reports the failure rather than displaying zero consumption
diff --git a/openspec/changes/replace-usage-trend-with-heatmap/tasks.md b/openspec/changes/replace-usage-trend-with-heatmap/tasks.md
index edfc2d532..f924345b0 100644
--- a/openspec/changes/replace-usage-trend-with-heatmap/tasks.md
+++ b/openspec/changes/replace-usage-trend-with-heatmap/tasks.md
@@ -19,3 +19,17 @@
- [x] 4.3 Run frontend tests/build, strict specification validation, scoped design scan, and prepare the reviewed repair for master while preserving unrelated changes.
Verification: all frontend tests passed, production Vite build and regex compatibility scan passed, strict OpenSpec validation passed, scoped Impeccable detector returned no findings, and git diff --check passed. Playwright exercised the real SettingsPage with deterministic mock usage responses at 390, 600, 900, 1280, 1920, and 2560px widths. Assertions verified a single-row summary, 365 square daily cells, twelve equally spaced months, proportional gaps and corner radii, a maximum chart width of 732px, cells below 11px, and no horizontal scrolling. Chinese and English headings and native mode controls fit their scaled SVG frame even at 390px. Daily/weekly/cumulative, hover/focus/click, Enter/Space, Home/End, Escape, built-in light/dark and a custom accent, independent loading/failure, historical-only and empty data, and refresh recovery passed. Final screenshots include the page title and use fixture data; the installed native desktop application was not rebuilt.
+
+## 5. 居中用量概览区域(阶段验证,范围由第 6 节扩展为整页)
+
+- [x] 5.1 让摘要、活动图和六项 Token 统计共用最大宽度并水平居中,保留窄窗口布局与下方明细宽度。
+- [x] 5.2 验证宽窄窗口居中与无横向溢出,运行前端测试、构建、严格规范校验和差异检查。
+
+居中验证:Playwright 加载真实 SettingsPage 与模拟用量数据,检查 390、900、1280、1920、2560px 的中英文、明暗主题,以及 1920px 展开窗口,共 12 组布局。三部分左右留白差小于 1px,宽度均为可用宽度与 732px 的较小值;保留六项统计和 365 个日格,无横向溢出,下方明细仍占满内容宽度。390px 同时使用减少动态效果偏好。pnpm test、pnpm build、严格 OpenSpec 校验和 git diff --check 通过,布局检测无发现。截图使用模拟数据,未重新打包桌面应用。
+
+## 6. 使用情况整页居中
+
+- [x] 6.1 将标题、刷新按钮、概览、模型与工作区明细以及提示状态放入统一的 732px 居中容器。
+- [x] 6.2 验证宽窄窗口、中英文、展开窗口与提示状态的对齐和无横向溢出,运行前端测试、构建、严格规范校验和差异检查。
+
+整页验证:Playwright 加载真实 SettingsPage 与模拟数据,检查 390、900、1280、1920、2560px 的中英文布局、1920px 展开窗口以及加载、空数据、错误状态,共 14 组。整页宽度等于可用宽度与 732px 的较小值,所有直属内容左右边界与整页一致,左右留白差小于 1px,无横向溢出;有数据时保留 365 个日格。验证等待设置窗口入场动画结束后测量,全程启用减少动态效果偏好。pnpm test、pnpm build、严格 OpenSpec 校验与 git diff --check 通过,布局检测无发现。截图使用模拟数据,未重新打包桌面应用。
diff --git a/openspec/changes/retry-trailing-user-message/.openspec.yaml b/openspec/changes/retry-trailing-user-message/.openspec.yaml
new file mode 100644
index 000000000..f2cbbe6a6
--- /dev/null
+++ b/openspec/changes/retry-trailing-user-message/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-18
diff --git a/openspec/changes/retry-trailing-user-message/design.md b/openspec/changes/retry-trailing-user-message/design.md
new file mode 100644
index 000000000..5129c572c
--- /dev/null
+++ b/openspec/changes/retry-trailing-user-message/design.md
@@ -0,0 +1,25 @@
+## Context
+
+InputBar 与 ChatView 分别拒绝空输入;普通消息经 SessionClient 入队,由 AgentLoop 追加用户记录。前端存在完整 items、折叠投影、窗口化渲染和本地待发消息,后端模型历史与持久化 transcript 也并不完全相同。
+
+## Goals / Non-Goals
+
+目标是对真实末尾用户消息或用户主动中断的最后一条用户消息重试,并保留模型角色序列和结构化内容。不提供任意历史消息重试,不更改忙碌排队、首条消息创建和内置命令路由。
+
+## Decisions
+
+1. 普通重试从已加载完整 transcript 的最后一个 item 判断,绝不向前查找用户消息;排除空历史、非用户、缺少持久化身份、加载中、流式中、忙碌及只读状态。用户中断例外按决策 6 校验。点击时读取最新 store 再校验。输入含文字或附件/上下文时继续普通发送。
+2. 新增 `POST /api/sessions/:id/messages/retry`,仅接受 `expected_user_message_id`。模型输入由后端原始消息恢复,不从展示文本或富文本重新展开。
+3. AgentLoop 在队列锁下验证无活动和排队任务,检查模型历史末尾及完整持久化 transcript 的最后一条可见消息均为指定用户消息。跳过与前端一致的 is_meta、文件检查点、压缩检查点、回合计时、文件差异及隐藏目标上下文;不跳过可见 assistant、tool、system、error。事件观察器同时阻止尚未进入 JSONL 的错误、系统消息及部分输出。入队后在 worker 开始时再校验。
+4. 使用现有 Chat worker 任务承载重试身份,在正常回合生命周期中复用原始 user 消息,不重新持久化、发出 user 事件或展开 skill/附件。复用回合身份和文件检查点。重试准备阶段不执行会改写末尾历史的前置压缩;正常模型边界压缩规则保留。
+5. 在途提交继续只禁用发送动作,不禁用编辑器;回执须按 session 身份处理,不能影响切换后的会话或清除新草稿。
+6. 用户主动中断作为明确的例外:回合收尾时持久化 `transcript_only` 系统记录,携带 `user_aborted: true` 和 `retry_user_message_id`。前后端只有完整 transcript 末尾为该标记时才能查找其指定的最后一条用户消息;后来出现的任何可见记录使该资格失效。插话、普通错误、同文案系统提示不构成此例外。
+7. 中断标记只属于 transcript,不进入模型上下文。模型历史仍以原用户消息结尾时复用;已经有 assistant/tool 消息时复制原始结构化内容并分配新用户身份,复用正常持久化和检查点流程,不重复展开 skill。中断时已流出的正文以 `transcript_only` / `interrupted_output` assistant 消息持久化,避免重新加载时丢失输出,同时不把未完成的工具调用加入模型历史。
+8. 本地点击停止只显示提示并标记中断待确认;收到后端回合结束前空输入发送仍禁用。历史恢复通过持久化中断标记恢复资格,按钮与 Enter 继续共用入口。
+
+## Risks / Trade-offs
+
+- 页面显示与后端状态竞态 → 前端提交时复核,后端队列门控及 worker 复核。
+- 展示消息不能完整恢复原始模型输入 → 后端复用 ChatMessage 的 content、content_parts、metadata。
+- 纯系统提示与错误出现在用户消息之后 → 严格拒绝,不从历史中挑选较早用户消息。
+- 原生程序需要重新构建才能包含新端点 → 本任务验证源码、前端构建及 C++ 测试,不声称已更新安装包。
diff --git a/openspec/changes/retry-trailing-user-message/proposal.md b/openspec/changes/retry-trailing-user-message/proposal.md
new file mode 100644
index 000000000..a72e57096
--- /dev/null
+++ b/openspec/changes/retry-trailing-user-message/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+Desktop 会话停留在用户消息时,空输入目前无法再次请求模型。直接复制并追加用户消息会形成连续 user 记录,影响要求角色交错的模型。
+
+## What Changes
+
+- 完整 transcript 末尾为已持久化用户消息、会话空闲且输入无新内容时,允许点击发送或按 Enter 重试该消息。
+- 用户主动中断完成后也允许空输入重发上一条用户消息,保留中断前已有输出。后端持久化明确的用户中断标记,不通过提示文案判断。
+- 重试复用原始模型输入、附件及上下文;末尾仍是该用户时保留原身份,主动中断且已有完整 assistant/tool 记录时以新身份追加,避免相邻 user。
+- 后端原子校验会话空闲状态和消息身份;除明确的用户中断外,非用户末尾、过期请求及忙碌状态均拒绝。
+
+## Capabilities
+
+### New Capabilities
+
+- `trailing-user-message-retry`: 对 transcript 最后一条用户消息进行受校验保护的空输入重试。
+
+### Modified Capabilities
+
+无。忙碌时空输入仍不能排队。
+
+## Impact
+
+涉及共享 Desktop/Web composer、会话 API、SessionClient 和 AgentLoop。新增重试端点及前后端回归测试,无新依赖,不修改 TUI 交互。
diff --git a/openspec/changes/retry-trailing-user-message/specs/trailing-user-message-retry/spec.md b/openspec/changes/retry-trailing-user-message/specs/trailing-user-message-retry/spec.md
new file mode 100644
index 000000000..f85e5b40d
--- /dev/null
+++ b/openspec/changes/retry-trailing-user-message/specs/trailing-user-message-retry/spec.md
@@ -0,0 +1,69 @@
+## Purpose
+
+允许用户在已有会话的完整对话记录以用户消息或明确的用户中断标记结束时,通过空输入发送重新请求对应用户消息,保持结构化输入与模型角色顺序,并防止过期界面状态导致错误重发。
+
+## ADDED Requirements
+
+### Requirement: 空输入仅允许重试完整记录末尾的用户消息
+
+系统 SHALL 仅在会话已加载且空闲、输入没有文字或新增附件/上下文、完整 transcript 最后一个条目是已持久化用户消息或明确的用户主动中断标记时启用空输入发送。按钮和 Enter SHALL 使用相同规则。
+
+#### Scenario: 末尾用户消息
+- **WHEN** 已加载空闲会话的完整 transcript 以用户消息结束,输入为空或只有空白
+- **THEN** 点击发送或按 Enter 重试该用户消息
+
+#### Scenario: 非用户内容在用户消息之后
+- **WHEN** 用户消息之后存在 assistant、推理、tool、system、error 或其他可见条目,包括空 assistant 条目,且完整 transcript 末尾没有明确的用户主动中断标记
+- **THEN** 空输入发送保持禁用,不得跳过条目查找更早的用户消息
+
+#### Scenario: 会话不可重试
+- **WHEN** 会话无历史、未加载、加载失败、只读、正在运行、提交中或缺少末尾消息身份
+- **THEN** 空输入不触发重试,也不进入排队或新建会话路径
+
+#### Scenario: 输入含新内容
+- **WHEN** 用户输入文字或新增附件/上下文
+- **THEN** 使用正常发送或排队流程
+
+### Requirement: 重试复用原始输入并保持角色序列
+
+系统 MUST 使用原始用户消息重新请求模型,保留附件、上下文及展示信息。模型历史末尾仍为该用户时保留消息身份,不增加重复 user 记录;主动中断前已有 assistant/tool 记录时保留已有记录并以新身份追加原始用户内容。
+
+#### Scenario: 富文本或附件消息重试
+- **WHEN** 末尾用户消息含已展开 skill、附件或上下文
+- **THEN** 模型收到原始结构化内容,transcript 保留原 user 记录且后续响应追加在其后
+
+### Requirement: 后端拒绝过期或并发重试
+
+系统 MUST 在接受重试时及执行时校验完整 transcript 末尾和指定用户身份符合对应重试规则,接受动作必须与任务队列状态校验串行化。
+
+#### Scenario: 界面状态过期
+- **WHEN** 点击时后端已有后续消息,或请求携带的用户消息身份不再匹配
+- **THEN** 拒绝重试,不追加消息且不调用模型
+
+#### Scenario: 重复点击或已有排队任务
+- **WHEN** 重试已接受或已有其他活动/排队任务
+- **THEN** 后续重试被拒绝,单次操作最多触发一个模型回合
+
+#### Scenario: 请求期间切换会话或编辑草稿
+- **WHEN** 重试请求尚未完成,用户切换会话或输入下一条消息
+- **THEN** 原会话请求回执不改变新会话的忙碌状态,也不清除新草稿
+
+### Requirement: 用户中断后可直接重发
+
+系统 MUST 以持久化的结构化用户中断标记判断重发资格,并等待后端实际结束当前回合。
+
+#### Scenario: 中断发生在输出之前
+- **WHEN** 用户停止回合,后端确认结束,模型历史仍以原用户消息结束
+- **THEN** 空输入重发复用原用户消息,不产生相邻 user
+
+#### Scenario: 中断发生在部分回复或工具结果之后
+- **WHEN** 用户停止回合,后端确认结束,已有部分回复或工具结果
+- **THEN** 保留已有输出并允许空输入重发;模型历史已有 assistant/tool 时追加原始结构化用户内容,否则复用原用户消息
+
+#### Scenario: 中断确认前或中断之后又有新记录
+- **WHEN** 后端尚未确认中断,或中断标记后出现新消息、错误或活动任务
+- **THEN** 不允许根据旧中断标记重发
+
+#### Scenario: 恢复中断历史
+- **WHEN** 重新加载的完整 transcript 以有效用户中断标记结束
+- **THEN** 恢复同样的重发资格;普通中断字样或插话标记不得获得该资格
diff --git a/openspec/changes/retry-trailing-user-message/tasks.md b/openspec/changes/retry-trailing-user-message/tasks.md
new file mode 100644
index 000000000..621f8da7d
--- /dev/null
+++ b/openspec/changes/retry-trailing-user-message/tasks.md
@@ -0,0 +1,39 @@
+## 1. 后端重试
+
+- [x] 1.1 增加 AgentLoop 串行重试入口及原消息复用,C++ 定向测试验证角色末尾、消息身份、并发拒绝和结构化输入保留。
+- [x] 1.2 接入 SessionClient 和重试 API,更新 daemon-api 文档,编译并验证请求校验及返回状态。
+
+## 2. 输入框接入
+
+- [x] 2.1 完整 transcript 末尾判定及提交时复核接入共享发送入口,前端测试验证按钮/Enter 同规则、禁止跳过非用户条目及状态边界。
+
+## 3. 集成验证
+
+- [x] 3.1 完成前端 pnpm test、pnpm build、C++ 定向测试、OpenSpec 严格校验和 git diff --check,记录验证边界并检查无关改动保留。
+
+## 4. 用户中断后重发
+
+- [x] 4.1 后端持久化用户中断标记并校验重试身份,按模型真实末尾复用或追加原始输入,覆盖输出前、部分输出、工具后、中断历史恢复与过期资格。
+- [x] 4.2 前端识别明确中断标记,等待后端结束后启用空输入发送,覆盖完整历史、按钮/Enter 和状态边界。
+- [x] 4.3 更新 API 文档并完成前端测试/构建、C++ 定向测试、OpenSpec 严格校验和差异检查。
+
+## 用户中断扩展验证记录
+
+- [x] 发布集成:测试清理前销毁 SessionManager,关闭 Windows SQLite 句柄;重新运行重发定向及完整原生回归。
+
+- `pnpm test`、`pnpm build` 通过,生产产物正则兼容性检查通过。
+- `cmake --build build --config Release --target acecode_unit_tests --parallel 6` 通过。
+- C++ 定向回归 34 项通过,覆盖重试、插话、目标中断、回合计时/差异、trajectory、skill、历史恢复及 HTTP 入口。
+- Playwright 挂载真实 InputBar 并使用实际 transcript reducer,24 项按钮、Enter、中断确认、历史恢复、禁止状态和 390px 中英文检查通过,无页面错误。
+- 中断后收到的剩余 token 与持久化部分回复不会产生重复回复或重复中断提示;旧中断标记回放不会重新启用已完成回合的重试。
+- `openspec validate retry-trailing-user-message --strict`、`git diff --check` 通过。未改动其他任务的功能,ChatView 的本任务改动仅涉及 `abortPending` 取值和重试判断。
+- 验证范围为源码、Web 构建、浏览器及本地 C++ 测试;没有重新打包或替换已安装 Desktop。
+
+## 原始变更验证记录
+
+- `pnpm test`、`pnpm build` 通过,生产产物正则兼容性检查通过。
+- `cmake --build build --config Release --target acecode_unit_tests --parallel 6` 通过。
+- `AgentLoopUserMessageRetry.*:AgentLoopTurnSteering.*:AgentLoopTrajectoryTest.*:WebServerHttp.RetryLastUserMessage*` 共 15 项通过。
+- Playwright 挂载真实 InputBar,13 项按钮、Enter、状态边界及 390px 中英文检查通过,无页面错误。
+- `openspec validate retry-trailing-user-message --strict`、`git diff --check` 通过。无关已修改文件哈希不变,daemon-api 原有改动保留。
+- 验证范围为源码、Web 构建、浏览器及本地 C++ 测试;没有重新打包或替换已安装 Desktop。
diff --git a/openspec/changes/revert-pr65-sidebar-ui/.openspec.yaml b/openspec/changes/revert-pr65-sidebar-ui/.openspec.yaml
new file mode 100644
index 000000000..1b9acb7fd
--- /dev/null
+++ b/openspec/changes/revert-pr65-sidebar-ui/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-22
diff --git a/openspec/changes/revert-pr65-sidebar-ui/design.md b/openspec/changes/revert-pr65-sidebar-ui/design.md
new file mode 100644
index 000000000..1620ce814
--- /dev/null
+++ b/openspec/changes/revert-pr65-sidebar-ui/design.md
@@ -0,0 +1,25 @@
+## Context
+
+See `proposal.md` for motivation and scope. The starting branch is clean `master` at `87ff29ea`. PR #65's net merge diff touches 19 paths. Subsequent work overlaps only `web/src/styles/globals.css` and `web/src/lib/runTests.js` within those paths, adding independent queue styles, a composer font adjustment, and test registrations.
+
+## Goals / Non-Goals
+
+**Goals:** Restore the exact pre-PR #65 implementation wherever no later changes overlap; preserve later changes in shared files; keep restored copy localized.
+
+**Non-Goals:** Redesign the sidebar, revert other contributions by the same author, rewrite history, change branches, publish packages, or alter daemon behavior.
+
+## Decisions
+
+- Apply `git revert --no-commit -m 1 398f906c3b77d31d7d2967e4c3bb122b9a358c26`. Reverting the net merge includes the onboarding integration adjustments and avoids undoing unrelated commits brought into the PR branch. Resetting to the old parent would discard subsequent work; restoring complete shared files would lose unrelated edits.
+- Keep the original regression tests restored by the revert and regenerate the localization catalog. English overrides are added only if the restored strings require them. The existing `webui-custom-sidebar` specification does not cover these visual details; this change adds the restored behavior without rewriting unrelated historical requirements.
+- Compare the zero-context rollback diff with the original merge's inverse, ignoring only blob IDs and hunk offsets, and separately compare preserved later changes in shared files. Run the existing frontend suite, production build, motion checker when its browser dependency is available, and strict OpenSpec validation.
+
+## Risks / Trade-offs
+
+- Shared-file overlap could erase later work -> use a three-way revert and compare the two shared-file deltas against the pre-revert HEAD.
+- Restored onboarding text may be missing from the current source catalog -> regenerate and review the localization diff before tests.
+- Web checks do not validate an installed desktop executable -> report the validation scope explicitly; package deployment remains a separate action.
+
+## Migration Plan
+
+Create a normal local revert commit after validation. The rollback needs no data migration; it can itself be reverted to reapply the PR if later requested.
diff --git a/openspec/changes/revert-pr65-sidebar-ui/proposal.md b/openspec/changes/revert-pr65-sidebar-ui/proposal.md
new file mode 100644
index 000000000..2c290cfb4
--- /dev/null
+++ b/openspec/changes/revert-pr65-sidebar-ui/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+The user reports that LIUXIN557's latest sidebar changes have an excessive impact on the interface and requests a rollback. PR #65 introduced those changes in merge commit `398f906c3b77d31d7d2967e4c3bb122b9a358c26`; the adjacent PR #67 changes the development launcher and is outside this rollback.
+
+## What Changes
+
+- Reverse the net changes from PR #65 against its first parent, restoring the previous sidebar spacing, alignment, disclosure controls, workspace actions, menu labels, and four-dot running indicator.
+- Restore the corresponding onboarding steps, regression tests, and motion checker; remove the preview and design document introduced only by PR #65.
+- Regenerate the localization catalog for the restored UI text while preserving subsequent independent changes, including tool preambles and queue behavior.
+- Record the rollback as a new local commit on the current branch, preserving Git history.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `webui-custom-sidebar`: Restore the sidebar presentation and workspace interactions from the first-parent baseline of PR #65.
+
+## Impact
+
+The 19 paths changed by PR #65, the generated source catalog and any required English overrides, and this OpenSpec change. No daemon protocol or dependency changes are needed. Existing release packages are not rebuilt or published by this change.
diff --git a/openspec/changes/revert-pr65-sidebar-ui/specs/webui-custom-sidebar/spec.md b/openspec/changes/revert-pr65-sidebar-ui/specs/webui-custom-sidebar/spec.md
new file mode 100644
index 000000000..f4b664a84
--- /dev/null
+++ b/openspec/changes/revert-pr65-sidebar-ui/specs/webui-custom-sidebar/spec.md
@@ -0,0 +1,32 @@
+## ADDED Requirements
+
+### Requirement: Restored compact sidebar presentation
+
+The sidebar SHALL use the presentation from before PR #65, including compact row spacing, content-sized trailing columns, the nested extension list, and the original header, icon, footer, and top-bar alignment.
+
+#### Scenario: Sidebar is visible after rollback
+- **WHEN** the user views the sidebar
+- **THEN** its layout matches the first-parent baseline of PR #65 rather than the fixed-height rows and fixed 76-pixel trailing columns introduced by that PR
+- **AND** unrelated features added after that baseline remain available
+
+### Requirement: Explicit workspace actions
+
+The sidebar SHALL provide the original add-workspace and collapse-all actions, preserve workspace-row expansion independently from workspace activation, and offer activation and expansion actions in the workspace context menu. Onboarding SHALL describe the restored add-workspace entry point.
+
+#### Scenario: User clicks a workspace row
+- **WHEN** the user clicks a workspace row or activates that row with Enter or Space
+- **THEN** the workspace folder expands or collapses without implicitly activating the workspace
+
+#### Scenario: User manages sidebar workspaces
+- **WHEN** the user opens the workspace header actions or a workspace context menu
+- **THEN** the original add, collapse-all, activate, and expand or collapse actions are available in their corresponding locations
+- **AND** the guided tour points to the available add-workspace button
+
+### Requirement: Four-dot running feedback
+
+Running sidebar sessions SHALL display the original four-dot indicator with a 16-pixel slot and retain visible running feedback under reduced-motion preferences.
+
+#### Scenario: Session is running
+- **WHEN** a session is in progress
+- **THEN** its sidebar indicator uses four orbiting dots under normal motion preferences
+- **AND** reduced-motion preferences replace orbital movement with the existing fade feedback
diff --git a/openspec/changes/revert-pr65-sidebar-ui/tasks.md b/openspec/changes/revert-pr65-sidebar-ui/tasks.md
new file mode 100644
index 000000000..aaca3ac55
--- /dev/null
+++ b/openspec/changes/revert-pr65-sidebar-ui/tasks.md
@@ -0,0 +1,9 @@
+## 1. Restore the prior sidebar
+
+- [x] 1.1 Reverse PR #65's first-parent merge delta and verify that the rollback patch exactly matches its inverse while preserving later changes in shared files.
+- [x] 1.2 Regenerate the restored static-copy catalog, adding English overrides only where needed; verify the catalog diff contains only relevant restored and removed text.
+
+## 2. Verify and record the rollback
+
+- [x] 2.1 Run `pnpm test`, `pnpm build`, the existing loading-motion browser check when available, strict OpenSpec validation, and `git diff --check`; record results and any runtime coverage limitation.
+- [x] 2.2 Create a scoped local revert commit and verify its file list, current branch, and final working-tree status.
diff --git a/openspec/changes/revert-pr65-sidebar-ui/verification.md b/openspec/changes/revert-pr65-sidebar-ui/verification.md
new file mode 100644
index 000000000..0e4a35430
--- /dev/null
+++ b/openspec/changes/revert-pr65-sidebar-ui/verification.md
@@ -0,0 +1,13 @@
+# Verification
+
+- Starting checkout: clean `master`, `87ff29ea`.
+- Target: LIUXIN557's PR #65, merge `398f906c3b77d31d7d2967e4c3bb122b9a358c26`, first-parent baseline `49179200`.
+- The rollback's zero-context diff matches the inverse merge diff for all 19 original paths, excluding only blob IDs and hunk line offsets. Seventeen paths match the old baseline exactly; later changes to `globals.css` and `runTests.js` are preserved exactly.
+- Localization regeneration passed with eight reviewed English overrides. The catalog restores eleven source strings and removes two superseded onboarding strings; existing entries are otherwise unchanged.
+- `pnpm test`: passed, with 2,723 `[pass]` markers and no failure markers.
+- `pnpm build`: passed; Vite transformed 3,097 modules and the compatibility check scanned 4,467 regex literals without lookbehind. The log-printing wrapper hit a Windows console encoding error after the build returned zero; reading the saved UTF-8 log confirmed successful completion without rerunning the build.
+- `python -X utf8 web/scripts/check-session-loading-motion.py`: passed on Chromium 145.0.7632.6 for all eight light/dark, 390/1280-pixel, normal/reduced-motion combinations, plus live preference changes and resting-position checks.
+- `openspec validate revert-pr65-sidebar-ui --strict`: passed.
+- Staged and unstaged `git diff --check`: passed.
+
+Validation covers source, the production Web build, and the production loading-indicator component in Chromium. The installed desktop application was not rebuilt or restarted. This change does not publish a package or push to a remote branch.
diff --git a/openspec/changes/review-pr60-pr61-integration/.openspec.yaml b/openspec/changes/review-pr60-pr61-integration/.openspec.yaml
new file mode 100644
index 000000000..f2cbbe6a6
--- /dev/null
+++ b/openspec/changes/review-pr60-pr61-integration/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-18
diff --git a/openspec/changes/review-pr60-pr61-integration/design.md b/openspec/changes/review-pr60-pr61-integration/design.md
new file mode 100644
index 000000000..acea93a18
--- /dev/null
+++ b/openspec/changes/review-pr60-pr61-integration/design.md
@@ -0,0 +1,21 @@
+## Context
+
+当前 PR 检查仅覆盖 Linux。图形安装器使用 `.acecode-install.lock`,更新器使用 `.ACECode.update.lock`;前者打开既有 FIFO 时可能阻塞。顶部导航四个 24px 按钮与三个 4px 间距已占满原来的 108px。
+
+## Goals / Non-Goals
+
+完成两个 PR 的审核、必要修复和合并。生产签名、公证、干净非管理员 Mac 的端到端安装仍属于实际发布验收,本任务不执行发布。
+
+## Decisions
+
+- 为顶部导航定义统一最小宽度,将新增 8px 左边距计入桌面及窄窗口规则。
+- 图形安装器沿用既有更新器锁名与锁安全策略:非阻塞、禁止跟随符号链接,检查普通文件、单硬链接及当前用户属主,保持锁 inode 不删除。
+- 使用真实文件系统故障注入验证互斥、异常锁、复制失败与回滚;在 GitHub macOS runner 运行原生 Swift 测试及双架构 UI 编译。
+- `NSApplication.shared` 必须先于含窗口属性的 UI delegate 构造;原生冒烟测试运行完整应用包并检查个人/系统页面截图,覆盖编译无法发现的启动错误。
+- 真实打包在 `hdiutil create` 遇到 `Resource busy`:明确使用 HFS+,仅对该错误最多尝试三次,每次使用不同的临时输出;其他错误立即失败。可信 PKG 的生成、公证和上传先于 DMG,正式 Release 仍要求整个构建成功。
+- 本次 macOS 自定义目录和双架构 DMG 规范取代 `add-macos-self-update`、`allow-release-without-macos-pkg` 等旧变更里的目录白名单和禁用 DMG 条款;签名、版本、更新 ZIP 校验与可选 PKG 规则继续适用。
+
+## Risks / Trade-offs
+
+- 共享目录内他人拥有的锁会被明确拒绝,避免对不可信锁操作;用户可选择个人可写目录。
+- 离线测试能验证顺序和失败行为,不能证明生产公证或 Gatekeeper 下载隔离行为;发布文档保留这些验收限制。
diff --git a/openspec/changes/review-pr60-pr61-integration/proposal.md b/openspec/changes/review-pr60-pr61-integration/proposal.md
new file mode 100644
index 000000000..541ed87eb
--- /dev/null
+++ b/openspec/changes/review-pr60-pr61-integration/proposal.md
@@ -0,0 +1,22 @@
+## Why
+
+审核 PR #60 与 #61 时发现顶部导航新增左边距没有计入最小宽度,且 macOS 图形安装器与应用内更新器没有共用安装锁。需要在合并前修复边界问题,并让原生安装器回归测试进入 PR 检查。
+
+## What Changes
+
+- 保留侧栏图标对齐,修正窄窗口顶部导航的宽度预算。
+- 支持可信应用在可写自定义目录内原位更新,以及带原生安装器的双架构签名 DMG。
+- 图形安装器与应用内更新共用锁文件,拒绝异常文件类型、链接和不安全属主,获取锁时不得阻塞。
+- 在 PR CI 中运行 macOS 原生安装器测试、双架构编译及离线发布脚本测试。
+
+## Capabilities
+
+### New Capabilities
+- `macos-graphical-installation`: 自定义安装位置、可信 DMG、互斥安装与失败恢复;此变更取代旧变更中“仅标准目录”及“禁止 DMG”的限制。
+
+### Modified Capabilities
+无。
+
+## Impact
+
+涉及侧栏与顶部导航样式、macOS 安装器及更新器、打包工作流、脚本测试和发布文档;不发布版本、不调用生产签名或公证凭据。
diff --git a/openspec/changes/review-pr60-pr61-integration/specs/macos-graphical-installation/spec.md b/openspec/changes/review-pr60-pr61-integration/specs/macos-graphical-installation/spec.md
new file mode 100644
index 000000000..6a45a66a1
--- /dev/null
+++ b/openspec/changes/review-pr60-pr61-integration/specs/macos-graphical-installation/spec.md
@@ -0,0 +1,56 @@
+## Purpose
+
+让用户通过可信的原生图形安装器将 ACECode 安装到个人、系统或自选可写目录,并在自定义位置安全更新;同时规定安装并发、异常输入、失败恢复与正式发布资产的可验证行为。
+
+## ADDED Requirements
+
+### Requirement: 支持安全自定义安装和原位更新
+安装器 SHALL 提供个人、系统和自定义目录选择,并仅使用当前用户权限。更新器 MUST 接受可写目录中的真实规范化 ACECode.app,拒绝符号链接、应用包嵌套及 App Translocation,并保留签名、团队、版本校验。
+
+#### Scenario: 自定义目录安装后更新
+- **WHEN** 用户选择可写自定义目录并安装可信应用
+- **THEN** 应用可在原位置更新,无需管理员授权或迁移用户配置
+
+#### Scenario: 不安全更新目标
+- **WHEN** 更新目标是符号链接、嵌套应用或没有父目录写权限
+- **THEN** 更新在替换文件前失败并给出可操作错误
+
+### Requirement: 所有应用替换入口相互排斥
+图形安装器和应用内更新器 MUST 对同一安装目录互斥执行;无法安全取得锁时 MUST 及时失败,保留原应用。
+
+#### Scenario: 更新与手动安装并发
+- **WHEN** 应用内更新或另一图形安装已持有该目录的锁
+- **THEN** 新安装失败,且不会复制或移动现有应用
+
+#### Scenario: 锁路径不是安全普通文件
+- **WHEN** 锁路径为 FIFO、目录、符号链接、多硬链接或其他用户拥有的文件
+- **THEN** 安装及时拒绝,不永久等待且不修改现有应用
+
+### Requirement: 替换失败保留可恢复应用
+安装器 SHALL 在同一目录暂存并验证候选应用;确认替换且目标未变化后才移动旧应用。提交失败 MUST 恢复旧版本;恢复失败 MUST 保留备份并报告路径。
+
+#### Scenario: 提交失败
+- **WHEN** 旧应用已备份但候选应用提交失败
+- **THEN** 自动恢复旧应用;若恢复也失败则保留并报告备份位置
+
+### Requirement: 正式发布提供双架构可信图形安装器
+正式 Release MUST 包含准确匹配版本的 x64 和 arm64 签名、公证、staple 的 DMG,内部应用 MUST 与对应架构已签名应用一致。更新资产 SHALL 继续使用精确匹配的更新 ZIP;PKG 仍为可选双架构资产。
+
+#### Scenario: 资产集合错误
+- **WHEN** DMG 缺失、多余、重复、版本不符或含 unsigned 后缀
+- **THEN** Release 收集失败,不发布不完整安装器
+
+#### Scenario: 安装器签名改变内部应用
+- **WHEN** 封装或签名导致内部已签名应用内容变化
+- **THEN** 构建失败,不发布该 DMG
+
+### Requirement: 磁盘映像创建失败不得丢失已验证 PKG
+打包工作流 SHALL 在 DMG 创建前提供已签名、公证的 PKG 构建产物。磁盘映像创建的临时占用错误 MUST 有限重试,其他错误 MUST 立即终止 DMG 发布;正式 Release 仍要求完整产物集合通过校验。
+
+#### Scenario: 磁盘映像短暂被占用
+- **WHEN** 磁盘映像创建返回临时资源占用
+- **THEN** 最多尝试三次,各次使用独立临时输出,恢复后继续签名与公证
+
+#### Scenario: 磁盘映像无法创建
+- **WHEN** 临时错误持续三次或出现永久错误
+- **THEN** DMG 阶段失败而不发布无效镜像,之前已验证的 PKG 仍可供手动测试下载
diff --git a/openspec/changes/review-pr60-pr61-integration/tasks.md b/openspec/changes/review-pr60-pr61-integration/tasks.md
new file mode 100644
index 000000000..1375e45fb
--- /dev/null
+++ b/openspec/changes/review-pr60-pr61-integration/tasks.md
@@ -0,0 +1,20 @@
+## 1. 侧栏对齐修复
+
+- [x] 1.1 计入顶部按钮新增左边距,通过 390px 和桌面宽度浏览器布局验证。
+- [x] 1.2 运行 Web 全量测试及生产构建,审核并合并 PR #60。
+
+## 2. macOS 安装修复
+
+- [x] 2.1 统一安装器与更新器锁,并通过并发及异常锁回归测试。
+- [x] 2.2 为 PR 添加 macOS 原生测试及双架构编译,确认远端检查通过。
+- [x] 2.3 完成更新路径、重启、发布流程审核,运行针对性 C++ 及离线脚本测试,更新发布文档。
+- [x] 2.4 在创建窗口前初始化 AppKit,并通过真实应用包的个人/系统页面启动截图测试。
+
+## 3. 集成验证
+
+- [x] 3.1 严格验证 OpenSpec 与差异,完成 PR #61 合并,核对工作区与远端提交一致。
+
+## 4. 真实 macOS 打包
+
+- [x] 4.1 修复磁盘映像临时占用与 PKG 上传顺序,通过短暂占用、持续占用及永久错误测试。
+- [x] 4.2 基于合并后的 master 构建两个架构的签名 PKG/DMG,下载并核对 PKG 架构、安装域和文件哈希。
diff --git a/openspec/changes/review-pr60-pr61-integration/validation.md b/openspec/changes/review-pr60-pr61-integration/validation.md
new file mode 100644
index 000000000..ecac1158e
--- /dev/null
+++ b/openspec/changes/review-pr60-pr61-integration/validation.md
@@ -0,0 +1,42 @@
+## 本地验证
+
+- PR #60:`pnpm test`、`pnpm build` 通过。使用真实 TopBar 组件和生产 CSS 在 Chromium 验证 390px / 1280px、侧栏展开 / 折叠、中英文和 reduced-motion,共 8 种组合。原 PR 在窄窗口或折叠状态溢出 8px,修复后全部包含于导航区域。
+- PR #61:WSL/Clang 与 Windows/MSVC 分别编译并通过 8 项 `MacosBundleLayout`、`MacosBundleInstallPath`、`DesktopRestart` 测试。
+- 对 Git index 的完整快照运行 9 项安装器发布测试、4 项便携包测试、发布 Shell 契约检查和新增脚本语法检查,全部通过。Windows 的 `core.autocrlf` 会将归档转为 CRLF;验证快照使用 `git -c core.autocrlf=false archive` 保持 Git 中的 LF。
+- OpenSpec 严格验证与 `git diff --check` 通过。
+
+## 远端原生验证
+
+- 代码提交 `ee7fe02d5d8967559c94342eee5804fd61ef624a` 的 [PR 检查](https://github.com/tmoonlight/acecode/actions/runs/35357925376) 中,`macos-installer-tests` 与 `web-tests (linux-x64)` 已通过。
+- 原生 Swift 19 项测试全部通过;x86_64 / arm64 UI 均编译通过;真实测试应用包的个人/系统安装页面启动并生成有效 PNG。
+- macOS 上的 9 项安装器发布测试、4 项便携包测试及发布 Shell 契约检查也全部通过。
+
+## 验证范围
+
+以上为合并前验证,没有调用生产证书或 Apple 公证服务。真实签名安装、下载隔离、非管理员干净机器及 macOS 11 运行验收按照发布文档执行。
+
+## 合并结果
+
+- PR #60 合并提交为 `658ee8343ccd5e42906c37a9d3195e2062c3380e`。
+- PR #61 已修复并合并,GitHub 合并提交为 `ee7fe02d5d8967559c94342eee5804fd61ef624a`。
+- 用户明确要求全部合并后统一编译,再查看 macOS PKG。已在 macOS/Web CI 和双平台定向 C++ 测试通过后合并,Linux 全量 CI 当时仍在运行。
+- 合并后本地 `master` 与远端相同,开放 PR 为 0;后续使用合并后的 `master` 手动构建 macOS PKG/DMG。
+
+## 真实打包修复
+
+- 首次合并后手动构建为 [Package 35358898470](https://github.com/tmoonlight/acecode/actions/runs/35358898470),源提交 `5b4b322d701c1a13514dd77ea2b89a58048ca0f2`。
+- arm64 应用和图形安装器已通过真实签名、公证及 Gatekeeper 验证,但 `hdiutil create` 返回 `Resource busy`。原顺序导致该错误阻断后续 PKG 生成。
+- 修改为最多三次独立路径尝试,仅重试 `Resource busy`;明确使用 HFS+。PKG 在 DMG 前完成创建、公证、验证和上传,保留失败时的已验证安装包。
+- 修复后完整 Git index 快照上的 12 项安装器发布测试、4 项便携包测试、Shell 契约与语法检查全部通过;OpenSpec 严格验证及 `git diff --cached --check` 通过。
+
+## 合并后 macOS 成品
+
+- [Package 35360448058](https://github.com/tmoonlight/acecode/actions/runs/35360448058) 使用源提交 `288133c760641e09373932db3cceacadd4693be6`,`macos-arm64` 和 `macos-x64` 两个任务均成功。
+- 两个架构的应用、PKG 和图形安装器 DMG 均通过真实签名、Apple 公证、stapler 验证及 Gatekeeper。PKG 在 DMG 前上传;本次两个 DMG 均一次创建成功。
+- 成品沿用项目当前版本号 `0.9.20`。本次为合并后手动工作流产物,不创建正式发布或版本标签。
+- 下载入口:Apple Silicon [PKG](https://github.com/tmoonlight/acecode/actions/runs/35360448058/artifacts/10555280101) / [DMG](https://github.com/tmoonlight/acecode/actions/runs/35360448058/artifacts/10554925344);Intel [PKG](https://github.com/tmoonlight/acecode/actions/runs/35360448058/artifacts/10554870943) / [DMG](https://github.com/tmoonlight/acecode/actions/runs/35360448058/artifacts/10554671189)。入口下载 ZIP,解压后使用对应安装文件。
+- [对应源提交的检查](https://github.com/tmoonlight/acecode/actions/runs/35360445942) 中,Web 检查和原生安装器检查已通过;后者包含 19 项 Swift 测试、双架构 UI 编译、个人/系统窗口启动、12 项发布脚本测试、4 项便携包测试及 Shell 契约检查。
+- 干净 Mac 上的交互安装效果需实际打开安装包验收;CI 的签名、公证、包结构及窗口启动检查不等同于该验收。
+- 四份下载归档均通过 ZIP 完整性检查,SHA-256 与 GitHub artifact 的 `digest` 完全相同。直接解读两个 PKG 的 XAR 目录和 Distribution XML,确认分别为 `arm64` / `x86_64`、只允许 `CurrentUserHomeDirectory`、禁止系统及任意卷安装,并声明替换前必须关闭 ACECode。
+- PKG 文件 SHA-256:arm64 为 `5ab5b7ba507c465024b717383e9c2d6148b0d182d2d43cc0c7545659d840be42`;x64 为 `2d3ebda53676f7b6eade62121805958ddf337d8feed598c54e7f4f384dd3c8b4`。成品保存于忽略目录 `build/review-pr60-pr61/artifacts-288133c7/`。
+- 整个打包工作流成功,各平台构建均通过。源提交的完整检查也全部成功:Web、macOS 原生安装器及 Linux 全量 C++ `ctest` 均通过。
diff --git a/openspec/changes/review-pr65-68-and-require-complete-release/.openspec.yaml b/openspec/changes/review-pr65-68-and-require-complete-release/.openspec.yaml
new file mode 100644
index 000000000..1b9acb7fd
--- /dev/null
+++ b/openspec/changes/review-pr65-68-and-require-complete-release/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-22
diff --git a/openspec/changes/review-pr65-68-and-require-complete-release/design.md b/openspec/changes/review-pr65-68-and-require-complete-release/design.md
new file mode 100644
index 000000000..4b653fb89
--- /dev/null
+++ b/openspec/changes/review-pr65-68-and-require-complete-release/design.md
@@ -0,0 +1,33 @@
+## Context
+
+当前 master 存在用户已完成但未提交的界面和系统提示改动;四个远端 PR 与这些改动部分重叠。先记录原始状态和哈希,审核增量,再在当前分支合并,避免覆盖既有工作。正式版本以 v0.9.22 为基线;v0.9.23 打包检查发现 Linux 编译问题,修正后完整发布 v0.9.24。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 四个 PR 的最终代码经过执行路径审核和针对性回归验证。
+- 每次正式版均具备 Windows、Linux、Linux old、macOS 的完整下载集合。
+- 不以缺包成功状态发布;GitHub 和更新镜像的哈希、版本与来源一致。
+
+**Non-Goals:**
+- 不将旧工作树中的无关实验纳入本次发布。
+- 不重设计已有用户界面,不将 macOS PKG 当作自动更新包。
+
+## Decisions
+
+1. 使用独立、可离线测试的产物验证入口,按发布版本匹配必需文件;在生成校验和和调用 GitHub Release 之前失败退出。保留已有的 macOS updater 精确选择规则。
+2. 正式标签构建在开始原生打包前校验应用和安装器签名凭据。PKG 签名和公证失败应使发布失败,不降级为没有 PKG 的发布。
+3. 审核拖放的 DOM 目标与输入框接收者、桌面实例的显示身份与运行资源所有权、成功与失败请求的计量边界。仅修复由源代码和回归测试支持的问题,并在任务清单记录结果。
+4. 本地已完成改动先单独提交,随后合入各 PR 并提交修复;推送前检查差异与测试,推送后验证 PR 状态、CI 和所有发布产物。
+5. 嵌入前端的 CMake 配置同时追踪文件名集合和文件内容变化,避免开发启动器重建同名 Vite 产物后继续嵌入旧界面。使用最小真实 CMake/Ninja 工程验证修改文件后自动刷新嵌入字节。
+6. Linux WebKitGTK 引入的 X11 `Status`、`Success` 宏必须在平台头文件边界清理,避免改写业务状态枚举。常规 Linux CI 同时编译 Desktop,将此类平台编译问题提前到发布前暴露。v0.9.23 标签打包由此失败,保持标签不可变,修复后以 v0.9.24 完整发布。
+
+## Risks / Trade-offs
+
+- 强制 PKG 会使签名凭据缺失的正式发布提前失败,这是完整发布的必要约束。
+- macOS 原生和多架构 ABI 只能在对应 runner 上完整验证;本地执行离线测试,GitHub CI 承担平台测试。
+- 跨 PR 与本地改动可能冲突;逐个合并,保留双方行为并补充交叉路径测试。
+
+## Migration Plan
+
+完成审核与本地验证后推送 master,等待分支检查,通过正式标签触发打包。校验完整资产清单后同步更新镜像,核验公网文件与 GitHub 哈希,确认 master 与远端同步。
diff --git a/openspec/changes/review-pr65-68-and-require-complete-release/proposal.md b/openspec/changes/review-pr65-68-and-require-complete-release/proposal.md
new file mode 100644
index 000000000..98ffadfdc
--- /dev/null
+++ b/openspec/changes/review-pr65-68-and-require-complete-release/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+待合入的 PR #65–#68 涉及侧栏、macOS 安装与拖放、开发启动器和回合用量统计,需要在当前主分支及已完成的本地界面改动上统一审核和验证。现有发布流程允许缺少 macOS PKG 时成功发布,对 Linux old 等产物也缺少统一的完整性检查,无法保证每次正式版都提供全部平台下载。
+
+## What Changes
+
+- 审核并合入四个 PR,修复真实执行路径中的问题,保留已有系统提示和输入区改动。
+- 为正式发布定义完整的必需产物集合,包括三个 Linux old 架构和两个 macOS PKG;缺失、空文件或重名时禁止发布。
+- 在打包前明确校验签名凭据,避免以成功状态跳过必需的 PKG。
+- 补充与发现的问题对应的回归测试,运行前端、原生和打包检查,发布并验证 v0.9.23 及更新镜像。
+
+## Capabilities
+
+### New Capabilities
+- `complete-platform-release`: 定义正式发布的完整产物和发布前验证要求。
+- `reviewed-pr-integration`: 定义跨界面和运行时改动合入时必须保持的目标隔离、用量统计与既有交互契约。
+
+### Modified Capabilities
+
+## Impact
+
+- `.github/workflows/package.yml`、发布校验脚本及其测试。
+- PR #65–#68 涉及的 Web 侧栏、原生拖放、开发环境启动、桌面实例及 AgentLoop 用量路径。
+- 当前主分支的版本文件、发行记录和 GitHub/aupdate 产物。
diff --git a/openspec/changes/review-pr65-68-and-require-complete-release/specs/complete-platform-release/spec.md b/openspec/changes/review-pr65-68-and-require-complete-release/specs/complete-platform-release/spec.md
new file mode 100644
index 000000000..06090820d
--- /dev/null
+++ b/openspec/changes/review-pr65-68-and-require-complete-release/specs/complete-platform-release/spec.md
@@ -0,0 +1,26 @@
+## ADDED Requirements
+
+### Requirement: 发布必须通过同一提交的检查
+正式发布任务 SHALL 等待发布提交对应的 master 测试全部成功;打包可与测试并行,测试缺失、失败或等待超时不得创建 Release。
+
+#### Scenario: 打包结束但测试仍未完成
+- **WHEN** 所有平台包已就绪而对应提交的检查尚未完成
+- **THEN** 发布继续等待检查,通过后才能创建 Release
+
+### Requirement: 正式发布必须包含全部受支持平台产物
+正式发布流程 SHALL 在创建 GitHub Release 前验证固定的必需文件集合,其中包括 linux-old-x64、linux-old-arm64、linux-old-armv7 归档,以及 macOS x64 和 arm64 PKG;任一文件缺失、为空或存在重名副本时 SHALL 失败。
+
+#### Scenario: 缺少任一 Linux old 或 macOS PKG
+- **WHEN** 正式标签的构建产物缺少任一必需文件
+- **THEN** 发布任务失败且不创建不完整的 Release
+
+#### Scenario: 所有必需产物完整
+- **WHEN** 每个预期文件均只有一个非空副本且版本匹配
+- **THEN** 发布流程生成校验和并提供全部下载
+
+### Requirement: PKG 签名凭据不得隐式跳过正式安装包
+正式标签构建 SHALL 要求 macOS 应用与安装器签名凭据完整,缺失时明确失败;不得将缺少安装器凭据解释为成功跳过 PKG。
+
+#### Scenario: 未配置安装器证书
+- **WHEN** 正式标签构建没有安装器签名证书或密码
+- **THEN** 打包任务明确失败并指出缺失的配置名称
diff --git a/openspec/changes/review-pr65-68-and-require-complete-release/specs/reviewed-pr-integration/spec.md b/openspec/changes/review-pr65-68-and-require-complete-release/specs/reviewed-pr-integration/spec.md
new file mode 100644
index 000000000..f78de7d07
--- /dev/null
+++ b/openspec/changes/review-pr65-68-and-require-complete-release/specs/reviewed-pr-integration/spec.md
@@ -0,0 +1,19 @@
+## ADDED Requirements
+
+### Requirement: 交互目标与运行资源保持隔离
+合入后的实现 SHALL 将原生文件拖放交给命中的聊天输入框或终端,并保持独立桌面进程的运行资源所有权;用户界面显示身份不得导致不同进程错误共用资源。
+
+#### Scenario: 同时存在多个聊天输入框
+- **WHEN** 用户将文件拖放到指定输入框
+- **THEN** 只有命中的输入框接收文件
+
+#### Scenario: 重复启动同一开发版本
+- **WHEN** 同一工作树和提交同时启动多个允许共存的桌面实例
+- **THEN** 各实例的运行资源与退出清理不相互干扰
+
+### Requirement: 回合统计与已计量请求一致
+回合结束事件 SHALL 包含本回合已计量的完整 TokenUsage 和一致的 turn_id,保持现有实时用量事件的计量语义。
+
+#### Scenario: 请求失败后仍存在已计量用量
+- **WHEN** 一个回合包含已计量的请求或重试并最终结束
+- **THEN** 结束事件的合计不漏记或重复计入这些用量
diff --git a/openspec/changes/review-pr65-68-and-require-complete-release/tasks.md b/openspec/changes/review-pr65-68-and-require-complete-release/tasks.md
new file mode 100644
index 000000000..d2e40afb9
--- /dev/null
+++ b/openspec/changes/review-pr65-68-and-require-complete-release/tasks.md
@@ -0,0 +1,21 @@
+## 1. 基线与审核
+
+- [x] 1.1 记录本地差异、远端 PR SHA、最新版本与发布资产基线。
+- [x] 1.2 审核 PR #65 侧栏行为及样式,修复确认的问题。修复键盘激活与点击不一致、引导依赖已删除入口。
+- [x] 1.3 审核 PR #66 macOS 安装和拖放路径,修复确认的问题。拖放绑定命中的输入框,覆盖多输入框与注销回归。
+- [x] 1.4 审核 PR #67 开发启动器和实例隔离,修复确认的问题。修复访问拒绝误判、64 位句柄、无效 PID/端口、链接目录清理和并发 owner 冲突;保留发布前设置私有 ACL 的原有路径,97 项脚本测试通过。
+- [x] 1.5 审核 PR #68 回合统计的成功、失败和终止路径,修复确认的问题。将累加移到实际计量边界,保证回调抛错后的恢复事件不漏记;保持失败重试的临时用量不计入。
+
+## 2. 集成与发布完整性
+
+- [x] 2.1 检查并提交当前可用本地改动,逐个合入四个 PR,解决冲突。已单独提交本地 53 个文件,四个 PR 均保留合并 ancestry。
+- [x] 2.2 为正式标签强制校验 PKG 凭据与完整发布资产,补充缺包回归测试。18 类下载包逐一验证,缺少任一文件、空文件、重名、未签名或错版本都失败;PKG 凭据四种组合验证通过。
+- [x] 2.3 完成前端、原生、脚本测试和构建,验证规范与差异范围。完整 pnpm test/build、97 项开发脚本、5 项资产清单、PKG 凭据与 macOS 脚本契约、527 项原生测试及 MinSizeRel CLI/Desktop 构建通过;中英/亮暗/窄宽系统提示、侧栏鼠标与键盘浏览器验证通过,严格规范和 diff 检查通过。
+
+## 3. 发布与验收
+
+- [x] 3.0 修复打包 CI 揭示的 X11 宏与剪贴板状态类型冲突,将 Linux Desktop 编译加入常规 CI;本地真实 WebKitGTK/X11 环境完整 web_host.cpp 编译检查通过,保留已推送的 v0.9.23 标签,完整修正版顺延为 v0.9.24。
+- [x] 3.1 推送集成提交,确认四个 PR 已合入及分支 CI 通过。发布提交 cf08d10b 的 test run 35641170501 全部成功,包含 Linux CLI/Desktop 编译、完整 C++ 测试、前端与 macOS 安装器测试。
+- [x] 3.2 发布 v0.9.24 并验证 GitHub 全平台资产及校验和。package run 35641530020 成功,18 个必需下载包齐全,37 个发布资产中的 36 个产物均与 SHA256SUMS 及 GitHub 内容哈希一致;包含三个 Linux old 和两个已签名、公证的 PKG。
+- [x] 3.3 同步 aupdate 并验证六类更新 ZIP、两个 PKG 及公网下载。镜像脚本正常完成,版本文件、固定别名和校验和均通过公网 MIME、大小、SHA256 检查;仅清理本轮失败 v0.9.23 的临时 Windows 更新记录,保留其他 104 条版本记录,公网 manifest 再次核验通过。
+- [x] 3.4 核验 master 同步状态并记录审核、修复和发布结果。开放 PR 为零,发布提交与远端一致;完整证据、历史缺包原因、包大小和 SHA256 已记录在 verification.md,最终提交仅补充验收文档。
diff --git a/openspec/changes/review-pr65-68-and-require-complete-release/verification.md b/openspec/changes/review-pr65-68-and-require-complete-release/verification.md
new file mode 100644
index 000000000..5540e6ffa
--- /dev/null
+++ b/openspec/changes/review-pr65-68-and-require-complete-release/verification.md
@@ -0,0 +1,52 @@
+# 审核与发布验收
+
+2026-09-22(Asia/Taipei)完成 [v0.9.24 正式发布](https://github.com/tmoonlight/acecode/releases/tag/v0.9.24)。发布提交为 `cf08d10b616b8309e1fb678be53c7eae265c43f6`。
+
+## PR 审核及修复
+
+| PR / 范围 | 已修复问题 |
+| --- | --- |
+| [#65](https://github.com/tmoonlight/acecode/pull/65) 侧栏 | 工作区键盘激活与鼠标点击不一致;新手引导依赖已删除的新增工作区按钮导致无法启动。 |
+| [#66](https://github.com/tmoonlight/acecode/pull/66) macOS 安装与拖放 | 原生拖放命中一个输入框却投递到全局最后注册者;改为按 DOM 元素登记接收者并正确注销,拒绝无效坐标。 |
+| [#67](https://github.com/tmoonlight/acecode/pull/67) 开发启动与实例隔离 | Windows 64 位进程句柄类型、访问拒绝误判进程退出、无效 PID/端口、链接目录清理和并发实例共享 owner 目录;保留原有文件发布前设置私有 ACL 的路径。 |
+| [#68](https://github.com/tmoonlight/acecode/pull/68) 回合用量 | 消费者回调抛错后终止事件漏记已经计量的 token;在实际计量边界累加,继续排除失败重试的临时用量。 |
+| 嵌入前端 | 同名 Vite 资源内容更新后 CMake 可能不重建嵌入字节,已添加实际文件内容依赖及真实 CMake/Ninja 回归验证。 |
+| Linux Desktop | X11 的 `Status`、`Success` 宏改写剪贴板状态枚举,已在平台头文件边界清理,并将 Desktop 编译加入普通 Linux CI。 |
+
+原有未提交的系统提示、图标和输入区改动先独立提交并验证,再合入四个 PR。保留并审核同期合入的中断后暂停排队消息功能。四个 PR 均已合并,验收时开放 PR 为零。
+
+## 验证证据
+
+- [发布提交的测试流水线](https://github.com/tmoonlight/acecode/actions/runs/35641170501) 全部成功:完整 Web 测试及构建、macOS 原生安装器与脚本测试、Linux CLI/Desktop 编译、4,705 项 C++ 测试全部通过。
+- 本地完整 `pnpm test`、`pnpm build`,527 项定向 Windows 原生测试,97 项开发脚本测试、5 项发布资产测试、PKG 凭据组合与打包脚本验证通过;最终 MinSizeRel CLI/Desktop 构建确认版本为 `0.9.24`。
+- 浏览器验证涵盖系统提示中英、明暗主题、窄宽窗口、独立默认折叠、完整详情与复制;侧栏鼠标/键盘激活;两个真实 InputBar 的原生文件投递与注销。
+- [全平台打包流水线](https://github.com/tmoonlight/acecode/actions/runs/35641530020) 全部必需任务成功。第一次尝试被取消,复用已完成任务重试成功;未改动标签。
+- GitHub 共 37 个发布资产:18 个必需下载包、18 个调试符号包及 `SHA256SUMS.txt`。36 个文件的 GitHub 内容哈希全部匹配校验和清单,文件名无改写或重名。
+- 三个 Linux old 包均存在;Intel 与 Apple Silicon 两个 PKG 均经过 macOS CI 签名、公证及安装器验证。
+- `publish_acecode_release.ps1` 正常退出,六类更新 ZIP 和两个 PKG 均完成内容、版本、权限、大小和哈希检查,版本文件、固定别名与校验和文件的公网 MIME、大小和 SHA256 检查全部通过。
+- Agent Browser 集成在 Desktop 中;更新 ZIP 未包含旧的独立 browser host/extension 产物。未在本机重新安装用户正在使用的桌面程序;平台安装器验证以对应 CI 为准。
+
+## 历史缺包原因与约束
+
+历史 [v0.8.14](https://github.com/tmoonlight/acecode/releases/tag/v0.8.14)、[v0.8.15](https://github.com/tmoonlight/acecode/releases/tag/v0.8.15) 的发布说明明确仅发布 macOS,Windows/Linux 继续使用 v0.8.13,当时也尚未引入 PKG。后续 `Allow releases without macOS PKG` 改动允许没有 Installer 凭据时跳过 PKG,并接受零个或两个 PKG。
+
+现在正式标签必须具备安装器签名凭据;最终清单强制要求 18 个非空、唯一且版本匹配的下载文件,包括三个 Linux old 和两个签名 PKG,缺少任何一个都不创建 Release。发布还必须等待同一提交的 master 测试全部通过。具体约定见 `docs/release-artifacts.md`。
+
+本轮 v0.9.23 标签打包发现 Linux 编译问题后没有创建 GitHub Release;标签保留不变,修复后顺延 v0.9.24。v0.9.24 完整镜像成功后,仅移除本轮创建的 v0.9.23 Windows 临时 manifest 记录,保留其余 104 条记录,未删除已上传文件。原始 manifest 已在工作区外备份,公网 manifest 与修改后文件一致。
+
+## 更新镜像文件
+
+目录:`J:\jenkins_green\aupdate`。公网根地址:`http://2017studio.imwork.net:82/aupdate/`。PKG 保持独立手动安装包,不进入自动更新 manifest;Linux 自动更新 target 为 `linux-x64-updater-v1`、`linux-arm64-updater-v1`。
+
+| 文件 | 字节数 | SHA256 |
+| --- | ---: | --- |
+| `acecode-0.9.24-windows-x64.zip` | 18330394 | `70758991161c30f6d854e6183960e3e96e2dc2620a155099eabaa3a67b550cf9` |
+| `acecode-0.9.24-windows-arm64.zip` | 18200393 | `570af4e50a7537fd1c3aff7d388491abd761cf516f999931e81c795e4ea42e6a` |
+| `ACECode-0.9.24-macos-x64-update.zip` | 39837808 | `5327f649e283e4b5672673e2dd34feadafabd2ea527553d3e1aa7125acc6cda3` |
+| `ACECode-0.9.24-macos-arm64-update.zip` | 39326378 | `3ee2a0f3e0f1ea81f0fa583baf9bd665b90b7803f6efa84488b7cb3be83046c6` |
+| `acecode-0.9.24-linux-x64-update.zip` | 21562197 | `10f59292b8a5901832d8884409f32a1415114b6df5a74b9748c2b9677a7f0943` |
+| `acecode-0.9.24-linux-arm64-update.zip` | 22072379 | `ff382e9df077514c7978dd93a0a6edb90da705bd1f15cc52df661b22f27f5d8a` |
+| `ACECode-0.9.24-macos-x64.pkg` | 21726095 | `a46a05fbe18d2c0bdcde8124edc673deeb3ef927fa7050caa863c4918ad78b4f` |
+| `ACECode-0.9.24-macos-arm64.pkg` | 21557247 | `ed0ea846dbeda7b3fbea4691c641d8d61c08a7497432d63c8ffdaf77183bc297` |
+
+当前公网 manifest 最新版本为 `0.9.24`,六个 updater target 齐全。清理失败记录后 manifest SHA256 为 `24b118c8bc29143459d72c87c4c2073d28668a81a5b3fc92bce4ea6776599937`。
diff --git a/openspec/changes/shrink-and-smooth-sidebar-loading-dots/.openspec.yaml b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/.openspec.yaml
new file mode 100644
index 000000000..d28e909f6
--- /dev/null
+++ b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-17
diff --git a/openspec/changes/shrink-and-smooth-sidebar-loading-dots/design.md b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/design.md
new file mode 100644
index 000000000..b19ba8899
--- /dev/null
+++ b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/design.md
@@ -0,0 +1,30 @@
+## Context
+
+`SessionAttentionIndicator` renders four spans in a 16px slot. CSS owns both the 6.47-second linear orbit and 2.156-second radial breathing. Identical keyframes at 36% and 72% create a 776ms radial hold; 5.5px dots overlap at the 2.35px inner radius. See proposal.md for motivation.
+
+## Goals / Non-Goals
+
+Keep the existing CSS animation and component lifecycle. No session-state, row-layout, timing-duration, or unrelated composer changes are needed.
+
+## Decisions
+
+- Set each dot's width and height to 4.3px, with centered margins of -2.15px, following the user's refinement after the initial 2.75px version. Keep the 16px container and 5px outer radius, and use a 3.5px inner radius so the larger dots remain separate during contraction.
+- Replace the shared 36%/72% contracted keyframes with a single 50% keyframe. Preserve eased breathing and linear rotation, with no finite stationary interval. JavaScript timers would add lifecycle complexity without addressing the CSS hold.
+- Keep accent tokens and status accessibility labels. Reduced motion uses a gentle opacity-only pulse on the four-dot group, with no rotation or radial movement.
+
+## Risks / Trade-offs
+
+- Smaller dots need to remain distinct at normal zoom: check built styles in Chromium at desktop and 390px widths, in light/dark themes and Chinese/English fixtures.
+- Browser checks establish frontend behavior; a running installed desktop still needs rebuilt embedded assets to receive this change.
+
+## Validation
+
+Measure the old radial hold, then sample the final animation through multiple cycles and former hold intervals. Check 4.3px diameter, four separate dots, stable row geometry, hover continuity, and reduced-motion behavior. Run the existing sidebar test, full web tests/build, strict OpenSpec validation, and diff checks.
+
+## Older WebView2 compatibility follow-up
+
+The production CSS was tested in Chromium 109.0.5412.0 and 145.0.7632.6. Both run all five original transform animations with normal motion preferences, but both stop every animation when `prefers-reduced-motion: reduce` matches. This reproduces a concrete static-indicator condition; the affected user's exact runtime and Windows settings remain unconfirmed.
+
+Use `top`, `left`, `width`, and `height` for the orbit instead of `inset`. Explicitly define the rotation's starting transform and the dots' resting positions, so geometry does not depend on animation initialization. Retain the existing CSS keyframe approach; the tested old engine already supports it, so a JavaScript timer or user-agent branch is unnecessary.
+
+In reduced-motion mode, fix all four dots at their 3.5px radius and animate only group opacity between 0.6 and 1 over 2.4 seconds. This retains running feedback without spatial movement or abrupt flashing. A browser regression script renders the production component against built CSS, checks both motion preferences, and accepts an older Chromium executable for repeatable compatibility checks. These tests do not substitute for an affected-machine WebView2 host check or claim whole-app compatibility with every older browser.
diff --git a/openspec/changes/shrink-and-smooth-sidebar-loading-dots/proposal.md b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/proposal.md
new file mode 100644
index 000000000..e8aa4e6f4
--- /dev/null
+++ b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+The sidebar's running-session indicator is too visually heavy, and its dots hold the contracted position for about 0.78 seconds in every breathing cycle. The hold and overlapping dots can look like the animation has stopped.
+
+## What Changes
+
+- Set each dot's diameter to 4.3px, following the user's size refinement.
+- Keep contracted dots separate by using a 3.5px inner radius.
+- Remove the contracted-position hold so breathing remains continuous alongside the existing rotation.
+- Preserve the indicator slot, session-row alignment, accent color, and status semantics.
+- Use explicit orbit geometry and static dot positions for older CSS engines; when reduced motion is requested, keep a gentle opacity pulse instead of removing all running feedback.
+
+## Capabilities
+
+### New Capabilities
+- `sidebar-session-loading-indicator`: Compact, continuous four-dot feedback for running sidebar sessions.
+
+### Modified Capabilities
+None.
+
+## Impact
+
+- `web/src/styles/globals.css` only for production behavior; a browser regression script checks the built styles.
+- Existing sidebar tests and browser checks against the production markup and built styles.
+- No API, dependency, or session-state changes.
diff --git a/openspec/changes/shrink-and-smooth-sidebar-loading-dots/specs/sidebar-session-loading-indicator/spec.md b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/specs/sidebar-session-loading-indicator/spec.md
new file mode 100644
index 000000000..34272a78a
--- /dev/null
+++ b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/specs/sidebar-session-loading-indicator/spec.md
@@ -0,0 +1,47 @@
+## Purpose
+
+Give running sidebar sessions compact, continuous visual feedback while preserving session-row alignment and motion accessibility preferences.
+
+## ADDED Requirements
+
+### Requirement: Compact running-session dots
+Running sidebar sessions SHALL display four accent-colored dots whose diameter is 4.3 CSS pixels. The indicator SHALL retain its existing slot and row alignment, and the four dots SHALL remain visually separate throughout contraction.
+
+#### Scenario: A session is running
+- **WHEN** a sidebar session is in progress
+- **THEN** four 4.3px dots appear in the existing 16px indicator slot without shifting the title or timestamp
+
+### Requirement: Continuous running animation
+With normal motion preferences, the visible running indicator SHALL continuously rotate and breathe without a finite hold at the contracted position. Hovering a row or refreshing its title SHALL NOT pause the ongoing animation while it remains mounted and running.
+
+#### Scenario: The indicator crosses its contracted phase
+- **WHEN** the animation traverses one or more complete breathing cycles
+- **THEN** each contraction proceeds into expansion without the former 36%-72% stationary interval
+- **AND** the orbit rotates continuously across cycle boundaries
+
+#### Scenario: The running row is hovered
+- **WHEN** the user hovers a running session row
+- **THEN** the indicator continues animating
+
+### Requirement: Preserve attention semantics and reduced motion
+The indicator SHALL retain its accessible running-state label and theme accent color. Reduced-motion preferences SHALL retain four spatially stationary dots with a gentle brightness pulse; unread and read sessions SHALL keep their existing presentation.
+
+#### Scenario: Reduced motion is requested
+- **WHEN** the system requests reduced motion
+- **THEN** four separate 4.3px dots appear without rotation or radial motion and the running status label remains available
+- **AND** the group gently pulses in opacity between 0.6 and 1 to show that the session is still running
+
+#### Scenario: Motion preference changes while running
+- **WHEN** the system motion preference changes while a running indicator is visible
+- **THEN** it switches between full motion and the gentle opacity pulse without disappearing or remaining entirely static
+
+#### Scenario: The session is no longer running
+- **WHEN** a session becomes unread or read
+- **THEN** the existing unread marker or absence of a running marker is used
+
+### Requirement: Legacy indicator geometry
+The running indicator SHALL maintain its 16px slot and four separate dots on older Chromium-based engines that support CSS keyframe animations and 2D transforms. Its initial geometry SHALL remain defined even before animations start.
+
+#### Scenario: An older Chromium engine displays a running session
+- **WHEN** the built indicator is rendered in Chromium 109 with normal motion preferences
+- **THEN** rotation and radial breathing advance while the four dots retain their configured size and alignment
diff --git a/openspec/changes/shrink-and-smooth-sidebar-loading-dots/tasks.md b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/tasks.md
new file mode 100644
index 000000000..17082d846
--- /dev/null
+++ b/openspec/changes/shrink-and-smooth-sidebar-loading-dots/tasks.md
@@ -0,0 +1,34 @@
+## 1. Sidebar indicator
+
+- [x] 1.1 Reduce dot diameter to 2.75px and remove the contracted hold, then verify the existing sidebar test and browser measurements of dimensions and continuous motion.
+
+## 2. Integration verification
+
+- [x] 2.1 Run full web tests and build, browser checks for light/dark and desktop/390px Chinese/English layouts, and reduced-motion checks; record the results.
+- [x] 2.2 Validate the OpenSpec change strictly, check the diff, and verify unrelated dirty files retain their original hashes.
+
+## Validation results
+
+The results below describe the initial 2.75px version. The user's final size refinement is tracked separately below.
+
+- `node src/lib/permissionSidebarArchitecture.test.js`, `pnpm test`, and `pnpm build`: passed; full test output includes 2531 pass records.
+- Chromium fixture using the production indicator component and complete built CSS: passed all 8 combinations of 1280px/390px, light/dark, and Chinese/English.
+- At 25ms sampling intervals over 6.5 seconds, the baseline radial hold measured 775ms; the updated animation had no held samples. Diameter measured 2.75px and minimum dot-center distance measured 3.323px, so dots remain separate.
+- The 16px slot, row height, and title position were unchanged. Hover and title updates preserved the live animations. Reduced motion showed four static dots and zero running CSS animations.
+- Strict OpenSpec validation and `git diff --check`: passed. All six pre-existing dirty files retained their starting SHA256 hashes.
+- Design detector reported only existing findings outside the changed CSS lines. Desktop packaging and an installed-app check were not performed.
+
+## 3. Size refinement
+
+- [x] 3.1 Set the dot diameter to 4.3px, center it, and retain separation during continuous motion; verify built CSS in the browser and run web tests/build plus strict validation.
+
+Refinement verification: `pnpm test`, `pnpm build`, strict OpenSpec validation, and the same 8 Chromium fixture combinations passed. CSS declares 4.3px (Chromium rounds its computed layout size to 4.29688px); minimum dot separation is 4.95px, with no held radial samples and preserved hover/reduced-motion behavior. The 3.5px contracted radius prevents the larger dots from overlapping.
+
+## 4. Older WebView2 compatibility
+
+- [x] 4.1 Define legacy-compatible orbit geometry and a gentle reduced-motion pulse, verified by a browser regression script against production markup and built CSS in Chromium 109 and current Chromium.
+- [x] 4.2 Run full web tests/build, strict OpenSpec validation, and diff checks; preserve unrelated home-logo edits and record the limits of affected-machine verification.
+
+Compatibility verification: the browser regression script fails against the pre-change build at the expected missing reduced-motion feedback assertion, and passes the updated build in Chromium 109.0.5412.0 and 145.0.7632.6. All 16 combinations of engine, light/dark, 390px/1280px, and normal/reduced motion passed, as did live preference switching and defined resting positions with animation disabled. Run `python web/scripts/check-session-loading-motion.py --browser ` after the web build to repeat the checks.
+
+`pnpm test`, `pnpm build`, strict OpenSpec validation, and `git diff --check` passed. The home-logo test retained its starting SHA256, and all CSS outside the loading-indicator block matched the starting snapshot. The design detector found no issues in the changed region. The affected machine's exact WebView2 version, Windows motion setting, and host behavior have not been verified; no desktop package was rebuilt.
diff --git a/openspec/changes/specialize-deepin-desktop-packaging/.openspec.yaml b/openspec/changes/specialize-deepin-desktop-packaging/.openspec.yaml
new file mode 100644
index 000000000..0ca5fbe8e
--- /dev/null
+++ b/openspec/changes/specialize-deepin-desktop-packaging/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-26
diff --git a/openspec/changes/specialize-deepin-desktop-packaging/design.md b/openspec/changes/specialize-deepin-desktop-packaging/design.md
new file mode 100644
index 000000000..59268f4f3
--- /dev/null
+++ b/openspec/changes/specialize-deepin-desktop-packaging/design.md
@@ -0,0 +1,43 @@
+## Context
+
+The referenced prototype (`/home/shaouos/ttt`, session `01a0d841-fa49-7d73-906a-c14de6ae76a5`) uses DTK 5 / Qt 5.11 and a transparent titlebar. ACECode already owns its titlebar and window operations in web content, and must retain WebKitGTK 4.0. The previous scaling fix remains uncommitted in this worktree.
+
+## Goals / Non-Goals
+
+**Goals:** Isolate the distribution-specific dependencies at configure and compile time; preserve GTK event, focus, drag, tray and WebView ownership; build dedicated packages on the existing GLIBC 2.28 baseline.
+
+**Non-Goals:** Replace WebKit with QtWebEngine, create a second titlebar, change host packages, or publish a release during local validation.
+
+## Decisions
+
+1. `ACECODE_DEEPIN` defaults to OFF, rejects non-Linux targets, and defines a build marker for update identity. Only Desktop links Qt Widgets and DTK Widget when enabled. TUI/daemon receive the identity without Qt linkage. Fractional scaling activation also requires this macro plus the existing runtime desktop/backend checks.
+2. Attach a DTK platform window handle to a non-owning `QWindow::fromWinId` wrapper around the existing GTK X11 window. A local prototype on this UOS system confirmed enabled DTK/no-titlebar and native radius 8. The window manager supplies frame effects; GTK retains native window ownership and all control/close callbacks. WebView has already mapped its GTK window at this point: flush the DTK properties and remap the window so the compositor applies corner clipping on its first visible frame. This avoids GTK-in-Qt embedding and overlapping native controls. Native Wayland skips X11 integration; XWayland is supported through its actual GDK X11 backend, irrespective of CPU architecture.
+3. Keep Qt headers in a dedicated Desktop translation unit with Qt keyword macros disabled. Discover Qt/DTK in a child CMake directory: DTK's package configuration otherwise adds global definitions to previously created daemon/test targets. Initialize Xlib threading at process entry, before GTK uses any display resources; the older UOS Xlib crashes if Qt initializes threading after GTK has created resource databases. Require successful early initialization, then initialize the DTK application lazily only after runtime checks, followed by the GTK scale controller. Service Qt through the common GLib dispatcher; release wrapper/handle and signal owners before `request_quit` destroys the GTK host, while preserving them across show/hide. A native GTK delete-event may already have removed widget signals; check the connection before disconnecting it. The user explicitly confirmed that DTK must supply only rounded corners and shadows: add no Qt widgets or input handlers, and leave the native move/resize policy unchanged for GTK's existing operations.
+4. Retain the Buster container baseline and build pinned DTK 5 sources against its Qt 5.11 in a separate CI SDK prefix. Current Deepin repositories can require newer Qt and must not silently raise the package ABI. Record immutable source revisions and hashes. x64/arm64 include Desktop; the existing ARMv7 CLI-only coverage remains CLI-only.
+5. Rename the old release family to `linux-deepin` throughout workflow dependencies, archives, completeness checks and docs. Use a separate Deepin update target; do not fall back to a generic Linux manifest entry. Dedicated update publication is separate from the archive migration and is not introduced here.
+
+## Risks / Trade-offs
+
+- DTK depends on the installed Deepin platform plugin and compositor -> verify native properties and screenshot locally; require DTK 5 runtime in dedicated-package documentation.
+- Qt and GTK share a process -> keep ownership separate and verify focus, window state, resize, close-to-tray and shutdown with the real Desktop.
+- ARM execution is unavailable locally -> retain architecture-independent code and CI native ARM64 build/ABI gates; report this validation limit.
+- No published Deepin update manifest yet -> report no compatible update instead of replacing this build with a generic Linux artifact.
+
+## Migration Plan
+
+Enable `-DACECODE_DEEPIN=ON` only in the dedicated jobs and explicit local builds. Release the renamed archives after normal CI/release gates. Rollback is to disable the option and restore the previous archive matrix; generic Linux, Windows and macOS require no migration.
+
+## Validation results
+
+- Built generic Desktop with the option OFF and dedicated Desktop with it ON. Generic Desktop and both daemon variants have no Qt/DTK linkage; the dedicated Desktop resolves system WebKitGTK 4.0 and DTK 5. Both dedicated executables retain the GLIBC 2.28 symbol ceiling. A non-Linux configure probe rejects the option.
+- Built all three pinned DTK 5.2 SDK modules in an isolated Debian Buster root filesystem. Compiled the actual frame adapter and linked the required DTK APIs against that SDK and Qt 5.11.3, including the remap synchronization call.
+- Passed 16 focused C++ tests covering DPI policy, manifest selection and versions; passed 6 release-asset verifier tests. Separate ON/OFF probes confirmed `linux-deepin-x64` versus `linux-x64` identity.
+- Parsed the workflow and checked its Deepin matrix and release dependency. All nine Deepin shell steps passed `bash -n`; actionlint passed with only the pre-existing paused npm job's constant-false expression excluded.
+- On real UOS X11, the final Desktop advertises `_DEEPIN_NO_TITLEBAR=1`, has compositor shadow data and reports radius 8. The final screenshot confirms native rounded corners, shadow and the existing web controls. The user also confirmed the visual result.
+- The isolated X11 display verified web maximize/restore/minimize, drag, edge resize, keyboard input and close. Its generic KWin instance did not advertise Deepin frame support, so it was used for interaction and DPI checks only. Final continuous native DPI changes 120 → 144 → 96 → 120 produced page zoom 1.25 → 1.5 → 1.0 → 1.25. Real UOS also verified maximize/restore/minimize and normal close, with no stale GTK signal-handler warning after the lifetime fix.
+- The final Release build on real UOS accepted compositor move/resize from
+ (320,105,1280,820) to (120,120,1000,720) and restored its original geometry.
+ Real UOS XTest pointer gestures were inconclusive, although the isolated
+ display accepted them. Physical mouse gestures on the locked real desktop
+ remain unverified; the automated result cannot certify that input path.
+- ARM64/ARMv7 execution, an actual XWayland session and the full GitHub packaging matrix remain unexecuted locally. No release was published.
diff --git a/openspec/changes/specialize-deepin-desktop-packaging/proposal.md b/openspec/changes/specialize-deepin-desktop-packaging/proposal.md
new file mode 100644
index 000000000..1b57c9153
--- /dev/null
+++ b/openspec/changes/specialize-deepin-desktop-packaging/proposal.md
@@ -0,0 +1,27 @@
+## Why
+
+UOS/Deepin needs its native DTK window effects and WebKit2GTK 4.0 while ordinary Linux installations must not acquire Qt/DTK dependencies. The existing `linux-old` release jobs are the intended home for this dedicated distribution.
+
+## What Changes
+
+- Add an explicit, default-off `ACECODE_DEEPIN` CMake option and compile definition for Linux builds.
+- Use DTK for the Deepin window frame while keeping the existing WebKit2GTK engine, full-height web content, window controls, and close-to-tray behavior.
+- Scope the UOS fractional scaling workaround to the dedicated build and preserve runtime Deepin/X11 checks, including XWayland.
+- **BREAKING**: Replace `linux-old-*` release archives and job names with `linux-deepin-*`; retain the existing architecture coverage and GLIBC 2.28/WebKitGTK 4.0 compatibility checks.
+- Give Deepin its own update identity so it cannot install a generic Linux update.
+- Update release validation, tests and installation/build documentation.
+
+## Capabilities
+
+### New Capabilities
+
+- `deepin-desktop-build`: Explicit DTK build boundary and native Deepin window integration.
+- `deepin-release-packaging`: Dedicated release artifacts, dependency checks and update identity.
+
+### Modified Capabilities
+
+None. This change supersedes the unarchived scaling change's unconditional Linux compilation boundary while retaining its DPI policy.
+
+## Impact
+
+Desktop host and CMake configuration; Linux release workflow, release verifier and update target selection; focused desktop/release tests and documentation. DTK/Qt are desktop-only optional build dependencies. Existing Windows, macOS and generic Linux builds remain independent of DTK. No release publication or system package replacement is part of this local work.
diff --git a/openspec/changes/specialize-deepin-desktop-packaging/specs/deepin-desktop-build/spec.md b/openspec/changes/specialize-deepin-desktop-packaging/specs/deepin-desktop-build/spec.md
new file mode 100644
index 000000000..cee6f9f8a
--- /dev/null
+++ b/openspec/changes/specialize-deepin-desktop-packaging/specs/deepin-desktop-build/spec.md
@@ -0,0 +1,31 @@
+## Purpose
+
+Provide UOS/Deepin native window effects and scaling without making other operating systems depend on the Deepin desktop toolkit.
+
+## ADDED Requirements
+
+### Requirement: Explicit Deepin build selection
+The project SHALL require an explicit default-off Linux build option to include DTK integration and the Deepin scaling workaround. Generic Linux, Windows and macOS builds MUST NOT discover or link DTK/Qt as a result of this feature.
+
+#### Scenario: Generic build
+- **WHEN** a developer builds without the Deepin option
+- **THEN** the Desktop builds without DTK or Qt development packages and does not activate the Deepin-specific integration
+
+#### Scenario: Unsupported platform
+- **WHEN** the Deepin option is enabled for a non-Linux target
+- **THEN** configuration fails with an actionable diagnostic
+
+### Requirement: Native frame with full-height web content
+The dedicated Desktop SHALL enable native Deepin window effects on Deepin X11 or XWayland and preserve WebKitGTK 4.0, full-height web content, existing web window controls and close-to-tray behavior. It MUST NOT overlay a second titlebar over web interactions.
+
+#### Scenario: Deepin session
+- **WHEN** the dedicated Desktop runs on a Deepin X11 or XWayland backend
+- **THEN** the system frame provides its rounded corners and shadow while the web titlebar remains interactive
+
+#### Scenario: Different runtime backend
+- **WHEN** the dedicated Desktop runs outside Deepin or uses native Wayland
+- **THEN** X11-specific native integration is skipped
+
+#### Scenario: Fractional scale
+- **WHEN** the dedicated Desktop runs on Deepin X11 with effective font DPI above 96 and valid scale parameters
+- **THEN** text and controls follow the same scale and live display changes refresh the correction
diff --git a/openspec/changes/specialize-deepin-desktop-packaging/specs/deepin-release-packaging/spec.md b/openspec/changes/specialize-deepin-desktop-packaging/specs/deepin-release-packaging/spec.md
new file mode 100644
index 000000000..dc2d1de5a
--- /dev/null
+++ b/openspec/changes/specialize-deepin-desktop-packaging/specs/deepin-release-packaging/spec.md
@@ -0,0 +1,27 @@
+## Purpose
+
+Make dedicated Deepin release packages identifiable and compatible with older UOS/Deepin installations while preserving the ordinary Linux release family.
+
+## ADDED Requirements
+
+### Requirement: Dedicated release family
+Release packaging SHALL replace `linux-old` archives with `acecode-linux-deepin-.tar.gz`, retain x64/arm64 Desktop and ARMv7 CLI coverage, and explicitly select the dedicated build variant. Desktop packages MUST use WebKitGTK 4.0 and DTK 5; the maximum required GLIBC version SHALL remain 2.28.
+
+#### Scenario: Complete release
+- **WHEN** a tagged release is assembled
+- **THEN** all three Deepin archives are required along with the existing generic platform artifacts
+
+#### Scenario: Incompatible Desktop linkage
+- **WHEN** the dedicated Desktop links WebKitGTK 4.1/6, lacks DTK 5 or requires GLIBC above 2.28
+- **THEN** the packaging verification fails
+
+### Requirement: Separate update identity
+The dedicated executables MUST use a Deepin-specific update target and MUST NOT select ordinary Linux update packages.
+
+#### Scenario: Only generic updates exist
+- **WHEN** a Deepin build checks a manifest containing only generic Linux packages
+- **THEN** no generic package is selected for installation
+
+#### Scenario: Generic Linux build
+- **WHEN** a generic build checks for updates
+- **THEN** its existing Linux update identity is preserved
diff --git a/openspec/changes/specialize-deepin-desktop-packaging/tasks.md b/openspec/changes/specialize-deepin-desktop-packaging/tasks.md
new file mode 100644
index 000000000..3dfd4b255
--- /dev/null
+++ b/openspec/changes/specialize-deepin-desktop-packaging/tasks.md
@@ -0,0 +1,15 @@
+## 1. Dedicated build boundary
+
+- [x] 1.1 Add the default-off Linux-only build option, desktop-only DTK dependencies and source isolation; verify generic and Deepin Desktop builds and linkage.
+- [x] 1.2 Attach native DTK frame effects to the existing GTK window and gate fractional scaling; verify window properties, screenshot, resize, focus and close behavior on local UOS.
+ - Verified native frame properties/screenshot, continuous DPI changes, isolated-display pointer interactions, real UOS window controls and real compositor move/resize/restore. Physical mouse gestures on the locked real desktop remain an explicitly unverified manual check; synthetic pointer gestures there were inconclusive.
+
+## 2. Release migration
+
+- [x] 2.1 Add a reproducible DTK 5 SDK build for the old ABI baseline and convert the old packaging matrix to Deepin; verify source pins, shell/workflow syntax and dependency/ABI gates.
+- [x] 2.2 Update archive completeness validation and build/install documentation; run release verifier regression tests including rejection of old archive names.
+- [x] 2.3 Separate Deepin update identity from generic Linux without adding Qt to the daemon; verify target selection regression tests for both variants.
+
+## 3. Integration review
+
+- [x] 3.1 Run focused build/runtime verification, strict OpenSpec validation and diff checks; record exact results and unavailable platform coverage.
diff --git a/openspec/changes/stabilize-deepin-linux-desktop/.openspec.yaml b/openspec/changes/stabilize-deepin-linux-desktop/.openspec.yaml
new file mode 100644
index 000000000..0ca5fbe8e
--- /dev/null
+++ b/openspec/changes/stabilize-deepin-linux-desktop/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-26
diff --git a/openspec/changes/stabilize-deepin-linux-desktop/design.md b/openspec/changes/stabilize-deepin-linux-desktop/design.md
new file mode 100644
index 000000000..7eadaadbe
--- /dev/null
+++ b/openspec/changes/stabilize-deepin-linux-desktop/design.md
@@ -0,0 +1,127 @@
+## Context
+
+The worktree contains the previous default-off Deepin variant and fractional scaling fix. The user reported unexpected exits both around directory selection and while idle. Those launches had a zero core-file limit, so no dump was available. Subsequent sanitizer and minimal Xlib probes reproduced the initialization-order crash described below.
+
+The Linux folder picker currently forks zenity/kdialog; neither exists on this installation. `SplashScreen` is a no-op on Linux, and the Linux host ignores the startup window mode. The sidebar uses `scrollbar-gutter: stable`, which requires a fallback on the installed WebKitGTK 2.38 engine.
+
+## Decisions
+
+1. Initialize Xlib threading at process entry in the dedicated Deepin build,
+ before GTK or any other library opens a display. Qt's late `XInitThreads`
+ call otherwise leaves existing Xlib resource-database locks null on this
+ UOS runtime. The DTK adapter requires successful early initialization and
+ retains GTK ownership of the main window/event loop. DTK remains limited
+ to rounded corners and shadows. Generic builds do not execute this path.
+2. Reuse the Desktop's GTK runtime for native folder selection. Keep GTK dependencies out of headless support targets and distinguish successful selection, cancellation and failure. Existing external picker fallback may remain for contexts without an initialized GTK Desktop.
+3. Add a CSS feature-detection fallback that permanently reserves the existing scrollbar width on the session list when `scrollbar-gutter` is unsupported. Preserve the current transparent track and thumb behavior, and verify content width before/after overflow in the actual old WebKit engine.
+4. Implement the Linux splash using the existing logo asset and GTK. Keep the main window visually hidden until the frontend is ready, then center and fit it in the active monitor's work area. Reuse existing pure window geometry helpers; preserve user placement on subsequent hide/show operations.
+5. Keep the dedicated macro and system shared DTK libraries. Add a package-content gate so no Qt/DTK runtime is accidentally bundled; generic Linux remains toolkit-independent.
+6. Stage only the authorized source, tests, docs, workflow and OpenSpec files. Exclude diagnostics, dumps, local configuration and runtime data. Inspect the remote branch before committing/pushing and verify the resulting remote commit.
+
+## Verification
+
+### Reproduced crash and initialization fix
+
+- The UOS user journal records Desktop segmentation faults at 01:47:15 and
+ 01:51:27. Core generation was disabled for those launches.
+- An independent AddressSanitizer build of the actual Desktop reproduced a
+ null-pointer crash in `pthread_mutex_lock` called by `XrmQGetResource` /
+ `XGetDefault` during GTK text drawing after DTK/Qt initialization.
+- A minimal non-sanitized Xlib program reproduces the same failure: open a
+ display, call `XGetDefault`, call `XInitThreads`, then call `XGetDefault`
+ again. It exits with SIGSEGV (-11). Moving `XInitThreads` before the first
+ Xlib call makes the identical resource access succeed (exit 0).
+- Preloading early Xlib initialization also allows the previously failing
+ sanitized Desktop to start without an ASan report. Production initialization
+ now runs before the splash/GTK and guards DTK activation on success.
+- Rebuilt the actual fixed Desktop with AddressSanitizer and launched it on
+ real UOS X11 without a preload workaround. The frontend became ready and
+ DTK advertised `_DEEPIN_NO_TITLEBAR=1`. Approximately six minutes of idle
+ observation, twelve native maximize/restore transitions, two folder-picker
+ open/cancel cycles and normal shutdown completed with no ASan report;
+ process exit status was 0. Full directory selection had already passed in
+ the isolated complete Desktop, and the real GTK picker passed selection
+ and cancellation independently.
+- The final production Desktop compiles with the early initialization fix.
+ The actual adapter also recompiles against the pinned DTK 5.2 SDK and the
+ linked SDK probe runs with Qt 5.11.3. The existing `loadDXcbPlugin` API emits
+ a deprecation warning but remains required by the supported old runtime.
+- Xlib's documented contract requires `XInitThreads` to precede every other
+ Xlib call: https://xorg.freedesktop.org/archive/X11R7.5/doc/man/man3/XInitThreads.3.html
+
+### Presentation and packaging checks
+
+- System WebKitGTK 2.38 reports no `scrollbar-gutter` support. With the fallback,
+ a 300px sidebar retains a 288px row/client width at content heights 20, 800
+ and 20px (12px track reserved in all three cases).
+- `pnpm test` passed using a temporary Node 22 runtime (the installed Node 20
+ cannot import the suite's existing TypeScript files); `pnpm build` passed,
+ including the generated-regex compatibility check.
+- The actual GTK picker implementation passed cancellation and selection of
+ `/tmp` on the real UOS display with X requests synchronized. No zenity or
+ kdialog is installed. It also passed on an isolated display with its own
+ session bus. UOS replaces GTK file dialogs with a D-Bus dialog service;
+ sharing the real session bus with a different test display caused a
+ `BadWindow` in `XSetTransientForHint`. This was a test-environment issue,
+ not evidence for the user's earlier unexpected exit.
+- The complete Desktop bridge was exercised in an isolated X11 session with
+ its own D-Bus: open the existing-directory picker, cancel, reopen and select
+ the existing ACECode project. The project appeared in the sidebar and home
+ screen, and the debugger caught no fatal signal.
+- Both updated Desktop variants compiled. The generic executable has no
+ Qt/DTK dependencies; the dedicated executable resolves WebKitGTK 4.0 and
+ DTK 5, with maximum GLIBC symbol 2.28. A local archive containing both
+ executables, logo, model catalog and 111 seed files passed the no-bundling
+ gate before compression and after extraction. Actionlint and 10 Python
+ package/release verification tests passed. The 16 focused native DPI and
+ update tests also passed. ARM and full GitHub matrix execution remain
+ unavailable locally.
+- Captured the actual startup logo and main window on a 1440x1000 test display
+ at native DPI 120. The first 1280x820 main window appeared at (80,90).
+ Pointer drag moved it to (115,115); edge resize produced 1180x760. After
+ minimize (`_NET_WM_STATE_HIDDEN`) and activation, (115,115,1180,760) was
+ preserved. The isolated window manager has no DTK frame-effect support.
+- The final Release build was launched through `scripts/dev_desktop.sh` on
+ real UOS. Its frontend loaded successfully at page zoom 1.25. Initial
+ geometry was (320,105,1280,820), centered in the 1920x1030 work area. The
+ Deepin compositor accepted move/resize to (120,120,1000,720) and restoration
+ to the initial geometry. This verifies native frame operations; physical
+ mouse gestures remain unverified while the desktop is locked. The test
+ Release process then shut down normally with exit status 0.
+- The 10 existing window-geometry unit tests passed. All three related
+ OpenSpec changes pass strict validation, and the complete diff passes
+ `git diff --check`.
+- Closing the test Desktop through its web close button returned from main
+ normally and reached `exit_group` without a fatal signal. The isolated
+ test session and helpers were then cleaned up.
+- The original user-launched process also remained alive under the debugger
+ without a fatal signal. After obtaining the reproducible failure and
+ verifying the fix in an independent process, the watcher was detached;
+ the user's original window was left running. It must be restarted to load
+ the new binary. Actual physical mouse interaction on the locked real
+ desktop remains outside the automated verification.
+- The remote master advanced from `3992dd62` to `86480019` with a Windows-only
+ top-border fix. Fast-forwarded the local branch and reapplied the staged
+ Deepin changes cleanly; the upstream Windows code is preserved. Diagnostics
+ and local runtime data are excluded from the staged files.
+- Rebuilt both executables successfully after integrating upstream. The
+ build directory remains configured with `ACECODE_DEEPIN=ON` and WebKitGTK
+ API 4.0. All 21 script-verifier tests passed, as did actionlint after the
+ explicit X11 development dependency was added.
+- Refreshed the local archive with the final fixed binaries and documentation.
+ Its 111 seed files and model catalog exactly match the source; the archive
+ passes the no-bundling gate before compression and after extraction. Both
+ binaries retain the GLIBC 2.28 ceiling and the daemon has no Qt/DTK linkage.
+- Implementation commit `ae98590a4245a752204771e976baa7dac71080f2` was
+ pushed to `origin/master`, and `git ls-remote` confirmed the same revision.
+ GitHub started the push-triggered `test` workflow. Added the Deepin package
+ verifier's regression tests to that workflow as well. The dedicated package
+ matrix still runs through the repository's manual/tag packaging triggers;
+ no release was published during this work.
+
+- Capture and explain the crash; exercise the failing path and idle runtime after the fix.
+- Exercise folder picker success and cancel with real GTK, including this machine without external picker programs.
+- Measure sidebar width at short/overflowing/short content lengths in system WebKitGTK; run the web suite and regenerate frontend assets.
+- Capture the Linux startup logo and verify centered first-show geometry, DPI changes and subsequent window controls.
+- Build Deepin and generic Desktop variants, inspect dynamic dependencies, verify package contents and workflow syntax, and run focused native regressions.
+- Check the complete staged diff, push to GitHub, and verify the remote revision. Report unavailable ARM/Wayland execution separately.
diff --git a/openspec/changes/stabilize-deepin-linux-desktop/proposal.md b/openspec/changes/stabilize-deepin-linux-desktop/proposal.md
new file mode 100644
index 000000000..2504f9fc6
--- /dev/null
+++ b/openspec/changes/stabilize-deepin-linux-desktop/proposal.md
@@ -0,0 +1,26 @@
+## Why
+
+The UOS Desktop can exit unexpectedly, directory selection depends on tools absent from the system, and the old WebKit engine does not reserve the sidebar scrollbar gutter. Linux also lacks the startup logo and centered first window already expected from the desktop application.
+
+## What Changes
+
+- Diagnose the reported crash from available process/debugger evidence and fix its cause; verify both idle operation and opening existing directories.
+- Provide directory selection on Linux without requiring zenity or kdialog.
+- Reserve the session-list scrollbar space on older WebKit without shifting content when overflow changes.
+- Show the existing ACECode logo during Linux startup and center the first main window within the selected monitor's usable area.
+- Verify and finish the existing Deepin release workflow, with dynamically linked system DTK/Qt and no toolkit libraries copied into archives.
+- Validate the combined changes and commit/push them to the GitHub repository as requested.
+
+## Capabilities
+
+### New Capabilities
+
+- `linux-desktop-reliability`: Crash resilience, native directory selection, stable sidebar layout and Linux startup presentation.
+
+### Modified Capabilities
+
+None. Dedicated packaging continues the existing `specialize-deepin-desktop-packaging` change.
+
+## Impact
+
+Linux Desktop native integration and startup, folder selection, sidebar CSS, regression tests, packaging validation and the two existing Deepin changes. Windows/macOS behavior and generic Linux's independence from DTK remain intact.
diff --git a/openspec/changes/stabilize-deepin-linux-desktop/specs/linux-desktop-reliability/spec.md b/openspec/changes/stabilize-deepin-linux-desktop/specs/linux-desktop-reliability/spec.md
new file mode 100644
index 000000000..6c62adb67
--- /dev/null
+++ b/openspec/changes/stabilize-deepin-linux-desktop/specs/linux-desktop-reliability/spec.md
@@ -0,0 +1,53 @@
+## Purpose
+
+Make Linux and Deepin Desktop stable during ordinary use and present a consistent startup and sidebar layout on the supported old WebKit runtime.
+
+## ADDED Requirements
+
+### Requirement: Stable desktop process
+The Desktop SHALL remain running during idle operation and opening an existing workspace directory, and SHALL release optional native integrations without accessing destroyed platform resources.
+
+#### Scenario: Idle and workspace interaction
+- **WHEN** a user leaves the Desktop idle or opens an existing directory in a new session
+- **THEN** the host remains responsive and does not unexpectedly exit
+
+#### Scenario: GTK and DTK share an X11 process
+- **WHEN** the dedicated Deepin build starts in a Deepin session
+- **THEN** Xlib threading is initialized before any GTK display or resource access
+- **AND** optional DTK effects are skipped if early initialization failed
+
+### Requirement: Available native directory selection
+The Linux Desktop SHALL offer directory selection through its GTK runtime without requiring an external zenity or kdialog executable.
+
+#### Scenario: External pickers absent
+- **WHEN** the user opens an existing directory on a system without zenity and kdialog
+- **THEN** a parented native directory picker opens and returns the selected directory
+
+#### Scenario: User cancels
+- **WHEN** the user cancels the native picker
+- **THEN** no workspace is registered and the Desktop remains available
+
+### Requirement: Stable session-list width
+The session list SHALL reserve scrollbar space whether or not its contents overflow, including on WebKit versions without `scrollbar-gutter` support.
+
+#### Scenario: Overflow threshold crossed
+- **WHEN** session-list content changes from short to overflowing and back
+- **THEN** the usable row width remains unchanged and no horizontal layout shift occurs
+
+### Requirement: Linux startup presentation
+Linux and Deepin Desktop SHALL show the existing ACECode logo during startup and reveal the first main window centered inside the selected monitor's usable area.
+
+#### Scenario: First window becomes ready
+- **WHEN** the initial frontend becomes ready
+- **THEN** the startup logo closes and the main window is shown centered with its size constrained to the available work area
+
+#### Scenario: Existing window restored
+- **WHEN** an already-shown main window is restored later
+- **THEN** its user-selected placement is preserved
+
+### Requirement: External Deepin runtime libraries
+The Deepin package SHALL dynamically link system DTK/Qt and SHALL NOT include copies of their runtime libraries or plugins in its archive.
+
+#### Scenario: Package staged
+- **WHEN** a Deepin release archive is staged
+- **THEN** verification confirms the expected shared dependencies and rejects bundled Qt/DTK libraries or plugins
diff --git a/openspec/changes/stabilize-deepin-linux-desktop/tasks.md b/openspec/changes/stabilize-deepin-linux-desktop/tasks.md
new file mode 100644
index 000000000..b3aff7807
--- /dev/null
+++ b/openspec/changes/stabilize-deepin-linux-desktop/tasks.md
@@ -0,0 +1,15 @@
+## 1. Reliability
+
+- [x] 1.1 Capture actionable crash evidence, implement the cause-specific fix, and verify the failing path plus idle operation.
+- [x] 1.2 Provide Linux GTK directory selection without external picker programs; verify success and cancellation.
+
+## 2. Presentation
+
+- [x] 2.1 Add the old-WebKit scrollbar reservation fallback; measure short/overflowing/short row widths and run the web tests/build.
+- [x] 2.2 Implement the Linux startup logo and centered first-show placement; verify screenshots, geometry and restore behavior.
+
+## 3. Packaging and delivery
+
+- [x] 3.1 Finish Deepin packaging with system DTK linkage and a no-bundling gate; verify generic/Deepin builds and the workflow.
+- [x] 3.2 Review and validate all changes, update the related OpenSpec tasks and record exact runtime/test evidence.
+- [x] 3.3 Commit the authorized changes, push to GitHub and verify the remote commit.
diff --git a/openspec/changes/unify-rounded-interface-icons/tasks.md b/openspec/changes/unify-rounded-interface-icons/tasks.md
index 51090a04a..efb61abef 100644
--- a/openspec/changes/unify-rounded-interface-icons/tasks.md
+++ b/openspec/changes/unify-rounded-interface-icons/tasks.md
@@ -27,3 +27,15 @@
- [x] 5.1 两处列表开关使用同一三横线图标、16 px 尺寸及圆端点细线;列表展开时按钮显示激活状态。
- [x] 5.2 验证列表展开/收起切换、明暗主题、SVG 导出及前端测试与构建。
+
+## 6. 新建任务图标简化
+
+- [x] 6.1 使用用户确认的纸张与简笔轮廓,移除纸上文字和多余细节,平滑笔尖过渡,保持 18 px 显示尺寸和主题颜色继承。
+- [x] 6.2 重新生成 SVG,确认与批准稿路径一致,并通过前端测试、构建及变更校验。
+- [x] 6.3 按用户反馈将纸张方框圆角由 1.5 加大到 3 个视图单位,保持外框边界、平滑笔尖、尺寸及线宽不变。
+- [x] 6.4 重新导出图标,检查实际尺寸与放大预览,并通过前端测试和构建。
+
+## 7. 扩展图标简化
+
+- [x] 7.1 使用三个空心圆角正方形和右上角一个圆形,整齐排列为 2×2;将轮廓收紧至 3.5–16.5 坐标范围,沿用共享尺寸、线宽和主题颜色继承。
+- [x] 7.2 重新导出 SVG,检查菜单实际尺寸、边界和明暗预览,并通过前端测试、构建和变更校验。
diff --git a/openspec/changes/unify-session-display-title/.openspec.yaml b/openspec/changes/unify-session-display-title/.openspec.yaml
new file mode 100644
index 000000000..75289e4b7
--- /dev/null
+++ b/openspec/changes/unify-session-display-title/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-24
diff --git a/openspec/changes/unify-session-display-title/design.md b/openspec/changes/unify-session-display-title/design.md
new file mode 100644
index 000000000..a32556679
--- /dev/null
+++ b/openspec/changes/unify-session-display-title/design.md
@@ -0,0 +1,24 @@
+## Context
+
+会话名字有两套推导:侧栏用会话列表里的 `title`(用户改名 / 大模型生成)与 `summary`(`SessionManager::extract_summary` 对最近一条 user 消息 `content` 的 80 字节截断);顶部标题栏由 `useSessionTranscript` 维护,初值抄侧栏对象,但 `loadTranscriptHistory` / `transcript_replace` 随后用 `titleFromMessages`(最后一条 user 消息全文)覆盖,ChatView 发送时还会 `setTranscriptTitle(text)`。侧栏在 `summary` 以 "..." 结尾时另拉整份消息把跑马灯「水合」成全文。
+
+`@session` 引用 / skill / 选区展开后,`content` 是给模型的长文本,用户敲的原文在 `metadata.display_text`;摘要与两条前端路径都没用它。
+
+## Goals / Non-Goals
+
+**Goals:** 会话显示标题是一个字段、一条派生规则、一条实时同步路径;长度由服务端约束;显示文本口径与消息气泡一致。
+
+**Non-Goals:** 改变自动标题生成的触发 / 重试策略;改变「无标题会话显示最近一条用户消息」的既有语义(不改成首条);改 TUI。
+
+## Decisions
+
+- **摘要口径进存储层。** `SessionStorage::visible_user_message_text`(display_text 优先)+ `SessionStorage::summarize_user_message_text`(空白折成单行、UTF-8 安全 80 字节 + "...",英文在词边界断)。`SessionManager::extract_summary` 与 `list_sessions` 补齐旧 meta 的路径都委托给它,内存值与磁盘值逐字节一致。放存储层而不是 SessionManager,是因为补齐旧 meta 的代码在 session_storage.cpp 里,两边必须同实现。
+- **服务端推 summary,前端不自己截。** AgentLoop 在用户消息落盘后(`append_user_turn_message` 与 soft-steer 插话两处)发 `session_updated{summary}`;`list_active` 填 `info.summary`(内存值,不读盘);`GET /messages?since=0` 快照带 `title` / `title_source` / `summary`(live 优先,磁盘 user 改名压过过期内存标题,与 `session_info_to_json` 同规则)。前端若自行按字符截断,会与 C++ 的字节 / 词边界规则出现细微偏差,而用户的要求正是「必须一致」。
+- **Web 只保留一条派生。** transcript store 存 `title` / `titleSource` / `summary` 三个原始字段,对外 `title = sessionDisplayTitle({...ref, title, title_source, summary})`,与侧栏同一函数;`session_updated` 在 transcript reducer 与 Sidebar 列表里做同样的字段合并。删除 `titleFromMessages`、`setTitle`、`sidebarFullTitle.js`;跑马灯滚动的就是显示标题本身。
+- **不改自动标题流程。** 生成标题就绪时的 `session_updated{title,title_source}` 路径原样保留,只是现在它覆盖的是同一个字段。
+
+## Risks / Trade-offs
+
+- [侧栏 hover 不再能看到超长请求的全文] → 这是有意为之:全文既不受限也与顶部不一致;完整原文在消息气泡里。
+- [新会话在 summary 事件到达前顶部短暂显示「新会话N」] → 事件在用户消息落盘时即发出(毫秒级),且 messages 快照兜底;不引入前端本地截断以避免第二套规则。
+- [老 daemon 不发 summary 事件] → 前端保留 ref 初值与快照字段,侧栏 5 秒轮询仍会带回 summary;只是顶部刷新变慢,不会出错。
diff --git a/openspec/changes/unify-session-display-title/proposal.md b/openspec/changes/unify-session-display-title/proposal.md
new file mode 100644
index 000000000..a3633a0c6
--- /dev/null
+++ b/openspec/changes/unify-session-display-title/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+会话名字在两处显示、却来自两条不同的推导路径:侧栏用服务端的 `title` / `summary`(80 字节截断),顶部标题栏在加载历史后用最后一条 user 消息的全文。会话 20260923-163126-e7e3 的首条消息是 `@session` 引用展开后的 9k 字符长文,顶部标题变成了整段展开文本(长度不受限),侧栏显示的又是按展开后 `content` 截出来的 "Referenced ACECode session context follows..."。两处经常不一致,而且用户真正敲的原文(`metadata.display_text`)在哪一边都没被用上。
+
+## What Changes
+
+- 会话摘要(`summary`)按用户消息的**显示文本**(`metadata.display_text` 优先于 `content`)生成,折成单行、UTF-8 安全截到 80 字节 + "..."。`SessionManager` 内存值与 `list_sessions` 补齐旧 meta 走同一实现。
+- daemon 在每条可见用户消息落盘后发 `session_updated{summary}`;`list_active` 与 `GET /messages?since=0` 快照带上 `title` / `title_source` / `summary`。
+- Web 顶部标题栏与侧栏改为同一条派生规则(`sessionDisplayTitle`):服务端 `title` 优先,否则 `summary`;实时刷新只认 `session_updated`。删除 transcript 的 `titleFromMessages`、ChatView 发送时的本地改标题、侧栏 hover 拉全文水合跑马灯(`sidebarFullTitle.js`)。
+- 大模型自动生成标题的流程不变:标题就绪时以 `session_updated{title,title_source}` 覆盖显示。
+
+## Capabilities
+
+### New Capabilities
+
+- `session-display-title`:会话显示标题的单一来源合同(daemon 事件 / 快照字段 + Web 派生规则)。
+
+### Modified Capabilities
+
+- `session-storage`:`summary` 的生成口径(显示文本、单行、80 字节)。
+
+## Impact
+
+`src/session/session_storage.*`、`src/session/session_manager.*`、`src/agent_loop.*`、`src/session/session_registry.cpp`、`src/web/server_helpers.cpp`;`web/src/lib/sessionTranscript.js`、`sessionTitle.js`、`components/Sidebar.jsx`、`components/ChatView.jsx`;`docs/daemon-api.md`。WS 协议只是给既有 `session_updated` 事件增加可选字段 `summary`,老客户端忽略即可。
diff --git a/openspec/changes/unify-session-display-title/specs/session-display-title/spec.md b/openspec/changes/unify-session-display-title/specs/session-display-title/spec.md
new file mode 100644
index 000000000..b4919af12
--- /dev/null
+++ b/openspec/changes/unify-session-display-title/specs/session-display-title/spec.md
@@ -0,0 +1,27 @@
+## ADDED Requirements
+
+### Requirement: 会话显示标题只有一个来源
+
+daemon MUST 为每个会话维护显示标题的三个字段:`title`、`title_source`、`summary`。会话列表(含 `list_active` 的活跃会话)与 `GET /api/sessions/:id/messages?since=0` 快照 MUST 携带这三个字段。用户改名或自动生成标题落地时 daemon MUST 发 `session_updated{title,title_source}`;每条可见用户消息落盘后 daemon MUST 发 `session_updated{summary}`,其值为已按摘要规则截断的文本。
+
+Web 客户端在侧栏、顶部标题栏、网格卡片、搜索面板等所有显示会话名字的地方 MUST 使用同一条派生规则:非空且非 generated `[Error]` 的 `title` 优先,否则 `summary`;实时刷新 MUST 只来自 `session_updated` 与快照字段。客户端 MUST NOT 从消息正文推导标题,MUST NOT 在本地用输入文本改写标题,也 MUST NOT 为显示目的另行拉取消息全文。
+
+#### Scenario: 顶部标题与侧栏一致
+
+- **WHEN** 会话无标题、最近一条用户消息是 9000 字符的引用展开文本(显示文本为 `@规划 继续`)
+- **THEN** 侧栏与顶部标题栏 MUST 都显示 `@规划 继续`,且长度受服务端 80 字节截断约束
+
+#### Scenario: 大模型标题就绪
+
+- **WHEN** daemon 发出 `session_updated{title:"规划 AgentLoop 拆分", title_source:"generated"}`
+- **THEN** 侧栏与顶部标题栏 MUST 同时切换为该标题,`summary` 保持不变
+
+#### Scenario: 新一轮用户消息
+
+- **WHEN** 用户在无标题会话里发送第二条消息 `第二轮 换个话题`
+- **THEN** daemon MUST 发出 `session_updated{summary:"第二轮 换个话题"}`,两处显示 MUST 同步更新
+
+#### Scenario: 刷新 / 深链打开会话
+
+- **WHEN** 客户端没有侧栏会话对象,仅凭 session id 加载 `messages?since=0`
+- **THEN** 顶部标题 MUST 取快照里的 `title` / `summary`,而不是消息正文
diff --git a/openspec/changes/unify-session-display-title/specs/session-storage/spec.md b/openspec/changes/unify-session-display-title/specs/session-storage/spec.md
new file mode 100644
index 000000000..49b069366
--- /dev/null
+++ b/openspec/changes/unify-session-display-title/specs/session-storage/spec.md
@@ -0,0 +1,25 @@
+## ADDED Requirements
+
+### Requirement: 会话摘要取显示文本并限制为单行 80 字节
+
+会话元数据的 `summary` MUST 由最近一条可见用户消息的显示文本生成:消息带非空白的 `metadata.display_text` 时以它为准,否则用 `content`。生成结果 MUST 是单行(换行 / 制表 / 连续空白折成一个空格,首尾空白丢弃),超过 80 字节时 MUST 在 UTF-8 字符边界截断并补 `...`;英文文本 SHOULD 在 60~80 字节区间内的词边界断开。内存中的会话摘要与从 JSONL 补齐旧元数据得到的摘要 MUST 使用同一实现。
+
+#### Scenario: @session 引用展开后的消息
+
+- **WHEN** 用户消息的 `content` 是引用展开后的长文本,`metadata.display_text` 是用户敲的原文 `@规划 AgentLoop 文件拆分与目录结构 继续`
+- **THEN** `summary` MUST 等于该原文,而不是以 `Referenced ACECode session context follows` 开头的展开文本
+
+#### Scenario: 中文长输入
+
+- **WHEN** 用户消息显示文本为 200 个汉字
+- **THEN** `summary` 长度 MUST 不超过 83 字节、以 `...` 结尾,且截断点落在汉字边界
+
+#### Scenario: 多行输入
+
+- **WHEN** 用户消息显示文本为 ` 第一行\r\n\n\t第二行 结尾 \n`
+- **THEN** `summary` MUST 为 `第一行 第二行 结尾`
+
+#### Scenario: 旧元数据补齐
+
+- **WHEN** `.meta.json` 没有 `summary`,会话列表打开 JSONL 补齐
+- **THEN** 补齐结果 MUST 与内存摘要口径一致(显示文本优先、同样的截断规则)
diff --git a/openspec/changes/unify-session-display-title/tasks.md b/openspec/changes/unify-session-display-title/tasks.md
new file mode 100644
index 000000000..409fbe4e2
--- /dev/null
+++ b/openspec/changes/unify-session-display-title/tasks.md
@@ -0,0 +1,16 @@
+## 1. 摘要口径(daemon / 存储层)
+
+- [x] 1.1 `SessionStorage::visible_user_message_text` + `summarize_user_message_text`(显示文本优先、单行、80 字节 UTF-8 安全截断);`SessionManager::extract_summary` 与 `list_sessions` 补齐旧 meta 委托到同一实现;`SessionManager::current_summary()`。
+- [x] 1.2 AgentLoop 在用户消息落盘后(正常回合 + soft-steer 插话)发 `session_updated{summary}`;`list_active` 填 `summary`;`GET /messages?since=0` 快照带 `title` / `title_source` / `summary`。
+- [x] 1.3 回归测试:`tests/session/session_summary_test.cpp`(显示文本优先 / 空白折叠 / CJK 与英文截断 / 旧 meta 补齐)、`tests/agent_loop/agent_loop_session_summary_event_test.cpp`(事件值、顺序、每回合刷新、已截断)。
+
+## 2. Web 单一来源
+
+- [x] 2.1 transcript store 改存 `title` / `titleSource` / `summary`,对外 `title` 经 `transcriptDisplayTitle` 派生;`session_updated` 与快照字段合并;删除 `titleFromMessages` / `setTitle`;ChatView 发送时不再本地改标题。
+- [x] 2.2 Sidebar 对 `session_updated` 合并 `summary`;删除 hover 拉全文水合跑马灯(`sidebarFullTitle.js` 及其测试),跑马灯滚动显示标题本身。
+- [x] 2.3 前端测试:`sessionTranscript.test.js`「会话显示标题」组、`sessionTitleSingleSourceArchitecture.test.js`、更新 `sidebarTitleMarqueeArchitecture.test.js`;`pnpm test` 全绿。
+
+## 3. 文档与验证
+
+- [x] 3.1 `docs/daemon-api.md`(`session_updated` 字段、messages 快照字段)、`CLAUDE.md` 备忘。
+- [x] 3.2 C++ 单测(新用例 + 标题 / 会话列表 / web smoke title 用例)通过;`git diff --check` 无夹带。
diff --git a/openspec/changes/unify-web-scrollbars/.openspec.yaml b/openspec/changes/unify-web-scrollbars/.openspec.yaml
new file mode 100644
index 000000000..cbd245e43
--- /dev/null
+++ b/openspec/changes/unify-web-scrollbars/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-20
diff --git a/openspec/changes/unify-web-scrollbars/design.md b/openspec/changes/unify-web-scrollbars/design.md
new file mode 100644
index 000000000..4f1936d0a
--- /dev/null
+++ b/openspec/changes/unify-web-scrollbars/design.md
@@ -0,0 +1,22 @@
+## Context
+
+动机见 proposal.md。globals.css 存在 5px 普通条、10px transcript、10px 代码预览以及自绘标签条;PPT 使用独立 iframe。xterm 初始化时缓存轨道宽度,因此悬停时改变真实轨道宽度会破坏布局与列数。
+
+## Goals / Non-Goals
+
+统一应用自有滚动表面的外观和命中范围,不重写浏览器滚动、终端输入或标签拖拽算法。不控制浏览器 PDF 插件和用户网页内容。
+
+## Decisions
+
+- 新建共享 `styles/scrollbars.css`,由 globals.css 引用,PPT 组件通过 raw 文本将同一规则传给 iframe 文档生成器;避免两份样式漂移。
+- 细条轨道固定 12px,透明边框将滑块视觉宽度保持为 4px,悬停或拖动时仅加深颜色。透明边框属于原生滑块命中区域,容器宽度不因 hover 改变;自绘标签滚动条同样保留 12px 命中区域和 4px 滑块厚度。
+- 默认隐藏,容器 hover 或 focus-within 时显现;`.ace-scrollbar-always` 或 `data-scrollbar-visibility="always"` 可明确选择常显。主 transcript 始终使用 10px 粗轨道,保留已有流式锚定行为。
+- 清除会绕开 Chromium WebKit 自定义规则的局部 `scrollbar-color` 和 `scrollbar-width: thin`;标准属性仅用于无 WebKit 滚动条的浏览器降级。
+- 标签条沿用既有自绘拖拽及点击逻辑,尺寸和颜色引用共享 token。下层编辑预览与紧凑标签导航的结构性隐藏继续保留。
+- 无 hover 的触摸设备显示细条以保持可发现性;自绘动画遵守 reduced-motion。
+
+## Risks / Trade-offs
+
+- 固定较宽轨道会占用更多初始空间,但 hover 不重新布局。检查输入框、窄屏、代码预览、终端与电子表格。
+- 原生 hover 伪状态不能仅用 getComputedStyle 证明;浏览器验证须开启可见滚动条并通过截图和透明边缘拖动验证。
+- Firefox 等标准属性降级遵循平台原生交互,无法保证与 Chromium 完全相同的滑块悬停视觉宽度。
diff --git a/openspec/changes/unify-web-scrollbars/proposal.md b/openspec/changes/unify-web-scrollbars/proposal.md
new file mode 100644
index 000000000..f24f0eba0
--- /dev/null
+++ b/openspec/changes/unify-web-scrollbars/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+当前 WebUI 的普通容器、侧栏、代码预览和标签栏各自定义滚动条尺寸、颜色与显隐,导致体验不一致且细条难以拖动。统一两类外观,同时扩大细条操作范围。
+
+## What Changes
+
+- 主会话 transcript 保留粗滚动条和常显行为。
+- 其他普通滚动容器共用细滚动条,按用户最后一句默认隐藏、移入容器显示;提供可复用的常显模式。
+- 细滚动条悬停或拖动时仅加深颜色,保持 4px 视觉宽度;12px 透明命中区域支持点击和拖动,悬停不改变内容宽度。
+- 合并代码预览、编辑器、侧栏以及自绘标签栏的分散样式,PPT 预览使用同一份共享规则。
+- 保留编辑器下层、紧凑标签导航等结构性隐藏,避免双重滚动条;浏览器内建 PDF 阅读器不受应用样式控制。
+
+## Capabilities
+
+### New Capabilities
+
+- `web-scrollbars`: WebUI 粗细滚动条、显隐、悬停和操作范围的一致行为。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+共享 CSS、globals.css 的旧规则、自绘预览标签、PPT iframe 样式传递与相关验证。无后端协议或持久配置变更,不修改第三方库文件。
diff --git a/openspec/changes/unify-web-scrollbars/specs/web-scrollbars/spec.md b/openspec/changes/unify-web-scrollbars/specs/web-scrollbars/spec.md
new file mode 100644
index 000000000..754c0bbd4
--- /dev/null
+++ b/openspec/changes/unify-web-scrollbars/specs/web-scrollbars/spec.md
@@ -0,0 +1,45 @@
+## Purpose
+
+为 ACECode 的应用自有滚动容器定义统一的粗细滚动条、显隐与悬停反馈,让用户在会话、列表、编辑预览和嵌入文档中获得一致且容易点击拖动的滚动体验,并避免悬停导致内容重新布局。
+
+## ADDED Requirements
+
+### Requirement: 粗细滚动条共享一致规则
+
+应用 SHALL 保留主会话 transcript 的粗常显滚动条,其余普通容器采用统一细样式。
+
+#### Scenario: 主会话和普通面板
+- **WHEN** 主会话、侧栏、设置或代码预览内容溢出
+- **THEN** 主会话 MUST 保持粗常显,其他区域 MUST 采用统一细条颜色和圆角
+
+### Requirement: 细条按模式显现并扩大操作范围
+
+细条 SHALL 默认隐藏,在移入所属容器或键盘聚焦时显现,并允许容器明确选择常显模式。
+
+#### Scenario: 悬停细滑块
+- **WHEN** 指针移入细滚动条滑块或其透明命中边缘
+- **THEN** 滑块 MUST 仅加深颜色并保持 4px 视觉宽度,12px 透明命中区域 MUST 支持点击拖动,容器内容尺寸 MUST 不变
+
+#### Scenario: 显隐与常显
+- **WHEN** 指针离开默认细条容器且该容器无键盘焦点
+- **THEN** 滑块 MUST 隐藏;明确设置常显的细条 MUST 保持可见
+
+#### Scenario: 水平滚动与拖动
+- **WHEN** 用户操作水平细条或拖动时指针离开轨道
+- **THEN** 系统 MUST 使用同一套仅加深颜色的反馈,保持滑块厚度和拖动有效,不能改变内容布局
+
+#### Scenario: 触摸与兼容降级
+- **WHEN** 设备不支持 hover 或浏览器不支持细滑块自定义外观
+- **THEN** 系统 MUST 保留可用的原生滚动操作与可发现性,不因视觉效果禁用滚动
+
+### Requirement: 特殊预览复用样式而保留滚动所有者
+
+应用自绘标签滚动条和 PPT 文档预览 SHALL 复用共享尺寸与颜色规则;编辑模式 MUST 只显示负责交互的滚动条。
+
+#### Scenario: 标签和嵌入文档
+- **WHEN** 预览标签溢出或 PPT 缩放产生滚动
+- **THEN** 滚动条 MUST 使用统一细样式,并保留标签拖动和文档缩放功能
+
+#### Scenario: 编辑预览
+- **WHEN** 代码预览进入编辑模式
+- **THEN** 输入层 MUST 采用统一细条,下层只读渲染的滚动条 MUST 保持隐藏以避免重复
diff --git a/openspec/changes/unify-web-scrollbars/tasks.md b/openspec/changes/unify-web-scrollbars/tasks.md
new file mode 100644
index 000000000..4f207ada5
--- /dev/null
+++ b/openspec/changes/unify-web-scrollbars/tasks.md
@@ -0,0 +1,30 @@
+## 1. 统一实现
+
+- [x] 1.1 建立共享细条、粗条和显隐模式,替换旧局部规则及自绘标签尺寸;通过原生纵横拖动、悬停像素和布局稳定性检查。
+- [x] 1.2 将同一滚动条样式传入 PPT iframe,保留文档生成与缩放;通过定向测试和 iframe 浏览器检查。
+
+## 2. 集成验证
+
+- [x] 2.1 浏览器检查亮暗主题、390px、reduced-motion、侧栏、预览编辑、标签、终端及电子表格的滚动与命中范围。
+- [x] 2.2 运行 pnpm test、pnpm build、OpenSpec strict、git diff --check,并记录验证边界。
+
+## 3. 悬停反馈调整
+
+- [x] 3.1 原生和自绘细条悬停、拖动时保持 4px 视觉厚度,仅加深颜色,保留 12px 点击拖动区域。
+- [x] 3.2 验证悬停尺寸、颜色和透明边缘拖动,运行前端测试、构建、OpenSpec strict 与差异检查。
+
+## 悬停反馈调整验证记录
+
+- 当前工作区全量前端测试通过(2761 条 pass 输出),生产构建、正则兼容扫描、OpenSpec strict 与限定差异检查通过。
+- Chromium 使用本次构建的 CSS,在亮暗主题与 1000px/390px 视口的 4 组场景中确认:原生纵横滑块悬停和按下时保持 4px,颜色加深,内容宽度不变;12px 区域两侧透明边缘均可拖动(共 16 次)。
+- 自绘标签滚动条悬停和拖动态的滑块厚度保持 4px、偏移不变,命中区域保持 12px;透明边缘命中与颜色反馈通过检查。沿用原有组件拖动逻辑。
+- 设计检测仅报告 globals.css 中本次未修改的旧规则;未运行完整业务端到端测试,未重建或验证已安装 Desktop。
+
+## 原始实现验证记录(调整前)
+
+- 全量前端测试 2635 项通过;最终生产构建、正则兼容扫描、OpenSpec strict 与限定差异检查通过。
+- Chromium 实际像素与拖动验证 44/44 通过:亮暗主题、1000px/390px、reduced-motion 下检查 11 类滚动表面,无页面错误。
+- 细条默认隐藏、容器 hover 4px、滑块 hover 8px;12px 透明边缘能纵横拖动,内容 clientWidth 不变;粗条与 always 模式符合约定。
+- 真实 xterm、x-data-spreadsheet、自绘标签组件及 PPT iframe 文档通过集成检查;其余采用真实 CSS 类名与结构的最小溢出容器。编辑下层隐藏保留。
+- 设计检测发现的其他旧样式提示未扩展处理;本次自绘加粗不引入尺寸动画。
+- 未运行完整业务端到端测试、未重建或验证已安装 Desktop;浏览器 PDF 内建界面不由应用 CSS 控制。
diff --git a/packaging/macos-installer/Engine.swift b/packaging/macos-installer/Engine.swift
new file mode 100644
index 000000000..52bca5e43
--- /dev/null
+++ b/packaging/macos-installer/Engine.swift
@@ -0,0 +1,180 @@
+import Foundation
+import Darwin
+
+struct InstallError: LocalizedError {
+ let message: String
+ var errorDescription: String? { message }
+}
+
+func runTool(_ path: String, _ arguments: [String]) throws -> String {
+ let task = Process()
+ task.executableURL = URL(fileURLWithPath: path)
+ task.arguments = arguments
+ let pipe = Pipe()
+ task.standardOutput = pipe
+ task.standardError = pipe
+ try task.run()
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ task.waitUntilExit()
+ let output = String(data: data, encoding: .utf8) ?? ""
+ guard task.terminationStatus == 0 else {
+ throw InstallError(message: "\(URL(fileURLWithPath: path).lastPathComponent) 失败:\n\(output)")
+ }
+ return output
+}
+
+enum InstallScope { case personal, system, custom }
+
+final class InstallerEngine {
+ let fm = FileManager.default
+ let home: URL
+ let validateSignature: (URL) throws -> Void
+ // Injected faults are used only by the standalone test executable.
+ var fault: ((String) throws -> Void)?
+ var isRunning: (URL) -> Bool = { _ in false }
+ private(set) var cleanupWarning: String?
+
+ init(home: URL = FileManager.default.homeDirectoryForCurrentUser,
+ validateSignature: @escaping (URL) throws -> Void = InstallerEngine.trustedSignature) {
+ self.home = home.resolvingSymlinksInPath().standardizedFileURL
+ self.validateSignature = validateSignature
+ }
+
+ static func trustedSignature(_ app: URL) throws {
+ _ = try runTool("/usr/bin/codesign", ["--verify", "--deep", "--strict", app.path])
+ let details = try runTool("/usr/bin/codesign", ["-dv", "--verbose=4", app.path])
+ guard details.contains("TeamIdentifier=T52GZCH73Y"),
+ details.contains("Authority=Developer ID Application:") else {
+ throw InstallError(message: "应用不是预期发行团队的 Developer ID 签名版本。")
+ }
+ _ = try runTool("/usr/sbin/spctl", ["--assess", "--type", "execute", app.path])
+ }
+
+ func bundleCheck(_ app: URL) throws {
+ let info = app.appendingPathComponent("Contents/Info.plist")
+ guard let data = try? Data(contentsOf: info),
+ let dict = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
+ dict["CFBundleIdentifier"] as? String == "dev.acecode.desktop",
+ let executable = dict["CFBundleExecutable"] as? String,
+ !executable.isEmpty, !executable.contains("/"), executable != "..",
+ fm.isExecutableFile(atPath: app.appendingPathComponent("Contents/MacOS/" + executable).path) else {
+ throw InstallError(message: "不是完整的 ACECode 应用:\(app.path)")
+ }
+ }
+
+ func checkedFolder(_ input: URL, create: Bool, scope: InstallScope = .personal) throws -> URL {
+ let folder = input.resolvingSymlinksInPath().standardizedFileURL
+ if scope == .personal && !folder.path.hasPrefix(home.path + "/") {
+ throw InstallError(message: "个人安装请选择主目录内的文件夹;系统目录请使用“这台 Mac”选项。")
+ }
+ if scope == .system && folder != URL(fileURLWithPath: "/Applications").resolvingSymlinksInPath() {
+ throw InstallError(message: "系统安装仅支持 /Applications。")
+ }
+ guard !folder.pathComponents.contains(where: { $0.lowercased().hasSuffix(".app") }) else {
+ throw InstallError(message: "不能安装到另一个应用包内部。")
+ }
+ var ancestor = folder
+ while !fm.fileExists(atPath: ancestor.path) { ancestor.deleteLastPathComponent() }
+ let attrs = try fm.attributesOfItem(atPath: ancestor.path)
+ guard attrs[.type] as? FileAttributeType == .typeDirectory,
+ fm.isWritableFile(atPath: ancestor.path),
+ fm.isExecutableFile(atPath: ancestor.path) else {
+ throw InstallError(message: "当前用户没有此目录的写入或访问权限:\(ancestor.path)\n本安装器不会请求提权。请选择“仅为我安装”,或更换可写位置。")
+ }
+ if create { try fm.createDirectory(at: folder, withIntermediateDirectories: true) }
+ return folder
+ }
+
+ func suggestedFolder() -> URL {
+ let first = home.appendingPathComponent("Applications")
+ return (try? checkedFolder(first, create: false)) != nil ? first : home.appendingPathComponent("Apps")
+ }
+
+ func install(source: URL, folder: URL, replace: Bool, scope: InstallScope = .personal,
+ progress: (String) -> Void = { _ in }) throws -> URL {
+ cleanupWarning = nil
+ progress("正在验证应用签名与系统信任…")
+ try bundleCheck(source)
+ try validateSignature(source)
+ let directory = try checkedFolder(folder, create: true, scope: scope)
+ let target = directory.appendingPathComponent("ACECode.app")
+ guard source.resolvingSymlinksInPath().path != target.resolvingSymlinksInPath().path,
+ !directory.path.hasPrefix(source.resolvingSymlinksInPath().path + "/") else {
+ throw InstallError(message: "源应用与目标不能相同,也不能安装到源应用内部。")
+ }
+ // Share the lock and its safety contract with src/upgrade/macos_app_installer.mm.
+ let lock = directory.appendingPathComponent(".ACECode.update.lock")
+ let fd = open(lock.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, mode_t(0o600))
+ guard fd >= 0 else { throw InstallError(message: "无法创建安装锁,请检查目录权限。") }
+ defer { close(fd) }
+ var lockInfo = stat()
+ guard fstat(fd, &lockInfo) == 0,
+ (lockInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG),
+ lockInfo.st_uid == geteuid(), lockInfo.st_nlink == 1 else {
+ throw InstallError(message: "安装锁必须是当前用户拥有的普通文件,且不能包含多个硬链接。")
+ }
+ guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { throw InstallError(message: "另一个安装或更新程序正在使用此目录。") }
+ defer { flock(fd, LOCK_UN) }
+ // Keep the lock inode: unlinking it allows two processes to lock different inodes.
+ let original = try targetState(target)
+ if original != nil && !replace { throw InstallError(message: "目标已有 ACECode,请确认替换后再安装。") }
+ if original != nil { try bundleCheck(target) }
+ if original != nil && !fm.isDeletableFile(atPath: target.path) {
+ throw InstallError(message: "当前用户不能替换已有应用:\(target.path)\n请改用个人目录安装。")
+ }
+ guard !isRunning(target) else { throw InstallError(message: "目标 ACECode 正在运行,请退出后重试。") }
+ let work = directory.appendingPathComponent(".acecode-stage-" + UUID().uuidString)
+ try fm.createDirectory(at: work, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
+ let staged = work.appendingPathComponent("ACECode.app")
+ let backup = work.appendingPathComponent("previous.app")
+ var preserve = false
+ defer { if !preserve { try? fm.removeItem(at: work) } }
+ progress("正在复制应用到临时目录…")
+ try fault?("copy")
+ _ = try runTool("/usr/bin/ditto", [source.path, staged.path])
+ try bundleCheck(staged)
+ try validateSignature(staged)
+ guard try targetState(target) == original, !isRunning(target) else {
+ throw InstallError(message: "目标应用在安装期间发生变化或已启动,请重试。")
+ }
+ progress("正在提交安装…")
+ if original != nil { try fm.moveItem(at: target, to: backup) }
+ do {
+ try fault?("commit")
+ try fm.moveItem(at: staged, to: target)
+ } catch {
+ let commitError = error
+ if original != nil {
+ do {
+ try fault?("rollback")
+ try fm.moveItem(at: backup, to: target)
+ } catch {
+ preserve = true
+ throw InstallError(message: "安装失败且自动恢复失败。旧版本安全保留在:\(backup.path)\n安装错误:\(commitError.localizedDescription)\n恢复错误:\(error.localizedDescription)")
+ }
+ }
+ throw InstallError(message: "安装未完成,原有版本未改变或已恢复。\n\(commitError.localizedDescription)")
+ }
+ // A shared installation may contain old files this user cannot delete.
+ // Never silently remove a backup when cleanup fails.
+ do { try fm.removeItem(at: work) }
+ catch {
+ preserve = true
+ cleanupWarning = "安装成功;旧文件未能清理,保留于:\(work.path)"
+ }
+ return target
+ }
+
+ func targetState(_ target: URL) throws -> String? {
+ do {
+ let attrs = try fm.attributesOfItem(atPath: target.path)
+ guard attrs[.type] as? FileAttributeType == .typeDirectory else {
+ throw InstallError(message: "目标不是普通应用目录,拒绝覆盖链接或其他文件。")
+ }
+ return "\(attrs[.systemFileNumber]!)|\(attrs[.modificationDate]!)"
+ } catch let error as NSError where error.domain == NSCocoaErrorDomain &&
+ (error.code == NSFileNoSuchFileError || error.code == NSFileReadNoSuchFileError) {
+ return nil
+ }
+ }
+}
diff --git a/packaging/macos-installer/Tests.swift b/packaging/macos-installer/Tests.swift
new file mode 100644
index 000000000..02f8ec4bd
--- /dev/null
+++ b/packaging/macos-installer/Tests.swift
@@ -0,0 +1,150 @@
+import Foundation
+import Darwin
+
+@main struct Tests {
+ static func main() throws {
+ let fm = FileManager.default
+ let root = fm.temporaryDirectory.resolvingSymlinksInPath().appendingPathComponent("acecode-installer-test-" + UUID().uuidString)
+ try fm.createDirectory(at: root, withIntermediateDirectories: false)
+ defer { try? fm.removeItem(at: root) }
+ let engine = InstallerEngine(home: root, validateSignature: { _ in })
+ var count = 0
+ func check(_ name: String, _ body: () throws -> Void) throws {
+ try body(); count += 1; print("PASS \(name)")
+ }
+ func rejected(_ body: () throws -> Void) throws {
+ do { try body() } catch { return }
+ throw InstallError(message: "Expected failure")
+ }
+ func fixture(_ name: String) throws -> URL {
+ let app = root.appendingPathComponent(name + "/ACECode.app")
+ let mac = app.appendingPathComponent("Contents/MacOS")
+ try fm.createDirectory(at: mac, withIntermediateDirectories: true)
+ let info: [String: String] = ["CFBundleIdentifier": "dev.acecode.desktop", "CFBundleExecutable": "ACECode"]
+ try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: app.appendingPathComponent("Contents/Info.plist"))
+ try Data("#!/bin/sh\nexit 0\n".utf8).write(to: mac.appendingPathComponent("ACECode"))
+ try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mac.appendingPathComponent("ACECode").path)
+ return app
+ }
+ let source = try fixture("source")
+ let folder = root.appendingPathComponent("个人 Apps")
+ let target = folder.appendingPathComponent("ACECode.app")
+ try check("new install / Chinese and spaces") { _ = try engine.install(source: source, folder: folder, replace: false) }
+ let marker = target.appendingPathComponent("old-marker")
+ try Data("old".utf8).write(to: marker)
+ try check("replacement needs confirmation") { try rejected { _ = try engine.install(source: source, folder: folder, replace: false) } }
+ try check("copy failure preserves original") {
+ engine.fault = { if $0 == "copy" { throw InstallError(message: "injected") } }
+ try rejected { _ = try engine.install(source: source, folder: folder, replace: true) }
+ assert(fm.fileExists(atPath: marker.path)); engine.fault = nil
+ }
+ try check("commit failure rolls back") {
+ engine.fault = { if $0 == "commit" { throw InstallError(message: "injected") } }
+ try rejected { _ = try engine.install(source: source, folder: folder, replace: true) }
+ assert(fm.fileExists(atPath: marker.path)); engine.fault = nil
+ }
+ try check("running app rejected") {
+ engine.isRunning = { _ in true }
+ try rejected { _ = try engine.install(source: source, folder: folder, replace: true) }
+ engine.isRunning = { _ in false }
+ }
+ try check("concurrent updater lock rejected without changing the app") {
+ let fd = open(folder.appendingPathComponent(".ACECode.update.lock").path, O_RDWR)
+ assert(fd >= 0); assert(flock(fd, LOCK_EX | LOCK_NB) == 0)
+ defer { close(fd) }
+ try rejected { _ = try engine.install(source: source, folder: folder, replace: true) }
+ assert(fm.fileExists(atPath: marker.path))
+ }
+ for kind in ["fifo", "directory", "symlink", "hardlink"] {
+ try check("unsafe \(kind) lock rejected before copying") {
+ let destination = root.appendingPathComponent("lock-" + kind)
+ try fm.createDirectory(at: destination, withIntermediateDirectories: false)
+ let lock = destination.appendingPathComponent(".ACECode.update.lock")
+ let sentinel = destination.appendingPathComponent("sentinel")
+ try Data("unchanged".utf8).write(to: sentinel)
+ switch kind {
+ case "fifo": assert(mkfifo(lock.path, mode_t(0o600)) == 0)
+ case "directory": try fm.createDirectory(at: lock, withIntermediateDirectories: false)
+ case "symlink": try fm.createSymbolicLink(at: lock, withDestinationURL: sentinel)
+ default: try fm.linkItem(at: sentinel, to: lock)
+ }
+ var copied = false
+ engine.fault = { if $0 == "copy" { copied = true } }
+ defer { engine.fault = nil }
+ try rejected { _ = try engine.install(source: source, folder: destination, replace: false) }
+ assert(!copied)
+ let sentinelContents = try String(contentsOf: sentinel, encoding: .utf8)
+ assert(sentinelContents == "unchanged")
+ assert(!fm.fileExists(atPath: destination.appendingPathComponent("ACECode.app").path))
+ }
+ }
+ try check("successful replacement") {
+ _ = try engine.install(source: source, folder: folder, replace: true)
+ assert(!fm.fileExists(atPath: marker.path))
+ }
+ try check("unwritable directory and fallback") {
+ let apps = root.appendingPathComponent("Applications")
+ try fm.createDirectory(at: apps, withIntermediateDirectories: false)
+ try fm.setAttributes([.posixPermissions: 0o555], ofItemAtPath: apps.path)
+ defer { try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: apps.path) }
+ assert(engine.suggestedFolder().lastPathComponent == "Apps")
+ try rejected { _ = try engine.checkedFolder(apps, create: true) }
+ }
+ try check("outside home and symlink escape") {
+ try rejected { _ = try engine.checkedFolder(URL(fileURLWithPath: "/Applications"), create: false) }
+ let link = root.appendingPathComponent("escape")
+ try fm.createSymbolicLink(at: link, withDestinationURL: URL(fileURLWithPath: "/tmp"))
+ try rejected { _ = try engine.checkedFolder(link, create: false) }
+ }
+ try check("system directory uses actual access, not owner identity (read only)") {
+ let system = URL(fileURLWithPath: "/Applications")
+ if fm.isWritableFile(atPath: system.path) && fm.isExecutableFile(atPath: system.path) {
+ let checked = try engine.checkedFolder(system, create: false, scope: .system)
+ assert(checked == system.resolvingSymlinksInPath())
+ } else {
+ try rejected { _ = try engine.checkedFolder(system, create: false, scope: .system) }
+ }
+ try rejected { _ = try engine.checkedFolder(folder, create: false, scope: .system) }
+ }
+ try check("custom install outside configured home") {
+ let limited = InstallerEngine(home: root.appendingPathComponent("fake-home"), validateSignature: { _ in })
+ let custom = root.appendingPathComponent("shared-location")
+ try rejected { _ = try limited.checkedFolder(custom, create: false) }
+ _ = try limited.install(source: source, folder: custom, replace: false, scope: .custom)
+ assert(fm.fileExists(atPath: custom.appendingPathComponent("ACECode.app").path))
+ }
+ try check("custom unwritable directory and app nesting rejected") {
+ let denied = root.appendingPathComponent("denied")
+ try fm.createDirectory(at: denied, withIntermediateDirectories: false)
+ try fm.setAttributes([.posixPermissions: 0o555], ofItemAtPath: denied.path)
+ defer { try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: denied.path) }
+ try rejected { _ = try engine.checkedFolder(denied, create: true, scope: .custom) }
+ try rejected { _ = try engine.checkedFolder(source.appendingPathComponent("nested"), create: false, scope: .custom) }
+ }
+ try check("unrelated app and missing source") {
+ let other = root.appendingPathComponent("other/ACECode.app")
+ try fm.createDirectory(at: other, withIntermediateDirectories: true)
+ try rejected { _ = try engine.install(source: source, folder: other.deletingLastPathComponent(), replace: true) }
+ try rejected { _ = try engine.install(source: root.appendingPathComponent("missing"), folder: folder, replace: true) }
+ }
+ try check("invalid signature") {
+ let strict = InstallerEngine(home: root)
+ try rejected { _ = try strict.install(source: source, folder: folder, replace: true) }
+ }
+ try check("rollback failure preserves backup") {
+ engine.fault = { if $0 == "commit" || $0 == "rollback" { throw InstallError(message: "injected") } }
+ try rejected { _ = try engine.install(source: source, folder: folder, replace: true) }
+ let dirs = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
+ assert(dirs.contains { fm.fileExists(atPath: $0.appendingPathComponent("previous.app").path) })
+ engine.fault = nil
+ }
+ if CommandLine.arguments.count > 1 {
+ try check("real Developer ID payload install and signature verification") {
+ let strict = InstallerEngine(home: root)
+ let result = try strict.install(source: URL(fileURLWithPath: CommandLine.arguments[1]), folder: root.appendingPathComponent("real"), replace: false)
+ try InstallerEngine.trustedSignature(result)
+ }
+ }
+ print("\(count) tests passed; temporary test directories removed on exit")
+ }
+}
diff --git a/packaging/macos-installer/build.sh b/packaging/macos-installer/build.sh
new file mode 100644
index 000000000..ce915a1b4
--- /dev/null
+++ b/packaging/macos-installer/build.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# Production adaptation of the v6 local installer build; never signs the payload.
+set -euo pipefail
+usage() {
+ printf 'Usage: bash packaging/macos-installer/build.sh --app ACECode.app --output NEW-DIR --arch x64|arm64\n'
+}
+source_app=""; output=""; arch=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --app|--output|--arch)
+ [[ $# -ge 2 && -n "$2" ]] || { usage >&2; exit 2; }
+ case "$1" in --app) source_app="$2";; --output) output="$2";; --arch) arch="$2";; esac
+ shift 2 ;;
+ --help|-h) usage; exit 0 ;;
+ *) usage >&2; exit 2 ;;
+ esac
+done
+case "$arch" in x64) target=x86_64;; arm64) target=arm64;; *) usage >&2; exit 2;; esac
+[[ -n "$source_app" && -n "$output" ]] || { usage >&2; exit 2; }
+[[ "$(uname -s)" == Darwin ]] || { printf 'Darwin required\n' >&2; exit 1; }
+[[ -d "$source_app" && "$(basename "$source_app")" == ACECode.app && ! -e "$output" && ! -L "$output" ]] || {
+ printf 'Source missing or output already exists\n' >&2; exit 1;
+}
+source_app="$(cd "$source_app" && pwd -P)"
+source_dir="$(cd "$(dirname "$0")" && pwd)"
+info="$source_app/Contents/Info.plist"
+[[ "$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$info")" == dev.acecode.desktop ]]
+version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$info")"
+build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$info")"
+for binary in ACECode acecode-daemon; do
+ /usr/bin/lipo "$source_app/Contents/MacOS/$binary" -verify_arch "$target"
+done
+/usr/bin/codesign --verify --deep --strict "$source_app"
+details="$(/usr/bin/codesign -dv --verbose=4 "$source_app" 2>&1)"
+grep -Fxq 'TeamIdentifier=T52GZCH73Y' <<< "$details"
+grep -Fq 'Authority=Developer ID Application:' <<< "$details"
+xcrun stapler validate "$source_app"
+/usr/sbin/spctl --assess --type execute "$source_app"
+mkdir -p "$output"
+output="$(cd "$output" && pwd -P)"
+app="$output/ACECode Installer.app"
+mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
+xcrun swiftc -swift-version 5 -O -target "${target}-apple-macosx11.0" \
+ -sdk "$(xcrun --sdk macosx --show-sdk-path)" -framework AppKit \
+ "$source_dir/Engine.swift" "$source_dir/main.swift" -o "$app/Contents/MacOS/ACECodeInstaller"
+/usr/bin/lipo "$app/Contents/MacOS/ACECodeInstaller" -verify_arch "$target"
+plist="$app/Contents/Info.plist"
+/usr/bin/plutil -create xml1 "$plist"
+/usr/bin/plutil -insert CFBundleIdentifier -string dev.acecode.userinstaller "$plist"
+/usr/bin/plutil -insert CFBundleExecutable -string ACECodeInstaller "$plist"
+/usr/bin/plutil -insert CFBundleName -string 'ACECode Installer' "$plist"
+/usr/bin/plutil -insert CFBundlePackageType -string APPL "$plist"
+/usr/bin/plutil -insert CFBundleVersion -string "$build_version" "$plist"
+/usr/bin/plutil -insert CFBundleShortVersionString -string "$version" "$plist"
+/usr/bin/plutil -insert LSMinimumSystemVersion -string 11.0 "$plist"
+icon_name="$(/usr/bin/plutil -extract CFBundleIconFile raw -o - "$info")"
+[[ -n "$icon_name" && "$icon_name" != */* && "$icon_name" != .* ]] || { printf 'Invalid app icon name\n' >&2; exit 1; }
+[[ "$icon_name" == *.icns ]] || icon_name="$icon_name.icns"
+/usr/bin/ditto "$source_app/Contents/Resources/$icon_name" "$app/Contents/Resources/InstallerIcon.icns"
+/usr/bin/plutil -insert CFBundleIconFile -string InstallerIcon.icns "$plist"
+/usr/bin/ditto "$source_app" "$app/Contents/Resources/ACECode.app"
+/usr/bin/diff -rq "$source_app" "$app/Contents/Resources/ACECode.app"
+printf 'Unsigned installer wrapper (trusted payload unchanged): %s\n' "$app"
diff --git a/packaging/macos-installer/main.swift b/packaging/macos-installer/main.swift
new file mode 100644
index 000000000..408c94503
--- /dev/null
+++ b/packaging/macos-installer/main.swift
@@ -0,0 +1,325 @@
+import AppKit
+
+final class InstallerCardView: NSView {
+ override func draw(_ dirtyRect: NSRect) {
+ super.draw(dirtyRect)
+ let borderRect = bounds.insetBy(dx: 0.5, dy: 0.5)
+ let background = NSBezierPath(roundedRect: borderRect, xRadius: 10, yRadius: 10)
+ NSColor.controlBackgroundColor.setFill()
+ background.fill()
+ NSColor.separatorColor.setStroke()
+ background.lineWidth = 1
+ background.stroke()
+ }
+}
+
+final class InstallerUI: NSObject, NSApplicationDelegate {
+ let engine = InstallerEngine()
+ let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 820, height: 600), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
+ let scopePicker = NSSegmentedControl(labels: ["仅为我安装", "这台 Mac", "其他位置"], trackingMode: .selectOne, target: nil, action: nil)
+ let locationTitle = NSTextField(labelWithString: "个人应用目录")
+ var scope: InstallScope { scopePicker.selectedSegment == 1 ? .system : scopePicker.selectedSegment == 2 ? .custom : .personal }
+ let heading = NSTextField(labelWithString: "安装 ACECode")
+ let detail = NSTextField(wrappingLabelWithString: "选择安装位置。")
+ let stepLabels = ["安装位置", "正在安装", "安装完成"].map { NSTextField(labelWithString: $0) }
+ let hero = NSImageView()
+ let path = NSTextField(wrappingLabelWithString: "")
+ let status = NSTextField(wrappingLabelWithString: "无需管理员密码,不修改系统目录或现有用户数据。")
+ let change = NSButton(title: "选择其他文件夹…", target: nil, action: nil)
+ let install = NSButton(title: "安装到个人目录", target: nil, action: nil)
+ let launch = NSButton(title: "启动 ACECode", target: nil, action: nil)
+ let reveal = NSButton(title: "在 Finder 中显示", target: nil, action: nil)
+ let spinner = NSProgressIndicator()
+ var destination: URL!
+ var installed: URL?
+ var busy = false
+ var source: URL { Bundle.main.resourceURL!.appendingPathComponent("ACECode.app") }
+
+ private func hasAmbiguousLayout(in view: NSView) -> Bool {
+ view.hasAmbiguousLayout || view.subviews.contains { hasAmbiguousLayout(in: $0) }
+ }
+
+ private func layoutSnapshot(card: NSView, cardStack: NSView, locationRow: NSView) -> [String: Any] {
+ let stackFrame = card.convert(cardStack.bounds, from: cardStack)
+ let rowFrame = card.convert(locationRow.bounds, from: locationRow)
+ return [
+ "scope": scopePicker.selectedSegment,
+ "cardClass": String(describing: type(of: card)),
+ "cardWidth": card.bounds.width,
+ "cardHeight": card.bounds.height,
+ "contentWidth": cardStack.bounds.width,
+ "contentHeight": cardStack.bounds.height,
+ "contentInsideCard": card.bounds.contains(stackFrame),
+ "rowInsideCard": card.bounds.contains(rowFrame),
+ "ambiguous": hasAmbiguousLayout(in: window.contentView!),
+ "changeHidden": change.isHidden,
+ "path": path.stringValue
+ ]
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ NSApp.setActivationPolicy(.regular)
+ if let iconURL = Bundle.main.url(forResource: "InstallerIcon", withExtension: "icns"),
+ let icon = NSImage(contentsOf: iconURL) {
+ NSApp.applicationIconImage = icon
+ }
+ let menu = NSMenu()
+ let root = NSMenuItem()
+ let appMenu = NSMenu()
+ appMenu.addItem(withTitle: "退出安装器", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
+ root.submenu = appMenu
+ menu.addItem(root)
+ NSApp.mainMenu = menu
+ window.title = "安装 ACECode"
+ window.isReleasedWhenClosed = false
+ let version = Bundle(url: source)?.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "未知"
+ heading.font = .boldSystemFont(ofSize: 25)
+ detail.font = .systemFont(ofSize: 13)
+ detail.textColor = .secondaryLabelColor
+ path.isSelectable = true
+ path.font = .systemFont(ofSize: 12)
+ path.lineBreakMode = .byTruncatingMiddle
+ path.maximumNumberOfLines = 2
+ status.isSelectable = true
+ status.font = .systemFont(ofSize: 12)
+ status.textColor = .secondaryLabelColor
+ change.target = self; change.action = #selector(selectFolder)
+ change.title = "更改位置…"
+ change.bezelStyle = .rounded
+ install.target = self; install.action = #selector(startInstall)
+ install.title = "安装"
+ install.bezelStyle = .rounded
+ install.keyEquivalent = "\r"
+ launch.target = self; launch.action = #selector(openApp)
+ reveal.target = self; reveal.action = #selector(showApp)
+ launch.bezelStyle = .rounded; reveal.bezelStyle = .rounded
+ launch.isHidden = true; reveal.isHidden = true
+ spinner.style = .bar; spinner.isIndeterminate = true; spinner.isDisplayedWhenStopped = false
+ let content = window.contentView!
+ let sidebar = NSVisualEffectView()
+ sidebar.material = .sidebar; sidebar.blendingMode = .behindWindow; sidebar.state = .active
+ let footer = NSView()
+ let separator = NSBox(); separator.boxType = .separator
+ let divider = NSBox(); divider.boxType = .separator
+ for view in [sidebar, footer, separator, divider] {
+ view.translatesAutoresizingMaskIntoConstraints = false; content.addSubview(view)
+ }
+ hero.image = NSWorkspace.shared.icon(forFile: source.path)
+ hero.imageScaling = .scaleProportionallyUpOrDown
+ let brand = NSTextField(labelWithString: "ACECode")
+ brand.font = .boldSystemFont(ofSize: 17)
+ let badge = NSTextField(labelWithString: "版本 \(version)")
+ badge.font = .systemFont(ofSize: 11); badge.textColor = .secondaryLabelColor
+ let sideStack = NSStackView(views: [hero, brand, badge])
+ sideStack.orientation = .vertical; sideStack.alignment = .leading; sideStack.spacing = 10
+ sideStack.translatesAutoresizingMaskIntoConstraints = false; sidebar.addSubview(sideStack)
+ let steps = NSStackView(views: stepLabels)
+ steps.orientation = .vertical; steps.alignment = .leading; steps.spacing = 22
+ steps.translatesAutoresizingMaskIntoConstraints = false; sidebar.addSubview(steps)
+
+ let card = InstallerCardView()
+ card.translatesAutoresizingMaskIntoConstraints = false
+ let folderIcon = NSImageView(image: NSImage(systemSymbolName: "folder.fill", accessibilityDescription: "个人应用目录")!)
+ folderIcon.contentTintColor = .systemBlue
+ locationTitle.font = .boldSystemFont(ofSize: 13)
+ let locationText = NSStackView(views: [locationTitle, path])
+ locationText.orientation = .vertical; locationText.alignment = .leading; locationText.spacing = 6
+ let locationRow = NSStackView(views: [folderIcon, locationText])
+ locationRow.spacing = 12; locationRow.alignment = .centerY
+ let cardStack = NSStackView(views: [locationRow, change])
+ cardStack.orientation = .vertical; cardStack.alignment = .leading; cardStack.spacing = 12
+ cardStack.translatesAutoresizingMaskIntoConstraints = false; card.addSubview(cardStack)
+ scopePicker.selectedSegment = 0
+ scopePicker.target = self; scopePicker.action = #selector(changeScope)
+ let stack = NSStackView(views: [heading, detail, scopePicker, card, status, spinner])
+ stack.orientation = .vertical; stack.alignment = .leading; stack.spacing = 20
+ stack.translatesAutoresizingMaskIntoConstraints = false; content.addSubview(stack)
+ let actions = NSStackView(views: [reveal, launch, install])
+ actions.spacing = 8; actions.translatesAutoresizingMaskIntoConstraints = false; footer.addSubview(actions)
+ NSLayoutConstraint.activate([
+ sidebar.leadingAnchor.constraint(equalTo: content.leadingAnchor), sidebar.topAnchor.constraint(equalTo: content.topAnchor),
+ sidebar.bottomAnchor.constraint(equalTo: footer.topAnchor), sidebar.widthAnchor.constraint(equalToConstant: 196),
+ footer.leadingAnchor.constraint(equalTo: content.leadingAnchor), footer.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+ footer.bottomAnchor.constraint(equalTo: content.bottomAnchor), footer.heightAnchor.constraint(equalToConstant: 64),
+ separator.leadingAnchor.constraint(equalTo: content.leadingAnchor), separator.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+ separator.topAnchor.constraint(equalTo: footer.topAnchor),
+ divider.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor), divider.topAnchor.constraint(equalTo: content.topAnchor),
+ divider.bottomAnchor.constraint(equalTo: footer.topAnchor), divider.widthAnchor.constraint(equalToConstant: 1),
+ sideStack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 26), sideStack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 32),
+ hero.widthAnchor.constraint(equalToConstant: 64), hero.heightAnchor.constraint(equalToConstant: 64),
+ steps.leadingAnchor.constraint(equalTo: sideStack.leadingAnchor), steps.topAnchor.constraint(equalTo: sideStack.bottomAnchor, constant: 44),
+ stack.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: 36), stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -36),
+ stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 38), stack.bottomAnchor.constraint(lessThanOrEqualTo: footer.topAnchor, constant: -20),
+ card.widthAnchor.constraint(equalTo: stack.widthAnchor), detail.widthAnchor.constraint(equalTo: stack.widthAnchor),
+ status.widthAnchor.constraint(equalTo: stack.widthAnchor), spinner.widthAnchor.constraint(equalTo: stack.widthAnchor),
+ cardStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), cardStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
+ cardStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), cardStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16),
+ locationRow.widthAnchor.constraint(equalTo: cardStack.widthAnchor), folderIcon.widthAnchor.constraint(equalToConstant: 30), folderIcon.heightAnchor.constraint(equalToConstant: 30),
+ actions.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -24), actions.centerYAnchor.constraint(equalTo: footer.centerYAnchor),
+ install.widthAnchor.constraint(greaterThanOrEqualToConstant: 90)
+ ])
+ destination = engine.suggestedFolder()
+ if CommandLine.arguments.contains("--preview-system") {
+ scopePicker.selectedSegment = 1
+ destination = URL(fileURLWithPath: "/Applications")
+ }
+ refreshPath()
+ updateStep(0)
+ content.layoutSubtreeIfNeeded()
+ engine.isRunning = { target in
+ NSWorkspace.shared.runningApplications.contains { $0.bundleURL?.resolvingSymlinksInPath() == target.resolvingSymlinksInPath() }
+ }
+ window.center(); window.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true)
+ if let index = CommandLine.arguments.firstIndex(of: "--layout-report"), CommandLine.arguments.count > index + 1 {
+ let output = CommandLine.arguments[index + 1]
+ var snapshots: [[String: Any]] = []
+ func capture() {
+ content.layoutSubtreeIfNeeded()
+ snapshots.append(layoutSnapshot(card: card, cardStack: cardStack, locationRow: locationRow))
+ }
+ capture()
+ for segment in [1, 2, 0] {
+ scopePicker.selectedSegment = segment
+ changeScope()
+ capture()
+ }
+ if JSONSerialization.isValidJSONObject(snapshots),
+ let data = try? JSONSerialization.data(withJSONObject: snapshots, options: [.prettyPrinted, .sortedKeys]) {
+ try? data.write(to: URL(fileURLWithPath: output))
+ }
+ NSApp.terminate(nil)
+ return
+ }
+ if let index = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > index + 1 {
+ let output = CommandLine.arguments[index + 1]
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ content.layoutSubtreeIfNeeded()
+ if let bitmap = content.bitmapImageRepForCachingDisplay(in: content.bounds) {
+ content.cacheDisplay(in: content.bounds, to: bitmap)
+ if let data = bitmap.representation(using: .png, properties: [:]) {
+ try? data.write(to: URL(fileURLWithPath: output))
+ }
+ }
+ NSApp.terminate(nil)
+ }
+ }
+ }
+
+ func updateStep(_ active: Int) {
+ for (index, label) in stepLabels.enumerated() {
+ let name = ["安装位置", "正在安装", "安装完成"][index]
+ label.stringValue = "\(index < active ? "✓" : index == active ? "●" : "○") \(name)"
+ label.textColor = index == active ? .controlAccentColor : .secondaryLabelColor
+ label.font = index == active ? .boldSystemFont(ofSize: 13) : .systemFont(ofSize: 13)
+ }
+ }
+
+ func refreshPath() {
+ path.stringValue = destination.appendingPathComponent("ACECode.app").path
+ path.toolTip = path.stringValue
+ locationTitle.stringValue = scope == .system ? "系统应用程序目录" : scope == .personal ? "个人应用目录" : "自选安装目录"
+ change.isHidden = scope != .custom
+ detail.stringValue = scope == .system ? "安装到 /Applications,供这台 Mac 的用户使用。" : "选择安装位置,推荐仅为自己安装。"
+ if installed == nil {
+ do {
+ _ = try engine.checkedFolder(destination, create: false, scope: scope)
+ install.isEnabled = true
+ status.textColor = .secondaryLabelColor
+ status.stringValue = "此位置可以安装。"
+ } catch {
+ install.isEnabled = false
+ status.textColor = .systemOrange
+ status.stringValue = error.localizedDescription
+ }
+ }
+ }
+ @objc func changeScope() {
+ destination = scope == .system ? URL(fileURLWithPath: "/Applications") : engine.suggestedFolder()
+ refreshPath()
+ }
+ @objc func selectFolder() {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true; panel.canChooseFiles = false
+ panel.canCreateDirectories = true; panel.allowsMultipleSelection = false
+ panel.directoryURL = destination
+ panel.message = "选择当前账号可写的位置。不会请求管理员授权;不要选择应用包内部。"
+ if panel.runModal() == .OK, let selected = panel.url {
+ do { destination = try engine.checkedFolder(selected, create: false, scope: scope); refreshPath() }
+ catch { showError(error) }
+ }
+ }
+
+ @objc func startInstall() {
+ guard !busy else { return }
+ let target = destination.appendingPathComponent("ACECode.app")
+ var replace = false
+ if FileManager.default.fileExists(atPath: target.path) {
+ let alert = NSAlert()
+ alert.messageText = "替换已有 ACECode?"
+ alert.informativeText = "仅替换 \(target.path),不删除个人配置。请先退出该位置的 ACECode。失败时尝试恢复旧版本。"
+ alert.addButton(withTitle: "替换"); alert.addButton(withTitle: "取消")
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
+ replace = true
+ }
+ busy = true; install.isEnabled = false; change.isEnabled = false
+ scopePicker.isEnabled = false
+ installed = nil
+ updateStep(1)
+ heading.stringValue = "正在安装 ACECode"
+ detail.stringValue = "正在复制应用,请稍候。"
+ install.title = "正在安装…"
+ window.standardWindowButton(.closeButton)?.isEnabled = false
+ launch.isHidden = true; reveal.isHidden = true
+ spinner.startAnimation(nil)
+ let folder = destination!, payload = source, confirmed = replace, selectedScope = scope
+ DispatchQueue.global(qos: .userInitiated).async {
+ let result = Result { try self.engine.install(source: payload, folder: folder, replace: confirmed, scope: selectedScope) { text in
+ DispatchQueue.main.async { self.status.stringValue = text }
+ } }
+ DispatchQueue.main.async {
+ self.busy = false; self.install.isEnabled = true; self.change.isEnabled = true
+ self.window.standardWindowButton(.closeButton)?.isEnabled = true
+ self.spinner.stopAnimation(nil)
+ switch result {
+ case .success(let url):
+ self.installed = url
+ self.updateStep(2)
+ self.heading.stringValue = "ACECode 已准备就绪"
+ self.detail.stringValue = "安装完成,可以开始使用了。"
+ self.status.stringValue = self.engine.cleanupWarning ?? ""
+ self.install.isHidden = true; self.change.isHidden = true
+ self.launch.keyEquivalent = "\r"
+ self.launch.isHidden = false; self.reveal.isHidden = false
+ case .failure(let error):
+ self.scopePicker.isEnabled = true
+ self.updateStep(0)
+ self.heading.stringValue = "安装未完成"
+ self.detail.stringValue = "请检查或更换安装位置后重试。"
+ self.install.title = "重试安装"
+ self.status.stringValue = "安装未完成。"
+ self.showError(error)
+ }
+ }
+ }
+ }
+
+ func showError(_ error: Error) {
+ let alert = NSAlert(error: error)
+ alert.runModal()
+ }
+ @objc func showApp() { if let url = installed { NSWorkspace.shared.activateFileViewerSelecting([url]) } }
+ @objc func openApp() {
+ guard let url = installed else { return }
+ NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) { _, error in
+ if let error = error { DispatchQueue.main.async { self.showError(error) } }
+ }
+ }
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { busy ? .terminateCancel : .terminateNow }
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
+}
+
+// Initialize AppKit and its window-server connection before creating NSWindow.
+let application = NSApplication.shared
+let delegate = InstallerUI()
+application.delegate = delegate
+application.run()
diff --git a/ports/ftxui/portfile.cmake b/ports/ftxui/portfile.cmake
index 8d2fd0ce2..e6d051ff3 100644
--- a/ports/ftxui/portfile.cmake
+++ b/ports/ftxui/portfile.cmake
@@ -6,7 +6,9 @@
#
# 因此:**每次 external/ftxui 的 gitlink 变更,必须同步 bump vcpkg.json 的
# port-version**,否则改动不会生效。当前对应子模块 commit:
-# f98588b4 (v7.0.3-22,含 synchronized output + opt-in mouse hover motion)
+# 62e7471b (含 synchronized output + opt-in mouse hover motion)
+# —— 输入追踪日志路径由 ACECODE_FTXUI_INPUT_TRACE_DIR 运行时指定。
+# —— 原子追加输入追踪记录,避免多个 TUI 进程互相覆盖。
# —— hover-motion 补丁(link-hover-tooltip 依赖无按键 Mouse::Moved 上报,
# DEC mode 1003),提供 ftxui::App::EnableMouseHoverMotion()。
vcpkg_check_linkage(ONLY_STATIC_LIBRARY)
diff --git a/ports/ftxui/vcpkg.json b/ports/ftxui/vcpkg.json
index 347bf6e91..7e8e30def 100644
--- a/ports/ftxui/vcpkg.json
+++ b/ports/ftxui/vcpkg.json
@@ -1,7 +1,7 @@
{
"name": "ftxui",
"version-semver": "7.0.3",
- "port-version": 5,
+ "port-version": 8,
"description": "C++ Functional Terminal User Interface (shaohaozhi286 fork; adds ACECode TUI selection, input trace, Kitty keyboard lifecycle, and synchronized output (DEC mode 2026) patches — see external/ftxui/ACECODE_PATCHES.md)",
"homepage": "https://github.com/shaohaozhi286/FTXUI",
"license": "MIT",
diff --git a/run_build.py b/run_build.py
index a82cdc888..0ff504ded 100644
--- a/run_build.py
+++ b/run_build.py
@@ -40,7 +40,10 @@ def prepend(name, dirs):
prepend("PATH", [SDK_BIN, MSVC_BIN, cmake, ninja])
prepend("LIB", [MSVC_LIB, SDK_LIB + r"\ucrt\x64", SDK_LIB + r"\um\x64"])
-prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um", SDK_INC + r"\shared"])
+# `winrt` and `cppwinrt` are required by the desktop WebView2 host: wrl.h and
+# EventToken.h live there, not under um/ or shared/.
+prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um",
+ SDK_INC + r"\shared", SDK_INC + r"\winrt", SDK_INC + r"\cppwinrt"])
env["VCPKG_ROOT"] = VCPKG_ROOT
env["VCPKG_DEFAULT_TRIPLET"] = "x64-windows-static"
diff --git a/run_cmake_configure.py b/run_cmake_configure.py
index a27c9ea4a..93e4a9c35 100644
--- a/run_cmake_configure.py
+++ b/run_cmake_configure.py
@@ -39,7 +39,10 @@ def prepend(name, dirs):
prepend("PATH", [SDK_BIN, MSVC_BIN, cmake, ninja])
prepend("LIB", [MSVC_LIB, SDK_LIB + r"\ucrt\x64", SDK_LIB + r"\um\x64"])
-prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um", SDK_INC + r"\shared"])
+# `winrt` and `cppwinrt` are required by the desktop WebView2 host: wrl.h and
+# EventToken.h live there, not under um/ or shared/.
+prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um",
+ SDK_INC + r"\shared", SDK_INC + r"\winrt", SDK_INC + r"\cppwinrt"])
# vcpkg config for manifest-mode find_package
env["VCPKG_ROOT"] = VCPKG_ROOT
diff --git a/scripts/build_deepin_dtk_sdk.sh b/scripts/build_deepin_dtk_sdk.sh
new file mode 100644
index 000000000..fcd2e79ae
--- /dev/null
+++ b/scripts/build_deepin_dtk_sdk.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Build DTK 5 against the release container's Qt 5.11 / GLIBC 2.28.
+# The SDK is used for linking only; Deepin supplies the runtime libraries.
+set -euo pipefail
+
+if [ "$#" -ne 2 ] || [[ "$1" != /* ]] || [[ "$2" != /* ]]; then
+ echo "Usage: bash scripts/build_deepin_dtk_sdk.sh " >&2
+ exit 2
+fi
+sdk_prefix="$1"
+sdk_work="$2"
+mkdir -p "$sdk_prefix" "$sdk_work"
+export QT_SELECT=qt5
+export QMAKEPATH="$sdk_prefix${QMAKEPATH:+:$QMAKEPATH}"
+export QMAKEFEATURES="$sdk_prefix/mkspecs/features${QMAKEFEATURES:+:$QMAKEFEATURES}"
+export PKG_CONFIG_PATH="$sdk_prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
+export LD_LIBRARY_PATH="$sdk_prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+
+build_module() {
+ local module="$1" version="$2" revision="$3" checksum="$4"
+ local archive="$sdk_work/$module-$revision.tar.gz"
+ local source_dir="$sdk_work/$module-$revision"
+ local build_dir="$sdk_work/build-$module"
+ curl --fail --location --retry 3 \
+ "https://codeload.github.com/linuxdeepin/$module/tar.gz/$revision" \
+ --output "$archive"
+ printf '%s %s\n' "$checksum" "$archive" | sha256sum --check --strict
+ tar -xzf "$archive" -C "$sdk_work"
+ mkdir -p "$build_dir"
+
+ local args=("VERSION=$version" "PREFIX=$sdk_prefix"
+ "LIB_INSTALL_DIR=$sdk_prefix/lib"
+ "MKSPECS_INSTALL_DIR=$sdk_prefix/mkspecs"
+ CONFIG+=release DTK_NO_TRANSLATION=YES)
+
+ (
+ cd "$build_dir"
+ qmake "$source_dir/src/src.pro" "${args[@]}"
+ make -j"${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
+ # Some upstream install rules use /etc. Stage everything and copy
+ # only the requested SDK prefix, leaving the host configuration alone.
+ make install INSTALL_ROOT="$build_dir/stage"
+ )
+ cp -a "$build_dir/stage$sdk_prefix/." "$sdk_prefix/"
+ pkg-config --atleast-version=5.2 "$module"
+
+ # Later modules invoke this host tool. Build it after core configuration
+ # has generated dtkcore_config.h, with no reliance on git tag discovery.
+ if [ "$module" = dtkcore ]; then
+ mkdir -p "$build_dir/os-release"
+ (
+ cd "$build_dir/os-release"
+ qmake "$source_dir/tools/deepin-os-release/deepin-os-release.pro" "${args[@]}"
+ make -j"${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
+ make install INSTALL_ROOT="$build_dir/stage"
+ )
+ cp -a "$build_dir/stage$sdk_prefix/." "$sdk_prefix/"
+ fi
+}
+
+# Immutable upstream revisions from DTK's Qt 5.11-compatible release series.
+build_module dtkcore 5.2.2.5 ca1e23ddfb9d197faee88c428d1912aad714c743 \
+ 2d2a5924caaf931f127b24c1e0ce1ba5199cce61ded9071a4ff011ba25f738a4
+build_module dtkgui 5.2.2.16 de1f742edefee47963515acf63721ffb53193a8b \
+ ce4256f0cfe543abb328ac78e8e4b5dfa011aeccf1913d9248c8cb76dea7f409
+build_module dtkwidget 5.2.2.16 36d3e769500b05a4901913ee08e4bd9571d1898c \
+ 25df1e2cda5c4504af13c6a91eccc3d6def6f2e00c72e9904e4778f4259ee2b1
+
+printf 'DTK 5 SDK ready: %s\n' "$sdk_prefix"
diff --git a/scripts/build_macos_arm64.sh b/scripts/build_macos_arm64.sh
new file mode 100755
index 000000000..1366c3e62
--- /dev/null
+++ b/scripts/build_macos_arm64.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Build a generic Apple Silicon macOS portable ACECode package.
+# Usage: scripts/build_macos_arm64.sh [suffix]
+set -euo pipefail
+
+if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
+ cat <<'USAGE'
+Usage: scripts/build_macos_arm64.sh [suffix]
+
+Builds a generic Apple Silicon (arm64) macOS portable ZIP.
+The default output is dist/acecode--macos-arm64-release-portable.zip.
+Optional environment overrides: NODE_BIN_DIR, VCPKG_ROOT, CMAKE_BIN.
+USAGE
+ exit 0
+fi
+
+repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
+suffix="${1:-release}"
+[[ "$suffix" != -* && "$suffix" != */* ]] || {
+ printf 'ERROR: suffix must not start with - or contain /\n' >&2
+ exit 2
+}
+
+export PATH="${NODE_BIN_DIR:-$HOME/.nvm/versions/node/v22.22.2/bin}:$PATH"
+export VCPKG_ROOT="${VCPKG_ROOT:-$HOME/vcpkg}"
+
+cd "$repo_root"
+[[ -d "$VCPKG_ROOT" ]] || {
+ printf 'ERROR: VCPKG_ROOT does not exist: %s\n' "$VCPKG_ROOT" >&2
+ exit 1
+}
+
+command -v python3 >/dev/null || { echo 'ERROR: python3 not found' >&2; exit 1; }
+command -v corepack >/dev/null || { echo 'ERROR: corepack not found; install Node.js 22+' >&2; exit 1; }
+
+printf '== building frontend ==\n'
+(
+ cd "$repo_root/web"
+ corepack pnpm install --frozen-lockfile
+ corepack pnpm test
+ corepack pnpm build
+)
+
+version="$(grep -m1 'project(acecode VERSION' "$repo_root/CMakeLists.txt" \
+ | sed -E 's/.* VERSION ([0-9.]+).*/\1/')"
+output="$repo_root/dist/acecode-${version}-macos-arm64-${suffix}-portable.zip"
+build_dir="$repo_root/build/macos-arm64-release"
+
+printf '== building package ==\n'
+bash "$repo_root/scripts/macos_create_portable_zip.sh" \
+ --arch arm64 \
+ --suffix "$suffix" \
+ --build-dir "$build_dir" \
+ --output "$output"
+
+printf '\nPACKAGE_PATH=%s\n' "$output"
+printf 'PACKAGE_SHA256=%s\n' "$(shasum -a 256 "$output" | awk '{print $1}')"
+printf 'PACKAGE_SIZE=%s\n' "$(du -h "$output" | awk '{print $1}')"
diff --git a/scripts/build_macos_x64.sh b/scripts/build_macos_x64.sh
new file mode 100755
index 000000000..ec07f724f
--- /dev/null
+++ b/scripts/build_macos_x64.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Build a generic Intel macOS portable ACECode package.
+# Usage: scripts/build_macos_x64.sh [suffix]
+set -euo pipefail
+
+if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
+ cat <<'USAGE'
+Usage: scripts/build_macos_x64.sh [suffix]
+
+Builds a generic Intel (x86_64) macOS portable ZIP.
+The default output is dist/acecode--macos-x86_64-release-portable.zip.
+Optional environment overrides: NODE_BIN_DIR, VCPKG_ROOT, CMAKE_BIN.
+USAGE
+ exit 0
+fi
+
+repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
+suffix="${1:-release}"
+[[ "$suffix" != -* && "$suffix" != */* ]] || {
+ printf 'ERROR: suffix must not start with - or contain /\n' >&2
+ exit 2
+}
+
+export PATH="${NODE_BIN_DIR:-$HOME/.nvm/versions/node/v22.22.2/bin}:$PATH"
+export VCPKG_ROOT="${VCPKG_ROOT:-$HOME/vcpkg}"
+
+cd "$repo_root"
+[[ -d "$VCPKG_ROOT" ]] || {
+ printf 'ERROR: VCPKG_ROOT does not exist: %s\n' "$VCPKG_ROOT" >&2
+ exit 1
+}
+
+command -v python3 >/dev/null || { echo 'ERROR: python3 not found' >&2; exit 1; }
+command -v corepack >/dev/null || { echo 'ERROR: corepack not found; install Node.js 22+' >&2; exit 1; }
+
+printf '== building frontend ==\n'
+(
+ cd "$repo_root/web"
+ corepack pnpm install --frozen-lockfile
+ corepack pnpm test
+ corepack pnpm build
+)
+
+version="$(grep -m1 'project(acecode VERSION' "$repo_root/CMakeLists.txt" \
+ | sed -E 's/.* VERSION ([0-9.]+).*/\1/')"
+output="$repo_root/dist/acecode-${version}-macos-x86_64-${suffix}-portable.zip"
+build_dir="$repo_root/build/macos-x64-release"
+
+printf '== building package ==\n'
+bash "$repo_root/scripts/macos_create_portable_zip.sh" \
+ --arch x64 \
+ --suffix "$suffix" \
+ --build-dir "$build_dir" \
+ --output "$output"
+
+printf '\nPACKAGE_PATH=%s\n' "$output"
+printf 'PACKAGE_SHA256=%s\n' "$(shasum -a 256 "$output" | awk '{print $1}')"
+printf 'PACKAGE_SIZE=%s\n' "$(du -h "$output" | awk '{print $1}')"
diff --git a/scripts/dev_build_artifacts.py b/scripts/dev_build_artifacts.py
new file mode 100644
index 000000000..65bc45af3
--- /dev/null
+++ b/scripts/dev_build_artifacts.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Shared development-build artifact discovery."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+
+def candidate_build_directories(build_dir: Path, max_depth: int = 2) -> list[Path]:
+ if not build_dir.is_dir():
+ return []
+ directories = [build_dir]
+ frontier = [build_dir]
+ for _ in range(max_depth):
+ next_frontier: list[Path] = []
+ for parent in frontier:
+ try:
+ children = sorted(
+ (path for path in parent.iterdir() if path.is_dir() and path.suffix != ".app"),
+ key=lambda path: str(path).lower(),
+ )
+ except OSError:
+ continue
+ directories.extend(children)
+ next_frontier.extend(children)
+ frontier = next_frontier
+ return directories
+
+
+def find_named_artifacts(
+ build_dir: Path,
+ names: list[str],
+ app_bundle: str | None = None,
+ require_executable: bool = True,
+) -> list[Path]:
+ results: list[Path] = []
+ seen: set[Path] = set()
+ for directory in candidate_build_directories(build_dir):
+ candidates = [directory / name for name in names]
+ if app_bundle:
+ candidates.insert(0, directory / app_bundle)
+ for candidate in candidates:
+ if not candidate.is_file() and not (app_bundle and candidate.name == app_bundle and candidate.is_dir()):
+ continue
+ if (
+ require_executable
+ and candidate.is_file()
+ and os.name != "nt"
+ and not os.access(candidate, os.X_OK)
+ ):
+ continue
+ resolved = candidate.resolve()
+ if resolved not in seen:
+ seen.add(resolved)
+ results.append(candidate)
+ return results
diff --git a/scripts/dev_desktop.bat b/scripts/dev_desktop.bat
index 750125fa8..b608e276f 100644
--- a/scripts/dev_desktop.bat
+++ b/scripts/dev_desktop.bat
@@ -1,25 +1,22 @@
@echo off
-REM ACECode Desktop 一键开发脚本 (Windows)
-REM 用法: scripts\dev_desktop.bat [选项]
-REM 详见 python scripts\dev_desktop.py --help
-
-setlocal enabledelayedexpansion
+REM ACECode Desktop development launcher (Windows)
+REM Usage: scripts\dev_desktop.bat [Desktop launcher options]
+setlocal
set "SCRIPT_DIR=%~dp0"
-REM 选择 python 解释器
where python >nul 2>&1
-if %errorlevel%==0 (
+if not errorlevel 1 (
set "PYTHON=python"
) else (
where py >nul 2>&1
- if %errorlevel%==0 (
+ if not errorlevel 1 (
set "PYTHON=py"
) else (
- echo [ERROR] 未找到 python 或 py,请先安装 Python 3.8+
+ echo [ERROR] python or py was not found. Install Python 3.8+ first.
exit /b 1
)
)
-"%PYTHON%" "%SCRIPT_DIR%dev_desktop.py" %*
+"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" desktop --yes %*
exit /b %errorlevel%
diff --git a/scripts/dev_desktop.py b/scripts/dev_desktop.py
index cb153130f..24181e93f 100644
--- a/scripts/dev_desktop.py
+++ b/scripts/dev_desktop.py
@@ -26,6 +26,12 @@
import sys
from pathlib import Path
+_SCRIPT_DIR = Path(__file__).resolve().parent
+if str(_SCRIPT_DIR) not in sys.path:
+ sys.path.insert(0, str(_SCRIPT_DIR))
+
+from dev_build_artifacts import find_named_artifacts
+
def _supports_color() -> bool:
if os.environ.get("NO_COLOR"):
return False
@@ -185,55 +191,12 @@ def build_web(web_dir: Path, pnpm: str, force: bool = False) -> None:
def find_desktop_builds(build_dir: Path) -> list[Path]:
"""查找 build 根、直接子目录及 preset/config 两层布局的产物。"""
- results: list[Path] = []
- if not build_dir.is_dir():
- return results
-
- search_dirs = [build_dir]
- try:
- first_level = sorted(
- (path for path in build_dir.iterdir()
- if path.is_dir() and path.suffix != ".app"),
- key=lambda path: str(path).lower(),
- )
- except OSError:
- first_level = []
- search_dirs.extend(first_level)
- for first in first_level:
- try:
- search_dirs.extend(sorted(
- (path for path in first.iterdir()
- if path.is_dir() and path.suffix != ".app"),
- key=lambda path: str(path).lower(),
- ))
- except OSError:
- continue
-
- seen = set()
- for child in search_dirs:
- # macOS .app bundle
- app_bundle = child / "ACECode.app"
- if app_bundle.is_dir():
- resolved = app_bundle.resolve()
- if resolved not in seen:
- seen.add(resolved)
- results.append(app_bundle)
- # Windows .exe
- exe = child / "acecode-desktop.exe"
- if exe.is_file():
- resolved = exe.resolve()
- if resolved not in seen:
- seen.add(resolved)
- results.append(exe)
- # Linux / macOS 裸可执行文件(非 .app)
- binary = child / "acecode-desktop"
- if binary.is_file() and os.access(binary, os.X_OK):
- resolved = binary.resolve()
- if resolved not in seen:
- seen.add(resolved)
- results.append(binary)
-
- return results
+ return find_named_artifacts(
+ build_dir,
+ ["acecode-desktop.exe", "acecode-desktop"],
+ "ACECode.app",
+ require_executable=False,
+ )
def display_path(path: Path, project_root: Path) -> str:
@@ -267,12 +230,34 @@ def pick_desktop_build(builds: list[Path], preferred: str | None = None) -> Path
# ─── 启动 Desktop ───────────────────────────────────────────────────────────
-def launch_desktop(desktop_path: Path, dev_web_dir: Path) -> None:
- """启动 desktop app,并设置 ACECODE_DEV_WEB_DIR 环境变量。"""
- env = os.environ.copy()
- env["ACECODE_DEV_WEB_DIR"] = str(dev_web_dir.resolve())
+def desktop_instance_identity(project_root: Path) -> str:
+ """Stable `-` identity, shared with the Web runtime naming."""
+ from dev_environment import current_commit, sanitize_identity
+
+ commit = current_commit(project_root)
+ return sanitize_identity(project_root.name, commit or project_root.name)
+
+
+def desktop_environment(project_root: Path, dev_web_dir: Path) -> tuple[dict, str]:
+ """Development-only process overrides, never persisted to user config."""
+ environment = os.environ.copy()
+ environment["ACECODE_DEV_WEB_DIR"] = str(dev_web_dir.resolve())
+ instance_id = desktop_instance_identity(project_root)
+ environment["ACECODE_DESKTOP_INSTANCE_ID"] = instance_id
+ environment["ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES"] = "1"
+ return environment, instance_id
+
+def launch_desktop(desktop_path: Path, dev_web_dir: Path, instance_id: str | None = None,
+ project_root: Path | None = None, environment: dict | None = None) -> None:
+ """启动 desktop app,并注入开发期进程级覆盖。"""
+ if environment is None:
+ environment, derived = desktop_environment(project_root or find_project_root(), dev_web_dir)
+ instance_id = instance_id or derived
info(f"ACECODE_DEV_WEB_DIR = {dev_web_dir.resolve()}")
+ info(f"ACECODE_DESKTOP_INSTANCE_ID = {instance_id}")
+ info("进程级覆盖:本实例允许多开(不读写全局配置)")
+ env = environment
if sys.platform == "darwin":
if desktop_path.suffix == ".app":
@@ -403,7 +388,9 @@ def main() -> None:
ok(f"Desktop 构建: {display_path(desktop_path, project_root)}")
# 5. 启动 desktop
- launch_desktop(desktop_path, dev_web_dir)
+ environment, instance_id = desktop_environment(project_root, dev_web_dir)
+ ok(f"Desktop 实例身份: {instance_id}")
+ launch_desktop(desktop_path, dev_web_dir, instance_id=instance_id, environment=environment)
ok("Desktop 已启动")
# 6. 打印使用提示
diff --git a/scripts/dev_desktop.sh b/scripts/dev_desktop.sh
index 09d7b3605..618d38a13 100755
--- a/scripts/dev_desktop.sh
+++ b/scripts/dev_desktop.sh
@@ -1,20 +1,18 @@
#!/bin/bash
-# ACECode Desktop 一键开发脚本 (macOS / Linux)
-# 用法: ./scripts/dev_desktop.sh [选项]
-# 详见 python scripts/dev_desktop.py --help
+# ACECode Desktop development launcher (macOS / Linux)
+# Usage: ./scripts/dev_desktop.sh [Desktop launcher options]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-# 选择 python 解释器
-if command -v python3 &>/dev/null; then
+if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
-elif command -v python &>/dev/null; then
+elif command -v python >/dev/null 2>&1; then
PYTHON=python
else
- echo "[ERROR] 未找到 python3 或 python,请先安装 Python 3.8+"
+ echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2
exit 1
fi
-exec "$PYTHON" "$SCRIPT_DIR/dev_desktop.py" "$@"
+exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" desktop "$@"
diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py
new file mode 100644
index 000000000..3cb9443a2
--- /dev/null
+++ b/scripts/dev_environment.py
@@ -0,0 +1,1166 @@
+#!/usr/bin/env python3
+"""Launch ACECode Web, Desktop, or TUI development environments safely."""
+
+from __future__ import annotations
+
+import argparse
+import errno
+import importlib.util
+import json
+import os
+import platform
+import re
+import shlex
+import shutil
+import socket
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+import zlib
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable
+
+from dev_build_artifacts import find_named_artifacts
+
+LAUNCH_TARGETS = ("web", "desktop", "tui")
+PRUNE_TARGET = "prune"
+TARGETS = (*LAUNCH_TARGETS, PRUNE_TARGET)
+CACHE_MARKER = ".acecode-sccache.json"
+WORKER_LOG_NAME = "daemon-worker.log"
+WORKER_LOG_MAX_BYTES = 1024 * 1024
+PORT_RANGE_START = 28080
+PORT_RANGE_COUNT = 201
+PORT_RANGE_END = PORT_RANGE_START + PORT_RANGE_COUNT - 1
+DAEMON_STARTUP_LOG_NAME = "daemon-startup.log"
+
+
+@dataclass(frozen=True)
+class BuildCandidate:
+ build_dir: Path
+ source_dir: Path
+ executable: Path
+ configuration: str | None = None
+
+
+def project_root() -> Path:
+ return Path(__file__).resolve().parent.parent
+
+
+def native_executable_name(name: str) -> str:
+ return f"{name}.exe" if os.name == "nt" else name
+
+
+def executable_for(build_dir: Path, target: str) -> Path | None:
+ name = native_executable_name("acecode-desktop" if target == "desktop" else "acecode")
+ app_bundle = "ACECode.app" if target == "desktop" and sys.platform == "darwin" else None
+ matches = find_named_artifacts(build_dir, [name], app_bundle)
+ for executable in matches:
+ # A nested preset is a different CMake build, even when its binaries
+ # happen to be below this build's directory. Validate it separately.
+ directory = executable.parent
+ while directory != build_dir and not (directory / "CMakeCache.txt").is_file():
+ directory = directory.parent
+ if directory == build_dir:
+ return executable
+ return None
+
+
+def artifact_configuration(build_dir: Path, executable: Path) -> str | None:
+ configurations = cmake_cache_value(build_dir / "CMakeCache.txt", "CMAKE_CONFIGURATION_TYPES")
+ if not configurations:
+ return None
+ relative_parts = executable.relative_to(build_dir).parts[:-1]
+ for configuration in configurations.split(";"):
+ if configuration in relative_parts:
+ return configuration
+ # Shared output directories still require an explicit build configuration.
+ return "Release" if "Release" in configurations.split(";") else configurations.split(";")[0]
+
+
+def cmake_cache_value(cache: Path, key: str) -> str | None:
+ try:
+ pattern = re.compile(rf"^{re.escape(key)}(?::[^=]+)?=(.*)$")
+ for line in cache.read_text(encoding="utf-8", errors="replace").splitlines():
+ match = pattern.match(line)
+ if match:
+ return match.group(1)
+ except OSError:
+ return None
+ return None
+
+
+def cmake_source_dir(build_dir: Path) -> Path | None:
+ value = cmake_cache_value(build_dir / "CMakeCache.txt", "CMAKE_HOME_DIRECTORY")
+ return Path(value).resolve() if value else None
+
+
+def configured_for_desktop(build_dir: Path) -> bool:
+ return cmake_cache_value(build_dir / "CMakeCache.txt", "ACECODE_BUILD_DESKTOP") == "ON"
+
+
+def current_commit(root: Path) -> str | None:
+ result = subprocess.run(
+ ["git", "-C", str(root), "rev-parse", "HEAD"],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ return result.stdout.strip() if result.returncode == 0 else None
+
+
+def registered_worktrees(root: Path) -> list[Path]:
+ result = subprocess.run(
+ ["git", "-C", str(root), "worktree", "list", "--porcelain"],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ return []
+ return [Path(line[len("worktree "):]).resolve() for line in result.stdout.splitlines() if line.startswith("worktree ")]
+
+
+def build_directories(worktree: Path, explicit: Path | None = None) -> Iterable[Path]:
+ if explicit:
+ yield explicit.resolve()
+ return
+ build_root = worktree / "build"
+ if build_root.is_dir():
+ yield build_root
+ yield from (path for path in build_root.iterdir() if path.is_dir())
+
+
+def platform_matches(build_dir: Path) -> bool:
+ triplet = cmake_cache_value(build_dir / "CMakeCache.txt", "VCPKG_TARGET_TRIPLET")
+ if not triplet:
+ return True
+ system = platform.system().lower()
+ expected_systems = ("windows", "mingw") if system == "windows" else ("osx",) if system == "darwin" else ("linux",)
+ machine = native_machine()
+ expected_arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"amd64", "x86_64"} else None
+ triplet_lower = triplet.lower()
+ return any(expected in triplet_lower for expected in expected_systems) and (expected_arch is None or expected_arch in triplet_lower)
+
+
+def find_compatible_build(root: Path, target: str, explicit: Path | None = None) -> BuildCandidate | None:
+ root = root.resolve()
+ directories = [(root / explicit).resolve()] if explicit else sorted(
+ build_directories(root),
+ key=lambda directory: (configured_for_desktop(directory), str(directory).lower()),
+ )
+ for build_dir in directories:
+ source_dir = cmake_source_dir(build_dir)
+ executable = executable_for(build_dir, target)
+ if source_dir != root or not executable or not platform_matches(build_dir):
+ continue
+ if target == "desktop" and not configured_for_desktop(build_dir):
+ continue
+ return BuildCandidate(build_dir.resolve(), source_dir, executable, artifact_configuration(build_dir, executable))
+ return None
+
+
+def default_preset(target: str) -> str | None:
+ system = platform.system().lower()
+ machine = native_machine()
+ arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"amd64", "x86_64"} else None
+ if not arch:
+ return None
+ prefix = {"windows": "windows", "darwin": "macos", "linux": "linux"}.get(system)
+ if not prefix:
+ return None
+ suffix = "-desktop-release" if target == "desktop" else "-release"
+ return f"{prefix}-{arch}{suffix}"
+
+
+def native_machine() -> str:
+ if platform.system().lower() == "windows":
+ architecture = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE")
+ if architecture:
+ return architecture.lower()
+ return platform.machine().lower()
+
+
+def needs_msvc(candidate: BuildCandidate | None) -> bool:
+ if candidate is None:
+ return True # The Windows presets use MSVC.
+ cache = candidate.build_dir / "CMakeCache.txt"
+ triplet = (cmake_cache_value(cache, "VCPKG_TARGET_TRIPLET") or "").lower()
+ compiler = (cmake_cache_value(cache, "CMAKE_CXX_COMPILER") or "").lower()
+ return "mingw" not in triplet and not compiler.endswith(("g++.exe", "g++"))
+
+
+def ensure_windows_environment(root: Path, candidate: BuildCandidate | None) -> bool:
+ if os.name != "nt" or not needs_msvc(candidate):
+ return True
+ environment = os.environ.copy()
+ environment["ACECODE_DEV_ENV_SCRIPT"] = str(root / "scripts/dev_windows_env.bat")
+ try:
+ # Expanding the path once, inside quotes, preserves spaces and cmd
+ # metacharacters in the checkout path. Never log the environment dump.
+ result = subprocess.run('"%ACECODE_DEV_ENV_SCRIPT%" --print-env', shell=True,
+ env=environment, capture_output=True, text=True,
+ errors="replace", timeout=60, check=False,
+ creationflags=subprocess.CREATE_NO_WINDOW)
+ except (OSError, subprocess.TimeoutExpired) as error:
+ print(f"[ERROR] Could not initialize the Visual Studio environment: {error}", file=sys.stderr)
+ return False
+ if result.returncode != 0:
+ print(result.stdout.strip() or result.stderr.strip() or "[ERROR] Visual Studio C++ initialization failed.", file=sys.stderr)
+ return False
+ for line in result.stdout.splitlines():
+ key, separator, value = line.partition("=")
+ if separator and key and not key.startswith("="):
+ os.environ[key] = value
+ return True
+
+
+def ask_to_build(preset: str, target: str, assume_yes: bool) -> bool:
+ binary_dir = f"build/{preset}"
+ executable_target = "acecode-desktop" if target == "desktop" else "acecode"
+ print("[INFO] No compatible build was found in this repository's registered worktrees.")
+ print(f"[INFO] Proposed configure preset: {preset} (BUILD_TESTING=OFF)")
+ print(f"[INFO] Proposed build: cmake --build {binary_dir} --target {executable_target}")
+ if assume_yes:
+ return True
+ if sys.stdin is None or not sys.stdin.isatty():
+ print("[ERROR] Refusing to compile without interactive confirmation. Re-run with --yes to confirm.", file=sys.stderr)
+ return False
+ try:
+ return input("Configure and build now? [y/N] ").strip().lower() in {"y", "yes"}
+ except EOFError:
+ print("[ERROR] Confirmation input was unavailable; no configuration or build was started.", file=sys.stderr)
+ return False
+
+
+def sccache_candidates() -> list[Path]:
+ executable = "sccache.exe" if os.name == "nt" else "sccache"
+ candidates: list[Path] = []
+ from_path = shutil.which("sccache")
+ if from_path:
+ candidates.append(Path(from_path))
+ if os.name == "nt":
+ local_app_data = os.environ.get("LOCALAPPDATA")
+ if local_app_data:
+ candidates.extend([
+ Path(local_app_data) / "Microsoft" / "WinGet" / "Links" / executable,
+ Path(local_app_data) / "scoop" / "shims" / executable,
+ ])
+ chocolatey = os.environ.get("ChocolateyInstall", r"C:\\ProgramData\\chocolatey")
+ candidates.append(Path(chocolatey) / "bin" / executable)
+ elif sys.platform == "darwin":
+ candidates.extend([Path("/opt/homebrew/bin/sccache"), Path("/usr/local/bin/sccache")])
+ else:
+ candidates.append(Path.home() / ".cargo" / "bin" / "sccache")
+ return candidates
+
+
+def find_sccache() -> Path | None:
+ for candidate in sccache_candidates():
+ if not candidate.is_file():
+ continue
+ resolved = candidate.resolve()
+ try:
+ result = subprocess.run(
+ [str(resolved), "--version"],
+ text=True,
+ capture_output=True,
+ check=False,
+ timeout=5,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ continue
+ if result.returncode == 0:
+ return resolved
+ return None
+
+
+def sccache_install_hint() -> str:
+ if os.name == "nt":
+ return "Install sccache with: winget install Mozilla.sccache"
+ if sys.platform == "darwin":
+ return "Install sccache with: brew install sccache"
+ return "Install sccache with your package manager or cargo install sccache"
+
+
+def cache_marker(build_dir: Path) -> Path:
+ return build_dir / CACHE_MARKER
+
+
+def cached_sccache_state(build_dir: Path) -> tuple[str | None, bool] | None:
+ try:
+ data = json.loads(cache_marker(build_dir).read_text(encoding="utf-8"))
+ path = data.get("sccache")
+ enabled = data.get("enabled", True)
+ return (path if isinstance(path, str) else None, bool(enabled))
+ except (OSError, ValueError, TypeError):
+ return None
+
+
+def write_sccache_marker(build_dir: Path, sccache: Path | None, enabled: bool = True) -> None:
+ build_dir.mkdir(parents=True, exist_ok=True)
+ cache_marker(build_dir).write_text(json.dumps({"sccache": str(sccache) if sccache else None, "enabled": enabled}) + "\n", encoding="utf-8")
+
+
+def cache_state_changed(build_dir: Path, sccache: Path | None) -> bool:
+ state = cached_sccache_state(build_dir)
+ return state is None or state[0] != (str(sccache) if sccache else None)
+
+
+def sccache_disabled_for_build(build_dir: Path, sccache: Path | None) -> bool:
+ state = cached_sccache_state(build_dir)
+ return sccache is not None and state == (str(sccache), False)
+
+
+def configure_build(root: Path, preset: str, build_dir: Path, sccache: Path | None) -> bool:
+ cache = build_dir / "CMakeCache.txt"
+ if cache.is_file():
+ command = [
+ "cmake", "-S", str(root), "-B", str(build_dir),
+ ]
+ triplet = cmake_cache_value(cache, "VCPKG_TARGET_TRIPLET")
+ toolchain = cmake_cache_value(cache, "CMAKE_TOOLCHAIN_FILE")
+ overlay = cmake_cache_value(cache, "VCPKG_OVERLAY_PORTS")
+ if triplet:
+ command.append(f"-DVCPKG_TARGET_TRIPLET={triplet}")
+ if toolchain:
+ command.append(f"-DCMAKE_TOOLCHAIN_FILE={toolchain}")
+ if overlay:
+ command.append(f"-DVCPKG_OVERLAY_PORTS={overlay}")
+ if configured_for_desktop(build_dir):
+ command.append("-DACECODE_BUILD_DESKTOP=ON")
+ else:
+ command = ["cmake", "--preset", preset, "-DBUILD_TESTING=OFF"]
+ if sccache:
+ command.extend([
+ f"-DCMAKE_C_COMPILER_LAUNCHER={sccache}",
+ f"-DCMAKE_CXX_COMPILER_LAUNCHER={sccache}",
+ ])
+ else:
+ command.extend(["-DCMAKE_C_COMPILER_LAUNCHER=", "-DCMAKE_CXX_COMPILER_LAUNCHER="])
+ if subprocess.run(command, cwd=root, check=False).returncode != 0:
+ return False
+ write_sccache_marker(build_dir, sccache)
+ return True
+
+
+def _sum_integer_leaves(value) -> int:
+ if isinstance(value, bool):
+ return 0
+ if isinstance(value, int):
+ return value
+ if isinstance(value, dict):
+ return sum(_sum_integer_leaves(child) for child in value.values())
+ if isinstance(value, list):
+ return sum(_sum_integer_leaves(child) for child in value)
+ return 0
+
+
+def sccache_stats(sccache: Path) -> dict[str, int] | None:
+ result = subprocess.run([str(sccache), "--show-stats", "--stats-format=json"], text=True, capture_output=True, check=False)
+ if result.returncode != 0:
+ return None
+ try:
+ data = json.loads(result.stdout)
+ stats = data["stats"]
+ except (ValueError, KeyError, TypeError):
+ return None
+ return {
+ "hits": _sum_integer_leaves(stats.get("cache_hits", {})),
+ "misses": _sum_integer_leaves(stats.get("cache_misses", {})),
+ "errors": sum(
+ _sum_integer_leaves(stats.get(key, 0))
+ for key in ("cache_errors", "cache_read_errors", "cache_write_errors", "dist_errors")
+ ),
+ }
+
+
+def report_sccache_delta(before: dict[str, int] | None, after: dict[str, int] | None) -> None:
+ if before is None or after is None:
+ print("[INFO] sccache statistics unavailable; continuing.")
+ return
+ delta = {key: max(0, after[key] - before[key]) for key in before}
+ print(f"[INFO] sccache this build: hits={delta['hits']} misses={delta['misses']} errors={delta['errors']}")
+
+
+def build_target(root: Path, build_dir: Path, target: str, sccache: Path | None = None, configuration: str | None = None) -> bool:
+ executable_target = "acecode-desktop" if target == "desktop" else "acecode"
+ before = sccache_stats(sccache) if sccache else None
+ command = ["cmake", "--build", str(build_dir), "--target", executable_target]
+ if configuration:
+ command.extend(("--config", configuration))
+ result = subprocess.run(
+ command,
+ cwd=root,
+ check=False,
+ )
+ if sccache:
+ report_sccache_delta(before, sccache_stats(sccache))
+ return result.returncode == 0
+
+
+def configure_and_build(root: Path, preset: str, target: str, sccache: Path | None = None) -> Path | None:
+ build_dir = root / "build" / preset
+ if not configure_build(root, preset, build_dir, sccache):
+ return None
+ return build_dir if build_target(root, build_dir, target, sccache) else None
+
+
+def web_worktree_is_clean(root: Path) -> bool:
+ result = subprocess.run(["git", "-C", str(root), "status", "--porcelain", "--", "web"], text=True, capture_output=True, check=False)
+ return result.returncode == 0 and not result.stdout.strip()
+
+
+def newest_web_seed(root: Path) -> Path | None:
+ commit = current_commit(root)
+ if not commit or not web_worktree_is_clean(root):
+ return None
+ candidates: list[Path] = []
+ for worktree in registered_worktrees(root):
+ if worktree == root.resolve() or current_commit(worktree) != commit or not web_worktree_is_clean(worktree):
+ continue
+ index = worktree / "web" / "dist" / "index.html"
+ if index.is_file():
+ candidates.append(index)
+ return max(candidates, key=lambda item: item.stat().st_mtime, default=None)
+
+
+def load_web_builder(root: Path):
+ script_path = root / "scripts" / "dev_desktop.py"
+ spec = importlib.util.spec_from_file_location("dev_desktop_for_environment", script_path)
+ if spec is None or spec.loader is None:
+ return None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def seed_web_assets(root: Path, builder) -> bool:
+ web_dir = root / "web"
+ index = web_dir / "dist" / "index.html"
+ if not builder.web_build_is_stale(web_dir, index):
+ return False
+ seed_index = newest_web_seed(root)
+ if seed_index is None:
+ return False
+ try:
+ if (web_dir / "dist").exists():
+ shutil.rmtree(web_dir / "dist")
+ shutil.copytree(seed_index.parent, web_dir / "dist")
+ if builder.web_build_is_stale(web_dir, index):
+ print("[INFO] Reused Web assets were stale; falling back to local build.")
+ shutil.rmtree(web_dir / "dist", ignore_errors=True)
+ return False
+ print(f"[INFO] Reused Web assets from: {seed_index.parent.parent.parent}")
+ return True
+ except OSError as error:
+ print(f"[INFO] Could not reuse Web assets ({error}); falling back to local build.")
+ shutil.rmtree(web_dir / "dist", ignore_errors=True)
+ return False
+
+
+def refresh_web_assets(root: Path, force: bool = False) -> bool:
+ module = load_web_builder(root)
+ if module is None:
+ print("[ERROR] Cannot load Web asset builder.", file=sys.stderr)
+ return False
+ if not force and seed_web_assets(root, module):
+ return True
+ try:
+ _, pnpm = module.ensure_node_and_pnpm()
+ module.build_web(root / "web", pnpm, force=force)
+ except (OSError, subprocess.SubprocessError, SystemExit):
+ return False
+ return True
+
+
+def sanitize_identity(name: str, identity: str) -> str:
+ """Build the shared filesystem-safe `-` runtime identity."""
+ return re.sub(r"[^A-Za-z0-9_.-]", "-", f"{name}-{identity[:12]}")
+
+
+def safe_identity(root: Path) -> str:
+ return sanitize_identity(root.name, current_commit(root) or root.name)
+
+
+def runtime_root(root: Path) -> Path:
+ return root / ".acecode" / "dev-run"
+
+
+def worktree_runtime_dir(root: Path) -> Path:
+ return runtime_root(root) / safe_identity(root)
+
+
+def worktree_prefix(root: Path) -> str:
+ """Prefix every runtime directory owned by this worktree shares."""
+ return re.sub(r"[^A-Za-z0-9_.-]", "-", root.name) + "-"
+
+
+def selected_web_runtime_dir(root: Path, extra: list[str]) -> Path:
+ parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
+ parser.add_argument("--run-dir", type=Path)
+ args, _ = parser.parse_known_args(extra)
+ if args.run_dir is None:
+ return worktree_runtime_dir(root)
+ return args.run_dir.resolve() if args.run_dir.is_absolute() else (root / args.run_dir).resolve()
+
+
+def selected_web_port(extra: list[str]) -> int | None:
+ """Return the daemon port explicitly requested on the command line, if any."""
+ parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
+ parser.add_argument("--port", type=int)
+ args, _ = parser.parse_known_args(extra)
+ return args.port
+
+
+def derived_port(identity: str) -> int:
+ """Derive a stable port from the runtime identity.
+
+ Uses zlib.crc32 because the built-in hash() is salted per process and would
+ hand out a different port on every run.
+ """
+ digest = zlib.crc32(identity.encode("utf-8"))
+ return PORT_RANGE_START + digest % PORT_RANGE_COUNT
+
+
+def port_is_available(port: int) -> bool:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+ try:
+ probe.bind(("127.0.0.1", port))
+ return True
+ except OSError:
+ return False
+
+
+def select_runtime_port(run_dir: Path, extra: list[str]) -> int | None:
+ """Pick the daemon port: explicit takes precedence, otherwise derive and probe.
+
+ The derivation key is the runtime directory name, so the port changes only
+ when the runtime identity does - the run-dir and its port move together.
+ """
+ explicit = selected_web_port(extra)
+ if explicit is not None:
+ if not 1 <= explicit <= 65535:
+ print("[ERROR] Web daemon port must be between 1 and 65535.", file=sys.stderr)
+ return None
+ if port_is_available(explicit):
+ return explicit
+ print(f"[ERROR] Requested Web daemon port is already bound: {explicit}", file=sys.stderr)
+ print("[INFO] Stop the process using it or start without --port.", file=sys.stderr)
+ return None
+ start = derived_port(run_dir.name)
+ for offset in range(PORT_RANGE_COUNT):
+ candidate = PORT_RANGE_START + (start - PORT_RANGE_START + offset) % PORT_RANGE_COUNT
+ if port_is_available(candidate):
+ return candidate
+ print(f"[ERROR] No free Web daemon port in {PORT_RANGE_START}-{PORT_RANGE_END}.", file=sys.stderr)
+ return None
+
+
+def pid_is_alive(pid: int) -> bool:
+ """Report whether a recorded pid still maps to a live process.
+
+ Never signals the process: POSIX uses signal 0 as a probe and Windows opens
+ it with query-only access.
+ """
+ if pid <= 0:
+ return False
+ if os.name != "nt":
+ try:
+ os.kill(pid, 0)
+ return True
+ except OSError as error:
+ # Permission failures do not prove that the process is dead.
+ return error.errno != errno.ESRCH
+ except OverflowError:
+ return True
+ return windows_pid_is_alive(pid)
+
+
+def windows_pid_is_alive(pid: int) -> bool:
+ import ctypes
+ from ctypes import wintypes
+
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+ STILL_ACTIVE = 259
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
+ kernel32.OpenProcess.restype = wintypes.HANDLE
+ kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
+ kernel32.GetExitCodeProcess.restype = wintypes.BOOL
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
+ kernel32.CloseHandle.restype = wintypes.BOOL
+ if not 0 < pid <= 0xFFFFFFFF:
+ return True
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
+ if not handle:
+ # ERROR_INVALID_PARAMETER identifies a nonexistent PID. Access denied
+ # and other probe failures must preserve the possibly live runtime.
+ return ctypes.get_last_error() != 87
+ try:
+ code = ctypes.c_ulong()
+ if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
+ return code.value == STILL_ACTIVE
+ return True
+ finally:
+ kernel32.CloseHandle(handle)
+
+
+def runtime_pid(run_dir: Path) -> int | None:
+ try:
+ pid = int((run_dir / "daemon.pid").read_text(encoding="utf-8").strip())
+ return pid if pid > 0 else None
+ except (OSError, ValueError):
+ return None
+
+
+def web_launcher_options(extra: list[str]) -> tuple[bool, bool, list[str]]:
+ """Return embedded mode, explicit native-build approval, and remaining args."""
+ embedded = False
+ build_daemon = False
+ remaining: list[str] = []
+ for argument in extra:
+ if argument == "--embedded":
+ embedded = True
+ elif argument == "--build-daemon":
+ build_daemon = True
+ else:
+ remaining.append(argument)
+ return embedded, build_daemon, remaining
+
+
+def vite_options(extra: list[str]) -> list[str]:
+ """Strip daemon-only runtime options before forwarding options to Vite."""
+ result: list[str] = []
+ skip_next = False
+ for argument in extra:
+ if skip_next:
+ skip_next = False
+ continue
+ if argument.startswith("--run-dir=") or argument.startswith("--port="):
+ continue
+ if argument in ("--run-dir", "--port"):
+ skip_next = True
+ continue
+ result.append(argument)
+ return result
+
+
+def web_runtime_is_available(root: Path, candidate: BuildCandidate | None, extra: list[str]) -> bool:
+ run_dir = selected_web_runtime_dir(root, extra)
+ explicit = any(arg == "--run-dir" or arg.startswith("--run-dir=") for arg in extra)
+ if not explicit and run_dir.parent.is_dir():
+ # A new commit changes the default run-dir name but an older worker
+ # can still hold this build's executable open. Inspect only this
+ # worktree's own launcher directories, without stopping any process.
+ prefix = worktree_prefix(root)
+ for previous in sorted(run_dir.parent.iterdir()):
+ if previous.name.startswith(prefix) and (previous / "daemon.pid").exists():
+ run_dir = previous
+ break
+ if not (run_dir / "daemon.pid").exists():
+ return True
+ print(f"[ERROR] Existing Web daemon runtime must be stopped before rebuilding: {run_dir}", file=sys.stderr)
+ if candidate is None:
+ print("[ERROR] No verified executable is available to inspect that runtime; check it manually.", file=sys.stderr)
+ return False
+ command = [str(candidate.executable), "daemon", "status", f"--run-dir={run_dir}"]
+ try:
+ result = subprocess.run(command, cwd=root, text=True, capture_output=True, timeout=10, check=False)
+ if result.returncode == 0:
+ command = [command[0], "daemon", "stop", command[3]]
+ print("[INFO] Stop this verified daemon, then rerun the launcher:", file=sys.stderr)
+ else:
+ print("[ERROR] Runtime identity is unverified; inspect it before stopping or removing anything:", file=sys.stderr)
+ except (OSError, subprocess.TimeoutExpired):
+ print("[ERROR] Runtime identity check failed; inspect it manually:", file=sys.stderr)
+ formatted = "& " + " ".join("'" + part.replace("'", "''") + "'" for part in command) if os.name == "nt" else shlex.join(command)
+ print(("PowerShell: " if os.name == "nt" else "") + formatted, file=sys.stderr)
+ return False
+
+
+def launch_surface(root: Path, target: str, candidate: BuildCandidate, dry_run: bool, extra: list[str]) -> int:
+ if target == "web":
+ run_dir = selected_web_runtime_dir(root, extra)
+ command = [sys.executable, str(root / "scripts" / "dev_web.py"), "--build-dir", str(candidate.executable.parent), "--run-dir", str(run_dir), *extra]
+ print(f"[INFO] Launching Web with build: {candidate.build_dir}")
+ print(f"[INFO] Isolated runtime directory: {run_dir}")
+ elif target == "desktop":
+ command = [sys.executable, str(root / "scripts" / "dev_desktop.py"), "--build-dir", str(candidate.executable), "--no-build", *extra]
+ print(f"[INFO] Launching Desktop with build: {candidate.build_dir}")
+ else:
+ command = tui_command(root, candidate.executable, extra)
+ if command is None:
+ print(f"[ERROR] Cannot open a new terminal automatically. Run: {candidate.executable}", file=sys.stderr)
+ return 1
+ print(f"[INFO] Launching TUI in a new terminal with build: {candidate.build_dir}")
+ if dry_run:
+ print("[INFO] Dry run: " + " ".join(f'"{part}"' if " " in part else part for part in command))
+ return 0
+ try:
+ if target == "tui" and os.name == "nt":
+ subprocess.Popen(command, cwd=root, creationflags=subprocess.CREATE_NEW_CONSOLE)
+ return 0
+ if target == "tui" and sys.platform != "darwin":
+ subprocess.Popen(command, cwd=root, start_new_session=True)
+ return 0
+ # Surface scripts and osascript finish after creating the UI; waiting
+ # propagates launcher/argument failures without waiting for the GUI.
+ return subprocess.run(command, cwd=root, check=False).returncode
+ except OSError as error:
+ print(f"[ERROR] Could not start {target}: {error}", file=sys.stderr)
+ return 1
+
+
+def tui_command(root: Path, executable: Path, extra: list[str] | None = None) -> list[str] | None:
+ arguments = [str(executable), *(extra or [])]
+ if os.name == "nt":
+ return arguments
+ if sys.platform == "darwin":
+ shell_command = f"cd {shlex.quote(str(root))} && exec {shlex.join(arguments)}"
+ script = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(shell_command, ensure_ascii=False) + "\nend tell"
+ return ["osascript", "-e", script]
+ for terminal in ("x-terminal-emulator", "gnome-terminal", "konsole", "xterm"):
+ path = shutil.which(terminal)
+ if path:
+ if terminal == "gnome-terminal":
+ return [path, "--", *arguments]
+ return [path, "-e", *arguments]
+ return None
+
+
+def daemon_port(runtime_dir: Path) -> int | None:
+ try:
+ port = int((runtime_dir / "daemon.port").read_text(encoding="utf-8").strip())
+ return port if 1 <= port <= 65535 else None
+ except (OSError, ValueError):
+ return None
+
+
+def daemon_worker_log_path(runtime_dir: Path) -> Path:
+ return runtime_dir / WORKER_LOG_NAME
+
+
+def worker_failure_detail(runtime_dir: Path, reason: str) -> str:
+ return (f"{reason}; see {daemon_worker_log_path(runtime_dir)} and "
+ f"{runtime_dir / DAEMON_STARTUP_LOG_NAME}")
+
+
+def open_worker_log(runtime_dir: Path, command: list[str]):
+ """Open the append-mode worker log, writing a header for this spawn."""
+ log_path = daemon_worker_log_path(runtime_dir)
+ runtime_dir.mkdir(parents=True, exist_ok=True)
+ previous = log_path.with_name(f"{WORKER_LOG_NAME}.1")
+ try:
+ if log_path.is_file() and log_path.stat().st_size > WORKER_LOG_MAX_BYTES:
+ previous.unlink(missing_ok=True)
+ log_path.replace(previous)
+ except OSError:
+ pass # Rotation is best-effort; never block startup on it.
+ handle = log_path.open("ab")
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
+ # The header carries no token; only paths and the requested port.
+ handle.write(f"\n===== {stamp} spawn: {' '.join(command)} =====\n".encode("utf-8", "replace"))
+ handle.flush()
+ return handle
+
+
+def spawn_daemon_worker(root: Path, runtime_dir: Path, command: list[str]):
+ """Start the daemon worker as the only place this script launches it."""
+ log = open_worker_log(runtime_dir, command)
+ options = {"cwd": root, "stdout": log, "stderr": subprocess.STDOUT}
+ if os.name == "nt":
+ options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
+ try:
+ worker = subprocess.Popen(command, **options)
+ except OSError as error:
+ log.close()
+ print(f"[ERROR] Could not start Web daemon: {error}", file=sys.stderr)
+ return None
+ log.close()
+ return worker
+
+
+def daemon_is_healthy(root: Path, candidate: BuildCandidate, run_dir: Path,
+ timeout_seconds: float = 0, worker=None) -> bool:
+ del root, candidate
+ deadline = time.monotonic() + timeout_seconds
+ while True:
+ # A worker that already exited will never answer /api/health; failing
+ # here keeps a crash visible in seconds instead of after the timeout.
+ if worker is not None and worker.poll() is not None:
+ return False
+ try:
+ pid = int((run_dir / "daemon.pid").read_text(encoding="utf-8").strip())
+ port = daemon_port(run_dir)
+ token = daemon_token(run_dir)
+ if pid > 0 and port is not None and token:
+ request = urllib.request.Request(
+ f"http://127.0.0.1:{port}/api/health",
+ headers={"X-ACECode-Token": token},
+ )
+ with urllib.request.urlopen(request, timeout=1) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ # A stale runtime can point at another daemon already using the
+ # same port. Match the daemon identity from the health payload,
+ # not merely TCP reachability.
+ if int(payload.get("pid", -1)) == pid and int(payload.get("port", -1)) == port:
+ return True
+ except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError,
+ urllib.error.URLError):
+ pass
+ if time.monotonic() >= deadline:
+ return False
+ time.sleep(0.1)
+
+
+def start_quick_web_daemon(root: Path, candidate: BuildCandidate, run_dir: Path, port: int) -> int | None:
+ """Start the worker directly; the detached daemon wrapper rejects token.tmp runtimes."""
+ command = [
+ str(candidate.executable), "daemon", "--foreground",
+ f"--cwd={root.as_posix()}", f"--run-dir={run_dir.as_posix()}", f"--port={port}",
+ ]
+ worker = spawn_daemon_worker(root, run_dir, command)
+ if worker is None:
+ return None
+ if not daemon_is_healthy(root, candidate, run_dir, timeout_seconds=15, worker=worker):
+ exit_code = worker.poll()
+ reason = f"worker exited with code {exit_code}" if exit_code is not None else "health check timed out"
+ print(f"[ERROR] {worker_failure_detail(run_dir, reason)}", file=sys.stderr)
+ return None
+ return daemon_port(run_dir)
+
+
+def stop_stale_runtime(root: Path, candidate: BuildCandidate | None, run_dir: Path) -> bool:
+ """Clear runtime files for a pid already confirmed dead. Never signals it."""
+ if candidate is None:
+ print("[ERROR] No verified executable is available to clean that runtime; check it manually.", file=sys.stderr)
+ return False
+ command = [str(candidate.executable), "daemon", "stop", f"--run-dir={run_dir.as_posix()}"]
+ try:
+ result = subprocess.run(command, cwd=root, text=True, capture_output=True, timeout=15, check=False)
+ except (OSError, subprocess.TimeoutExpired) as error:
+ print(f"[ERROR] Could not clean the stale runtime: {error}", file=sys.stderr)
+ return False
+ if result.returncode != 0:
+ detail = result.stdout.strip() or result.stderr.strip() or f"exit code {result.returncode}"
+ print(f"[ERROR] Stale runtime cleanup failed: {detail}", file=sys.stderr)
+ return False
+ return True
+
+
+def reconcile_stale_runtime(root: Path, candidate: BuildCandidate | None, run_dir: Path) -> bool:
+ """Self-heal a rejected runtime only when its recorded pid is provably dead."""
+ if not (run_dir / "daemon.pid").exists():
+ return True # Nothing ever claimed this directory; start fresh.
+ pid = runtime_pid(run_dir)
+ if pid is None:
+ print(f"[ERROR] Web daemon runtime has an unreadable pid file; inspect it before retrying: {run_dir}", file=sys.stderr)
+ return False
+ if pid_is_alive(pid):
+ print(f"[ERROR] Existing Web daemon runtime is unhealthy; stop it manually before retrying: {run_dir}", file=sys.stderr)
+ print(f"[INFO] Its recorded pid {pid} is still alive, so nothing was stopped or removed.", file=sys.stderr)
+ return False
+ print(f"[INFO] Recovering stale runtime whose pid {pid} is dead: {run_dir}")
+ return stop_stale_runtime(root, candidate, run_dir)
+
+
+def daemon_token(runtime_dir: Path) -> str | None:
+ for name in ("token", "token.tmp"):
+ try:
+ token = (runtime_dir / name).read_text(encoding="utf-8").strip()
+ if token:
+ return token
+ except OSError:
+ pass
+ return None
+
+
+def launch_vite(root: Path, daemon_port_number: int, runtime_dir: Path, extra: list[str]) -> int:
+ token = daemon_token(runtime_dir)
+ if token is None:
+ print(f"[ERROR] Web daemon did not provide an authentication token: {runtime_dir}", file=sys.stderr)
+ return 1
+ builder = load_web_builder(root)
+ if builder is None:
+ print("[ERROR] Cannot load Web development server helper.", file=sys.stderr)
+ return 1
+ try:
+ _, pnpm = builder.ensure_node_and_pnpm()
+ except (OSError, subprocess.SubprocessError, SystemExit):
+ return 1
+ web_dir = root / "web"
+ if not (web_dir / "node_modules").is_dir():
+ print("[INFO] Installing Web dependencies...")
+ if subprocess.run([pnpm, "install"], cwd=web_dir, check=False).returncode != 0:
+ return 1
+ environment = os.environ.copy()
+ environment["ACECODE_DAEMON_PORT"] = str(daemon_port_number)
+ environment["ACECODE_DAEMON_TOKEN"] = token
+ print(f"[INFO] Starting Vite with daemon proxy: http://127.0.0.1:{daemon_port_number}")
+ return subprocess.run([pnpm, "dev", *vite_options(extra)], cwd=web_dir, env=environment, check=False).returncode
+
+
+def prune_candidates(root: Path) -> list[Path]:
+ """Runtime directories worth inspecting: the worktree root plus the legacy tree."""
+ candidates: list[Path] = []
+ base = runtime_root(root)
+ if base.is_dir() and base.resolve().is_relative_to(root.resolve()):
+ candidates.extend(sorted(path for path in base.iterdir()))
+ legacy = root / ".acecode-dev-run"
+ if legacy.is_dir() and legacy.resolve().is_relative_to(root.resolve()):
+ candidates.append(legacy)
+ return candidates
+
+
+def classify_runtime_directory(directory: Path) -> tuple[bool, str]:
+ """Return whether the directory can be deleted and the reason why (not)."""
+ if not directory.is_dir():
+ return False, "not a directory"
+ if directory.is_symlink() or getattr(directory, "is_junction", lambda: False)():
+ return False, "linked runtime directory"
+ if directory.resolve().parent != directory.parent.resolve():
+ return False, "runtime path escapes its parent"
+ pid = runtime_pid(directory)
+ if pid is None:
+ return False, "no readable daemon.pid"
+ if pid_is_alive(pid):
+ return False, f"pid {pid} is alive"
+ return True, str(pid)
+
+
+def prune_runtime_directories(directories: Iterable[Path]) -> tuple[list[tuple[Path, str]], list[tuple[Path, str]]]:
+ """Delete only directories whose recorded pid is confirmed dead."""
+ pruned: list[tuple[Path, str]] = []
+ skipped: list[tuple[Path, str]] = []
+ for directory in directories:
+ deletable, detail = classify_runtime_directory(directory)
+ if not deletable:
+ skipped.append((directory, detail))
+ continue
+ try:
+ shutil.rmtree(directory)
+ except OSError as error:
+ skipped.append((directory, f"remove failed: {error}"))
+ continue
+ pruned.append((directory, detail))
+ return pruned, skipped
+
+
+def prune_runtime_tree(root: Path, dry_run: bool = False) -> int:
+ candidates = prune_candidates(root)
+ if dry_run:
+ verdicts = [classify_runtime_directory(path) for path in candidates]
+ count = sum(1 for deletable, _ in verdicts if deletable)
+ print(f"[INFO] Dry run: pruning would remove {count} of {len(verdicts)} inspected path(s).")
+ return 0
+ pruned, skipped = prune_runtime_directories(candidates)
+ print("[INFO] Pruned:")
+ for directory, pid in pruned or []:
+ print(f" {directory} (dead pid={pid})")
+ if not pruned:
+ print(" (none)")
+ print("[INFO] Skipped:")
+ for directory, reason in skipped or []:
+ print(f" {directory} ({reason})")
+ if not skipped:
+ print(" (none)")
+ print(f"[INFO] Total: pruned {len(pruned)}, skipped {len(skipped)}")
+ return 0
+
+
+def prune_worktree_runtime_dirs(root: Path, current: Path) -> int:
+ """Drop this worktree's own runtime directories left behind by dead pids."""
+ base = runtime_root(root)
+ if not base.is_dir() or not base.resolve().is_relative_to(root.resolve()):
+ return 0
+ prefix = worktree_prefix(root)
+ stale = [
+ previous for previous in sorted(base.iterdir())
+ if previous.is_dir() and previous.name != current.name and previous.name.startswith(prefix)
+ ]
+ pruned, _ = prune_runtime_directories(stale)
+ for directory, pid in pruned:
+ print(f"[INFO] Removed stale runtime for dead pid {pid}: {directory}")
+ return len(pruned)
+
+
+def launch_quick_web(root: Path, candidate: BuildCandidate, extra: list[str]) -> int:
+ run_dir = selected_web_runtime_dir(root, extra)
+ if daemon_is_healthy(root, candidate, run_dir):
+ port = daemon_port(run_dir)
+ print(f"[INFO] Reusing Web daemon: http://127.0.0.1:{port}")
+ return launch_vite(root, port, run_dir, extra)
+ if not reconcile_stale_runtime(root, candidate, run_dir):
+ return 1
+ prune_worktree_runtime_dirs(root, run_dir)
+ port = select_runtime_port(run_dir, extra)
+ if port is None:
+ return 1
+ started = start_quick_web_daemon(root, candidate, run_dir, port)
+ if started is None:
+ print("[ERROR] Web daemon could not be started.", file=sys.stderr)
+ return 1
+ print(f"[INFO] Started Web daemon: http://127.0.0.1:{started}")
+ return launch_vite(root, started, run_dir, extra)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Start an ACECode development environment", allow_abbrev=False)
+ parser.add_argument("target", nargs="?", choices=TARGETS, help="development surface to start, or prune to reclaim stale runtimes")
+ parser.add_argument("--build-dir", type=Path, help="build directory to validate and use")
+ parser.add_argument("--yes", action="store_true", help="confirm a required CMake build")
+ parser.add_argument("--dry-run", action="store_true", help="print the selected command without starting it")
+ args, extra = parser.parse_known_args()
+ args.extra = extra
+ return args
+
+
+def choose_target(target: str | None) -> str | None:
+ if target:
+ return target
+ if sys.stdin is None or not sys.stdin.isatty():
+ print("[ERROR] Specify one target: web, desktop, or tui.", file=sys.stderr)
+ return None
+ selected = input("Choose development target [web/desktop/tui]: ").strip().lower()
+ if selected not in TARGETS:
+ print("[ERROR] Choose web, desktop, or tui.", file=sys.stderr)
+ return None
+ return selected
+
+
+def main() -> int:
+ args = parse_args()
+ target = choose_target(args.target)
+ if not target:
+ return 2
+ root = project_root()
+ if target == PRUNE_TARGET:
+ return prune_runtime_tree(root, dry_run=args.dry_run)
+ embedded, build_daemon, extra = web_launcher_options(args.extra) if target == "web" else (False, False, args.extra)
+ if target == "web" and not embedded:
+ if "--yes" in extra:
+ print("[ERROR] --yes is only available to the embedded validation workflow; use --build-daemon for quick mode.", file=sys.stderr)
+ return 2
+ candidate = find_compatible_build(root, target, args.build_dir)
+ if args.build_dir and candidate is None:
+ print(f"[ERROR] --build-dir does not contain a compatible configured {target} build: {(root / args.build_dir).resolve()}", file=sys.stderr)
+ return 1
+ if candidate is None:
+ preset = default_preset(target)
+ if preset is None:
+ print("[ERROR] No supported CMake preset for this platform and architecture.", file=sys.stderr)
+ return 1
+ if not build_daemon and (sys.stdin is None or not sys.stdin.isatty()):
+ print("[ERROR] No compatible daemon executable was found. Re-run with --build-daemon to authorize its native build.", file=sys.stderr)
+ return 1
+ if not ask_to_build(preset, target, build_daemon):
+ return 1
+ if args.dry_run:
+ print(f"[INFO] Dry run: cmake --preset {preset}")
+ return 0
+ if not ensure_windows_environment(root, None):
+ return 1
+ sccache = find_sccache()
+ built = configure_and_build(root, preset, target, sccache)
+ if not built and sccache:
+ print("[INFO] sccache configuration failed; retrying normal compilation.")
+ built = configure_and_build(root, preset, target, None)
+ if not built:
+ return 1
+ candidate = find_compatible_build(root, target, built)
+ if candidate is None:
+ print("[ERROR] Build completed but did not produce a compatible executable.", file=sys.stderr)
+ return 1
+ if args.dry_run:
+ print(f"[INFO] Dry run: start Vite with {candidate.executable}")
+ return 0
+ return launch_quick_web(root, candidate, extra)
+ if target == "desktop" and "--list" in args.extra:
+ command = [sys.executable, str(root / "scripts/dev_desktop.py"), *args.extra]
+ if args.build_dir:
+ command.extend(("--build-dir", str(args.build_dir)))
+ if args.dry_run:
+ print("[INFO] Dry run: " + subprocess.list2cmdline(command))
+ return 0
+ return subprocess.run(command, cwd=root, check=False).returncode
+ sccache = find_sccache()
+ if sccache:
+ print(f"[INFO] Using sccache: {sccache}")
+ else:
+ print(f"[INFO] sccache not found. {sccache_install_hint()}")
+ candidate = find_compatible_build(root, target, args.build_dir)
+ if args.build_dir and candidate is None:
+ print(f"[ERROR] --build-dir does not contain a compatible configured {target} build: {(root / args.build_dir).resolve()}", file=sys.stderr)
+ return 1
+ if not args.dry_run and target == "web" and not web_runtime_is_available(root, candidate, extra):
+ return 1
+ if not args.dry_run and not ensure_windows_environment(root, candidate):
+ return 1
+ sccache_disabled = candidate is not None and sccache_disabled_for_build(candidate.build_dir, sccache)
+ if sccache_disabled:
+ print("[INFO] sccache is disabled for this build after a prior compiler failure.")
+ sccache = None
+ if candidate is None:
+ preset = default_preset(target)
+ if preset is None:
+ print("[ERROR] No supported CMake preset for this platform and architecture.", file=sys.stderr)
+ return 1
+ if not ask_to_build(preset, target, args.yes or (target == "web" and embedded and os.name == "nt")):
+ return 1
+ if args.dry_run:
+ print(f"[INFO] Dry run: cmake --preset {preset}")
+ return 0
+ built = configure_and_build(root, preset, target, sccache)
+ if not built:
+ if sccache:
+ print("[INFO] sccache configuration failed; retrying normal compilation.")
+ built = configure_and_build(root, preset, target, None)
+ if not built:
+ return 1
+ candidate = find_compatible_build(root, target, built)
+ if candidate is None:
+ print("[ERROR] Build completed but did not produce a compatible executable.", file=sys.stderr)
+ return 1
+ if not args.dry_run and not sccache_disabled and cache_state_changed(candidate.build_dir, sccache):
+ preset = default_preset(target)
+ if preset is None:
+ print("[ERROR] Cannot reconfigure the build for this platform.", file=sys.stderr)
+ return 1
+ if not configure_build(root, preset, candidate.build_dir, sccache):
+ if sccache:
+ print("[INFO] sccache reconfiguration failed; retrying without it.")
+ sccache = None
+ if not configure_build(root, preset, candidate.build_dir, None):
+ return 1
+ else:
+ return 1
+ if not args.dry_run and target == "web":
+ if not refresh_web_assets(root):
+ print("[ERROR] Web asset refresh failed; embedded development environment was not started.", file=sys.stderr)
+ return 1
+ if not args.dry_run and not build_target(root, candidate.build_dir, target, sccache, candidate.configuration):
+ if sccache:
+ preset = default_preset(target)
+ print("[INFO] sccache build failed; retrying this build without sccache.")
+ if preset and configure_build(root, preset, candidate.build_dir, None) and build_target(root, candidate.build_dir, target, None, candidate.configuration):
+ write_sccache_marker(candidate.build_dir, find_sccache(), enabled=False)
+ sccache = None
+ else:
+ print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr)
+ return 1
+ else:
+ print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr)
+ return 1
+ if target == "desktop" and not args.dry_run:
+ refreshed = refresh_web_assets(root, force=True) if "--rebuild" in args.extra else refresh_web_assets(root)
+ if not refreshed:
+ print("[ERROR] Web asset refresh failed; development environment was not started.", file=sys.stderr)
+ return 1
+ return launch_surface(root, target, candidate, args.dry_run, extra)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/dev_tui.bat b/scripts/dev_tui.bat
new file mode 100644
index 000000000..4c8d9a3b5
--- /dev/null
+++ b/scripts/dev_tui.bat
@@ -0,0 +1,22 @@
+@echo off
+REM ACECode TUI development launcher (Windows)
+REM Usage: scripts\dev_tui.bat [launcher options]
+
+setlocal
+set "SCRIPT_DIR=%~dp0"
+
+where python >nul 2>&1
+if not errorlevel 1 (
+ set "PYTHON=python"
+) else (
+ where py >nul 2>&1
+ if not errorlevel 1 (
+ set "PYTHON=py"
+ ) else (
+ echo [ERROR] python or py was not found. Install Python 3.8+ first.
+ exit /b 1
+ )
+)
+
+"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" tui --yes %*
+exit /b %errorlevel%
diff --git a/scripts/dev_tui.sh b/scripts/dev_tui.sh
new file mode 100755
index 000000000..effe187e4
--- /dev/null
+++ b/scripts/dev_tui.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+# ACECode TUI development launcher (macOS / Linux)
+# Usage: ./scripts/dev_tui.sh [launcher options]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+if command -v python3 >/dev/null 2>&1; then
+ PYTHON=python3
+elif command -v python >/dev/null 2>&1; then
+ PYTHON=python
+else
+ echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2
+ exit 1
+fi
+
+exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" tui "$@"
diff --git a/scripts/dev_web.bat b/scripts/dev_web.bat
index 8b127d486..98309a98b 100644
--- a/scripts/dev_web.bat
+++ b/scripts/dev_web.bat
@@ -1,7 +1,6 @@
@echo off
-REM ACECode Web UI daemon launcher (no Desktop GUI)
-REM Usage: scripts\dev_web.bat [options]
-REM Details: python scripts\dev_web.py --help
+REM ACECode Web development launcher (Windows)
+REM Usage: scripts\dev_web.bat [--embedded] [--build-daemon] [Vite options]
setlocal
set "SCRIPT_DIR=%~dp0"
@@ -19,5 +18,5 @@ if not errorlevel 1 (
)
)
-"%PYTHON%" "%SCRIPT_DIR%dev_web.py" %*
+"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" web %*
exit /b %errorlevel%
diff --git a/scripts/dev_web.py b/scripts/dev_web.py
index 3e88e245d..b7ffc827b 100644
--- a/scripts/dev_web.py
+++ b/scripts/dev_web.py
@@ -11,6 +11,8 @@
import webbrowser
from pathlib import Path
+from dev_build_artifacts import find_named_artifacts
+
def find_project_root() -> Path:
current = Path(__file__).resolve().parent
@@ -23,24 +25,14 @@ def find_project_root() -> Path:
def find_executable(build_dir: Path) -> Path | None:
name = "acecode.exe" if os.name == "nt" else "acecode"
- candidates = [
- build_dir / name,
- build_dir / "Release" / name,
- build_dir / "Debug" / name,
- build_dir / "MinSizeRel" / name,
- build_dir / "RelWithDebInfo" / name,
- ]
- for candidate in candidates:
- if candidate.is_file():
- return candidate
-
- matches = sorted(build_dir.glob(f"**/{name}")) if build_dir.is_dir() else []
+ matches = find_named_artifacts(build_dir, [name])
return matches[0] if matches else None
def main() -> int:
parser = argparse.ArgumentParser(
- description="Start ACECode Web UI daemon without starting the Desktop GUI"
+ description="Start ACECode Web UI daemon without starting the Desktop GUI",
+ allow_abbrev=False,
)
parser.add_argument("--build-dir", default="build", help="ACECode build directory")
parser.add_argument("--cwd", default=None, help="Workspace directory served by the daemon")
@@ -50,6 +42,11 @@ def main() -> int:
default=None,
help="Web assets directory; defaults to /web/dist",
)
+ parser.add_argument(
+ "--use-embedded-assets",
+ action="store_true",
+ help="Serve assets embedded in the executable instead of web/dist",
+ )
parser.add_argument(
"--run-dir", default=None, help="Isolate daemon runtime files to this directory"
)
@@ -75,14 +72,16 @@ def main() -> int:
print(" Build the acecode target first, or pass --build-dir.", file=sys.stderr)
return 1
- static_dir = Path(args.static_dir) if args.static_dir else project_root / "web" / "dist"
- if not static_dir.is_absolute():
- static_dir = project_root / static_dir
- static_dir = static_dir.resolve()
- if not (static_dir / "index.html").is_file():
- print(f"[ERROR] Web UI assets not found: {static_dir / 'index.html'}", file=sys.stderr)
- print(" Run `pnpm --dir web build` first, or pass --static-dir.", file=sys.stderr)
- return 1
+ static_dir = None
+ if not args.use_embedded_assets:
+ static_dir = Path(args.static_dir) if args.static_dir else project_root / "web" / "dist"
+ if not static_dir.is_absolute():
+ static_dir = project_root / static_dir
+ static_dir = static_dir.resolve()
+ if not (static_dir / "index.html").is_file():
+ print(f"[ERROR] Web UI assets not found: {static_dir / 'index.html'}", file=sys.stderr)
+ print(" Run `pnpm --dir web build` first, pass --static-dir, or use --use-embedded-assets.", file=sys.stderr)
+ return 1
workspace = Path(args.cwd).resolve() if args.cwd else project_root
if not workspace.is_dir():
@@ -97,7 +96,9 @@ def main() -> int:
print("[ERROR] --port must be between 1 and 65535", file=sys.stderr)
return 1
command.append(f"--port={args.port}")
- command.extend((f"--cwd={workspace}", f"--static-dir={static_dir}"))
+ command.append(f"--cwd={workspace}")
+ if static_dir is not None:
+ command.append(f"--static-dir={static_dir}")
if args.run_dir:
run_dir = Path(args.run_dir)
if not run_dir.is_absolute():
@@ -107,29 +108,29 @@ def main() -> int:
print(f"[INFO] Starting Web UI daemon: {executable}")
print(f"[INFO] Workspace: {workspace}")
- print(f"[INFO] Static assets: {static_dir}")
+ print(f"[INFO] Static assets: {static_dir if static_dir is not None else 'embedded executable assets'}")
print("[INFO] Desktop GUI is not started.", flush=True)
if args.foreground:
return subprocess.run(command, cwd=project_root).returncode
+ runtime_dir = _runtime_dir(project_root, args.run_dir)
+ previous_port_mtime_ns = _port_mtime_ns(runtime_dir)
run_options = {"cwd": project_root}
if os.name == "nt":
run_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
result = subprocess.run(command, **run_options)
- # Exit 6 means the daemon validated an already-running instance. Other
- # failures must not be hidden by a stale daemon.port from an earlier run.
- if result.returncode not in (0, 6):
- return result.returncode
-
- runtime_dir = _runtime_dir(project_root, args.run_dir)
- port = _wait_for_port(runtime_dir)
+ # The Windows daemon wrapper can time out before its worker has finished
+ # loading configuration. Accept a nonzero wrapper exit only when it is
+ # followed by a freshly written port file from this launch.
+ allow_existing_port = result.returncode in (0, 6)
+ port = _wait_for_port(runtime_dir, previous_port_mtime_ns, allow_existing_port)
if port is None:
- print("[ERROR] Daemon started without a readable Web UI port.", file=sys.stderr)
+ print("[ERROR] Daemon started without a fresh readable Web UI port.", file=sys.stderr)
return result.returncode or 1
- _open_web_ui(port, args.no_browser, already_running=result.returncode != 0)
+ _open_web_ui(port, args.no_browser, already_running=result.returncode == 6)
return 0
@@ -152,13 +153,21 @@ def _open_web_ui(port: int, no_browser: bool, already_running: bool = False) ->
webbrowser.open(url)
-def _wait_for_port(runtime_dir: Path) -> int | None:
+def _port_mtime_ns(runtime_dir: Path) -> int | None:
+ try:
+ return (runtime_dir / "daemon.port").stat().st_mtime_ns
+ except OSError:
+ return None
+
+
+def _wait_for_port(runtime_dir: Path, previous_mtime_ns: int | None = None, allow_existing: bool = True) -> int | None:
port_file = runtime_dir / "daemon.port"
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
try:
port = int(port_file.read_text(encoding="utf-8").strip())
- if 1 <= port <= 65535:
+ mtime_ns = port_file.stat().st_mtime_ns
+ if 1 <= port <= 65535 and (allow_existing or previous_mtime_ns is None or mtime_ns > previous_mtime_ns):
return port
except (OSError, ValueError):
pass
diff --git a/scripts/dev_web.sh b/scripts/dev_web.sh
new file mode 100755
index 000000000..9ee912ce9
--- /dev/null
+++ b/scripts/dev_web.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+# ACECode Web development launcher (macOS / Linux)
+# Usage: ./scripts/dev_web.sh [--embedded] [--build-daemon] [Vite options]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+if command -v python3 >/dev/null 2>&1; then
+ PYTHON=python3
+elif command -v python >/dev/null 2>&1; then
+ PYTHON=python
+else
+ echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2
+ exit 1
+fi
+
+exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" web "$@"
diff --git a/scripts/dev_windows_env.bat b/scripts/dev_windows_env.bat
new file mode 100644
index 000000000..5247d702f
--- /dev/null
+++ b/scripts/dev_windows_env.bat
@@ -0,0 +1,43 @@
+@echo off
+REM Initialize the native Visual Studio C++ environment when MSVC is required.
+
+setlocal EnableDelayedExpansion
+set "ACECODE_VSARCH=amd64"
+set "ACECODE_VSCOMPONENT=Microsoft.VisualStudio.Component.VC.Tools.x86.x64"
+if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "ACECODE_VSARCH=arm64"
+if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" set "ACECODE_VSARCH=arm64"
+if "!ACECODE_VSARCH!"=="arm64" set "ACECODE_VSCOMPONENT=Microsoft.VisualStudio.Component.VC.Tools.ARM64"
+set "ACECODE_VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
+if not exist "!ACECODE_VSWHERE!" goto :missing_installer
+
+set "ACECODE_VSINSTALL="
+for /f "usebackq delims=" %%I in (`"!ACECODE_VSWHERE!" -latest -products * -requires !ACECODE_VSCOMPONENT! -property installationPath`) do set "ACECODE_VSINSTALL=%%I"
+if not defined ACECODE_VSINSTALL goto :missing_tools
+
+set "ACECODE_VSDEVCMD=!ACECODE_VSINSTALL!\Common7\Tools\VsDevCmd.bat"
+if not exist "!ACECODE_VSDEVCMD!" goto :missing_command
+
+for %%I in ("!ACECODE_VSDEVCMD!") do for %%A in (!ACECODE_VSARCH!) do endlocal & set "ACECODE_VSDEVCMD=%%~fI" & set "ACECODE_VSARCH=%%A"
+call "%ACECODE_VSDEVCMD%" -arch=%ACECODE_VSARCH% -host_arch=%ACECODE_VSARCH% >nul
+if errorlevel 1 goto :initialization_failed
+if /i "%~1"=="--print-env" set
+exit /b 0
+
+:missing_installer
+endlocal
+echo [ERROR] Visual Studio Installer was not found. Install Visual Studio Build Tools with the Desktop development with C++ workload.
+exit /b 1
+
+:missing_tools
+endlocal
+echo [ERROR] Visual Studio C++ tools were not found. Install the Desktop development with C++ workload.
+exit /b 1
+
+:missing_command
+endlocal
+echo [ERROR] Visual Studio developer command script was not found.
+exit /b 1
+
+:initialization_failed
+echo [ERROR] Failed to initialize the Visual Studio C++ developer environment.
+exit /b 1
diff --git a/scripts/macos_create_installer_dmg.sh b/scripts/macos_create_installer_dmg.sh
new file mode 100644
index 000000000..e71243235
--- /dev/null
+++ b/scripts/macos_create_installer_dmg.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -euo pipefail
+set +x
+usage() {
+ printf 'Usage: bash scripts/macos_create_installer_dmg.sh --app ACECode.app --output NEW.dmg --arch x64|arm64 --identity APPLICATION-IDENTITY [--keychain PATH] [--keychain-profile NAME]\n'
+}
+app=""; output=""; arch=""; identity=""; keychain=""; profile="${NOTARYTOOL_PROFILE:-}"
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --app|--output|--arch|--identity|--keychain|--keychain-profile)
+ [[ $# -ge 2 && -n "$2" ]] || { usage >&2; exit 2; }
+ case "$1" in
+ --app) app="$2";; --output) output="$2";; --arch) arch="$2";;
+ --identity) identity="$2";; --keychain) keychain="$2";; --keychain-profile) profile="$2";;
+ esac
+ shift 2 ;;
+ --help|-h) usage; exit 0 ;;
+ *) usage >&2; exit 2 ;;
+ esac
+done
+[[ -n "$app" && "$output" == *.dmg && -n "$identity" && "$identity" != - ]] || { usage >&2; exit 2; }
+case "$arch" in x64|arm64) ;; *) usage >&2; exit 2;; esac
+[[ ! -e "$output" && ! -L "$output" ]] || { printf 'Refusing existing output\n' >&2; exit 1; }
+[[ "$(uname -s)" == Darwin ]] || { printf 'Darwin required\n' >&2; exit 1; }
+auth=()
+if [[ -n "$profile" ]]; then
+ auth=(--keychain-profile "$profile")
+else
+ [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] || {
+ printf 'Notarization credentials required\n' >&2; exit 2;
+ }
+ auth=(--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD")
+fi
+root="$(cd "$(dirname "$0")/.." && pwd)"
+work="$(mktemp -d "${TMPDIR:-/tmp}/acecode-installer-release.XXXXXX")"
+trap 'rm -rf -- "$work"' EXIT
+bash "$root/packaging/macos-installer/build.sh" --app "$app" --output "$work/image" --arch "$arch"
+installer="$work/image/ACECode Installer.app"
+sign_args=(--sign "$identity" --timestamp)
+if [[ -n "$keychain" ]]; then sign_args+=(--keychain "$keychain"); fi
+# Deliberately NOT --deep: only sign the wrapper, never the stapled payload.
+/usr/bin/codesign --force --options runtime "${sign_args[@]}" "$installer"
+/usr/bin/codesign --verify --deep --strict "$installer"
+details="$(/usr/bin/codesign -dv --verbose=4 "$installer" 2>&1)"
+grep -Fxq 'TeamIdentifier=T52GZCH73Y' <<< "$details"
+grep -Fq 'Authority=Developer ID Application:' <<< "$details"
+/usr/bin/diff -rq "$app" "$installer/Contents/Resources/ACECode.app"
+notarize() {
+ local input="$1" ticket="$2"
+ xcrun notarytool submit "$input" "${auth[@]}" --wait --output-format json > "$work/result.json"
+ local status submission
+ status="$(/usr/bin/plutil -extract status raw -o - "$work/result.json")"
+ if [[ "$status" != Accepted ]]; then
+ submission="$(/usr/bin/plutil -extract id raw -o - "$work/result.json" 2>/dev/null || true)"
+ if [[ -n "$submission" ]]; then xcrun notarytool log "$submission" "${auth[@]}" || true; fi
+ printf 'Notarization rejected: %s\n' "$status" >&2; exit 1
+ fi
+ xcrun stapler staple "$ticket"
+ xcrun stapler validate "$ticket"
+}
+/usr/bin/ditto -c -k --keepParent "$installer" "$work/installer.zip"
+notarize "$work/installer.zip" "$installer"
+/usr/bin/codesign --verify --deep --strict "$installer"
+/usr/sbin/spctl --assess --type execute --verbose=4 "$installer"
+/usr/bin/diff -rq "$app" "$installer/Contents/Resources/ACECode.app"
+xcrun stapler validate "$installer/Contents/Resources/ACECode.app"
+# The image root contains only the installer, not a misleading Applications link.
+# DiskImages can transiently fail with EBUSY on hosted macOS runners. Keep each
+# attempt separate, capture logs without a pipe, and retry only that error.
+for attempt in 1 2 3; do
+ attempt_image="$work/release-$attempt.dmg"
+ if /usr/bin/hdiutil create -volname 'ACECode Installer' -srcfolder "$work/image" \
+ -fs HFS+ -format UDZO "$attempt_image" > "$work/hdiutil.log" 2>&1; then
+ cat "$work/hdiutil.log"
+ mv "$attempt_image" "$work/release.dmg"
+ break
+ else
+ status=$?
+ cat "$work/hdiutil.log" >&2
+ if [[ "$attempt" -eq 3 ]] || ! grep -Fq 'Resource busy' "$work/hdiutil.log"; then
+ exit "$status"
+ fi
+ printf 'Disk image creation is busy; retrying (%s/3)\n' "$attempt" >&2
+ sleep "$attempt"
+ fi
+done
+/usr/bin/codesign "${sign_args[@]}" "$work/release.dmg"
+notarize "$work/release.dmg" "$work/release.dmg"
+/usr/bin/codesign --verify --strict "$work/release.dmg"
+/usr/sbin/spctl --assess --type open --context context:primary-signature --verbose=4 "$work/release.dmg"
+mkdir -p "$(dirname "$output")"
+/usr/bin/ditto "$work/release.dmg" "$output"
+printf 'Signed, notarized and stapled installer DMG: %s\n' "$output"
diff --git a/scripts/macos_create_pkg.sh b/scripts/macos_create_pkg.sh
index 3cce9d982..a4d07bd60 100755
--- a/scripts/macos_create_pkg.sh
+++ b/scripts/macos_create_pkg.sh
@@ -156,6 +156,11 @@ if [[ ! -x "$app_executable" ]]; then
echo "Missing executable ACECode payload: $app_executable" >&2
exit 1
fi
+computer_use_helper="$app_path/Contents/MacOS/acecode-computer-use"
+if [[ ! -x "$computer_use_helper" || ! -s "$computer_use_helper" ]]; then
+ echo "Missing Computer Use helper payload: $computer_use_helper" >&2
+ exit 1
+fi
architectures="$(/usr/bin/lipo -archs "$app_executable" 2>/dev/null || true)"
if [[ -z "$architectures" ]]; then
@@ -172,6 +177,10 @@ for architecture in $architectures; do
exit 1
;;
esac
+ if ! /usr/bin/lipo "$computer_use_helper" -verify_arch "$architecture" >/dev/null 2>&1; then
+ echo "Computer Use helper does not support app architecture: $architecture" >&2
+ exit 1
+ fi
if [[ -n "$host_architectures" ]]; then
host_architectures+=","
fi
diff --git a/scripts/macos_create_portable_zip.sh b/scripts/macos_create_portable_zip.sh
index 25f03bfd2..0e953b198 100644
--- a/scripts/macos_create_portable_zip.sh
+++ b/scripts/macos_create_portable_zip.sh
@@ -154,9 +154,11 @@ echo "== incrementally building current binaries =="
echo "$current_hash" > "$embed_marker"
for binary in "$build_dir/acecode" \
+ "$build_dir/acecode-computer-use" \
"$build_dir/ACECode.app/Contents/MacOS/ACECode" \
- "$build_dir/ACECode.app/Contents/MacOS/acecode-daemon"; do
- if [[ ! -x "$binary" ]] || ! "$LIPO_BIN" -verify_arch "$arch" "$binary"; then
+ "$build_dir/ACECode.app/Contents/MacOS/acecode-daemon" \
+ "$build_dir/ACECode.app/Contents/MacOS/acecode-computer-use"; do
+ if [[ ! -x "$binary" ]] || ! "$LIPO_BIN" "$binary" -verify_arch "$arch"; then
echo "ERROR: missing executable or wrong architecture ($arch): $binary" >&2
exit 1
fi
@@ -193,6 +195,7 @@ if [[ ! -f "$build_dir/acecode" ]]; then
exit 1
fi
cp "$build_dir/acecode" "$package_dir/"
+"$CMAKE_BIN" --install "$build_dir" --prefix "$package_dir" --component computer_use_runtime >/dev/null
cp "$repo_root/README.md" "$repo_root/README_CN.md" "$package_dir/"
"$CMAKE_BIN" --install "$build_dir" --prefix "$package_dir" --component models_dev_registry >/dev/null
@@ -231,8 +234,10 @@ trap 'rm -rf -- "$verify_root"' EXIT
extracted="$verify_root/$package_name"
[[ -x "$extracted/acecode" ]] || { echo "extracted acecode not executable" >&2; exit 1; }
+[[ -x "$extracted/acecode-computer-use" ]] || { echo "extracted Computer Use helper missing" >&2; exit 1; }
[[ -x "$extracted/ACECode.app/Contents/MacOS/ACECode" ]] || { echo "extracted app main missing" >&2; exit 1; }
[[ -x "$extracted/ACECode.app/Contents/MacOS/acecode-daemon" ]] || { echo "extracted daemon missing" >&2; exit 1; }
+[[ -x "$extracted/ACECode.app/Contents/MacOS/acecode-computer-use" ]] || { echo "extracted app Computer Use helper missing" >&2; exit 1; }
if ! grep -aq "provider-logos" "$extracted/acecode"; then
echo "ERROR: built binary does NOT contain embedded web UI (provider-logos absent)." >&2
echo " The embed step silently fell back to an empty asset map. Reconfigure cmake." >&2
diff --git a/scripts/macos_create_update_zip.sh b/scripts/macos_create_update_zip.sh
index 181d2f99c..26bc8f42c 100755
--- a/scripts/macos_create_update_zip.sh
+++ b/scripts/macos_create_update_zip.sh
@@ -71,6 +71,11 @@ if [[ ! -d "$app_path" || "$(basename "$app_path")" != "ACECode.app" ]]; then
echo "Missing ACECode.app update payload: $app_path" >&2
exit 1
fi
+computer_use_helper="$app_path/Contents/MacOS/acecode-computer-use"
+if [[ ! -x "$computer_use_helper" || ! -s "$computer_use_helper" ]]; then
+ echo "ACECode.app is missing the Computer Use helper: $computer_use_helper" >&2
+ exit 1
+fi
models_dev_dir="$app_path/Contents/Resources/share/acecode/models_dev"
seed_dir="$app_path/Contents/Resources/share/acecode/seed"
models_dev_files=(api.json MANIFEST.json LICENSE)
@@ -110,6 +115,7 @@ trap cleanup EXIT
# Reuse the already signed/notarized bundled daemon as the flat CLI payload.
/usr/bin/ditto "$app_path/Contents/MacOS/acecode-daemon" \
"$temporary_root/acecode"
+/usr/bin/ditto "$computer_use_helper" "$temporary_root/acecode-computer-use"
mkdir -p "$temporary_root/share/acecode"
/usr/bin/ditto "$models_dev_dir" \
"$temporary_root/share/acecode/models_dev"
@@ -120,7 +126,7 @@ if [[ -d "$app_path/Contents/Resources/channels" ]]; then
fi
(
cd "$temporary_root"
- /usr/bin/zip -qr "$temporary_zip" acecode share
+ /usr/bin/zip -qr "$temporary_zip" acecode acecode-computer-use share
if [[ -d channels ]]; then /usr/bin/zip -qr "$temporary_zip" channels; fi
)
mkdir -p "$verify_root"
@@ -135,10 +141,15 @@ verified_cli_models="$verify_root/share/acecode/models_dev"
verified_app_seed="$verified_app/Contents/Resources/share/acecode/seed"
verified_cli_seed="$verify_root/share/acecode/seed"
if [[ ! -d "$verified_app" || ! -x "$app_main" || ! -x "$app_daemon" ||
- ! -x "$verified_cli" ]]; then
+ ! -x "$verified_cli" || ! -x "$verify_root/acecode-computer-use" ||
+ ! -x "$verified_app/Contents/MacOS/acecode-computer-use" ]]; then
echo "Extracted update archive is missing executable ACECode app or CLI binaries." >&2
exit 1
fi
+if ! /usr/bin/cmp -s "$verify_root/acecode-computer-use" "$verified_app/Contents/MacOS/acecode-computer-use"; then
+ echo "Extracted Computer Use helpers differ between CLI and app payloads." >&2
+ exit 1
+fi
for models_dev_file in "${models_dev_files[@]}"; do
if [[ ! -f "$verified_app_models/$models_dev_file" ||
! -f "$verified_cli_models/$models_dev_file" ]] ||
@@ -158,6 +169,7 @@ python3 "$repo_root/scripts/verify_seed_bundle.py" \
if [[ "$require_trusted" == true ]]; then
/usr/bin/codesign --verify --deep --strict --verbose=2 "$verified_app"
/usr/bin/codesign --verify --strict --verbose=2 "$verified_cli"
+ /usr/bin/codesign --verify --strict --verbose=2 "$verify_root/acecode-computer-use"
xcrun stapler validate "$verified_app"
/usr/sbin/spctl --assess --type execute --verbose=4 "$verified_app"
fi
diff --git a/scripts/npm/prepare-npm-packages.mjs b/scripts/npm/prepare-npm-packages.mjs
index a1e68c39f..f3f315b0a 100644
--- a/scripts/npm/prepare-npm-packages.mjs
+++ b/scripts/npm/prepare-npm-packages.mjs
@@ -6,7 +6,7 @@
//
// 输入布局(与 package.yml 的 Package 步骤产物一致):
// extracted/acecode-linux-x64/{acecode,acecode-desktop,share/...}
-// extracted/acecode-windows-x64/{acecode.exe,acecode-desktop.exe,share/...}
+// extracted/acecode-windows-x64/{acecode.exe,acecode-desktop.exe,acecode-computer-use.exe,share/...}
// extracted/acecode-macos-arm64/{acecode,ACECode.app/...,share/...}
//
// 输出布局(发布顺序:先 platform/* 再 cli / desktop):
@@ -56,29 +56,29 @@ const PLATFORMS = [
ciId: 'windows-x64',
os: 'win32',
cpu: 'x64',
- files: ['acecode.exe', 'acecode-desktop.exe', 'share', 'channels'],
+ files: ['acecode.exe', 'acecode-desktop.exe', 'acecode-computer-use.exe', 'share', 'channels'],
executables: [],
},
{
ciId: 'windows-arm64',
os: 'win32',
cpu: 'arm64',
- files: ['acecode.exe', 'acecode-desktop.exe', 'share', 'channels'],
+ files: ['acecode.exe', 'acecode-desktop.exe', 'acecode-computer-use.exe', 'share', 'channels'],
executables: [],
},
{
ciId: 'macos-x64',
os: 'darwin',
cpu: 'x64',
- files: ['acecode', 'ACECode.app', 'share', 'channels'],
- executables: ['acecode'],
+ files: ['acecode', 'acecode-computer-use', 'ACECode.app', 'share', 'channels'],
+ executables: ['acecode', 'acecode-computer-use'],
},
{
ciId: 'macos-arm64',
os: 'darwin',
cpu: 'arm64',
- files: ['acecode', 'ACECode.app', 'share', 'channels'],
- executables: ['acecode'],
+ files: ['acecode', 'acecode-computer-use', 'ACECode.app', 'share', 'channels'],
+ executables: ['acecode', 'acecode-computer-use'],
},
];
@@ -164,6 +164,10 @@ function buildPlatformPackage(platform, version, inputRoot, outputRoot) {
if (!fs.existsSync(src)) {
throw new Error(`平台 ${platform.ciId} 缺少产物文件: ${src}`);
}
+ if ((file === 'acecode-computer-use.exe' || file === 'acecode-computer-use') &&
+ (!fs.statSync(src).isFile() || fs.statSync(src).size === 0)) {
+ throw new Error(`平台 ${platform.ciId} 的 Computer Use runtime 无效: ${src}`);
+ }
if (file === 'channels') {
const channelDir = path.join(outDir, CHANNEL_RELATIVE_DIR);
fs.mkdirSync(channelDir, { recursive: true });
@@ -183,6 +187,10 @@ function buildPlatformPackage(platform, version, inputRoot, outputRoot) {
if (!fs.existsSync(path.join(macosDir, 'ACECode'))) {
throw new Error(`平台 ${platform.ciId} 的 ACECode.app 不完整: 缺少 ${macosDir}/ACECode`);
}
+ const bundledHelper = path.join(macosDir, 'acecode-computer-use');
+ if (!fs.existsSync(bundledHelper) || !fs.statSync(bundledHelper).isFile() || fs.statSync(bundledHelper).size === 0) {
+ throw new Error(`平台 ${platform.ciId} 的 ACECode.app 缺少 Computer Use runtime: ${bundledHelper}`);
+ }
validateModelsDevRegistry(
path.join(outDir, 'ACECode.app', 'Contents', 'Resources'),
`平台 ${platform.ciId} 的 ACECode.app`
diff --git a/scripts/select_macos_update_assets.py b/scripts/select_macos_update_assets.py
new file mode 100644
index 000000000..877facc17
--- /dev/null
+++ b/scripts/select_macos_update_assets.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""Select only exact, trusted macOS updater ZIP names, never installer assets."""
+import argparse
+from pathlib import Path
+
+
+def select_assets(directory, version):
+ if not version or any(c not in '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-+' for c in version):
+ raise ValueError('Invalid release version')
+ selected = []
+ for arch in ('x64', 'arm64'):
+ name = f'ACECode-{version}-macos-{arch}-update.zip'
+ matches = [p for p in Path(directory).rglob(name) if p.is_file() and not p.is_symlink()]
+ if len(matches) != 1:
+ raise ValueError(f'Expected exactly one updater asset {name}; found {len(matches)}')
+ selected.append(matches[0])
+ return selected
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('directory', type=Path)
+ parser.add_argument('version')
+ args = parser.parse_args()
+ try:
+ for asset in select_assets(args.directory, args.version):
+ print(asset)
+ except ValueError as error:
+ parser.exit(1, f'{error}\n')
diff --git a/scripts/verify_deepin_package.py b/scripts/verify_deepin_package.py
new file mode 100644
index 000000000..ae5ffe3f7
--- /dev/null
+++ b/scripts/verify_deepin_package.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+"""Require Deepin packages to use system Qt/DTK libraries and plugins."""
+
+import argparse
+from pathlib import Path
+import re
+
+
+def verify_deepin_package(directory):
+ directory = Path(directory)
+ if not directory.is_dir():
+ raise ValueError(f"Missing package directory: {directory}")
+ # Qt platform, theme, image and style plugins use the libq*.so convention.
+ # Include versioned libraries and symlinks, even if their target is absent.
+ runtime_name = re.compile(r"lib(?:qt[56]|dtk|dxcb|q)[^/]*\.so(?:\..*)?$", re.I)
+ bundled = [str(path.relative_to(directory)) for path in directory.rglob("*")
+ if (path.is_file() or path.is_symlink()) and
+ runtime_name.fullmatch(path.name)]
+ if bundled:
+ raise ValueError("Bundled Qt/DTK runtime is forbidden:\n" +
+ "\n".join(sorted(bundled)))
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("directory", type=Path)
+ args = parser.parse_args()
+ try:
+ verify_deepin_package(args.directory)
+ except (OSError, ValueError) as error:
+ parser.exit(1, f"{error}\n")
+ print("Verified: no bundled Qt/DTK runtime libraries or plugins.")
diff --git a/scripts/verify_release_assets.py b/scripts/verify_release_assets.py
new file mode 100644
index 000000000..ed164f2be
--- /dev/null
+++ b/scripts/verify_release_assets.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Reject incomplete or ambiguous full releases before publishing any assets."""
+
+import argparse
+from collections import defaultdict
+from pathlib import Path
+import re
+
+
+def required_asset_names(version):
+ if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version):
+ raise ValueError("Invalid release version")
+ names = [f"acecode-windows-{arch}.zip" for arch in ("x64", "arm64")]
+ names += [f"acecode-{platform}-{arch}.tar.gz"
+ for platform in ("linux", "linux-deepin") for arch in ("x64", "arm64", "armv7")]
+ for arch in ("x64", "arm64"):
+ names += [f"acecode-macos-{arch}.tar.gz",
+ f"acecode-{version}-linux-{arch}-update.zip",
+ f"ACECode-{version}-macos-{arch}-update.zip",
+ f"ACECode-{version}-macos-{arch}.dmg",
+ f"ACECode-{version}-macos-{arch}.pkg"]
+ return sorted(names)
+
+
+def verify_release_assets(directory, version):
+ expected = required_asset_names(version)
+ found = defaultdict(list)
+ for path in Path(directory).rglob("*"):
+ if not path.name.lower().endswith((".tar.gz", ".zip", ".pkg", ".dmg")):
+ continue
+ if path.is_symlink():
+ raise ValueError(f"Release assets must not be symlinks: {path}")
+ if path.is_file():
+ found[path.name].append(path)
+ errors = []
+ selected = []
+ for name in expected:
+ matches = found.get(name, [])
+ if len(matches) != 1:
+ errors.append(f"Expected exactly one {name}; found {len(matches)}")
+ elif matches[0].stat().st_size == 0:
+ errors.append(f"Empty release asset: {name}")
+ else:
+ selected.append(matches[0])
+ for name in sorted(found.keys() - set(expected)):
+ errors.append(f"Unexpected release asset: {name}")
+ if errors:
+ raise ValueError("\n".join(errors))
+ return selected
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("directory", type=Path)
+ parser.add_argument("version")
+ args = parser.parse_args()
+ try:
+ assets = verify_release_assets(args.directory, args.version)
+ except (OSError, ValueError) as error:
+ parser.exit(1, f"{error}\n")
+ print(f"Verified {len(assets)} required release assets, including Deepin and both macOS PKGs.")
diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp
index 5f8cb2e5d..b92a66f9a 100644
--- a/src/agent_loop.cpp
+++ b/src/agent_loop.cpp
@@ -1,6 +1,8 @@
#include "agent_loop.hpp"
#include "agent_loop_doom_guard.hpp"
#include "agent_loop_shell_guard.hpp"
+#include "computer_use/runtime.hpp"
+#include "desktop/workspace_registry.hpp"
#include "sandbox/exec_permission.hpp"
#include "prompt/context_usage_breakdown.hpp"
#include "prompt/system_prompt.hpp"
@@ -10,10 +12,13 @@
#include "utils/logger.hpp"
#include "utils/stream_processing.hpp"
#include "utils/text_file_buffer.hpp"
+#include "utils/tool_errors.hpp"
#include "utils/uuid.hpp"
#include "commands/compact.hpp"
+#include "commands/compact_prompt.hpp"
#include "session/compact_checkpoint.hpp"
#include "session/compact_notice.hpp"
+#include "session/system_notice.hpp"
#include "session/session_history_recovery.hpp"
#include "session/tool_metadata_codec.hpp"
#include "session/tool_result_storage.hpp"
@@ -43,6 +48,7 @@
#include "pa/pa_context_budget.hpp"
#include "pa/pa_overflow_rescue.hpp"
#include "pa/pa_quirks.hpp"
+#include "provider/text_tool_call_recovery.hpp"
#include
#include
#include
@@ -94,6 +100,9 @@ std::vector model_facing_provider_messages(
const std::vector& messages,
const char* boundary) {
auto history = recovered_provider_messages(messages, boundary);
+ // 旧的纯文本工具调用 / 被污染的摘要换成固定说明(只由内容决定、逐字节
+ // 稳定),模型不再照着历史里的样本继续写文本调用。
+ sanitize_text_tool_call_history(history, get_compact_summary_prefix());
rewrite_tool_calls_for_model(history);
return history;
}
@@ -215,6 +224,33 @@ nlohmann::json model_step_usage_to_json(const TokenUsage& usage) {
return value;
}
+void accumulate_turn_usage(TokenUsage& aggregate,
+ bool& initialized,
+ const TokenUsage& step) {
+ aggregate.prompt_tokens += step.prompt_tokens;
+ aggregate.completion_tokens += step.completion_tokens;
+ aggregate.total_tokens += step.total_tokens;
+ aggregate.cache_read_tokens += step.cache_read_tokens;
+ aggregate.cache_write_tokens += step.cache_write_tokens;
+ aggregate.reasoning_tokens += step.reasoning_tokens;
+
+ auto& total_context = aggregate.context_breakdown;
+ const auto& step_context = step.context_breakdown;
+ total_context.system_prompt += step_context.system_prompt;
+ total_context.project_rules += step_context.project_rules;
+ total_context.skills += step_context.skills;
+ total_context.builtin_tools += step_context.builtin_tools;
+ total_context.mcp_tools += step_context.mcp_tools;
+ total_context.conversation += step_context.conversation;
+ total_context.dynamic_context += step_context.dynamic_context;
+ total_context.has_data = total_context.has_data || step_context.has_data;
+
+ aggregate.has_data = initialized
+ ? aggregate.has_data && step.has_data
+ : step.has_data;
+ initialized = true;
+}
+
std::string provider_error_summary_for_log(const ProviderErrorInfo& info) {
std::string message = info.display_message;
if (message.empty()) message = info.pretty_json;
@@ -253,6 +289,48 @@ std::string format_bytes_detail(std::size_t bytes) {
return "参数 " + human_bytes(bytes);
}
+const char* text_tool_call_outcome_name(TextToolCallDiagnostic::Outcome outcome) {
+ switch (outcome) {
+ case TextToolCallDiagnostic::Outcome::Recovered: return "recovered";
+ case TextToolCallDiagnostic::Outcome::Rejected: return "rejected";
+ case TextToolCallDiagnostic::Outcome::IgnoredWithNative: return "ignored_with_native";
+ case TextToolCallDiagnostic::Outcome::None: break;
+ }
+ return "none";
+}
+
+// 文本工具调用诊断 → JSON(trajectory payload / 被拒消息 metadata 共用)。
+// raw_excerpt 只进这里(诊断用),绝不进发给模型的正文。
+nlohmann::json text_tool_call_diagnostic_to_json(const TextToolCallDiagnostic& diag) {
+ nlohmann::json out{
+ {"outcome", text_tool_call_outcome_name(diag.outcome)},
+ {"format", diag.format},
+ {"reason", diag.reason},
+ {"error", diag.error},
+ {"tools", diag.attempted_tools},
+ {"raw_excerpt", diag.raw_excerpt},
+ };
+ if (!diag.unexecuted_detail.empty()) out["unexecuted"] = diag.unexecuted_detail;
+ if (diag.recovered_count > 0) out["count"] = diag.recovered_count;
+ return out;
+}
+
+// 被拒文本工具调用的落盘正文:provider 已去掉的标记不再出现;可疑级(标记
+// 已经流出)截到 visible_cut;去掉末尾空白,只剩空白时清成空串 —— 切断
+// 「历史里越多文本调用样本、模型越模仿」的循环,headless 也不会把 "\n\n\n"
+// 当成最终回复。
+std::string text_tool_call_rejected_persisted_content(
+ const std::string& content, const TextToolCallDiagnostic& diag) {
+ std::string persisted = content;
+ if (diag.visible_cut != std::string::npos && diag.visible_cut < persisted.size()) {
+ persisted.resize(diag.visible_cut);
+ }
+ const auto last = persisted.find_last_not_of(" \t\r\n");
+ if (last == std::string::npos) return {};
+ persisted.resize(last + 1);
+ return persisted;
+}
+
std::string ascii_lower(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
return static_cast(std::tolower(c));
@@ -355,6 +433,25 @@ bool is_hidden_goal_context_message(const ChatMessage& msg) {
msg.metadata.value("hidden_goal_context", false);
}
+bool is_transcript_bookkeeping(const ChatMessage& message) {
+ return message.is_meta || is_file_checkpoint_message(message) ||
+ is_compact_checkpoint_message(message) ||
+ is_turn_timing_message(message) || is_turn_net_diff_message(message) ||
+ web::is_hidden_goal_context_message(message);
+}
+
+const ChatMessage* trailing_transcript_message(
+ const std::vector& messages, bool user_only = false) {
+ for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
+ // Match the transcript's invisible bookkeeping records. Never skip a
+ // visible non-user unless an explicit user-abort marker allows retry.
+ if (is_transcript_bookkeeping(*it)) continue;
+ if (user_only && it->role != "user") continue;
+ return &*it;
+ }
+ return nullptr;
+}
+
std::string escape_xml_text(const std::string& input) {
std::string out;
out.reserve(input.size());
@@ -477,7 +574,41 @@ void AgentLoop::set_session_manager(SessionManager* sm) {
events_.set_observer({});
return;
}
- events_.set_observer([sm](const SessionEvent& event) {
+ events_.set_observer([this, sm](const SessionEvent& event) {
+ switch (event.kind) {
+ case SessionEventKind::Message: {
+ const auto& payload = event.payload;
+ const auto metadata = payload.value("metadata", nlohmann::json::object());
+ if (!payload.value("is_meta", false) &&
+ !(metadata.is_object() && metadata.value("hidden_goal_context", false))) {
+ const auto role = payload.value("role", std::string{});
+ const bool user_abort = role == "system" && metadata.is_object() &&
+ metadata.value("transcript_only", false) &&
+ metadata.value("user_aborted", false);
+ live_transcript_tail_blocked_ = role != "user" && !user_abort;
+ }
+ break;
+ }
+ case SessionEventKind::Token:
+ case SessionEventKind::Reasoning:
+ if (!event.payload.value("text", std::string{}).empty()) {
+ live_transcript_tail_blocked_ = true;
+ }
+ break;
+ case SessionEventKind::ToolStart:
+ case SessionEventKind::ToolUpdate:
+ case SessionEventKind::ToolEnd:
+ case SessionEventKind::Error:
+ live_transcript_tail_blocked_ = true;
+ break;
+ case SessionEventKind::TranscriptReplace:
+ // A full replacement discards transient output. The canonical
+ // histories are still checked before any retry is accepted.
+ live_transcript_tail_blocked_ = false;
+ break;
+ default:
+ break;
+ }
if (!should_persist_trajectory_event(event)) return;
sm->record_trajectory_event(
to_string(event.kind), event.payload, event.timestamp_ms);
@@ -505,6 +636,76 @@ void AgentLoop::set_cwd(const std::string& new_cwd) {
sandbox_runtime_.clear_session_grants();
last_sandbox_violation_.reset();
reload_exec_rules();
+ // 进出 worktree 会改变写边界,可写附加文件夹随之重算。
+ sandbox_runtime_.set_workspace_writable_roots(writable_workspace_folders());
+}
+
+void AgentLoop::refresh_workspace_folders() {
+ // workspace.json 与会话文件同在 // 下。worktree 会话的 cwd_
+ // 是 worktree 路径,但会话存储目录不动,所以优先用 SessionManager 的 project dir。
+ std::string project_dir =
+ session_manager_ ? session_manager_->current_project_dir() : std::string{};
+ if (project_dir.empty()) project_dir = SessionStorage::get_project_dir(cwd_);
+ auto folders = desktop::load_workspace_folders(project_dir);
+ {
+ std::lock_guard lk(workspace_folders_mu_);
+ workspace_main_folder_ = std::move(folders.main_folder);
+ workspace_extra_folders_ = std::move(folders.extra_folders);
+ }
+ sandbox_runtime_.set_workspace_writable_roots(writable_workspace_folders());
+}
+
+std::vector AgentLoop::workspace_extra_folders() const {
+ std::lock_guard lk(workspace_folders_mu_);
+ return workspace_extra_folders_;
+}
+
+std::vector AgentLoop::writable_workspace_folders() const {
+ std::string main_folder;
+ std::vector extras;
+ {
+ std::lock_guard lk(workspace_folders_mu_);
+ main_folder = workspace_main_folder_;
+ extras = workspace_extra_folders_;
+ }
+ if (extras.empty() || main_folder.empty() || write_root().empty()) return extras;
+ // 写边界存在的意义是护住主 checkout。与主文件夹互为包含的附加文件夹
+ // (主仓的上级目录,或主仓里的子目录)一旦放行,worktree 隔离就被绕开了。
+ const auto contains = [](const std::string& root, const std::string& path) {
+ return PathValidator(root, false).validate(path).empty();
+ };
+ std::vector out;
+ for (const auto& folder : extras) {
+ if (contains(folder, main_folder) || contains(main_folder, folder)) continue;
+ out.push_back(folder);
+ }
+ return out;
+}
+
+bool AgentLoop::path_in_workspace_folders(const std::string& path) const {
+ if (path.empty()) return false;
+ const auto folders = writable_workspace_folders();
+ if (folders.empty()) return false;
+ std::filesystem::path target = path_from_utf8(path);
+ // 相对路径永远按会话 cwd 解析,不能拿附加文件夹当基准去"凑"出一个放行。
+ if (target.is_relative()) target = path_from_utf8(cwd_) / target;
+ const std::string absolute = path_to_utf8(target);
+ for (const auto& folder : folders) {
+ if (PathValidator(folder, false).validate(absolute).empty()) return true;
+ }
+ return false;
+}
+
+SystemPromptWorkspaceFolders AgentLoop::system_prompt_workspace_folders() const {
+ SystemPromptWorkspaceFolders result;
+ result.additional = writable_workspace_folders();
+ for (const auto& folder : workspace_extra_folders()) {
+ if (std::find(result.additional.begin(), result.additional.end(), folder) ==
+ result.additional.end()) {
+ result.read_only.push_back(folder);
+ }
+ }
+ return result;
}
std::string AgentLoop::global_exec_rules_dir() const {
@@ -827,7 +1028,8 @@ HookCommonPayloadFields AgentLoop::build_hook_common_fields(
void AgentLoop::apply_hook_side_effects(const HookAggregateOutcome& outcome,
bool include_additional_context) {
for (const auto& message : outcome.system_messages) {
- if (!message.empty()) dispatch_message("system", "[Hook] " + message, false);
+ if (!message.empty()) dispatch_message("system", "[Hook] " + message, false,
+ make_system_notice_metadata("hook_message", {{"text", message}}));
}
if (include_additional_context) {
for (const auto& context : outcome.additional_context) {
@@ -889,6 +1091,7 @@ void AgentLoop::dispatch_session_title_changed_hook(
void AgentLoop::abort() {
abort_requested_ = true;
+ if (session_manager_) computer_use::release_session(session_manager_->current_session_id());
wake_active_provider_retry();
}
@@ -972,9 +1175,28 @@ void AgentLoop::worker_main() {
worker_task_active_ = true;
worker_task_kind_ = task.kind;
}
+ if (task.kind == WorkerTask::Kind::Chat) {
+ active_turn_usage_ = TokenUsage{};
+ active_turn_usage_initialized_ = false;
+ }
try {
switch (task.kind) {
case WorkerTask::Kind::Chat:
+ if (!task.retry_user_message_id.empty()) {
+ const auto message = retryable_user_message(task.retry_user_message_id);
+ if (!message) {
+ throw std::runtime_error("user message is no longer eligible for retry");
+ }
+ UserInput input;
+ input.text = message->content;
+ input.content_parts = message->content_parts;
+ input.metadata = message->metadata;
+ if (message->metadata.is_object()) {
+ input.display_text = message->metadata.value("display_text", std::string{});
+ }
+ run_agent_with_input(input, false, &*message);
+ break;
+ }
if (task.input.empty() && !task.payload.empty()) {
task.input.text = std::move(task.payload);
task.input.display_text = std::move(task.display_text);
@@ -1039,9 +1261,15 @@ void AgentLoop::recover_worker_task_error(const char* detail, bool chat_task) {
attempt([&] {
if (chat_task && callbacks_.on_turn_finished) callbacks_.on_turn_finished("error");
});
- const nlohmann::json idle = {
+ nlohmann::json idle = {
{"busy", false}, {"outcome", "error"}, {"turn_id", turn_id}};
- const nlohmann::json done = {{"outcome", "error"}};
+ nlohmann::json done = {{"outcome", "error"}};
+ if (chat_task) {
+ const auto usage = model_step_usage_to_json(active_turn_usage_);
+ idle["usage"] = usage;
+ done["turn_id"] = turn_id;
+ done["usage"] = usage;
+ }
attempt([&] { record_terminal_trajectory_events(idle, done); });
attempt([&] {
if (callbacks_.on_busy_changed) callbacks_.on_busy_changed(false);
@@ -1189,7 +1417,8 @@ bool AgentLoop::complete_task_handoff(
if (paused_goal) emit_goal_updated(*paused_goal);
emit_transcript_system_message(
"Continued in session " + target_session_id + ".",
- {{"task_handoff", true}, {"target_session_id", target_session_id}});
+ make_system_notice_metadata("session_continued", {{"session", target_session_id}},
+ {{"task_handoff", true}, {"target_session_id", target_session_id}}));
return true;
}
@@ -1217,6 +1446,54 @@ void AgentLoop::submit(const UserInput& input) {
queue_cv_.notify_one();
}
+std::optional AgentLoop::retryable_user_message(
+ const std::string& expected_user_message_id) const {
+ if (expected_user_message_id.empty() || live_transcript_tail_blocked_.load()) return std::nullopt;
+ const auto* model_tail = trailing_transcript_message(messages_);
+ if (!model_tail || (model_tail->role != "user" &&
+ model_tail->role != "assistant" && model_tail->role != "tool")) return std::nullopt;
+ bool user_aborted = false;
+ if (session_manager_) {
+ const auto persisted = session_manager_->load_active_messages();
+ const auto* tail = trailing_transcript_message(persisted);
+ user_aborted = tail && tail->role == "system" && tail->metadata.is_object() &&
+ tail->metadata.value("transcript_only", false) &&
+ tail->metadata.value("user_aborted", false) &&
+ tail->metadata.value("retry_user_message_id", std::string{}) == expected_user_message_id;
+ if (user_aborted) tail = trailing_transcript_message(persisted, true);
+ if (!tail || tail->role != "user" ||
+ tail->uuid != expected_user_message_id) return std::nullopt;
+ }
+ const auto* message = trailing_transcript_message(messages_, user_aborted);
+ if (!message || message->role != "user" ||
+ message->uuid != expected_user_message_id) return std::nullopt;
+ return *message;
+}
+
+bool AgentLoop::retry_last_user_message(
+ const std::string& expected_user_message_id, std::string& error) {
+ {
+ std::lock_guard lock(queue_mu_);
+ if (shutdown_requested_ || worker_task_active_ || busy_.load() ||
+ !priority_task_queue_.empty() || !task_queue_.empty()) {
+ error = "session has active or queued work";
+ return false;
+ }
+ if (!retryable_user_message(expected_user_message_id)) {
+ error = "user message is not eligible for retry";
+ return false;
+ }
+ WorkerTask task;
+ task.kind = WorkerTask::Kind::Chat;
+ task.retry_user_message_id = expected_user_message_id;
+ task_queue_.push(std::move(task));
+ abort_requested_ = false;
+ }
+ error.clear();
+ queue_cv_.notify_one();
+ return true;
+}
+
ControlEnqueueReceipt AgentLoop::enqueue_control(
std::function control) {
ControlEnqueueReceipt receipt;
@@ -1545,8 +1822,8 @@ void AgentLoop::submit_compact() {
queue_cv_.notify_one();
}
-void AgentLoop::emit_system_message(const std::string& content) {
- dispatch_message("system", content, false);
+void AgentLoop::emit_system_message(const std::string& content, nlohmann::json metadata) {
+ dispatch_message("system", content, false, std::move(metadata));
}
void AgentLoop::emit_transcript_system_message(const std::string& content,
@@ -1643,7 +1920,8 @@ void AgentLoop::note_pa_context_rejection(int request_tokens) {
"[智能压缩] 服务端在约 " + std::to_string(request_tokens) +
" tokens (最大 " + std::to_string(declared) +
" tokens) 处拒收了请求,压缩阈值下调至 " + std::to_string(after) +
- " tokens");
+ " tokens", make_system_notice_metadata("context_threshold_lowered",
+ {{"tokens", request_tokens}, {"declared", declared}, {"threshold", after}}));
}
void AgentLoop::note_pa_context_accepted(
@@ -1689,13 +1967,15 @@ std::vector AgentLoop::build_compaction_initial_context() const {
acecode::environment::prompt_environment();
const SystemPromptSandboxState sandbox_state{sandbox_prompt_description()};
const SystemPromptModelState model_state = system_prompt_model_state();
+ const SystemPromptWorkspaceFolders workspace_folders_state = system_prompt_workspace_folders();
std::string system_prompt = build_system_prompt(
tools_, cwd_, skill_registry_, memory_registry_,
memory_cfg_, project_instructions_cfg_,
&tool_capability_policy_,
&worktree_state,
active_model_can_read_images(),
- &prompt_environment, &sandbox_state, &model_state);
+ &prompt_environment, &sandbox_state, &model_state,
+ &workspace_folders_state);
if (loop_execution_policy_.active &&
!loop_execution_policy_.system_context.empty()) {
system_prompt += "\n\n\n";
@@ -1836,7 +2116,7 @@ void AgentLoop::apply_compact_result(
make_compact_notice_metadata(notice_id, "checkpoint"));
emit_transcript_system_message(
"[Conversation summary]\n" + result.summary_text,
- make_compact_notice_metadata(notice_id, "summary", true));
+ make_compact_notice_metadata(notice_id, "summary", true, {{"summary", result.summary_text}}));
if (checkpoint_persisted) {
const auto project_dir = session_manager_->current_project_dir();
const auto source_id = session_manager_->current_session_id();
@@ -1905,7 +2185,9 @@ bool AgentLoop::run_mechanical_compact_fallback(
"),已改为丢弃最旧的 " + std::to_string(repair.pruned_groups) +
" 组历史、清除 " + std::to_string(repair.cleared_tool_outputs) +
" 条旧工具输出腾出空间,会话继续。",
- make_compact_notice_metadata(compact_notice_id, "warning"));
+ make_compact_notice_metadata(compact_notice_id, "warning", false,
+ {{"error", summarization_error}, {"groups", repair.pruned_groups},
+ {"outputs", repair.cleared_tool_outputs}}));
return true;
}
@@ -1932,7 +2214,8 @@ bool AgentLoop::maybe_run_auto_compact() {
auto outcome = dispatch_codex_hook(kCodexHookEventPreCompact, "auto", payload);
apply_hook_side_effects(outcome);
if (outcome.continue_false || outcome.blocked || outcome.denied) {
- emit_transcript_system_message("[Auto-compact] Stopped by hook.");
+ emit_transcript_system_message("[Auto-compact] Stopped by hook.",
+ make_system_notice_metadata("context_compact_stopped"));
return false;
}
}
@@ -1953,7 +2236,7 @@ bool AgentLoop::maybe_run_auto_compact() {
LOG_WARN("Auto-compact failed; provider unavailable");
emit_transcript_system_message(
"[Auto-compact] provider unavailable for compaction",
- make_compact_notice_metadata(compact_notice_id, "error"));
+ make_compact_notice_metadata(compact_notice_id, "error", false, {{"provider_unavailable", true}}));
return false;
}
@@ -1988,7 +2271,7 @@ bool AgentLoop::maybe_run_auto_compact() {
}
emit_transcript_system_message(
"[Auto-compact] " + result.error,
- make_compact_notice_metadata(compact_notice_id, "error"));
+ make_compact_notice_metadata(compact_notice_id, "error", false, {{"error", result.error}}));
return false;
}
@@ -2145,7 +2428,8 @@ void AgentLoop::account_goal_usage(std::int64_t token_delta, bool allow_complete
emit_goal_updated(*result.goal);
if (budget_notice_goal_id_ != result.goal->goal_id) {
budget_notice_goal_id_ = result.goal->goal_id;
- dispatch_message("system", "[Goal] Token budget reached; automatic continuation stopped.", false);
+ dispatch_message("system", "[Goal] Token budget reached; automatic continuation stopped.", false,
+ make_system_notice_metadata("goal_budget_reached", {{"goal", thread_goal_to_json(*result.goal)}}));
// 让运行中的回合在下一次模型请求前收到 wrap-up 提示(对齐 Codex
// budget_limit steering):总结进展、指出剩余工作,不再开新活。
pending_goal_budget_limit_steering_.store(true);
@@ -2415,7 +2699,7 @@ void AgentLoop::stop_active_goal_after_turn_error(const ProviderErrorInfo& info)
usage_limited
? "[Goal] Provider usage limit hit; goal marked usage_limited and automatic continuation stopped. Use /goal resume to continue later."
: "[Goal] Turn ended with an error; goal marked blocked and automatic continuation stopped. Use /goal resume to retry.",
- false);
+ false, make_system_notice_metadata(usage_limited ? "goal_usage_limited" : "goal_blocked"));
LOG_WARN("[goal] stopped active goal after turn error: status=" +
to_string(next) + " provider_status_code=" +
std::to_string(info.status_code));
@@ -2459,17 +2743,6 @@ void AgentLoop::begin_active_turn(const std::string& turn_id) {
active_turn_accepting_ = !turn_id.empty();
}
-std::string AgentLoop::abort_notice_text() const {
- return turn_interrupt_requested_.load() ? "[Interjected]" : "[Interrupted]";
-}
-
-nlohmann::json AgentLoop::abort_notice_metadata() const {
- if (!turn_interrupt_requested_.load()) return nlohmann::json{};
- return nlohmann::json{
- {"turn_interrupt", true},
- };
-}
-
void AgentLoop::append_interrupted_turn_context(const std::string& turn_id) {
ChatMessage marker;
marker.role = "user";
@@ -2512,6 +2785,7 @@ void AgentLoop::commit_turn_steering_input(
if (session_manager_) {
session_manager_->on_message(message);
}
+ emit_session_summary_updated();
const std::string display = message.metadata.value(
"display_text", message.content);
@@ -2674,6 +2948,21 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input,
if (!user_msg.metadata.is_object()) user_msg.metadata = nlohmann::json::object();
user_msg.metadata["hidden_goal_context"] = true;
}
+ append_user_turn_message(info, hidden_goal_context);
+ start_user_turn(info);
+ return info;
+}
+
+void AgentLoop::emit_session_summary_updated() {
+ if (!session_manager_) return;
+ const std::string summary = session_manager_->current_summary();
+ if (summary.empty()) return;
+ events_.emit(SessionEventKind::SessionUpdated,
+ nlohmann::json{{"summary", summary}});
+}
+
+void AgentLoop::append_user_turn_message(UserTurnInfo& info, bool hidden_goal_context) {
+ auto& user_msg = info.user_msg;
ensure_user_message_identity(user_msg);
info.active_turn_id = user_msg.uuid;
info.visible_timed_turn =
@@ -2684,21 +2973,14 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input,
messages_.push_back(user_msg);
if (session_manager_) {
session_manager_->on_message(user_msg);
- if (info.visible_timed_turn) {
- session_manager_->record_trajectory_event(
- "turn_start",
- {{"turn_id", info.active_turn_id},
- {"user_message_id", user_msg.uuid},
- {"started_at_ms", info.turn_started_at_ms}},
- info.turn_started_at_ms);
- }
if (!hidden_goal_context) {
session_manager_->begin_user_turn_checkpoint(user_msg.uuid);
}
}
if (!hidden_goal_context) {
+ emit_session_summary_updated();
nlohmann::json msg_event = {
- {"role", "user"}, {"content", model_user_message},
+ {"role", "user"}, {"content", user_msg.content},
{"is_tool", false}, {"id", user_msg.uuid},
};
if (!user_msg.content_parts.is_null() && user_msg.content_parts.is_array() &&
@@ -2710,8 +2992,48 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input,
}
events_.emit(SessionEventKind::Message, msg_event);
}
+}
+AgentLoop::UserTurnInfo AgentLoop::prepare_retry_user_turn(const ChatMessage& message) {
+ UserTurnInfo info;
+ info.user_msg = message;
+ info.turn_started_at_ms = now_epoch_ms();
+ const auto* tail = trailing_transcript_message(messages_);
+ if (tail && tail->role != "user") {
+ // An aborted turn may already contain assistant/tool output. Preserve
+ // it and append the original input with a fresh identity, without
+ // expanding skills or attachments for a second time.
+ info.user_msg.uuid.clear();
+ info.user_msg.timestamp.clear();
+ if (info.user_msg.metadata.is_object()) {
+ for (const auto* key : {"client_message_id", "turn_steer", "turn_id",
+ "turn_interrupt", "interrupted_turn_id"}) {
+ info.user_msg.metadata.erase(key);
+ }
+ }
+ append_user_turn_message(info, false);
+ start_user_turn(info);
+ return info;
+ }
+ info.active_turn_id = message.uuid;
+ info.visible_timed_turn = true;
+ info.turn_user_uuid = message.uuid;
+ // Preserve the original checkpoint and message. Re-expansion or a second
+ // on_message call would change the input or create adjacent user records.
+ start_user_turn(info);
+ return info;
+}
+
+void AgentLoop::start_user_turn(const UserTurnInfo& info) {
if (session_manager_) {
+ if (info.visible_timed_turn) {
+ session_manager_->record_trajectory_event(
+ "turn_start",
+ {{"turn_id", info.active_turn_id},
+ {"user_message_id", info.turn_user_uuid},
+ {"started_at_ms", info.turn_started_at_ms}},
+ info.turn_started_at_ms);
+ }
session_manager_->record_trajectory_event(
"busy_changed",
{{"busy", true}, {"turn_id", info.active_turn_id}});
@@ -2724,8 +3046,6 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input,
{"busy", true},
{"turn_id", info.active_turn_id},
});
-
- return info;
}
AgentLoop::ApiRequestBundle AgentLoop::build_api_request_messages(
@@ -2748,13 +3068,15 @@ AgentLoop::ApiRequestBundle AgentLoop::build_api_request_messages(
acecode::environment::prompt_environment();
const SystemPromptSandboxState sandbox_state{sandbox_prompt_description()};
const SystemPromptModelState model_state = system_prompt_model_state();
+ const SystemPromptWorkspaceFolders workspace_folders_state = system_prompt_workspace_folders();
std::string system_prompt = build_system_prompt(
tools_, cwd_, skill_registry_, memory_registry_,
memory_cfg_, project_instructions_cfg_,
&tool_capability_policy_,
&worktree_state,
active_model_can_read_images(),
- &prompt_environment, &sandbox_state, &model_state);
+ &prompt_environment, &sandbox_state, &model_state,
+ &workspace_folders_state);
if (loop_execution_policy_.active && !loop_execution_policy_.system_context.empty()) {
system_prompt += "\n\n\n";
system_prompt += loop_execution_policy_.system_context;
@@ -3101,6 +3423,139 @@ void AgentLoop::emit_retry_lifecycle(
opts);
}
+// ---------------------------------------------------------------------------
+// 工具前言(openspec add-tool-preamble)
+// ---------------------------------------------------------------------------
+
+void AgentLoop::set_tool_preamble_config(const ToolPreambleConfig& cfg) {
+ std::lock_guard lk(tool_preamble_mu_);
+ tool_preamble_cfg_ = cfg;
+}
+
+ToolPreambleConfig AgentLoop::tool_preamble_config() const {
+ std::lock_guard lk(tool_preamble_mu_);
+ return tool_preamble_cfg_;
+}
+
+bool AgentLoop::concrete_activity_enabled() const {
+ return tool_preamble_config().enabled;
+}
+
+void AgentLoop::set_phase_preamble(const ToolPreambleTitle& preamble) {
+ std::lock_guard lk(tool_preamble_mu_);
+ phase_preamble_ = preamble;
+}
+
+AgentLoop::ToolPreambleTitle AgentLoop::phase_preamble() const {
+ std::lock_guard lk(tool_preamble_mu_);
+ return phase_preamble_;
+}
+
+void AgentLoop::publish_phase_preamble(const ToolPreambleTitle& preamble,
+ const ProgressEmitter& emit_progress) {
+ if (preamble.title.empty()) return;
+ set_phase_preamble(preamble);
+ // 独立的 phase 键 + force,绕开进度节流:标题一出现 loading 就换文案,
+ // 不用等下一条 reasoning / tool_planning。
+ emit_progress("preamble", preamble.title, std::string{},
+ std::string{}, std::string{}, -1, true);
+}
+
+void AgentLoop::note_planned_tool(int tool_index, const std::string& native_name) {
+ if (tool_index < 0 || native_name.empty()) return;
+ std::lock_guard lk(tool_preamble_mu_);
+ const auto index = static_cast(tool_index);
+ if (step_planned_tools_.size() <= index) step_planned_tools_.resize(index + 1);
+ step_planned_tools_[index] = native_name;
+}
+
+void AgentLoop::reset_activity_for_step() {
+ std::lock_guard lk(tool_preamble_mu_);
+ phase_preamble_ = {};
+ step_planned_tools_.clear();
+ current_batch_activity_ = {};
+}
+
+void AgentLoop::reset_activity_for_turn() {
+ std::lock_guard lk(tool_preamble_mu_);
+ phase_preamble_ = {};
+ step_planned_tools_.clear();
+ current_batch_activity_ = {};
+ last_batch_tools_.clear();
+ last_announced_activity_.clear();
+}
+
+void AgentLoop::announce_activity(const std::string& label) {
+ if (label.empty()) return;
+ {
+ std::lock_guard lk(tool_preamble_mu_);
+ if (label == last_announced_activity_) return;
+ last_announced_activity_ = label;
+ }
+ if (callbacks_.on_thinking_title) callbacks_.on_thinking_title(label);
+}
+
+AgentLoop::ToolPreambleTitle AgentLoop::concrete_activity_for_phase(
+ const std::string& phase) const {
+ if (!concrete_activity_enabled()) return {};
+ std::lock_guard lk(tool_preamble_mu_);
+ if (phase == "responding") {
+ return {tool_preamble::kRespondingActivityLabel, tool_preamble::kSourceContext, ""};
+ }
+ const bool tool_phase = phase == "tool_running" || phase == "tool_planning";
+ const bool waiting_phase =
+ phase == "model_waiting" || phase == "reasoning" || phase == "preamble";
+ if (!tool_phase && !waiting_phase) return {};
+ if (phase == "tool_running" && !current_batch_activity_.title.empty()) {
+ return current_batch_activity_;
+ }
+ if (tool_phase) {
+ std::vector planned;
+ for (const auto& name : step_planned_tools_) {
+ if (!name.empty()) planned.push_back(name);
+ }
+ if (!phase_preamble_.title.empty()) {
+ ToolPreambleTitle out = phase_preamble_;
+ out.kind = tool_preamble::batch_activity_kind(planned);
+ return out;
+ }
+ if (!planned.empty()) {
+ return {tool_preamble::batch_activity_label(planned),
+ tool_preamble::kSourceTemplate,
+ tool_preamble::batch_activity_kind(planned)};
+ }
+ }
+ if (!phase_preamble_.title.empty()) return phase_preamble_;
+ if (last_batch_tools_.empty()) {
+ return {tool_preamble::kInitialActivityLabel, tool_preamble::kSourceContext, ""};
+ }
+ return {tool_preamble::after_batch_activity_label(last_batch_tools_),
+ tool_preamble::kSourceContext, ""};
+}
+
+AgentLoop::ToolPreambleTitle AgentLoop::resolve_tool_preamble_for_step(
+ ProviderCallResult& result) {
+ const ChatResponse& accumulated = result.accumulated;
+ if (!concrete_activity_enabled() || accumulated.tool_calls.empty()) return {};
+ std::vector names;
+ names.reserve(accumulated.tool_calls.size());
+ for (const auto& tc : accumulated.tool_calls) names.push_back(tc.function_name);
+ ToolPreambleTitle out;
+ out.kind = tool_preamble::batch_activity_kind(names);
+ const ToolPreambleTitle title = phase_preamble();
+ if (!title.title.empty()) {
+ out.title = title.title;
+ out.source = tool_preamble::kSourceReasoning;
+ } else {
+ out.title = tool_preamble::batch_activity_label(names);
+ out.source = tool_preamble::kSourceTemplate;
+ }
+ std::lock_guard lk(tool_preamble_mu_);
+ last_batch_tools_ = std::move(names);
+ current_batch_activity_ = out;
+ return out;
+}
+
AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
const std::shared_ptr& provider,
const ApiRequestBundle& bundle,
@@ -3116,10 +3571,40 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
int provider_attempt = 1;
bool first_output_recorded = false;
+ // 具体进度提示(add-tool-preamble):本次调用期间的开关快照。开启时在推理流
+ // 里抠第一对加粗标题作本步文案;正文开始流出时 loading 换成「正在撰写回复」。
+ // 每次调用(含重试)都从干净的本步状态开始。
+ const bool concrete = concrete_activity_enabled();
+ bool reasoning_title_found = false;
+ bool step_text_started = false;
+ reset_activity_for_step();
+ text_preamble_scanner_.reset();
+ // 正文增量的统一出口:可见文本给 TUI / Web。
+ auto publish_visible_text = [&](const std::string& text) {
+ if (text.empty()) return;
+ if (concrete && !step_text_started &&
+ text.find_first_not_of(" \t\r\n") != std::string::npos) {
+ step_text_started = true;
+ emit_progress("responding", tool_preamble::kRespondingActivityLabel,
+ std::string{}, std::string{}, std::string{}, -1, true);
+ }
+ if (callbacks_.on_delta) {
+ callbacks_.on_delta(text);
+ }
+ events_.emit(SessionEventKind::Token, nlohmann::json{{"text", text}});
+ };
+ // 历史里残留的 标签(前一版要求模型打标签)总是从可见正文里
+ // 剥掉,不再当 loading 文案。
+ auto publish_scanned = [&](tool_preamble::TextPreambleScanner::Output out) {
+ publish_visible_text(out.visible);
+ };
+
auto stream_callback = [&result, &resp_mu, &emit_progress, &bundle,
&reasoning_bytes, &reasoning_fragments,
&provider_attempt, &first_output_recorded,
- model_step_index,
+ &reasoning_title_found, &step_text_started,
+ &publish_scanned,
+ model_step_index, concrete,
this](const StreamEvent& evt) {
switch (evt.type) {
case StreamEventType::Delta:
@@ -3135,12 +3620,11 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
}
{
std::lock_guard lk(resp_mu);
+ // 落盘正文保留标签原文(模型会模仿自己的历史输出,剥掉历史
+ // 反而让它几轮后忘记格式);只有下发给界面的 token 流是剥过的。
result.accumulated.content += evt.content;
}
- if (callbacks_.on_delta) {
- callbacks_.on_delta(evt.content);
- }
- events_.emit(SessionEventKind::Token, nlohmann::json{{"text", evt.content}});
+ publish_scanned(text_preamble_scanner_.feed(evt.content));
break;
case StreamEventType::ReasoningDelta:
if (!evt.content.empty() && !first_output_recorded) {
@@ -3159,6 +3643,27 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
}
reasoning_bytes += evt.content.size();
reasoning_fragments++;
+ if (concrete && !reasoning_title_found) {
+ std::string reasoning_so_far;
+ {
+ std::lock_guard lk(resp_mu);
+ reasoning_so_far = result.accumulated.reasoning_content;
+ }
+ const std::string bold =
+ tool_preamble::extract_first_bold_span(reasoning_so_far);
+ if (!bold.empty()) {
+ const std::string title = tool_preamble::normalize_title_line(
+ bold, tool_preamble::kReasoningTitleMaxCodePoints);
+ if (!title.empty()) {
+ reasoning_title_found = true;
+ ToolPreambleTitle found;
+ found.title = title;
+ found.source = tool_preamble::kSourceReasoning;
+ publish_phase_preamble(found, emit_progress);
+ }
+ }
+ }
+ // 开启具体进度提示时,发射口会把这条换成本步标题或场景文案。
emit_progress("reasoning", "正在推理",
"片段 " + std::to_string(reasoning_fragments) + ", " +
human_bytes(reasoning_bytes),
@@ -3166,6 +3671,16 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
events_.emit(SessionEventKind::Reasoning, nlohmann::json{{"text", evt.content}});
break;
case StreamEventType::ToolCallDelta:
+ if (evt.text_tool_call_hold) {
+ // provider 正在扣住一段疑似文本工具调用:只是进度提示,不代表
+ // 模型已经开始输出原生调用,所以不记 model_first_output 的
+ // channel(等真正的正文或调用出现时再记),也不走工具名解析 /
+ // 前言抽取(tool_index=-1、工具名为空)。
+ emit_progress("tool_planning", "正在准备工具调用",
+ human_bytes(evt.tool_call_argument_bytes),
+ std::string{}, std::string{}, -1, false);
+ break;
+ }
if (!first_output_recorded) {
first_output_recorded = true;
if (session_manager_) {
@@ -3183,6 +3698,8 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
const std::string label = tool_name.empty()
? "正在准备工具调用"
: "正在准备调用 " + tool_name;
+ // 具体进度提示:记下本步流出来的工具,发射口据此拼「正在读取 2 个文件」。
+ note_planned_tool(evt.tool_index, tool_name);
emit_progress("tool_planning", label,
format_bytes_detail(evt.tool_call_argument_bytes),
tool_name, evt.tool_call.id, evt.tool_index, false);
@@ -3204,8 +3721,10 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
native_call.function_name =
tools_.resolve_model_tool_name_to_native(
native_call.function_name);
- std::lock_guard lk(resp_mu);
- result.accumulated.tool_calls.push_back(std::move(native_call));
+ {
+ std::lock_guard lk(resp_mu);
+ result.accumulated.tool_calls.push_back(std::move(native_call));
+ }
}
break;
case StreamEventType::Done: {
@@ -3218,6 +3737,9 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
if (evt.content_parts.is_array() && !evt.content_parts.empty()) {
result.accumulated.content_parts = evt.content_parts;
}
+ // 文本形式工具调用的诊断(fix-feedback-0924 第 3 条):主循环据此
+ // 决定是否注入纠正提示重试,而不是把被拒的回复当纯文本静默结束。
+ result.accumulated.text_tool_calls = evt.text_tool_calls;
break;
}
case StreamEventType::Usage: {
@@ -3248,6 +3770,10 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
reasoning_fragments = 0;
++provider_attempt;
first_output_recorded = false;
+ reasoning_title_found = false;
+ step_text_started = false;
+ reset_activity_for_step();
+ text_preamble_scanner_.reset();
if (callbacks_.on_stream_retry_reset) {
callbacks_.on_stream_retry_reset();
}
@@ -3297,6 +3823,9 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
provider->chat_stream(bundle.messages_with_system, bundle.tool_defs,
stream_callback, &abort_requested_);
clear_active_provider_for_retry(provider);
+ // 流结束:把扫描器扣住的字节结清(半截开标签按正文放行,没闭合的标签
+ // 正文仍算前言)。
+ publish_scanned(text_preamble_scanner_.flush());
LOG_INFO("chat_stream returned. content_len=" +
std::to_string(result.accumulated.content.size()) +
" tool_calls=" + std::to_string(result.accumulated.tool_calls.size()));
@@ -3324,6 +3853,10 @@ AgentLoop::ProviderCallResult AgentLoop::call_provider_and_collect(
if (!result.provider_error_seen &&
!abort_requested_.load() &&
final_usage.has_data) {
+ // Record at the same boundary as live accounting, before consumers
+ // can throw and transfer control to worker recovery.
+ accumulate_turn_usage(
+ active_turn_usage_, active_turn_usage_initialized_, final_usage);
last_api_total_tokens_.store(
final_usage.total_tokens > 0
? final_usage.total_tokens
@@ -3583,19 +4116,19 @@ AgentLoop::HandleErrorResult AgentLoop::run_pa_overflow_rescue(
if (!waiting_for_recovery && state.same_request_retries == 1) {
emit_transcript_system_message(
"[智能压缩] 服务端报「请求上下文过大」,先原样重发确认"
- "是否为瞬时故障;确认拒收后才会收缩历史。");
+ "是否为瞬时故障;确认拒收后才会收缩历史。",
+ make_system_notice_metadata("context_retrying"));
} else if (waiting_for_recovery && state.wait_retries == 1) {
emit_transcript_system_message(
"[智能压缩] 请求已缩到最小仍被服务端拒收;将按 5 秒起、"
"最长 60 秒的间隔反复重试(最多 " +
std::to_string(pa::PA_RESCUE_MAX_WAIT_RETRIES) +
- " 次),可随时停止。");
+ " 次),可随时停止。", make_system_notice_metadata("context_waiting",
+ {{"attempts", pa::PA_RESCUE_MAX_WAIT_RETRIES}}));
}
emit_pa_rescue_wait_progress(
error, plan, attempt, max_attempts, true);
if (!wait_for_pa_rescue_delay(plan.wait_ms)) {
- dispatch_message("system", abort_notice_text(), false,
- abort_notice_metadata());
return HandleErrorResult::Break;
}
emit_pa_rescue_wait_progress(
@@ -3645,7 +4178,9 @@ AgentLoop::HandleErrorResult AgentLoop::run_pa_overflow_rescue(
" 次收缩):已丢弃最旧的 " +
std::to_string(repair.pruned_groups) + " 组历史、清除 " +
std::to_string(repair.cleared_tool_outputs) +
- " 条旧工具输出后重试。");
+ " 条旧工具输出后重试。", make_system_notice_metadata("context_history_pruned",
+ {{"round", state.shrink_rounds}, {"groups", repair.pruned_groups},
+ {"outputs", repair.cleared_tool_outputs}}));
skip_auto_compact_once_ = true;
return HandleErrorResult::Continue;
}
@@ -3659,7 +4194,8 @@ AgentLoop::HandleErrorResult AgentLoop::run_pa_overflow_rescue(
{"label", plan.label},
{"detail", "去掉工具定义与注入上下文,仅保留核心工具"},
});
- emit_transcript_system_message("[智能压缩] " + plan.label + "。");
+ emit_transcript_system_message("[智能压缩] " + plan.label + "。",
+ make_system_notice_metadata("context_emergency"));
skip_auto_compact_once_ = true;
return HandleErrorResult::Continue;
}
@@ -3793,6 +4329,37 @@ bool AgentLoop::execute_tool_calls(
std::string& turn_timing_status) {
// Record the assistant message with tool_calls in the history
auto tc_msg = ToolExecutor::format_assistant_tool_calls(accumulated);
+ // 文本工具调用恢复成功:消息本体与原生调用字节级同形(不会发给模型的
+ // metadata 只多一个诊断字段),让后续历史里出现原生调用可供模仿。
+ if (accumulated.text_tool_calls.outcome ==
+ TextToolCallDiagnostic::Outcome::Recovered) {
+ if (!tc_msg.metadata.is_object()) tc_msg.metadata = nlohmann::json::object();
+ tc_msg.metadata["text_tool_call_recovery"] = {
+ {"format", accumulated.text_tool_calls.format},
+ {"count", accumulated.text_tool_calls.recovered_count > 0
+ ? accumulated.text_tool_calls.recovered_count
+ : static_cast(accumulated.tool_calls.size())},
+ };
+ }
+ // 工具前言(add-tool-preamble):本批次沿用的阶段前言挂在这条
+ // assistant(tool_calls) 消息的 metadata 上落盘(只为记录,不还原任何显示行),
+ // 实时界面经每个调用的 tool_start.preamble 拿到它。
+ const ToolPreambleTitle step_preamble = std::move(current_step_preamble_);
+ current_step_preamble_ = {};
+ nlohmann::json preamble_metadata;
+ if (!step_preamble.title.empty()) {
+ preamble_metadata = {
+ {"source", step_preamble.source},
+ {"title", step_preamble.title},
+ {"kind", step_preamble.kind},
+ };
+ if (!tc_msg.metadata.is_object()) tc_msg.metadata = nlohmann::json::object();
+ tc_msg.metadata[tool_preamble::kMetadataKey] = preamble_metadata;
+ }
+ // 单个调用的前言 = 本批次的阶段前言。
+ auto preamble_for_call = [&step_preamble](const ToolCall&, std::size_t) {
+ return step_preamble.title;
+ };
messages_.push_back(tc_msg);
if (session_manager_) session_manager_->on_message(tc_msg);
dispatch_assistant_completed_hook(tc_msg, provider_snapshot);
@@ -3804,19 +4371,29 @@ bool AgentLoop::execute_tool_calls(
// 自愈(磁盘已落全量,所以切会话重载才恢复)。这里补发一条 assistant 文本的
// Message 事件让前端用完整文本整体替换草稿。仅走 web 的 events_,不经
// dispatch_message 的 on_message 回调,避免改变 TUI 的流式渲染行为。
- if (!accumulated.content.empty()) {
+ // 工具前言:正文里的 标签不进界面(id 仍按落盘原文算,与
+ // GET /messages 重读一致);整段都是标签时不发这条帧。
+ const std::string visible_content =
+ tool_preamble::strip_text_preamble_tags(accumulated.content);
+ const bool has_content_parts =
+ accumulated.content_parts.is_array() && !accumulated.content_parts.empty();
+ if (!visible_content.empty() || has_content_parts) {
ChatMessage id_basis;
id_basis.role = "assistant";
id_basis.content = accumulated.content;
nlohmann::json assistant_event = {
{"role", "assistant"},
- {"content", accumulated.content},
+ {"content", visible_content},
{"is_tool", false},
{"id", web::compute_message_id(id_basis)},
};
- if (accumulated.content_parts.is_array() && !accumulated.content_parts.empty()) {
+ if (has_content_parts) {
assistant_event["content_parts"] = accumulated.content_parts;
}
+ if (preamble_metadata.is_object()) {
+ assistant_event["metadata"] = {
+ {tool_preamble::kMetadataKey, preamble_metadata}};
+ }
events_.emit(SessionEventKind::Message, std::move(assistant_event));
}
@@ -3834,7 +4411,7 @@ bool AgentLoop::execute_tool_calls(
const auto& tc = accumulated.tool_calls[i];
bool ro = tools_.is_read_only(tc.function_name);
ToolCallEntry entry{i, &tc, ro};
- if (ro) {
+ if (tools_.can_execute_in_parallel(tc.function_name)) {
read_entries.push_back(entry);
} else {
write_entries.push_back(entry);
@@ -3911,16 +4488,18 @@ bool AgentLoop::execute_tool_calls(
tool_name != "create_workspace") {
const std::string boundary_error =
PathValidator(boundary_root, false).validate(path);
- if (!boundary_error.empty()) {
+ if (!boundary_error.empty() && !path_in_workspace_folders(path)) {
return "Write boundary blocked: " + path +
" is outside the session write root " + boundary_root +
". Reads may go anywhere, but every write must stay inside "
"the worktree / execution root.";
}
}
- return is_cwd_validation_exempt(tool_name, path, boundary_root)
- ? std::string{}
- : path_validator_.validate(path);
+ if (is_cwd_validation_exempt(tool_name, path, boundary_root)) return {};
+ std::string cwd_error = path_validator_.validate(path);
+ // 「编辑项目」的附加文件夹与工作目录同等对待。
+ if (!cwd_error.empty() && path_in_workspace_folders(path)) return {};
+ return cwd_error;
};
// Helper: execute a single tool (for both parallel and serial use).
@@ -3949,7 +4528,10 @@ bool AgentLoop::execute_tool_calls(
}
} else {
LOG_WARN("Unknown tool: " + tool_name);
- return ToolResult{"Unknown tool: " + tool_name, false};
+ return ToolResult{
+ ToolErrors::unknown_tool(tool_name,
+ current_request_model_tool_names_),
+ false};
}
};
@@ -3983,7 +4565,9 @@ bool AgentLoop::execute_tool_calls(
project_dir,
session_id,
[this](const std::string& path) {
- return path_validator_.validate(path);
+ std::string error = path_validator_.validate(path);
+ if (!error.empty() && path_in_workspace_folders(path)) error.clear();
+ return error;
},
cwd_);
result.attachments = std::move(materialized.attachments);
@@ -4068,6 +4652,7 @@ bool AgentLoop::execute_tool_calls(
auto tool_start_tp = std::chrono::steady_clock::now();
const std::int64_t tool_started_at_ms = now_epoch_ms();
const int tool_index_int = static_cast(tool_index);
+ const std::string call_preamble = preamble_for_call(tc, tool_index);
{
nlohmann::json args_payload;
@@ -4078,10 +4663,17 @@ bool AgentLoop::execute_tool_calls(
cmd_preview, display_override,
is_task_complete, tc.id, tool_index_int);
start_payload["started_at_ms"] = tool_started_at_ms;
+ // 工具前言:这次调用的前言随 tool_start 下发,工具行 / loading 直接用。
+ if (!call_preamble.empty()) {
+ start_payload["preamble"] = call_preamble;
+ start_payload["preamble_source"] = step_preamble.source;
+ start_payload["preamble_kind"] = step_preamble.kind;
+ }
events_.emit(
SessionEventKind::ToolStart, std::move(start_payload));
}
+ // 开启具体进度提示时,发射口把这条换成本批次文案、清掉命令预览。
emit_progress("tool_running", "正在调用工具 " + tc.function_name,
cmd_preview, tc.function_name, tc.id, tool_index_int, true);
@@ -4227,7 +4819,7 @@ bool AgentLoop::execute_tool_calls(
};
ProgressGuard guard;
if (emit_tui_progress && callbacks_.on_tool_progress_start) {
- callbacks_.on_tool_progress_start(tc.function_name, cmd_preview);
+ callbacks_.on_tool_progress_start(tc.function_name, cmd_preview, std::string{});
guard.end_cb = callbacks_.on_tool_progress_end;
}
@@ -4636,12 +5228,14 @@ bool AgentLoop::execute_tool_calls(
bool auto_allow = true;
for (const auto& rule_path : rule_paths) {
if (!permissions_.should_auto_allow(
- effective_tc.function_name, false, rule_path, ctx_command)) {
+ effective_tc.function_name,
+ tools_.is_read_only(effective_tc.function_name), rule_path, ctx_command)) {
auto_allow = false;
}
}
if (permissions_.mode() == PermissionMode::Plan) {
- auto_allow = targets_active_plan_file || effective_tc.function_name == "TodoWrite";
+ auto_allow = tools_.is_read_only(effective_tc.function_name) ||
+ targets_active_plan_file || effective_tc.function_name == "TodoWrite";
}
if (effective_tc.function_name == "ExitPlanMode" &&
permissions_.mode() != PermissionMode::Plan) {
@@ -4665,7 +5259,8 @@ bool AgentLoop::execute_tool_calls(
permissions_.mode() == PermissionMode::Yolo &&
!permissions_.is_dangerous()) {
const std::string boundary_rejection =
- loop_shell_write_escape_reason(ctx_command, boundary_root);
+ loop_shell_write_escape_reason(ctx_command, boundary_root,
+ writable_workspace_folders());
if (!boundary_rejection.empty()) {
audit_gate(security::kAuditDecisionForbidden, security::kAuditSourceRule, "write_boundary");
return ToolResult{"[Error] " + boundary_rejection, false};
@@ -4887,7 +5482,8 @@ bool AgentLoop::execute_tool_calls(
: security::kAuditDecisionAllow,
security::kAuditSourceUser,
exec_permission ? exec_permission->decision.reason : "confirmation");
- emit_progress("tool_running", "正在调用工具 " + effective_tc.function_name,
+ emit_progress("tool_running",
+ "正在调用工具 " + effective_tc.function_name,
effective_tc.function_name, effective_tc.function_name, effective_tc.id,
static_cast(entry.original_index), true);
if (perm == PermissionResult::AllowScoped) {
@@ -5211,11 +5807,21 @@ bool AgentLoop::execute_tool_calls(
}
void AgentLoop::run_agent_with_input(const UserInput& input,
- bool hidden_goal_context) {
+ bool hidden_goal_context,
+ const ChatMessage* retry_message) {
+ // Capture the owner before callbacks can switch/delete the active session.
+ // RAII also releases on exceptions and early hook returns.
+ struct DesktopTurnLease {
+ std::string owner;
+ ~DesktopTurnLease() { computer_use::release_session(owner); }
+ } desktop_turn_lease{session_manager_ ? session_manager_->current_session_id() : std::string{}};
{
std::lock_guard lock(sandbox_prompt_mutex_);
sandbox_prompt_snapshot_.reset();
}
+ // 「编辑项目」保存的附加文件夹:每回合开头重读,放在沙盒描述快照之前,
+ // 本回合的系统提示与可写根一致且回合内不变(prompt cache 前缀稳定)。
+ refresh_workspace_folders();
abort_requested_ = false;
turn_interrupt_requested_ = false;
busy_ = true;
@@ -5248,16 +5854,25 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
if (callbacks_.on_turn_finished) {
callbacks_.on_turn_finished("error");
}
- record_terminal_trajectory_events(
- {{"busy", false}, {"outcome", "error"}},
- {{"outcome", "error"}});
+ const std::string turn_id = generate_uuid();
+ const auto usage = model_step_usage_to_json(active_turn_usage_);
+ const nlohmann::json idle = {
+ {"busy", false},
+ {"outcome", "error"},
+ {"turn_id", turn_id},
+ {"usage", usage},
+ };
+ const nlohmann::json done = {
+ {"outcome", "error"},
+ {"turn_id", turn_id},
+ {"usage", usage},
+ };
+ record_terminal_trajectory_events(idle, done);
if (callbacks_.on_busy_changed) callbacks_.on_busy_changed(false);
record_turn_outcome("error");
busy_ = false;
- events_.emit(SessionEventKind::BusyChanged, nlohmann::json{
- {"busy", false}, {"outcome", "error"}});
- events_.emit(SessionEventKind::Done, nlohmann::json{
- {"outcome", "error"}});
+ events_.emit(SessionEventKind::BusyChanged, idle);
+ events_.emit(SessionEventKind::Done, done);
maybe_continue_goal();
return;
}
@@ -5277,12 +5892,15 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
// already-recorded history. Persisting first would put the new request into
// the summary and append the checkpoint after the input, breaking replay.
bool preturn_compaction_failed = false;
- if (active_estimate_exceeds_auto_threshold(&input)) {
+ if (!retry_message && active_estimate_exceeds_auto_threshold(&input)) {
preturn_compaction_failed = !maybe_run_auto_compact();
}
// Phase 1: Build and persist user message after the pre-turn compact attempt.
- auto turn_info = prepare_user_turn(input, hidden_goal_context);
+ auto turn_info = retry_message
+ ? prepare_retry_user_turn(*retry_message)
+ : prepare_user_turn(input, hidden_goal_context);
+ if (session_manager_) desktop_turn_lease.owner = session_manager_->current_session_id();
std::string turn_timing_status = "completed";
if (preturn_compaction_failed) {
turn_timing_status = "error";
@@ -5297,6 +5915,7 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
bool emergency_request_profile = false;
pa_rescue_state_ = pa::RescueState{};
skip_auto_compact_once_ = false;
+ reset_activity_for_turn();
const int max_iter = loop_cfg_.max_iterations;
const bool has_max_iterations = max_iter > 0;
@@ -5308,6 +5927,14 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
// 一旦某轮产出有效输出(文本或工具调用)即清零。
constexpr int kMaxEmptyResponseRetries = 2;
int empty_response_retries = 0;
+ // 文本形式工具调用纠正(fix-feedback-0924 第 3 条):模型把调用写进正文、
+ // provider 认出了意图却无法执行(非法名 / 参数 / 截断 / 块前有正文 / 可疑
+ // 标记)时,注入隐藏纠正提示重试。按连续次数计,某一步产出工具调用即清零;
+ // XML / JSON 形态上限 2 次(与空回复一致,每次都是带全量工具表的整段请求),
+ // DSML 只 1 次(那是网关把 DeepSeek 原生协议漏进了正文,纠正文本作用有限)。
+ constexpr int kMaxTextToolCallCorrections = 2;
+ constexpr int kMaxDsmlToolCallCorrections = 1;
+ int text_tool_call_corrections = 0;
AgentLoopDoomGuard doom_guard;
std::mutex doom_guard_mu;
int observed_compact_generation = compact_generation_.load(std::memory_order_relaxed);
@@ -5337,6 +5964,17 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
bool force = false) {
const auto now = std::chrono::steady_clock::now();
const std::string key = phase + "\0" + tool + "\0" + tool_call_id + "\0" + std::to_string(tool_index);
+ // 具体进度提示(add-tool-preamble,「适合日常工作」):开启时 loading 只说正在
+ // 做什么、不带参数 —— 等待 / 推理 / 准备调用 / 执行 / 撰写回复这几类 phase 的
+ // 文案在这里统一换成 推理加粗标题 > 工具模板 > 场景文案,detail(命令预览、
+ // 字节数、片段计数)一律清空;权限 / 提问 / 压缩 / 重试这些必须被看见的状态
+ // 不换。批次之间的 model_waiting 也换(「正在分析文件内容」),否则 Web 实时行
+ // 会在具体文案与「正在等待模型响应」之间闪动。
+ const ToolPreambleTitle preamble = concrete_activity_for_phase(phase);
+ const bool concrete_label = !preamble.title.empty();
+ const std::string effective_label = concrete_label ? preamble.title : label;
+ const std::string effective_detail = concrete_label ? std::string{} : detail;
+ if (concrete_label) announce_activity(preamble.title);
nlohmann::json payload;
{
std::lock_guard lk(progress_mu);
@@ -5351,9 +5989,18 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
}
last_progress_emit_at = now;
payload = build_agent_progress_payload(
- phase, label, detail, tool, tool_call_id, tool_index,
+ phase, effective_label, effective_detail, tool, tool_call_id, tool_index,
active_progress_started_at_ms);
}
+ // 具体文案随进度帧透传,界面据此区分它与普通阶段文案;kind 给以后的
+ // 读 / 写效果留位。
+ if (concrete_label) {
+ payload["preamble"] = {
+ {"title", preamble.title},
+ {"source", preamble.source},
+ {"kind", preamble.kind},
+ };
+ }
EventDispatcher::EmitOptions opts;
opts.buffered = true;
opts.coalesce_key = "agent_progress";
@@ -5489,6 +6136,11 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
payload["error"] = provider_error_to_json(
result.provider_error_info);
}
+ if (result.accumulated.text_tool_calls.outcome !=
+ TextToolCallDiagnostic::Outcome::None) {
+ payload["text_tool_calls"] = text_tool_call_diagnostic_to_json(
+ result.accumulated.text_tool_calls);
+ }
if (!result.accumulated.content.empty()) {
ChatMessage id_basis;
id_basis.role = "assistant";
@@ -5513,8 +6165,6 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
if (abort_requested_) {
LOG_WARN("Abort requested, breaking loop");
- dispatch_message(
- "system", abort_notice_text(), false, abort_notice_metadata());
break;
}
@@ -5544,6 +6194,11 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
// Phase 2: Build API request messages
auto bundle = build_api_request_messages(emergency_request_profile);
publish_side_question_context(bundle.messages_with_system);
+ current_request_model_tool_names_.clear();
+ current_request_model_tool_names_.reserve(bundle.tool_defs.size());
+ for (const auto& def : bundle.tool_defs) {
+ current_request_model_tool_names_.push_back(def.name);
+ }
// Get provider snapshot
std::shared_ptr provider_snapshot;
@@ -5575,8 +6230,22 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
TokenUsage step_usage = provider_result.accumulated.usage;
if (abort_requested_) {
- dispatch_message(
- "system", abort_notice_text(), false, abort_notice_metadata());
+ const auto& output = provider_result.accumulated;
+ if (!output.content.empty() ||
+ (output.content_parts.is_array() && !output.content_parts.empty())) {
+ // Keep already displayed output across history reloads. An
+ // interrupted response (especially partial tool calls) is not
+ // a completed provider message, so it remains transcript-only.
+ ChatMessage partial;
+ partial.role = "assistant";
+ partial.content = output.content;
+ partial.content_parts = output.content_parts;
+ partial.reasoning_content = output.reasoning_content;
+ partial.metadata = {{"transcript_only", true}, {"interrupted_output", true}};
+ if (session_manager_) session_manager_->on_message(partial);
+ dispatch_message(partial.role, partial.content, false,
+ partial.metadata, partial.content_parts);
+ }
record_model_response(
current_model_step, provider_result, step_usage, "aborted");
emit_model_step_finish(current_model_step, "aborted", step_usage);
@@ -5623,17 +6292,111 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
estimated_usage.completion_tokens = estimate_message_tokens({estimated_response});
estimated_usage.total_tokens = estimated_usage.prompt_tokens + estimated_usage.completion_tokens;
estimated_usage.has_data = false;
+ estimated_usage.context_breakdown = reconcile_context_usage_breakdown(
+ bundle.context_usage_estimate,
+ estimated_usage.prompt_tokens);
step_usage = estimated_usage;
+ accumulate_turn_usage(
+ active_turn_usage_, active_turn_usage_initialized_, estimated_usage);
account_goal_usage(estimated_usage.total_tokens, false);
if (callbacks_.on_usage) callbacks_.on_usage(estimated_usage);
if (session_manager_) session_manager_->record_token_usage(estimated_usage);
}
-
record_model_response(
current_model_step, provider_result, step_usage, "completed");
// Text-only response (no tool calls) → end the loop
if (!provider_result.accumulated.has_tool_calls()) {
+ // 文本工具调用被拒:必须放在 response_is_blank 判断之前 —— provider
+ // 藏起标记后内容往往只剩空白,会被误判成空回复(错误的纠正文案)。
+ const TextToolCallDiagnostic& text_diag =
+ provider_result.accumulated.text_tool_calls;
+ if (text_diag.outcome == TextToolCallDiagnostic::Outcome::Rejected) {
+ TextToolCallDiagnostic diag = text_diag;
+ if (diag.reason == "truncated" &&
+ provider_result.accumulated.finish_reason == "length") {
+ diag.reason = "truncated_by_length";
+ }
+ const int limit = diag.format == "dsml"
+ ? kMaxDsmlToolCallCorrections
+ : kMaxTextToolCallCorrections;
+
+ // 被拒的 assistant 消息去掉标记后落盘(原始标记只进 metadata /
+ // 日志 / trajectory);只剩空白时清成空串。
+ ChatMessage rejected_msg;
+ rejected_msg.role = "assistant";
+ rejected_msg.content = text_tool_call_rejected_persisted_content(
+ provider_result.accumulated.content, diag);
+ rejected_msg.reasoning_content =
+ provider_result.accumulated.reasoning_content;
+ rejected_msg.metadata = nlohmann::json{
+ {"text_tool_call_rejected", text_tool_call_diagnostic_to_json(diag)},
+ };
+ messages_.push_back(rejected_msg);
+ if (session_manager_) session_manager_->on_message(rejected_msg);
+ if (!rejected_msg.content.empty()) {
+ // 定稿消息替换流式草稿:可疑级已经流出的标记在界面上随之消失。
+ dispatch_message("assistant", rejected_msg.content, false);
+ }
+
+ if (text_tool_call_corrections < limit) {
+ ++text_tool_call_corrections;
+ LOG_WARN("Text-form tool call rejected (format=" + diag.format +
+ " reason=" + diag.reason + "); correction " +
+ std::to_string(text_tool_call_corrections) + "/" +
+ std::to_string(limit) + ": " + diag.error +
+ " excerpt=" + log_truncate(diag.raw_excerpt, 300));
+
+ // 与空回复重试同款注入:role=user + hidden_goal_context,
+ // 进 API、持久化,TUI / Web 不显示。工具名取本次请求实际
+ // 发给模型的模型侧名。
+ ChatMessage correction;
+ correction.role = "user";
+ correction.content = build_text_tool_call_correction_prompt(
+ diag, current_request_model_tool_names_);
+ correction.metadata = nlohmann::json{
+ {"hidden_goal_context", true},
+ {"text_tool_call_correction", true},
+ };
+ ensure_user_message_identity(correction);
+ messages_.push_back(correction);
+ if (session_manager_) session_manager_->on_message(correction);
+
+ emit_transcript_system_message(
+ std::string(u8"[文本工具调用] 模型把工具调用写成了正文文本,未执行(") +
+ diag.error + u8"),已要求其改用原生工具调用重发 " +
+ std::to_string(text_tool_call_corrections) + "/" +
+ std::to_string(limit) + u8"…",
+ make_system_notice_metadata("response_text_tool_call_retry",
+ {{"attempt", text_tool_call_corrections},
+ {"attempts", limit},
+ {"error", diag.error}}));
+
+ if (total_iterations > 0) {
+ --total_iterations; // 纠正轮不计入 max_iterations
+ }
+ emit_model_step_finish(
+ current_model_step, "text_tool_call_retry", step_usage);
+ continue;
+ }
+
+ LOG_ERROR("Text-form tool call still rejected after " +
+ std::to_string(limit) + " correction(s); ending turn "
+ "with error (format=" + diag.format + " reason=" +
+ diag.reason + "): " + diag.error);
+ turn_timing_status = "error";
+ dispatch_message(
+ "error",
+ std::string(u8"[Error] 模型连续 ") + std::to_string(limit + 1) +
+ u8" 次把工具调用写成正文文本,无法执行(最后一次:" +
+ diag.error +
+ u8")。任务未完成,请重试或换用支持原生工具调用的模型。",
+ false);
+ stop_active_goal_after_turn_error(ProviderErrorInfo{});
+ emit_model_step_finish(current_model_step, "error", step_usage);
+ break;
+ }
+
const bool has_content_parts =
provider_result.accumulated.content_parts.is_array() &&
!provider_result.accumulated.content_parts.empty();
@@ -5697,7 +6460,10 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
: u8"无正文也无工具调用") +
u8"),自动重试 " +
std::to_string(empty_response_retries) + "/" +
- std::to_string(kMaxEmptyResponseRetries) + u8"…");
+ std::to_string(kMaxEmptyResponseRetries) + u8"…",
+ make_system_notice_metadata("response_empty_retry",
+ {{"attempt", empty_response_retries}, {"attempts", kMaxEmptyResponseRetries},
+ {"truncated", truncated_by_length}}));
if (total_iterations > 0) {
--total_iterations; // 空轮不计入 max_iterations
@@ -5739,15 +6505,28 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
auto completed_context = bundle.messages_with_system;
completed_context.push_back(assistant_msg);
publish_side_question_context(completed_context);
- dispatch_message("assistant", provider_result.accumulated.content, false,
- nlohmann::json::object(),
- provider_result.accumulated.content_parts);
+ {
+ // 工具前言:模型若违规给最终回答也打了 标签,界面
+ // 照样剥掉;落盘正文保留原文。
+ const std::string visible_content =
+ tool_preamble::strip_text_preamble_tags(
+ provider_result.accumulated.content);
+ const bool has_parts =
+ provider_result.accumulated.content_parts.is_array() &&
+ !provider_result.accumulated.content_parts.empty();
+ if (!visible_content.empty() || has_parts) {
+ dispatch_message("assistant", visible_content, false,
+ nlohmann::json::object(),
+ provider_result.accumulated.content_parts);
+ }
+ }
emit_model_step_finish(
current_model_step, provider_result.accumulated.finish_reason,
step_usage);
if (truncated_by_length) {
emit_transcript_system_message(
- u8"[输出截断] 本回复因输出 token 上限被截断,内容可能不完整。");
+ u8"[输出截断] 本回复因输出 token 上限被截断,内容可能不完整。",
+ make_system_notice_metadata("response_truncated"));
}
dispatch_assistant_completed_hook(assistant_msg, provider_snapshot);
if (maybe_continue_from_stop_hook(provider_result.accumulated.content)) {
@@ -5759,14 +6538,42 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
break;
}
- // 本轮产出了有效输出(工具调用),连续空回复计数清零。
+ // 本轮产出了有效输出(工具调用),连续空回复 / 文本调用纠正计数清零。
empty_response_retries = 0;
+ text_tool_call_corrections = 0;
+
+ // 工具前言(add-tool-preamble):在 assistant(tool_calls) 消息落盘之前把
+ // 本步沿用的阶段前言定下来,execute_tool_calls 开头把它挂进 metadata
+ // 并随 tool_start 下发。
+ current_step_preamble_ = resolve_tool_preamble_for_step(provider_result);
// Phase 5: Execute tool calls
terminator_fired = execute_tool_calls(
provider_result.accumulated, provider_snapshot,
emit_agent_progress, doom_guard, doom_guard_mu,
turn_timing_status);
+ // 混合形态:同一回复里既有原生调用,又有与之不一致的文本调用(回显在
+ // provider 那边已剔除,记为 None 不会走到这里)。只执行了原生调用,
+ // 批次跑完后追加隐藏说明,免得模型以为文本里那几个也执行了。不消耗
+ // 纠正预算,不发界面通知。
+ if (provider_result.accumulated.text_tool_calls.outcome ==
+ TextToolCallDiagnostic::Outcome::IgnoredWithNative &&
+ !terminator_fired && !abort_requested_) {
+ const std::string note = build_text_tool_call_ignored_note(
+ provider_result.accumulated.text_tool_calls);
+ if (!note.empty()) {
+ ChatMessage ignored;
+ ignored.role = "user";
+ ignored.content = note;
+ ignored.metadata = nlohmann::json{
+ {"hidden_goal_context", true},
+ {"text_tool_call_ignored", true},
+ };
+ ensure_user_message_identity(ignored);
+ messages_.push_back(ignored);
+ if (session_manager_) session_manager_->on_message(ignored);
+ }
+ }
emit_model_step_finish(
current_model_step, provider_result.accumulated.finish_reason,
step_usage);
@@ -5789,7 +6596,8 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
std::to_string(max_iter) + ")";
LOG_WARN(stop_msg);
turn_timing_status = "error";
- dispatch_message("system", stop_msg, false);
+ dispatch_message("system", stop_msg, false,
+ make_system_notice_metadata("iteration_limit", {{"limit", max_iter}}));
{
// 走的是 system 角色,dispatch_message 的 error 收集点抓不到;
// 子会话被 cap 截断时父会话同样要拿到原因。
@@ -5835,14 +6643,40 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
turn_timing_status);
}
+ if (abort_requested_) {
+ if (interrupted_for_new_turn) {
+ dispatch_message("system", "[Interjected]", false,
+ make_system_notice_metadata("turn_interjected", {}, {{"turn_interrupt", true}}));
+ } else {
+ const auto* user = trailing_transcript_message(messages_, true);
+ // Persist the completed stop, including its exact retry target.
+ // This notice stays out of the provider's message history.
+ emit_transcript_system_message("[Interrupted]", make_system_notice_metadata("turn_interrupted", {}, {
+ {"user_aborted", true},
+ {"retry_user_message_id", user ? user->uuid : std::string{}},
+ }));
+ }
+ }
+
+ // 回合结束:阶段前言不留到下一回合。
+ reset_activity_for_turn();
+ computer_use::release_session(desktop_turn_lease.owner);
if (callbacks_.on_turn_finished) {
callbacks_.on_turn_finished(turn_timing_status);
}
- record_terminal_trajectory_events(
- {{"busy", false},
- {"outcome", turn_timing_status},
- {"turn_id", turn_info.active_turn_id}},
- {{"outcome", turn_timing_status}});
+ const auto usage = model_step_usage_to_json(active_turn_usage_);
+ const nlohmann::json idle = {
+ {"busy", false},
+ {"outcome", turn_timing_status},
+ {"turn_id", turn_info.active_turn_id},
+ {"usage", usage},
+ };
+ const nlohmann::json done = {
+ {"outcome", turn_timing_status},
+ {"turn_id", turn_info.active_turn_id},
+ {"usage", usage},
+ };
+ record_terminal_trajectory_events(idle, done);
if (callbacks_.on_busy_changed) {
callbacks_.on_busy_changed(false);
}
@@ -5854,13 +6688,8 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
}
record_turn_outcome(turn_timing_status);
busy_ = false;
- events_.emit(SessionEventKind::BusyChanged, nlohmann::json{
- {"busy", false},
- {"outcome", turn_timing_status},
- {"turn_id", turn_info.active_turn_id},
- });
- events_.emit(SessionEventKind::Done, nlohmann::json{
- {"outcome", turn_timing_status}});
+ events_.emit(SessionEventKind::BusyChanged, idle);
+ events_.emit(SessionEventKind::Done, done);
if (terminate_session_after_turn_) {
// There must be no provider-visible state left for a deleted session.
// The post-turn action owns writer teardown and persistent cleanup.
@@ -5923,7 +6752,8 @@ void AgentLoop::run_compact() {
auto outcome = dispatch_codex_hook(kCodexHookEventPreCompact, "manual", payload);
apply_hook_side_effects(outcome);
if (outcome.continue_false || outcome.blocked || outcome.denied) {
- emit_transcript_system_message("[Compact] Stopped by hook.");
+ emit_transcript_system_message("[Compact] Stopped by hook.",
+ make_system_notice_metadata("context_compact_stopped"));
finish();
return;
}
@@ -6047,7 +6877,7 @@ void AgentLoop::run_shell(std::string command) {
};
ProgressGuard guard;
if (callbacks_.on_tool_progress_start) {
- callbacks_.on_tool_progress_start("bash", cmd_preview);
+ callbacks_.on_tool_progress_start("bash", cmd_preview, std::string{});
guard.end_cb = callbacks_.on_tool_progress_end;
}
diff --git a/src/agent_loop.hpp b/src/agent_loop.hpp
index fc8519f67..b33504ead 100644
--- a/src/agent_loop.hpp
+++ b/src/agent_loop.hpp
@@ -11,6 +11,7 @@
#include "session/permission_prompter.hpp"
#include "session/ask_user_question_prompter.hpp"
#include "config/config.hpp"
+#include "tool_preamble/tool_preamble.hpp"
#include "hooks/hook_runtime.hpp"
#include "skills/skill_usage_store.hpp"
#include "pa/pa_overflow_rescue.hpp"
@@ -95,6 +96,7 @@ struct ProjectInstructionsConfig;
struct ExpertDefinition;
struct CompactResult;
struct SystemPromptModelState;
+struct SystemPromptWorkspaceFolders;
class AgentLoopDoomGuard;
// Callbacks for the TUI to observe agent loop events
@@ -143,6 +145,11 @@ struct AgentCallbacks {
// The payload shape matches the todo_updated session event.
std::function on_todo_updated;
+ // 具体进度提示(add-tool-preamble,「适合日常工作」):loading 文案变化时回调
+ // (「正在分析你的请求」「正在读取 3 个文件」「正在分析命令输出」、推理加粗
+ // 标题…),TUI 用它替换等待动画里的随机短语。关闭时不回调。
+ std::function on_thinking_title;
+
// Legacy display observer for replacement-style transcript updates. Normal
// compact success appends marker messages and no longer calls this hook.
std::function& messages,
@@ -160,8 +167,11 @@ struct AgentCallbacks {
// Called just before a tool begins executing. `command_preview` is a short
// human-readable summary (e.g. the first 60 chars of a bash command).
+ // `preamble` 保留给以后用,当前恒为空:进度头是工具行,参数照常显示;具体
+ // 进度提示只走 on_thinking_title(loading 行)。
std::function on_tool_progress_start;
+ const std::string& command_preview,
+ const std::string& preamble)> on_tool_progress_start;
// Called from the tool's streaming thread with each cleaned chunk.
// `tail_snapshot` is the last-5-lines sliding window; `current_partial` is
@@ -206,6 +216,11 @@ class AgentLoop {
// context parts. Existing text-only submit overloads delegate here.
void submit(const UserInput& input);
+ // Retry the trailing user or the last user of an explicitly aborted turn
+ // while idle. Reuses stored input without adding adjacent user messages.
+ bool retry_last_user_message(const std::string& expected_user_message_id,
+ std::string& error);
+
// Submit a user-initiated shell command triggered by `!` mode. Non-blocking:
// enqueues on the same worker so it serialises with LLM turns. The worker
// invokes BashTool directly (no LLM round-trip), emits tool_call + tool_result
@@ -230,7 +245,8 @@ class AgentLoop {
// Emit a visible system message without adding it to LLM history. Used by
// daemon-owned builtin commands for TUI-like progress and fallback output.
- void emit_system_message(const std::string& content);
+ void emit_system_message(const std::string& content,
+ nlohmann::json metadata = nlohmann::json::object());
void emit_transcript_system_message(const std::string& content,
nlohmann::json metadata = nlohmann::json::object());
@@ -299,6 +315,7 @@ class AgentLoop {
// Clear all messages (for /clear command)
void clear_messages() {
messages_.clear();
+ live_transcript_tail_blocked_ = false;
last_api_total_tokens_.store(0, std::memory_order_relaxed);
compact_window_initialized_ = false;
compact_window_number_ = 0;
@@ -340,6 +357,15 @@ class AgentLoop {
// project dir)不动 —— worktree 是同一个项目会话的临时工作区,不是新项目。
// 只应在工具执行线程(turn 内)或会话未运行时调用。
void set_cwd(const std::string& new_cwd);
+ // 重读会话所属项目 workspace.json 里的附加文件夹(「编辑项目」保存的
+ // extra_folders)。每回合开始调一次,保存后已打开的会话下一轮即生效。
+ void refresh_workspace_folders();
+ // 系统提示列出的附加文件夹(已过滤掉磁盘上不存在的)。
+ std::vector workspace_extra_folders() const;
+ // 真正放行写入的附加文件夹:文件工具的路径校验、bash 写边界守卫与沙箱可写
+ // 根都用它。有写边界(worktree / LOOP / 继承)时去掉与主文件夹重叠的项 ——
+ // 否则附加一个主仓的上级目录就能绕开 worktree 隔离。
+ std::vector writable_workspace_folders() const;
void set_sandbox_config(const SandboxConfig& config);
void set_exec_rules(sandbox::ExecRules rules) { exec_rules_ = std::move(rules); }
// 测试用:把全局规则目录(默认 `/rules`)指到临时目录,让
@@ -372,7 +398,16 @@ class AgentLoop {
// Install / update the agent-loop termination policy. Called once from
// main.cpp at startup (and could be called again if config reloads).
// A fresh-default AgentLoopConfig is used when this setter is never called.
- void set_agent_loop_config(AgentLoopConfig cfg) { loop_cfg_ = cfg; }
+ void set_agent_loop_config(AgentLoopConfig cfg) {
+ loop_cfg_ = cfg;
+ set_tool_preamble_config(cfg.tool_preamble);
+ }
+
+ // 工具前言(add-tool-preamble)。配置可在设置页动态改,所以单独一把锁、
+ // 每次用时取快照。
+ void set_tool_preamble_config(const ToolPreambleConfig& cfg);
+ ToolPreambleConfig tool_preamble_config() const;
+
void set_task_suggestion_compact_threshold(int threshold) {
task_suggestion_compact_threshold_.store(
threshold > 0 ? threshold : 0, std::memory_order_relaxed);
@@ -455,6 +490,9 @@ class AgentLoop {
void set_tool_capability_policy(ToolCapabilityPolicy policy) {
tool_capability_policy_ = std::move(policy);
}
+ const ToolCapabilityPolicy& tool_capability_policy() const {
+ return tool_capability_policy_;
+ }
void set_git_context_config(const GitContextConfig* cfg) {
git_context_cfg_ = cfg;
}
@@ -506,7 +544,10 @@ class AgentLoop {
void join_side_question_threads();
void run_agent(const std::string& user_message);
void run_agent_with_input(const UserInput& input,
- bool hidden_goal_context = false);
+ bool hidden_goal_context = false,
+ const ChatMessage* retry_message = nullptr);
+ std::optional retryable_user_message(
+ const std::string& expected_user_message_id) const;
// Variant that records `display_text` into the user message's metadata.display_text
// so UI can show the original input while the LLM sees an expanded `prompt`.
// When `display_text` is empty, behaves identically to run_agent(prompt).
@@ -542,11 +583,6 @@ class AgentLoop {
// acceptance under the same lock, eliminating the final-response race.
bool drain_active_turn_inputs(bool close_if_empty);
void append_interrupted_turn_context(const std::string& turn_id);
- // Visible abort notice: manual stop keeps [Interrupted]; interjection
- // uses a dedicated [Interjected] system marker so the transcript does
- // not look like a user stop.
- std::string abort_notice_text() const;
- nlohmann::json abort_notice_metadata() const;
std::size_t close_active_turn_and_discard();
bool maybe_run_auto_compact();
// 摘要压缩失败后的兜底:改用不调用模型的机械修剪腾出空间。返回 true 表示
@@ -634,6 +670,13 @@ class AgentLoop {
std::int64_t turn_started_at_ms = 0;
};
UserTurnInfo prepare_user_turn(const UserInput& input, bool hidden_goal_context);
+ UserTurnInfo prepare_retry_user_turn(const ChatMessage& message);
+ void append_user_turn_message(UserTurnInfo& info, bool hidden_goal_context);
+ // 用户消息落盘后把新的会话摘要(无标题时的显示标题)以 session_updated
+ // {summary} 推给界面:侧栏与顶部标题栏同源于这一个字段,前端不再各自从
+ // 消息正文现推标题(那正是两处标题不一致、且长度不受限的根因)。
+ void emit_session_summary_updated();
+ void start_user_turn(const UserTurnInfo& info);
// Phase 2: Build the full message list for the LLM provider.
struct ApiRequestBundle {
@@ -646,6 +689,13 @@ class AgentLoop {
void publish_side_question_context(
const std::vector& messages_with_system);
+ // 具体进度提示(add-tool-preamble)的一条 loading 文案。
+ struct ToolPreambleTitle {
+ std::string title;
+ std::string source; // reasoning | template | context
+ std::string kind; // read | write | ""(按工具类型定,透传给界面)
+ };
+
// Phase 3: Stream provider response and accumulate.
struct ProviderCallResult {
ChatResponse accumulated;
@@ -654,6 +704,25 @@ class AgentLoop {
std::shared_ptr provider_snapshot;
int provider_attempt = 1;
};
+ bool concrete_activity_enabled() const;
+ // 本模型步的工具批次文案(推理加粗标题 > 工具模板),同时记下这批工具,
+ // 供下一次等待模型时给出「正在分析文件内容」这类场景文案。关闭时返回空。
+ ToolPreambleTitle resolve_tool_preamble_for_step(ProviderCallResult& result);
+ // 推理加粗标题就绪时的出口:记成本步标题,并发一条 agent_progress 让
+ // loading 立刻换文案。
+ void publish_phase_preamble(const ToolPreambleTitle& preamble,
+ const ProgressEmitter& emit_progress);
+ // 本步推理加粗标题的读写(受 tool_preamble_mu_ 保护)。
+ void set_phase_preamble(const ToolPreambleTitle& preamble);
+ ToolPreambleTitle phase_preamble() const;
+ // 给某个进度 phase 算具体文案;关闭或该 phase 不替换时返回空(调用方沿用
+ // 原文案)。权限 / 提问 / 压缩 / 重试不替换。
+ ToolPreambleTitle concrete_activity_for_phase(const std::string& phase) const;
+ // loading 文案变了就回调 TUI(on_thinking_title),同一句不重复回调。
+ void announce_activity(const std::string& label);
+ void note_planned_tool(int tool_index, const std::string& native_name);
+ void reset_activity_for_step();
+ void reset_activity_for_turn();
ProviderCallResult call_provider_and_collect(
const std::shared_ptr& provider,
const ApiRequestBundle& bundle,
@@ -738,12 +807,16 @@ class AgentLoop {
std::string display_text;
bool hidden_goal_context = false;
std::function control;
+ std::string retry_user_message_id;
};
ProviderAccessor provider_accessor_;
ToolExecutor& tools_;
AgentCallbacks callbacks_;
std::vector messages_;
+ // Visible events (notably errors and partial output) may not be present
+ // in model history or JSONL. They must also invalidate an empty retry.
+ std::atomic live_transcript_tail_blocked_{false};
mutable std::mutex side_question_context_mu_;
std::vector side_question_context_;
std::mutex side_question_threads_mu_;
@@ -781,11 +854,43 @@ class AgentLoop {
mutable std::optional> sandbox_prompt_snapshot_;
PermissionManager& permissions_;
PathValidator path_validator_;
+ // 「编辑项目」的主文件夹与附加文件夹快照(refresh_workspace_folders 每回合重读)。
+ // 并行只读工具会在工作线程上查它,用锁保护。
+ mutable std::mutex workspace_folders_mu_;
+ std::string workspace_main_folder_;
+ std::vector workspace_extra_folders_;
+ // 路径落在某个可写附加文件夹内(相对路径按 cwd_ 解析)。
+ bool path_in_workspace_folders(const std::string& path) const;
+ // 系统提示 # Environment 的附加工作目录两行(可写 / 本会话只读)。
+ SystemPromptWorkspaceFolders system_prompt_workspace_folders() const;
std::atomic context_window_{128000};
std::string no_model_config_prompt_;
// agent_loop termination policy. Fresh defaults come from AgentLoopConfig
// until set_agent_loop_config is called from main.cpp.
AgentLoopConfig loop_cfg_;
+ // 具体进度提示(add-tool-preamble)状态,全部受 tool_preamble_mu_ 保护(设置页
+ // 可在回合中途改配置;进度帧可能在工具线程上发)。
+ mutable std::mutex tool_preamble_mu_;
+ ToolPreambleConfig tool_preamble_cfg_;
+ // 本模型步的推理加粗标题:每次 provider 调用开头(含重试)清空。
+ ToolPreambleTitle phase_preamble_;
+ // 本模型步已经流出来的工具调用(按 tool_index,原生名),给「准备调用」阶段拼模板。
+ std::vector step_planned_tools_;
+ // 正在执行的这批工具的文案,tool_running 用。
+ ToolPreambleTitle current_batch_activity_;
+ // 本回合上一批工具(原生名):下一次等待模型时据此说「正在分析文件内容」等。
+ std::vector last_batch_tools_;
+ // 上一次回调给 TUI 的 loading 文案,避免同一句重复回调。
+ std::string last_announced_activity_;
+ // 流式标签扫描器:历史里残留的 标签只从界面上剥掉,不再当文案。
+ tool_preamble::TextPreambleScanner text_preamble_scanner_;
+ // 本模型步给工具批次的前言:run_agent_with_input 在 Phase 5 之前填,
+ // execute_tool_calls 开头消费(挂 metadata、随 tool_start 下发)后清空。
+ ToolPreambleTitle current_step_preamble_;
+ // 本次模型请求实际发出的模型侧工具名(bundle.tool_defs[i].name,已经过
+ // 「工具重写」映射)。主循环每次组装请求后刷新;只在 worker 线程的工具批次
+ // 之间写入,并行工具线程只读。Unknown tool 错误文本据此列出可用名。
+ std::vector current_request_model_tool_names_;
LoopExecutionPolicy loop_execution_policy_;
// spawn_subagent 透传的父会话写边界根;见 write_root()。
std::string inherited_write_root_;
@@ -802,6 +907,12 @@ class AgentLoop {
// Latest server-reported total active-context usage. For providers that do
// not return total_tokens, prompt_tokens is used as the fallback.
std::atomic last_api_total_tokens_{0};
+ // Aggregate usage for the regular turn currently owned by the worker.
+ // Kept as worker state (rather than a stack local) so the outer worker
+ // recovery boundary can still publish an accurate terminal summary after
+ // an exception unwinds run_agent_with_input().
+ TokenUsage active_turn_usage_;
+ bool active_turn_usage_initialized_ = false;
// PA 兜底的 episode 进度(见 run_pa_overflow_rescue)。服务端收下请求即
// 清零;回合开始也清零。只在回合线程上读写。
pa::RescueState pa_rescue_state_;
diff --git a/src/agent_loop_shell_guard.hpp b/src/agent_loop_shell_guard.hpp
index 4949512d5..b3adc937d 100644
--- a/src/agent_loop_shell_guard.hpp
+++ b/src/agent_loop_shell_guard.hpp
@@ -210,8 +210,13 @@ inline bool windows_absolute_is_inside(const std::string& target,
// common redirection/cmdlet/copy destinations that command_looks_like_file_write
// recognizes. Empty result means the command is allowed; otherwise it is a
// fail-closed reason returned before process execution.
+//
+// extra_roots:「编辑项目」里放行写入的附加文件夹(AgentLoop::
+// writable_workspace_folders)。只接受绝对路径目标落在其中;相对路径仍只按
+// working_dir 解析。
inline std::string loop_shell_write_escape_reason(const std::string& command,
- const std::string& working_dir) {
+ const std::string& working_dir,
+ const std::vector& extra_roots = {}) {
if (!command_looks_like_file_write(command)) return {};
const auto tokens = tokenize_shell_command(command);
std::vector targets;
@@ -230,7 +235,7 @@ inline std::string loop_shell_write_escape_reason(const std::string& command,
const std::string& nested = tokens[i + 1];
if (nested != command && command_looks_like_file_write(nested)) {
const std::string nested_reason =
- loop_shell_write_escape_reason(nested, working_dir);
+ loop_shell_write_escape_reason(nested, working_dir, extra_roots);
if (!nested_reason.empty()) return nested_reason;
}
}
@@ -319,11 +324,25 @@ inline std::string loop_shell_write_escape_reason(const std::string& command,
}
PathValidator validator(working_dir, false);
+ const auto inside_extra_root = [&](const std::string& target) {
+ for (const auto& root : extra_roots) {
+ if (shell_target_is_windows_absolute(target)) {
+ if (windows_absolute_is_inside(target, root)) return true;
+ continue;
+ }
+ if (path_from_utf8(target).is_absolute() &&
+ PathValidator(root, false).validate(target).empty()) {
+ return true;
+ }
+ }
+ return false;
+ };
for (auto target : targets) {
target = trim_shell_target(std::move(target));
if (shell_target_is_dynamic(target)) {
return "Write boundary blocked a dynamic shell write destination: " + target;
}
+ if (inside_extra_root(target)) continue;
// std::filesystem follows the host OS. On Linux, a Windows absolute
// path such as C:/outside is otherwise treated as relative and may be
// incorrectly joined under the work root. Compare Windows paths
diff --git a/src/commands/builtin_commands.cpp b/src/commands/builtin_commands.cpp
index 1fc3bec54..d8973329d 100644
--- a/src/commands/builtin_commands.cpp
+++ b/src/commands/builtin_commands.cpp
@@ -13,6 +13,7 @@
#include "resume_state_sync.hpp"
#include "websearch_command.hpp"
#include "../config/config.hpp"
+#include "../config/mcp_config.hpp"
#include "../tui/mode_picker.hpp"
#include "../tui/theme_palette.hpp"
#include "../config/saved_models.hpp"
@@ -501,18 +502,10 @@ static void cmd_feedback(CommandContext& ctx, const std::string& raw_args) {
package_req.session_id = session_id;
package_req.session_jsonl_path = session_jsonl;
package_req.acecode_version = ACECODE_VERSION;
- // TUI 自己的日志(cwd/acecode.log)+ 同机 daemon / desktop 的滚动日志:
- // TUI 会话也可能被 daemon 侧的组件影响,缺失的来源会被静默跳过。
- {
- acecode::feedback::FeedbackLogSource tui_log;
- tui_log.path = path_from_utf8(ctx.cwd) / "acecode.log";
- tui_log.entry_name = "logs/acecode.log.tail.txt";
- package_req.logs.push_back(std::move(tui_log));
- }
+ // TUI feedback carries its own surface log plus the shared daemon log, but not
+ // an unrelated Desktop surface log. Missing sources are skipped.
const fs::path logs_dir = path_from_utf8(get_logs_dir());
- for (auto& source : acecode::feedback::collect_runtime_log_sources(logs_dir)) {
- package_req.logs.push_back(std::move(source));
- }
+ package_req.logs = acecode::feedback::collect_tui_runtime_log_sources(logs_dir);
// 最近三天的升级记录合并成一个条目:「更新之后就不对了」这类反馈要看的就是它。
if (auto upgrade_logs = acecode::feedback::collect_recent_upgrade_log_bundle(logs_dir)) {
package_req.log_bundles.push_back(std::move(*upgrade_logs));
@@ -837,13 +830,13 @@ static void mcp_push(CommandContext& ctx, const std::string& msg) {
ctx.state.chat_follow_tail = true;
}
-static std::string mcp_known_servers(const McpManager& mgr) {
- auto names = mgr.server_names();
+static std::string mcp_known_servers(const std::map& names) {
if (names.empty()) return "(none)";
std::ostringstream oss;
- for (size_t i = 0; i < names.size(); ++i) {
- if (i) oss << ", ";
- oss << names[i];
+ for (const auto& [name, owner] : names) {
+ (void)owner;
+ if (oss.tellp() > 0) oss << ", ";
+ oss << name;
}
return oss.str();
}
@@ -865,6 +858,23 @@ static void cmd_mcp(CommandContext& ctx, const std::string& args) {
}
McpManager& mgr = *ctx.mcp_manager;
ToolExecutor& tools = *ctx.tools;
+ // Commands address the current project's effective names. A project
+ // override (including a disabled one) never falls back to a global owner.
+ const auto project_scope = ctx.cwd.empty() ? std::string{} : mcp_project_root(ctx.cwd);
+ std::map visible;
+ for (const auto& server : mgr.list_servers()) {
+ if (mgr.server_scope(server.name).empty()) visible[server.name] = server.name;
+ }
+ if (!project_scope.empty()) {
+ for (const auto& server : mgr.list_servers()) {
+ if (mgr.server_scope(server.name) == project_scope)
+ visible[mcp_server_display_name(server.name)] = server.name;
+ }
+ }
+ const auto is_visible = [&](const std::string& id) {
+ const auto found = visible.find(mcp_server_display_name(id));
+ return found != visible.end() && found->second == id;
+ };
// Parse: first token is subcommand, remainder is name.
std::string trimmed = normalized_args;
@@ -892,14 +902,15 @@ static void cmd_mcp(CommandContext& ctx, const std::string& args) {
// Default view: list servers with state summary.
if (sub.empty()) {
auto servers = mgr.list_servers();
- if (servers.empty()) {
+ if (visible.empty()) {
mcp_push(ctx, "No MCP servers configured.");
return;
}
std::ostringstream oss;
oss << "MCP servers:";
for (const auto& s : servers) {
- oss << "\n " << s.name
+ if (!is_visible(s.name)) continue;
+ oss << "\n " << mcp_server_display_name(s.name)
<< " [" << mcp_state_label(s.state) << "]"
<< " [" << s.transport << "]"
<< " tools=" << s.tool_count
@@ -927,7 +938,7 @@ static void cmd_mcp(CommandContext& ctx, const std::string& args) {
if (sub == "list") {
auto grouped = mgr.list_tools_by_server();
- if (grouped.empty()) {
+ if (visible.empty()) {
mcp_push(ctx, "No MCP servers configured.");
return;
}
@@ -938,9 +949,10 @@ static void cmd_mcp(CommandContext& ctx, const std::string& args) {
std::ostringstream oss;
oss << "MCP tools:";
for (const auto& [server, defs] : grouped) {
+ if (!is_visible(server)) continue;
auto it = state_map.find(server);
const char* label = (it != state_map.end()) ? mcp_state_label(it->second) : "unknown";
- oss << "\n " << server << " [" << label << "]";
+ oss << "\n " << mcp_server_display_name(server) << " [" << label << "]";
if (defs.empty()) {
oss << "\n (no tools registered)";
} else {
@@ -963,28 +975,38 @@ static void cmd_mcp(CommandContext& ctx, const std::string& args) {
mcp_push(ctx, "Usage: /mcp " + sub + " ");
return;
}
- if (!mgr.has_server(name)) {
- mcp_push(ctx, "Unknown MCP server '" + name + "'. Known: " + mcp_known_servers(mgr));
+ const auto found = visible.find(name);
+ if (found == visible.end()) {
+ mcp_push(ctx, "Unknown MCP server '" + name + "'. Known: " + mcp_known_servers(visible));
return;
}
+ const auto& owner = found->second;
+ // Slash commands remain runtime controls; persisted configuration is
+ // edited in MCP management. Preserve all other capability restrictions.
+ auto policy = ctx.agent_loop.tool_capability_policy();
+ if (policy.mcp_servers) {
+ if (sub == "disable") policy.mcp_servers->erase(owner);
+ else policy.mcp_servers->insert(owner);
+ ctx.agent_loop.set_tool_capability_policy(std::move(policy));
+ }
bool changed = false;
if (sub == "disable") {
- changed = mgr.disable(name, tools);
+ changed = mgr.disable(owner, tools);
if (changed) {
mcp_push(ctx, "Disabled MCP server '" + name + "'.");
} else {
mcp_push(ctx, "MCP server '" + name + "' is already disabled.");
}
} else if (sub == "enable") {
- changed = mgr.enable(name, tools);
+ changed = mgr.enable(owner, tools);
if (changed) {
mcp_push(ctx, "Starting MCP server '" + name + "' in the background.");
} else {
// Distinguish already-connected vs failed.
auto servers = mgr.list_servers();
for (const auto& s : servers) {
- if (s.name == name) {
+ if (s.name == owner) {
if (s.state == McpServerState::Connected) {
mcp_push(ctx, "MCP server '" + name + "' is already connected.");
} else if (s.state == McpServerState::Starting) {
@@ -997,7 +1019,7 @@ static void cmd_mcp(CommandContext& ctx, const std::string& args) {
}
}
} else { // reconnect
- changed = mgr.reconnect(name, tools);
+ changed = mgr.reconnect(owner, tools);
if (changed) {
mcp_push(ctx, "Reconnecting MCP server '" + name + "' in the background.");
} else {
diff --git a/src/commands/compact.cpp b/src/commands/compact.cpp
index 39be94cbc..5e5408df8 100644
--- a/src/commands/compact.cpp
+++ b/src/commands/compact.cpp
@@ -3,6 +3,8 @@
#include "../session/compact_checkpoint.hpp"
#include "../session/session_history_recovery.hpp"
#include "../pa/pa_quirks.hpp"
+#include "../provider/text_tool_call_recovery.hpp"
+#include "../utils/encoding.hpp"
#include "../utils/logger.hpp"
#include
@@ -172,6 +174,36 @@ int remove_oldest_history_item(
return removed_count;
}
+// 压缩请求被判上下文超限时收缩被总结的历史。前 kSingleItemOverflowRetries 次
+// 每次只删最旧的一条(连同配对的 tool 结果),尽量多保留可总结的内容;之后每次
+// 至少删掉约 1/4 的估算 token。起因(yubo2):换到窗口更小的模型后,旧历史比新
+// 窗口多出几十万 token,一次删一条要来回请求几百次。
+constexpr int kSingleItemOverflowRetries = 3;
+
+int estimate_history_tokens(const std::vector& history) {
+ std::size_t bytes = 0;
+ for (const auto& msg : history) bytes += message_payload_bytes(msg);
+ const std::size_t tokens = (bytes + 3) / 4;
+ return tokens > static_cast(std::numeric_limits::max())
+ ? std::numeric_limits::max()
+ : static_cast(tokens);
+}
+
+int shrink_history_for_overflow(std::vector& history,
+ int overflow_retries) {
+ if (history.empty()) return 0;
+ if (overflow_retries <= kSingleItemOverflowRetries) {
+ return remove_oldest_history_item(history);
+ }
+ const int before = estimate_history_tokens(history);
+ const int target = before - before / 4;
+ int removed = 0;
+ do {
+ removed += remove_oldest_history_item(history);
+ } while (!history.empty() && estimate_history_tokens(history) > target);
+ return removed;
+}
+
} // namespace
namespace acecode {
@@ -275,7 +307,38 @@ std::vector build_compacted_history(
}
retained.role = "user";
- retained.content_parts = nlohmann::json::array();
+ // Structured parts are dropped (images, contexts), except `file`
+ // parts: a file reference is a few hundred bytes, and dropping it
+ // takes away the only read path the model has for that file. A
+ // large paste stored as a file would otherwise vanish from the
+ // model's input after every compaction. When parts are kept, the
+ // (possibly truncated) text goes first as a text part, because
+ // providers render content_parts instead of content.
+ nlohmann::json retained_parts = nlohmann::json::array();
+ if (it->content_parts.is_array()) {
+ for (const auto& part : it->content_parts) {
+ if (part.is_object() &&
+ part.value("type", std::string{}) == "file") {
+ retained_parts.push_back(part);
+ }
+ }
+ }
+ if (!retained_parts.empty() && !retained.content.empty()) {
+ retained_parts.insert(retained_parts.begin(), nlohmann::json{
+ {"type", "text"}, {"text", retained.content}});
+ }
+ // 只有图片的用户消息:图片部件在上面被丢掉,content 又是空的,留下一条
+ // 空内容的 user 消息 —— 部分服务端直接 400「message content cannot be
+ // empty」(yubo2 反馈:压缩后切到 agnes-3.0-flash 每次请求都失败)。
+ // 留一句占位说明;本来就什么都没有的消息直接跳过。
+ if (retained_parts.empty() &&
+ retained.content.find_first_not_of(" \t\r\n") == std::string::npos) {
+ const bool had_parts =
+ it->content_parts.is_array() && !it->content_parts.empty();
+ if (!had_parts) continue;
+ retained.content = "[Image attachment omitted during context compaction]";
+ }
+ retained.content_parts = std::move(retained_parts);
retained.tool_calls = nlohmann::json();
retained.tool_call_id.clear();
retained.reasoning_content.clear();
@@ -368,6 +431,13 @@ bool is_context_overflow_error(const std::string& error_message) {
"prompt is too long",
"input is too long",
"too many input tokens",
+ // vLLM / LiteLLM 转发的写法(yubo2 切到 agnes-2.5-flash 后的原文):
+ // "ContextWindowExceededError: ... The input (564686 tokens) is longer than
+ // the model's context length (524288 tokens)." 认不出时压缩内部「删最旧历史
+ // 再重试」不启动,退化成机械裁剪,一次丢掉 527 条消息且没有摘要。
+ "contextwindowexceeded",
+ "longer than the model's context length",
+ "exceeds the model's context length",
};
return contains_any(text, needles);
}
@@ -419,6 +489,22 @@ void insert_context_before_last_real_user_or_summary(
std::make_move_iterator(context.end()));
}
+std::string compact_summary_rejection_reason(const ChatResponse& response) {
+ // 压缩请求不带工具表,仍有模型(实测 dots3)接着历史里的 tool_calls「做下一步」:
+ // 要么真的回原生 tool_calls,要么把调用写成正文。两种都不是摘要,绝不能落盘。
+ if (response.has_tool_calls()) return "tool_calls";
+ const bool blank = std::all_of(
+ response.content.begin(), response.content.end(), [](char c) {
+ return std::isspace(static_cast(c)) != 0;
+ });
+ // DSML 标记会被 provider 的 DSML 过滤器吞掉,剩下的空内容在这里被拦住。
+ if (blank) return "empty";
+ // 任意位置出现调用标记都算污染(标记后面还跟着正文也一样);围栏与行内代码里的
+ // 不算 —— 摘要引用命令、代码是允许的。刻意不设长度下限:中文合法摘要可能只有几个字。
+ if (text_contains_tool_call_markup(response.content)) return "tool_call_markup";
+ return {};
+}
+
CompactResult compact_messages(
LlmProvider& provider,
const std::vector& messages,
@@ -430,6 +516,9 @@ CompactResult compact_messages(
const std::vector original_history =
normalize_messages_for_api(messages);
std::vector request_history = original_history;
+ // 被总结的模型同样不该看到旧的文本工具调用样本(与主请求同一套清洗),
+ // 否则 dots 这类模型会接着把调用写进摘要(yubo2 现场)。
+ sanitize_text_tool_call_history(request_history, get_compact_summary_prefix());
if (provider.supports_native_compaction()) {
LOG_WARN("Provider advertises native compaction but the active LlmProvider contract "
@@ -442,6 +531,9 @@ CompactResult compact_messages(
std::string summary_suffix;
std::uint64_t transient_retries = 0;
+ // 与上下文溢出、瞬时错误的重试互不共享计数。
+ int invalid_summary_retries = 0;
+ int overflow_retries = 0;
for (;;) {
if (abort_flag && abort_flag->load()) {
result.error = "Compaction cancelled.";
@@ -456,7 +548,10 @@ CompactResult compact_messages(
ChatMessage prompt;
prompt.role = "user";
- prompt.content = get_compact_prompt();
+ prompt.content = invalid_summary_retries > 0
+ ? get_compact_prompt() + "\n\n" +
+ get_compact_invalid_summary_reminder()
+ : get_compact_prompt();
request.push_back(std::move(prompt));
try {
@@ -481,12 +576,13 @@ CompactResult compact_messages(
: is_context_overflow_error(response.content);
if (context_overflow &&
!request_history.empty()) {
- const int removed =
- remove_oldest_history_item(request_history);
+ const int removed = shrink_history_for_overflow(
+ request_history, ++overflow_retries);
result.compaction_request_items_removed += removed;
transient_retries = 0;
- LOG_WARN("Context window exceeded while compacting; removed one oldest "
- "history item and any paired tool item");
+ LOG_WARN("Context window exceeded while compacting; removed " +
+ std::to_string(removed) + " oldest history item(s) (retry " +
+ std::to_string(overflow_retries) + ")");
continue;
}
@@ -540,16 +636,41 @@ CompactResult compact_messages(
return result;
}
+ const std::string rejection =
+ compact_summary_rejection_reason(response);
+ if (!rejection.empty()) {
+ LOG_WARN("Compact summary rejected; reason=" + rejection +
+ " attempt=" +
+ std::to_string(invalid_summary_retries + 1) + "/" +
+ std::to_string(kMaxInvalidCompactSummaryRetries + 1) +
+ " tool_calls=" +
+ std::to_string(response.tool_calls.size()) +
+ " excerpt=" +
+ truncate_utf8_prefix(response.content, 300));
+ if (invalid_summary_retries < kMaxInvalidCompactSummaryRetries) {
+ ++invalid_summary_retries;
+ continue;
+ }
+ // 不安装任何摘要:自动压缩由 AgentLoop 现有的丢弃最旧历史兜底接手。
+ result.error = "Summarization returned an invalid summary (" +
+ rejection + ") after " +
+ std::to_string(kMaxInvalidCompactSummaryRetries + 1) +
+ " attempts.";
+ return result;
+ }
+
summary_suffix = response.content;
break;
} catch (const std::exception& error) {
const std::string message = error.what();
if (is_context_overflow_error(message) && !request_history.empty()) {
- const int removed = remove_oldest_history_item(request_history);
+ const int removed = shrink_history_for_overflow(
+ request_history, ++overflow_retries);
result.compaction_request_items_removed += removed;
transient_retries = 0;
- LOG_WARN("Context window exceeded while compacting; removed one oldest "
- "history item and any paired tool item");
+ LOG_WARN("Context window exceeded while compacting; removed " +
+ std::to_string(removed) + " oldest history item(s) (retry " +
+ std::to_string(overflow_retries) + ")");
continue;
}
result.error = is_context_overflow_error(message)
diff --git a/src/commands/compact.hpp b/src/commands/compact.hpp
index 7b2380177..81c8327f8 100644
--- a/src/commands/compact.hpp
+++ b/src/commands/compact.hpp
@@ -66,6 +66,18 @@ bool is_context_overflow_error(const ProviderErrorInfo& info);
bool is_context_overflow_error(const std::string& error_message);
bool is_retryable_compaction_error(const ProviderErrorInfo& info);
+// Validate a summarization reply before it is installed as the compact
+// summary. Returns "" when acceptable, otherwise a short reason:
+// "tool_calls" (native tool calls), "empty" (blank after trimming) or
+// "tool_call_markup" (tool-call tags anywhere outside code fences/inline
+// code). There is intentionally no minimum length: a valid Chinese summary
+// can be only a few characters.
+std::string compact_summary_rejection_reason(const ChatResponse& response);
+
+// Rejected summaries are retried at most this many times (3 attempts total)
+// before compaction fails without installing anything.
+constexpr int kMaxInvalidCompactSummaryRetries = 2;
+
using CompactRetryCallback =
std::function;
diff --git a/src/commands/compact_prompt.cpp b/src/commands/compact_prompt.cpp
index 779241266..b21d4d997 100644
--- a/src/commands/compact_prompt.cpp
+++ b/src/commands/compact_prompt.cpp
@@ -13,7 +13,17 @@ const std::string kCompactPrompt =
"- Important context, constraints, or user preferences\n"
"- What remains to be done (clear next steps)\n"
"- Any critical data, examples, or references needed to continue\n\n"
- "Be concise, structured, and focused on helping the next LLM seamlessly continue the work.";
+ "Be concise, structured, and focused on helping the next LLM seamlessly continue the work."
+ // 以下一段是 ACECode 有意偏离 Codex 原文的追加:压缩请求不带工具表,部分模型
+ // (实测 dots3)会接着历史里的 tool_calls「做下一步」,把工具调用写成正文当摘要。
+ // 只禁止调工具 / 输出调用标签,不禁止引用命令与路径 —— 上面要求保留关键数据。
+ "\n\nOutput requirements: tools are not available for this request. Reply with the summary as plain text only; "
+ "do not call any tool and do not emit tool-call or function-call tags. "
+ "You may still quote commands, paths and code that the next step needs.";
+
+const std::string kInvalidSummaryReminder =
+ "Your previous reply was not a valid summary (it contained a tool call or was empty). "
+ "Tools are disabled for this request. Write the handoff summary now as plain text.";
const std::string kSummaryPrefix =
"Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:";
@@ -24,6 +34,10 @@ const std::string& get_compact_prompt() {
return kCompactPrompt;
}
+const std::string& get_compact_invalid_summary_reminder() {
+ return kInvalidSummaryReminder;
+}
+
const std::string& get_compact_summary_prefix() {
return kSummaryPrefix;
}
diff --git a/src/commands/compact_prompt.hpp b/src/commands/compact_prompt.hpp
index 02dbaea34..afc3d8d48 100644
--- a/src/commands/compact_prompt.hpp
+++ b/src/commands/compact_prompt.hpp
@@ -4,8 +4,14 @@
namespace acecode {
-// These strings intentionally match the pinned Codex checkpoint contract.
+// These strings intentionally match the pinned Codex checkpoint contract,
+// except for the trailing "Output requirements" paragraph of the prompt: the
+// compaction request carries no tools, and some models otherwise keep "doing
+// the next step" by writing tool calls as text into the summary.
const std::string& get_compact_prompt();
+// Appended to the prompt when the previous summarization reply was rejected
+// (tool calls, tool-call markup, or blank). ACECode-specific, not from Codex.
+const std::string& get_compact_invalid_summary_reminder();
const std::string& get_compact_summary_prefix();
// Prefix the model-produced suffix exactly as Codex stores it.
diff --git a/src/computer_use/accessibility_context.hpp b/src/computer_use/accessibility_context.hpp
new file mode 100644
index 000000000..320773079
--- /dev/null
+++ b/src/computer_use/accessibility_context.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include
+
+namespace acecode::computer_use {
+
+inline void append_accessibility_focus_actions(nlohmann::json& actions, bool enabled, bool offscreen, bool focusable) {
+ if (enabled && !offscreen && focusable) {
+ actions.push_back("focus");
+ actions.push_back("raise");
+ }
+}
+
+// A focused readable control is authoritative. Otherwise a visible document
+// provides context even when toolbar buttons or other non-text controls own focus.
+inline int accessibility_document_priority(bool password, bool offscreen, bool focused, bool document) {
+ if (password || offscreen) return 0;
+ if (focused) return 2;
+ return document ? 1 : 0;
+}
+
+} // namespace acecode::computer_use
diff --git a/src/computer_use/application_identity.hpp b/src/computer_use/application_identity.hpp
new file mode 100644
index 000000000..fa4c0243b
--- /dev/null
+++ b/src/computer_use/application_identity.hpp
@@ -0,0 +1,68 @@
+#pragma once
+
+#include
+#include
+#include