# 开发指南 > 上手与日常操作。改 `src/` 之前先看 `架构与实现.md`;打包发版看 `打包与发版.md`。 > > **基线(2026-09-19 实测,写前门四批 + D-13 修完之后重跑)**:`tsc --noEmit` exit 0; > `vitest run` 无浏览器 **433 passed / 5 skipped**(18 文件,~17s); > 接真 Chrome(本机 `--headless=new` + 空闲端口)**438 passed / 0 skipped**。 > 每批都会动这个数,且本仓库**常有并行会话同时改用例**(这一轮的数就含别人未提交的用例)—— > **以现跑 `vitest run` 为准**,别拿文档里的旧值当契约。 ## 文档地图 | 文档 | 讲什么 | 什么时候读 | |---|---|---| | `开发指南.md` | 环境路径、命令、基线、纪律红线 | 开工前 | | `架构与实现.md` | 接线、两个 provider、ref 状态机、大纲折叠/去重、上下文预算、窗口宿主 | 动 `src/` 之前 | | `打包与发版.md` | 便携版构成、打包四前置、四道自检、发版流程 | 打包 / 发版 | | `CDP实测事实.md` | CDP 状态作用域的逐条实测结论 | 接入新 CDP 命令前 | | `多会话防冲突-设计评估.md` + `多会话防冲突-实施方案.md` | **进行中的设计**:P1 写前门(第 1-4 批)已落地,P2 及之后未做 | 排这部分工作时 | | `webpage交互改进-实施方案.md` | **进行中**:click 遮挡、fill、后退、快照浮层、scroll 超时;与多会话方案的边界见该文文首 | 排 webpage 交互改进时 | | `portable-install.md` | 面向最终用户的安装说明 | 改用户可见文案时 | | `harness-desktop-build.patch` | 打进 harness 的桌面端补丁(打包链要用,不是文档) | 补丁失配时 | --- ## 1. 路径 | 用途 | 路径 | |---|---| | 本仓库 | `D:\dev\cli\dsh-webops-plugin` | | harness 检出(devDependencies 全部 `link:` 指向它) | `D:\dev\cli\deepseek-harness` | | 纯 Chrome(live 测试用) | `C:\Program Files\Google\Chrome\Application\chrome.exe` | | managed node | `C:\Users\yemaf\.workbuddy\binaries\node\versions\22.22.2-3\node.exe` | | managed python | `C:\Users\yemaf\.workbuddy\binaries\python\versions\3.13.12\python.exe` | | 便携包产物 | `dist/` | | 临时解压 / 验证目录 | `.rel-verify-*` / `.desktop-stage*`(仓库根,`--dir` 指它们) | ## 2. 命令 ```bash cd D:/dev/cli/dsh-webops-plugin NODE="C:/Users/yemaf/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" # 类型检查(15s) $NODE node_modules/typescript/bin/tsc --noEmit # 单测(无浏览器)→ 433 passed / 5 skipped $NODE node_modules/vitest/vitest.mjs run # 单测(接真 Chrome)→ 438 passed / 0 skipped # 端点必须是一台能开标签页的真 Chrome:先自己起一个 # "C:/Program Files/Google/Chrome/Application/chrome.exe" --headless=new \ # --remote-debugging-port=9444 --user-data-dir=D:/Temp/<一次性目录> about:blank & # ⚠️ 端口被桌面端 Electron 占着时这组会**跳过**并打印原因(不是绿),别拿 skip 当通过。 DSH_CDP_ENDPOINT=http://127.0.0.1:9444 $NODE node_modules/vitest/vitest.mjs run ``` **两个「别这么干」**: 1. **别用 `pnpm run test` / `pnpm test`**。pnpm 11 的 `pnpm run` 会隐式触发一次 install(约 10 分钟), `npm_config_verify_deps_before_run=false` 拦不住。直接调 `node_modules/` 下的入口。 2. **别用裸 `node`**。本机 shell env 自带 `ELECTRON_RUN_AS_NODE=1`,起桌面端尤其要 `env -u ELECTRON_RUN_AS_NODE`。 **live 组为什么默认跳过**:`live.test.ts` 会读端点的 `User-Agent`,**含 `Electron/` 即判定为嵌入式并整组带原因跳过**。 本机 9222 常被 dsh 桌面端的 Electron renderer 占着,那个端点同样能响应 `/json/version` 但**不实现 `PUT /json/new`**。 要跑 live 组请显式指 `9333`,并起一个无头 Chrome: ```bash "C:\Program Files\Google\Chrome\Application\chrome.exe" --headless=new \ --remote-debugging-port=9333 --user-data-dir=%TEMP%\dsh-cdp-profile about:blank ``` > `--user-data-dir` 必须是**新目录**:Chrome 发现同名目录已在运行时会丢掉调试端口、直接附着到那个实例,表现是「端口起不来但不报错」。 ### 本地 profile 联调(CLI 路线,与桌面端是两条) ```bash cd /d/dev/cli/deepseek-harness pnpm dsh plugin --profile <名字> add D:/dev/cli/dsh-webops-plugin # 初始化 profile + link + 追加进 bundles pnpm dsh --profile <名字> --dump-config | grep -A4 "== dsh-webops-plugin" # 应出现 5 行,层序对 ``` `dsh plugin add` 自己完成包名解析 —— **不需要 junction,也不需要改 dsh 的 `pnpm-workspace.yaml`**。 本仓的 `link:` 依赖只负责反方向(插件仓 import 得到 dsh 的包)。 生产期是同一套命令;git 安装形态要先让本仓提供 self-contained 的 `prepare`, 并在 profile 里 `allowBuilds: { dsh-webops-plugin: true }`(**这条没有实测过**,本地目录安装才是实测通过的)。 开 `DSH_BROWSER_PLUGIN_DEBUG=1` 能看到加载诊断(写 **stdout** —— 桌面端会把 stderr 吞掉): ``` [dsh-webops-plugin] root: client row registered [dsh-webops-plugin] browser-cdp: endpoint=http://127.0.0.1:9333 [dsh-webops-plugin] browser-electron: enabled=true electron=…/electron.exe [dsh-webops-plugin] tool-browser: registered open, navigate, snapshot, screenshot ``` ## 3. 桌面端开发态 ```bash pnpm run build # 产出 lib/(桌面端只吃构建产物,src/ 不进去) pnpm run dev:desktop # 装配进开发态 profile 并拉起 Electron pnpm run check:desktop # 外部 CDP 断言「host 认了 / 客户端跑了 / 面板渲染了 / 卡片注册了」 pnpm run window:desktop # 让桌面端 host 进程自己开一个 Electron 窗口 pnpm run shot:desktop # 截一张真实桌面端的 PNG ``` 开发态**装插件是被硬禁的**(`main.ts` 里 `plugin package changes require a packaged application`, 且 `plugin-add` 只收 npm registry 形态的 spec),并且 `dev.ts` 每次启动都会用 `prepareDevelopmentProject` **整目录重建** `project/`。所以 `scripts/dev-desktop.mjs` 复刻了 dev.ts 的启动序列,只在中间插一步装配 (真实目录复制,不是链接),并**不改动 deepseek-harness 里任何文件**。 桌面端开发态的 `$DSH_HOME` 是 `apps/desktop/.desktop-build/development/home`, profile 是 `apps/desktop/.desktop-build/development/project`。 ### keyless(fake-llm)端到端验证 `src/fake-llm/` 是**脚本化模型回放**,用来在没有 API key 的情况下跑全链路。三道防线必须同时在: | 防线 | 位置 | |---|---| | 那一行**不在**出货 patch 里 | `cordis.patch.yml` | | 开发专用 overlay 单独一个文件,且不在出货白名单 | `cordis.fake-llm.patch.yml`(由 `dev-desktop.mjs` 拼进开发态 profile) | | `apply` 有闸门,默认哑 | `DSH_FAKE_LLM=1` 才注册监听器 | `src/bundle-patch.test.ts` 把前两条钉死在 `pnpm test` 里。 跑一次: ```bash # ① 杀旧实例:必须按端口(9222/9229/9230)找 PID,不要 taskkill /IM electron.exe(屏上常有多个,会误伤) # ② 后台起桌面端 DSH_BROWSER_PLUGIN_DEBUG=1 env -u NODE_OPTIONS -u ELECTRON_RUN_AS_NODE pnpm run dev:desktop # ③ 等 9222 就绪(最多 4 分钟),再 sleep 15 等插件装配 # ④ 跑端到端 VERIFY_MESSAGE="打开谷歌首页,点击 AI 模式,问:为什么天空是蓝色的" pnpm run verify:card ``` 铁律: - **fake-llm 脚本每进程只消费一次**。同一实例上重跑 `verify:card` 会脚本耗尽、只回兜底文本。每轮必须重启桌面端。 - 验证链路**必须走 `dev:desktop`**(overlay 与 `DSH_FAKE_LLM=1` 都由它置好)。手工装配时要自己补这两样。 - 症状对照:插件在图上、`llm/stream` 却没人接管 → 先查 `DSH_FAKE_LLM` 与 overlay 在不在。 - 热搜榜是实时数据,**别对具体标题写死断言**。 ## 4. 本机环境坑(都不进生产,但会浪费半天) | 坑 | 现象 | 处置 | |---|---|---| | **火绒按 exe 路径放行** | 新目录里的副本一律出不去网,看着像「包坏了」 | 复测沿用**同一安装目录**;换目录先 `ELECTRON_RUN_AS_NODE=1 D:/Temp/net-check2.mjs` 验出网 | | **`app.asar` 的 EBUSY** | 打包在组装阶段失败 | 持有者按概率:① 没关掉的 Harness 进程(`taskkill` 立刻解锁);② **IDE** —— VS Code 系把 asar 当可解析归档打开,句柄**不带 `FILE_SHARE_DELETE` 且永不释放**(2026-09-19 用 Restart Manager 点名:`WorkBuddy.exe` / `Qoder CN`,五个杀软进程一个都没出现)。触发条件是**工作区内 + 内容真能解析成 asar**。根治 = 把产物放工作区外(`DSH_DESKTOP_BUILD_ROOT`);仍要压包时走 `python scripts/zip-stage.py`(共享读)。详见 `打包与发版.md` §2 | | **本机路径不许写死在脚本里**(2026-09-19 起) | 换机器/换盘后脚本**安静地**去读一个不存在的目录(版本判错、报「没有可用缓存槽」)—— 最难查的一类 | 一律经 `scripts/local-env.mjs` 取;值写在仓库根 `.env.local`(**不进库**,模板 `.env.local.example`)。排查「配了怎么没生效」:`pnpm portable:env` 打每个键的**来源**(文件/环境/未设)。详见 `AGENTS.md`「本机路径」一节 | | **安全删除垫片拦住 harness 构建**(2026-09-19) | `prepare:runtime` / `prepare:dsh` / `package:win:x64:dir`(内部 `build:official`→`build:web`)**每一步都挂**,报的全是 `[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED]` —— **看着像四个互不相干的构建失败,其实同一个根因** | 跑链前 `export CODEBUDDY_SAFE_DELETE_ENABLED=0`(CI 无此垫片) | | **MSYS GNU tar 把 `D:` 当远程主机名**(2026-09-19) | `prepare:packages` 挂,报 `tar (child): Cannot connect to D: resolve failed`,exit 2。**栈里只有 `packedManifest → capture`,离真因很远**(它读的是 `tar -xOzf <绝对路径> package/package.json`) | `export TAR_OPTIONS=--force-local`。手敲同一条 tar 命令可复现 | | **重编桌面端的两处网络死结**(2026-09-19) | 直跑 `package:win:x64:dir` **先跑 20 分钟再挂**:① `prepare:dsh` 报 `[23] The operation was aborted due to timeout` —— 上游把 registry 硬编码成 npmjs.org 并剥掉子进程全部 `npm_*`/`pnpm_*` 变量(`~/.npmrc` 的镜像**无效**),本机直连只有 11–31 KB/s(`node-pty` 7.15MB 要 ~10 分钟),且 store 每轮 `mkdtemp` 重建 → 必超时;② `prepare:runtime` 下 157MB Electron zip 报 `TypeError: fetch failed` | 一律走 `pnpm harness:build`(`scripts/harness-build.mjs`,自动临时换 `DSH_NPM_REGISTRY`/`ELECTRON_MIRROR` 并**无条件还原** `prepare-dsh.ts`)。自测 `--dry-run`。详见 `打包与发版.md` §2 末 | | **shell env 自带 `ELECTRON_RUN_AS_NODE=1`**(WorkBuddy CLI 注入,注册表 HKCU/HKLM 里都没有) | 双向都翻车:桌面端起不来 / node 脚本被当 GUI 起。**中招特征三条:exit 0 + stdout/stderr 全空 + 目标应用 userData 的 mtime 没动**(连 Chromium 都没到)—— 全中就是它,别去查文件锁 / 杀软 / 缺依赖 | 起之前 `env -u ELECTRON_RUN_AS_NODE -u NODE_OPTIONS` | | **PowerShell 工具输出恒为空** | 看不到输出 | 一律回 bash 工具 | | **bash 的 `cat >>` 偶发重复执行** | 追加一次写出两份 | 追加后 `grep -c` 确认份数;更推荐用 Edit 工具 | | **截不了图** | `Add-Type` 被拦 | 别指望会话内截图,用产物文件(脚本写 `--out dist/x.png`) | | **`DSH_DESKTOP_` 前缀被过滤** | 自定义环境变量设了不生效 | `host-process.ts:119` 会过滤该前缀,自定义变量只能用其它 `DSH_*` | | **替用户拉 GUI 进程会被回收** | 工具会话一返回进程就没了(`nohup … &`、PowerShell `Start-Process -PassThru` 都不行)。⚠️ **在同一调用里 `sleep` 再查是假阳性**,必须**跨 tool 调用**判 | 脱离工具会话的 job object:① explorer 当父进程 `MSYS_NO_PATHCONV=1 /c/Windows/explorer.exe "…/app/DeepSeek Harness.exe"`;② WMI `([wmiclass]"Win32_Process").Create('"…exe"','…wd')` → `ret=0 pid=…`(能拿 PID,且继承**服务环境**) | | **抓进程别用 `tasklist //FO CSV` + grep** | CSV 引号骗过 grep | 按端口找 PID(`Get-NetTCPConnection`) | | **包内 node 只能走 primary-runtime** | PTC / 沙箱 runner 指向空文件 | 见 `打包与发版.md` §6 | --- ## 5. 纪律红线 **这些不是建议,是踩过的坑。** 1. **绝不用正则批量改字符串字面量的引号**。把「含插值的单引号串」批量改成模板串时,正则会把反引号塞进 **代码里其它字符串内部**。后果:语法合法、`tsc` 过、测试全绿,但模型收到的回执里 `session_id`/`url` 变成字面量 `${...}`。要改就**逐处 Edit**,且必须补「断言输出文本完整内容」的测试(只测「不抛错」兜不住)。 2. **新增断言必须做反向验证**:改坏一处,确认它真的转红,再填回去。否则断言可能只是装饰。 3. **引用数字/行号前回源码核对语义**。曾把 `desktop-runtime.json` 的「清单条数」当成「要解包的文件数」, 据此提了个不存在的性能优化。结论涉及「某功能有没有」时先看 mtime / `git status`(本仓库有并行改动)。 4. **只在打包态复现的 bug,自检必须跑在打包态那条运行时上**(`verify:ptc` 的 `[3/4]` 用包内主 exe + `ELECTRON_RUN_AS_NODE=1` 就是为此)。否则验的是另一条路径。 5. **自检通过 ≠ 可以发版**。四道自检只证「包结构 + 生产链」,证不了「状态条看得见、模型回话、真去调工具」。 6. **判「开发态脚本设的变量 → 打包态必然 bug」**:翻 `scripts/dev-desktop.mjs` 的每行 `environment.X = …`, 问「`main.ts` 里兜底在不在」。已踩三遍:`ELECTRON_RUN_AS_NODE`、`DSH_PTC_NODE`、`DSH_BROWSER_PROVIDER`。 修法一律落在 `main.ts` **顶层判空兜底**,**不能只写启动脚本**(双击 exe 与双击脚本等效)。 7. **写 AX 树夹具必须照实测画**。曾因夹具里没画 `button` 下面那行同名 `text`,26 条断言全绿而真实页面 一个都折不到。live 组(`DSH_CDP_ENDPOINT` + 真 Chrome)是形状契约的唯一防线。 8. **计数口径分开报**。`truncated` / `droppedElements` / `foldedRepeats` / `dedupedLines` / `outside_region` 各是一套,混报会把模型引到错误动作上。已为「口径混报」付过两次代价。 9. **未提交的改动不算完成**。`git status` 是真心话来源。 10. **提交规范**:中文提交信息;破坏性操作先确认;用户点名只要 `main` 时不要顺手推其它分支。 --- ## 6. `dsh-app://shell/*` 曾让包「界面正常但完全点不动」(2026-09-19 已修) **真机事故 + 同日在 harness 补丁里修掉**(`docs/harness-desktop-build.patch` 的最后一个 hunk,补丁现在是 6 目标 / 14 hunk)。 ### 现象与根因 应用起来了、界面正常、`workspace.json` 也 `initialized: true`、用 JS 点按钮也能通 —— **但手点完全没反应**(主上原话「有一层遮罩,点击不了」)。完整链路: 1. `apps/desktop/src/main.ts` 的 `protocol.handle(SCHEME, …)` **只实现了 `app` host**,其它 host 一律 404。 而 `update-dialog.ts:33` 与 `mandatory-update-window.ts:46` 加载的是 **`dsh-app://shell/update-dialog.html`** —— 文件确实打进了 asar 的 `renderer/`,协议却取不到(上游**没实现** `shell` host)。 2. ⇒ 那个窗口永远加载出空文档(``,39 字节), **却照样 `show` 成与主窗口同尺寸(1279×840、偏移仅 7px)的透明窗口并抢走焦点** → 吃光所有鼠标事件。 3. 触发点是**启动时的更新检查**:测试版应用查更新要求先过策略登录,于是 `main.ts:627` 走 `updateDialog.show(mainWindow, { title: messages.policyLoginTitle, … })` ⇒ **每次启动必现**;又因为窗口里没内容,**没有关闭按钮,用户自己关不掉**。 **为什么极难发现(这条最有价值)**:DOM 层是全绿的 —— `disabled: false`、`elementFromPoint()` 命中的就是按钮本身、`document.hasFocus()` 之外一切正常,用 JS `.click()` 一点就通 (`POST /api/settings/mutate` 200、弹窗正常推进)。**因为命中测试只在同一个文档内做, 跨窗口的遮挡它根本看不见** —— 「DOM 正常 + 手点无效」这个组合只能往**窗口层**查。 ### 修法 `protocol.handle` 里补一个 `shell` 分支,复用已有的 `serveWebDocument`(自带路径穿越防护与 MIME 表), 根取 `app.getAppPath()/renderer`: ```ts if (url.hostname === 'shell') { return serveWebDocument(request, join(app.getAppPath(), 'renderer')) } ``` **不能改用 `loadFile`**:`preload-update-dialog.ts:9` 与 `preload-mandatory.ts:16` 是按 `location.href` **字面**放行桥接的,`update-dialog.ts` 的 `owned()` 也拿 `senderFrame.url` 跟同一个 字面比 —— URL 必须真的是那个 `dsh-app://shell/…` 才有人认。 ### 怎么验(修复前后实测对照) | 观测点 | 修复前 | 修复后 | |---|---|---| | 弹窗窗口 HTML | 39 字节 | 1121 字节 | | 弹窗背景 | `rgba(0,0,0,0)` 全透明 | `rgba(0,0,0,0.24)` 遮罩 | | CDP target 标题 | (空) | `登录测试环境` | | 真实鼠标点「稍后」 | — | 窗口关闭、主窗口拿回焦点(`hasFocus` false→true) | ```bash node scripts/portable-dev.mjs launch --cdp 9333 # 起包(WMI,脱离 job) node scripts/cdp-act.mjs text --target shell # 读弹窗真实内容与按钮 node scripts/cdp-act.mjs click --target shell --text 稍后 # 发真实鼠标事件 node scripts/diag-window-layers.mjs --port 9333 # 列窗口层,找同尺寸空白窗 ``` **核对 UI 文案**(改 `description` / 工具描述后要肉眼确认时):先 `eval` 取出那个元素的 `getBoundingClientRect()` 和 `textContent`,再用 `--clip` 裁那一块放大 —— 整图截图在 小字号文案上常常糊到读不出字。 ```bash node scripts/cdp-act.mjs eval --target "dsh-app://app/" \ --js "(() => { const e=[...document.querySelectorAll('*')].find(x=>x.children.length===0&&x.textContent.includes('webpage_*')); const r=e.getBoundingClientRect(); return JSON.stringify({text:e.textContent,rect:{x:r.x,y:r.y,w:r.width,h:r.height}}) })()" node scripts/cdp-act.mjs shot --target "dsh-app://app/" --out docs/_shot.png --clip "371,660,830,52,3" ``` ⚠️ `--target app` 会**同时命中 shell 页**(URL `dsh-app://…` 里含 `app`)→ 用完整 `--target "dsh-app://app/"` 才不会报「匹配到 2 个页面」。 ⚠️ **验证必须走 `Input.dispatchMouseEvent`(`cdp-act.mjs click` 就是),不能用 JS `.click()`** —— 后者在这个 bug 下**照样通过**,是假阳性。 **影响面(修之前)**:`shell` host 挂着 ⇒ `mandatory-update.html`(强制更新页)、 `policy-login-loading.html`(策略登录页)同样加载不出来,不只这一个对话框。 **以后再遇到「界面正常但点不动」**:先 `diag-window-layers.mjs` 找同尺寸的空白可见窗口, 基本就是同一类(某个 `dsh-app:///` 没实现)。