AGENTS.md · git:20261004.e920062 · 2026-10-04 · sha256 d0fb447eb44c5227
AGENTS.md git:20261004.e920062A
Immutable. This exact content never changes and is served at /api/v1/blob/d0fb447eb44c5227.
# AGENTS.md — pi-web-ui 项目指南
> 给 AI 编码助手(pi / Claude Code / Cursor 等)看的高层指南,细节按主题在 `docs/`。
> 修改后在 pi 里跑 `/reload`。精简前全文备份:`AGENTS.md.bak`。
## 1. 项目是什么
pi-web-ui 是 pi 编码智能体(`@earendil-works/pi-coding-agent` SDK)的 Web 聊天界面:
浏览器对话、文件树、附件、内置终端(xterm.js + node-pty)、模型管理、声音提醒、中英文切换。
一条命令可跑,可 Docker / systemd / launchd / Windows 计划任务部署。
另有 **Electron 桌面壳**(`desktop/`,随机空闲口起同一 server,网页版零改动;见 `desktop/README.md`)。
- 仓库:`git@github.com:xing-shuyin/pi-web-ui.git`;npm 包 `pi-web-ui`
- Node **>= 22.19.0**;版本 `package.json` 与 `package-lock.json` 两处同步
## 2. 技术栈
| 层 | 技术 |
| ------ | ---------------------------------------------------------------------- |
| 后端 | Node + Express(静态 + `/api/health`)+ `ws`(`/ws`) |
| 前端 | 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. 目录结构
完整注释版目录树见 `docs/directory-reference.md`(扁平速览)。
顶层:`server/` 后端(ESM,编译到 `dist/server/`)· `web/` 前端(编译到 `web/dist/`,gitignore 但进 npm 包)·
`bin/pi-web-ui.mjs`(CLI)· `desktop/`(Electron 壳)· `deploy/`(launchd/systemd/Windows 任务示例)·
`themes/`(完整样式表主题)· `plugins/`(官方插件,各自 README;`catalog.json` = 市场内置列表,加插件在此加一条 + PR;`page-picker/extension` 是浏览器扩展不是插件)·
`extensions/webui.ts`(pi 扩展 `/webui`)· `dev/`(本地辅助,不进包)· `docs/`(见 §4 表)· `tests/`(见下)。
server 关键文件(★=单源/入口,必读;其余按名读源码,不在此逐条注释):
`protocol.ts`★(wire 协议唯一事实源)· `index.ts`(express + `/ws` 分发 + 心跳 + `pending` 重放队列)·
`agent-service.ts`(ClientSession/多对话并发/工具注册 customTools)· `serialize.ts`(SDK→UiMessage,`details` ≤64KB 下发)·
`tool-manager.ts`★(`AGENT_TOOL_CATALOG`,工具开关唯一事实源)· `tool-approval.ts` + `approval-rules.ts`(审批门禁 + 规则库)· `tool-prompts.ts`(bash 工具提示词唯一事实源:description/snippet/guidelines/参数 schema 三路径共用)·
`client-state.ts`(`client-state.json` 持久化:locale/workspaceRoots/softCap…)· `composer-drafts.ts`(草稿中心)·
`plugins.ts`(`UI_SLOT_ALIASES`/`UI_SLOTS` 别名+枚举)· `plugin-catalog.ts`/`plugin-catalog-sync.ts`/`plugin-installer.ts`(市场目录同步+后台安装)·
`plugin-grants.ts`(目录授权)· `plugin-project.ts`(host.project.create)·
`subagents.ts` + `subagent-templates.ts`(第一方子代理+模板库)·
`attachments.ts`(只发路径引用,不注入内容)· `files-service.ts` + `text-sniff.ts`(`decodeText`/`countLines`)·
`vision-bridge.ts`(纯文本模型看图转写)· `present-files-tool.ts`(只读探测+摘录预算)·
`terminals.ts`(PTY 管理;`terminalBash` 开关分流)· `scm.ts`(只读 git,execFile 直跑)·
`model-admin.ts`(models.json+多密钥)· `patch-remote-catalog.ts`(模型目录整表替换补丁)·
`soft-cap.ts`(压缩软上限纯函数)· `i18n.ts`(服务端语言:`resolveServerLang`/`pick`/`bilingual`/`getServerBlock`)·
`control-socket.ts`(本地 status/quiesce)· `launch-origin.ts`(托管探测)· `webui-context.ts`(扩展 UI 桥)·
`settings-service.ts`/`goal-service.ts`/`slash-commands.ts`/`themes.ts`/`uploads.ts`/`bg-servers.ts`/`queue-utils.ts`/`process-utils.ts`/`tool-info.ts`/`conversation-read-tool.ts`/`compact-context-tool.ts`/`edit-soft-tool.ts`/`read-tool.ts`/`ensure-bash.ts`/`patch-node-pty.ts`。
`server/dsh/`:`preset-clones.ts`(file: 克隆)+ `runtime/{launcher,goal-rpc,custom-prompt}.mjs` + `dsh-usage.ts`(统计映射)。
web/src 关键(★=先读这三个):`use-chat.ts`★(WS+reducer+终端 bridge)· `app-globals.ts`★(模块级 store + `appSend`;快照数据禁入)·
`types.ts`(`export type * from "../../server/protocol"` shim,由 `scripts/check-protocol-sync.mjs` 守护)·
`ui-slots.ts`★(宿主 UI 扩展点:四层合并+22 slot)· `slot-toolbar.tsx`(各组件 `*HostNodes`+`renderMergedToolbar`)·
`base-url.ts`(`appUrl()`,见 §9)· `i18n.tsx`(文案只许字符串字面量,见 §9)· `styles.css`(全部样式)·
`notify.ts`(`isCollapsedWindow`/`shouldSuppressNotify`)· `composer-bridge.ts` + `composer-draft.ts`(`advanceComposerSession`/`focusComposer`)·
`topbar-fit.ts`(实测宽度溢出)· `use-floating-panel.ts`(浮层统一 Hook)· `pending-question.ts`(问卷恢复)·
`present-items.ts`/`present-settings.ts`/`file-preview-bridge.ts`(present_files 卡片)· `tool-info-state.ts`/`tool-schema.ts`· `plugin-host.ts`(宿主 API 时序)。
components 按文件名自解释,只记特殊机制:`ContextMenu`/`ToolInfoDialog`(portal 到 body + fixed,同因 overflow 裁剪)·
`TopBar`/`FooterBar`(单一扁平流,slot 顺序直排+`hostNodes`)· `SlotTabs`/`PluginPage`(切走即 cleanup 不挂载)·其余读源码。
tests:`run-smoke.mjs`(零 token 冒烟聚合,ALL 列表)· `unit/`(vitest 纯函数单测)· `*-test.mjs`(手写 Playwright/WS;**需 Chrome 的不入 smoke**,手动跑,缺 Chrome 自动 SKIP)· `scratch/`(一次性,gitignore)。
CI:`ci.yml`(协议同步→typecheck→build→vitest→冒烟)· `release-notes.yml` · `desktop-release.yml`。
## 4. 核心架构(每主题一句话)
`docs/` 索引(找详细文档先看这里,均为 `docs/<名>.md`):**architecture-core** 快照驱动/协议单源/安全边界/主题/多对话并发 · **architecture-attachments** 附件/图片/视觉桥/上传预览下载 · **architecture-terminal** 终端 PTY/SCM/活力检测/bash 接管 · **architecture-plugins** 插件形态/协议/宿主扩展点/MCP 桥/多根工作区 · **architecture-system-prompt** 系统提示词组装链路与 override 钩子 · **tool-context-budget** 工具上下文预算(文案精简 / 逐工具覆盖 / **延迟加载** + 前缀缓存硬约束)· **dsh-engine** DSH 预设/问卷/底栏统计/工具桥 · **goal-conversation-design** 目标模式 2.0(把目标审查变成对话:执行对话干活 + 当前对话当审查者;**已实施且只剩这一条路径** —— v1 的隐藏隔离审查会话 + 自治标记路径已删除,轮次/熔断/代次全在服务端,见该文档 §4/§11)· **development** 开发工作流/CI/编码约定/测试规范 · **release** 发布流程 · **deployment** 部署 · **env-vars** 环境变量全表 · **preset-sharing** 预设导入/导出/社区共享仓库(交换格式 / 白名单净化 / 一键分享 / 浏览列表)· **antigravity-proxy** 反代接入 · **directory-reference** 完整注释版目录树
| 主题 | 文档 | 一句话 |
| --------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 快照驱动 | `docs/architecture-core.md` | 服务端唯一事实源,60ms 节流;`snapshot_delta` 增量 + `message_delta` 实时通道;`send()` 背压超阈丢 snapshot(幂等,250ms 重试);`get_state`/`flushSnapshot` 立即推一次 |
| 协议单源 | 同上 | 只改 `server/protocol.ts`,两端 switch 各加分支;`web/src/types.ts` 是 shim |
| 全局运行态 | 同上 | 整树共享放 `app-globals.ts`(`useAppField` 单字段订阅,组件不收 `send` prop 用 `appSend`);快照流数据禁入 store |
| 安全边界 | 同上 | 默认 loopback;WS Origin/Host 校验;quiesce 准入;provider headers 不下发浏览器 |
| 主题 | 同上 | styles.css 是共享基线;主题=完整样式表可覆盖一切;终端跟随主题 |
| 多对话并发 | 同上 | 每对话独立 runtime,上限 8/项目(子代理不计);运行列表口径 listed ∪ 有内容的当前对话;clientId 存 sessionStorage,每标签页独立 |
| 附件/预览 | `docs/architecture-attachments.md` | 只给路径引用(`reference`/`lines`,内容不注入);预览 512KB 上限+嗅探+GBK 回退;媒体走 HTTP Range;下载绕 Safe Browsing |
| 终端/SCM | `docs/architecture-terminal.md` | 每 Conversation 一个 TerminalManager;`terminalBash` 开关分流(`persist` 决定一次性/ai-bash 持久);SCM 只读走 execFile,写操作走可见终端 |
| 工具开关/read 目录 | `docs/architecture-core.md` | `AGENT_TOOL_CATALOG` 唯一事实源;`read` 覆盖目录走 ls 口径(`readDirEnabled` 是行为开关,不入目录;仅 pi 引擎);`read`/`write`/`edit` 三处覆盖与第三方扩展同名工具的共存(基底 = 扩展实现优先)见 `server/tool-overrides.ts` |
| 审批 | `server/tool-approval.ts` | 规则库 `<dataDir>/approval-rules.json`(ask/deny/allow);三档放行:全局关→本对话全部允许→允许同类;记忆只在内存,随过户搬 |
| 问卷/草稿进快照 | `docs/architecture-core.md` | `UiState.pendingQuestion`(刷新恢复)+ `UiState.draft`(只跟全量快照,`draft_update` 自带 sessionId) |
| 临时对话 | `server/agent-service.ts` | `new_chat {ephemeral:true}`→内存不落盘;`UiState.isEphemeral` 恒存在于 light state;`persist_conversation` 一键转正(id 不变) |
| 插件/UI 扩展点 | `docs/architecture-plugins.md` | `<dataDir>/plugins/<id>/`;manifest `ui` 与 `host.ui.register()` 同一套别名+枚举;合并「宿主<插件<arrange<用户偏好」;插件不碰 DOM;`PLUGIN_API_VERSION=2` |
| 多根工作区 | 同上 | `set_workspace_roots` 按项目 cwd 存(上限 8);**额外根=工作区内**,插件读免授权;AI 只在主 cwd 干活 |
| 子代理模板 | `server/subagent-templates.ts` | 全局共享;append/replace + 白名单 + 可选模型/强度(空=跟随);停用对 AI 不可见 |
| DSH | `docs/dsh-engine.md` | 四预设 file: 克隆 mount;问卷/技能钩子挂 agent scope(host 收不到 scoped 事件);底栏统计吃直播帧+usage(`dsh-usage.ts`) |
| 压缩软上限 | `server/soft-cap.ts` | C→`reserveTokens=W−C` live 注入;关/非法回填 16384;pi 引擎独有 |
| 看门狗 | `docs/architecture-core.md` | 20 分钟 abort 会话(不碰后台服务);`ask_user_question` 豁免 |
| present_files | `server/present-files-tool.ts` | 只读探测+摘录预算走 `details`;前端无 details 也能渲染(参数解析+合并);远端本地打开报不支持 |
| browser_page/对话引用 | `docs/architecture-core.md` | 闸门在扩展侧;对话引用只发 `<conversation-ref>` aside,模型经 `conversation_read` 按需取 |
| 计划模式 | `server/plan-mode.ts` | **目标条展开行**的 `host:goal-plan` —— **一次性动作按钮,不是开关**:点一下 = 开服务端只规划闸门 + 把目标输入直接当消息发出(`set_plan_mode{true}` 紧跟 `prompt`,同 WS 按序 → 本轮在闸门内跑);空输入则只开闸门。与提炼/发送同列同口径(「点一下直接发」),`goalModeEnabled=false` 时随目标条一起隐藏。**退出计划的唯一出口 = 计划看板里的「开始实施」**(PlanBoard:`set_plan_mode{false}` + 发实施请求);开启时通过 `applyToolGating` 动态将写类与旁路工具从活跃集剔除(模型视野内无写工具,物理杜绝倾倒代码发起写调用);`withPlanModeGate` 硬闸门挂 customTools 整列 + write/edit 覆盖 + 终端 `checkSafety`,非只读 bash 与越权写调用显式抛错拦截(isError 为 true);提示词追加 `PLAN_MODE_SYSTEM_PROMPT`(切换时 reload,**正文可在设置页「提示词」区改**:append/replace + 自定义,语义同视觉桥/AI 提交信息,不进预设;**禁代码倾倒**——不贴整文件/完整函数,代码只留 ~20 行短片段;**小需求跳过 `plan_update` 走短计划**);产物走 `plan_update` 看板;DSH 不支持。回归 `plan-mode-test` |
| 审查者模式 | `server/delegate-mode.ts` | **自动委派(默认关)**:会话级开关(`set_delegate_mode`,设置页「审查」区 + 底栏 `host:status-delegate` 标识)→ 开启后本对话**只审阅不施工**:用户每条 prompt 由服务端转给一个**常驻落盘执行对话**(`dispatchToDelegate`,首轮 prompt 交给 `spawnSubagentConversation` 起步、之后 `sendUserMessage` 追加),主对话这一轮不跑模型;`withDelegationGate` 硬闸门拒写类/非常规 bash/**派发类**(派活是服务端的活)+ `DELEGATION_SYSTEM_PROMPT` 段(切换时 reload)。**计划模式优先**(同开不派活,否则与 plan 拒 spawn 死锁);DSH 不支持(明确 notice,不静默)。执行对话跑完只通知(不自动验收)。布尔随转录落盘(`delegate/mode`),执行对话 id 只在本进程内有效。回归 `delegate-mode-test` |
## 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 + build:server
npm start # 跑 dist/server/index.js(生产)
npm test # vitest
npm run test:smoke # 零 token 冒烟
```
约定:缩进 Tab;样式全在 `styles.css`;协议消息只改 `protocol.ts`(§4);服务端 URL 一律 `appUrl()` 包一层(§9)。
i18n:前端 `useT()`,核心 `zh`/`en`(新 key 两处都加,`tests/unit/locales.test.ts` 锁对齐;语言包 `locales/*.json` 缺 key 回落英文);守卫 `tests/unit/i18n-dead-keys.test.ts` 拦「加了没人引用的 key」(模板拼接的 `thinking.*` / `promptTok_*` 在白名单里);
服务端 `pick(lang,zh,en,key?)`(key 全局唯一 `<模块>.<slug>`;多行 `getServerBlock`);**工具定义提示词(description/promptSnippet/promptGuidelines)纯英文精简**,守卫 `tests/unit/tool-prompt-hygiene.test.ts`;notice 推 UI 用 `text`+`textEn` 双字段。
测试:端口 ≥8900 隔离;data-dir `mkdtempSync` 隔离;精确清理自己进程;**禁 `pkill -f`**。
Playwright:Chrome 路径走 `tests/lib/chrome.mjs`(`PI_WEB_CHROME` 可覆盖);仓库根用 `fileURLToPath(new URL("..", import.meta.url))`(Windows 下 `URL.pathname` 会 ENOENT);win32 清理走 `tests/lib/port-utils.mjs` 的 `freePort`。
## 6. 发布流程
> 详见 `docs/release.md`
```bash
npm run typecheck && npm run build
npm run changelog:i18n # 文案有增减必跑
git add -A && git commit -m "feat(xxx): 描述" # 不带 Co-authored-by
git push origin main
git tag vX.Y.Z && git push origin vX.Y.Z # tag 带 v,与 npm 版本一致;Action 自动建 Release+桌面包
npm publish
```
版本须高于 registry;升级后 `pi-web-ui server restart`;发布前检查示例文件不泄密;Release 说明预览:`node scripts/release-notes.mjs X.Y.Z --base v<上版>`。
## 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` | 数据目录 |
| `PI_WEB_SDK` | `global` | #321 起默认跟随:机器上有**更新**的 pi 副本(祖先链,如全局 pi CLI)就用它(多份取最高),没有或更旧回落自带副本;`bundled`/off/0/false/no = 强制自带(可复现,报 bug 用)。升级全局 pi 后**重启服务**即生效;更新面板会亮「运行中 vs 机器上」差距并提供「安装全局引擎」入口 |
| `PI_WEB_TOKEN` | 空 | 共享口令鉴权 |
| `PI_WEB_PLUGIN_CATALOG_URL` | 官方清单 | 市场目录来源;空/`off`/`0`/`false`/`no` 关闭;`PI_WEB_PLUGIN_CATALOG_INSTALL=1` 才开机自动安装 |
| `PI_WEB_TOOL_TIMEOUT_MS` | 20 分钟 | 看门狗超时(`ask_user_question` 豁免) |
| `PI_WEB_TOOL_LAZY_LOADING` | `1`(开) | 工具延迟加载的**部署级默认**(设置页开关优先):`0`/`false`/`off` = 全部工具直接活跃(见 `docs/tool-context-budget.md`) |
| `PI_WEB_PRESET_REPO` | `xing-shuyin/pi-web-ui-presets` | 设置预设的社区共享仓库(`owner/name`);`off`/`0`/`false`/`no` = 关闭一键分享(导入/导出仍可用),见 `docs/preset-sharing.md` |
| `PI_WEB_PRESET_CATALOG_URL` | 仓库 `index.json` 的 raw 地址 | 「浏览分享」的列表来源;空串或 `off` = 关闭浏览;5 分钟缓存 + 刷新绕过,失败保留上次列表 |
| `PI_WEB_PRESET_GH` | `gh` | 一键分享调用的 GitHub CLI 路径(不可用时先试令牌 API,再回落「复制 JSON + 打开预填建 Issue 页」) |
| `PI_WEB_PRESET_TOKEN` | 空 | 无 gh 时直连 GitHub API 的令牌(`PI_WEB_PRESET_TOKEN` > `GH_TOKEN` > `GITHUB_TOKEN`):有令牌就不需要 gh;不设也能用(预填页点一下 Submit) |
## 8. 部署
> 详见 `docs/deployment.md`
- 前台:`pi-web-ui --port 9000 --cwd /path`
- 自启:`pi-web-ui server install`(macOS→launchd / Linux→systemd / Windows→登录 Run 键,免管理员)
- Docker:`docker compose up -d`
## 9. 常见坑(现行有效禁令清单)
- **`details` ≤64KB 超限整丢**:`serialize.ts` 下发+随会话持久;新工具别塞大块内容(自封顶,见 `present-files-tool.ts` 摘录预算);前端渲染不许依赖 details(参数解析+可选合并,见 `present-items.ts`)。
- **工具提示词三处职责分离(单一口径 + 守卫)**:模型同一轮里能同时看到三处工具信息 —— tool schema 的 `description`(写「做什么 + 副作用/边界」,不写「何时用」)、系统提示词 `Available tools` 列表里的 `promptSnippet`(只写触发条件;列表已渲染成 `- name: snippet`,所以不写工具名前缀;≤80c)、`Guidelines` 段里的 `promptGuidelines`(何时用/顺序/禁止/跨工具路由)。**同一条信息只说一遍**:snippet 与 guideline 都不复述 description,参数说明不复述主描述;同一个 schema 块被多个工具用(SSH 凭据、连接/库参数、坐标参数)要抽共享常量 —— 所有活动工具的 schema 同时在上下文里,写 N 份就是花 N 份 token。主描述 ≤600c(`patch-tool` 这类整份 op 语法表豁免)。守卫 `tests/unit/tool-prompt-hygiene.test.ts`(CI 必跑):纯英文、长度上限、snippet 工具名前缀、snippet/guideline 复述检测、同文件同义重复检测。**`edit` 的模型可见文案也归本项目单源**:SDK 自带的 description 与 guidelines 逐条复述同一批规则,无扩展覆盖时(`fallback`)由 `agent-service.ts` 的 `EDIT_DESCRIPTION` / `EDIT_GUIDELINES` 顶上(只换文案,参数 schema / 执行体 / 渲染仍用 SDK);有扩展 edit 时(`composeWith`)它的文案一字不动。
- **逐工具文案可编辑(设置→工具区「编辑文案」)**:上面那三处(description/snippet/guidelines)都可被用户逐工具覆盖,存 `ClientSettings.toolPromptOverrides`(不进预设,纯运行偏好),留空 = 用出厂默认。归一化/打补丁的唯一事实源是 `server/tool-prompt-overrides.ts`:`_toolDefinitions` 永为出厂值(复原基准),`applyToolPromptOverrides` 在 `applyToolGating()` 链尾给 `_toolRegistry` 的 AgentTool 打 description 补丁 + 重置 `_toolPromptSnippets`/`_toolPromptGuidelines`(`_refreshToolRegistry()` 会重建注册表,所以每次门控后都要重放)——注册表是 `[...扩展, ...customTools]` 合并后的全集,所以核心内置/本项目工具/插件/MCP 一网打尽;SDK 改私有字段名则返回 null 降级。系统提示词那一侧(compose 循环 + `getToolInfo`/`getToolPrompt`)按同一张表取生效文案。**新增可编辑维度要同时改三处**:`server/tool-prompt-overrides.ts`(归一化/打补丁)、`protocol.ts` 的 `UiToolPromptOverride`(wire 面)、设置页编辑器(`ToolPromptEditor`)。回归 `tests/unit/tool-prompt-overrides.test.ts` + `tests/tool-prompt-test.mjs`(后者用 mock LLM 断言真正发给模型的 tools[].description 与系统提示词)。DSH 无 pi 工具注册面,设置页不显示入口、服务端回 `unsupported`。
- **工具延迟加载(设置→工具区「工具按需加载」,默认开)**:只有核心工具(`bash`/`read`/`edit`/`write`)+ 常驻脚手架 `load_tools` 的 schema 进上下文,其余工具在系统提示词里只有「名字 + 一行摘要」目录,模型调 `load_tools` 后才把参数 schema 追加进来(`server/load-tools-tool.ts`)。门控不另开路径:未加载 = 临时加进禁用名单(`lazyLoadingDisabledTools()`)走 `applyAgentToolsGating`,加载只是把名字拿出来;`load_tools` 由 `forceActive` 保证非 standard 预设下仍活跃,且**不入目录**(不能单独关掉,否则机制断了);已加载集合按 **session 对象**存在 `WeakMap`(过户跟着走,转录恢复时用当时的活跃集做种子)。**硬约束 —— 不得打坏前缀缓存**(provider prompt cache 按前缀命中):① 系统提示词的 `{{tools}}` 永远列**完整目录且不过滤已加载**(`promptToolCatalog()`),文字与已加载集合无关;② Guidelines 段只取基线(核心 + `load_tools`),被加载工具的要点随 `load_tools` **回执文字**进对话(追加);③ tools 数组只允许 `toolsAdded` 增量(无 `toolsRemoved`/redefinition),供应商按初始声明锚定流式注入。回归 `tests/lazy-tools-test.mjs`(零 token)逐字断言「加载前后 system 文本相同」+「第二次 tools 以第一次为前缀」;单测 `tests/unit/tool-manager.test.ts` 的 `lazyLoadingDisabledTools` / `forceActive`。细节见 `docs/tool-context-budget.md`。DSH 不适用(设置行隐藏)。
- **一个工具多条执行路径 → 提示词必须单源**:bash 有原生/终端/分流三条路径,提示词(description/snippet/guidelines/参数 schema)统一在 `server/tool-prompts.ts`,`makeAdaptiveBashTool` 只覆盖 `execute`。被覆盖的那份文案**永远不发送却仍会被继续维护**(历史上终端版整段写死、"prefer head/tail over piping" 只写在它里面),是最容易积累的腐化点。
- **新增可开关工具动三处**:`tool-manager.ts` 的 `AGENT_TOOL_CATALOG`(唯一事实源;工具名字符串只在此定义,模块 import+re-export)+ `agent-service.ts` 的 customTools 注册(DSH 无注册面除外)+ `tests/unit/tool-registration.test.ts` 的 FACTORY_TOOLS。**对 SDK 内置工具的覆盖(read/write/edit)不进创建时的 customTools** —— 那会静默顶掉第三方扩展注册的同名工具,要走 `server/tool-overrides.ts`(基底 = 扩展实现优先,见 `docs/architecture-core.md`「覆盖与第三方扩展同名工具共存」)。两道 CI 守卫:注册→目录、目录→设置行(`OTHER_AGENT_TOOLS` 循环渲染,不手写行;`todo_list` 固定 markers 分区;核心内置工具 bash/read/edit/write 不入目录,走设置页「核心工具」区单独开关(`CORE_BUILTIN_TOOL_NAMES`,禁用名单同样接受))。
- **服务端 URL 必须 `appUrl()` 包一层**(`/ws`、`/api/*`、`/plugins/*`、`/themes/*`),否则 nginx 子路径部署 404;排查先看请求带没带应用根前缀。
- **elsewhere/过户**:`take_over_conversation` 搬 runtime 本体(单 writer);`ask_user_question`/`browser_page` 在调用瞬间按 runtime 身份解析持有方(`bridgeTarget`+`findConversationHome`,锚点是 `created.session` 对象不是 conv id);跨页作答 `question_answer` 带 `owner`;「另一处」行左键两段确认;elsewhere 只列 live 会话。回归:`takeover-test`/`idle-takeover-test`/`remote-answer-test`/`elsewhere-lifecycle-test`。
- **远期 cron 别交给 `host.schedule`**:Node 超 2^31-1ms 的 setTimeout 截断成 1ms → 死循环;远期用 `armDelay` 分片(≤6h)+ `nextCronFire` 回 null(「不再触发」);notes 式需求挂一条 `* * * * *` 巡检、自带 `nextDue` 落库。回归:`plugin-cron-overflow-test.mjs`。
- **工作区禁家目录根**:`server install` 默认 `~/pi-web-ui`;`$HOME` 的同步扫描会被坏挂载挂起致事件循环假死。`ready` 只代表传输通,不代表会话就绪——测试判活按插件真实内容,不拿 `ready` 当信号;`attach` 链用户路径禁同步 `statSync`。
- **文本解码/行号**:预览·附件统一 `decodeText`(UTF-8→GBK→latin1);`countLines` 不算尾随换行,前端 `split("\n")` 后 pop 末尾空串。
- **Windows 最小化通知**:`hasFocus`/`visibilityState` 在最小化时照常撒谎,只看窗口矩形(`isCollapsedWindow`,`notify.ts`);Windows 吞通知还要求近 2 分钟有页面交互;**通知禁带 `tag`**(同 tag 静默替换,`renotify` 救不回)。排查:`NotifyToggle.tsx` 的 `SHOW_NOTIFY_TEST_PANEL` 改 true。
- **模型目录=官方整表替换**:`patch-remote-catalog.ts` 启动幂等改写 SDK(无旧残留、无"新增 N 个");`npm install`/SDK 升级后重启自动重打,结构变化自动跳过。验证:`tests/scratch/verify-patch.mjs`。
- **token cookie**:`pi_web_token` 存 `encodeURIComponent` 后的值,**读时先 `decodeCookieToken` 再比**(`server/auth-cookie.ts`);改口令后一次正确的 `?token=` 即永久恢复(有效请求刷新 cookie,401+失效 cookie 自动 Expire)。回归:`token-auth-test.mjs`。
- **CSS 变量先定义后引用**:未定义的 `var(--x)` 整条声明失效;守卫 `tests/unit/css-tokens.test.ts`(CI 必跑),新增豁免写理由。
- **长英文/长路径不许溢容器**(手机端高频):`.messages` 是 `overflow-y:auto`,未声明的 `overflow-x` 计算值即 `auto` —— 任何子元素横向溢出都会变成整条消息区的横向滚动条。三条同源病根:①`white-space: pre-wrap` 只在空白处折行,挡不住无空格长 token(长单词/绝对路径/URL/model id);②行级 flex 子项 `min-width:auto` 不肯缩,一个长单词顶宽整张卡片;③宽表格/长公式要自己收着。统一口径:**行容器**(装长文本的 flex 行/列)先钉 `min-width:0` + `max-width:100%`;**能断行的**用 `min-width:0` + `overflow-wrap:anywhere` + `word-break:break-word`(**基线在 `.md`**,p/li/td/a/内联 code 靠继承全覆盖);**单行元素**用 `overflow:hidden` + `text-overflow:ellipsis`(overflow 非 visible 时 `min-width:auto` 自动解析为 0);代码块/终端输出走 `pre` + `overflow-x:auto`。高度封顶的列表(如 `.plan-board-steps`)必须 `overflow-y:auto` + `overscroll-behavior:contain`,否则条目一多整页连输入框/顶栏/状态栏一起滚。守卫 `tests/unit/text-wrap.test.ts`(静态扫 styles.css,CI 必跑;清单分「可断行容器 / 单行文本 / 行容器」三档),新增「渲染模型文本的容器或行」要进它的清单 —— 目标条、工具调用卡头、折叠徽标都已登记(窄屏实报过:目标条一行长文案把消息区顶出横向滚动条)。
- **消息级按钮只有两个落点:卡头=纯复制键,`.msg-actions` 行=正文/用户消息**(口径简化过,别再加第三处):① **所有卡头只有自己的复制键** —— 思考 / 工具 / 附件 / 技能 / 压缩摘要共用的 `.chead` 行右端就是 `.chead-copy`(复制思考 / 复制参数),**不挂任何消息级按钮**(历史包袱 `headExtra` prop + `.chead-actions` 容器 + 对应 CSS 已全删,见 `ThinkingBlock` / `ToolCallBlock`;此前「谁恰好是第一个 head 谁挂整排按钮」导致同一条回复里两张 bash 卡右端按钮数不一样,窄屏实报);② **`.msg-actions` 行只给「有正文的助手消息」和「用户消息」**(`Message.tsx` 的 `showMessageActions = !editing && (role === "user" || canCopyWhole)`)—— 纯工具调用 / 纯思考 / 附件卡 / 插件消息没有正文,`canCopyWhole`(复制三件套要正文)与 `canSpeak` 都为假,那一行只剩「派生分支 / 回滚」这类对卡片无意义的按钮,复制已在卡头。**助手消息把这一行锚在最后一个正文块之后**(`anchorIndex` 扫 `asText` 找末个 text 块插在它后面,窄屏实报「先说话、再发工具」时按钮被工具卡隔开、看着像属于工具卡),无正文块才落消息末尾;**用户消息不锚**(`.msg-user .msg-body` 是气泡,插进去会坐进气泡里)。用户那一行是「直接重问 / 编辑重问 / 派生分支 / 回滚 / 复制三件套」。**右键菜单不受影响**:`contextmenu.toolcall`(卡头右键)+ `contextmenu.message`(消息右键)保持全功能,是这两条之外的功能入口。
- **卡头(`.chead`)右端的复制键要对齐**(思考 / 工具 / 附件 / 技能 / 压缩摘要共用一套):触屏 `hover:none` 下 `.chead > .chead-copy` 在流内常驻并钉 `flex:none`(右缘对齐基准),卡头成了一行「左:图标+标题+参数提示,右:复制键」。写 `.toolcall-path` 等提示文本时**必须可缩**(`min-width:0` + ellipsis,不许 `flex-shrink:0`):不让步 → 整行顶宽 → 复制键被顶出卡头右缘(`.toolcall overflow:hidden` 裁掉、点不到),且每行按提示长度各自右移,右端**列不齐**(窄屏实报)。配套口径:主体 `.chead-title.toolcall-name` 写 `flex:none`(选择器要 `.chead-title.toolcall-name` 提权重才压得住后面那条 `.chead-title`),提示文本一律 `flex: 0 1 auto` + `min-width:0`(缺口全由它们吸收),复制键钉 `flex:none`;≤640px 额外把**路径少显示点**(`max-width: min(20ch, 32%)`,命令预览 `min(28ch, 44%)`)并把 `.chead` 的 `gap` 收到 4px。守卫:同一个 `text-wrap.test.ts` 的「卡片头右端按钮列」分组(`.chead` 行容器 / 四个 hint 可缩 / `.chead > .chead-copy` 的 `flex:none` / 工具名 `flex:none` / 思考标题可缩)。⚠️ 别用 `flex-wrap:wrap` 兜底:复制键是最后一个子项,换行会把它单独甩到第二行左端。
- **UI 扩展点三规则**:① slot 两端同口径(manifest `ui` 与 `host.ui.register()` 同一别名+枚举;前端不认识静默丢弃);② 新可隐藏/可排序入口必须在渲染组件接 slot(`hostNodes`+顺序驱动,无数据时退化全画;顶栏单一扁平流,禁按种类分组容器;溢出与隐藏走同一「⋯」菜单、实测宽度决定,`topbar-fit.ts`);③ 往下展开的菜单 portal 到 body + fixed(z-index 救不了 overflow 裁剪),关闭走 ref;浮层统一 `useFloatingPanel`,**禁在 scroll 里关**(只重算锚点;mousedown/Escape 才关);**禁把 slot 容器做成 `position:fixed` 贴边浮层**(会压住左右面板里的按钮)——`sidebar.left/right` 的贴边停靠栏(`SideDock`)是 `.layout` 里的 flex 子项,由它给面板/主区让出宽度,无图标时整条不渲染(`return null`,不占宽);想要旧行为由设置项 `uiLayout.sideDockFloat` 开关(悬浮模式才加 `.side-dock-float` 回落到 `fixed`,**且折叠按钮只在这个模式下出现** —— 流内停靠栏只占一条窄边,收起省不下宽度);停靠栏里的 `align` 是**竖轴**(start=靠上 / center=居中 / end=靠下,三段各一颗内容高的贴边药丸,配置入口是布局页那个对齐下拉)。回归:`ui-layout-ui-test`、`side-dock-align-ui-test`(真 Chrome)。
- **图标编辑模式(拖拽改布局)**:顶栏「⋯」→「编辑图标」进入(`components/IconEditor.tsx`)。四个栏(`topbar.primary`/`bottombar`/`sidebar.left`/`sidebar.right`)+「待放回」托盘同处一面板,拖拽 = **直接写同一份 `uiLayout`**(`order`/`align`/`slots`/`hidden`,与设置页互通),翻译口径全在纯函数 `web/src/ui-layout-edit.ts`(单测 `ui-layout-edit.test.ts`)。三条约束:① **⋯ 因此常驻**(顶栏有任何条目就画,否则用户根本进不来),② 拖拽用 **pointer 事件**而非 HTML5 DnD(DnD 在触屏上不触发,`PluginMenu` 的旧实现只能桌面用),③ 内置条目的 `icon` 名字要在 `host-icon.tsx` 登记(否则编辑面板里只剩文字),守卫 `host-icon.test.ts`。
④ **「⋯」菜单里的行必须同字号**:portal 挂在 `body` 下,菜单里自绘的扁平行(`.tb-row`,如「编辑图标」)会继承 `body` 的 14px,比搬进来的 chip 行(`.chip` 自带 12.5px)大一号 —— `.plugin-topbar-menu` 与菜单行选择器都写死 `font-size: 12.5px`;图标格 `.plugin-icon-glyph` 只装 emoji 字形,行内图标要**直接放 svg**(走 15px 方盒规则),包进 glyph 时另有 `svg{width/height:100%}` 兜底。守卫 `topbar-overflow-menu.test.ts`。
- **手机端抽屉横滑手势**(`web/src/swipe-drawer.ts` 纯判定 + `use-swipe-drawer.ts` DOM 粘合):从屏幕左/右边缘往内横滑拉出该侧面板(跟手),面板开着时在遮罩/面板上往外横滑收回。三条约束:① **起手区**(`SWIPE_EDGE_ZONE_PX`)要避开系统侧滑返回,且轴向必须横向主导才接管(竖向整手作废,交给页面滚动)、起手点下面是横向可滚动元素(`pre`/宽表格)时**一律放行**;② 跟手期间直接写面板内联 `transform` + `.drawer-dragging`(禁过渡,不走 React 状态),松手先清内联值再让 React 的 `open` 类过渡到终态 —— 所以移动端遮罩改成**常驻**(`.drawer-backdrop.persistent` / `.on`)才能垫在下面渐显;③ 常量与 CSS 绑死(`drawerWidth` = `min(300px, 86vw)`、`DRAWER_HIDDEN_RATIO` = `translateX(±105%)`),改样式要同步改。回归:`tests/unit/swipe-drawer.test.ts`、`swipe-drawer-ui-test`(真 Chrome 触摸,手动跑)。终端视图的 `term-commands` 抽屉未接手势。
- **新对话三连**:经 `focusComposer()` 自动聚焦输入框(触屏豁免);非 chat 视图先切回 chat;待发附件 chips 随会话清空(`advanceComposerSession`,判据 `UiState.sessionId`;空 sessionId 瞬时态不清)。单测:`composer-draft.test.ts`。
- **目标模式 2.0 三规则**(唯一路径:主对话=审查者、服务端另起**落盘**常驻执行对话):① 一轮 = 服务端派活 + 服务端判定,**轮次/熔断/代次全在服务端**,契约明令禁止模型自己 spawn/wait(否则预算与停滞检测同时失真);② 取样口径 = 工作区 `git diff` 指纹 + **执行者会话**错误特征(主对话是审查者,它的工具报错不代表执行受挫);③ 审查指令投递前先 `waitMainIdle`、**先登记 verdict waiter 再投递**(`conv.awaitingVerdict` + 代次作废丢弃迟到结论);**「中止/关闭就是停止」** —— `waitRoleAgent` 返回 `canceled`/`gone` 即终止循环(不续轮),`error`/`timeout` 才在预算内续轮。执行者落盘 → 占项目 8 个普通对话名额之一(满员即中止并提示,无降级路径)。回归:`tests/unit/goal-delegated.test.ts`、`goal-delegated-test`(零 token,已在 ALL 列表)。**审查者输入 = 目标 + 轮次 + 执行者自述 + 计划看板 + 服务端自动采集的执行证据**(`server/goal-evidence.ts`:执行者会话最近的 `bashExecution`/`toolResult` → 工具名 + 参数提示 + 输出尾部,单条 400c / 整段 6000c 封顶;`GoalHost.readRoleEvidence` 缺席 = 不注入);空 `feedback` 由 `ensureReviewerFeedback` 兜底(fail 沿用上一轮意见,都空则给可执行失败说明)—— 否则下一轮执行者收到「请按以上意见修改」+ 空意见,白烧一轮。回归:`tests/unit/goal-evidence.test.ts` + `goal-delegated.test.ts` 的 E1–E5。
- **DSH 三规则**:shipped 预设用 `preset-clones.ts` 的 file: 克隆(`file:` 指文件不是目录;roster 以 clone 为唯一 system 根,关 `includeShippedRoot`);问卷 answerer 与技能过滤钩子挂每次会话 `setup()` 的 agent scope;底栏统计吃直播帧(wrapper `{global:true}` 转 `assistant.stream`)+ `assistant/message.usage`,纯映射 `dsh-usage.ts`。回归:`preset-clones.test.ts`、`dsh-smoke-test.mjs` §2.5、`dsh-stats-test.mjs`。
- **`i18n.tsx` value 只许字符串字面量**(`+` 续行可):`scripts/i18n-diff.mjs` 手写解析器只认这个;改完文案跑 `npm run changelog:i18n` 验证。
- **预设分享只走白名单净化**:导入的外部 JSON(粘贴/文件/网址/社区目录)在服务端 `server/preset-share.ts` 里过四道闸门——形状(`format=pi-web-ui-preset` / `version=1` / name / settings 对象)→ 19 字段白名单 + 类型校验(不符丢弃并回报 `rejected`,未知字段回报 `ignored`)→ 尺寸上限(文档 512KB)→ 抓取收口(只 http(s)、拒内网/回环/`.local`、超时 10s)。**绝不许把外部 JSON 直接摊进 `ClientSettings`**:`settings` 里出现白名单外的键就是 bug。落盘前必须 `dryRun` 预览(用户确认才写),且导入后的预设与本地保存的预设走**同一批归一化函数**。两个引擎共用同一份编排(`PresetSharePort`),别在 DSH 侧另写一套。协议/一键分享细节见 `docs/preset-sharing.md`;回归 `tests/unit/preset-share.test.ts` + `tests/settings-test.mjs`(后者显式 `PI_WEB_PRESET_REPO=0`,**绝不在共享仓库真开 Issue**)。
- **PR 提交前必须三连验(防 CI 失败铁律)**:① `npx prettier --write <改动文件>`(CI 第一道硬门禁,漏掉直接首步挂掉);② `npm run typecheck`(Vitest 运行时只管执行、默认不阻断类型错误,必须显式全量编译确保 5 个 tsconfig 零错误;编写单测时 mock context 必须传 `{} as any`,严禁传裸 `{}` 触发 TS2740 阻断);③ `npx vitest run <单测>`。严禁未跑格式化与全量类型校验直接提交 PR。
---
_结构/流程变更时同步更新本文件及相关 `docs/`。修改后运行 `/reload` 生效。_