Agent Client Protocol 的本地管理面板:注册 agent、发起会话、与 agent 连续对话,实时看到回复、思考与工具调用。
- 前端 — Vite + React 19 + TypeScript + shadcn/ui(Base UI + Tailwind v4)+ i18next(中/英)
- 后端 — Go + net/http + GORM + SQLite(
glebarez/sqlite,纯 Go 无需 CGO)+ 自写的 ACP 客户端
后端扮演 ACP 里的 client 角色:拉起 agent 子进程 → initialize 握手 → session/new → session/prompt → 消费 session/update 事件流 → 裁决权限。不实现 agentic loop,那是 agent(codex-acp / claude-agent-acp)自己的事。
两条 runtime 的语义差异(权限档、模型清单位置、配置项 id、rawOutput 形状等)全部收敛在 adapter 层,上层与前端只见统一词汇表(模型 / 思考深度五档 / 权限三档 / plan / fast)。规范原则是交集:两条 ACP 都能做到的才进统一接口,单端独有的功能废弃。设计与差异清单见 docs/adr-001。
acpp/
├── AGENTS.md # 通用工程规范(人与 AI 协作者共同遵守,CLAUDE.md 指向它)
├── Makefile # 常用命令入口,make help 查看;make check 一键全量验证
├── docs/ # 决策记录(adr-001 差异收敛;adr-002 工作区多面板;adr-003 messages 表退役;adr-004 macOS 桌面壳;adr-007 多租户隔离与项目管理;adr-008 数据库数据源;adr-009 子代理转录;adr-010 租户会话能力与 owner 对齐;adr-011 ACP 能力面补全;adr-012 编排与角色下线;adr-013 通知体系;adr-014 消息重试与上下文回退;adr-015 桌面壳换 Electron;adr-016~018 Discord;adr-019 服务器观察能力;adr-020 Discord 定时任务;adr-021 租户只读数据库 HTTP 面;adr-022 别的 AI 的同步问答面;adr-023 GitHub issue 页;adr-025 套餐用量取数路径;性能优化-2026-08 全栈盘点)
├── scripts/ # 开发辅助脚本(dev.sh 服务管理;check-structure.sh 结构检查;acp-probe.py 协议探针;build-macos-app.sh 桌面版打包)
├── build/ # 编译产物:build/web(vite)+ build/server/acp-server + build/app(macOS 桌面版),不入库
├── desktop/ # macOS 桌面壳
│ ├── electron/ # main/ 主进程(窗口·服务托管·菜单栏·通知·bridge);preload/ 页面通道;Info.extend.plist
│ └── icons/ # icongen.swift 程序化绘制 App 图标与菜单栏模板图
├── web/ # 前端
│ ├── AGENTS.md # 前端规范 + 设计规范
│ ├── src/
│ │ ├── main.tsx # 入口:Theme + Tooltip + i18n + Router
│ │ ├── App.tsx # 路由表
│ │ ├── routes/ # 页面,与路由表一一对应:overview / sessions /
│ │ │ # session-chat(工作区宿主,草稿态共用)/ skills / skill-detail /
│ │ │ # usage(用量报表)/ databases / servers(远程服务器)/ tools(MCP 工具台)/ jobs(定时任务)/ tenants(连接)/ github(issue 列表)/
│ │ │ # settings(系统 + claude/codex 工具分区)/ dashboard-layout /
│ │ │ # placeholder / not-found
│ │ ├── hooks/ # use-chat(SSE 状态机)/ use-draft-session /
│ │ │ # use-async-data / use-mobile /
│ │ │ # identity-context(身份与 owner 判定)
│ │ ├── i18n/ # i18next 初始化 + 类型增强 + locales/{zh,en}.ts
│ │ ├── components/
│ │ │ ├── ui/ # shadcn 组件(CLI 托管区,目录级 AGENTS.md)
│ │ │ ├── shell/ # 应用外壳:侧边栏、顶栏、导航、主题/语言切换
│ │ │ ├── chat/ # 消息渲染;composer/ 输入域;cards/ 权限、计划审批、提问卡
│ │ │ ├── workspace/ # 工作区编排(dock/menu/provider);panels/ 十类面板
│ │ │ ├── projects/ # 克隆仓库对话框(gh 清单 + URL)
│ │ │ ├── db/ # 数据库:连接对话框、库表浏览、SQL 结果表格
│ │ │ ├── servers/ # 服务器:连接对话框、验证方式文案映射
│ │ │ ├── tools/ # 工具台:工具清单、参数表单、响应视图、自定义请求、调用记录
│ │ │ ├── overview/ # 概览页四张卡
│ │ │ ├── usage/ # 用量报表:指标卡、曲线、Token 构成、分组明细、健康面板、单价表
│ │ │ ├── settings/ # 设置页分区面板(内置工具 claude/codex 的配置面)
│ │ │ └── *.tsx # 跨域小组件:status-dot / diff-view / dir-picker / agent-icon / list-page-header / list-page-states
│ │ ├── lib/ # 纯函数与客户端;README.md 是工具索引(脚本对账)
│ │ ├── types/ # 领域类型,与 server/internal/model 对齐(acp.ts 转出 db.ts、apilog.ts)
│ │ └── index.css # Tailwind v4 主题变量 + 视觉深度层
│ └── vite.config.ts # /api 代理到 127.0.0.1:48080;outDir 指向 ../build/web
└── server/
├── AGENTS.md # 后端规范
├── cmd/server/main.go # 装配层:连库、构建全部 service、挂路由、优雅关闭
└── internal/ # README.md 是包地图与跨包工具索引(脚本对账)
├── acp/ # ACP 客户端(目录级 AGENTS.md:铁律 / 文件地图 / runtime 差异表)
│ ├── protocol.go # JSON-RPC 与 ACP 线级类型
│ ├── conn.go # stdio 连接:ndjson 读写、请求关联、反向调用路由
│ ├── runtime.go # runtime 注册表 + 嵌套环境变量清理
│ ├── event.go # 归一化事件模型(推给上层的唯一形状)
│ ├── session.go # 会话状态体 + 协议原语 + prompt/steering 裸调用
│ ├── manager.go # 会话池:Open 并发去重、握手(load 恢复优先)、回收
│ ├── turn.go # 轮次执行:Prompt/Interject/Cancel + 设置门面
│ ├── updates.go # 反向调用:update 归一化、权限与 elicitation 挂起
│ ├── fsproxy.go # fs 代理(路径限制在会话 cwd)
│ ├── adapter*.go # 统一词汇表 + claude/codex/generic 三实现
│ └── isolation.go # 技能隔离注入
├── config/ # 环境变量配置、数据目录准备与迁移、路径工具
├── db/ # GORM 连接 + AutoMigrate + LIKE 模式辅助
├── model/ # Agent / Session / Message(重建 DTO) / SkillUsage / TokenUsage / APILog …
├── apilog/ # HTTP 请求日志:中间件写、日志页读(留最近 5000 条,凭证抹掉,正文截断)
├── transcript/ # 会话转录 JSONL(对话内容唯一的持久化)
├── stream/ # SSE 事件形状与广播器(会话流的叶子包)
├── project/ # 工作区项目(adr-007):git 仓库发现、克隆、gh 远端仓库清单
├── mcp/ # 我方 MCP server 的协议外壳(JSON-RPC + 工具分发),数据源工具面用
├── sshdial/ # SSH 拨号:认证方式、known_hosts 校验(accept-new)、连接建立。数据源隧道与服务器观察共用
├── remote/ # 远程服务器(adr-019):连接配置、只读观察工具面(文件 / Docker / 主机)、老数据源 SSH 配置的一次性迁移
├── datasource/ # 外部 MySQL 数据源(adr-008):连接配置、SSH 隧道(跳板机取自 remote)、库表探查、多段执行、MCP 工具面
├── discord/ # Discord 频道工作区(adr-016/017/018):频道绑定、子区对话、工作树与数据库环境锁定;定时任务的运行管线与入口(adr-020)
├── schedule/ # 定时任务调度核心(adr-020):任务与运行记录存储、cron 解析、整分钟扫描、失败退避与自动停用;Runner 与 Scope 由调用方注入
├── ask/ # 别的 AI 的同步问答面(adr-022):/api/ask 的开会话→发一轮→等轮末→取回答
├── usage/ # 轮次用量账本:轮末落账、照转录回填、聚合查询、折算单价表
├── service/
│ ├── agent.go / session.go / broker.go / system.go / fs.go / terminal.go
│ ├── tenant.go / guard.go # 多租户:租户 CRUD 与隔离范围(Scope)
│ ├── chat.go # 服务骨架与生命周期(Peek/Open/Close/回收)
│ ├── chat_stream.go # SSE 契约 + ACP 事件映射
│ ├── chat_turn.go # 发送、内容块组装、轮次执行
│ ├── chat_settings.go# 配置页取舍过滤 + 统一设置
│ ├── chat_messages.go# 转录重建读路径;rebuild.go 是重建器
│ ├── probe.go # agent 能力探测
│ └── skill*.go # 技能库、附属文件、脚本试运行、使用统计
└── httpapi/ # 路由、handler、中间件、统一响应(服务由装配层传入)
先装 ACP runtime(两条都支持,可各注册一个)。版本统一交给 Homebrew 管,
只有 claude-agent-acp 在 brew 里没有对应包,走 npm:
brew install codex-acp
npm i -g @agentclientprotocol/claude-agent-acp
codex login # codex 复用本机登录态;claude 复用 Claude Code 登录态装好之后这几项的安装与升级都能在 设置 → 环境 里一键完成。
make install
make dev # 一键启动/重启前后端(后端 :48080,前端 :45173,日志在 /tmp/acpp-dev/)make dev 每次都会重新编译后端——改完代码再跑一次就是更新;make stop 停止、make status 看状态。要盯实时日志时用 make dev-server / make dev-web 前台跑。端口是固定约定,被占会自动清掉旧进程,见 AGENTS.md §4.0。
claude 与 codex 两个工具是内置的(后端启动时自动预置记录,命令分别为 claude-agent-acp / codex-acp;改命令、启停模型与 / 命令在 设置 → Claude / Codex 分区里调,见 docs/adr-005)。任意页面点 新建会话 直接进入对话。新会话与老会话是同一个页面,只有两处差异:草稿态的模型选择器按 agent 分组列出全部可用模型(探测自动缓存,选哪个模型就用哪个工具),且状态栏里的工作目录可点击修改(选择器内可就地新建子目录);发出首条消息才真正创建会话,此后模型只能在当前 agent 内切、工作目录不可再改。
输入框支持:粘贴/上传图片、@ 引用文件(后端读内容嵌入 prompt;超过 32KB 的大文件改发 resource_link 由 agent 按需读取——芯片以链条图标标注;文件树右键与预览面板也可添加引用,文件夹引用嵌入两层目录清单而非全文)、/ 斜杠命令补全(清单来自 agent),以及 turn 进行中直接插话(不用等上一轮结束)。输入卡顶缘右侧蹲着一只吉祥物(第三方 grok-ball 引擎,MIT),表情跟着 AI 的工作状态走(思考 / 查资料 / 干活 / 回复 / 等你裁决 / 干完 / 出错),眼睛跟着鼠标看;状态全部由前端从会话状态派生,不占后端字段。
改壳或改窗口相关的界面时用 make dev-app:另起一份预览壳加载开发前端(45173),不启动 acp-server、不占 48090、不放菜单栏图标,因此和你正装着的 ACPP.app 可以同时开着对比。它靠改名换 userData 与正式版隔离(打包产物里的 package.json name 和开发态相同,不改就会被当成第二个实例挤掉),菜单里另有「开发」项可刷新与开控制台。
单进程部署(后端托管前端产物):make serve;macOS 桌面版打包:make app(见下节)。
make app 一键打包出 build/app/ACPP.app:Electron 菜单栏壳 + 捆绑 acp-server + 前端产物,图标全部由脚本程序化绘制(仓库不存二进制),ad-hoc 签名本机直接用。行为决策见 docs/adr-004,壳选型见 docs/adr-015。
壳原本是 Swift/AppKit + WKWebView(21MB),2026-08 换成 Electron(约 300MB):WKWebView 在 macOS 拖窗口 live resize 下卡顿严重,且空白页照样卡——与前端代码无关,壳侧四种规避策略全部无效,只能换引擎。改窗口参数前先读 adr-015:backgroundColor 在那里是性能开关不是外观选项,删掉它卡顿立刻回来。
窗口没有系统标题栏(titleBarStyle: "hiddenInset"):红绿灯仍是原生的,浮在界面左上角,拖动、边缘缩放、双击顶部最大化、全屏与窗口吸附全部由系统提供——前端只负责标出拖动区并给红绿灯让位(做法与硬规则见 web/AGENTS.md §5.6)。没有走 frame: false 自绘三颗按钮:视觉上没有区别,却要把上面这些系统行为逐个手写补回来。窗口顶部那 40px 因此处处可拖:侧栏让位条、内容区顶栏、右侧面板的 tab 行三段拼满,任何一段缺席都会留下拖不动的死区。
行为约定:
- 关闭 ≠ 退出:关闭按钮 / Cmd+W / Cmd+Q / Dock 退出都只是隐藏窗口,服务常驻菜单栏;真退出只有菜单栏图标右键 → 「退出 ACPP」(收到 SIGTERM 也走真退出,自更新靠这条回收全部子进程)。注意换 Electron 后的一处退让:Swift 版能读 Quit AppleEvent 的
why?参数放行系统注销/关机,Electron 没有等价 API,注销时系统会提示「应用阻止了注销」,需要确认一下(adr-015 已记,是权衡后接受的代价)。 - 菜单栏图标:左键弹 我的 issue 面板(照 codex-ui 的 Codex Viewer,图标就是一张 issue 清单),右键(或 ⌃+左键)弹操作菜单。面板是壳自绘的(
desktop/electron/popover/,Electron 原生菜单画不出带色点、状态徽标与行内按钮的行):一块贴在图标下方的vibrancy: "menu"非激活式 NSPanel,失焦即收,随系统明暗自适应。清单条件与 GitHub 页默认一致(open、排除做完 / 取消的看板列、按优先级排,最多 15 条),一行一条:优先级色点 · 编号 · 仓库 · 看板状态徽标 · 标题;点行用选定 Chrome 账号开 GitHub,点编号复制纯数字编号(行内闪「✓ 已复制」);头部「↻」带refresh=1让后端立刻去 GitHub 拉一次,底部「打开 ACPP」「查看全部 issue」(进/github)。操作菜单:打开主窗口 / 在浏览器中打开 / 用哪个 Chrome 账号打开 issue(选一个 Chrome profile,私有仓库只有某个账号有权限时用,直接跑 Chrome 二进制传--profile-directory才不会被已运行的实例忽略)/ 允许局域网访问 / 复制局域网链接 / 开机启动 / 开机最小化 / 重启服务 / 打开服务日志 / 退出。issue 清单壳里缓存、每 3 分钟刷新、弹面板时顺手再拉一次(这两种都只读后端缓存,后端自己每 3 分钟刷一轮 GitHub)。 - 开机启动走 Electron 的
setLoginItemSettings(系统设置 › 通用 › 登录项里能看到并关掉,我们只是同一个开关的另一个入口);未签名或不在「应用程序」下时系统会拒绝注册。Electron 的这个 API 不报错,壳改成设完回读比对,对不上就把原因回给设置页。 - 系统通知(决策 / 问答 / 答完了 / 出错了):授权不在启动时索要,由设置页 › 系统 › 通知里的开关发起——启动就弹授权框最招人烦,而且用户还没见过这个 app 会通知什么。两个实测坑(macOS 26):app 必须待在「应用程序」目录,放在别处
requestAuthorization会直接返回Code=1且连系统弹窗都不出现,状态停在 notDetermined,看着像什么都没发生;一旦被拒就再也弹不出来,只能拉起系统设置(x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=<bundleID>)让用户自己开。设置页两种情况都会如实说明并给出对应按钮。ad-hoc 签名够用,不需要开发者证书。决策通知上带的按钮就是 agent 当场给的选项(按下即裁决,最多 4 个);问答不带按钮,它是结构化多题(选择 + 自由输入),塞不进一条通知,点开会话回答。换 Electron 后两处如实降级(adr-015):查不到真实授权状态(没有getNotificationSettings的等价物),壳按「发过没有、成功没有」推断,「请求授权」就是发一条示例通知去触发系统弹框;没有 threadIdentifier,同一会话的通知不再堆叠成组。 - 开机最小化:勾上后开机只驻留菜单栏,不弹窗口也不占 Dock(运行时
app.dock.hide(),不用 LSUIElement)。只管开机那一下——用户从菜单栏打开窗口后就切回正常 app,Dock 图标与主菜单一并回来(复制粘贴依赖主菜单的 Edit 项,没有它 Cmd+C/V 全失效)。 - 端口固定
48090,与开发态 48080 隔离——make dev与桌面版互不误杀,可同时运行;数据共用~/.acpp,桌面版和 dev 看到同样的会话。 - 局域网共享默认关(工作区终端是任意命令执行面,见 §安全姿态)。菜单栏开启后服务监听
0.0.0.0,「复制局域网链接」得到http://<局域网IP>:48090/,发给局域网内其他设备即可在浏览器使用完整 web 端。切换开关会重启后台服务(agent 上下文在 runtime 侧持久化,续聊自动恢复)。 - 服务日志:
~/Library/Logs/ACPP/server.log。agent 子进程的 PATH 取自登录 shell——GUI app 默认拿不到 Homebrew 路径,壳启动时注入,否则拉不起codex-acp/claude-agent-acp。
打包脚本 scripts/build-macos-app.sh(--skip-web 复用已有前端产物提速,APP_VERSION 覆盖版本号,版本与发布仓库经 ldflags 注入后端);壳源码在 desktop/electron/。
版本发布与更新:make release VERSION=0.2.0(scripts/release-macos.sh)构建 → zip → git tag → GitHub Release(notes 缺省取上个 tag 以来的提交标题)。App 内 设置 → 关于与更新 后台每日自动检查 Releases,显示新版本描述,桌面版可一键「更新并重启」(下载 zip → 原地替换 .app → 壳正常退出回收子进程 → 自动拉起新版);开发态只提示不安装。
浏览器 ──POST /api/sessions/{id}/send──→ 立刻 202,只落库用户消息
│ │
│ └─→ goroutine: session/prompt(阻塞到整轮结束)
│ │
└──GET /api/sessions/{id}/events (SSE)←─── broker ←── session/update 通知(逐 chunk)
关键点:
session/prompt阻塞到整轮结束,流式的唯一来源是session/update通知。所以send不等它,chunk 一到就经 SSE 写出并 flush,中间层不攒。- 一条用户对话 = 一条 ACP 会话。ACP 会话自带上下文,第二轮起只发用户这一句,不重复系统提示。
stopReason只有end_turn算正常说完;max_tokens/max_turn_requests/refusal/cancelled会在界面上标出来,不会被当成完整回答。tool_call_update除toolCallId外全是可选,前后端都按 id 合并,空值不覆盖已有字段。- 每轮结束后由重建器按时序还原成多条消息:正文被工具调用打断处就是断点(agent「说一句 → 干点活 → 再说一句」会还原成两条消息,而不是首尾相接的一条),同一段内的连续分片仍然合并。前端收到
turn_done后重新拉取消息列表,用重建结果取代流式拼接的文本,所以偶尔丢一个 chunk 也不会留下残缺消息。除正文/思考/工具调用外,重建器还产出:轮末的 plan 快照(kind=plan,历史里折叠成一行进度)、权限裁决记录(kind=permission_request,谁请求 + 用户选了什么)、挂在最后一段正文上的本轮 token 计量(payload.turnUsage,hover 可见)。
统一响应 {"data": ...} 或 {"error": "..."},列表再包一层 {items, total, page, pageSize}。
分页协议:所有列表端点统一收 ?page=&pageSize=(页码从 1 起,缺省 20,上限 200,解析在 httpapi.pageParams),统一回 {items, total, page, pageSize}。事实源是数据库的走 LIMIT/OFFSET,是磁盘的(技能)在内存切片——形状对调用方一致。
排序协议:列表端点另收 ?sort=<字段>&order=asc|desc。字段名是数据库列名(技能没有数据库,沿用同样的 snake_case 写法),走白名单校验后才拼进 ORDER BY——那是不能用占位符的位置;认不出的字段当没排序,不报错。排序必须在服务端做:客户端排序在分页列表上是错的,它只会把当前这一页重排一遍,用户以为看到的是「全部里最大的」,其实是「这 20 条里最大的」。同理,技能的内存排序也在切页之前。
传输:文本响应(JSON / HTML / JS / CSS)统一走 gzip,由 httpapi.withCompression 惰性决定——响应类型与体量都够了才压。三条边界原样直通,压错比不压严重得多:SSE(压了分片会攒在压缩窗口里,第一个 token 迟到)、带 Range 的请求(日志面板每 2 秒尾随读转录、媒体预览拖进度条,都靠字节偏移对齐)、WebSocket 升级(连接要被 Hijack 走)。静态产物按内容哈希永久缓存(immutable),入口 HTML 恒 no-cache 走协商——那是「更新完不用手动刷新」的前提。前端产物本身按路由懒加载(终端模拟器与图表库都不进首屏),配上压缩后首次打开的传输量是 270 KB。全栈的性能盘点、每一项的改前改后数字与复现方法见 docs/性能优化-2026-08。
前端四个列表页(会话 / 访客 / 数据库 / 技能)共用 usePagedData + DataTable:翻页、每页行数、表头三态排序(无序 → 升 → 降 → 无序)都发给后端,列显隐留在客户端(那是一个人此刻想看什么,不是配置)。行为(改排序或每页行数回第一页、删空当前页退回上一页)因此只有一套。
列表页的版式也只有一套(与 gosaic 的 CRUD 页同一副骨架),自上而下固定四区,全部由 DataTable 画、页面只往里填内容:搜索区(SearchBar + 关键词 / 枚举控件,草稿与已提交分开,点「查询」或回车才请求;关键词一律走 ?q=,服务端 LIKE 且转义通配符)→ 操作区(左:新建 / 批量按钮;右:刷新 + 列显隐)→ 表格区(一张卡片,有行画表、没行在同一张卡里画三态壳)→ 翻页(靠右,只有一页时不出现)。页头是 ListPageHeader(标题 + 「共 N 条」)。请求飞行中表格区顶端亮一条进度线、内容压暗、刷新图标转圈,且至少亮完一趟 500ms(useMinLoading),快请求不闪。搜索区与操作区在没有行时照常在——筛出 0 条时最需要的恰恰是清掉条件的那个控件。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/health |
健康检查与版本({status, version, repo},repo 是发布仓库标识,前端拼版本日志链接用) |
| GET | /api/events |
全局 SSE 流(与会话无关):连上先收 {kind:"hello", version},之后推 {kind:"notify", ...}。两个用途见下方「更新后怎么让所有人刷新」与「通知」 |
| GET | /api/auth/me |
当前身份(owner / 租户 / 被停用 / 匿名)。未认证也返回 200,前端据此渲染邀请页 |
| POST | /api/auth/redeem |
用邀请链接里的 token 换 HttpOnly cookie({token}) |
| POST | /api/auth/logout |
清凭证 |
| GET | /api/logs |
请求日志列表(owner 专属;?q=路径关键词、method=、status=状态码百位、identity=、分页与排序)。中间件对每条 /api 请求记方法、路径、查询串、状态、耗时、对方 IP、来源地址(Origin / Referer)、身份、请求与响应的头和正文;SSE、WebSocket 升级与 /api/logs 自身不记,凭证头抹成 [redacted],正文只留前 8 KB,最多留最近 5000 条。列表不带头与正文 |
| GET | /api/logs/{id} |
一条请求的完整记录(含头与正文) |
| DELETE | /api/logs |
清空请求日志 |
| GET/POST | /api/tenants |
局域网访客列表(?q=名字关键词、`disabled=1 |
| PUT/DELETE | /api/tenants/{id} |
停用/启用、改 root、设 githubLogin(访客的 GitHub 用户名,GitHub 页按它筛「分配给我」)/ 删除(保留其会话与目录) |
| POST | /api/tenants/{id}/rotate |
重新生成分享链接(旧链接立刻作废) |
| GET | /api/github/issues |
GitHub issue 列表(adr-023,租户可用):当前身份关注仓库里的 issue 一页。?repos=逗号分隔限定仓库、q=标题关键词或编号、`assignee=me |
| GET/PUT | /api/github/repos |
可关注的仓库清单(gh 登录账号能看到的全部,含个人名下的;每条带 watched)/ 覆盖当前身份的关注清单({repos:["owner/name",…]},立即拉一遍新加的仓库) |
| GET/POST | /api/projects |
工作区项目(工作区根下的 git 仓库;每条带 name 位置与 repo 身份)/ 新建空项目({name},最多 <组织>/<仓库> 两层) |
| DELETE | /api/projects/{name...} |
删项目目录(会话记录保留) |
| POST | /api/projects/clone |
后台克隆({url, name?});租户强制禁用 git 凭证助手 |
| GET | /api/projects/clones |
克隆任务进度(内存态,只对发起者可见) |
| GET | /api/projects/repos |
可克隆仓库清单(gh,只要组织与协作关系,个人账号名下的不出现) |
| GET | /api/system/env |
环境体检:brew/node/npm、CLI 与 ACP 适配器是否就位(含版本与路径);除 node 外各项附带包管理器上的最新版与 outdated 标记(brew 项查 formulae.brew.sh,npm 项查 registry),?refresh=1 绕过 5 分钟缓存重查。命令若还是旧的 npm 全局安装占着,附 migrateHint 给出清理命令 |
| POST | /api/system/env/install |
一键安装/升级依赖({key},只认后端白名单)。除 claude-agent-acp 外全部走 Homebrew(cask:codex、claude-code;formula:codex-acp、node),已装的自动改用 brew upgrade;claude-agent-acp 在 brew 里没有包,走 npm install -g。被旧 npm 安装占着命令名的项直接拒绝,让用户先清理 |
| GET | /api/system/title-model |
会话标题模型配置(本机 ollama:{enabled, baseUrl, model}) |
| PUT | /api/system/title-model |
存标题模型配置,热更生效(启用时必须选模型) |
| GET | /api/system/title-model/models |
列某个 ollama 端点上已装的模型(?baseUrl=,为空取默认地址) |
| POST | /api/system/title-model/test |
用给定配置当场生成一个标题看效果,不落盘 |
| GET/PUT | /api/system/codex-home · /api/system/codex-home/file?name= |
codex 隔离 home 的两个文件:config.toml(系统配置的一次性副本,给这里的 codex 换模型/provider 改的就是它)与 auth.json(软链系统登录态,写它等于改系统那一份)。只认这两个名字,不是文件管理器 |
| POST | /api/system/codex-home/reveal |
在访达里打开 codex home 目录(仅 macOS) |
| GET | /api/system/quota?flavor=claude|codex |
套餐用量:本机登录账号在订阅上的限额水位(5 小时窗 / 周窗 / 按模型的周窗,各带已用百分比与重置时刻),不是本地账本——账本记这台机器花了多少,水位是账号在服务端还剩多少。claude 借 CLI 的 /usage 数据面取(claude -p 的 stream-json 控制请求,令牌过期由 CLI 自己刷新,本项目不碰钥匙串);codex 用 auth.json 的登录态打 ChatGPT 的 usage 接口,另带额度余额与「这里的 codex 是否走第三方 provider」。status 非 ok(expired / not_logged_in / unavailable / error)时窗口为空、界面给引导。后端缓存一分钟,?refresh=1 绕过。对租户开放(花的是同一份额度)。取舍见 docs/adr-025 |
| GET | /api/system/update |
版本检查(GitHub Releases 缓存,后台每日刷新;?force=1 现查)。pending 带当前版本与最新版本之间全部待更新版本的日志(最多 5 条,更早的计入 pendingMore)——跨版本更新时中间几版改了什么也要看得到 |
| POST | /api/system/update/apply |
一键更新:后台下载最新 release 替换 .app 并自动重启(仅桌面版)。立即返回 {applied:true, progress},进行中再调只回同一份进度(不会开第二份下载)。下载按停滞判失败(连续 90 秒没有新字节),不设总时长上限——慢链路上 129MB 要十几分钟,以前 10 分钟的硬上限会把它整段掐断。有会话正在生成回复时返回 {applied:false, runningTurns} 供前端弹确认,body 带 {force:true} 才真装 |
| GET | /api/system/update/progress |
一键更新的进行态:phase(idle / downloading / paused / unpacking / installing / restarting / done 装好了但要手动重开 / failed)、downloaded / total 字节、speed(最近 3 秒平均,字节/秒)、message / error。前端下载期间每 0.5 秒轮询,页面刷新后也能接着看。下载可续传:半成品放 <dataDir>/updates/,断线自动按 Range 接着下(连续 5 次毫无进展才放弃),进程重启后 phase 直接报 paused 并带上下到哪 |
| POST | /api/system/update/pause · /api/system/update/discard |
暂停下载(只有下载阶段能暂停,半成品留着,再 POST apply 就是续传)/ 放弃这次更新(停掉下载并删掉半成品,回到 idle) |
| GET | /api/discord |
discord 频道工作区总览(adr-016,bot 申请与双 bot 隔离见 docs/discord-bot-setup.md,owner 专属):{config:{enabled,tokenSet,workRoot}, status:{running,connected,botUser,guilds…}, bindings, catalog};token 永不回传 |
| PUT | /api/discord/config |
存 discord 配置({enabled?, botToken?, workRoot?},token 空串=清除),gateway 即时起停 |
| PUT | /api/discord/bindings/{channelId} |
改频道绑定的模型/思考深度(换仓库、换绑数据库走频道里的 /init 与 /db source) |
| DELETE | /api/discord/bindings/{channelId} |
解绑频道(工作树有未提交/未合回 base 的东西才保留,否则连分支一起清理);频道名下的定时任务一并删除 |
| GET | /api/discord/jobs |
定时任务清单(adr-020,owner 专属):scope 是频道 id,plan 是计划的人话,nextRunAt 含失败重试,runs 是最近 30 次运行 |
| POST | /api/discord/jobs |
新建({channelId, name, cron, tz?, at?, prompt},cron 与 at 二选一;频道必须已绑定),201 |
| PUT | /api/discord/jobs/{id} |
改任务(缺省字段不动;enabled:true 会清掉自动停用原因与连败计数) |
| DELETE | /api/discord/jobs/{id} |
删任务 |
| POST | /api/discord/jobs/{id}/run |
立即跑一次(不看启用状态;正在跑时 400),202 |
| GET | /api/fs/dirs |
列目录(?path=,空为家目录;?files=1 连文件、?hidden=1 含隐藏项;条目带大小与修改时间),供选择器导航 |
| GET | /api/fs/places |
选择器侧边栏的默认位置(家目录/桌面/文稿/下载/工作区;租户只有自己的 root) |
| POST | /api/fs/dirs |
在指定目录下新建单层子目录({path, name}),选择器就地建目录 |
| GET/POST | /api/agents |
agent 列表 / 新建(新建后自动探测模型与命令清单) |
| GET/PUT/DELETE | /api/agents/{id} |
agent 详情 / 更新 / 删除 |
| POST | /api/agents/{id}/probe |
重探统一设置能力(flavor、模型与命令清单),同步返回 |
| PUT | /api/agents/{id}/catalog |
配置页勾选:更新 models/commands 的启用状态(禁用只影响本软件的下拉与补全,agent 侧能力不变);另收 askModel / askEffort(AI 协作会话的模型与思考深度,adr-022,必须在探测清单内,空串=沿用默认) |
| GET/POST | /api/skills |
技能列表(遍历 <dataDir>/skills,磁盘为事实源;?q=名字或描述关键词、`enabled=1 |
| GET/PUT/DELETE | /api/skills/{name} |
技能详情(body 为 frontmatter 之后的正文)/ 更新({description?, body?, enabled?} 逐项可选,启停即建/删 skillpack 符号链接)/ 删除(连源目录带分发链接) |
| GET/PUT/DELETE | /api/skills/{name}/files/{path...} |
附属文件(references/ / assets/ 等)读 / 写 / 删;文本可编辑、二进制只列出,路径限制在技能目录内 |
| GET | /api/skills/{name}/files |
附属文件清单(带 size / binary / 修改时间) |
| GET | /api/skills/export |
导出技能为 zip(换设备搬家):整库一个包,/api/skills/{name}/export 只导那一个。包内结构就是技能库的结构(<name>/SKILL.md + 附属文件) |
| POST | /api/skills/import |
从 zip 导入(multipart file):还原回来的技能一律停用,同名的跳过而不覆盖,目录名归一到 kebab-case 并对齐 frontmatter 的 name;带路径穿越的包整包拒绝。回 {imported:[名字], skipped:[{name, reason}]},reason 是 exists / invalid_name / no_doc |
| GET | /api/skills/{name}/scripts |
scripts/ 下脚本的头部元信息(desc/usage/arg/opt/env 注释解析成参数控件描述) |
| POST | /api/skills/{name}/scripts/run |
传参试运行脚本({path, args, opts, env}):以技能目录为 cwd、60s 超时、输出各 256KB 截断,返回退出码与 stdout/stderr |
| GET/POST/DELETE | /api/uploads |
本机文件上传:列出传过的 / 上传(multipart file,单个 ≤32 MiB)/ 删除(?hash=&name=)。落点是各自身份的家目录(owner 是工作区根,访客是自己的 root)下的 .acpp-uploads/<内容 hash 前 12 位>/<原名>——隔离由路径本身给,不需要再加一层归属过滤;同内容不重复写盘 |
| GET/POST | /api/sessions |
会话列表(?q=&agentId=&origin=&state=&page=&pageSize=,q 是标题关键词、state 精确匹配;按更新时间倒序分页;origin=ask 只要别的 AI 问出来的、user 只要界面里开的)/ 新建({agentId, cwd?, title?, worktree?}:带 worktree 时先开隔离工作区再把会话开在里面) |
| GET/PATCH/DELETE | /api/sessions/{id} |
会话详情(Peek:绝不拉进程,查看记录零成本;未连接时 settings/commands 由 agent 探测缓存降级拼出,Current* 留空) / 改标题({title},空白不受理——标题原本由后端从首条消息自动简写,这里让用户改成自己认得的说法) / 删除(回收子进程,并尽力调 session/delete 清掉 agent 侧线程历史) |
| GET | /api/sessions/{id}/messages |
历史消息(?limit= 取尾部 N 条,?before=<id> 加载更早)。正文优先:工具调用的超大入出参截成预览下发(rawInputTruncated / rawOutputTruncated 标记),完整版展开时按需拉 |
| GET | /api/sessions/{id}/outline |
提问索引:会话里全部用户提问的锚点与文案({items:[{messageId, text, createdAt, digested, reply}], pending},reply 是这一轮回答的开头,给索引气泡当第二行),供对话左侧的索引条跳转。不分页——服务端在已缓存的重建结果上遍历,覆盖整条会话而与界面加载到哪儿无关 |
| GET | /api/sessions/{id}/tool-calls/{toolCallId}/output |
一次工具调用的完整入出参({rawInput, rawOutput}),工具卡展开那一刻按需取 |
| GET | /api/sessions/{id}/fs/entries |
工作区文件树(?path=&depth=,depth≤2;全量展示,仅过滤固定黑名单 .git/node_modules/.DS_Store,路径限制在会话 cwd 内) |
| GET | /api/sessions/{id}/fs/file |
工作区文件预览(?path=;1MB 截断、二进制检测,同上 path guard) |
| GET | /api/sessions/{id}/fs/watch |
工作目录文件变动流(SSE):目录里有东西变了就推一条 {kind:"fs_changed"},工作区面板据此整片重读。事件不带路径——面板本来就要重读文件树、git 汇总与正在看的那个文件。成串事件在后端合帧(400ms),.git/node_modules/产物目录里的动静不算数。macOS 走 FSEvents 递归流——整棵树一个句柄,1.3 万个目录的工作区根也不额外花钱。监视建不起来时推一条 {kind:"unavailable"} 就收线,客户端不再重连、退回手动刷新 |
| GET | /api/sessions/{id}/fs/table |
csv/tsv/xlsx 摊平成表格(?path=,返回 {sheets:[{name,rows,truncated}]},csv 是只有一页的特例)。csv 走标准库、xlsx 走 excelize;非 UTF-8 的 csv 按 GBK 兜底解码(Excel 导出默认就是它);5000 行 / 200 列 / 32MB 三道闸,超了如实标记 |
| GET | /api/sessions/{id}/fs/download |
原样下发文件(?path=;?archive=1 把目录边打包边发 zip)。?inline=1 改成浏览器内预览:按扩展名给真实 Content-Type + Content-Disposition: inline,PDF/图片/音视频/纯文本浏览器自己就画得出来;HTML/SVG 这类可能自带脚本的加 CSP: sandbox(同源下不设防就能读走身份 cookie),白名单外的类型回落成另存为 |
| GET | /api/sessions/{id}/git/overview |
git 汇总:分支、upstream、ahead/behind、变更文件(含 numstat)、未推送 commit;非仓库返回 isRepo:false |
| GET | /api/sessions/{id}/git/diff |
单文件工作区 diff(?path=,返回 HEAD 版与工作区版全文,行级对齐在前端) |
| GET | /api/sessions/{id}/git/commits/{sha} |
提交详情(文件清单);带 ?path= 时返回该文件在这条提交前后的全文 |
| GET | /api/sessions/{id}/git/branches |
分支面:当前分支、本地/远端分支、标签、worktree 清单(被占用的分支带占用者) |
| POST | /api/sessions/{id}/git/checkout |
切换分支({branch, create?});脏工作区拒绝切换 |
| GET | /api/sessions/{id}/git/history |
提交链路(?ref=&limit=&offset=,hasMore 指示还有没有) |
| GET | /api/sessions/{id}/git/compare |
两 ref 对比(?base=&head=):head 独有的提交 + 三点 diff 的文件变更 |
| POST/DELETE | /api/sessions/{id}/git/worktrees |
开/拆隔离工作区(<仓库>/worktrees/<名字>),拆时保留分支 |
| * | /api/workspace/fs/* /git/* |
草稿态工作区数据面:会话还没建,目录由 ?cwd= 给(路径闸照旧),端点形状与上面会话侧一一对应——选完工作目录文件树与 git 面板即可用(adr-002) |
| GET/POST | /api/sessions/{id}/terminals |
工作区终端列表 / 新建(会话 cwd 起交互 shell,每会话上限见 ACP_MAX_TERMINALS) |
| DELETE | /api/sessions/{id}/terminals/{tid} |
关闭终端(杀 pty) |
| WS | /api/sessions/{id}/terminals/{tid}/ws |
终端双向流:二进制帧 = 原始字节,文本帧 = {"type":"resize","cols","rows"};断线后 pty 保活 30s 供重连(带回放缓冲) |
| POST | /api/sessions/{id}/open |
拉起 agent 并握手,幂等。前端不再主动调——发消息时 send 顺路连接(懒连接),连接完成经 SSE 推 settings/commands |
| POST | /api/sessions/{id}/send |
发一轮({content, images?, files?}:图片 base64、@ 引用文件路径由后端读内容嵌入),立即返回;turn 进行中再发会插进当前轮(claude 排队为独立一轮,codex steering 注入当前轮) |
| GET | /api/sessions/{id}/events |
SSE 事件流 |
| GET | /api/sessions/{id}/transcript |
线级转录 JSONL 原样下发(http.ServeFile,支持 Range 字节续读——工作区 logs 面板靠它轮询增量实时跟随) |
| GET | /api/system |
系统配置:当前/默认数据目录,pendingDir 表示已迁移待重启。响应另带 lanBase(局域网可访问的地址前缀 http://<ip>:<端口>,界面用它把相对路径拼成可转发的完整链接)与 lanShareable(这条地址当下能不能真发出去:只监听回环时给 127.0.0.1) |
| PUT | /api/system/data-dir |
迁移数据目录({dataDir} 绝对路径):VACUUM INTO 在线快照 + 转录拷贝 + 写 ~/.acpp/config.json,旧数据保留,重启后生效 |
| PUT | /api/system/workspace-dir |
改工作区根({workspaceDir}):agent 干活的地方与访客 root 的父目录,立刻生效 |
| POST | /api/sessions/{id}/cancel |
中止当前轮 |
| POST | /api/sessions/{id}/retry |
重跑最后一条用户消息:不必重发原文,界面上不留重复气泡;claude 会话还会把 agent 侧上下文退回那条消息之前(响应 {rewound} 说明是否做到,codex 一律 false),见 docs/adr-014 |
| PUT | /api/sessions/{id}/settings |
统一设置({model?, effort?, level?, plan?, fast?} 逐项可选),响应带最新 Settings;未连接的老会话会先幂等拉起进程再应用。turn 进行中也能改:界面在轮里只放开权限档与思考深度(前者是就地管住 agent 的唯一手段,后者给下一轮预约),模型/plan/fast 锁到轮末。生效时机两端不同——权限档 claude 立刻对本轮生效、codex 要等下一轮(档位是轮开始时的快照),思考深度两端一律下一轮;控件的悬停说明照实写明 |
| POST | /api/sessions/{id}/permission |
回传权限裁决({permissionId, optionId},optionId 空=取消)。卡片挂起最长 30 分钟(等真人点选的反向调用统一这个时限,含交互式提问;机器应答的 fs 读写仍是 1 分钟),超时按 cancelled 回给 agent,那一步工具调用随即失败 |
| GET/POST | /api/servers |
服务器列表(?q=名称或主机关键词)/ 新建({name, host, port, user, auth, password?, keyPath?, passphrase?, note?};凭证永不下发,响应只给 hasPassword / hasPassphrase 标志位) |
| GET | /api/connections/export |
连接配置的换设备搬家:私钥 + 服务器 + 数据源导出成 jsonl(一行一条,kind 区分,按这个次序写,后面的靠名字引用前面的)。默认带凭证(密码、私钥内容与通行短语)——搬过去就能连是这件事的意义所在;文件名标 -secrets,?secrets=0 导一份不带的。跳板机与私钥都按名字引用而不是 id(id 是本机自增的,换台机器必然对不上) |
| POST | /api/connections/import |
从 jsonl 导入(multipart file):同名的跳过而不覆盖,引用了不存在跳板机的数据源跳过并说明,坏行只跳过自己。回 {imported, skipped:[{name,reason}], needSecret}——needSecret 是还缺密码、连不通的那些 |
| GET/PUT/DELETE | /api/servers/{id} |
服务器详情 / 更新(凭证留空=不改) / 删除(被数据源当跳板机用着的不让删) |
| POST | /api/servers/probe |
测一份还没保存的配置(新建对话框的按钮) |
| POST | /api/servers/{id}/test |
测一条已存记录;请求体带表单内容则先合并再测,传 {} 表示就测这条 |
| GET | /api/servers/{id}/secret |
明文凭证(密码 + 通行短语),密码框的「看一眼」用。口径同数据源那条 |
| POST | /api/mcp/server/{token} |
agent 回连:服务器观察工具面的 JSON-RPC 端点(公开,凭会话 token;不在 owner 前缀内) |
| GET/POST | /api/datasources |
数据库连接列表(?q=项目/库名/主机关键词、env=、`readOnly=1 |
| GET/PUT/DELETE | /api/datasources/{id} |
连接详情 / 更新(密码留空=不改) / 删除 |
| POST | /api/datasources/{id}/test |
测试连接(连不上返回 200 带 {ok:false, error},那是配置问题不是服务故障) |
| POST | /api/datasources/probe-databases |
配置页选库:列出这组连接参数可见的库(参数走请求体,编辑时带 id 沿用已存密码) |
| POST | /api/datasources/probe-ssh |
配置页 SSH 页签单独测隧道,不碰 MySQL(拨的是 serverId 指向的那台机器) |
| GET | /api/datasources/{id}/databases /tables /schema |
库清单 / 表清单(?database=) / 表结构(?database=&table=,含列、索引与建表语句) |
| POST | /api/datasources/{id}/query |
执行 SQL({database?, sql, maxRows?},可含多条语句:按序执行、遇错即停,每条独立返回耗时与影响行数;行数硬顶 1000) |
| GET | /api/datasources/{id}/uri |
导出连接 URI(Navicat 与通用两种写法,含真实密码——那条链接本身就是凭证) |
| GET | /api/datasources/{id}/secret |
明文密码:密码框的「看一眼」与「复制连接」用。与 URI 导出同口径,owner 专属;会话侧的清单端点永远只给 hasPassword |
| GET | /api/sessions/{id}/datasources |
会话可见的数据源:只有当前工作目录所属项目的那几条(斜杠命令数据源) |
| GET | /api/sessions/{id}/datasources/{dsid}/databases /tables |
同上但按会话过滤,项目之外的 id 按「不存在」处理 |
| GET | /api/workspace/servers |
会话可见的服务器(@ 引用选择器用):不按项目过滤,租户也能取,响应不含凭证 |
| GET | /api/workspace/datasources 及 .../{dsid}/databases /tables |
草稿态数据源:项目由 ?cwd= 的目录决定——选完工作目录 @ 引用与 /db 即可用,不必等首条消息建会话;过滤规则与会话侧相同 |
| GET | /api/db/sources |
租户与脚本的只读数据库面(adr-021):全部启用数据源,?project= 过滤,不含密码。不经工作目录、不在 owner 专属前缀内;凭证走租户 cookie 或 Authorization: Bearer <租户 token> |
| POST | /api/db/query |
同上一面的只读查询:{source, sql, maxRows},source 是 <项目>/<环境> / 环境名 / 数据源 id(与 db_* 工具同一套写法);响应比 /api/datasources/{id}/query 多一个 source 说明落到了哪条。写语句一律拒绝:只读源 403、可写源 400 |
| GET | /api/usage/summary |
用量合计(租户可用,只看得到自己的账):筛选参数就是页面上那一排选择器——from=/to=(YYYY-MM-DD,按本地时区的日界,to 那天算全;缺省最近 14 天,from=0 要全量)、tenant=(owner 才有意义)、flavor=、project=、origin=、model=、session=。回一组合计 + previous(紧邻的上一个等长周期,供界面显示环比;没给时间范围时不带)。合计里 token 分五项(输入 / 输出 / 缓存读 / 缓存写 / 思考),成本分 reportedMicro(agent 自报)与 estimatedMicro(按单价表折算),另有 unpricedTurns——没有价的轮子单独数,不按零算 |
| GET | /api/usage/series |
用量曲线:同一套筛选参数 + bucket=day|hour(默认 day)。补齐没有数据的格子——没有活动的那天如果整格消失,两周的曲线会被压成三个点 |
| GET | /api/usage/breakdown |
分组明细:by=project|tenant|flavor|model|origin|session|day(默认 project),limit=(默认 50,上限 200)。六个维度是同一条 SQL 换 group by,不是六个端点;维度名走白名单,认不出当场 400。按成本倒序,成本相同时退到 token 量 |
| GET | /api/usage/errors |
异常明细:agent 报回来的错误,按类聚合(过载 / 额度 / 登录 / 未分类,从文案认,只用于展示)+ 最近 N 条原文。轮次收尾与工具失败这两层本来就在合计里,不单独跑 |
| GET·PUT | /api/usage/prices |
折算单价表(读所有人可以,改是 owner 专属)。按模型 id 精确匹配,匹配不上退到 runtime 方言的兜底价(claude 报的模型名多半只是档位,一个个配没有意义);单位统一是美元 / 百万 token,四项分开(输入 / 输出 / 缓存读 / 缓存写,思考项留空时按输出计)。不内置任何默认价——模型 id 一个月里就能改,猜一个填进去报表会拿它一路算下去;没配就是「未计价」,界面如实显示。保存时校验负数与离谱量级,rev 自增并落进每一行账目(改价之后已记的账不变,要重算走 backfill) |
| POST | /api/usage/backfill |
照转录重算全部历史账目(owner 专属)。按会话先删后建,幂等;会话记录已经没了的转录跳过并单独报数(账目必须有主人)。实测 224 份转录 1.5 秒 |
| POST | /api/ask |
别的 AI 的同步问答面(adr-022):本机 CLI 里的 claude / codex 经它把问题交给另一方。{agent, cwd, prompt, level?, thread?, title?}——agent 按内置工具名认(claude / codex),level 是权限档(safe 默认只读 / auto-edit / full),thread 带上即续聊(此时 agent / cwd 忽略、level 省略表示不动),title 给新会话起名(省略按 prompt 首句简写;prompt 前注入了角色 header 的调用方应该传)。阻塞到轮末才回 {thread, text, stopReason?}:text 是这一轮 agent 说的全部正文(工具调用之间的段落按序拼接),thread 是 acpp 会话 id,界面里能点开看全程。权限请求由后端替人裁决(full 放行一次、其余拒绝一次),交互式提问一律取消;同一 thread 上一轮没完再问回 409,等答案途中界面上的人插话也回 409(会话与那一轮留给人,不取消);单轮上限 1 小时(超时 504),调用方挂断即中止那一轮。新会话的模型与思考深度取该工具配置页「AI 协作」里的设定。回环即 owner,脚本不需要凭证;owner 专属,租户 403 |
| POST | /api/mcp/db/{token} |
会话的数据库 MCP 端点(agent 回连,token 为每会话专属凭证,不出现在 API 响应里) |
| POST | /api/mcp/report/{token} |
会话的报告 MCP 端点(agent 回连,同一套 token)。工具 report_open 把 agent 写好的单文件 HTML 报告在用户工作区打开;只收路径不收全文,且限死会话工作目录内的 .html |
| POST | /api/mcp/discord-cron/{token} |
discord 子区的定时任务工具面端点(与上一条同一枚凭证):cron_add / cron_list / cron_update / cron_remove,投递固定为子区所属频道。一次性任务用 at(RFC3339)或 in(相对时长 2h / 90m / 1d,服务端按当前时刻换算);cron_add 的描述里注入会话开始时刻,模型据此换算「明早 9 点」 |
| POST | /api/mcp/discord/{token} |
discord 子区自家工具面端点(agent 回连,内存凭证)。send_file 交付文件(paths 一次最多 10 个,as 选形态:auto / file 附件 / link 渲染外链 / image 长图,expire 定外链有效期,默认 7d);list_links 列出本子区还有效的外链;revoke_link 撤销(传 id 或 all)。外链是 secret gist + gistpreview 渲染页,只能撤 acpp 自己发的 |
| GET | /api/tools/servers |
工具台:当前上下文(?cwd=)下的 MCP 工具面——工具名、给模型看的描述原文、参数 JSON Schema、只读/破坏性注解,外加这个面会不会真的挂给 agent(数据源为空就不挂) |
| POST | /api/tools/inspect |
工具台试运行与自定义请求({cwd, request},request 是原样的 JSON-RPC 消息):走与 agent 完全相同的协议路径,回完整响应与耗时;通知类消息回 accepted:true(协议上就没有响应) |
| GET | /api/tools/calls |
调用记录(?server=&tool=&source=&errorsOnly=1 + 分页,时间倒序) |
| GET | /api/tools/calls/stats |
按工具聚合的调用统计(次数、失败数、平均耗时、最近使用) |
| DELETE | /api/tools/calls |
清空调用记录 |
SSE 事件的 kind:user_message、message_chunk、thought_chunk、tool_call、permission、permission_done、permission_auto、plan、settings、usage、commands、elicitation、elicitation_done、turn_end、session_title、retry、report_open、turn_done、error。每条带单调递增的 seq,断线重连时用它去重。settings 在 agent 自行切档/改配置时带全量统一视图(含 prompt 内容能力:{image, audio, embeddedContext},来自 initialize 的 promptCapabilities,claude/codex 由 adapter 按实测兜底,generic 按声明——前端据此门控图片按钮,后端在发送前把越界内容块收敛:resource 降级为 text、图片直接报错);usage 是上下文用量 {used, size}(claude 会间歇附带累计费用 cost:{amount,currency},状态栏顺带显示,codex 无此字段则不出现);turn_end 附带本轮 token 计量(两端交集字段);permission 表示 agent 阻塞等用户裁决(带选项列表),裁决走上表的 permission 端点;同一时刻可能挂起多个——agent 会并发发起(比如并行读几个文件),界面必须把它们全渲染出来,只画一张会让没被裁决的那个永远挂在 agent 侧,整轮就此卡死。permission_auto 是后端替用户放行、没弹卡片的请求(只发生在「读技能包内的文件」这一种情形,见下文权限裁决一节),界面只留一条「已自动允许」的痕迹,不进通知中心。retry 表示最后一条用户消息正在重跑,界面据此撤掉那条消息之后的内容(rewound 说明 agent 侧上下文是否也退回去了,见 docs/adr-014)。report_open 表示 agent 调了报告工具,把一份写好的单文件 HTML 报告摊开给用户看:text 是相对会话工作目录的路径、title 是标签名,前端据此在预览面板打开它。刻意不让前端从 tool_call 里认这件事——ACP 的 rawInput 是流式累积的(末帧还会变回 null),从那种流里捞路径既脆又和 runtime 实现绑死,而后端在工具被调用时是确定知道会话与路径的。session_title 在标题被升级时发一次(带新标题):来源要么是 agent 自己推的 AI 标题(claude 的 session_info_update),要么是首轮末的 ollama 概括。tool_call 另带一组子代理字段:isSubagent(这次调用派出了子代理)、subagentOf(这条是某个子代理干的,值为它所挂的启动调用 id)、codex 专用的 subagentThreadId / subagentPath;还带 locations(ACP 的 follow-along 位置 [{path, line?}]),前端用它做「正在触碰」指示(消息流小字 + 文件树呼吸点 + 查看器跟随模式 + 子代理面板当前文件)。这组指示只在 claude 会话里出现——2026-08 实测 claude 的 read/edit 工具带 locations、codex 一条都不发,没有位置信息时界面静默降级(不显示,不报错)。
更新后怎么让所有人刷新:/api/events 是一条与会话无关的全局 SSE 流,每个页面(不只是会话页)都挂着它,连上先收到 {kind:"hello", version}。owner 点「一键更新」会替换 .app 并重启后端,进程一换,所有人的这条流必断——断开本身就是信号:浏览器自动重连(服务端用 retry 指令把间隔调到 1.5 秒),重连拿到的版本和手里那份对不上,页面就弹出常驻的刷新提示。局域网访客因此在后端起来后一两秒内就知道该刷新了,而不是等轮询——轮询的发现延迟下限就是轮询间隔,要做到秒级得让每个页面每秒打一次 health。只提示不强制刷新:会话状态在后端、刷新即恢复,唯独输入框里没发出去的草稿找不回来,什么时候刷由用户定。
通知:同一条全局流还推 notify 事件。载荷契约(源:server/internal/stream/notice.go,前端对齐 web/src/types/acp.ts 的 ServerEvent):
| 字段 | 类型 | 说明 |
|---|---|---|
kind |
string | 固定 notify(与 hello 区分) |
event |
string | 由来:permission / elicitation / turn_end / error,外加撤回信号 permission_done / elicitation_done(事情在页面上处理掉了,客户端应收回对应通知) |
sessionId |
number | 归属会话 |
sessionTitle |
string | 会话标题(可空) |
text |
string | 一句话摘要:后端已折叠空白并截断到 120 字符;turn_end 取正文尾巴(结论在最后)、error 是错误原因、permission 是工具标题 |
permissionId / options |
string / array | 决策专用:桌面壳把 options 变成系统通知按钮,按下即裁决(走既有 POST /api/sessions/{id}/permission) |
elicitationId |
string | 问答的撤回定位 |
投递按会话归属精确匹配:租户之间互不可见,owner 也只收自己名下会话的通知——owner 看得见租户的全部会话(adr-007),但看得见不等于该被打扰,租户会话上的决策卡片长在租户自己那一页。通知不落库、不重放:错过的打扰没有补发价值,真正还悬着的事以会话页的卡片为事实源;前端的通知中心存量同样是内存态(web/src/lib/notify/store.ts,优先级 update > permission/elicitation > error > turn_end,上限 20 条)。服务关停时后端主动关闭全部 SSE 流(Hub.Close / Broker.Close)——SSE 不会自己收尾,不关它优雅关闭只能干等超时,更新后的重连发现就被拖慢。
后端只广播「发生了什么」,不判断该不该弹:那要知道用户此刻在看哪一页、页面在不在前台,只有客户端清楚。于是同一条通知在两端走两条路——桌面壳里交给 macOS 系统通知(决策通知带按钮,按一下就是裁决;其余点开会话),浏览器里落进侧栏底部的通知中心(照 iOS 的分工:留得住、按优先级排序、按视口高度平行显示 1–3 张、其余折叠成垫层;单卡点击直接执行动作——update 刷新、会话通知跳会话),配标题闪烁与提示音兜住「人不在看这一页」的情况。版本更新也是一条通知(优先级最高,带刷新按钮),与用户菜单底部的常驻刷新入口互为补充(通知可以被划掉,状态不该跟着丢;用户条目上的状态点也会转黄提示)。刻意不用 toast 做通知:弹一下就走的横幅留不住「有事等人处理」的信息。完整设计决策与「怎么发一条通知 / 新增一类通知」的手册见 docs/adr-013。
-
Agent — 可通过 stdio 启动的 agent 配置(
command/args/env/cwd),args与env以 JSON 文本存入 SQLite。产品形态上固定为内置的 claude / codex 两条记录(启动时缺失自动预置、按 name 判存不覆盖用户配置,见 adr-005),API 仍是通用的/api/agents。flavor/models/commands/skeleton是注册/更新后自动探测的缓存(拉临时会话读能力):模型与命令供草稿态展示与/补全(条目带disabled标记,重探不清空取舍);skeleton是模型之外的设置骨架(efforts/levels/plan/fast 支持位),与模型清单一起构成未连接会话的完整降级设置视图。模型条目支持alias(配置页起显示别名,所有模型下拉优先显示);fastPolicy是快速模式取舍(首探按 flavor 落默认:claude 因额外计费默认 off,其余 on;off 时快速开关不出现在任何界面)。askModel/askEffort是别的 AI 经/api/ask问这个工具时新会话拨到的模型与思考深度(adr-022),配置页设、重探不清空,空=沿用 runtime 默认。 -
Session — 对应一次
session/new,acpSessionId是 agent 返回的 uuid v7,stopReason记录上一轮的结束原因。origin标记会话是谁开的:空是界面里的人、ask是别的 AI 经/api/ask问出来的(adr-022)、discord是子区里聊的、cron是定时任务跑的(adr-016/020)。侧栏与用量报表据此分类——每个入口都要有名字,混在一起就说不清「这笔钱是谁花的」。lastSettings是最后一次生效的统一设置当前值快照(用户改设置、或 agent 自己切档时写回;查看会话这类只读路径不写,它读到的可能正是一份还没拨回去的默认值),两个用途:未连接会话的工具栏靠它显示与断开前一致的当前值,子进程重开后也按它把模型/思考深度/权限档拨回去——这些是会话级运行时状态,跟着子进程一起死,不回放的话空闲回收一次,用户没做任何操作设置就变了;lastUsage同理存最近一次上报的用量({used, size, cost?},轮末写一次)——上下文水位只经usage_update通知流过,没有这份快照的话会话一停、页面一刷新,占用比例就没了。externalKey非空表示这条会话由外部子系统管着 acp 连接(现在只有 Discord 子区:dc:<子区 id>),登记靠它幂等;网页侧对这类会话只读。promptDigests是长提问的一句话摘要缓存(对话索引用,键是提问正文的内容指纹而不是消息 id——消息 id 是转录行号,重建逻辑一变就整体漂移),不出 API。state语义:active只表示有一轮正在跑;空闲子进程超时会被回收(state 归idle),服务重启时遗留的active也会归一——续聊时凭acpSessionId用session/load恢复上下文,进程挂不挂着不影响会话可用性。 -
Message — 会话内一条记录,
kind覆盖session/update的各类内容块,结构化内容放payload。不落库(adr-003):它是转录重建器的输出 DTO 与消息接口的响应契约,事实源是转录 JSONL。 -
Tenant — 一位局域网访客的身份与隔离单元(adr-007):
name(同时是 root 目录名,建后不可改)、token(邀请链接与 cookie 的凭证,只对 owner 可见)、root(最上层工作目录)、disabled、githubLogin(访客在 GitHub 上的用户名,owner 填;GitHub 页按它筛「分配给我」的 issue——issue 是用 owner 本机的 gh 登录态拉的,访客没有自己的凭证)。owner 刻意不入表——他由 loopback 判定,没有记录也就没有「把自己停用」这种事故。Session.tenantId是会话归属(0= owner),隔离靠查询条件执行。 -
GithubWatch — 一个身份关注的 GitHub 仓库清单(adr-023):
tenantId(0= owner,与 Session 同一约定)+repos(JSON 文本)。issue 本身不入库:按仓库缓存在内存里、后台刷新,进程重启就重拉。 -
Project / Clone — 都不入库:项目是工作区根下的 git 仓库目录(扫盘得来)。每条带两个名字——
name是位置(相对根的路径),repo是身份(哪个 git 仓库,见上面「项目」一节)。克隆任务只存在于内存(进程重启时 git 子进程也一起没了,留个「进行中」的假记录只会骗人)。 -
MCPCall — 一次 MCP 工具调用的观测记录:server、工具名、来源(
agent= 子进程回连、manual= 工具台人工试运行)、会话 id、参数与返回文本、是否报错、耗时。只记发生过的调用,工具声明是代码不入库。参数 4KB / 返回 8KB 截断后落库,全表留最近 2000 条(超出按自增 id 裁最老的)——它是运行时观测不是账本。 -
TokenUsage — 一轮对话的用量账目:一轮一行,只存计量不存正文(
<会话, 轮序号>是唯一键)。归属(租户、工具方言、模型、项目、来源)在写入时从会话定格,四项 token(输入 / 输出 / 缓存读 / 缓存写 / 思考)分开存——实测缓存读占全部 token 的 96%,而它的单价只有普通输入的 1/10,合成一个总数就说不清钱花在哪。成本存整数微元(百万分之一美元)并标明来源:reported是 claude 自报(按会话累计值逐轮差分)、estimated是按单价表折算、none是「不知道」(不按零算)。不做外键:会话删了账目还在。事实源仍是转录 JSONL,这张表随时可以照转录重算。 -
DataSource — 一个外部 MySQL 数据源(adr-008)。身份是项目 + 环境两级(
pp-game的local/dev/pre),组合唯一,<项目>/<环境>即对外标识(AI 调工具时填的source)。只存配置不存连接:每次调用都是「拨号 → 执行 → 关闭」的一次性连接(含 SSH 隧道),因此没有任何运行态字段。密码类字段永不出 API,响应只带hasPassword这类布尔位。
项目就是一个 git 仓库。 标识是 <组织>/<仓库>(取自 origin 的 URL),
与它被克隆到磁盘上的哪个位置无关。
这条定义要紧,因为同一个仓库在本软件里会同时存在于好几个位置:owner 自己的
目录、每个租户的 root(<工作区根>/<租户名>/…)、discord 频道的工作树
(<仓库>/.worktree/<分支>)、会话的隔离工作区。它们是同一个项目。
于是两个名字要分清:
| 是什么 | 例子 | |
|---|---|---|
| 位置 | 相对工作区根的路径,租户目录与分组目录都在里面。项目列表按它做标识与增删 | orange/BDBGAME2024/pp-game |
| 身份 | 这个项目是哪个 git 仓库。数据源归属、会话归属、频道绑定认的都是它 | BDBGAME2024/pp-game |
推导规则(gitrepo.ProjectOf):从目录往上找到最近的 git 仓库 → 读它
.git/config 里 origin 的 URL → <组织>/<仓库>。三条退路:
- 没有 origin(本地新建、还没关联远端)→ 用目录名。那时也没有更好的答案。
- 工作树 → 归属主仓库,分支名不会被当成项目名。
- 不是仓库(会话可以开在任意目录)→ 不属于任何项目,界面显示
—。 不拿目录名硬凑一个,否则「项目」这个词就失去意义了。
读的是 .git/config 而不是 exec git:这条路径在项目列表、会话列表、会话取
数据源时都会走,每次 fork 一个 git 进程,项目一多就卡。会话列表里按 cwd 缓存,
一页 50 条通常只落在几个目录上。
哪里在用它:数据源的归属(一条开在 BDBGAME2024/pp-game 里的会话只看得到
这个项目的数据源,见下面「数据库」一节)、会话列表与侧边栏的分组(同名仓库
在不同组织下很常见,带上组织才分得开)、Discord 频道绑定(/init 选的就是
一个仓库)。
会话页是一套 dockview 工作台,面板可自由开关与拖放(⋯ 菜单勾选,或用布局预设一次摆好):
| 面板 | 管什么 |
|---|---|
| 对话 | 不可关闭,永远在。左侧空白列是提问索引:一格一条用户提问,静息只露当前读到的那一段(9 格),鼠标移过去展开整条会话,指着谁谁伸长(Dock 式放大镜),气泡给「问了什么 + 答了什么」,点了跳过去——落点还没加载就自动把「加载更早」泵到那一条为止。面板窄到正文要占满时整条收起 |
| 文件树 | 工作目录的目录树,右键加 @ 引用 |
| 查看器 | 文件内容 / 改动两种形态——「现在什么样」与「改了什么」是同一个阅读动作的两面 |
| 分支 | 本地/远端/标签;点选驱动其他 git 面板,⌘ 点第二条进对比模式 |
| 提交链路 | 提交历史(分页),顶部第一条是「工作区改动」,未推送的带标记 |
| 变更 | 文件清单,跟随选择态:没选看工作区,选提交看那条提交,选两个 ref 看对比。按目录树展示,单子目录链压缩 |
| 详情 | 提交说明 / 对比摘要 |
| 日志 | 线级转录实时跟随 |
| 子代理 | agent 派出去的活:按进行中/已完成/失败分组,展开看这次派了什么、拿回了什么 |
| 终端 | 可多实例的真实 pty |
四个 git 面板不互相说话,全部读命令总线里的同一份选择态——因此可以只开其中一个,也可以任意摆放。布局预设 Git 工作台 把它们按「左分支 | 中链路 | 右上变更 / 右下详情」一次摆好。
面板什么时候重读(三条路互补,每条都有手动刷新兜底):
- agent 干完一件事——轮内每完成一次工具调用合帧刷一次(1.5s 窗口),外加 turn 结束刷一次。它管得到版本库的动静:commit 只改
.git,文件系统那边看不见。 - 文件真的变了——后端监视工作目录(
/fs/watch的 SSE 流,400ms 合帧),用户自己在编辑器里改的、命令行里跑出来的同样算数。页面进后台即断开,回到前台重连。设计与选型见 adr-024。 - 手动——每个面板头部都有刷新按钮。
看不见就不拉:藏在 tab 后面的面板收到刷新广播只记一笔账,切回来那一刻补一次。文件树刷新不会把展开状态折回去(展开着的深层目录各自重读,旧内容先摆着、新的到手再换),查看器拿回一模一样的内容时也不会白重渲一遍。
AI 联动:右键提交「让 AI 审查」、右键分支「让 AI 对比」、右键文件「让 AI 分析改动」,写好的 prompt 只填进输入框,不自动发送——发消息是用户的动作。
侧边栏的「用量」是 token 与费用的报表,owner 与租客是同一个页面:owner 看全部身份的合计,租客只看得到自己的(范围由后端按身份收在查询条件里,租客那边连「身份」筛选都不渲染)。
统计不靠埋点。每一轮的 token 本来就写在 session/prompt 的响应里,账本只是把它读出来建的索引——所以
- 轮末落一行
token_usages(见「数据模型」),写入是旁路,挂了不影响对话; - 任何时候都能照转录重算(页顶「重算历史」,owner 专属):上线当天就有全部历史,记账逻辑改了重跑一次全部对齐,实时漏记的一轮也补得回来。实测 224 份转录 1.5 秒;会话记录已经没了的转录跳过并单独报数(账目必须有主人)。
金额是等价成本,不是账单。两条 runtime 走的都是订阅登录,这里的钱是「同样的活按 API 价值多少」,分三档显示:
| 档 | 谁 | 怎么来 |
|---|---|---|
| 实报 | claude | agent 自报的会话累计费用,逐轮差分。不猜模型、不查单价,也不会因为价格调整失真 |
折算 ≈ |
codex | 模型 id × 单价表。与实报分开合计 |
| 未计价 | — | 既没实报、模型又不在表里。不按零算——零和「不知道」是两件事 |
为什么不统一按模型单价算:claude 有 89% 的轮次报的模型名是 default(档位名),协议里看不到底下究竟跑的哪个模型;codex 反过来模型 id 精确却一分钱不报。两边正好互补。单价表在页顶「单价表」里填,不预置任何默认价(模型 id 一个月里就能改,猜一个填进去报表会拿它一路算下去);改价不动已记的账,要对齐走重算。
四项 token 分开存:实测缓存读占全部 token 的 96%,而它的单价只有普通输入的 1/10。合起来看不出问题,分开看才知道钱花在哪——缓存命中率因此上了指标卡。
异常分三层,因为每层的责任人不一样:轮次中止(多半是人按了停止)、agent 报错(过载 / 额度 / 登录过期,要人管的那层)、工具调用失败(干活的一部分,AI 自己会重试)。糊成一个「错误率」这个判断就做不出来了。
Discord 也在这本账上:子区烧的是同一个额度,所以它的对话同样登记一条会话记录(键是 dc:<子区 id>,幂等)、写同一份转录、轮末落同一行账目,靠 origin 分成 discord(人在子区里聊的)与 cron(定时任务自己跑的)。三个接口仍是 Deps 闭包,adr-016 的「与会话零耦合」继续成立。
代价与边界:这些会话在网页里只读——它的 acp 连接在另一个会话池里,从这边发消息会给同一条记录开出第二份分叉的上下文,所以对话面板给提示条并禁用输入,要接着聊回子区。接进来之前的历史子区拿不回来(那时没有转录也没有账),从接入后的第一轮开始记。
按项目 + 环境管理 MySQL 连接(adr-008):pp-game 的 local / dev / pre 是三条独立数据源,<项目>/<环境> 就是它对外的标识。侧边栏「数据库」页配置(连接对话框照 Navicat 分常规 / SSH / 高级三个页签),页面里可以直接浏览库表、看表结构、跑多段 SQL。
项目是可见性边界——这是整个功能的安全底座:
会话 cwd ──推项目名──→ 只取该项目的数据源 ──→ MCP 工具面 / 斜杠命令
一条开在 pp-game 里的会话,看得到、连得上的只有 pp-game 的几个环境;别的项目的连接对它而言不存在(会话侧按 id 直取也返回 404)。过滤的执行点只有一个(datasource.Service.ForCwd),界面与 AI 共用;推不出项目就一个都看不见,而不是看见全部。
项目名按上面「项目」一节的规则推——认的是 git 仓库身份(BDBGAME2024/pp-game),所以同一个仓库克隆到租户目录、discord 工作树、owner 自己的目录,匹配到的是同一批数据源。为兼容早先填的裸仓库名(pp-game)与完整路径,那两种写法同样算候选。
每条连接管两件事:能看哪些库、能不能写。
- 可访问的库:一个账号常常连得到整台实例上的所有库。留空时范围就是「默认库」那一个——AI 与界面都只看得到它;要跨库就把库名逗号分隔列出来,或填
*放开。 - 只读开关(新连接默认开):开着时写语句一律拒绝,AI 那边连执行工具都不会挂上。
两者都是闸门不是边界:明写 别的库.表 的 SQL 会被挡、UPDATE 开头的语句会被挡,但存储过程与动态 SQL 绕得过去。真正的边界始终是连接账号的授权范围——要硬保证,就给这条连接配一个只授权对应库、只有 SELECT 的账号。
AI 怎么用:会话所在项目有可用数据源时才挂载 acpp-db 这个 MCP server(没有就完全不挂,免得工具清单里多几个用不了的条目)。工具分读写两路——db_sources(列数据源)、db_databases、db_tables、db_schema(列/索引/建表语句)、db_query(只读查询,写语句会被拒并引导去执行工具);db_execute(改数据与结构)只在存在非只读数据源时才出现在清单里。claude 侧预批这些工具不弹权限卡。挂的只有工具,没有提示词——用法约定(先看表结构再写 SQL、改数据前确认环境)不进会话开场,等用户真的 @ 引用了数据库,才随引用下发一段简短告知(引用的数据源标识 + 用 db_* 工具实查的规矩);不嵌表清单与表结构,那些让 AI 用工具现查。告知以 resource 块进 prompt,不混进用户正文——界面上用户气泡永远只显示用户自己打的字,引用本身落成数据库芯片。开场就铺一段数据库说明,等于每条会话都替用户按下「我要动数据库」。
行数护栏在我们这侧:最多 1000 行(默认 500)。不给用户的 SQL 自动加 LIMIT,也不在库上设任何会话变量——你的库我们只读它、不改它的行为。实现是流式游标逐行读,读满上限就取消这次查询让驱动断开,而不是把剩下几百万行读完再丢掉。诚实的边界:断开后正在回传结果的查询会因写失败很快中止,但还在扫描/排序、尚未吐数据的查询 MySQL 不会察觉客户端已走,会跑完那一段——要立刻杀掉得发 KILL QUERY,那是在库上动手,没做。
SSH 隧道:开启后主机/端口填的是跳板机视角的地址(线上库多半是 127.0.0.1:3306),跳板机本身从服务器页选一台(adr-019:跳板机与 AI 观察的目标本来就是同一台机器,配一次两边都用)。凭证与指纹校验的规则见下面「服务器」一节。
复制与看密码:同一台库上常常要开好几个连接(几个库、几个环境),地址、账号、密码、SSH 全是同一套——列表行上的「复制连接」照着新建一条,只把项目 / 环境 / 库留空(那三样正是要改的,留着旧值会让人一路点保存、建出一条指向原库的重复连接)。密码框带「看一眼」:编辑已存连接时框里是空的(留空即不修改),点一下把它取回来填进去,之后就是个可选中可复制的普通值。取密码走 /secret 端点,与 URI 导出同一口径——owner 专属,会话侧永远拿不到。
两个查看入口:
- 对话里 AI 的
db_query有专用渲染——数据源标识、SQL、耗时、字段表头与可滚动数据,与配置页的 SQL 控制台是同一个组件(MCP 只回文本,前端按两端约定的制表符格式解析回结构化;解析不出来退回原始文本,不编造表格)。 - 输入框里的
/db是本地斜杠命令:前端拦截,结果浮在输入框上方,不进对话、不消耗 token、不用等 agent。/db列本项目数据源,/db dev列库,/db dev mydb列表。
配一台机器(SSH),AI 就能读它的文件、看容器与负载——发布之后到底怎么样,不用再自己 ssh 上去翻(adr-019)。侧边栏「服务器」页管理,字段是名字 / 地址 / 账号 / 验证方式(密码 / 公钥 / 密码和公钥,公钥留空路径则走 ssh-agent)/ 备注。
它同时是两件事的底座:AI 的观察目标,与数据源的拨号跳板。这两件事本来就是同一台机器——分开各存一份凭证的话,改个端口要改两处,而且没有任何一处知道它们是同一台。老数据源里的 SSH 配置在首次启动时自动搬进服务器表(幂等,共用同一台跳板的多条只建一条)。
指纹校验:按 ~/.ssh/known_hosts,策略等价 OpenSSH 的 accept-new——没连过的主机首次连接自动补录,指纹与记录不符则拒绝且没有跳过开关。那是唯一真正的中间人信号,后面挂着的是生产环境,人工核实后删掉旧记录再连。
AI 怎么用:配了机器就挂 acpp-server 这个 MCP server(一台都没有就完全不挂),十二个工具全部只读:
| 面 | 工具 |
|---|---|
| 主机 | server_hosts(有哪些机器,备注会给 AI 看)、server_info(系统 / 负载 / 内存 / 磁盘一次拿全)、server_ps、server_ports、server_journal |
| 文件 | server_ls、server_read(看日志优先 tail)、server_grep |
| Docker | docker_ps(含重启次数)、docker_logs、docker_inspect(compose 标签给出远程项目根目录;环境变量只列名字不列值)、docker_stats |
工具形状对标模型已有的本地工具(Read 的 offset/limit、Grep 的 pattern/context),是同一套心智的远程版。三层护栏都在服务端:命令自身限流(grep -m、head、tail)、nice 降优先级、32KB 输出预算 + 兜底截断。所有路径与 pattern 经 shell 引用——那是这套东西唯一的注入面。
不提供任意命令通道:给了它,上面十二个工具的护栏与输出预算就全废了,模型总会倾向最灵活的那个。AI 真需要跑任意命令时它自带 shell,那是用户在权限卡上看得见的显式选择。
不做项目隔离(与数据源刻意不同):一台机器上跑着多个项目是常态,按项目切会把 AI 需要的上下文一起切掉。配置一台服务器,就等于授权 AI 观察整台机器(含 /root/.ssh/、各项目的配置与密钥),这一条对租户同样成立。要收窄就给它配一个受限的 SSH 账号——这是闸门不是边界,与数据库那套同理:真正的边界是 SSH 账号自身的权限。
挂的只有工具,没有提示词:什么时候该看服务器、该看哪个目录,由模型从任务与项目代码自己判断。用法手册在 skill 里按需加载(范本见 docs/skill-server-inspect.md),核心是那条铁律——远程路径从项目代码推断(compose 给容器名与挂载、Makefile/CI 给部署路径),工具只负责验证那些推断。
@ 引用:输入框的 @ 菜单里选一台机器交给 AI,随引用下发一段告知(acpp-server://<名字>)。引用只到机器这一级——要看哪个目录由 AI 从项目代码推断。
Discord:频道可以锁定一台服务器(/init 选库之后那一步,或事后用 /server 换绑),锁定后子区的 AI 只看得见那一台;不锁定则全部可见。绑定信息在频道主题、/status 与 /mcps 里都看得到。
在 Discord 频道里让 AI 定时干活(adr-020,设计取舍与 openclaw 对照见 docs/定时任务-设计调研.md)。任务挂在频道绑定上:一条任务 =「频道 + cron 表达式 + 一段自包含的任务提示词」,环境(工作目录 / 模型 / 权限档 / 锁定的库与机器)全部跟随绑定运行时现读,任务只回答「什么时候、干什么」。
一次运行 = 频道里一条起始消息 + 挂在它下面的子区 + 一条全新的 acp 会话。 起始消息扮演普通对话里用户那条 @acpp,往下全是现成管线:工具卡、权限卡、报告卡、send_file、回合小结一个都不用改;跑完起始消息编辑成一行状态与摘要(📅 用户七日行为日报 · 09-03 10:00 · ✅ 4m12s · 🔧 15 + 回复首行),子区里是完整成果,用户可以在子区里接着追问(子区 ↔ acpSessionId 照旧落盘)。巡检类任务无事时整条回复只写 NO_REPORT:起始消息变成「✅ 无需汇报」,子区归档。
三条入口:
- 子区里对 AI 说(主路):「以后每天早上 10 点这样出一份发到这个频道」。子区会话挂着
acpp-cron工具面(cron_add/cron_list/cron_update/cron_remove),投递目标固定为当前频道——从凭证推,不让模型填频道 id。「2 小时后跑一次」用in相对时长由服务端换算,模型不必知道现在几点;「明早 9 点」才用at,工具描述里带了会话开始时刻当基准。建完子区里出一张任务卡(立即运行 / 停用 / 删除三个按钮)。scheduled-task技能(范本 docs/skill-scheduled-task.md)教它「先做一次再固化」,并给出提示词的自包含清单。 /cron:手机上看与管——action选 list / run / pause / resume / runs / remove / clear,id认任务 id 或名字前缀;remove的id逗号分隔可一次删多条,clear删光本频道全部(弹确认卡二次确认)。- 网页侧栏「定时任务」页(Discord 页只管频道绑定,任务不再叠在它下面):清单、新建(循环给 cron 常用预设,一次性选时刻或点「1 小时后 / 明早 9:00」快捷,时区缺省本机)、编辑(可在循环与一次性之间切)、启停、立即运行、运行记录(能跳到子区)。
无人值守契约(注入会话提示词与每次运行的开场输入):不提问不等待、交成品不交计划、避开要审批的操作、无事回 NO_REPORT、失败如实报。提问(elicitation)到达时后端直接取消;权限卡照常发在子区并 @ 任务创建者,没人点就等到轮超时算失败。开场还注入运行时事实——几点、上次运行时间与摘要——巡检类「只看自上次以来」全靠它。
调度器(internal/schedule,叶子包,不认识 discord):整分钟扫描;cron 用 robfig/cron 的 parser(5 段 + IANA 时区,按墙钟解释);同任务不重入(上一轮没跑完记 skipped);bot 断线或会话池满记 ErrBusy 一分钟后重试(池满先收掉空闲 2 分钟以上的子区会话腾位);连续失败退避 5m → 15m → 60m,连败 5 次自动停用并在频道里 @ 创建者;重启不补跑,晚 30 分钟以内仍算准时;一次性任务(at)跑成功即删。存储 <dataDir>/schedule.json(回退面 = 删文件)。跑完即关会话释放席位,子区续聊时凭 acpSessionId session/load 回来。
边界:任务按频道绑定的权限档跑——full 档的频道等于让定时任务无人审批地执行任意命令,这是频道级的显式选择。任务提示词是持久化的注入面,建任务的人只能是频道成员与网页 owner,任务卡与清单上永远显示创建者。一次运行是一整条 agent 会话(技能注入 + 工具调用),巡检类靠 NO_REPORT 省的是投递不是 token。
侧边栏「工具」页把我方 MCP server 暴露给 agent 的工具摊开给人看与试。页面的立场是复现 AI 那一侧:工具集、描述、参数、往返,全部走与 agent 完全相同的那条协议路径(datasource.InspectMCP 与会话侧的 HandleMCP 共用 toolsForCwd),页面上看到的就是模型此刻看到的那一份。
- 先选项目——工具集本身随项目的数据源变(
db_execute只在存在可写连接时才挂,没有可用数据源时整个面根本不会挂给 agent,页面会明说这件事)。 - 工具清单标两件事:读/写(MCP 标准注解
readOnlyHint/destructiveHint)与被调用过几次。一个从没被 AI 调过的工具,问题多半出在描述而不是实现上。 - 详情给的是给模型看的描述原文、按
inputSchema生成的参数表单、Schema 原文,以及 agent 侧的工具全名(mcp__acpp-db__db_query,claude 的预批清单用的就是它)。 - 试运行填参数即可跑;自定义请求直接写 JSON-RPC 消息体(预填当前参数的
tools/call,另有initialize/ping/tools/list模板)——两者是同一个端点,试运行只是替你把请求体拼好了。agent 说「看不到这些工具」时,自己发一次tools/list看端点回了什么,是最快的一刀。 - 结果分两页:结果是模型真正读到的那段文本(数据库查询还原成表格,与对话里同一个组件),原始 JSON 是完整响应。协议错误(请求没被受理)与工具错误(跑了但失败)分开标——混成一句「出错了」等于把最有用的那半句删掉。
- 会改数据的工具按下运行前先弹确认,框里原样列出将要发出的参数。这里连的是真实数据库,跑下去的效果和 AI 自己调用一模一样。
- 调用记录记下每一次调用(AI 的与人工试运行的都记,来源分开标):参数、返回、耗时、成败,可按工具/来源/只看失败筛。上方是按工具聚合的统计。记录是运行时观测不是账本——参数 4KB、返回 8KB 截断落库,全表留最近 2000 条。
- 不开页面、用 curl 或脚本直接调:数据库读取的 HTTP 接口文档(基础地址、鉴权与局域网边界、逐接口的路径/请求头/请求体/返回结构、MCP 工具面文本格式)见 docs/http-数据库读取.md。
三层,缺一不可,且各自边界诚实:
-
cwd 隔离 —
session/new的cwd必须是绝对路径,不存在会先创建。 -
fs 代理 path guard — 声明了
fscapability,agent 的fs/read_text_file/fs/write_text_file会走到我们进程里,路径解析成 canonical 形式后必须落在 cwd 内。注意这条不是可靠拦截点:codex 用自带 shell 完全不走 fs 代理,claude 0.63 实测权限批准后也由 SDK 自行落盘。它只是纵深防御的一层,真正的隔离靠第 3 条之外的 runtime 沙箱档 + OS 兜底。 -
权限裁决 —
session/request_permission挂起交给用户在界面上点选(批准/拒绝),拒绝真实生效(实测文件不会被创建)。runtime 只在当前权限档认为需要时才会问,所以它仍是策略层不是安全边界——真正的隔离要靠 runtime 自身的沙箱档位 + OS 级兜底。一个例外:读技能包内的文件由后端直接放行,不惊动用户。技能包是控制端自己注入给 agent 的只读知识库,却住在会话 cwd 之外,于是 agent 每读一个参考文件都要问一次——问了也只有一个答案,却真的把会话卡死过(并发两个请求挂起,旧版界面只画得下一张卡,没被裁决的那个永远等下去)。放行范围刻意收窄:只认
read、只认技能包目录、路径按软链解析后仍在目录内,写操作照常问(server/internal/acp/autoallow.go)。也没有改用additionalDirectories把技能包并进工作区——那连写也一并放行了,agent 还会把它当自己的工作目录翻。每次放行都会推一条permission_auto事件留痕。
局域网分享打开后,访问者分两种身份:owner 是本机访问(loopback 判定,全权),租户凭 owner 发的邀请链接换到一个 HttpOnly cookie。选 cookie 而不是 Authorization header,是因为 SSE(EventSource)与工作区终端(WebSocket)都带不了自定义 header——三条通道要统一鉴权,只有 cookie 能做到。脚本与外部程序另认 Authorization: Bearer <租户 token>(adr-021):同一枚 token、同一套判定,只是换了载体;配套的只读数据库面是 /api/db/sources 与 /api/db/query,按数据源标识寻址、不经工作目录,接口文档见 docs/http-数据库读取.md。
隔离只有一个执行点(service.Scope):数据面把租户条件写进查询本身(漏写等于查不到,不会变成越权),路径面把一切目录操作 canonical 化后钉在租户 root(<工作区根>/<租户名>)内。别人的会话按「不存在」处理而不是 403——403 会泄露会话是否存在,凭 id 递增就能数出别人有多少条。owner 专属面(系统设置、数据库连接管理、技能/工具的写)由集中的前缀表判定,新增路由自动继承策略。
会话内的能力面租户与 owner 一致(adr-010):数据源引用//db/MCP 数据库工具、终端、全部工作区面板对租户开放,只按工作目录所属项目过滤,不按身份分家;能在库里干什么交给数据库账号权限管。凭证永不经会话侧下发。
分享链接什么时候真的能用:服务默认只监听 127.0.0.1,那时任何链接发出去都打不开(访客管理页会直说,并把链接指向本机,方便 owner 自己验一眼访客视角)。要让局域网里的人能用:桌面版在菜单栏图标右键开「允许局域网访问」,命令行用 make serve-lan(后端托管前端产物 + 监听 0.0.0.0:48080)。开发态的 make dev 不适合分享——后端不托管前端,vite 只监听本机。
owner 判定与反向代理:本机访问(loopback)且没带租户凭证才算 owner;带了凭证一律按租户算。后半句是必要的——任何反向代理都会把来源改写成回环,只看地址的话代理后面的每个访客都会被提权成 owner。即便如此,不要把 acpp 直接挂在反向代理后面:代理过来的无凭证请求仍会被当作 owner。
诚实的边界:隔离对界面是硬的,对执行是软的。租户能开工作区终端(cd / 就出了 root),agent 自带的 shell 同样不受 root 约束。也就是说这套东西防的是「看见别人的东西」与「误操作走出自己的目录」,不防有意越权的人——真正的执行隔离需要 OS 级沙箱(每租户一个系统用户 / 容器),不在范围内。局域网共享的前提仍是可信网络。
另外,租户克隆仓库时强制禁用 git 凭证助手(-c credential.helper=):不禁的话访客能借 owner 钥匙串里的凭证,把他有权访问的任何私有仓库拖下来。
工作区终端是本机任意命令执行面:/terminals 端点在会话 cwd 起真实交互 shell(用户显式操作才创建),与 agent 已有的命令执行权限面同级。服务只监听 127.0.0.1;若把 ACP_ADDR 改成对外地址,这个面会随之暴露,必须配合网络层访问控制。macOS 桌面版的「允许局域网访问」开关就是这个暴露面,因此默认关闭,只应在可信局域网内开启。
另外启动 agent 前会摘掉嵌套会话标记(CLAUDECODE、CODEX_SANDBOX 等)。不摘的话,从 Claude Code 终端启动本服务时 agent 会误判自己跑在另一个 agent 内部而拒绝服务——这个坑只在那种场景复现,本机开发时碰不到。
| 变量 | 默认值 | 说明 |
|---|---|---|
ACP_ADDR |
127.0.0.1:48080 |
监听地址(macOS 桌面版由壳固定传 48090,局域网开关切换 host) |
ACP_DATA_DIR |
~/.acpp |
数据根目录(db 与转录都派生于它)。优先级:本变量 > ~/.acpp/config.json 里设置面板选定的目录 > 默认。首次启动自动创建;旧版 server/data 的存量数据自动迁入(拷贝,原数据保留) |
ACP_DSN |
<dataDir>/acp.db |
SQLite 文件路径(显式设置时覆盖派生值) |
| 工作区根 | ~/acpp |
不是环境变量:在 设置 → 系统 里选,存 ~/.acpp/config.json。agent 干活的地方与访客 root 的父目录,与数据目录刻意分开 |
| 会话标题模型 | 关闭 | 不是环境变量:在 设置 → 系统 里配,存 ~/.acpp/config.json。标题按三级取值:agent 自己推的 AI 标题(session_info_update,claude 首轮末推,白拿)> 本机 ollama 概括 > 首句前 15 字。codex 推的 title 只是首条消息原文,后端会认出来并拒收,交给 ollama 兜底——所以只跑 claude 时这项可以关掉,codex 会话仍需要它。同一个模型还负责对话索引的提问摘要:只有超过 60 字的长提问才跑(短提问首行就是最好的索引文案),结果按内容指纹缓存进会话,没开或跑挂了索引就用提问首行 |
ACP_CORS_ORIGINS |
http://localhost:45173 |
允许的跨域来源,逗号分隔 |
ACP_WEB_DIR |
空 | 前端产物目录,设置后由后端托管静态文件 |
ACP_MAX_SESSIONS |
8 |
同时活着的 agent 子进程上限 |
ACP_IDLE_TIMEOUT |
10m |
空闲会话子进程的回收时限(0 关闭)。上下文留在 agent 侧,续聊时 session/load 无感恢复,模型/思考深度/权限档按 lastSettings 回放 |
ACP_TURN_TIMEOUT |
0(不限时) |
单轮硬上限。长程任务跑几个小时是正常使用方式;turn 进行中(含等待权限/提问裁决)不会被空闲回收 |
ACP_MAX_TERMINALS |
5 |
每会话的工作区终端(pty)实例上限 |
ACP_UPDATE_REPO |
构建注入(HuLuca1998/acpp) |
版本发布的 GitHub 公开仓库(owner/repo),更新检查读它的 Releases |
ACP_DEBUG |
空 | 非空则打开 SQL 与 debug 日志 |
前端可用 VITE_API_BASE 覆盖 API 前缀,默认 /api。
系统自管一套技能,注入每条会话、替换机器级技能,项目级技能照常加载。磁盘即事实源,不进数据库——技能列表就是遍历目录、读 SKILL.md 的 frontmatter。数据目录下两处:
<dataDir>/
├── skills/<name>/ # 源目录:全部技能(含停用),SKILL.md + 可选 references/ scripts/ assets/
└── skillpack/ # 分发目录:只放注入内容,首次操作自动搭好骨架
├── .claude-plugin/plugin.json # {"name":"acpp"},两端把技能显示为 acpp:<name>
├── skills/<name> -> ../../skills/<name> # 启用 = 存在这条符号链接
└── .agents/skills -> ./skills # codex extraRoots 的固定发现入口
- 启停 = 建/删 skillpack 里的符号链接,源目录路径永远稳定;启用状态从文件系统读。
- SKILL.md 走结构化编辑:前端只提交
name/description/body,frontmatter 由后端组装并对 description 做 YAML 转义——手写一个冒号就能弄坏的东西不交给手写。name 与 description 之外的 frontmatter 行(第三方技能的license等)编辑时原样保留。 - 附属文件(
references//scripts//assets/)在详情页就地读写,路径限制在技能目录内,二进制只列出。 - 脚本头部规范:
scripts/下脚本用注释键值声明元信息(desc/usage/arg/opt/env),页面据此渲染参数控件并支持传参试运行(以技能目录为 cwd、60s 超时、非零退出码是结果不是错误)。规范细则见 .claude/skills/skills-manage。 - 对话里建的技能会被收录:codex 自带的
skill-creator写死把新技能建到$CODEX_HOME/skills,而那条路径被技能隔离软链到了分发目录——这类技能于是落成skillpack/skills/<name>真实目录,管理页看不见(列表只遍历源目录)却已对每条会话生效。启动时与技能列表前各纳管一遍:真实目录搬回源目录、原位置补回软链,启用状态与附属文件都不变;目录名归一到 kebab-case,与已有技能撞名时加-codex后缀,绝不覆盖用户自己写的那份。codex 自己铺的.system系列(它的运行数据)跳过。acpp 自带的create-skill技能教 AI 直接写<dataDir>/skills/<name>/,那条路本来就在源目录里。 - 换设备搬家走导出/导入 zip(见上表两个端点):技能库不跟着 app 分发,也没有云同步——在旧机器上「导出全部」,到新机器上「导入」。导入的技能一律停用,页面上看过再开;同名的跳过而不是覆盖。
- 一切变更只对新会话生效——agent 在
session/new时读取一次,进行中的会话不重载。
会话注入(已落地):每条会话在 session/new 与 session/load 都注入技能隔离,差异全部在 adapter 的 Isolation 里:
| claude | codex | |
|---|---|---|
| 加载技能包 | _meta.claudeCode.options.plugins(本地插件) |
<codex-home>/skills 软链到 skillpack/skills |
| 屏蔽机器级 | settingSources:["project"](不开 user 档)+ strictMcpConfig |
进程 env CODEX_HOME 重定向到 <dataDir>/codex-home——机器级 ~/.codex/skills 彻底不在视野 |
| 保留项目级 | project 档保住 cwd 的 .claude/skills |
cwd 进 additionalDirectories |
| 附加 | env CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 |
认证软链、配置复制自系统 ~/.codex |
不写 ~/.codex、~/.claude 一个字节。generic runtime 无可靠注入口,不隔离。
基础提示词(acp.ClaudeInstructions() / acp.CodexInstructions(),与隔离同批注入):每条会话追加一段通用约定,目前只讲一件事——两步以上的请求先建待办清单再动手,逐步更新状态,且必须用工具建(界面的进度卡只认工具事件,正文里手写 markdown 复选框等于没建)。正文两端共用,工具那段按方言分开:claude 的待办工具是延迟加载的(会话开场 TodoWrite 不存在,Task* 六件套只有名字没有 schema),提示它先 ToolSearch 检索 select:TaskCreate,TaskUpdate,TaskList;codex 的 update_plan 原生可用,但计划每轮独立,提示它跨轮重列。
注入口两端不同:claude 走 _meta.systemPrompt.append(必须传对象 {append},传字符串会整体替换 claude_code 的 preset),codex 没有协议注入口(session/new 的 _meta 只认 additionalRoots),写 <codex-home>/AGENTS.md——内容随版本比对覆盖。这里只放与项目无关的通用约定,按项目才成立的内容(数据库那段)不进来。
为什么不直接关掉延迟加载(2026-08-20 实测,claude-agent-acp 0.63.0 / agent-sdk 0.3.220):SDK 没有这个开关。tools 显式数组会整体替换内置工具集(漏一个就废掉一项能力,且随版本漂移),allowedTools 里列出 Task* 不会加载 schema(实测无效),env ENABLE_TOOL_SEARCH 是内部 gate、设了不生效。所以只能在提示词里教它自己去取。
codex 的 CODEX_HOME 隔离把家目录整体重定向到 <dataDir>/codex-home(codex 运行数据写这里,几 MB 量级),机器级技能连 /skills 都不再列出——比会话级禁用(CODEX_CONFIG 的 enabled=false 只挡使用不挡显示)彻底。家目录里 auth.json 软链系统的(静态 key、跟随登录态、不复制密钥),config.toml 复制系统副本(避免 codex 写回污染系统 config;只复制一次,之后系统 ~/.codex/config.toml 的改动不再同步——要给 acpp 的 codex 换模型/provider 就直接改这份副本,codex 自动写入的 [projects.*] 等段落留着不动),skills 软链技能包。副作用:切换到本方案后,旧 codex 会话的 thread 存在系统 ~/.codex、新 home 找不到,首次恢复会回退 session/new(丢一次上下文),之后正常。认证不隔离:claude 用系统钥匙串登录态、codex 用系统 ~/.codex 的 auth/config。
中文为默认与兜底语言,右上角切换,选择存在 localStorage(acp-language)。文案在 src/i18n/locales/{zh,en}.ts,i18next.d.ts 做了类型增强——写错 key 在编译期就会报错,不会等到运行时才发现少了一句翻译。
cd web && npx shadcn@latest add <component>组件基于 Base UI(不是 Radix),自定义触发元素用 render={<Link to="..." />},不是 asChild。
- 侧边栏的 agent 新建页仍是占位页(详情页已是配置页)。
- 服务器观察能力(adr-019):已落地(见上面「服务器」一节)。剩余:网页管理页还不能改频道锁定的服务器(走频道里的
/init),skill 还要用户自己放进技能库,服务器页没有只读浏览面板(人要看自己 ssh)。 - Discord 接入:已落地(频道绑定 adr-016、子区对话 adr-017、工作树与数据库环境绑定 adr-018);bot 申请与双 bot 隔离见 docs/discord-bot-setup.md。剩余:网页管理页能看到频道锁定的库与机器(「作用域」列),但改仍要走频道里的
/db source//server。 - 定时任务(Discord,adr-020):已落地(见上面「定时任务」一节)。剩余:
scheduled-task技能与其它技能一样要放进技能库;投递只到任务所在频道,「跑在 prod 频道、发到 #alerts」待需求出现再加。 - 技能助理:复用对话面板、把工作目录固定到技能源目录
<dataDir>/skills/<name>/,让 agent 帮忙起草/优化 SKILL.md。技能管理与会话注入均已落地,助理待做。 - 工作区面板(adr-002)M1–M4 已落地:dockview 骨架、九类面板、布局预设、多实例 PTY 终端与联动。剩 diff 虚拟滚动与压力验收。
- 消息流与 diff 的虚拟滚动:现在靠
content-visibility:auto让屏外内容不绘制,元素与 DOM 节点仍然全在,几千条的会话滚动仍有代价。与另两项性能遗留(git status的地板耗时、局域网场景的 h2c)一起记在 docs/性能优化-2026-08 末尾。 - 默认档:会话开在 runtime 默认档上(codex 默认 auto-edit 级、claude 默认 safe 级——两端不同),未强制归一;用户可在会话内随时切统一权限档。