- Accueil
- Plugins
- Modèles et fournisseurs
- dsh-connect-qoder
dsh-connect-qoder
eghrhegpe/dsh-connect-qoder
Enregistre les modèles Qoder CN et internationaux dans DSH et récupère automatiquement le tarif de chaque modèle, qui varie aux heures creuses. Fournit aussi pointage quotidien, indicateur d’utilisation et consultation des crédits. Nécessite une session Qoder sur cette machine ou une connexion dans le navigateur.
Installer
dsh plugin --profile web add github:eghrhegpe/dsh-connect-qoderREADME
dsh-connect-qoder
把本机已登录的 Qoder 模型(国内版 / 国际版)接入 DeepSeek Harness,零配置用你的账号额度。
此项目已停更,最高支持dsh-0.21.5,相关功能已合并到[dingminhua版](https://github.com/dingminhua/dsh-connect-qoder)
把本机已登录的 Qoder 模型接入 DeepSeek Harness, 零配置即可在 DSH 的模型选择器里使用你的 Qoder 账号额度。
国内版 Qoder CN 与国际版 Qoder 是两个并行的 provider(qoder-cn / qoder),
装哪个就出现哪一组模型,两个都装就两组并存,各自使用自己的账号与额度。
输入框旁的用量胶囊:工具栏上的 Qoder CN · 2,222 是区域前缀加主额度剩余 Credits,
点开就是同一份用量面板——套餐内额度与剩余、今日经本客户端的请求数与 token 消耗(含缓存命中)、
7 天用量趋势,以及今日签到状态——与 Plugins 页卡片同款(同一套读数与阈值)。
名册里出现了本机此前没记过的模型时,胶囊会多出第三段 · 3 新模型(没有就不画,宽度与从前
一致),点开面板能看到名字。这个「新」的口径是本机第一次见到,不是上游的上架时间:目录里
没有这个字段,插件能证明的只有「本机上次记下的名册里没有它」。所以首次运行只播种不宣布,
只列最近 7 天内首见的,见 docs/issues/25。

工作原理
DSH PiAiAdapter(每个区域一套)
-> 安全 loopback shim(随机端口 + 进程内随机 secret)
-> COSY 签名 + 自定义 base64 编码
-> 国内版 https://gateway.qoder.com.cn/
-> 国际版 https://api3.qoder.sh/
-> Qoder 双层包装 SSE
-> OpenAI SSE
-> DSH 本地执行工具并回传结果
Qoder 不是 OpenAI 兼容端点,所以需要三样东西:
- COSY 签名头 —— 每个网关请求都带
Cosy-*头与Authorization: Bearer COSY.<payload>.<sig>, 签名是对 base64 负载、RSA 包装的 AES 密钥、时间戳、请求体与签名路径做 MD5。 - 置换 base64 请求体 —— 查询串里的
Encode=1表示 JSON 体要先 base64、再按换过的字母表 逐字符替换、最后按三分之一旋转。 - 双层 SSE —— 每个
data:帧是一个信封对象,它的body字段本身又是 JSON 字符串, 里面才是 OpenAI 风格的 chunk。
凭据来源
插件有三个凭据来源,按优先级依次尝试(桌面应用优先):
- Qoder 桌面应用自己的登录状态(下面几段描述的解密过程)。只要它在且没过期,用的就是它: 应用运行时自己保持登录新鲜,本机没有比它更新的凭据。
- 插件自己的设备登录。在浏览器里批准一个码,插件把换回来的可刷新令牌存进
$DSH_HOME/.qoder-auth.<region>.json。这是没装桌面端(或不想装)的机器零配置登录的路径 —— 详见 凭据安全 与docs/issues/21-device-login-credential-source.md。 - 环境变量 PAT(
QODERCN_PAT等)。最后的兜底,需要用户自己到后台造一个长期令牌。
桌面端那条过期了就不算数(2026-10-20):过期的 access token 什么都干不了——每个带它签名的
请求都会被拒,这个版本本身也不会被注册(region-gate 直接按"登录已过期"跳过发布)。所以它
不再压住后面两条:插件会继续往设备登录、再往 PAT 找一条还能用的凭据。只有三条都拿不出
东西时,那条过期的桌面端登录才作为"本机真实发生过的事"被报出去,而不是被一句含糊的"没登录"
盖过去。
无论走哪一条,都不写入应用的文件:桌面端的凭据文件始终以只读方式打开。
卡片按"真正在服务这个版本的是谁"说话(2026-10-20):宿主把桌面端登录与插件自有登录并列
上报(两条凭据两种寿命,不合并),但账号卡要回答的其实只有一个问题——"这个版本登录了吗"。
所以当插件自有凭据在服务、而桌面端那条不在服务(过期、读不到、或只是环境变量 PAT 在服务)时,
这个版本读作**"已登录"**,并配一句"当前用的是插件自有登录(浏览器登录)——不需要桌面端
客户端";此时那条"本地登录信息读不到"的红字不再出现——它描述的是桌面端,不是这个版本能不能用。
只签浏览器登录的用户也因此第一次有了「在线校验登录」按钮可点(以前按钮只在 source 有值时
出现,而插件自有登录在账号行里没有 source)。
Qoder 把登录信息放在 Chromium OSCrypt 格式的凭据文件里(v10 + nonce + 密文 + tag,AES-256-GCM)。
新版(0.3.x)的凭据直接保存在 <userData>/auth.v1.dat,旧版(0.2.x 及更早)则放在
VS Code 风格的 state.vscdb SQLite 数据库里。两种布局都支持,新版优先尝试。
密文用的密钥保存在应用的 Local State 中,由操作系统 keystore 包裹 —— Windows 上是当前用户
作用域的 DPAPI,因此同一用户下的进程都能解开它。
Node 没有内置 DPAPI 绑定,这一步交给 PowerShell,并通过临时文件交换结果(不使用管道),
这样在禁止管道 stdio 的沙箱里同样可用。
没有桌面应用登录时,可以用官方文档的 PAT 兜底:设置 QODERCN_PAT(国内版)或
QODER_PAT(国际版),插件会用它换取 job token。
平台边界:读桌面端登录态那条路径只在 Windows 成立。 解密链路是 PowerShell + DPAPI
(Crypt32.dll),src/host/ 里没有任何 macOS / Linux 解包分支——在其它平台上应用目录
找得到(应用数据根目录已按平台解析,见下),但凭据读不出来(loadCredential 返回
undefined)。非 Windows 上的失败说的是真话:runUnwrap 在 spawn 之前就判平台,
记下的是"这个版本只在 Windows 上读得到登录",而不是一条在 Mac 上显示的 Windows 路径;
账号面板的提示也随之改为指向在哪个平台都成立的路子(先「浏览器登录」,再 PAT),
而不是让一个已经装了客户端的用户去确认"客户端在不在"。
但那些平台已经不再只有 PAT 一条路:插件自己的设备登录(上面「凭据来源」的第 2 条)
不读桌面端任何东西——在浏览器里批准一次,凭据就落在本机,平台无关。所以第 1 项 keystore
从"必须补"变成"可选"。诚实的限定:设备登录这条路径只在 Windows 上真机跑过(两区各
一次授权闭环,见 probe/qoder-device-login.mjs),它用到的
都是跨平台 API,但"在 macOS / Linux 上也确实能用"仍属下面第 2 项那类实机验证。
这不是"没有跨平台客户端",而是客户端已跨平台、插件尚未跟上:Qoder 桌面版在
macOS 12+ / Linux (.deb/.rpm) / HarmonyOS 上都有下载(qoder.com.cn/download),
但 src/host/ 只实现了 Windows 这一条解密链。缺口在两处,全部登记在
docs/KNOWN_GAPS.md 第 6 条(跨平台凭据链未实现):
- OS keystore 封装(含 key 派生):Windows 走 DPAPI;macOS 需 Keychain(
security), 且 Chromium 在 macOS 上对encrypted_key还要做 PBKDF2-HMAC-SHA1 派生("peanuts" 常量 + 1003 次迭代)——与 DPAPI 直解是两种算法,不是换个命令;Linux 需 libsecret(secret-tool) 或 Chromium 在 Linux 上的peanuts硬编码 key 兜底。 - 跨平台 CI 与实机验证:GitHub Actions 的 macos / ubuntu runner 可以编译并跑单测,但
Keychain 弹窗、签名打包、
secret-tool的 D-Bus session 都得在实机或 runner 上验。
已修的两项(原缺口共四处):应用数据根目录现在按平台解析(Windows %APPDATA、
macOS ~/Library/Application Support、Linux $XDG_CONFIG_HOME/~/.config)——此前直接读
process.env.APPDATA,而该变量在 macOS 上不存在,于是探测得到空串、把"应用装着但读不出密钥"
谎报成"您没登录";MACHINE_OS 也补上了 darwin 档(本机实测过网关对
x86_64_darwin / aarch64_darwin 一律正常应答)。
剩下的是实机工作:service 名、PBKDF2 参数、Linux 的变体都必须实测确认,写出来就是盲代码。 macOS / Linux 用户现在可以走设备登录(零配置),或 PAT 兜底,或自行从本仓库移植解密链。
CI 的 Ubuntu 绿灯说明测试在那边能跑,不等于零配置在 Linux 上存在。
安装
从 npm 安装(推荐)
dsh plugin --profile web add @eghrhegpe/dsh-connect-qoder
从 DSH 市场安装(DSH 桌面用户)
本仓库是 hdhgsysh/dsh-connect-qoder 的 fork。
可在 DSH 的「插件市场」里粘贴这个源:
github:eghrhegpe/dsh-connect-qoder
github: 源是零配置的:宿主产物 lib/(host + client bundle)随仓库提交,克隆即可加载,
安装过程不需要批准构建脚本、也不需要拉 devDependencies。若 DSH 反而提示该包有待批准的构建
脚本,说明它拿到了一份缺 lib/ 的旧克隆——删掉重装即可(lib/ 自 docs/issues/19 起必须
入库,CI 有新鲜度门禁)。
注意:npm 上不带 scope 的
dsh-connect-qoder由上游发布,装到的是上游那份; 本 fork 自己发的是 scoped 包@eghrhegpe/dsh-connect-qoder(上面那条),二者不是同一个包。 npm 安装每次发布都会现场重建产物(prepack),同版本号下 npm 的产物比仓库提交里更新的 可能性更大——追新走 npm,github:源装的是对应提交时刻的产物。
或本地开发模式:
dsh plugin --profile web add <本仓库路径>
本地开发时 lib/ 对应的是提交时刻的源码:改过 src/ 后先
npm install && npm run build,再让 DSH 指向本目录,否则跑的还是旧产物。
安装后需要重启 DSH 进程:bundle 的 patch 在启动时读取。
凭据安全
来源不同,插件是否在本机留下东西也不同:
- 桌面应用的凭据:只读打开(新版
auth.v1.dat/ 旧版state.vscdb,不做任何写入), 不存储、不复制。解密主密钥会在%TEMP%短暂明文落盘,回收窗口见docs/issues/02。 - 设备登录换来的令牌:这是插件自己持有的唯一凭据,因此也是唯一会被写下来的 ——
存在
$DSH_HOME/.qoder-auth.<region>.json,0600+ 临时文件改名写入(不会留下半写文件), 按 region 分文件(两个版本互不影响)。面板上的「退出插件登录」删的就是这个文件;它不影响 桌面端的登录,也不影响环境变量里的 PAT。令牌过期时插件用同一次的刷新令牌换新,换新失败 会在面板上说明原因,而不是装作还登录着。 - 环境变量 PAT:只读,插件不写入任何地方。
- 进程内随机 bearer token 绑定 loopback 端口;Qoder 真实的 RSA 包装 key 与 session key 不会离开本插件的沙箱。
已知行为
- 始终思考的模型:部分模型(如
GLM-5.3-Flash、Kimi-K3)声明了推理档位但不允许关闭思考, 对它们发送enable_thinking: false会被上游以provider_error 1210拒绝。插件从模型目录 识别这类模型并完全省略该字段,让模型使用自己的默认档位。 - 上游错误可见:上游失败时返回的是普通 200 帧里的错误对象,而不是 chunk。插件把它翻译成 一条可读的错误,而不是让用户看到一个空的助手回合。
- 今日请求次数用完(不是"排队"):Qoder 每天有请求次数上限,用满后上游会带一个以小时计的 重试提示(约两小时)把它伪装成排队。此前插件照单全收,显示成"重试延迟:7350 毫秒", 而这个等待怎么等都没用——次数要到日期切换才重置,不是排队排空的。现在它是一个独立状态: 不重试、不显示误导性的延迟,明确告知约几小时后重置,并指出仍然有效的两条路: 22:00-08:00 的错峰价(折后价,通常不计入或少计日次数)与每日签到。 状态码也从此前的 502("Qoder 坏了")改为 429("今天用完了")。判别点在 src/host/errors.ts 的 classifyUpstreamError:先认 110、再认队列标记—— 顺序是承重的,因为这个报文本身带着全部队列标记,由 test/daily-limit.test.js 钉住。
- 国际版额度:国际版的试用额度可能已用尽(
isQuotaExceeded),此时目录请求会返回 403Login expired,该区域就不会显示模型;国内版不受影响。 - 账号状态四档与卡片重读:插件入口读不出登录凭据的区域不注册 provider(启动日志会说
原因)。卡片顶部的「当前账号」面板收敛成版本条(对齐 WorkBuddy 的 tab 条):每个
区域一个胶囊——状态点(
ok绿 /expired红 /needs-app琥珀 /signed-out中性)+ 区域名 +「模型」开关;点哪个胶囊,账号详情、用量与模型列表都只看那一版,区域名因此 在卡片上只出现一次(模型行的区域 badge 已随之移除)。身份详情只带 name / email / 到期日,不带任何凭据(判定在src/host/account-state.ts,纯本地证据、可注入可直测)。 两个按钮各管一件事:「重读登录」 让宿主丢弃凭据缓存、重读应用存储,并把启动时未能上线的区域现在就上线(重建并重新 注册 adapter,失败时回滚到原注册,不影响已在服务的区域)——重新登录后不必再重启 DSH; 面板每次渲染只读已缓存的密钥,不会同步起 PowerShell(那会阻塞整个 DSH 进程,最坏 30 s), 所以解不开的机器也能立刻看到"读不到"和原因;即便真的需要解包(点「重读登录」时)也是 异步等待——本机实测调用 29 ms 就返回、等待期间 DSH 照常响应别的请求,而同步版本会冻结 490 ms;「重读登录」是您主动点的,那一次是真读,不受缓存与失败窗口限制。 密钥缓存与Local State文件绑定,Qoder 重装换掉主密钥后插件会立刻重新解包,不必重启 DSH; 「在线确认」是账号流程里唯一的联网调用(fetchUserInfo),回答「上游现在还认不认 这个登录」,失败按classifyUpstreamError分档(sign-in-expired/ 其他)。 - 每一版的模型可以单独关掉(
enabledRegions,版本条上的「模型」开关,对齐 WorkBuddy 的 per-tab 供应商开关):取消勾选的一版向 DSH 提供零个模型,它的 模型组按「空目录即隐藏」的同一条规则从选择器消失,但登录、用量与模型筛选全部 保留,重新勾选即恢复,无需重启 DSH;卡片模型列表与选择器用同一个regionEnabledFor谓词过滤,两个界面不会打架。 - 刷新失败会说出来,而不是留着旧数据装作没事:
- 上游当前没有模型(账号被收窄 / 模型全部下线)是结果不是故障——插件会照实把目录清空, 并把该版本的模型组从选择器撤下。此前这两种情况走同一条静默路径,于是旧名单永远留着, 点"刷新计费"也不可能有任何变化。
- 抓取失败(网络、凭据、上游 5xx)保留上一份好数据,并把卡片上的时间标成 「上次更新:X(刷新失败)」,而不是给一个全新的时间假装刚更新过。
- Qoder 改了接口格式(HTTP 200 但返回的信封不认识,例如分组改名)是一档独立状态,
卡片直接说"请更新插件(重新登录没有用)",且不显示时间戳——它不会被当成"排队"慢慢重试。
判别边界取自实测(
probe/model-shape.mjs,两端真实账号):空分组用[]表达, 所以"0 个模型"是可信结果、必须落盘,而"分组键缺失/类型变了"才是格式变化。
- 卡片接口只接受同源请求(安全):十二条路由里七条是 POST 且能改状态——「签到领积分」
(真实账号变更)、「保存模型勾选与图像模式」(
__save)、「重读登录」、「在线确认」, 以及设备登录那一组的三条写路由:「开授权页」、「取消」、「登出并删掉本插件存的凭据」。 逐条方法、能否改状态、谁在调用见 docs/ROUTES.md (本仓路由的唯一清单;改路由必须同步那张表)。这些接口此前只按"来源主机名是不是 本机"判断,因此本机任意端口上的任意 HTTP 服务都能让它的网页代为调用(浏览器会挡跨域 读响应,但操作已经发生)。现在要求来源的地址与端口与请求实际拨到的地址一致 (比对请求自己的Host头,所以不写死端口、DSH 换端口也不用改),且必须是回环地址。 判定在src/host/routes.ts的loopbackRequest,由test/route-gates.test.js覆盖。 - 依赖 Qoder 客户端接口(非官方开放 API),Qoder 更新后插件可能需要随之调整。
- 设置命名空间由宿主决定,不能自选(0.2 代起):
describe()用 Loader 条目 id 作为ns(本 bundle 是llm-qoder,不是dsh-connect-qoder),而「设置 → 模型」 页按精确匹配查namespaces.get(entry.settingsNs)。若插件宣告的命名空间与宿主实际 服务的不一致,该 provider 会被判为「未配置」,整行从页面上消失——不报错、不灰显, 而插件本身仍在正常注册和应答,非常难查。因此settingsNs一律经settingsNamespaceOf(ctx)从ctx.fiber.entry.options.id推导,常量只作为宿主不暴露 条目 id 时的回落值(对齐 WorkBuddy 2.1.0 的同款修复)。 - 设置保存走插件的
__save主机端点 + 读回校验(对齐 WorkBuddy 同修):DSH 0.2 代的 客户端设置 scopeset()在原子写重试耗尽后会静默返回成功而不落盘,卡片改为「主机端点 权威写、本地写仅作镜像、写后读回确认」,保存按钮只会显示真实结果。在宿主未为本插件注册设置 命名空间行的环境(如link:开发安装)下端点会 503,卡片如实显示失败而不是假"已保存"; 正式(registry)安装下该端点可用。 - 每日签到:Qoder 每天 10:00(UTC+8)给每个账号发一轮
CLAIM_BENEFIT活动,一轮只能领一次, 重复领取是幂等的(replayed,不再发放)。卡片在「我的用量」右侧放一张签到小卡片 + 三态按钮 (立即签到 / 签到中… / 今日已签到)——只有你点才会领,插件不会自动签到。每次点击都先重读一次 活动列表取当轮 id,绝不复用渲染时的旧 id(一天一轮,旧 id 就是上一轮的);领取后用量缓存作废 重读,因为 Credits 落在同一块面板显示的 Add-on 资源包里。两个过滤器是照真实返回加的:同一账号 下的另一个VIEW_DETAILS活动同样带着claimStatus: "CLAIMED"却没有 benefit,只按状态挑会对 它去领;而列表顶层的claimable在「已领过」和「没有活动」时都是false,不能当判据。 卡片右上角大字是主机解析出的当轮额度(checkin.amount,来自活动自身的 benefit),领过之后 仍然显示——签到卡片因此不是「一行两段短文案占满整行」,而是与用量面板并排的一张卡;窄卡片下 自动换行到面板下方。额度落在addOnQuota里,是因为国内版免费层的计划额度为 0,签到攒的资源包 是它唯一的额度。 - 国际版签到行需要 umid 机器身份:国际版
GET /sash/api/v1/me/campaigns把每日CLAIM_BENEFIT轮次按机器身份下发——请求须携带桌面应用 umid 服务的Cosy-MachineToken/Code/Type头,否则只回常驻的VIEW_DETAILS横幅,签到卡片按active: false设计不渲染。插件在 OpenAPI 请求上自动附加这组头(Windows 上定位安装 目录的resources\umid\runtime-info.exe读取,失败静默降级为原头组;国内版与 PAT 不受影响;发现与验证记录在 docs/KNOWN_GAPS.md 第 8 条(国际版 campaigns 端点按 umid 机器身份门控每日签到))。
文档
npm 包里只有 6 项:lib/、locale/*.json、cordis.patch.yml、icon.svg、README.md、
CHANGELOG.md(即 package.json 的 files;另有 npm 无论如何都会打包的 LICENSE)。
docs/、AGENTS.md、src/、test/、probe/、scripts/、assets/ 都在仓库里、不在包里
——registry 页面上点这些路径会 404,请到 GitHub 仓库看。本文的截图因此一律写成
raw.githubusercontent.com 绝对地址(相对路径在 registry 页面上是裂图)。
阅读顺序按身份分,别从头读到尾:
| 你是 | 读到哪为止 | 之后去哪 |
|---|---|---|
| 装它 / 用它 | 「已知行为」这一节结束 | 没有必须再读的;遇到现象先查 docs/README.md 里的 howto/symptom-triage.md(按屏幕原文排) |
| 改它 / 审它 | 继续往下:「目录」(逐文件职责)、「构建产物」、「测试」 | docs/README.md 的完整地图:缺口 KNOWN_GAPS.md、路由契约 ROUTES.md、平台事实 docs-qoder-cn/ |
| AI 协作会话 | — | AGENTS.md(不随包发布,只在仓库里):验证怎么跑、红线、门禁真源 |
目录
| 文件 | 作用 |
|---|---|
src/host/credentials.ts | 从 Qoder 应用读取登录凭据的存储半边:探测候选应用目录、以只读方式打开 state.vscdb、解码各行(2026-10-20 从中拆出 OSCrypt 链,链在下一行) |
src/host/oscrypt-unwrap.ts | OSCrypt 链:PowerShell/DPAPI 解包主密钥、密钥缓存与 Local State 绑定、失败窗口 60 s、清零与启动清扫(唯一会把密文写到进程外、唯一 spawn 子进程、唯一删东西的一半;两个解包入口都接受注入的 spawner,因此链内环节可无 PowerShell 测) |
src/host/upstream.ts | 上游层的门面(26 行):逐名 re-export src/host/upstream/ 下的 11 个叶模块,让 20 个下游 importer 一行不动(2026-10-17 拆分,docs/issues/22) |
src/host/upstream/crypto.ts | RSA 公钥、置换字母表与 Encode=1 请求体编码(CUSTOM_ALPHABET / encodeBody);协议事实原文注释在此文件头 |
src/host/upstream/client-constants.ts | COSY_VERSION / CLIENT_TYPE / MACHINE_TYPE / DATA_POLICY / LOGIN_VERSION / MACHINE_OS |
src/host/upstream/sign.ts | signaturePath(签名路径,剥掉 gateway 路由掉的 /algo 前缀) |
src/host/upstream/cosy-sign.ts | authHeaders:COSY 签名头组装(AES 加密签名 info 块、RSA 包 AES key、MD5 over payload/key/ts/body/path) |
src/host/upstream/headers.ts | openApiHeaders 与 umid 机器身份头(Windows 定位 runtime-info.exe;读不到时显式降级,__dshQoderUmidState 报原因) |
src/host/upstream/endpoints.ts | 各 API 端点 URL 构造(目录/对话/兑换/账号/用量/签到) |
src/host/upstream/retry.ts | 重试与队列等待预算(QUEUE_WAIT_*、queueWaitFor,含两分钟预算的 rationale) |
src/host/upstream/sse.ts | 双层包裹 SSE 解码与对话流(streamChat,含重试循环与 Retry-After 处理) |
src/host/upstream/fetch-usage.ts | 用量快照(fetchUsage / UsageSnapshot / normalizeQuotaBucket / normalizeDedicatedPackage / normalizePromotion);促销读失败时具名降级而非静默(campaignsError) |
src/host/upstream/api-calls.ts | 各调用的编排与 JSON 读取(readJson / readCampaigns / claimCampaign / fetchModels / readModelCatalogShape / exchangePat / fetchUserInfo) |
src/host/upstream/messages.ts | DSH ↔ Qoder 的消息与工具描述转换(toQoderMessages / toQoderTools) |
src/host/shim.ts | 面向 pi-ai 的 OpenAI 兼容回环端点 |
src/host/adapter.ts | pi-ai provider 与 PiAiAdapter profile(被关的 provider 以零模型组呈现,由 DSH 自行隐藏) |
src/host/route-retry-policy.ts | 本路由交出去给宿主的重试策略:只留 EMPTY_RESPONSE / TIMEOUT / TRANSPORT,让插件的队列阶梯成为唯一重试源——宿主默认策略含 RATE_LIMIT / SERVER,会把已烧完 120 s 预算的队列失败再续 5 次(≈12 min),也会白重试 shim 一律用 502 表达的终态判决;刻意不设 maxRetries / backoff(未知键会让整个 profile 注册失败;docs/issues/24,无 peer 依赖) |
src/host/catalog-entry.ts | 目录条目的归一化、模型过滤(含按区域开关)与卡片行投影(无 peer 依赖) |
src/host/catalog-store.ts | 目录的磁盘缓存与原子落盘(无 peer 依赖) |
src/host/roster-seen-store.ts | 「本机首次见到这个模型」的历史(每区域一份 .qoder-roster-seen.<region>.json,id → 首次见到时间戳):7 天窗口、首次写入只播种不宣布、离开名册即遗忘、不认识的版本不覆盖、超过上限按形状漂移处理(不写不报);它证明的只是「本机上次记下的名册里没有它」,不是上游的上架时间(无 peer 依赖,docs/issues/25) |
src/host/catalog-refresh.ts | 一次目录刷新的结果如何落地:空目录也是结果(照实清空并推进 fetchedAt),只有失败才保留上一份,且失败按 credential / no-credential / fetch / protocol-shape-changed 分档(无 peer 依赖) |
src/host/credential-cache.ts | 凭据缓存与「登录失效后重读」规则,以及 env-PAT 兑换的 429 节流闸门(平台自述窗口优先、否则指数退避;401/403 停泊,仅「重读登录」释放;无 peer 依赖)。三源顺序是「桌面端(没过期才算在场)→ 插件自有设备登录 → 环境变量 PAT」:过期的桌面端 access token 什么都干不了(连 provider 都不会被注册),所以它不再压住后面两条 |
src/host/credential-kind.ts | 「这个版本现在是谁在服务」的答案:凭据来源标签 → desktop / device / env 三档。宿主是唯一知道实际解出哪条凭据的一方,所以由它说;卡片与 status 路由据此把「桌面端登录」与「插件自有登录」两本账并成一条,不再靠面板猜(无 peer 依赖) |
src/host/throttle-store.ts | 兑换节流闸门的磁盘持久化(只存未过期的 rate-limit 窗口、parked 停泊与令牌一律不落盘,原子写,无 peer 依赖) |
src/host/request-counter.ts | 本地「今日经本客户端请求次数」计数:上游只报「用完了」不给每日上限,故只能本地自计(每次对话轮 +1,重试不重复计)。按 region + 上海本地日分文件、零点翻页、原子写(temp/rename)、越界 region 分量拒绝写盘、gc 只删本插件命名且早于保留期的旧日文件;撞 daily_limit_exceeded 时置 dailyLimitHit 不丢计数(无 peer 依赖) |
src/host/token-counter.ts | 本地「今日经本客户端消耗的 token 数」累加器:shim 每抓到一个上游 usage 帧(total_tokens / cached_tokens,上游每轮明确下发)就累加进本插件文件(.qoder-token-count.<region>.<day>.json)。与请求计数器同源同套路(按 region + 上海本地日分文件、零点翻页、原子写、越界防护、gc 只删本插件旧文件),但数据来自上游而非本地估算——聚合的是上游已经公开的真实 token 数,不画比例条(无每日上限口径);tokensFromUsage 对坏帧返回 null 宁可不加也不加错 |
src/host/credential-vault.ts | 插件自有凭据文件的读写(设备登录换来的可刷新令牌):0600 + temp/rename 原子写、损坏文件隔离留存而非被后来者覆盖、读-改-写整体串行(并发的刷新不会把刚登出的凭据写回来,无 peer 依赖) |
src/host/device-login.ts | 设备登录的协议层(两个 region 各自的端点、client_id、PKCE 与轮询/刷新请求):404 与「还没审批」的空信封都读作未授权而非失败;刷新应答不是凭据——实测只有六个字段、把访问令牌叫 device_token 且不带 user_id,必须叠加回被刷新的那一份,否则签名带上空 uid 会被网关回 403 Signature invalid(无 peer 依赖) |
src/host/device-credential.ts | 把 vault 里那份令牌变成可签名的凭据、并在过期时轮换:五档状态(none / ok / expired / refresh-failed / unreadable)而非两档——「本机没登录过」与「文件读不出来」指向不同下一步;刷新被拒后有 60s 窗口(否则每轮对话都会重试一条已被吊销的刷新令牌);刷新成功但落盘失败仍照常出借凭据、只 warn;从不抛(抛出去就等于把登录问题变成 provider 坏掉)(无 peer 依赖) |
src/host/device-login-flow.ts | 设备登录的会话状态机(按 region 一次一个尝试,5 分钟 TTL):把探针那种「一个进程等着」拆成卡片能驱动的 start / poll / cancel,否则一个路由要挂五分钟且面板一关就丢;PKCE verifier 只留在宿主内存、绝不出现在任何返回值里;poll 每次只发一次请求;兑换码只能用一次(尝试先删再写凭据,否则之后的 poll 会给第二个令人困惑的答案);「批准了但没存下」报 failed 而非成功(假确认是红线 1 禁止的)(无 peer 依赖) |
src/host/account-payload.ts | 账号面板三条路由共用的那份应答:逐区域状态 + 开关映射,以及「渲染读缓存 / 重读登录读真」这一个开关(从 index.ts 抽出以便直测,无 peer 依赖) |
src/host/account-state.ts | 每区域账号状态四档判定(ok / expired / needs-app / signed-out;纯本地证据、不含凭据,无 peer 依赖);三种读取模式(默认 / cachedOnly 不解包 / force 忽略失败窗口) |
src/host/settings-save.ts | 设置命名空间的解析(0.2 代由宿主推导,插件不能自选)、设置写入、按区域合并与落盘读回校验(无 peer 依赖) |
src/host/pi-model.ts | pi-ai 模型描述符的构造(纯函数,无 peer 依赖) |
src/host/adapter-models.ts | 单个区域向 DSH 提供的模型列表:区域开关(只认显式 true)、勾选过滤、最大上下文开关、逐模型图像模式(从 adapter.ts 抽出以便直测,无 peer 依赖) |
src/host/region-gate.ts | 一个区域能否作为 provider 上线:三档拒绝(无登录 / 已过期 / 读不到)各自的判定与日志级别(从 index.ts 抽出以便直测,无 peer 依赖) |
src/host/lifecycle.ts | 插件 fiber 退出时要撤销的东西:路由注册的注销句柄收集与释放(宿主是否随 fiber 回收无法从插件侧确认,故两种语义都正确;无 peer 依赖) |
src/host/preferences.ts | 四个设置项的读取与 volatile 解包(enabledRegions 区域开关:缺失/非对象一律读作开启,只有显式 false 才关) |
src/host/offpeak.ts | 错峰窗口与费率算术(无 peer 依赖) |
src/host/single-flight.ts | 同类异步任务的并发合并:刷新在途时,后来的调用并入同一次请求(目录/用量刷新用,无 peer 依赖) |
src/host/claim.ts | 每日签到:当轮活动的挑选、可领状态判定与领取结果的归一化(纯函数,无 peer 依赖) |
src/host/errors.ts | 上游错误帧的判定:105(登录没了)与 10605(在排队)的分诊,队列提示的提取,以及协议形状变化这一档(ProtocolShapeChangedError,retryable: false 所以它不会被当成"排队"慢慢等)(无 peer 依赖) |
src/host/time.ts | 上游时间戳的单一换算(秒 / 毫秒 / RFC 3339 → epoch 毫秒),曾经的两份副本行为不一致(无 peer 依赖) |
src/host/volatile.ts | volatile 活引用 { get() } 的解包,此前散在三处(无 peer 依赖) |
src/host/http-utils.ts | 回环路由共用的 JSON / OpenAI 形状错误响应(no-store,卡片轮询读不到陈旧数据;无 peer 依赖) |
src/host/domain.ts | 各宿主模块标注时共用的领域词表(Region / CatalogEntry / Promotion / CatalogOutcome 等)。只有类型、没有运行时值,所以不进产物、不改变任何 bundle 字节;刻意不描述任何 peer 模块的形状——那些包由宿主运行时提供、此处只有 declare module 空壳,凭空造一个"看起来对"的接口比 any 更危险,因为它不会承认自己不知道(无 peer 依赖) |
src/host/routes.ts | 每条卡片路由共用的两道闸:方法检查(405 带 Allow、HEAD 交给 GET)与同源来源检查(403;比对 Host 头,POST 路由靠它挡住本机其它端口的网页),以及带 64 KiB 上限的 JSON body 读取器(无 peer 依赖) |
src/host/handlers.ts | 十二条卡片路由的 handler,(req, res, deps) => Promise<void> 形态:方法/来源两道闸 + 业务判定 + 应答全在这(含签到 claimTodayFor 与「重读登录」的失败保护),index.ts 只保留 webServer.register 与 deps 装配(从 index.ts 抽出以便直测,无 peer 依赖)。「在线校验登录」的兜底路径经 deps.readCredential 取凭据,宿主把它接到按 region 共享的凭据缓存上,所以没有 runtime 的 region 也能校验插件自有登录(无 peer 依赖) |
src/host/publish-regions.ts | 把当前区域集发布给宿主(registerAdapter + registerConfigurableProviders)以及失败时的三层回滚:先释放旧的一对,新注册失败则释放半注册的 adapter 并把旧的一对装回去,装不回去则如实留空而不谎称已注册(从 index.ts 抽出以便直测,宿主两处注册接缝靠注入,无 peer 依赖) |
src/host/route-mount.ts | 一组路由的挂载判定:要么全挂上、要么全撤下。三个账号路由是一个功能,宿主若只收下 /account 而拒了 /account/confirm,旧写法会留下一个"能读状态、两个按钮却不动"的半截面板,且日志写着"account routes unavailable"——那句话是假的,比没有日志更坏,因为它让人不再往下查。组内任一注册被拒即回滚已拿到的 release 并如实报失败(无 peer 依赖) |
src/host/dispose-order.ts | fiber 注销时的次序(次序本身就是内容):provider 注册先撤、路由先于 runtime 拆(否则 issue 13 那条泄漏)、disposed 必须在 clearInterval 之前置位——否则一次还在途的 refreshCatalog().then 会把一个全新的 interval 装到已经没人管的 runtime 上,成为活过插件的僵尸凭据解析循环。此前这条只由注释担保(无 peer 依赖) |
src/host/injected.ts | requireInjected:把 "ctx.inject(['webServer'], cb) 保证 cb 跑时该服务已在" 这个只活在注释里的前提,转成一次类型收窄——服务缺失就抛错点名该服务,而不是让后续注册把路由挂到 undefined 上、再于更晚静默失败。此前它是 activate() 里的内联闭包(七处调用共用),此前这条也只由注释担保;抽出以便直测,只认 undefined(其它 falsy 值是宿主自己的契约违反,原样交回、不改写)(无 peer 依赖) |
src/host/index.ts | 按区域注册 provider 的插件入口,与十二条路由的注册接线(handler 本体在 handlers.ts;发布与回滚的判定在 publish-regions.ts;挂载分组在 route-mount.ts;注销次序在 dispose-order.ts;注入服务缺失的判定在 injected.ts) |
docs/docs-qoder-cn/ 是从 Qoder CN 官方文档抓回并清洗的资料(纯 Markdown,源 .txt 已删),
由根目录的 convert-qoder-docs.ps1 一次性转换而成——它只处理这一批抓取件,不是常规构建步骤;
产物由 test/docs-markdown.test.js 守着,改动转换规则后重跑该测试即可。文档与代码的对应关系
(哪条系数/活动/签到事实落在哪个模块)记录在该目录的 Qoder CN 文档与插件设计佐证.md。
卡片侧的源码在 src/client/(产物是 lib/client.js,react 由宿主提供,产物不打包它):
| src/client/paths.ts | 卡片用到的十条路由常量(QODER_*_PATH):四条读、六条写。__save 不在其中——它由 settings-write.ts 以字面量直接 POST;status 只供宿主诊断,卡片不读 |
| src/client/styles.ts | 卡片样式与 installStyles(dsm-* 一套与 dsh-connect-workbuddy 逐字一致,原因见文件头) |
| src/client/settings-write.ts | 「写入后读回校验」的浏览器半边 |
| src/client/controller.ts | 卡片可编辑状态机(staged/saved 三字段、dirty、save、discard 与派生的模型视图)——无 React、无 DOM,可被 node:test 直接测 |
| src/client/copy.ts | 卡片文案聚合入口(中/英),index.ts 与 card.tsx 只从这里取 |
| src/client/copy-row.ts copy-usage.ts copy-account.ts | 按面板拆分的三段文案:模型行与错峰、用量与签到、账号与区域标签条 |
| src/client/card-model.ts | 卡片的浏览器无关决策层:视图模型类型词汇 + 纯规则(offPeakState / rateAt / windowLabelOf / refreshNoticeKey / 格式化 / describeThrown——卡片所有 catch 块给用户看的失败文案都过它)。无 React、无 DOM、无 fetch,因此 Node 测试可直接 import 真规则,而非镜像副本(这是向 sensenova snapshot.ts 对齐的那一刀;describeThrown 也从 card.tsx 挪进来,拆掉装配层与两个容器之间的循环 import) |
| src/client/http.ts | 卡片唯一的 fetch 收口:getJson / postJson,模型名册读与两台容器(用量 / 账号)共六道宿主路由 fetch 都只通过这里(向 sensenova http.ts 看齐);GET / POST 失败都先取宿主的 error 字段(如 loopback origin 检查的 origin-not-trusted),无才回落 HTTP {status};宿主的 writeError 写的是 {error:{message,type,code}},故对象形状也取它的 message(否则每道 writeError 拒绝都会退化成一句没有下文的 HTTP 500);GET 200 但 body 解析失败按 unparseable JSON body 抛出而非当空数据,避免「假 undefined 失败」 |
| src/client/usage-panel.tsx | 用量容器 QoderUsagePanel:自有额度读 + 每日签到领取两条 fetch,渲染 RegionUsage / CheckinCard(二者留在 card.tsx 作为无 hook 纯展示件) |
| src/client/account-panel.tsx | 账号容器 QoderAccountPanel:区域条 + 选中区域登录卡,四道 fetch(读 / 重读 / 确认 / 开关写),开关写仍带完整 enabledRegions map。按"在服务的是谁"显示:effectiveStateOf() 判出这个版本真正在服务的是桌面端还是插件自有凭据,只有后者在服务时才读作"已登录"、才给「在线校验登录」按钮(桌面端那条在服务时按钮同样在) |
| src/client/device-login-panel.tsx | 插件自有登录那一块(第三个凭据来源的卡片半边):与账号行的 state 并列上报、绝不合并——桌面端登录与插件令牌是两份凭据两种寿命。两层结构(外层无 hook 做门控,device 缺席 = 老宿主 → 什么都不渲染、绝不报「未登录」;内层持全部 hook)。start → window.open 授权页 + 保留可点回退链接 → 每 2s 轮询一次(链式而非 interval,慢响应只会推迟下一次而不会叠请求);authorized 停并回读,expired/idle/failed 各给不同文案;卸载时若仍在等待就 best-effort cancel(宿主 5 分钟 TTL 是兜底)。每个失败都说出是哪个失败,t/onChanged 由 ref 持有并刻意排除在轮询 effect 依赖外(否则父组件每次渲染都会重启计时器) |
| src/client/card.tsx | 卡片的装配层 QoderPluginCard(把 QoderAccountPanel / QoderUsagePanel 与模型列表拼起来,纯规则委托 card-model.ts、可编辑状态委托 controller.ts、名册读走 http.ts)+ 三个无 hook 纯展示件(RegionUsage / CheckinCard / QuotaBlock) |
| src/client/pill-model.ts | 输入框用量胶囊的浏览器无关决策层:区域归属门控(isQoderRegion / selectedRegionOf / candidateOf)、主额度选择(primaryQuotaOf——套餐优先,CN 免费层 plan 为 0 回落资源包)、分级(tierOfQuota,阈值与卡片同源 WARN_SHARE = 0.8)、读数格式化(remainingOf / formatCredits)。无 React、无 DOM、无 fetch,Node 测试直接 import 真规则 |
| src/client/composer-usage-pill.tsx | 对话框用量胶囊 ComposerUsagePill:挂在宿主输入框工具栏 conversation.input.right 座席,读数 Qoder CN · 1,034(区域前缀 · 主额度剩余 Credits)+ 分级色点,点击展开原始用量卡(未签到给领取按钮,走与 Plugins 页同一条 claim 路由,领完立刻重读把 Credits 推回余额);面板顶部画当前模型的错峰窗口状态(折扣挂在模型上,必须 projection.model 匹配到名册里的 id/name 才画,匹配不上不声称打折);5 分钟轮询、读失败保留上次数值不抛。归属由 useProjection("modelSelection") 物化,provider 非 qoder-cn/qoder 整颗胶囊不挂载(不跑 fetch)。样式走 inline style + 一条容器锚定规则,不注入整表样式以免覆盖宿主工具栏 |
| src/client/token-history-chart.tsx | 迷你柱状图 TokenHistoryChart:SVG 手绘 7 天 token 消耗趋势(柱高按占比、颜色按消耗量分三档),底部标注周总量与日均、趋势箭头(今天 vs 昨天)。无依赖,内联样式 |
| src/client/index.ts | 注册入口(apply / inject / name),含 conversation.input.right 座席注册(失败降级 console.error,不影响 host provider) |
| src/client/react-shim.d.ts | 最小 React 类型垫片(只管类型检查,不参与构建、不随包发布) |
构建产物:lib/ 不是源码
源码全在 src/,lib/ 是构建产物,但受版本管理**(docs/issues/19:DSH 市场的
github: 源安装正是把这两个文件原样发给用户的,缺了它装出来的插件没有宿主入口,所以
lib/ 必须入库,.gitignore 里不许再出现 /lib/)。改 src/ 后跑 npm run build 并把
lib/ 与源码同一次提交(CI 有新鲜度门禁:重建产物必须与提交里的一致);发到 registry
的那一份仍由 prepack 现场构建。npm run build 从 src/ 重建出两个文件:
| 产物 | 由谁构建 | 内容 |
|---|---|---|
lib/index.js | tsdown -c tsdown.config.mjs(npm run build:host) | 上面那张表里全部 src/host/*.ts 打成的单个 ESM bundle——发布面就是 package.json#main 一个入口,peer 包一律外置 |
lib/client.js | node scripts/build-client.mjs(npm run build:client) | 注入到宿主设置页的那张卡片,外面套着 window.__ModuleLoader__.load(...) 的加载器外壳 |
这些源码不是原始手稿,是还原出来的:2026-09 用 docs/history/restore-client-src.mjs 把当时的
产物按 //#region 标记机械切分而成,模块边界来自产物,card.ts 那一段在产物里没有标记、
是按引用关系推断的。还原后做过一次对拍——把卡片里每个纯函数用同一组输入各调一遍,新旧产物的
返回值与抛错逐条一致(方法记录在 docs/issues/17-client-source-restore.md;现在的探针清单与
调用数以 npm run verify:bundle 的输出为准,不再在文档里写死数字)。
产物是构建输出:改源码重建,不要手改产物。test/client-bundle.test.js 从产物里提取并
执行卡片的纯函数,所以产物一改那里的断言就得跟着看一眼。
test/*.test.js 直接 import src/host/*.ts(Node 原生剥类型),不经过 lib/——所以一套
测试跑的是源码,产物陈旧与否由 npm run verify:host 单独把关。
probe/ 下是一次性只读探针,每个文件头写明 WHY 与 Run,不参与构建、也不被测试收集。
其中 probe/host-compat.mjs 回答的是「本机装的 DSH 是什么版本、本仓库的 peer 声明它还认不认」——
宿主把代码打在 app.asar 里,这件事从仓库内部看不出来。手法与三个会浪费时间的坑记在
docs/howto/host-version-probe.md。
测试
npm run verify # typecheck + 两道 any 审计 + 测试树两道类型门禁 + build + test + 市场条目 + 产物核验,全过才算过
npm run typecheck # tsc -p tsconfig.json,源码全量类型检查(0 error)
npm run audit:test-types # 测试树自己的类型程序:tsc -p tsconfig.test.json(已 opt-in 的文件数见输出)
npm test # node --test "test/*.test.js"
npm run test:coverage # 同上 + 覆盖率门槛(行 68 / 分支 82 / 函数 66,跌破即失败)
npm run build # 从 src/ 重建 lib/(宿主 bundle + 卡片产物)
npm run verify 是一条链,每一步回答一个不同的问题。下表与 package.json 的 verify 脚本
一一对应——test/docs-facts.test.js 会核对这张表和那条链是不是同一份,改了一个忘了另一个,
当场就红:
| 步骤 | 回答什么 | 全过时的输出 |
|---|---|---|
typecheck | src/** 的每个 .ts 都过类型检查 | tsc 退出 0、无输出 |
audit:any | 源码里没有隐式 any | implicit-any sites: <数量> (budget <上限>) |
audit:explicit:any | 源码里没有显式 any | explicit-any sites: <数量> (budget <上限>) |
audit:test-types | 测试文件自己也过类型检查(坏 import、改了名的导出、变了形的参数) | opted in to type checking: <数量> (floor <下限>) |
audit:test-type-errors | 测试树的类型债不许涨:新债进不来,旧债只许降 | test type errors: <实测> (budget <上限>) |
build(宿主 + 卡片) | lib/index.js 与 lib/client.js 确实由 src/ 生成 | MATCH: … byte-for-byte identical |
test | 卡片逻辑与宿主半边没被改坏;发布门禁仍能被打破 | 最后一行 # fail 0 |
test:market-entry | 市场条目还合规则吗(对着上游那份条目规则核一遍) | market-entry.test.mjs: all checks passed |
verify:host | 宿主 bundle 不陈旧、公开面与 peer 外置都还在 | all 11 checks passed(装了 tsdown 时 11 条;没装时 9 条,见下) |
verify:bundle | 重建产物与上一份已发布产物的行为一致 | behaviour: IDENTICAL + OK: … |
四道类型/any 门禁的阈值都住在 scripts/ratchet-ledger.json 这本账里(谁、什么时候、为什么),
不在脚本里——脚本里改阈值这件事本身会当场被账本对不上而红。
中间那条是最要紧的:宿主与卡片两步都只比较、不认账。它过了,就说明产物不是手抄进来的
副本——改 src/ 而产物不变的情况会在这里红。verify:host 补的是迁移带来的新缺口:源码
在 src/host/、产物在 lib/ 之后,「改了源码忘了重建」第一次成为可能的错误;lib/ 如今
受版本管理(docs/issues/19),这道本地对拍之外,CI 的 git diff --exit-code -- lib 门禁
也会在推上去时看见它。
测试树自己也有类型门禁。 主 tsconfig.json 只管 src/(它的 exclude 里写着 test),
所以测试文件过去完全不在任何编译器程序里:套件经 Node 的剥类型加载器 import src/**/*.ts,
注解被抹掉,一个都没查;测试自身的 .js 更连解析都没发生过。tsconfig.test.json 是另一半——
它把测试树放进一个程序,opt-in 的测试文件用 // @ts-check 逐个打开,npm run audit:test-types
盯着这个程序。两个程序是分开的:npm run typecheck 仍然只意味着「每个 src/**/*.ts 是
干净的」,测试程序有自己的门禁与自己的范围。
allowJs: true 是让 .js 测试文件进程序的那一步,checkJs: false 是让没 opt-in 的文件
只被解析、不被检查。别把 checkJs 打开在 tsconfig.test.json 里:整棵树是数千条错误的
量级(今天的实测数见 npm run audit:test-type-errors 的输出),那需要一次有预算的迁移,不是一次
翻开关。整棵树打开的那份程序是
tsconfig.test.budget.json(同源、checkJs: true),它只用来数债:npm run audit:test-type-errors 把整棵树的错误总数与预算比,只许降不许涨。棘轮轴从「文件数」换成
「错误数」是有理由的:给一个干净文件加 pragma,文件数 +1(有本事不要钱);而给一个带着 60 条
错的文件 opt-in,它当场就变红(最该被看的文件进不来);新写的测试文件带 20 条错进来,文件数
一动不动。错误预算反过来:新债一进来就被数进去。
tsconfig.test.json 里的 exclude 必须重新声明:
extends 会继承父配置的 exclude(["node_modules","lib","test"]),继承了之后 include
里的 test 根本不进程序,门禁会报「0 error、0 个被检查的文件」,然后一脸无辜地绿着。
pragma 的规则是实测出来的、不是猜的:只有 // 行注释算数(块注释里的 @ts-check 编译器
根本不看),token 必须在注释开头,且最后一个 pragma 赢。没 opt-in 的测试文件还剩一大半
(总数见 npm run audit:test-types 的输出),缺口与代价记在
docs/KNOWN_GAPS.md 第 10 条(测试树的类型债被记成预算)。
绿灯不等于门禁有效。 这几道门禁每条都用故意的破坏验过:build 缺关键串时会拒绝写入
(第一次构建摇掉全部模块、只剩 84 行,bundler 仍然退出 0);verify:host 往 lib/index.js
尾上追加一行就让 fresh 那条红(the artifact is stale)并以非 0 退出;verify:bundle 把
探针下界从 Math.max(0, …) 改成 1,就会在 formatCountdown|undefined|0 上报出
00:00:00 → 00:00:01 并以非 0 退出;audit:test-types 有四种破法——摘掉某个文件的
// @ts-check(棘轮立刻点名「opt-in 的文件数低于下限」)、给已 opt-in 的文件塞一个类型
错误(tsc 点名到行)、把 tsconfig.test.json 的 exclude 加回 test(逐个点名「这个文件
opt-in 了却不在程序里」)、在前导区补一行 // @ts-nocheck(点名「这个文件的检查被静默关掉
了」);audit:test-type-errors 有三种破法——把 tsconfig.test.budget.json 的 checkJs 翻成
false(债数塌成 0、门禁从此永远绿)、把 tsconfig.test.json 的 include 里的 test 删掉
(测试树整个离开程序、债数同样塌成 0),或者把上限本身悄悄抬高(账本对不上,门禁当场点名
「这个阈值没有记录」)——三种都会被当场点名。四道门禁的阈值都住在
scripts/ratchet-ledger.json 里,所以「抬阈值」永远是一笔带理由的记账,而不是改一行常量:
单独改账本里的 value、不补一条 changes,门禁立刻红(the ratchet ledger is not consistent)。怀疑门禁时照这个法子再破一次,比看它绿不绿有用。
上面那几条破法现在自己会跑。 test/gate-mutations.test.js 把门禁要读的那些文件复制到
.verify-tmp/ 下的临时目录(检出本身一个字节都不动,所以它可以和别的套件并行跑),在副本
上破一道门禁,要求那道门禁自己变红、并且用它自己的话作证(the build is incomplete /
the artifact is stale / does not behave like its baseline / the debt is above the ceiling /
the ratchet ledger is not consistent),再跑一遍没破的副本当对照,
证明红是这次破坏造成的、不是本来就在红。债那条的破法是往副本里塞一个带类型错误的测试文件
(债真的涨了),账本那条是把账本里的上限改大却不记账(门槛被人悄悄抬了)。副本留在仓内是有原因的:verify:host 与
verify:bundle 从 git 和 node_modules 读基准,放在系统临时目录里两道门禁都会因为
「找不到基准」而直接放行——那正是这道测试要抓的空转绿。怀疑门禁时照上面的法子再破一次仍然
有用;只是想确认门禁还活着,让这道测试跑一遍就行。
npm run build 需要构建器(tsdown),它是 devDependency,跑之前先 npm install。
测试与 typecheck 都不需要 lib/:测试直接 import src/** 的源码(Node 原生剥类型),
verify:host 在没有 tsdown 时打印一声响亮的 SKIP 并以 0 退出。
两条关于这道 SKIP 的实话,免得它被当成 CI 的实际路径:
- CI 永远走不到 SKIP:两个 job 都是先
npm install再npm run build,tsdown一定在。 那是给裸检出直接node scripts/verify-host-bundle.mjs的人准备的。 npm run verify也走不到:它自己的build那步就需要tsdown,会先在build失败。 换句话说,这一条不可能靠跳过而"绿着通过"。
verify:bundle 另有一条容易误读的性质,2026-10 起它的行为是:baseline 不再默认取 HEAD
(CI 的 build job 先跑 npm run build,那时 HEAD:lib/client.js 就是工作树产物本身,跟自己比
必然 IDENTICAL,什么也没证明),而是自动回溯历史(上限 80 条),选最新一条 lib/client.js
与工作树不同的提交作为 baseline——「最新的、其产物与我不一样的那个 ref」才是这个问题该有的
问法。固定的 ref(比如 HEAD^)是错答案:lib/client.js 大多数提交根本没碰(本分支上曾连续
24 条提交都相同),任何固定 ref 都可能恰好逐字节相同。回溯无果(80 条内全相同,或浅克隆没有
历史可走)时退回 HEAD,而那是一次失败而非放行:FAIL (vacuous) 并 exit 1,浅克隆分支
还单独报出(修法是给 checkout 加 fetch-depth: 0,CI 的 build job 正是为此设置的;修法恰好
相反——走完全相同是传一个显式 ref,浅克隆是改 checkout,所以分开报)。确需跨大距离对照某份
已发布产物时仍传 --baseline <ref>。
对拍覆盖不到渲染:JSX 被 stub 成 null,所以改了 UI 仍然要在浏览器里看一眼(展开卡片 →
切区域 → 改图像档位 → 保存 → 看错峰倒计时)。
卡片侧测三层,别互相替代
卡片的可编辑逻辑(dirty / save / discard、staged 与 saved、per-region 允许清单、名称
过滤)住在 src/client/controller.ts 的 QoderCardController 里,是个纯类——不 import
React、不碰 DOM、不发 fetch。这不是顺手为之,是为了让测它不需要浏览器:
| 层 | 测什么 | 跑在哪里 |
|---|---|---|
test/controller.test.js(16 条) | 状态机的规则:什么时候算 dirty、拒绝写入怎么呈现、discard 回滚到哪、auto 怎么写最小、过滤为什么只窄化视图 | 裸 node:test,无 jsdom、无 React |
test/card-dom.test.js(9 条) | 产物级渲染接线:QoderPluginCard 真的把快照渲染成了用户看见的 DOM、按钮 disabled 条件对不对 | jsdom,驱动 lib/client.js(出货包) |
| 浏览器手动 | 视觉与交互手感(样式、错峰倒计时动效) | 人类 |
规则在 controller、接线在 card-dom,两者都在 CI 里,谁都不能替谁:只测 controller 会漏掉
「快照没接到 JSX」这种接线错误,只测 card-dom 会把状态机的每条规则塞进 jsdom、慢且脆。改
规则去 test/controller.test.js,改渲染去 test/card-dom.test.js——一条规则同时出现在两个
文件里,通常是抽得不够干净,而不是「保险起见各写一遍」。
useSyncExternalStore 的签名(含「为什么 subscribe / getSnapshot 必须做箭头属性、
getSnapshot 为什么必须引用稳定」)写在 src/client/react-shim.d.ts 里,改 store 接口先看
那段注释。
.npmrc 里的 legacy-peer-deps=true 是必需的、不是随手加的:本包的 peer 依赖是
@deepseek-ai/*,由宿主在运行时提供,不在公共 registry 上,npm 自动安装 peer 会在装到
devDependencies 之前就失败。
本机两份安装形态不同、这是有意的:web profile 是指向本仓库的符号链接(开发态,改完刷新
即生效);desktop profile 走 npm 官方安装,与真实用户同形态,跑的是已发布版。桌面端要试本地
代码:dsh plugin add <本仓库路径> --profile desktop。
(形态定义与"版本号相同 ≠ 内容相同"这条坑见 AGENTS.md「工作目录与装载方式」。)
CI 在 Node 22.19 / 24 × Ubuntu / Windows 上跑(.github/workflows/test.yml)——
Windows 不是冗余:shim 绑定回环监听、目录缓存依赖 rename 覆盖、凭据读取要调
PowerShell,这些在别的平台上行为不同。
测试只用 Node 内置的 node:test,不需要安装任何依赖——也不需要安装 peer 依赖,
这是刻意的:src/host/ 中凡是纯逻辑的部分都放在无 peer 依赖的模块里(见上表),
这样它们才能被直接 import 并断言真实的实现,而不是在测试里手抄一份。
几个文件存在的理由,都是因为曾经出过问题:
test/catalog-fields.test.js与test/model-row.test.js—— 错峰机制曾因promotion.active/promotion.timezone在 Host 投影时被丢掉而全程哑火, 而当时的测试是绿的,因为它测的是自己手抄的副本。同一个毛病在客户端又犯过一次: 卡片自己算窗口时漏看promotion.active,把拿不到的折扣价显示给用户, 而模型选择器按before价计费,两个界面自相矛盾。model-row.test.js现在逐状态对拍两个门控;client-bundle.test.js更进一步, 直接从产物里提取卡片的offPeakState并执行——删掉那行门控会让它变红, 而只会让model-row.test.js保持绿色。副本不是防线。test/credential-cache.test.js—— 「重新登录无需重启」这条卖点的完整链路: 网关拒绝 → 谓词判定 → 置失效标志 → 下次请求重读。此前只有两端被测。也覆盖 env-PAT 兑换的 429 节流闸门:平台自述窗口被 honour、窗口内的 poll 不重探 (兑换计数是证据)、二次拒绝指数翻倍、401/403 停泊仅「重读登录」释放、5xx 不闸门。test/exchange-throttle-e2e.test.js—— 上面那条闸门的端到端版:真实的exchangePat走fetch打到真实 fake platform,「poll 有没有重探」的答案是 fake 自己的请求计数器,而不是插件自述。与姊妹插件的 429 e2e 同一标准。test/throttle-store.test.js—— 节流闸门的磁盘持久化,用真实临时目录跑: 未过期的 rate-limit 窗口跨重启读回、parked 与令牌一律不落盘、过期窗口在读取时 丢弃、原子写不留半截文件。test/credential-invalidation.test.js—— 上面那条链路上的两个纯谓词。test/account-state.test.js—— 账号状态四档判定(src/host/account-state.ts): 全注入的存储读器 + 真实临时目录跑目录存在性检查,钉住「判定只信本地证据」 与「状态记录不含任何凭据材料」两条不变量。test/credential-kind.test.js—— 「这个版本现在是谁在服务」这句话曾经只有卡片能猜: 宿主把桌面端与插件自有两条凭据并列上报,卡片靠source字段反推,而source只在 「桌面端 → PAT」的读取链里产生,插件自有登录那条永远不出现——桌面端登录一过期, 卡片就显示成"没登录"。现在宿主是唯一知道实际解出哪条凭据的一方,它把答案写成credentialKind(desktop/device/env)随账号面板与 status 路由上报, 卡片不再猜。这个文件钉住三档命名、缓存解析前后的取值,以及「宿主还没解析出凭据时 那个键不出现」(出现了就成了另一种撒谎)。test/oscrypt-unwrap.test.js—— OSCrypt 链(src/host/oscrypt-unwrap.ts)曾经 只有一台装了 Qoder 的 Windows 机器能测:解包要真 PowerShell、真 DPAPI、真登录态, 于是「临时目录建好 → 标记写好 → 交接文件读回 → 失败路径」这一段在覆盖率报告里长期 是空的。两个解包入口现在都接受注入的 spawner,测试用假 spawner 写出与真解释器同形的 交接文件,于是这些环节在任何机器上都能跑。这个文件钉住:子进程拿到的 key 就是调用方 拿到的 key、成功与每一条失败路径都不留临时目录(明文主密钥不能活过这一次调用)、 子进程永远看不到用户的 PAT、解释器是绝对路径的系统 PowerShell、非 Windows 主机 拿到的是「为什么读不了」而不是一句「没登录」,以及清零会覆盖字节、且拒绝清零不是 普通文件的目标(越界那条红线)。test/mutation-gates.test.js—— 「这些闸门到底有没有被测住」以前只能靠人读代码: 断言走通路径的测试,删掉闸门照样全绿。这个文件把「改坏 → 看是否变红」变成门禁: 它把src/host/oscrypt-unwrap.ts的副本里的一道闸删掉(不碰检出本身),再跑 真正守着它的那套测试,要求变红,且红在守这道闸的那条断言上。当前钉住 sweep 的 五道闸(不跟随联接、联接碰撞必须上报、身份标记、目录恰好两件、32 字节主密钥)、 年龄闸,以及zeroOutFile的「必须是普通文件」与「必须真的写零」;每次先跑一遍 不删闸的对照,副本必须全绿,否则这套结论就是空的。test/errors-classify.test.js—— 105 与 10605 的优先级决定了「提示用户重新登录」 还是「排队等待」,两者弄反的代价完全不同。test/settings-save.test.js—— DSH 0.2 代上set()会静默成功而不落盘; 这段代码用「写入→读回→深比较」把假成功变成显式失败,测试里直接模拟 「mutate成功但文档没变」的那个场景。也钉住了两件靠肉眼会漏的事: 命名空间只做全等匹配(llm-qoder-extra不算我们的),字段白名单用Object.hasOwn(constructor不是一个字段)。test/pi-model.test.js——toPiModel抽离成纯函数后的直接覆盖:compat.supportsDeveloperRole: false与「不声明maxTokens」这两处, 失效时每个请求都会 403,或长回复被截成finish: max-tokens。test/catalog-store.test.js—— 目录缓存的原子落盘,用真实临时目录跑, 在 CI 所在的平台上实测rename覆盖行为,而不是在注释里假设。test/shim.test.js—— 回环端点的鉴权与/v1/models过滤;对着真实 HTTP 服务器说话,Host 头用裸 socket 发送(fetch禁止设置该头)。test/oscrypt.test.js—— 凭据解密往返;夹具用真实 AES-256-GCM 构造, key 是固定哈希,失败可复现。test/upstream-protocol.test.js—— 编码与签名。两个盲区是原理上不可测的, 已在文件头写明。test/upstream-messages.test.js—— 消息与工具调用翻译,注释里自称 「最重要的一件事」,此前零覆盖。test/docs-markdown.test.js——docs/docs-qoder-cn/*.md是一次性转换的产物:convert-qoder-docs.ps1把抓回的原始资料洗成纯 Markdown,随后原始.txt被删除, 于是「转换是否忠实」在事后无法从内容本身判断,而docs-facts.test.js只管散文断言、 不读这个目录。2026-10 因此放过去两类静默污染:转义顺序写反(先<后&,把刚生成的<二次转成&lt;)让两段「Temporarily hidden … Keep for restoration」的隐藏 注释渲染成可见文本,同一 bug 又把链接里的\&转成\&、弄坏三个订阅链接。 门禁只查转换器真能损坏的形状(双重转义、注释开合、围栏配对、MDX 残留),并带存活守卫 (文件数与最小长度)——否则目录被改名或清空时,循环跑零次、门禁照样全绿。
尚未覆盖的部分集中登记在 docs/KNOWN_GAPS.md,不在各文件里重复叙述——
重复三处正是「手抄副本」那类问题的文档版。
免责声明
仅供个人学习研究使用,仅驱动使用者自己的 Qoder 账号在本机调用。使用者需遵守 Qoder 的服务条款, 因使用本项目产生的后果由使用者自行承担。本项目与 Qoder、DeepSeek 均无关联。
插件除「每日签到」外的所有请求都是只读的;签到是它唯一会对 Qoder 账号产生变更的操作(领取 Qoder 正在向你自己的账号发放的额度),它不会自动执行,每次都要手动点击。是否使用它、以及它是否符合 Qoder 当前的服务条款,请自行判断。
许可证
MIT,全文见 LICENSE。
第三方内容与出处(docs/docs-qoder-cn/ 的 8 份官方文档转换件、上游产品与开发期工具链)逐项见
THIRD_PARTY_NOTICES.md
(同样不随 npm 包发布)。
Plugins associés
dsh-our-free-model
ebony-vinyl/dsh-our-free-model
dsh-routing-suite
yjh051108/dsh-routing-suite
dsh-connect-workbuddy
dingminhua/dsh-connect-workbuddy
dsh-workbuddy-connect
corrinehu/dsh-workbuddy-connect