AGENTS.md · git:20260924.568f05a · 2026-09-24 · sha256 15bf31023c49d7a2
AGENTS.md git:20260924.568f05aB
Immutable. This exact content never changes and is served at /api/v1/blob/15bf31023c49d7a2.
# AGENTS.md — pi-web-ui 项目指南
> 本文件是给 AI 编码助手(pi / Claude Code / Cursor 等)看的高层项目说明书。
> 详细文档按主题分拆在 `docs/` 目录下。
> 修改本文件后,在 pi 中运行 `/reload` 生效。
## 1. 项目是什么
pi-web-ui 是 pi 编码智能体(`@earendil-works/pi-coding-agent` SDK)的 Web 聊天界面:
浏览器里对话、查看文件树、附加文件、内置终端(xterm.js + node-pty)、模型管理、
声音提醒、中英文切换。一条命令可跑(`pi-web-ui`),可 Docker / systemd / launchd /
Windows 计划任务部署。
另有 **Electron 桌面壳**(`desktop/`):随机空闲口起同一个 server + BrowserWindow,网页版零改动;
Windows 安装包随 GitHub Release 发布(CI 出包,当前未签名,见 `desktop/README.md`)。
- 仓库(公开):`git@github.com:xing-shuyin/pi-web-ui.git`
- npm 包:`pi-web-ui`(发布者 npm 账号 `xingshuyin`)
- Node 要求:**>= 22.19.0**(pi SDK 的 dist 使用了 `import … with { type: "json" }` 语法)
- 版本:`package.json` 与 `package-lock.json` 两处同步维护
## 2. 技术栈
| 层 | 技术 |
| ------ | ----------------------------------------------------------------------------- |
| 后端 | Node + Express(静态 + `/api/health`)+ `ws`(`/ws` WebSocket 协议) |
| 前端 | React 19 + Vite 8 + react-markdown + highlight.js + xterm.js |
| 智能体 | `@earendil-works/pi-coding-agent` SDK(进程内,读 `~/.pi/agent` 配置) |
| 终端 | node-pty(服务端 PTY)+ `@xterm/xterm`(浏览器渲染,经 terminal bridge 转发) |
| 样式 | 单文件 `web/src/styles.css`(CSS 变量主题,深色) |
## 3. 目录结构
```
pi-web-ui/
├── server/ # 后端(Node ESM,编译到 dist/server/)
│ ├── index.ts # 入口:express 静态 + /ws 端点、消息分发、心跳、优雅停机
│ ├── protocol.ts # ★ 唯一事实源:wire 协议类型(client↔server 消息)
│ ├── agent-service.ts # 核心:ClientSession(每客户端一个会话组,可并行多个对话)+ AgentService
│ ├── serialize.ts # SDK 消息 → UiMessage 序列化
│ ├── text-sniff.ts # 文件预览纯函数(previewKind/looksLikeText/decodeText/sniffImageMime/hexDump/countLines)
│ ├── queue-utils.ts # 排队消息纯函数(removeFirstOccurrence:只移除第一条匹配,重复文本不连带删除)
│ ├── process-utils.ts # 进程工具:snapshotListeningPorts/killPidTree/lookupProcessName
│ ├── client-state.ts # ClientStateStore:<dataDir>/client-state.json 持久化
│ ├── uploads.ts # 文件对话上传 + 保留期清理
│ ├── bg-servers.ts # 后台任务跟踪(bash 前后端口快照 diff + 存活刷新)
│ ├── settings-service.ts # 设置面板状态机
│ ├── goal-service.ts # 目标/审查循环/调研向导
│ ├── i18n.ts # 服务端语言协商 + 翻译表注册(resolveServerLang/pick/bilingual/getServerBlock;v2 见下)
│ ├── tool-manager.ts # ★ Agent 工具统一开关:AGENT_TOOL_CATALOG(终端 7+子代理 8+其他 17:edit_soft/delegate_task/ask_user_question/todo_list/browser_page/conversation_read/skill/schedule_task/schedule_list/schedule_cancel/present_files/claim_files/plan_update/compact_context/patch/lsp/eval;合计 32)+ tool_manage 出入口(setAgentToolEnabled/applyAgentToolsGating),持久化只有 disabledAgentTools,遗留三开关双向同步
│ ├── approval-rules.ts # ★ 审批规则库与匹配引擎(ApprovalRulesStore:<dataDir>/approval-rules.json,多工具/多字段/多模式规则;ask/deny/allow 三种动作;纯函数评估)
│ ├── compact-context-tool.ts # ★ 主动压缩上下文工具(compact_context):AI 主动根据当前任务精简上下文,自主控制保留范围并在回合后生效
│ ├── edit-soft-tool.ts # 独立宽松编辑工具 edit_soft(行核心匹配,忽略缩进差异;开关走统一工具 tab)
│ ├── read-tool.ts # 覆盖内置 read:路径是目录时列目录条目(复用 SDK ls 的口径,其余原样转发内置;行为开关 readDirEnabled 在设置「工具」页,不入 AGENT_TOOL_CATALOG)
│ ├── present-files-tool.ts # ★ AI 展示文件(present_files):只读探测 stat/未知扩展嗅探/文本摘录 → 结构化 items 走 toolResult 的 details → 前端预览卡片;不自动打开任何窗口
│ ├── tool-info.ts # 工具定义说明(工具卡右键 → 显示工具详细信息):按名现取 SDK / DSH 运行时的工具定义 → `tool_info`(定义是大对象,不进快照;DSH 拿不到时回 `unsupported`)
│ ├── subagents.ts # 第一方子代理:subagent_* 工具(spawn/get_result/steer/list/stop/templates)+ 运行态快照
│ ├── subagent-templates.ts # 子代理模板库(全局 <dataDir>/subagent-templates.json;白名单语义;可选模型/思考强度,空=跟随主对话;enabled=false 对 AI 不可见)
│ ├── slash-commands.ts # 斜杠命令(NATIVE_COMMANDS 内置命令拦截执行 + 目录推送)
│ ├── model-admin.ts # 模型/服务商配置管理(含内置服务商多密钥:provider-keys.json + add/activate/remove_provider_key,模型目录复用系统默认,不复制)
│ ├── attachments.ts # 附件构建(路径/行范围引用 + imageData/fileData + 视觉桥;内容不注入)
│ ├── webui-context.ts # 扩展 UI 桥(WebUIContext:widgets/statuses/dialog → 浏览器)
│ ├── themes.ts # 主题管理(listThemes/resolveThemeFile)
│ ├── plugins.ts # 可选界面组件插件(扫描 <dataDir>/plugins/<id>/;renderer 插件字段 view:false + renderers)
│ ├── plugin-catalog.ts # 插件市场列表(builtin plugins/catalog.json + 用户自定义 <dataDir>/plugin-catalog.json)+ 目录同步纯函数(normalizeSyncPayload/writeCustomCatalog,issue #148)
│ ├── plugin-grants.ts # 插件目录授权表(<dataDir>/plugin-grants.json):工作区外目录的「记住」授权(父目录覆盖子目录),设置面板可撤销
│ ├── plugin-project.ts # ★ 插件项目组装执行层(host.project.create:clone/写文件/git init;路径越界三道校验,授权由上层判,本模块不动授权)
│ ├── plugin-installer.ts # ★ 插件后台作业:安装/更新/卸载跑 CLI 子进程(不占用户终端、不打断设置面板),输出按行回传 + 单作业锁 + 看门狗(issue #152)
│ ├── plugin-catalog-sync.ts # 市场目录同步编排(拉取/校验/原子写/可选安装/重载+重推,issue #148)
│ ├── vision-bridge.ts # 视觉桥:纯文本主模型看图转写
│ ├── files-service.ts # 文件服务(readDirForUI/readFile/searchFiles/watcher)
│ ├── scm.ts # SCM 只读 git 查询(execFile git status/branches/history/filediff/commit)
│ ├── patch-node-pty.ts # node-pty × Node --watch 兼容自愈补丁
│ ├── patch-remote-catalog.ts # pi.dev 模型目录整表替换补丁(幂等改写 SDK remote-catalog-provider:刷新后内置服务商列表=官方目录整表,不保留内置旧模型/不报“新增 N 个”)
│ ├── ensure-bash.ts # Windows 轻量 bash 兜底(busybox-w32)
│ ├── control-socket.ts # 本地控制 socket(status / quiesce / unquiesce)
│ ├── launch-origin.ts # ★ 启动来源探测:本实例是否被 launchd/systemd/Windows watchdog 托管(更新面板「重启服务」的前置条件)
│ └── terminals.ts # TerminalManager(PTY 管理 + 增量输出/按键工具)
├── web/ # 前端(React + Vite,编译到 web/dist/)
│ ├── vite.config.ts # dev 端口 5173,/ws 代理到后端
│ ├── src/
│ │ ├── App.tsx # 顶层布局
│ │ ├── use-chat.ts # ★ useChat():WebSocket 连接管理、reducer 状态机、终端 bridge
│ │ ├── app-globals.ts # ★ 全局运行态 store(engine/managed/tabs/版本号 + 全局发送器 appSend)
│ │ ├── types.ts # ★ wire 协议 re-export shim(`export type * from "../../server/protocol"`)
│ │ ├── i18n.tsx # ★ 多语文案(zh/en/it,zh 默认),新增 key 必须三处都加
│ │ ├── styles.css # ★ 全部样式(按组件分区,带注释分隔线);也是默认深色主题本体
│ │ ├── theme.ts # 主题切换(/api/themes 列表 + localStorage 持久化 + applyTheme)
│ │ ├── sounds.ts # WebAudio 提示音
│ │ ├── notify.ts # 桌面/OS 通知(PWA):是否吞掉通知的判定(Windows 最小化检测)+ 诊断",
│ │ ├── download.ts # 下载(fetch→blob,绕开 Chrome Safe Browsing)
│ │ ├── ui-slots.ts # ★ 宿主 UI 扩展点引擎(issue #146 完整版):BUILTIN_UI_ITEMS(109 条宿主内置入口,覆盖 12 个 slot;其中 browser/sound/language/theme/update/github 六个默认 `hidden:true`,落在「⋯」溢出菜单里)+ buildUiSlots(宿主默认→插件贡献→插件 arrange→用户偏好 四层合并,22 个 slot)+ splitOverflow/restoreUiItem
│ │ ├── topbar-fit.ts # ★ 顶栏「放不下的自动进 ⋯」纯函数(fitTopbar):按实测宽度从尾部丢,宽度 0 的条目(桌面端 CSS 藏起的 ☰/📁)不占位也不进菜单,测不到宽度全保留,有单测
│ │ ├── context-menu-state.ts # ★ 通用右键菜单状态(contextmenu.* 槽位的 open/close + 定位/禁用/键盘导航纯函数,有单测)
│ │ ├── composer-bridge.ts # ★ 输入框注入桥:宿主(扩展/插件)把内容塞进输入框草稿的模块级 sink 注册点,有单测(配 composer-draft.ts 的合并/去重纯函数)
│ │ ├── caret-visual-line.ts # ★ 输入框光标的首/末**视觉行**判定(镜像 div 量 offsetTop,自动折行算行;无布局时回落逻辑行),供 ↑/↓ 翻输入历史用,有单测 + 真浏览器回归(tests/composer-history-test.mjs)
│ │ ├── message-delta.ts # message_delta 增量 patch 纯函数,有单测
│ │ ├── lazy-window.ts # 消息列表惰性窗口化纯函数,有单测
│ │ ├── search-text.ts # 会话内搜索索引纯函数,有单测
│ │ ├── present-items.ts # ★ present_files 卡片纯函数(参数解析/与 details 合并/能力判定/自动打开闸门),有单测
│ │ ├── tool-info-state.ts # 工具定义弹窗的模块级 store(打开/关闭/应答 → 视图,纯函数 + 单测)
│ │ ├── present-settings.ts # 卡片偏好:自动打开预览弹窗(localStorage,默认关)+ 已开过的 toolCallId 去重表
│ │ ├── tool-schema.ts # 参数 JSON Schema → 表格行(参数名/类型/必填/说明,纯函数 + 单测)
│ │ ├── file-preview-bridge.ts # 工具卡片 → 文件预览弹窗的模块级 sink(App 注册 opener,同 composer-bridge 那一套)
│ │ ├── skill-block.ts # parseSkillBlock:<skill> 块解析,有单测
│ │ ├── tool-args.ts # 工具卡头参数提示纯函数(路径/超时安全提取,脏参数不抛错),有单测
│ │ ├── auth-token.ts # PI_WEB_TOKEN 口令注入,有单测
│ │ ├── image-paste.ts # 粘贴图片等比缩放 ≤1568px + PNG/JPEG 转码
│ │ ├── uuid.ts # randomUuid(crypto 兜底),有单测
│ │ ├── protocol-version.ts # 协议版本常量
│ │ ├── main.tsx # 入口:首帧前应用主题防闪烁 + initAuthToken
│ │ └── components/ # 见下
│ └── dist/ # 构建产物(gitignore,但打进 npm 包)
├── bin/pi-web-ui.mjs # CLI:前台启动 / server install|uninstall|start|stop|restart|status
├── desktop/ # Electron 桌面壳(sidecar:随机空闲口起 dist/server + BrowserWindow)
│ ├── main.ts # 主进程:ready → 选空闲口 → spawn server(ELECTRON_RUN_AS_NODE) → loadURL
│ ├── electron-builder.yml # 打包配置(win nsis / mac dmg / linux AppImage;npmRebuild:false)
│ └── README.md # 桌面版说明:跑起来 / 约定 / 发布 / 签名(SignPath)
├── deploy/ # 部署示例:launchd plist / systemd unit / Windows 任务 XML
├── themes/ # 内置主题(完整样式表::root 变量 + 任意选择器/布局覆盖皆可,styles.css 只是共享基线)
├── make-light-theme.mjs # 主题生成器(从 styles.css 的 :root 变量清单生成纯调色板)
├── tests/ # 全部测试脚本(自包含:独立端口 ≥8900 + 临时 data-dir)
│ ├── run-smoke.mjs # 零 token 协议冒烟聚合跑器(ALL 列表;插件后台作业/目录同步 plugin-jobs-test、多根工作区
│ │ # workspace-roots-test 都在里面)
│ ├── unit/ # vitest 纯函数单测(插件 UI 扩展点:plugin-ui-manifest / ui-slots / context-menu /
│ │ # plugin-grants / plugin-project / plugin-installer / plugin-catalog-sync;多根工作区:workspace-roots)
│ │ # 插件设置(声明式 schema / select 候选值):plugin-settings / plugin-setting-options
│ ├── *-test.mjs # 手写 Playwright E2E / WS 协议测试
│ │ # ⚠ 需 Chrome 的浏览器 E2E **不入 run-smoke**,手动 `node tests/<name>.mjs` 跑(缺 Chrome 自动 SKIP):
│ │ # context-menu-ui-test(右键菜单槽位)/ ui-layout-ui-test(布局页 ↔ 界面一致性)/ plugin-settings-page-test(插件设置页)/ plugin-settings-select-ui-test(插件设置下拉)/ plugin-topbar-ui-test(顶栏条目 + 面板内后台卸载)/ elsewhere-click-takeover-ui-test(「另一处」行左键两段确认过户)
│ └── scratch/ # 一次性调试脚本(gitignore,不入库)
├── scripts/check-protocol-sync.mjs # 守护 types.ts shim 单源机制 + protocol.ts 纯类型约束
├── .github/workflows/ci.yml # CI:协议同步 → typecheck → build → vitest → 冒烟
├── .github/workflows/release-notes.yml # tag 推送 → 按 CHANGELOG 建/更新 GitHub Release
├── .github/workflows/desktop-release.yml # tag 推送 → windows-latest 出 NSIS 安装包并附到 Release(签名以后加这里)
├── extensions/ # pi 扩展:webui.ts(/webui 命令启动本机服务并打开浏览器)
├── plugins/ # 官方插件(webmail / db-client / vscode-editor / demo-mailbox / mermaid / run-trace / legado-web / wechat-ilink / image-toolkit / notes / live-preview / voice-input / desktop-use,各自的 README.md 见其目录;page-picker/extension 是**浏览器扩展**,不是 pi-web-ui 插件)
│ └── catalog.json # ★ 插件市场内置列表(随包发布;社区加插件 = 在此加一条 + PR)
├── dev/ # 本地开发辅助(notice/search 预览等,不入 npm 包)
├── Dockerfile / docker-compose.yml
├── docs/ # 详细文档(本文件的分拆)
│ ├── architecture-core.md # 核心架构:快照驱动、协议单源、安全边界、主题切换、多对话并发
│ ├── architecture-attachments.md # 附件、图片、视觉桥、文件上传/预览/下载
│ ├── architecture-terminal.md # 终端架构:PTY 管理、SCM 查询、活力检测、终端接管 bash
│ ├── architecture-plugins.md # 插件系统:形态、协议、宿主扩展点、MCP 桥
│ ├── architecture-system-prompt.md # 系统提示词组成:base/追加段/项目上下文/技能段 组装链路与 override 钩子
│ ├── development.md # 开发工作流、CI、编码约定、测试规范
│ ├── release.md # 发布流程(GitHub + npm)
│ ├── deployment.md # 部署(CLI / Docker)
│ ├── dsh-engine.md # DSH 引擎(agent 预设、问卷、底栏统计、工具桥)
│ ├── antigravity-proxy.md # antigravity 反代接入
│ └── env-vars.md # 环境变量参考
├── tsconfig.server.json / tsconfig.extensions.json / tsconfig.tests.json / web/tsconfig.json
```
`web/src/components/` 速览:
| 组件 | 职责 |
| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FilePreview.tsx` | 文件预览弹窗:行号、点选/拖拽/Shift 选区、添加到对话;Markdown 预览可切换原文;可编辑保存 |
| `PresentedFiles.tsx` | `present_files` 工具卡片正文:图片/视频/音频在消息里直接显示/播放(点媒体开预览弹窗看大图),文本给开头摘录 + 「预览」按钮,每行带「预览/本地打开/在文件夹中显示/下载/复制路径」(后两项走 `file_reveal`/`file_open_default`);AI 标 `focus` 的条目按偏好自动弹预览窗(开关/刚发生/toolCallId 未开过 三道闸) |
| `ToolInfoDialog.tsx` | 「工具详细信息」弹窗(工具卡右键 `contextmenu.toolcall` 的宿主内置条目):展示工具的**定义**(说明 / 提示词摘要与要点 / 参数 schema 表格 + 原始 JSON),不含本次调用数据;内容由 `get_tool_info` 点开现取(不进快照),状态机在 `tool-info-state.ts`、schema 表格化在 `tool-schema.ts`(都是纯函数 + 单测);portal 到 body(同 ContextMenu 的理由:消息流祖先有 overflow/transform) |
| `LeftPanel.tsx` | 左栏:最近项目、运行的对话、历史对话(含删除) |
| `RightPanel.tsx` | 文件树浏览(list_files),文件名点击→预览,🔗 引用路径(仅路径,无内容注入)/👁 预览/⬇ 下载等按钮;服务端原生递归 watcher |
| `ChatInput.tsx` | 输入框 + 附件 chips(引用/行范围/图片/上传/网页/对话多彩);全窗口拖放目标;followUp 排队/steer 插队;斜杠命令选择器 |
| `Message.tsx` / `MessageList.tsx` | 消息渲染(附件卡片、流式光标、tool 结果关联);编辑重问保留原附件;技能卡片折叠;惰性窗口化;问题导航双通道;流式 StreamMarkdown |
| `ToolCallBlock.tsx` / `ThinkingBlock.tsx` / `BashBlock` | 工具调用卡片(卡头状态图标右侧显示关键参数提示:文件路径 `.toolcall-path` / 超时 `.toolcall-timeout`,任何工具都试取,脏参数静默不显示)、思考块、bash 输出 |
| `TerminalPanel.tsx` / `TermXterm.tsx` | 终端视图 + xterm 实例桥接 |
| `SCMPanel.tsx` | 源代码管理(Git)视图:status/branch/diff;提交/推送/拉取/切换分支;左栏宽度可拖分隔条调、双击复位、宽度存 localStorage(#139) |
| `BrowserControl.tsx` | 顶栏「浏览器操作」入口 + 状态面板(扩展在不在 / 授权几个页面 / 总开关 / 打开扩展设置页 / 可照抄例子);已授权页面一键「引用到对话」→ `mode:"page"` 的网页引用附件 chip(单页面时顶栏按钮直接变成该页面标题,点主体=引用、右侧 ▾=开面板) |
| `TopBar.tsx` / `FooterBar.tsx` | 顶栏(模型/思考强度/后台任务/声音/新对话/视图切换)、底栏(上下文/成本/工作目录) |
| `Dialog.tsx` | 扩展 `ui.select/confirm/input` → 浏览器弹窗(正文/选项走 `Markdown(rawHtml)` 富渲染) |
| `DshQuestionDialog.tsx` | 模型提问对话框(`question_pending`,DSH 引擎经 goal-rpc userQuestions、标准 pi 引擎经 pi-web-ui 注册的 `ask_user_question` customTool 共用):单选/多选/自定义文本 + 选中带 `preview` 的选项时「选项预览」富文本;question/detail/description/preview 走 `Markdown(rawHtml)`。待答问卷同时挂在快照(`UiState.pendingQuestion`)上,刷新/重连后由 `web/src/pending-question.ts` 恢复面板 |
| `ModelConfigModal.tsx` / `PiSetupModal.tsx` | models.json 管理 / 首次配置引导 |
| `SettingsModal.tsx` | 设置面板(侧边栏分页:提示词/工具(含终端+标记管理)/消息显示/技能/插件/界面插件/目标审查/视觉桥/预设/子代理模板;DSH 另有问卷页、无工具页) |
| `GoalBar.tsx` | 输入框上方目标条:设目标/清除/AI 提炼/轮数下拉 |
| `BgTasksModal.tsx` | 后台任务弹窗:AI 启动的监听端口进程列表 |
| `ModelThinking.tsx` | 模型 + 思考强度下拉(模型下拉左侧按服务商筛选 + 顶部搜索过滤框) |
| `GlobalSearchModal.tsx` | 全局搜索弹窗(Ctrl+K):搜历史对话/最近项目/工作区文件名 |
| `PluginView.tsx` | 插件视图宿主:薄 React 壳 + 动态 import client bundle |
| `SlotTabs.tsx` | slot 驱动的 tab 容器(`rightpanel.tabs` 与设置面板「内置页+插件页」共用):除当前选中项外一律**不挂载**,切走即调插件 cleanup;选中态存 localStorage |
| `ContextMenu.tsx` | 通用右键菜单渲染层(`contextmenu.topbar/message/session/file` 四个槽位):portal + fixed + 先渲染再实测尺寸钳制;只渲染,点条目交回宿主 `onAction` 分发 |
| `PluginPage.tsx` | 插件自定义设置页宿主(`settings.pages` 槽位):随选中项挂载/卸载(切走即 cleanup);容器子节点只由插件写,失败提示是兄弟节点 |
| `CollapsedMessage.tsx` / `LazyMount.tsx` | 消息折叠摘要行 / 消息级惰性挂载包装 |
| `SearchBar.tsx` | 会话内搜索栏(Ctrl+F,CSS Custom Highlight API 高亮) |
| `Markdown.tsx` / `Dropdown.tsx` / `copy-button.tsx` / `HintTip.tsx` / `SoundSettings.tsx` | 通用件(HintTip:`?` 悬浮提示 portal 顶层渲染) |
## 4. 核心架构(摘要)
> 详细文档见 `docs/architecture-*.md`
| 主题 | 文档 | 要点 |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **快照驱动** | `docs/architecture-core.md` | 服务端是唯一事实源,60ms 节流推快照;增量快照(snapshot_delta);message_delta 实时增量通道不经 snapshot 通道;WS permessage-deflate 压缩;多标签页序列化共享;协议版本协商 |
| **协议单源** | `docs/architecture-core.md` | `server/protocol.ts` 是唯一事实源;`web/src/types.ts` 是 `export type *` shim;新增消息只改 protocol.ts,两端 switch 各加分支 |
| **全局运行态** | `docs/architecture-core.md` | `web/src/app-globals.ts`:身份/能力(engine/managed/tabs/版本号)+ 连接态与 cwd 这类「整棵树都要」的信息放模块级 store,窄 props 组件用 `useAppField(key)` 单字段订阅、不再要 prop(吃整个 ChatState 的 App/TopBar/FooterBar 仍直读 `chat.*`);全局发送器 `appSend` 也在这里(组件不收 `send` prop,面板的 `panelSend` 包装除外);快照流里的数据严禁进去(store 通知绕过 memo) |
| **安全边界** | `docs/architecture-core.md` | 默认只绑 loopback;WS Origin/Host 同权威校验;quiesce 准入控制;控制 socket;provider headers 不下发浏览器 |
| **主题切换** | `docs/architecture-core.md` | styles.css 是共享基线(默认深色主题 + 全局布局,抽离只为复用方便);主题 = 完整样式表,可覆盖一切(:root 变量/任意选择器/布局改动/自带新 token 皆可);生成器主题由 make-light-theme.mjs 从 styles.css 变量清单生成;终端跟随主题 |
| **多对话并发** | `docs/architecture-core.md` | 每对话独立 AgentSessionRuntime;对话按项目归属;set_cwd 切到目标项目对话;8 个上限/项目(子代理不计入);共享同一个 ModelRuntime;左栏「运行的对话」的展示口径 = listed ∪「当前对话 + 已有内容」(#140:空白对话不入列、只影响展示,不动保留/释放规则) |
| **附件** | `docs/architecture-attachments.md` | 一律只给路径引用(reference/lines;文件内容不注入 prompt,旧 `inline` 按 reference 处理);图片问答(base64 + 缩放);文件上传(fileData 落盘);视觉桥(纯文本模型看图转写) |
| **文件预览** | `docs/architecture-attachments.md` | 512KB 上限 + 内容嗅探(文本/二进制 + GBK 回退);媒体预览走 HTTP Range;下载绕开 Chrome Safe Browsing |
| **终端** | `docs/architecture-terminal.md` | 每 Conversation 一个 TerminalManager;spawn 统一准入;按键编码纯函数;输出微批合并;node-pty × --watch 兼容自愈 |
| **SCM** | `docs/architecture-terminal.md` | 只读 git 查询走 execFile 直跑(不经过 shell);git-dir watcher;写操作走可见终端 tab |
| **终端接管 bash** | `docs/architecture-terminal.md` | 覆盖 SDK bash;设置开关 `terminalBash` 分流(关=原生 SDK 纯进程 bash,开=可见终端);开时 `persist` 决定一次性/持久(false=跑完进程结束、输出保留;true=持久 ai-bash,shell 状态跨调用保留);`head`/`tail` 截返回行;哨兵行技术;静默解阻(持久);ai-bash/ai-bash-<n> 前端「AI bash」折叠分组且不计入终端数量上限 |
| **read 读目录** | `docs/architecture-core.md` | 覆盖 SDK 内置 read(customTools 按 name 覆盖,bash 是先例):路径是目录 → 分流到 SDK 的 ls 实现列条目(`/` 后缀、同截断口径),其余(文件/图片/不存在)原样转发内置;开关 `readDirEnabled`(设置「工具」页,默认开)是行为开关不是工具开关,不入 AGENT_TOOL_CATALOG/预设,live 生效;仅 pi 引擎(DSH 无 customTool 注册面) |
| **插件** | `docs/architecture-plugins.md` | <dataDir>/plugins/<id>/ 目录(manifest.json + index.mjs + client/entry.mjs);attach 时热重扫;fenced-code 渲染插件(renderers + view:false,命中 ```lang 才懒加载,见 plugin-fence.ts;`view:false` 但顶层代码要常驻的插件写 `preload:true`,宿主每次进页预加载、可无 `default.mount`,见 plugin-loader.ts 的 preloaded);官方插件走 `pi-web-ui install`(含子目录 source)分发,不进 npm 包;插件市场(plugins/catalog.json 内置列表 + 用户自定义,设置面板一键 `install --name <id>`,见 plugin-catalog.ts);插件后台作业(安装/更新/卸载不占用户终端、设置面板就地显示进度,见 server/plugin-installer.ts);`host.reloadCatalog()` 市场目录同步(#148);`pi-web-ui install --build` 源码隔离构建(#150);**UI 扩展点框架**(#146 完整版):manifest `ui` / `host.ui.*` 贡献 + `arrange` 整理,宿主负责渲染/排序/溢出/可访问性/用户偏好/审计,插件不碰 DOM(动作经 `host.onUiAction` 回插件),合并规则与 22 个挂载点见 `web/src/ui-slots.ts`;插件自定义设置页(`settings.pages` → PluginPage.tsx);通用右键菜单(contextmenu.* → ContextMenu.tsx);目录授权与跨目录 fs(`server/plugin-grants.ts` + `host.fs.requestAccess/authorizedDirs/listPath/readPath/readTextPath/writePath/removePath`,工作区内免授权);项目组装 `host.project.create`(`server/plugin-project.ts`);插件→宿主动作桥 `window.__piWebUiHost`(宿主 API **v11**:setView / startChat / compose(塞输入框草稿,见 web/src/composer-bridge.ts)/ openSession(带目录授权,多根)/ sessions.list\|open / reloadCatalog / onUiAction / openModal+closeModal / shortcuts.register / searchProviders.register / composerProviders.register / pageCall / dom.anchors(特权 DOM 挂载点,见下),时序见 web/src/plugin-host.ts);特权 DOM:manifest `permissions` 含 `dom` 族的插件 bundle 默认 403,需用户在设置面板逐个授权(`server/plugin-dom.ts` 的授权表 + `plugin_dom_consent`,授权后 epoch+1 重拉),限制的是“谁可以”而非能力;宿主设施版本 `PLUGIN_API_VERSION = 2`(manifest `apiVersion` 高于宿主则拒绝激活);MCP 工具桥 |
| **多根工作区** | `docs/architecture-plugins.md` | `set_workspace_roots`(额外工作区根,**按项目 cwd 持久化**、上限 `MAX_WORKSPACE_ROOTS = 8`,见 `server/client-state.ts` 的 `normalizeWorkspaceRoots`):右栏「工作区根」选择器可切根/移除根;**AI 仍只在主 cwd 干活**,额外根只改「哪些算工作区内」—— `PluginManager.isInsideWorkspace()` 把它们算进去,插件 `host.fs.*` 读那些目录**免授权**;快照走 `UiState.workspaceRoots`(pi/DSH 双引擎都落快照;DSH 没有插件宿主,多根在那里只影响右栏文件树) |
| **子代理模板** | `server/subagents.ts` + `server/subagent-templates.ts` | 设置面板配置角色系统提示词(append/replace)+ 技能/扩展白名单 + 可选模型/思考强度(空 = 跟随主对话当前模型/强度);AI 经 subagent_templates 查询、subagent_spawn(template=) 选用也可不传按默认;停用模板对 AI 不可见;全局共享 |
| **DSH Agent 预设** | `server/dsh/preset-clones.ts` + `server/dsh/runtime/`(`override.patch.yml`/`custom-prompt.mjs`/`goal-rpc.mjs`/`launcher.mjs`)+ `DshPresetBar.tsx` | dsh-web 四模式复刻(standard/ptc/minimal/cordis + 自建):file: 克隆 shipped 预设(裸包名→入口文件 URL,roster 唯一 system 根)+ preset-plane patch 原子下发(缺失回落 legacy);会话创建 mount preset、空白可切、首轮锁定(`agent-preset/locked`);自定义提示词走独立 host section(minimal 压住);桥接工具/问卷在 minimal 调用期拒绝;问卷与技能钩子必须挂 agent scope(host 收不到 scoped 事件) |
| **DSH 底栏统计** | `server/dsh/dsh-usage.ts` + `server/dsh/runtime/goal-rpc.mjs` + `tests/dsh-stats-test.mjs` | 上下文占用 / 缓存命中 / 回复速率:老运行时吃持久 `assistant/chunk`,新版运行时走 agent scope 的 `agent/assistant-stream` 直播帧(wrapper `{ global: true }` → `assistant.stream`)+ 结算时的 `assistant/message.usage`;纯映射在 dsh-usage.ts |
| **压缩软上限** | `server/soft-cap.ts`(纯函数:normalize/effective/softCapToReserve,单测)+ `ClientSettings.softCapTokens/softCapByModel`(全局+按模型,`client-state.json`,随预设走)+ `ClientSession.applyCompactionOverrides()` | 软上限 C → `compaction.reserveTokens = W − C` 经 `settingsManager.applyOverrides` 注入(与重试次数同一 live 机制;SDK 每次自动压缩检查前重读,无需 reload);关闭/非法时回填 SDK 默认 16384;重放点 = 全部重试重放点 + 换模型 + 设置变更即时(含 `flushSnapshot` 推底栏标记线);快照走 `contextUsage.softCap`(底栏琥珀色标记线 + hover 阈值);pi 引擎独有,DSH 保持关闭 |
| **工具结束实时状态** | `docs/architecture-core.md` | tool_status 先于快照落盘,浏览器卡片立即从「执行中」→「已结束」 |
| **AI 展示文件** | `server/present-files-tool.ts` + `web/src/components/PresentedFiles.tsx` | `present_files` 工具(工具目录第 25 个,默认开,仅 pi 引擎):模型给路径清单 → 服务端**只读**探测(stat + 未知扩展嗅探前 4KB + 文本开头摘录 ≤1200 字、总量 6000 字)→ 结构化 items 进 toolResult 的 `details`(`serialize.ts` 现在会把 details 下发浏览器,≤64KB 超限**整丢**;随会话文件持久化,重放后卡片照常)→ 前端卡片:图片/视频/音频**内联直接看**(不折叠、不用点;媒体 URL 复用 `download.ts` 的 `downloadUrl(path, false)`,`/api/file` 白名单加了 `isAudioFile`)、文本给开头摘录 + 「预览」按钮开既有预览弹窗(`web/src/file-preview-bridge.ts` 模块级 sink,App 注册 opener)、每行「预览/本地打开/在文件夹中显示/下载/复制路径」(本地两项复用 issue #187 的 `file_open_default`/`file_reveal`,远端或无桌面主机由服务端 notice 报不支持);AI 标 `focus: true` 的条目 → 设置「对话」页的「自动打开 AI 展示的预览」(localStorage,默认关)开着时自动弹预览窗,三道闸:开关开 + 工具结果时间戳 ≤30s(翻旧会话不弹)+ toolCallId 未开过(`web/src/present-settings.ts`);卡片渲染**不依赖 details**(参数解析 + 按路径配对合并,缺 details 时 kind=unknown 照给预览按钮,见 `web/src/present-items.ts`);开关在设置「工具」页(「其他」组,默认开) |
| **工具挂死看门狗** | `docs/architecture-core.md` | 20 分钟超时自动 abort 会话;只停止运行不碰后台服务;**`ask_user_question` 豁免**(等人类回答不限时,问卷挂着也不算失联) |
| **审批规则与三档放行** | `server/tool-approval.ts` + `server/approval-rules.ts` + `web/src/components/ToolApprovalDialog.tsx` | 人机协同拦截与自定义规则:① **规则引擎与规则库**(`<dataDir>/approval-rules.json` 全局共享,设置面板「审批规则」页可视化管理):支持针对工具名(bash/write/edit/edit_soft/*)、字段(command/path/params)与多种匹配模式(regex/glob/contains/prefix/outside_workspace)自定义规则;命中动作支持需审批(ask)、直接拒绝报错(deny)与免审放行(allow 白名单);内置高危规则(rm -rf / 破坏性 git / 格式化 / chmod / 敏感文件 / 越界写入)均可自定义启停、改动作或恢复默认;② **三档放行策略**:全局关(设置 `toolApprovalEnabled`)→ 一切审批不弹;本对话「**全部允许**」;本对话「**允许同类**」(粒度 = 规则档位 `UiApprovalCategory.id`)。门禁在 `ClientSession.askApproval` 入口(纯函数 `approvalSuppressionReason`):直接 resolve approve,不建 pending、不弹窗;记忆挂在 `Conversation.approvalPolicy`(仅内存,手动过户随对话搬),设置页可撤销。 |
| **待答问卷进快照** | `docs/architecture-core.md` | `UiState.pendingQuestion` + `web/src/pending-question.ts`:刷新/重连后恢复问卷对话框(即时通道只推给提问那一刻在线的连接) |
| **未发送草稿进快照** | `docs/architecture-core.md` | `UiState.draft` + `server/composer-drafts.ts`(单中心文件 `<dataDir>/composer-drafts.json`,按 sessionId 键入,每会话最新一条;localStorage 同步镜像一层):只跟**全量快照**走(增量 delta 里 key 整个缺席,否则 `{...ui, ...d.state}` 合并会把草稿洗掉),`draft_update` 自带 sessionId(切会话的离开刷盘晚于服务端切换,不能按 active 落键);发送/删会话即清,30 天 TTL 兜底;不进 LLM 上下文/搜索,pi 引擎独有 |
| **后台任务列表** | `docs/architecture-core.md` | bash 前后端口快照 diff;按客户端持久;单停/全部关闭 |
| **临时对话(无痕会话)** | `server/agent-service.ts` + `web/src/components/TopBar.tsx` | `new_chat { ephemeral: true }` → `SessionManager.inMemory`(**不落盘**、不进历史、不占每项目 8 个持久对话名额;`Conversation.isEphemeral`):顶栏「🎭 临时对话」按钮(**仅 pi 引擎**,DSH 的 `newChat` 无 inMemory 分流,画出来只会得到普通持久对话);对话流顶部常驻提示条 + 「💾 保存为正式对话」= **一键转正**(`persist_conversation` 把内存条目 dump 成 `.jsonl`、清 `isSubagent`/`isEphemeral`、登记进历史,**对话 id 与内容原地保留**;右键行里的「保存为正式对话」是同一动作,左栏行带紫色「临时」徽标)。两个坑:① 提示条读的是**快照上的 `UiState.isEphemeral`**,不能读 `conversations` 列表 —— 空白的临时对话不在运行列表里(`shownInRunningList` 只列有内容的),刚新建时列表里查不到它;② 该字段必须**恒存在**于 `buildLightState`(不能只在 true 时展开),转正后它 true→false 而增量快照是 `{...ui, ...d.state}` 浅合并,缺字段会把 true 残留。回归:`tests/ephemeral-chat-test.mjs`(含「普通新对话确实落盘」的对照组) |
| **扩展 UI 桥** | `docs/architecture-core.md` | setWidget/setStatus/notify/select/confirm/input → 浏览器消息;dialog_response 回传 |
| **模型操作浏览器页面** | `docs/architecture-core.md` | `browser_page` 工具:模型 → 服务端(`page_request`)→ 浏览器里的 pi-web-ui 页面(宿主桥 `pageCall`,宿主 API v3)→ page-picker 扩展 → **被授权的目标页面**(`page_response`);闸门全在扩展侧(授权表 + 动作白名单 + 总开关 + `eval` 开关);页面关着时工具报错,不做服务端代理 |
| **对话引用** | `server/conversation-read-tool.ts`(工具)+ `server/attachments.ts`(`conversation` 附件)+ 左栏右键 / 全局搜索(`contextmenu.session` 三件套 + `gs-quote`) | 用户把别的对话塞进输入框(💬 chip:运行中按 conversationId、历史按 sessionPath):服务端只发小的 `<conversation-ref>` aside(转录不内联、不占 token、不过期),模型经 `conversation_read` 按需取(list 看运行+历史,read 分页读;历史 path 必须在会话列表里,任意文件拒绝;其他标签页的实时运行读不到,以落盘历史为准);子代理会话同样注册该工具可嵌套读;开关走统一工具 tab(默认开) |
## 5. 开发工作流
> 详细文档见 `docs/development.md`
```bash
npm run dev # 并行:node --watch 后端(:8788) + vite 前端(:5173)
npm run typecheck # 双端 tsc --noEmit(提交前必跑)
npm run format # prettier 全仓库格式化(提交前必跑;只检查用 format:check)
npm run lint # oxlint(未使用变量/危险模式;提交前必跑;自动修用 lint:fix)
npm run build # build:web (vite) + build:server (tsc)
npm start # 跑编译产物 dist/server/index.js(生产)
npm test # vitest 纯函数单测
npm run test:smoke # 零 token 协议冒烟聚合跑器
npm run desktop:dev # Electron 桌面壳指到本地 sidecar(:随机口,需先 npm run build)
npm run build:extension # 打包 page-picker 浏览器扩展(esbuild → plugins/page-picker/extension/dist/)
npm run pack:extension # 打浏览器扩展 zip → release/(打 tag 时 CI 自动出包并挂 Release)
npm run desktop:dist # 本地打桌面安装包 → release/(gitignore;CI 也跑同一条)
```
**关键约定**:缩进用 Tab;i18n 走 `useT()`(核心只含 `zh`/`en`,其余语言是 `locales/*.json` 可下载语言包、不进 npm,缺 key 自动回落英文;新 key 加 zh+en 即可,`tests/unit/locales.test.ts` 锁 key 对齐;8 个语言包也要同步加 key 且顺序与 zh 一致);服务端多语言(issue #91):`server/i18n.ts` 中英内联 + 翻译表(v2)。`resolveServerLang` 只做归一(zh-CN→zh、pt-BR→pt,空→en);`pick(lang,zh,en,key?)` 第 4 参数是全局唯一翻译 key(`<模块>.<slug>`,如 `subagents.list.empty`),zh 走内联中文、其他语言查表、缺表/缺 key 回落英文;多行块用 `getServerBlock`(表里存 `\n` 拼接的一行);tool definition 用 `bilingual(en,zh)` 静态双语(无 key,模型看英文无碍);模板内容/用户覆盖保持 zh/en 字段(配置品,不进表)。第三语言的表放在语言包的 `serverStrings` 节(与 `strings` 同文件、一次下载全带走,`validatePack` 校验;缺 key 自动回英文所以部分翻译可安全上线);服务端启动 + 包安装/删除时经 `loadServerStrings`/`unloadServerStrings` 注册。浏览器经 `hello.locale`/`set_locale` 上报 UI 语言(`client-state.json` 的 `locale` 持久化,切换经 settings reload 通道自动应用);maker 统一收可选 `lang?: () => ServerLang`,推 UI 的 notice 走 `text`+`textEn` 双字段(前端按 locale 自选);样式全部在 `styles.css`;新增协议消息只改 `protocol.ts` 再两端 switch 加分支;**前端新增服务端 URL(`/ws`、`/api/*`、`/plugins/*`、`/themes/*`)一律用 `web/src/base-url.ts` 的 `appUrl()` 包一层**(nginx 子路径反代依赖应用根前缀,裸写根路径会在子路径部署下 404)。
**测试规范**:端口隔离(≥8900);data-dir 隔离(`mkdtempSync`);精确清理自己进程;不允许 `pkill -f` 杀全局。
## 6. 发布流程
> 详细文档见 `docs/release.md`
```bash
# 升版本 → 写 CHANGELOG(含 npm run changelog:i18n 自动记文案增量)→ 自检构建 → commit → push → 打 tag(Action 自动建 Release + 出桌面安装包)→ npm publish
npm run typecheck && npm run build
npm run changelog:i18n # 文案有增减时必跑:自动刷新 CHANGELOG Unreleased 的 ### i18n
# 预览 Release 说明(只看不发):node scripts/release-notes.mjs X.Y.Z --base v<上个版本>
git add -A && git commit -m "feat(xxx): 描述"
git push origin main
git tag vX.Y.Z && git push origin vX.Y.Z # tag 带 v 前缀,数字与 npm 版本一致;推送后 Action 自动创建/更新 GitHub Release(含现场生成的 ### i18n),无需手跑 gh release create
npm publish
```
注意事项:版本号必须高于 npm registry;提交信息不要带 `Co-authored-by`;升级后需手动重启服务 `pi-web-ui server restart`;发布前检查示例文件不泄密。
## 7. 环境变量
> 完整列表见 `docs/env-vars.md`
| 变量 | 默认 | 一句话作用 |
| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PI_WEB_PORT` | `8787` | HTTP 端口 |
| `PI_WEB_HOST` | `127.0.0.1` | 监听地址(默认只绑 loopback) |
| `PI_WEB_CWD` | `process.cwd()` | 智能体工作区 |
| `PI_WEB_DATA_DIR` | `~/.pi-web` | 数据目录(client-state / uploads / plugins) |
| `PI_WEB_SDK` | `bundled` | 用哪一份 pi SDK(issue #260):`bundled`(默认)= 自带副本;`global` = 祖先链上有**更新**版本时改用它(升级全局 pi CLI 默认不会改变服务运行的 SDK) |
| `PI_WEB_TOKEN` | 空 | 可选共享口令鉴权 |
| `PI_WEB_PLUGIN_CATALOG_URL` | 官方清单 URL | 开机插件市场清单来源:默认指向官方社区清单 `xing-shuyin.github.io/pi-web-ui-plugins/catalog.json`(仅拉取目录文档写可安装列表,**不自动安装插件**);设为空串或 `off`/`0`/`false`/`no` 可关闭;配合 `PI_WEB_PLUGIN_CATALOG_INSTALL=1` 才会在开机时顺手自动安装 |
| `PI_WEB_TOOL_TIMEOUT_MS` | 20 分钟 | 工具挂死看门狗超时(`ask_user_question` 问卷豁免,不受此限制) |
## 8. 部署
> 详细文档见 `docs/deployment.md`
- **CLI 前台**:`pi-web-ui --port 9000 --cwd /path`
- **开机自启**:`pi-web-ui server install`(macOS→launchd / Linux→systemd / Windows→登录 Run 键,无需管理员)
- **Docker**:`docker compose up -d`
## 9. 常见坑
- **服务活着时跑 `npm run build` 会黑屏**:vite 先清空 `web/dist` 再写新文件,构建窗口内打开页面,`index.html` 与 hash 产物对不上;旧版里缺失的产物会穿透 `express.static` 落进 SPA catch-all 回 200 的 HTML,浏览器当 JS 执行失败,且 SW 会把它按 200 缓进 STATIC_CACHE(之后服务恢复了也好不了,必须 Unregister SW)。正确姿势:先停服务 → build → 启动 → 黑页标签 Unregister SW 后重载。服务端已加 `/assets/*` 等缺失 404(不再回 HTML)、SW 只缓存 content-type 对得上的资源。
- **服务活着时 npm install 换了 SDK 会新旧混装**:pi-ai 的 provider 按 api 懒加载,启动时只进内存一部分;依赖在服务运行时被原地替换后,之后首次用到的 provider 从盘上加载新文件,而 ./text.js 这类基础模块命中启动时缓存的旧命名空间,新文件 import 的新导出在旧命名空间里不存在,直接报 The requested module ./text.js does not provide an export named ...(实测:0.85.1 的 text.js 只有 contentText,0.87 起才有 getSystemMessageText;表现是别的模型好好的、某个通道的模型一用就挂,因为那个通道的 provider 恰好是更新后第一次加载)。正确姿势:先停服务 再 npm install 再启动;排查看 /api/health 的 piVersion(内存)与 piSdkCopies(磁盘)是否一致,不一致就是混装,重启即好。
- **改了 `protocol.ts` 后忘了在两端 dispatch/onmessage switch 加分支** → 前端收到未知消息类型被 switch 静默丢弃,表现为"没反应"。先跑 `npm run typecheck`。
- **toolResult 的 `details` 现在会下发浏览器**(`server/serialize.ts`,JSON ≤64KB 才带、超限**整丢**;随会话文件持久化,重放后还在):`present_files` 的卡片数据、`ask_user_question` 的答案、`todo_list` 都走它,前端不必为了一点元数据再打一次 HTTP。两个坑:**新增自定义工具时别把大块内容塞进 details**(bash 的 details 里带整份 output,小输出会因此翻倍进快照;工具作者要自己封顶,见 `present-files-tool.ts` 的摘录预算);**前端要有「拿不到 details 也能渲染」的兑底**(present_files 卡片就是「工具参数解析 + 可选 details 增强」,见 `web/src/present-items.ts`)。
- **新增可开关的 Agent 工具要动两处**:① `server/tool-manager.ts` 的 `AGENT_TOOL_CATALOG` 加一项(开关的唯一事实源;「其他」组同时给 `descKey`/`offHintKey` 文案 key,顺序即设置页渲染顺序;`todo_list` 例外固定 markers 分区)② `server/agent-service.ts` 的 customTools 注册(DSH 除外,它无 customTool 注册面)+ `tests/unit/tool-registration.test.ts` 的 FACTORY_TOOLS 给工厂登记产出(逼着当场决定入目录还是内核)。设置页「其他」组按目录循环渲染(`OTHER_AGENT_TOOLS`),不再手写行。历史教训:`skill` 与 `present_files` 都曾只有目录、设置里找不到开关;`claim_files` 更惨——连目录都没进。两道静态守卫:注册→目录(tool-registration,含 bash/read 永不入目录)+目录→设置行(settings-tool-rows),否则 CI 直接报红。工具名字符串只在 tool-manager 定义,工具模块 import+re-export(present-files/skill/conversation_read/claim_files/delegate_task 模式)。
- **nginx 子路径部署(页面在 /pi/ 下)插件/WS/API 加载失败** → 大概率是新增的请求路径没走 `appUrl()`(见关键约定),请求落在网站根绕过了 `location /pi/` 的剥离转发;排查时先看浏览器 Network 的请求带没带 `/pi` 前缀。
- **快照 60ms 节流**:调试时 `get_state` 可立即推一次(`cs.flushSnapshot()`)。
- **snapshot 发送背压**:`send()` 在序列化之前检查 `ws.bufferedAmount`,超过阈值时丢弃 snapshot(全量幂等且稍后必有更新);丢弃时安排 250ms 重试 timer。
- **`hello` 前/会话未就绪时的命令**:`server/index.ts` 的 `pending` 队列会缓存并在 attach 后重放。
- **clientId 每标签页独立**(issue #10):前端 `getClientId()` 存 sessionStorage(非 localStorage),同源多标签页是多个独立客户端。回归:`multi-tab-test.mjs`。关浏览器重开 = 新 clientId,旧 ClientSession 变残留:**无其他在线浏览器时 attach 自动认领**最近断开的有内容残留(只换 map 键、不搬 runtime;首帧推送前必须按新 id 重接 `wireClient`,否则把自己当“另一处”),有在线标签时不认领(跑着的照常走 elsewhere 只读 + 双写拦截)。回归:`tests/orphan-adopt-test.mjs` + `tests/unit/orphan-adopt.test.ts`。**手动过户**:右键 elsewhere 行 → `take_over_conversation` 把整段对话(含子代理后代、等答复问卷/页调用、看门狗剩余计时)搬到本页并切过去(搬 runtime 本体,单 writer 不变;id 撞车时搬入方改名;源 active 自动修好,双方通知;源页旧对话框经 question_retracted 同步收起)。elsewhere 口径 = 运行中 + 源会话仍持有的已结束空闲对话(`isStreaming:false` + `sessionFile`,结束后仍可过户查看;空白对话不入列)。**新标签/切项目不再自动恢复别处持有的会话**:最近文件被其他在线客户端持有(跑着或空闲)时空白落地 + 提示去向(之前只拦 running,空闲照样恢复出第二个 writer,两边轮流发送分叉历史)。回归:`tests/takeover-test.mjs` + `tests/idle-takeover-test.mjs`(结束后过户 + 空白落地)。**过户搬不动工具闭包**:`ask_user_question` / `browser_page` 是建 runtime 时把当时的 ClientSession + conversationId 闭包进去的,过户只搬对话 —— 所以这两条桥接在**调用瞬间**按 runtime 身份解析「现在谁持有它」(`ClientSession.bridgeTarget` + `findConversationHome`,锚点是稳定的 `created.session` 对象而不是会改名的 conv id);否则过户后新提问的 question_pending / page_request 会推给**过户前那台设备**(持有方收不到、老设备上那张问卷还不进快照 → 刷新即丢,pi 引擎问卷不超时 ⇒ run 永远挂住)。**跨页作答**:点 elsewhere 行的 `?` 经 `peek_elsewhere_question` 把问卷原文拉到本页弹框,`question_answer` 带 `owner` 由持有方 resolve(本页只展示/关闭,id 对方作用域、不进 answered 集合;问卷消失则明确通知)。别处列表靠运行签名驱动其他页重推(覆盖空闲行:流式→空闲、空闲行出现/消失都重推;问卷态已计入签名,挂起/解决不改变流式集合,不带它已打开的页看不到 `?`)。回归:`tests/remote-answer-test.mjs`。回归:`tests/takeover-test.mjs`。**左键点击「另一处」行 = 两段确认过户**(#290):可过户的行(有 `owner` + `convId`)本身就是一个 `<button>`,第一次点只进入确认态(行内文案变「确认过户到当前页面?」+ `.confirm` 高亮、title 同步、鼠标移开自动复位),第二次点才发 `take_over_conversation`;旧条目无 owner/convId 时仍是只读(`elsewhere-static`、`tabIndex=-1`)。回归:`tests/elsewhere-click-takeover-ui-test.mjs`(真 Chrome,不入 run-smoke)。**elsewhere 列表只列 live 会话**(#291):`listExternalRunning` 跳过非伪客户端且 `sinkCount()===0` 的断连残骸(伪客户端常驻一个 noop sink,不能按 sinkCount 判活),`detach` 里最后一个 sink 断开时 `pokeExternalRunning` 让其他页立刻重推;删除定时任务(`SchedulerTasks.remove()` → `onTaskRemoved` 钩子)回收 `scheduler:<taskId>` 伪客户端,不再永久占据别人的运行列表(伪客户端 sinkCount 恒为 1,所以 `releaseSchedulerClient` 不能拿 sinkCount 当守卫)。回归:`tests/elsewhere-lifecycle-test.mjs`(零 token,已入 run-smoke)。
- **`host.schedule` 的超长延迟会溢出成 1ms(死循环)**:宿主用 `setTimeout(next - now)` 引爆,而 Node 的 setTimeout 延迟超过 2^31-1ms(≈24.8 天)会被**静默截断成 1ms**(打一条 `TimeoutOverflowWarning`);旧版 `nextCronFire` 在「一年内找不到」时还会回一个 366 天后的哨兵值。两者相遇 = 「1ms 后再触发 → 插件回调写盘/广播 → 再排下一次」的死循环(`{monthly, dom:31}`、`0 9 1 1 *` 都是合法且常见的触发档位)。现在 `armDelay()` 把延迟切成 ≤6 小时的分片、醒来重算,`nextCronFire` 找不到回 `null`(后台面板显示「不再触发」)。插件侧也有一条硬规则:**别把远期 cron 交给 `host.schedule`**(notes 插件只挂一条 `* * * * *` 巡检,每条提醒自带 `nextDue` 落库)。回归:`tests/plugin-cron-overflow-test.mjs`(5 秒内 0 次触发才算过)。
- **socket 半开**:服务端每 2s 发送一次心跳并携带主机资源指标;客户端 30s 无消息主动断开重连(指数退避 1s→10s)。
- **工作区不能是家目录根(#295)**:`server install` 默认工作区已是 `~/pi-web-ui`(不存在即建;显式 `--cwd`/`PI_WEB_CWD` 不动);SDK 初始化期的同步目录扫描落在 `$HOME` 上会被 iCloud 占位符/外部卷坏挂载挂起(`scandir/open` 内核等待 → 事件循环假死 → `hello` 后无 `ready`、连 `server status` 控制通道一起超时)。握手已改成 **ready 先行**(`hello` 回来立刻发 `ready`,快照随后到;`attach` 超 8s 补一句可见提示),所以「`ready` 到达」只代表传输通了,不代表会话建好了 —— **测试里拿 `ready` 当「可发插件 RPC/可读插件路由」的信号是错的**(`plugin-proxy-test` 曾因此被 SPA catch-all 的 200 误导,判活必须按插件真实内容)。`attach` 链里用户路径的同步 `statSync` 已改异步,坏挂载只挡本连接。
- **预览与附件行号**:`countLines` 不算尾随换行;前端 `split("\n")` 后也要 pop 掉末尾空串。
- **Windows 老中文文件乱码**:预览/内联附件/行附件统一走 `decodeText`(严格 UTF-8 失败 → GBK → latin1)。
- **Windows 窗口最小化后收不到桌面通知**(Win11 + Edge/Chrome 实测,2026-09):最小化后 `document.hasFocus()` 仍为 `true`、`visibilityState` 仍为 `"visible"`,连 `blur`/`visibilitychange` 都不发 —— 只有原生窗口矩形会变(`screenX/screenY` 变成 -21334(Edge)/-32000(Chrome)、`outerWidth/Height` 塌成标题栏 108×20 / 160×28)。判定全在 `web/src/notify.ts`(`isCollapsedWindow` + `shouldSuppressNotify`,纯函数 + 单测);Windows 上还额外要求「最近 2 分钟内有页面交互」才肯吞掉通知(焦点/可见性本来就在骗人,宁可多提醒也不静默)。**另一个坑:通知不能带 `tag`** —— Windows 把同 tag 的新通知当成「替掉旧条目」而且静默(没横幅、没提示音),只要通知中心里还留着一条 pi-web-ui 通知,后面每条都会被无声替换(`showNotification` 仍然 resolve,页面上看不出问题;`renotify: true` 实测救不回来)。排查入口:顶栏声音下拉 → 通知块底部的「发送测试通知」按钮(`NotifyToggle.tsx` 的 `SHOW_NOTIFY_TEST_PANEL` 常量,默认关闭,排障时改成 `true`),它显示实际通道(sw/page)、浏览器是否真的持有这条通知(`getNotifications()` 计数)与判定依据(焦点/可见性/最小化/空闲)。
- **模型列表刷新 = 官方目录整表替换(非并集)**:内置服务商(opencode-go 等)的模型目录来自 pi.dev(`https://pi.dev/api/models/providers/<id>`),`server/patch-remote-catalog.ts` 在启动时幂等改写 SDK 的 `remote-catalog-provider.js`,使 `getModels` 在远程数据存在时**整表返回官方目录**(无内置旧模型残留、无“新增 N 个”合并);`listModels()` 先 `mr.refresh({ allowNetwork: true })` 与官方接口校验(SDK 4h 窗口内走 304)。注意:改 `node_modules` 的补丁在 `npm install`/SDK 升级后会失效,服务重启时自动重打;SDK 源码结构变化时自动跳过(回落 SDK 默认并集语义,不崩溃)。验证:`tests/scratch/verify-patch.mjs`。
- **PI_WEB_TOKEN 改口令后旧 cookie 卡死**(issue #71):有效 token 请求会刷新 `pi_web_token` cookie 为当前值,401 且带失效 cookie 时自动 Expire——用户改了口令后**一次正确的 `?token=` 进入即永久恢复,无需清缓存**;别再实现「仅在无 cookie 时才下发」的旧逻辑(那是卡死根因)。回归:`tests/token-auth-test.mjs`。
- **`pi_web_token` cookie 存的是 `encodeURIComponent` 后的值**(issue #261):cookie-value 只允许 ASCII,所以 `=` / `+` / 空格 / 非 ASCII 口令必须转义才能下发——**读取时一律先解码再和 `AUTH_TOKEN` 比**(`server/auth-cookie.ts` 的 `decodeCookieToken`,脏值解不开就原样返回)。反例(曾经的真 bug):拿 `encodeURIComponent(AUTH_TOKEN)` 去比,于是 base64 口令尾巴上的 `=` 被存成 `%3D`,带 cookie 的请求全 401——表现为「`?token=` 那一次 200,之后每个资源都 401」。手写/旧客户端的明文 cookie 也要继续接受(`requestTokens` 两种候选都试)。
- **CSS 变量引用必须先有定义**:`var(--ghost)` 引用一个全仓没有 `--ghost:` 声明的变量按规范是 guaranteed-invalid —— 整条声明在计算值阶段失效(`background` 直接没背景、`box-shadow` 连投影与聚焦光环一起丢),带 fallback 的则静默用硬编码值(浅色主题下必是深色块)。`--bg-elev1`(正确名 `--bg-elev`)全史未定义还被 10 处引用,goalbar 因此一直没填充。回归靠 `tests/unit/css-tokens.test.ts`(静态体检,CI 必跑);新增豁免(运行时注入的变量、刻意的中性兜底)要在该测试的豁免表里写上理由。
- **插件 UI 扩展点的 slot 必须两端同口径**:manifest `ui` 与运行时 `host.ui.register()` 走的是**同一套**别名映射 + 枚举校验(`UI_SLOT_ALIASES` / `UI_SLOTS`,见 `server/plugins.ts`),只改一边就会变成“注册了但界面上没有”—— 前端 `buildUiSlots` 遇到不认识的 slot 是**静默丢弃**,最难排。条目全局 id 是 `<pluginId>:<itemId>`、内置条目是 `host:<name>`;合并优先级固定为「宿主默认 < 插件贡献 < 插件 arrange < 用户偏好」,**用户偏好永远最后说话**。
- **新增「可隐藏 / 可排序」的宿主入口必须在渲染组件里接上 slot**:布局页的勾选框与 ↑↓ 只对**真按 slot 列表渲染**的东西生效。顶栏整条与底栏整条由 `TopBar.tsx` / `FooterBar.tsx` 里的 `hostNodes` 节点工厂 + `uiPrimary`/`bottombarItems` 顺序驱动(顶栏是**单一扁直流**:slot 顺序直排、align 只决定落两个 spacer 划出的哪一段,无任何按种类包裹的容器、无贴边例外;放不下/被隐藏的入口落进「⋯」溢出菜单:菜单型整块搬组件、动作类走 `dispatchHostOverflow`),消息工具条由 `Message.tsx` 跳 `hidden`,右栏 tab 由 `RightPanel.tsx` 按 `uiRightPanelTabs` 顺序渲染,输入框/文件预览/目标条/SCM/终端/左栏分区同理(各组件的 `*HostNodes` + `renderMergedToolbar`,见 `web/src/slot-toolbar.tsx`)。**加新入口忘了这一处 = 一个点了没反应的勾选框**(历史上就这么退化过)。另外:slot 数据没传时要退化成「按内置默认顺序全画」,别把整条顶栏/底栏清空。回归:`tests/ui-layout-ui-test.mjs`(真 Chrome,不入 run-smoke;含 #162 的 elementFromPoint 可见可点断言)。
- **顶栏平铺化(方案 A)的两条硬规则**:① 渲染层不得再加按种类分组的容器(`.brand` / `.view-switch` / `.topbar-desktop` 已全部删除)—— 跨类调序会把组撕成两截,而且手机端会退化成「一整组被 CSS 藏掉」;条目外观一律挂在条目自己身上(`.chip` / `.tb-tab` / `.plugin-topbar-item` / `.panel-toggle`)。②「放不下」与「被隐藏」必须走**同一个**「⋯」菜单、且由**实测宽度**决定(`web/src/topbar-fit.ts` 纯函数 + `TopBar` 的 ResizeObserver:宽度按 id 缓存,被丢进菜单的条目量不到宽度,缓存才能让它在窗口变宽时回来;宽度为 0 的条目(桌面端 CSS 藏起的 ☰/📁)不占位也不进菜单;测不到宽度时全保留)。手机端不再有第二套硬编码「⋯」面板,桌面与手机共用一份 slot 数据。单测:`tests/unit/topbar-fit.test.ts` + `tests/unit/topbar-panel-toggle.test.ts`(结构锁 + 实测溢出接线)。
- **往下展开的菜单不许挂在横滑/圆角裁剪容器里**(issue #162):顶栏「⋯」溢出菜单曾用 `position:absolute` 挂在 `.view-switch{overflow:hidden}`(桌面)/ `.topbar-flow{overflow-x:auto}`(窄屏;扁平化之前叫 `.topbar-actions`)里,往下展开的部分全被祖先裁掉 —— `z-index` 救不了 overflow 裁剪,`overflow-x:auto + overflow-y:visible` 写了也白写(浏览器把可见轴算成 auto)。解法与 `ContextMenu.tsx` 同路:portal 到 body + `position:fixed` + 按触发按钮实测锚定钳制。另:这类菜单的关闭回调若是内联箭头,effect 每 render 解绑/重绑全套 document 监听,离散按键可能落在空窗里丢键 —— 关闭走 ref,监听在菜单开着期间只装一次。
- **菜单/下拉面板类浮层严禁因页面滚动而关闭**:浮层(如顶栏「⋯」菜单、插件面板 `PluginMenu`、各类按钮下拉等)统一接入 `web/src/use-floating-panel.ts` 的 `useFloatingPanel` Hook。**严禁在 `scroll` 事件中关闭浮层**——`addEventListener("scroll", ..., true)` 处于捕获期,页面内任何子元素滚动(如 AI 流式生成触发消息流自动吸底、侧边栏滚动)都会触发事件,导致弹窗被秒关。正解是 `scroll`/`resize` 时只重算锚点坐标保持贴合(`measure`),只有点击外部(`mousedown`)或按 `Escape` 才关闭。
- **新对话必须自动聚焦输入框**:新建对话(点击顶栏/左栏/预设条的新对话按钮、或进入/切换到空白新会话)必须通过 `web/src/composer-bridge.ts` 的 `focusComposer()` 与 `ChatInput` 的空白会话自动聚焦机制聚焦输入框(触屏设备 `IS_TOUCH` 豁免以防软键盘遮挡);在非 `chat` 视图(如 terminal/scm)点击新对话必须同步切回 `chat` 视图,避免界面停在旧视图且无法打字。
- **待发附件跟会话走,不跟页面走**:输入框上方那排 chips(📎/🔗/💬/🖼/🌐)只活在 `App.tsx` 的 `attachments` state 里(不持久化),发送成功即清;**会话身份一变(新建对话 / 切对话 / 手动过户 / 切项目)也要清空** —— 正文草稿按 `sessionId` 存、切会话会清空再恢复该会话的草稿,附件不清就会出现「正文已被新对话清掉、chips 还挂着上一个对话的文件」的错位,且会随下一条消息一起发出去。判据用 `UiState.sessionId`(pi/DSH 双引擎都有;`conversationId` 重启即变,不能当会话身份),纯函数在 `web/src/composer-draft.ts` 的 `advanceComposerSession`(空 `sessionId` 的瞬时态不清,避免断线重连误清;不做按会话保存)。单测:`tests/unit/composer-draft.test.ts`。
- **`set_workspace_roots` 是按项目(cwd)存的**:同一客户端在不同项目下各有一份额外根(`client-state.json` 的 `workspaceRoots[cwd]`),切项目不会把 A 项目的根带到 B。安全含义:**额外根 = 工作区内** —— `isInsideWorkspace()` 算进去后,插件 `host.fs.*` 读那些目录不再需要单独授权(工作区外才要走确认框),用户加根前得知道这一点(右栏文案已提示)。
- **Playwright 脚本**:Chrome 路径由 `tests/lib/chrome.mjs` 逐平台探测(`PI_WEB_CHROME` 可覆盖),不再写死本机路径;脚本里取仓库根一律用 `fileURLToPath(new URL("..", import.meta.url))`——`URL.pathname` 在 Windows 上得到 `/E:/...`,`spawn` 会直接 ENOENT;服务端进程清理在 win32 走 `tests/lib/port-utils.mjs` 的 `freePort`(负数 PID 的进程组在 Windows 上不存在)。
- **DSH shipped 预设在 launcher boot 下不可直接 mount**:composition 的裸包名按 agent scope 的 baseUrl(= cordis.yml 目录)解析,官方靠 PluginPackages resolution 走安装锚点、pi-web-ui 没装那套。解法是 `server/dsh/preset-clones.ts` 的 file: 克隆(裸名→运行时树入口文件 URL,注意 `file:` 必须指到**文件**,目录 URL 恒判 broken;含子路径的包按 exports map 解析),roster 以 clone 目录为唯一 system 根并关 `includeShippedRoot`(同名早根优先,否则 shipped 的 broken 条目遮住 clone)。回归:`tests/unit/preset-clones.test.ts` + `tests/dsh-smoke-test.mjs` 的 2.5 节。
- **DSH 的 agent-scoped 事件 host 收不到**:`user-questions/request` 与 `agent/pre-step` 只在 agent scope 递送,host 注册的观察者永远看不见(且新版 `UserQuestionService` 已删 `registerProvider`,旧写法直接让整个运行时 boot 崩溃)。问卷 answerer 与技能目录过滤钩子必须在每次会话创建的 `setup()` 里挂到 agent scope(见 `goal-rpc.mjs` 的 `agentSetup`)。
- **`web/src/i18n.tsx` 的文案 value 只许是字符串字面量(含 `+` 续行),不许用变量/模板表达式/函数调用**:`npm run changelog:i18n`(`scripts/i18n-diff.mjs`)是手写解析器,只认引号字面量 + `+` 拼接(0.87.0 的 `filesHelp` 长文案曾用 `+` 续行把脚本搞崩过一次,修完才支持)。改完文案必跑一次该脚本验证;新增豁免先修解析器再合。
- **DSH 底栏统计(上下文占用 / 缓存命中 / 回复速率)吃的是「直播帧 + usage」事件面**:新版运行时不发持久 `assistant/chunk` 了(改成 agent scope 的 `agent/assistant-stream` 直播帧 → **host 侧同样收不到**,见上一条),usage 只在结束时随持久 `assistant/message.usage` 落一次。所以 wrapper(`goal-rpc.mjs`)构造器用 `{ global: true }` 订阅直播帧并转 `assistant.stream` 通知;服务端两条路共用 `applyStreamChunk`,`conv.liveChunks` 标记互斥(过渡期两边都在也不会喂两遍),纯映射在 `server/dsh/dsh-usage.ts`(单测 + `tests/dsh-stats-test.mjs` 假 runtime 回归,零 key / 零子进程)。**漏了这条路 = 没有 streamingMessage / 没有速率 / 底栏上下文永远是 `—`。**
---
_结构/流程变更时同步更新本文件及相关 `docs/` 文档。修改后运行 `/reload` 生效。_