# AGENTS.md — 实现笔记(给 AI / 维护者) > 这不是用户文档。安装步骤、能力边界、优缺点在 [README.md](README.md)。 > 这里记录:**为什么这么实现、依赖了 DSH 的哪些内部契约、哪些路走不通(附证据)、升级时会先坏在哪里**。 > 内容会随 DSH 版本漂移而失效,所以每条结论都尽量带上当时的证据形态,方便复核。 - 包名:`@local/dsh-download-progress` - 版本:`0.3.0` - 运行位置:Host 半在 DSH 主进程,Client 半在 DSH Web UI 的渲染进程 - 零外部依赖:只用 `node:fs` / `node:path`(Host 半)+ 宿主提供的 `react`(Client 半) - 许可证:MIT,见 [LICENSE](LICENSE) --- ## 1. 触发机制(这是整个插件的难点) ### 1.1 结论先说 DSH **没有**"下载进度"这类事件。工具层只有两个端点事件: | 事件 | 时机 | 内容 | | --- | --- | --- | | `session/event` → `tool/call` | 工具开始 | `turn, step, callId, name, arguments` | | `session/event` → `tool/result` | 工具结束 | `turn, step, message, error?` | 中间过程是黑盒(`tool/stream`、`tool/progress`、`tool/partial` 在源码里搜不到)。 所以**"网卡收了多少字节"永远拿不到**,能拿到的只有"文件在磁盘上长大了多少"。 ### 1.2 用到的官方扩展点 全部是官方扩展点,没有改任何 DSH 核心代码、没有 monkey-patch: | 用途 | 扩展点 | 说明 | | --- | --- | --- | | **触发**(核心) | `ctx.on('tools/execute', (exec, next) => ...)` | waterfall,包住**所有**工具执行。`exec` 含 `callId / name / arguments / agent`。不拥有决策时**必须** `return next()`,本插件全程如此 | | 安装进度 | `plugin-manager/install-log` | 流式 pnpm 输出,`chunk.text` 是真实的一行日志 | | 安装阶段 | `plugin-manager/install-state` | `progress.phase` = `installing` / `cancelling` / `applying` | | 数据下发 | `ctx.webServer.register({ kind:'exact', path:'/download-progress/state' })` | Host 每 1s 采样一次,Client 每 1s 拉一次 JSON | | **UI 挂载** | `ctx.slots.register({ name:'shell.overlay', ... })` | 全帧浮动层,**在布局流之外** | | **简易条挂载** | `ctx.slots.register({ name:'conversation.input.left', order:2000 })` | 输入框上方那一行的 list 槽位,跟「完全权限」同排。见 §3.3 | ### 1.3 为什么是 `shell.overlay` 而不是输入框停靠区 `shell.overlay` 是"Frame-wide floating layer, above every column and outside their scroll containers", `replaceRisk: none`,且"the layer itself is click-through"。 因为它在布局流之外,**出现/消失不会挤动任何东西**——这直接满足"进度条触发时窗口不要抖动/错位"。 换成 `conversation.input.dock` 之类的停靠槽位,每出现一条就会把输入框往下推,必然抖。 **但 2026-10-22 追加了可选的第二形态**(§3.3):`conversation.input.left` 是个**已经存在**的工具行 (权限 chip 就住那儿),往里塞一个 `22px` 高、`flex:0 0 auto` 的 chip **不会改变行高**, 所以「不抖动」这条对它同样成立。两者由「显示方式」开关二选一,默认仍只开浮层。 ### 1.4 已排除的路径(都试过,有证据) | 方案 | 为什么不行 | | --- | --- | | 包装 `globalThis.fetch` | `dsh-web-fetch-http` 的 `requestPinned` 是 `await import("undici")` 后 `fetch(url,{dispatcher})`,自带私有 dispatcher,全局钩子抓不到 | | 替换全局 undici dispatcher | 同上,`requestVia` 用的是 `route.dispatcher` | | 监听 `tool/stream` 之类 | 源码里不存在这类事件 | | `ctx.tools.guard` | 签名是同步的、只能返回拒绝原因,无法观测 | | `subprocess` 服务 | 只有 `spawn`/`spawnTerminal`,**没有** spawn 事件,无法拦截子进程 | --- ## 2. 能显示什么、不能显示什么 ### 2.1 有真实进度(进度条 + 已下载/总大小 + 速度 + 悬停百分比) 判据:命令行里能解析出**落盘目标路径**,且目标 URL 的 `HEAD` 能拿到 `Content-Length`。 | 形态 | 例子 | | --- | --- | | `curl -o <文件>` / `--output` / `--output-document` | `curl -L -o D:\x\a.zip https://...`;`--output=D:\x\a.zip` 等号写法同样识别 | | `curl -O` / `--remote-name` | 目标推断为 `cwd + basename(url)` | | `wget -O <文件>` | 同上;`--output-document=o.bin` 也可 | | `Invoke-WebRequest -OutFile <文件>` | 必须有 `-OutFile`;`-OutFile:D:\x` 冒号写法也认(PS 参数语法允许 `-Param:Value`) | | `git clone ` | 采样目录总体积 | | `powershell -File <脚本>` / `pwsh -File <脚本>` | 静态解析脚本里的落盘路径,见 2.1.1 | | `certutil -urlcache -split -f <文件>` | 合法 urlcache 动作才认,`-hashfile` 等不算 | 进度 = 目标文件体积 / `Content-Length`;速度 = 滑动窗口(约 1s 跨度)算出的字节速率。 Host 每秒采样一次;Client 空闲期每秒拉一次,**有卡在跑时 400 ms 补一拍**(见 8.6.2)。 **`Start-BitsTransfer` 走另一条路**:实测(2026-10-25,`lab/work/write-shape.mjs` 每 200 ms stat) BITS 先把字节写进同目录 `BITxxxx.tmp`,14.8 s 时一次性写满 41943040 字节,跑完才改名到 `-Destination`——**目标文件在整个传输期间不存在**。所以它被登记为 `staged:true`:不探 `Content-Length`、不算百分比,卡片只画「下载中 + 已耗时」,且 `sweep()` 里不会因「不增长」 被判成停住。`Start-BitsTransfer `(无 `-Destination`)目标路径由 BITS 自定,一律不猜。 `certutil` 的写盘形状**本机未能实测**(`certutil.exe` 在该环境被策略拦,spawn EPERM, 三种提权方式都试过)。按普通文件采样处理,属推断而非实测结论。 `git clone` 的目标是**目录**,而 Windows 上 `stat(dir).size` 恒为 0——早期版本直接 `stat` 目标,于是整个 clone 期间读到「0 字节、无增长」,空闲计数器几秒后就把卡片收了。 现在 clone 任务带 `clone` 标记,采样走 `directoryBytes()` 累加目录树(跳过符号链接/junction, 最多 20000 条目、12 层,超限就停在当时的和,宁可少数也不会卡住主线程)。 卡片底部另有一行状态信息(`.ddp-log`,透明度 0.55,单行截断):显示**下载来源 URL**; 命令里没有 URL 时退回目标文件路径,再退回命令行首行。只显示这一行,不堆历史。 ### 2.1.1 `-File` 脚本这条路(`script.js`) **为什么需要**:`powershell -NoProfile -ExecutionPolicy Bypass -File E:\win22h2\get-iso.ps1` 这行命令里没有任何路径、没有下载器名、没有 `-OutFile`,`classify()` 的三条下载判定全不沾, 在分类阶段就被否掉。但脚本里明明有 `Invoke-WebRequest -OutFile` 或 `& curl.exe -o $Out`。 **做过又被否掉的两条路(别再试)**: - **进程 IO 计数**:`Get-CimInstance Win32_Process` 的 `ReadTransferCount` / `WriteTransferCount` 只统计**文件/设备**读写,**socket 流量完全不计入**。实测 12 MiB 下载只让它动了 0 字节。 - **TCP 扩展统计**:`GetPerTcpConnectionEStats`(iphlpapi)理论上能按 4-tuple 拿 `DataBytesIn`, 但需要先 `SetPerTcpConnectionEStats` 启用、要遍历 `Get-NetTCPConnection`、还要处理端口字节序, 单次开销到不了 1 Hz 采样预算,且子进程(`& curl.exe`)的连接归属 PID 判定不稳。放弃。 **现在这条路**:检测到 `-File <路径>` → 同步读一次脚本(`readFileSync`,实测 **0.409 ms / 1612 字节**)→ 纯文本解析出落盘路径和 URL → 后面跟普通下载一样走文件大小采样。 能算的(`script.js` 的 `parseScript`): - 单层字面量赋值:`$iso = "D:\x\a.iso"` / `$Out = 'x.iso'` - `param([string]$Out = 'C:\x.iso')` 的默认值 - `$env:USERPROFILE` / `$env:TEMP` 等环境变量展开(大小写不敏感) - `Join-Path $dir 'name'` 的链式求值(`Join-Path` 嵌套也吃) - `-OutFile` / `-o` / `--output` / `-Destination` 后面的路径参数 - 脚本里的 URL(供 `Content-Length` 探测) **一律不猜**(解析不出就返回空 → 不出卡片,绝不出错卡片): - 循环里的赋值(`foreach` / `for` 体内) - `Get-Content cfg.json` 之类运行时才知道的取值 - `+` 拼接、here-string、`$(...)` 子表达式 - 含 `$` / `%` 无法解析的路径 安全边界:只读、只解析、不执行;`classify()` 全程 try/catch,观测失败绝不影响被观测的 tool call。 ### 2.2 只能显示"进行中"(不确定态进度条) | 形态 | 显示文案 | 底部信息行 | | --- | --- | --- | | 插件安装 | `插件安装中...下载中...`;`applying` 阶段换成 `插件安装中...应用配置中...` | pnpm 输出的**最新一行**(整块输出只取最后一行) | | 网页抓取 | `正在抓取网页中...s`,秒数实时递增 | 被抓取的 URL | | 搜索(research/web_search 等) | `正在搜索「<摘要,最多 50 字>」中...请稍后` | `工具名 · 完整查询词`(不受 50 字限制) | 搜索卡片的标题取自 `searchQuery(args)`:真实工具(`web_search` / `advanced_search` / `multi_search`)声明的是 **`queries` 数组**,早期只读 `query`/`question`/`q` 三个标量键, 于是真实调用一律取到空串,卡片渲染成 `正在搜索「…」中` 完全没有主题。现在先读标量 (仍优先,兼容旧参数),没有就把 `queries` 用 ` · ` 连起来。 | 其他包管理器命令(pip/npm/docker pull…) | `载入中...`(统一未定义条,后续若拿到数据流再细化) | 命令行首行 | ### 2.3 拿不到的(明确说明) - **`Invoke-WebRequest` 不带 `-OutFile`**:全在内存里,磁盘上没有可采样的文件 → 不触发。 - **目标是目录/多文件**:只采样已知的顶层目标。`git clone` 是唯一的例外, 见下一条。 - **命令里用变量拼路径**(如 `-o $dst`):插件只能读到命令行文本里的字面量 `$dst`,解析出的路径不存在 → 任务约 5 秒后被回收,不显示进度。 - **纯内存下载 / API 调用**:无落盘 → 无进度。 - **上传(`curl -T` / `--upload-file`)**:本端不写文件,卡片只可能永远停在 0 B, 所以**整段排除**。排除是**按命令段**判的:`curl -T a u && curl -o out u2` 这种 既传又下的命令仍然出卡片(下载那一段确实在长)。`-T` 只在与 `curl` 同一个命令段、 且作为独立参数时才算,`git commit -m "add -T support"`、URL 里的 `?upload-file=` 都不误判。 - **`-File` 脚本里路径要跑起来才知道**:`Get-Content cfg.json` 取值、`+` 拼接、here-string、 `$(...)` 子表达式、循环内赋值——一律不猜,不出卡片(理由见 2.1.1)。脚本被改写后, 要等**下一次**工具调用才会重新读,不会热更新。 --- ## 3. UI 独立抽出的做法(改样式只动一处) Client 半**发布产物**是单文件 `client.js`(宿主按一个 bundle 下发,它不能写 `import`), 但**源码**在 `src/client/` 里按职责分片,由 `scripts/build-client.mjs` 按固定顺序拼回去: ``` src/client/ ├── 01-prologue.js ← 顶部注释 + 模块头 ├── styles-open.js ← CSS 模板字符串的开头(后面插 css/*.css) ├── css/overlay.css ← 浮层视觉(改外观只动这里) ├── css/inline.css ← 简易条视觉 ├── css/settings.css ← 设置页视觉 ├── styles-close.js ← 模板字符串收尾 ├── 20-utils.js ← 纯函数(截断、去重、格式化) ├── 30-settings.js ← 设置项与默认值 ├── 40-samples.js ← 调试模式的模拟数据 ├── 50-presence.js ← 「有没有任务在跑」的探测 ├── 60-data.js ← taskStore(全插件唯一的轮询器)+ describe() ├── 70-views.js ← VIEWS 表:wire → view-model ├── 75-sessions.js ← 会话 id/标题解析 ├── 78-scope.js ← 当前会话过滤 ├── 80-drag.js ← 拖动定位 ├── 85-inline.js ← InlineBar(对话框简易条,见 §3.3) ├── 90-overlay.js ← Card + Overlay(浮层) ├── 95-settings-page.js ← 设置页 └── 99-plugin.js ← 三个槽位挂载 ``` **改任何一片都必须跑 `node scripts/build-client.mjs`**,它把 `src/client/**` 拼成 `client.js`; `node scripts/build-client.mjs --check` 会在产物过期时失败并指出第一处差异(`npm test` 第一条就是它)。 顺序是显式写死的一张表,不是目录序:浏览器从上往下读,切片顺序错了会出 TDZ 错误, 而这种错误**在 Node 里看不见,只在真机 UI 里炸**。 - **改颜色/圆角/动画/位置** → 只改 `src/client/css/*.css`。 - **改文案/文案格式** → 只改 `describe()`;简易条的极简文案单独在 `briefOf()`(§3.3)。 - **改卡片结构** → 只改 `Card()`。 - **改简易条结构** → 只改 `InlineBar()`。 - **改数据获取/失败策略** → 只改 `taskStore`。两个表面(浮层 + 简易条)都在不同槽位、 不同 React 树里,靠模块级 store 共享同一帧;`subscribe()` 引用计数,第一个订阅者启动、 最后一个退订停止。**不要**让任何表面自己 `fetch('/download-progress/state')`—— 那会分裂成两份可能不一致的列表,bug 就只在一个表面上出现。 样式注入走 `ctx.effect(() => { 建