# Changelog 本文件记录**每次修复背后的宿主契约**。只看 diff 看不出「为什么不能改回去」, 所以把约束与症状一起写下来。 ## 0.9.8 — API Keys 管理区重构(现代控制台形态) 需求:「API 管理换成更现代的管理方式,目前太简陋了」。 ### 旧形态的问题 一条扁平行塞 7 个控件(名字/用量/−/限额/+/状态/钉定/删除);限额只能 ±100 万 步进(调到 4.2 亿要点 420 次);新 key 明文只有一行 code(无复制按钮); 删除一键即中、无确认;创建表单三个空输入框常驻面板;空列表零引导。 ### 新形态(参照 OpenAI / Anthropic / Linear 控制台的通行模式) - **卡片化列表**:每把 key 一张卡 —— 头行(状态点 + 名字 + 启用/钉定 chip + 创建日期)、用量行、操作行,信息分层不再挤压; - **用量进度条**:已用/限额可视化,**≥90% 转危险色**(快用完一眼可见); - **限额编辑**:点「限额:500万」展开预设分段(100万/500万/2000万/不限)+ 自定义输入 + 保存/取消,取代无级步进; - **新 key 强调卡**:成功色描边 + 等宽明文 + 「复制」(clipboard API)+ 「我已保存」关闭 —— 明文只此一面且不常驻; - **两步删除确认**:第一次点只武装(出现实底「确认删除?」),3 秒不确认 自动回退;防误触; - **创建表单折叠**:收进「+ 新建 API Key」按钮;空列表给「生成第一把」引导。 ### 硬约束 | 约束 | 原因 | |---|---| | 删除必须两步确认 | 一键即中的破坏性操作是误触重灾区;确认态 3 秒自动回退防「忘了关」 | | token 明文只在创建响应出现一次(服务端既有契约,不变) | 列表下发明文/掩码 = 泄露或定位失败(0.9.5/0.9.6 两轮教训) | | CRUD body 键名 `id`、失败冒泡错误行(0.9.7 契约,不变) | 服务端按 `body.id` 定位;静默 catch 让按钮「没反应」无从排查 | | 样式只用宿主主题 token(`--dsw-alias-*`) | 浅色/深色主题都要成立;不 import 任何宿主 Client 包 | | 测试的状态化 React 桩必须**重建模块** | 组件闭包绑死构建时的 React —— 复用 FakeReact 版组件时 setState 永远 no-op,两步交互测不出来 | ## 0.9.7 — 双轴审计修复:领取纪律闭环、流式门禁、契约文档对齐 来源:根提交 → 0.9.6 的全量双轴审计(Standards 轴 smell 基线 + Spec 轴官方契约)。 追加:真实报障「已有的 api 无法删除」——0.9.6 删除修复的漏网之鱼(见硬约束表)。 ### Spec 轴(实现与契约相悖,全数修复) - **confirm 回执双向违背(最重)**:`auto-claim` 对任意领取结果(含 1004/1005 失败)都发 `marketing/touch/action confirm`,而手动 `/claim` 成功后**从不**发。 纪律(docs/upstream-protocol.md):**只在 `0`/`1003` 后发** —— 失败时发 = 叫服务端别再投了(白丢机会);成功不发 = 服务端重复投放(每轮白烧 captcha)。 现两侧同口径:auto-claim 按 `ok/code` 门禁;`/claim` 接受可选 `campaignId` (面板从 `/claimable` 探测结果带上),成功后发 confirm; - **auto-claim 不补活跃上报**:tick 直接查投放,缺 `app_launch` + `app_daily_active` ⇒ `preview` 恒空 ⇒ 每日赠送包永远探测不到,自动领取 静默失效(面板/CLI 路径此前已补,唯此路径漏)。tick 现在每账号查投放前 先 `reportActivation`; - **OpenAI 流式缺 finish 门禁**:非流式有 `ended before finish` 检查,流式 `toOpenAiSse` 却在循环结束后无条件发 usage/[DONE] —— 上游半截断开时客户端 把半截输出当完整结果持久化。现在流式收尾未见 `finish` 时发 error 帧、 不发 usage/[DONE](与非流式同口径); - **契约文档与实现脱节**:`docs/local-endpoints.md` 改为分发 key 鉴权口径 (0.9.5 起主 apiKey 已废除,旧文档仍承诺 `openai-api-key` 文件与 `DSH_OPENAI_GATEWAY_API_KEY`);`docs/host-contract.md` 的 `inject = ['llm','tools','timer','webServer']` 更正为三硬依赖 + `webServer` 可选注入(与 index.js 一致);README 补分发 key 能力声明。 ### Standards 轴(smell 基线,判断题择要处理) - **Duplicated Code**:`plan-client` 的 3009 处理(忙标记→退避→钉定报错→换号) 在非 200 与 HTTP-200 两处近乎逐字重复(~25 行×2)→ 提取 `#handleConcurrency`; `anthropic-body` 的 attachment 引用提取两处各写一份 → 共用 `attachmentIdOf` (防两处字段名集合漂移);两适配器的 `imageRequestPricing` 空实现 → 共用 模块级 `imageRequestPricingDefault`(宿主直调契约的注释只留一份); - **Speculative Generality**:删除无调用方的 `/use` 路由(0.7.0 起 expiry 选号、面板无「使用」按钮;改顺序统一走 `/reorder`);删除 `estimatedReadyMs` 兼容字段(同包发布的 client.js 从不读它);清理已废弃的 `requestRestart?` JSDoc;修正 `ZCODE_BILLING_PREVIEW_URL` 的过期注释(0.8.0 双来源合并后 `fetchPreviewPlans` 在用,非纯兼容遗留); - **Middle Man**:`buildRestartCommand` 直传引用(去转发 lambda); - **Data Clumps / 配置漂移**:`Config` schema 的默认值投影改为**直接引用 `DEFAULT_CONFIG`**(此前两处手写字面量,改一处漏一处即漂移); - **不拆项(审计结论的修正)**:用量三元组 `{inputTokens, outputTokens, requests}` 已由 `distribution-keys.js` 单一模块拥有(建/投影/校验/记账同域), 引入新类型属为拆而拆,保留;`client.js` 面板单文件暂保留(拆模块收益不抵 装载结构风险,待下次面板功能改动时顺势处理)。 ### 硬约束 | 约束 | 原因 | |---|---| | confirm 只在 `0`/`1003` 后发 | 两个方向都踩过:乱发白丢投放、不发白烧 captcha | | 查 preview 前必补活跃上报 | 不补则 preview 恒空(官方启动行为实证) | | 流缺 finish 绝不发 usage/[DONE] | 谎报成功会让半截输出被持久化 | | `/claim` 不带 campaignId 仍可领取 | 老面板兼容;confirm 缺 campaignId 时静默跳过(`reportMarketingAction` 对空 campaignId 是 no-op) | | 分发 key CRUD 请求体键名必须是 `id` | 0.9.6 只改了服务端与调用点,`op()` 序列化仍是 `{token: …}` ⇒ body 里没有 `id` 键 ⇒ 服务端 400「需要 id」,**删除/禁用/限额调节全部失效**且 catch 后静默(真实报障「已有的 api 无法删除」) | | 面板 CRUD 失败必须冒泡成错误行 | 静默 catch 让「按钮没反应」无从排查 —— 本次事故的第二半 | | **加载时迁移**:0.9.6 前落盘的 key(无 `id`)必须补稳定 id 并立即落盘固化 | 旧 key 是 0.9.6 之前建的,文件里只有 token —— 投影出 `id: undefined`,JSON 丢键,面板永远删不掉(真实报障「已创建的 id 丢失无法删除」)。不在加载时固化的话,重启会给同一把 key 换新 id,「稳定 id」名存实亡 | ## 0.9.6 — 分发 key 按 id 定位(删除/调节修复)+ 不限额的设限额入口 ### 症状 「删除按钮没用,而且不限额的 API 不应该有增加的选项」。 ### 根因(删除) 列表下发的 token 是**掩码**(`••••mLuw`),前端 `op(key.token, 'delete')` 拿掩码 去删 → 后端按**明文** token 匹配 → 找不到 → 404。也就是说除刚创建的那把外, 任何 key 刷新列表后就永远删不掉了。 ### 修复 - 每条分发 key 增加稳定公开 `id`(`dk_` 前缀,非敏感):列表用它、 update/delete 按 **id** 定位;token 从列表**完全消失**(只创建响应有一次明文); - 不限额的 key:`+` 变为「设限额」(一键设 500 万起点),`−` 仍禁用 —— 「不限」行不再出现无意义的递增按钮。 ### 硬约束 | 约束 | 原因 | |---|---| | 列表永不携带 token(明文或掩码) | 掩码当 id 用 = 定位失败(本条 bug);明文 = 泄露 | | CRUD 定位只用稳定 id | id 非敏感且稳定,token 只服务鉴权 | ## 0.9.5 — 废除主 apiKey;分发 key 限额调节 ### 需求(用户) 「主 key 不需要了」——彻底删除。网关**只认分发 key**。 ### 变更 - **主 apiKey 全链路移除**:`resolveOpenAiApiKey`、`openai-api-key` 文件、 `DSH_OPENAI_GATEWAY_API_KEY` 环境变量、`/openai-gateway?reveal=1` 全部删除; 网关启动不再生成/读取任何主 key。没有分发 key 时网关拒绝所有请求(401); - **面板精简**:网关区只剩状态行 + 开关(去 apiKey 显隐与「已开启(重启后生效)」 文案);分发 key 区的说明压缩成一句; - **限额 −/+ 调节**:每条分发 key 行带 `−` / `+` 按钮(步进 100 万 tokens), 点完即写(update op),不用弹输入框;「不限」的 key `−` 禁用(先设限额)。 ### 硬约束 | 约束 | 原因 | |---|---| | 网关只认分发 key | 单一鉴权路径;主 key 是绕过限额的后门 | | 用量记账在生成器内部旁路观察 | 并发消费同一生成器会互相抢帧(实测:记账协程吃掉 head 的 reasoning 帧) | ## 0.9.4 — 分发 key:多 key、限额、钉定账号、用量计量 ### 需求(用户) 「新增分发 key:可以生成多个 key、设置限额、账号、额度用尽是否切换」。 用户决策:**额度用尽即拒绝(不切换)**;账号**可选绑定**(不绑 = 用整个池)。 ### 模型 主 apiKey 之外可生成任意多个分发 key(`zsp-` 前缀 + 22 位 base64url): | 字段 | 语义 | |---|---| | `name` | 给谁的(人类标识) | | `quotaTotal` | token 限额;null = 不限 | | `accountId` | 可选钉定:只烧该账号(plan-client 的 pinned 语义,绝不换号);不绑 = 池内 expiry 选号 | | `enabled` | 禁用立即 401(用量保留) | | `usage` | 累计 input/output tokens 与请求数 | **额度用尽即 429**(`quota_exceeded`)——给外人用时隔离最强,不怕烧超(用户决策)。 ### 实现 - `lib/distribution-keys.js`:key 表持久化(`distribution-keys.json`,变更即写)、 限额判定、脱敏展示(token 尾 4 位); - openai-gateway 两级鉴权:主 key 或分发 key;分发 key 命中后按限额/禁用判定, 请求从 chunk 流收 `usage` 记账(**只有产出才计**——被拒/上游失败不消耗), 回写节流 1s 落盘(每次请求写 JSON 会把它变成热点); - `POST /distribution-keys?op=create|update|delete` + `GET`(脱敏列表): **token 明文只在创建响应出现一次**(服务端保存的是可复现形态,但展示纪律 与主 key 一致:丢了删了重建);`accountId` 创建时校验在池内; - 面板「分发 Key」区:生成(备注/限额/钉定账号下拉)、逐条用量与剩余、 禁用/启用/删除。 ### 硬约束 | 约束 | 原因 | |---|---| | 额度用尽不切换主额度 | 用户决策:给外人用时隔离最强 | | 分发 key 钉定账号绝不换号 | 「钉定」语义(0.3.0 起):换号让它名存实亡 | | token 明文只出现一次 | 与主 key 同一展示纪律;列表永久是脱敏形态 | | 用量记账只计有产出的请求 | 被拒/上游失败没有消耗,计了就是虚扣 | | accountId 创建时校验在池内 | 钉到不存在的账号 = 该 key 的请求全 404 | ## 0.9.3 — 面板:池子总额度置顶 + OpenAI 端点配置区 ### 需求(用户) 「写一个总额度在顶上,以及转发 OpenAI API 功能自定义面板未加入」。 ### 变更 **① 总额度行(面板置顶)** - `TotalQuota` 组件:把各账号**已加载**的余额桶求和,按到期日分层显示 (同一天到期的合并成一条进度条,快到期的排前); - 标注 `N/M 个账号已加载`——余额是懒加载的,没加载完的账号不计入(诚实显示); - `formatTokens`:token 数紧凑显示(3.9亿 / 500万 / 8400)。 **② OpenAI 兼容端点配置区** - 面板新区块:状态点 + 「OpenAI 兼容端点」+ 运行状态(`baseUrl` 或「重启后生效」) + 拨盘开关 + apiKey 显隐(常态掩码 `••••后4位`,点击向宿主要全量); - `GET/POST /openai-gateway`:状态读运行时句柄;开关写 `settings.json` 的 `openaiGatewayEnabled`(config 显式 true 时始终开)——**重启宿主生效**, UI 写明;apiKey **绝不进 summary**(只在显式 reveal 的那次请求下发); - summary 新增 `openAiGateway` 状态块(running / enabledInSettings / baseUrl / port)。 ### 硬约束 | 约束 | 原因 | |---|---| | apiKey 不进 summary、常态掩码 | summary 是面板首屏必拉的;apiKey 泄进每次请求 = 泄进日志与截图 | | 开关不热切换 | HTTP server 热启停牵扯端口占用;「重启生效」写在 UI 上 | | 未加载账号不计入总额 | 拿「加载中」当 0 汇总是撒谎 | ## 0.9.2 — 领取面板「一视同仁」:已领账号也显示活动 ### 症状 用户:Venshell 刚领取后才显示活动,别的号不显示,理论上一视同仁。 ### 根因(2026-10-31 实测) 服务端行为:**领取成功后 `marketing/touch` 不再给该账号投放**(`deliveries` 变空)。 | 账号 | 投放 | 领取记录(`billing/balance` 的 `plans[]`) | |---|---|---| | 森也 / Venshell / baqkh937 | 0(已领过) | 有 `zcode-v3-start-plan-1030-wk (active)` | | Ye Send | 1(投放中) | 无 | ⇒ 只看投放:已领账号显示「无可领」,未领的显示「可领」——参差不齐, 而领取记录在 `balance.plans[]` 里明明可查。 ### 修复 - `fetchClaimedPlanIds`:读 `billing/balance` 的 `plans[]`(失败返回空集合不抛); - `/claimable` 合并两个来源:投放中的标可领;无投放但记录里有的(限 `zcode-v3-start-plan-` 前缀)标 `claimed: true`; - 面板:每账号显示全部已知活动,已领的显示「已领取」且按钮禁用 (按钮只对真正可领的条目生效)。 ### 硬约束 | 约束 | 原因 | |---|---| | 已领活动必须可见(标「已领取」) | 「一视同仁」:参差不齐的列表让用户以为其它账号坏了 | | 领取记录前缀过滤 | `balance.plans[]` 含非活动 plan(如 0817 常驻包),全部回填会把面板变成账单页 | ## 0.9.0 — 本地 OpenAI 兼容端点(`/v1/chat/completions`) ### 需求 「支持 openai 格式吗?」——此前**不支持**:本地只有 Anthropic 格式的 `/v1/messages`。 现在新增 `lib/openai-gateway.js`,与它**并列**(可同时开启),共用同一个 `planClient.call()`(账号池、换号、captcha、限流全部复用)。 ### 用法 ```yaml # cordis.patch.yml openaiGateway: enabled: true port: 17900 ``` ```bash curl http://127.0.0.1:17900/v1/chat/completions \ -H "Authorization: Bearer <~/.dsh/zcode-start-plan/openai-api-key 的内容>" \ -H "Content-Type: application/json" \ -d '{"model":"GLM-5.3-Flash","stream":true,"messages":[{"role":"user","content":"hi"}]}' ``` 任何 OpenAI 兼容客户端(Codex / Cline / Continue)填 `baseURL = http://127.0.0.1:17900/v1` + 该 key 即可。端点:`GET /v1/models`、`POST /v1/chat/completions`、`GET /health`(免 key)。 ### 对齐参考实现(deepseek-harness-codearts 的 `openai-gateway/`) | 坑 | 症状 | 对策 | |---|---|---| | `typeof x === 'string'` vs `!== undefined` | 真实 SSE 里 `content` 与 `reasoning_content` **另一侧恒为 null** ⇒ 首帧 `Cannot read properties of null` | 一律 `typeof` 判断 | | 孤儿 `tool` 结果 | 上游 400,而坏历史每轮原样重放 ⇒ **会话永久报废** | 发请求前剔除配对已坏的结果 | | 同 id 既有好又有坏调用 | 只看 dropped 会删掉好调用的回执 ⇒ 留下孤儿 tool-call | 剔除条件 `dropped && !kept` | | 分片带空 `function.name` | 清空已解析工具名 ⇒ `unknown tool ""` | 只接受非空覆盖 | | 空 assistant(调用被剔光) | 变 wire 上的空开头发言 ⇒ 上游 `model_param_invalid` | 整条剔除;**正文非空的保留** | | 上游 `{status:200}` 表业务失败 | 「额度用尽」被客户端读成**成功** | 状态码门禁 400–599 | | 流缺 `finish` | 半截输出被当完整结果持久化 | 报错而非假装成功 | **实现期自己踩到并修掉的 bug**:`result.decoder` 是 `{ read(): Promise<{done, value?}> }`(plan-client.js 的返回类型注释),**不是**异步 生成器。误当生成器 `for await` 消费 → `read is not a function` → 被 catch 兜住后只发 一帧 error chunk,表现为「流式只有一个 reasoning 帧就断了」。已加回归测试用**真实 decoder 形状**驱动整条 HTTP 路径。 ### apiKey 策略 - 默认持久化在 `dataDir/openai-api-key`,形态固定 `randomBytes(32).toString('base64url')` (定长 43 位 `[A-Za-z0-9_-]`); - 支持 `DSH_OPENAI_GATEWAY_API_KEY` 环境变量(优先); - ⚠ **文件内容异常时报错而不是静默换 key** —— 静默换会让所有已配置客户端同时 401, 而用户只看到 "unauthorized",无从判断是自己粘错了哪一位还是服务端变了。 ### 硬约束 | 约束 | 原因 | |---|---| | 日志与错误里永不打印 apiKey | 它会被贴进配置文件和 bug 报告 | | `/health` 不校验 key | 探活工具不该持有凭据 | | `tool_choice` 只支持 auto / none | 其余取值 DSH 侧表达不出来,静默降级会让客户端误以为约束生效 | | 工具名判据只有一份(trim 后非空) | 预扫与主转换各写一遍会静默漂移 | | 工具 id 判据**不 trim** | id 是不透明标识符,trim 会让配对找错对象 | ## 0.8.0 — 领取链路对齐参考实现(版本门控 + 双来源 + 转发头逐字对齐) ### 来源(用户指路) 参考 [deepseek-harness-codearts](https://gitee.com/iJetLi/deepseek-harness-codearts) 的 zcode 领取实现(`zcode-upstream.ts` / `zcode-auth.ts`,全部为真机实测结论)。 ### 变更 **① 版本门控(最关键 —— 之前「有活动也领不到」)** `marketing/touch` 与 `billing/claim` 两端点的 `X-ZCode-App-Version` 必须抬到 ≥ **3.16.3**(`zcodeVersionGatedAppVersion`:凭据版本与门控值取较大者): | 版本 | 实测(参考实现,真机) | |---|---| | `3.14.3` | 投放恒空;claim `400 / 1004 no permission`(文案「当前账号或**客户端版本**不满足领取条件」,极易误判成账号没资格) | | `3.16.2` | claim `200 / code 0` | 推理路径**不抬**(`3012` 消融矩阵在 3.14.3 取的,无证据不扩大)。脏 `app_version` (含换行注入)被 semver 白名单挡住 —— 它原样进 `User-Agent`/`Headers()`。 **② 双来源合并** `fetchClaimablePlans` = preview(每日赠送包)+ marketing(1024 这类活动**只在投放里**) 按 planId 合并:preview 优先、只补 marketing 的 `campaignId`(领完要回执);都按 priority 降序。 **③ 营销投送解析加固** - plan 元数据在投送物**镜像多处**(banner.background.args、success_popup.hero.args), 递归扫描(深度上限 8)取代写死按钮路径; - `grant_units` **求和**(1024 活动两条 entitlement:3 亿 Flash + 2 亿 5.3, 只取第一条会少报 2 亿);不同 unit_type 不混加; - popup / banner.success_popup 的按钮也扫(投放 schema 有三种位置)。 **④ confirm 回执门控** `reportMarketingAction('confirm')` 只在领取成功(`0`/`1003`)后发 —— 不回报服务端 会**重复投放同一 campaign**,每轮白烧 captcha;失败时绝不发(等于叫服务端别再投了)。 **⑤ API 转发头逐字对齐官方抓包(18 头)** | 修正 | 旧 → 新 | |---|---| | `X-Release-Channel` | `stable` → `production` | | `X-Platform` | `win32` → `win32-x64` | | 新增 | `X-Title` / `X-Os-Version`(os.release 真值)/ `X-ZCode-Agent` / `X-Session-Id`(每账号稳定)/ `X-ZCode-Session-Type` / `X-Request-Id`(每请求)/ `X-ZCode-Trace-Id`(每请求) | `claimPlan` 的业务码解析认**字符串数字**(`{"code":"3012"}` 此前被 `num()` 吃成 undefined,风控码整条丢失)。 ### 硬约束 | 约束 | 原因 | |---|---| | marketing/claim 必须抬版本 ≥3.16.3,推理必须不抬 | 两侧各有实测依据,混用两头都坏 | | 凭据里的 `app_version` 永不被改写 | 门控只影响请求头 | | 会话 id 每账号一个(不能全局常量) | 全局 id 会把多账号轮换关联成「同一会话」 | | confirm 只在 0/1003 后发 | 失败时回报 = 白丢投放 | | 投送物递归扫描有深度上限 | 服务端可控的任意 JSON,递归必须有界 | ## 0.7.1 — 移除账号卡片高亮(聚焦框) expiry 选号(0.7.0)下「下一个用的账号」是动态计算且随每次余额刷新变化, 高亮框反而误导(看起来像手动置顶的残留)。按用户决策移除: - 卡片 `data-next` 边框高亮、`isNext` prop、`nextAccount` 计算、对应 CSS 一并删除; - 卡片视觉恢复中性,账号顺序即面板顺序。 ## 0.7.0 — 统一选号默认「快过期先用」;移除「使用」按钮 ### 变更(用户决策) 「使用」按钮的置顶(手动顺序)被证实对钉定会话无效(0.6.9 排查),且手动排序 本身容易让快过期的额度白白作废。用户决策:**统一选号默认按到期时间取号**, 并移除失去意义的按钮。 ### 行为 - **`config.selectionOrder`(新增)**: - `'expiry'`(默认):**快过期的先用** —— `pool.pick` 按 `/balance` 回填的 最早有余额桶到期时间升序取号(`pool.noteExpiry` 进程内缓存,不落库); 无到期数据的账号排最后但仍参与;全部无数据时按手动顺序兜底; - `'manual'`:0.6.x 旧行为(面板置顶优先),仍可配置切回。 - **面板移除「使用」按钮**:expiry 模式下置顶不决定顺序,按钮没有意义。 卡片高亮(`data-next`)保留 —— 它现在显示的是「下一次快到期的账号」。 - 钉定栏位(`-acct-xxx`)不受影响:固定消耗指定账号是设计契约。 ### 硬约束 | 约束 | 原因 | |---|---| | 到期缓存只存进程内存 | 余额是上游现拉数据,落库 = 过期快照误导选号 | | 无到期数据的账号排最后但不跳过 | 缺数据 ≠ 不可用;跳过会白白浪费有额度的账号 | | 两种策略互斥,非法值回落 expiry | 「快过期先用」是默认契约,静默回落安全 | ## 0.6.9 — 「使用」按钮补作用域说明(排查「点了没作用」) ### 排查结论(功能本身是好的) 用户反馈「聚焦框默认在第一个账号、使用按钮没实际作用」。全链路实测: | 环节 | 实测结果 | |---|---| | `POST /use` | 真实调用成功,SQLite `added_at` 顺序立即变化 | | summary 顺序 | 立即跟随(面板卡片顺序 = 新顺序) | | 前端高亮 | 首位 `data-next=true` + 「使用中」,其余显示「使用」(假 React 渲染验证) | | `pool.pick` | 按 `added_at` 升序取第一个启用账号 —— 与置顶一致 | **真正的原因**:会话用的是模型选择器里的**钉定栏位**(`zcode-start-plan-acct-xxx` / `@acct:` 后缀)。钉定模式固定消耗指定账号的额度、绝不换号 —— 这是 0.3.0 起的 设计契约(用户选了「GLM · 主号」就该吃主号)。置顶只影响**统一选号**栏位。 ### 修复(0.6.9) - 「使用」按钮加 `title` 提示:「下次统一选号优先用这个账号(钉定栏位不受影响)」; - 源码注释写明作用域;新增契约测试锁住该提示。 ### 硬约束 | 约束 | 原因 | |---|---| | 钉定栏位永不因置顶换号 | 「钉定」的语义就是固定消耗;换号会让它名存实亡 | | 「使用」按钮必须说明作用域 | 否则用户在钉定会话里验证置顶 → 必然得出「没作用」 | ## 0.6.8 — 额度桶分开显示各自的到期时间 ### 症状 账号有多个额度桶(不同活动领取的包到期时间不同)时,面板只画一条汇总进度条, 到期时间只显示**最早的**那个 —— 其余桶的到期信息被吞掉。 ### 修复(0.6.8) - `fetchBalance`:`buckets` 按「有余额优先 + 到期时间升序」排序(空桶沉底), 每桶自带 `remainingUnits/totalUnits/expiresAt`; - `/balance` 逐桶下发 `{planId, showName, remaining, total, expiresAt}`; - 面板:**多桶 = 每桶一条进度条**(桶名 + 自己的剩余/总量 + 自己的到期时间); 单桶保持旧汇总条,视图不膨胀。 ### 硬约束 | 约束 | 原因 | |---|---| | 汇总条只该在单桶时使用 | 多桶共用一条 = 除最早外全部到期信息丢失 | | 空桶沉底但不删 | 用户要知道「哪些包用光了」 | ## 0.6.7 — 面板「账号列表消失」:summary 不再被余额拉取阻塞 ### 症状 打开插件面板长时间停在「加载中…」,账号列表迟迟不出现(看起来像消失了); 但对话照常可用 —— 数据都在,只是被阻塞的请求挡住。 ### 根因 `/summary` 在返回前用 `Promise.all` **并行拉全部账号的余额**:fetchBalance 每账号超时 30s,上游慢/不可用时(两个账号同时 30s 超时)summary 要 60s+ 才吐。 账号数据本身在 SQLite 里毫秒可读,却和慢速网络请求绑在同一个响应里。 ### 修复(0.6.7) - **summary 只读库、立即返回**:`balance: null` 语义从「拉取失败」改为 「未拉取」;整个响应毫秒级。 - **客户端懒加载**:对 `balance: null` 的账号**逐个** GET `/balance?id=` 补拉 (此前 api() 助手是 POST body,而该路由读 query —— 顺手修正为直接 fetch GET)。 单账号慢/失败只影响自己那张卡的余额行,账号列表秒出。 - 附带澄清:另一处「账号栏位消失」是面板卡片右侧开关的**隐藏功能** (settings.json 的 `hiddenModelLists`),数据并无丢失 —— 关掉开关 = 模型选择器 里隐藏该账号栏位 = 空目录。 ### 硬约束 | 约束 | 原因 | |---|---| | summary 不得发起任何上游请求 | 它是面板首屏;被网络拖住 = 面板「消失」 | | `balance: null` = 未拉取,客户端必须懒加载补 | 旧语义「失败」会让客户端放弃补拉 | ## 0.6.6 — 彻底放弃自动重启:被火绒判毒是正确行为 ### 症状 `scripts/restart-dsh.ps1` 被**火绒报毒**。同时按钮点了依然没反应(日志缺失或 停在半途)。 ### 根因(两层,都实测确认) **安全层(报毒是对的)**:为了让重启在宿主死后还能继续,脚本先后叠加了 `Start-Process`、`cmd /c start`、**计划任务**(脱离 Job Object 的唯一可靠手段)、 `-ExecutionPolicy Bypass`、隐藏窗口、往临时目录写 .cmd 再执行、强制结束进程 —— 这组特征与恶意软件的持久化/提权手法**高度重合**。AV 拦它不是误报。 **技术层(本来就走不通)**:脚本作为宿主子进程落在宿主的 **Job Object** 里 (Electron 建进程树时创建,`KillOnJobClose`),宿主一死脚本被杀 —— 日志反复停在 `forcing pid N` 之后没有下文,就是这个。能逃出 job 的手段 (计划任务)恰是 AV 最敏感的那类:**技术上可行的方案 = 安全上不可接受的方案**。 排查中还踩掉三个坑(均已记录在案): 脚本无 BOM 时 PowerShell 5.1 按 GBK 解码 → 解析失败且**零日志**; `$pid` 是只读自动变量(赋值即抛错);`ProcessStartInfo.ArgumentList` 在 .NET Framework 里不存在(5.1 崩)。 ### 修复(0.6.6):改为「只给指引,不执行」 - **插件不再 spawn 任何进程**。`/restart-cdp` 改为返回用户可直接复制的启动命令: `{mode, command, port, note}`;`debugPort` 缺省 = 普通重启(不带端口)。 - **`scripts/restart-dsh.ps1` 重写为无害的指引生成器**:只读定位宿主 exe (从运行中进程读路径 / 查常见安装位置,不结束不修改任何进程)、打印启动命令、 可选把命令写到插件 dataDir。无计划任务、无隐藏窗口、无临时脚本、无执行策略绕过。 - **面板按钮改为「获取指引」**:点击展示命令块(等宽、`user-select:all` 点一下全选), 由用户自己退出并重开 DSH。 - **新增安全契约测试**(`tests/restart-script.test.mjs`):禁止 结束进程 / 计划任务 / 隐藏窗口 / 编码命令回到脚本里 —— 每条对应一个真实误判特征。 ### 硬约束 | 约束 | 原因 | |---|---| | **插件永远不杀宿主进程** | AV 判毒是正确行为;技术上 Job Object 也会让脚本必死 | | 重启指引只含「退出后手动执行一条命令」 | 与官方 `app.relaunch()` 等效,由用户完成 | | 脚本必须带 UTF-8 BOM | 无 BOM 时 5.1 按 GBK 解码中文注释 → 解析失败且零日志 | | 测试锁死「无结束进程/计划任务/隐藏窗口」 | 防止将来有人为绕 Job 把高危手段加回来 | ## 0.6.4 — 「重载插件」不等于「开调试端口」;拨盘左文右钮 ### 症状 - 面板把重启按钮写成 **「以调试模式重启」**,而且只在 captcha 不可用时出现 —— 暗示「普通模式不能重启」。**这是错的**:装了/更新了插件,任何模式下重启都生效, 与 `--remote-debugging-port` 毫不相干;调试端口只是**领 captcha 时的额外需求**。 - 服务端 `POST /restart-cdp` 无条件 `debugPort ?? 9229`,等于用户只想重载插件 也被偷偷开了调试端口。 - 「自动领取」拨盘是**开关在左、说明在右**,与上面那排「标签 … 按钮」的右对齐列 错位,视觉上是歪的。 ### 修复(0.6.4) - **面板区分两种重启**(都走两段式确认): | captcha 状态 | 按钮 | 实际行为 | |---|---|---| | 可达 | `重启` | 普通重启:重载插件,**不带**端口 | | 不可达 | `开调试端口重启` | 带 `--remote-debugging-port` 重启 | 两者都会变成 `确认重启?会中断任务` 才执行。 - **`restart('plain')` / `restart('debug')`**:`plain` 不传 `debugPort`; `debug` 才传 `captcha.debugPort`。 - **服务端语义修正**:`debugPort` 缺省 = 普通重启(不再 `?? 9229`); 显式传才开 CDP,非法值仍 400。响应里普通重启不下发 `debugPort`。 - **脚本支持普通重启**:`-DebugPort 0`(或不传)= 不带端口。 普通重启**不等端口、不验 CDP**(没端口可等),改为确认主进程重新起来; `Start-Host` 在 `Port <= 0` 时还会**剥掉原 argv 里已有的** `--remote-debugging-port`(用户此刻不想要端口)。 - **拨盘左文右钮**:说明文案在左(`flex:1`),开关在右(`.zsp-toggle` 自带 `margin-left:auto`),与上方按钮列同一条右对齐线。 ### 硬约束 | 约束 | 原因 | |---|---| | 「重载插件」不得要求调试模式 | 两者无关;要求调试模式会让用户误以为必须开端口 | | `debugPort` 缺省 ≠ 9229 | 缺省必须表示「普通重启」,否则等于偷偷开调试端口 | | 普通重启不等端口 | 0 号端口等 15 秒纯属白等 | | 拨盘开关放右 | 与上方按钮列共用右对齐线,放左会错位 | ## 0.6.3 — 修好重启脚本(对照 DSH 官方 app.relaunch 语义)+ 重启二次确认 ### 症状 面板「以调试模式重启」永远失败:宿主被杀掉又原样起来(不带 CDP 端口), `captcha.reachable` 仍是 false;有时甚至把宿主搞成半死状态。 ### 根因(实测进程树 + app.asar 反编译,不是推测) **官方重启长什么样**(`D:\software\dsh\resources\app.asar`): ```js // 恢复面板的 restart / 菜单项 restart: () => { app.relaunch(); quitWithoutConfirmation(); } // quitWithoutConfirmation 仅置 skipQuitConfirmation = true 后 app.quit() ``` 即:**优雅退出 + 原样重放 argv**。我们没法在插件里调 `app.relaunch()` (宿主未把该能力暴露给插件),只能在进程外等效实现 —— 而旧脚本三处都错了: | # | 旧实现 | 后果 | |---|---|---| | ① | 主进程判定 `CommandLine -notmatch '--type='` | 把 `DeepSeek Harness.exe --expose-internals … dsh-desktop-host\lib\index.js` 这个 **host 子进程**和 `runner.js` 一起当成主进程;杀完 UI 主进程还在 | | ② | 杀完立刻 `Start-Process` | 宿主有 `claimDesktopSingleInstance()` / `requestSingleInstanceLock()`:**第二个实例只会触发 second-instance(聚焦已有窗口)然后自己 quit(),命令行参数完全不生效** → `--remote-debugging-port` 永远带不上 | | ③ | `-Force` 秒杀,不确认进程真的消失 | 单实例锁可能仍在,拉起必被吞 | 实测进程树(本机):主进程 `15828`(命令行只有 exe)、host `28128` (`--expose-internals`)、runner `13776/34120`、Chromium 子进程 (`--type=gpu-process|renderer|utility`)。旧规则会把 `28128/13776` 误判为主进程。 ### 修复(0.6.3) - **`scripts/restart-dsh.ps1` 按 app.relaunch 语义重写**: 1. `Test-IsMainProcess`:排除 `--type=` / `dsh-desktop-host` / `runner.js` / `--expose-internals`;多候选时取「不是任何候选子进程」的根(实测唯一命中 `15828`); 2. `Stop-MainProcessGracefully`:先 `CloseMainWindow()`(= 用户点 X,走 before-quit), 超时才 `-Force`,**并且无论哪条路径都等到 PID 从进程表消失**; 3. 清理残留子进程 → `Wait-PortFree` 确认端口真空出来; 4. `Start-Host`:**原 argv + 追加 `--remote-debugging-port`**(等效 relaunch 重放), 已有的不重复加; 5. `Test-Cdp` 45s 轮询验证;失败则**不带端口**重新拉起(不让用户卡在「应用没了」), 并以退出码 2 报告。 - **重启二次确认(面板 + 服务端)**: - 面板两段式:`以调试模式重启` → `确认重启?会中断任务`(`data-danger` 红色), 成功后提示「约 N 秒后可用;**所有正在运行的任务都会中断**」; - 服务端 `POST /restart-cdp` **要求 `body.confirm === true`**,否则 400 并回 `{requiresConfirm: true, consequence: 'kills-host-and-interrupts-tasks'}` —— 脚本/旧页面/误触都不该悄悄把宿主干掉; - `waitSeconds` 默认从 3 改为 2(只等响应写完,不再赌会话空闲)。 ### 硬约束 | 约束 | 原因 | |---|---| | 主进程判定必须排除 `--expose-internals` 与 `runner.js` | 否则杀错进程,单实例锁不释放 | | 拉起前必须**等 PID 消失 + 端口空出** | 单实例锁会让带参数的新实例静默 quit | | 新实例必须**重放原 argv** | 等效 `app.relaunch()`;只给 exe 会丢掉用户原有启动参数 | | 重启端点必须要求 `confirm: true` | 它会中断所有任务,代价必须显式确认 | ## 0.6.2 — 移除手工输入 plan_id 的组件 ### 语义 领取面板经历了三个版本,这是收敛后的最终形态: | 版本 | 形态 | 为什么不行 | |---|---|---| | 0.5.3 及以前 | 查 preview 列表 → 有才显示领取按钮 | preview 恒空,按钮永不出现 | | 0.5.4 | 用户手抄 plan_id 填输入框 | **用户无从得知 plan_id**;抄错/过期 → 白烧一次 captcha | | 0.6.0 | 探测按钮自动填输入框 | 半自动:填进去的是「可被改坏」的可变状态 | | **0.6.2** | **展开即自动探测 → 可领取时直接给「领取」按钮** | — | ### 修复(0.6.2) - **删掉 plan_id 输入框**(`client.js` ClaimSection):plan_id 改由探测结果 `state[id].plans[0].planId` 直接驱动,**只读展示**(`.zsp-plan-id`,等宽、 可选复制)。没有手输路径 = 不存在「填了个过期 id 白烧 captcha」。 - **删掉「探测」按钮**:探测在展开时由 `useEffect` 自动跑(零成本 GET), 按钮只是让人多点一次。无活动时「领取」按钮禁用并给出原因。 - 多个活动时展示「等 N 个活动」提示,实际领取 priority 最高的那个。 - 删除遗留的临时探针脚本 `scripts/tmp-preview-probe.mjs`(读真实账号库调试用, 从未入库)。 ### 硬约束 | 约束 | 原因 | |---|---| | **不恢复 plan_id 输入框** | 手输只会填错/填过期;探测零成本且权威 | | plan_id 只读 | 它来自服务端裁决,不该是用户可编辑的本地状态 | ## 0.6.1 — 自动领取改为面板开关;探测始终自动 ### 语义收敛(用户明确要求) 0.6.0 把自动领取做成 `config.autoClaim.enabled`——只能改配置文件 + 重启。 但这两件事的**成本完全不同**,不该共用一个开关: | | 探测(发现 plan_id) | 领取(花掉活动资格) | |---|---|---| | 网络成本 | 一次 GET | 一次 POST | | 稀缺资源 | **零**(不烧 captcha) | **一次设备 captcha** + 占用活动名额 | | 是否该自动 | 是 | 用户说了算 | 于是:**探测永远自动**(面板展开即探测、CLI `claim` 直接探测), **领取是可开关项**。 ### 修复(0.6.1) - **面板「自动领取」拨盘**:ClaimSection 顶部常驻,开/关即时生效,位置写入 `settings.json`(重启后保持)。开启时显示「已记录 N 个活动,不会重复领」。 - **控制器常驻**(`lib/assemble.js`):`createAutoClaim` 不再只在 config 开启时 创建;`ctx.timer.interval` 无条件挂载,`enabled=false` 时 tick 空转 —— **拨盘切换不需要重启宿主**。这是本次的关键点:0.6.0 的「开启才注册」会让 面板拨盘必须重启才生效。 - **初值优先级**:`settings.json`(拨盘最后位置)> `config.autoClaim.enabled`。 配置文件仍是「首次启动的默认值」,面板拨动后以面板为准。 - **面板展开自动探测**:`useEffect` 在展开时对每个账号探测一次(已探测的跳过), 用户不用再点「探测」按钮。 - **新增 `POST /api/zcode-start-plan/auto-claim {enabled}`**;`/summary` 下发 `autoClaim: {enabled, seenCount, lastRunAt, lastError}`。 - `Toggle` 增加可选 `label`:拨盘的 aria-label 不能沿用「在模型列表中显示这一栏」。 ### 硬约束 | 约束 | 原因 | |---|---| | 自动领取轮询**必须常驻注册**,不能「开启时才注册」 | 否则拨盘要重启宿主才生效 | | `tick()` 首行判断 `enabled` | 关着时空转零成本,且绝不代替用户花资格 | | 拨盘位置落 `settings.json` 而非 config | config 改动要重启;面板开关必须即时 | ## 0.6.0 — 活动 plan_id 自主探测 + 可选自动领取 ### 症状 0.5.4 把领取改成「填 planId 直接 claim」后,用户**无法得知 plan_id**—— 它只存在于 ZCode 官方客户端活动页里,面板与 CLI 都没有获取途径。 ### 根因(ZCode 客户端 3.16.3 反编译实证,不是推测) 活动发现的真实链路是**营销投放**,与 `billing/preview` 完全无关: ``` GET /api/v1/marketing/touch?seq= ← 官方 createMarketingTouchService.query(),约 10 分钟一次 → {code:0, data:{server_time, deliveries:[{ campaign_id, priority, resource_position: "banner"|"popup", banner|popup:{ buttons:[{ text:{content}, action:{type:"claim_zcode_plan", args:{plan_id}} }] } }]}} GET /api/v1/marketing/touch/action(POST {campaign_id, action_type}) ← 点击统计,纯上报 POST /api/v1/zcode-plan/billing/claim {plan_id} + captcha 头 ← 真正领取 ``` `plan_id` 就写在投放按钮的 `claim_zcode_plan` action 里——**官方客户端从来不需要 用户抄 plan_id**,它按按钮点击。`/billing/preview` 全天 0 次调用,恒空。 ### 修复(0.6.0) - **`fetchClaimablePlans` 改走 `marketing/touch`**(`lib/upstream-api.js`): 从投放按钮提取 `{planId, campaignId, priority, name}`,同 planId 去重取高 priority,priority 降序。⚠ 请求头必须带 `X-Client-Language: zh-CN`(服务端按 语言过滤投放);**GET、零 captcha**——发现活动不烧任何稀缺资源。解析失败 一律返回 `[]`(投放抖动不该让领取流程崩掉)。 - **面板「探测」按钮**:每账号一行「探测」→ 自动填最高优先级的 plan_id, 其余显示在提示里;探测结果与领取状态分列展示。 - **`config.autoClaim`(默认关)自动领取**(`lib/auto-claim.js`): 定时(默认 30 分钟,下限 5 分钟)扫全部启用账号的投放 → 发现**未见过**的 planId 才 mint captcha 领取。`0`(成功)/`1003`(已领过)/`1005`(领完) 三种终态码记入 `seen` 并持久化到 `dataDir/auto-claim-state.json`; captcha 失败与网络异常**不记**(下轮重试,与官方「原地重试消化 CDN 抖动」 同策略)。⚠ 为什么必须记集合:投放会持续存在数天,不记的话每 30 分钟就 白 mint 一次 captcha 吃一次 1003。官方点击后还会上报 `marketing/touch/action confirm`(纯统计,已照做)。 - **`/claimable` 语义修正**:保留路由(面板探测用),返回 `{plans:[{planId, campaignId, priority, name}]}`;不再有「preview 恒空」的死路。 - **CLI `claim`**:直接受益于新发现端点——现在能自主找到活动并领取, 不再依赖「活动页手抄」。 ### 硬约束(不要改回去) | 约束 | 原因 | |---|---| | 发现必须走 `marketing/touch`,不许回 `billing/preview` | preview 恒空(官方 0 次调用) | | 请求头必须带 `X-Client-Language: zh-CN` | 服务端按语言过滤投放,缺省可能空 deliveries | | 终态码 `{0,1003,1005}` 之外不记 seen | captcha 稀缺;1003/1005 是「无蜡可蜡」,重试无意义 | | autoClaim 下限 5 分钟 | 官方投放刷新约 10 分钟;更频繁只有白扫 | ## 0.5.5 — 3009 并发限流:忙标记让统一选号绕开忙账号 ### 症状 一个账号触发 `3009`(并发限流)后,**统一选号路由**(不带账号后缀)发起的后续请求 仍然先撞这个忙账号:本请求内只对同一账号退避重试(1.5s/3s,共 4.5s),穷尽后直接抛 `PROVIDER_ERROR`——哪怕池里其它账号完全空闲;跨请求也不留任何痕迹,下一个请求再从 忙账号开始,白吃一轮退避。并行开多个会话时,所有请求串行叠加在同忙账号上。 ### 根因 `plan-client.js` 的 3009 处置是「退避重试、**不换号**」(照抄参考实现),而 1005 的 处置是「标号 + 换号」。3009 与 1005 的区别只在于**时长**:前者秒级瞬态、后者持续到 当日重置。把「不换号」绝对化,等于让瞬态忙锁死整个请求与后续请求的选号起点。 ### 修复(0.5.5) - **忙标记(软优先级,不是硬过滤)**:`AccountPool` 新增进程内 `markModelBusy` / `isModelBusy` / `clearModelBusy` / `busyCount` (`"${accountId}|${modelId}"` → busyUntil,60s 窗口 = `MODEL_BUSY_TTL_MS`)。 ⚠ **刻意不落库**:忙是秒级瞬态,写库只会让重启后残留过期标记; 额度用尽那种要跨重启的状态才落 `model_rate_limits`。两者**分字段**, 互不污染 —— 3009 绝不能把账号冤枉到「当日限流」。 - **`pool.pick` 两档选号**:第一档「启用 + 未限流 + 不忙」按手动顺序取第一个 —— 「另一个账号空闲时,请求路由到空闲账号」就靠这一档;第一档为空时 **放宽忙标记**回落到忙账号排队(限流标记仍硬过滤,绝不捞回)。 - **3009 处置改为「标记 + 退避 + 穷尽换号」**(`plan-client.js` 两处:非 200 与 HTTP 200 包裹错误):命中即打忙标记;退避(1500/3000ms)穷尽后 `tried.add` + 换下一个账号;**钉定路由(`-acct-` / `@acct:`)语义不变 —— 绝不换号**,持续 3009 报 `RATE_LIMIT`(带账号昵称与出路提示)。 - **成功命中即清忙标记**:退避期间账号已空闲,别让后续请求白白绕开它。 - **报错分型**:忙导致的「选不到号 / 全部穷尽」报 `RATE_LIMIT` 且提示 「稍后重试」(瞬态),额度用尽仍报 `QUOTA` 且提示等次日重置 —— 混用会 把人带去错误的排查方向。上一轮换号原因由 `lastSwitchReason` 记录 (外层循环的兜底文案不再无条件覆盖 3009 文案)。 ### 为什么不是「3009 直接换号」 3009 是**并发**限流:同一账号同时只允许 N 路请求。多会话场景下忙账号往往 几百毫秒后就空闲,立刻换号会把手动排序置顶的账号边缘化(下次它又不忙了, 优先级却回不来——和「限流重置最早者当选」破坏拖拽语义同款问题)。 退避重试保住「手动顺序即优先级」,忙标记保住「并行请求不互相堵」, 穷尽换号保住「单请求最终有产出」——三者各取所需。 ## 0.5.4 — 领取改为「直接 claim」:preview 是个从未被调用的端点 ### 症状 面板「赠送额度」区永远显示「暂无可领取的活动」,领取按钮**从不出现**; 但用户在 ZCode 客户端里能看到并成功领取活动(`ZCODE GLOBAL BUILD`)。 ### 根因(来自官方客户端日志的实证,不是推测) 分析 `~/.zcode/v2/logs/2026-10-28.log`(2962 次 RPC 调用)的**全天方法名统计**: | RPC | 次数 | 含义 | |---|---|---| | `coding-plan-subscription.getManualClaimPlanPreviews` | **0** | 即 `/billing/preview` —— **官方客户端从不调用** | | `coding-plan-subscription.claimManualPlan` | 1(用户点击时) | 即 `/billing/claim` | | `marketing-touch.query` | 29(约 10 分钟一次) | 活动曝光候选来源 | 领取时刻的完整时序(21:00:41): ``` 21:00:40.989 captcha attempt.start (source: send_preflight) 21:00:41.178 resource.failed ×4 ← 阿里云 CDN [prefix].captcha-open.aliyuncs.com 21:00:41.549 attempt.end outcome=success(第一次是 error,重试后成功) 21:00:41.741 claimManualPlan OK (193.6ms) ``` ⇒ 官方客户端**从不预判有没有活动**,点按钮就直接 claim,用服务端返回的 `code`/`msg` 告诉用户结果。我们的 `preview` 恒空,于是按钮永远不出现。 ### 修复 - `/claim` **语义变更**:`planId` 必填,不再先查 preview;直接调 claim, 业务码**原样回传**(`0` 成功 / `1003` 已领过 / `1005` 领完), 不再翻译成含糊的「失败」; - **captcha 失败原地重试一次**:对应官方「按两次才领到」的根因 (CDN 首轮抖动是常态)。只重试一次 —— mint 有独立退避闸门,无限重试烧设备信誉; - `client.js`:`ClaimSection` 从「查看可领取 → 列表 → 领取」改为 **输入 `plan_id` + 直接领取**,结果如实显示服务端业务码; - `/claimable` 路由**保留**(preview 语义待进一步求证),但**不再作为领取前置**。 ### 与「按两次」「没领到」两条用户反馈的对齐 | 反馈 | 实测对应 | |---|---| | 有时要按两次 | `send_preflight outcome=error` → 6 秒后 `success`;CDN 首轮 4 次 `resource.failed`。已在服务端加重试一次 | | 这次没领到是领完了 | `claimManualPlan` 返回 RPC OK(HTTP 层成功),业务层名额已空。现在面板会显示 `code 1005` 而不是含糊失败 | ### 测试 136 个用例(+4):`/claim` 不碰 preview、缺 planId 给 400、captcha 重试一次成功、 `1005` 业务码原样回传、`ClaimSection` 源码不含 `/claimable`。 ## 0.5.3 — 出处声明:身份块文本的源头是 Apache-2.0 的开源 ZCode ### 为什么补这条 `lib/anthropic-body.js` 的身份块(`OFFICIAL_CLI_PREFIX` / `OFFICIAL_STABLE_SECTIONS`) 此前声明"移植自 bonus-plan-4-open-zcode",而那个仓库**无许可证**——审计时说不清 这段逐字文本的版权归属。 ### 核实结果 身份块文本与开源仓库 [zai-org/ZCode](https://github.com/zai-org/ZCode)(**Apache-2.0**, 8.3k stars,官方描述 "Z.ai's coding agent harness")的 `apps/zcode-cli/packages/core/src/context/sections/identity.ts` **逐字一致**: - `OFFICIAL_CLI_PREFIX` ≈ `buildIdentityPrompt()` 的 intro("You are ZCode, an interactive coding agent") - stable 段的 SECURITY_NOTICE ≈ 同文件 `SECURITY_NOTICE` 常量(逐字符,含 em-dash) - `# Harness` 块 ≈ 同文件 `buildHarnessBlock()`(连 `\u2014` 转义都对得上) 历史上经 bonus-plan / a137460387/zcode2api 从官方闭源 bundle 反解(当时开源仓库 尚未放出或未被注意到);现在源头公开,出处升级为 Apache-2.0 仓库。 ### 合规含义 - Apache-2.0 → GPL-3.0-or-later:FSF 确认单向兼容,无需换许可证; - 唯一义务是保留版权与许可声明 —— 已在 README「许可」节建立**四级出处表** (zai-org/ZCode / bonus-plan / plugin-manager / workbuddy), 逐项写明参考了什么、许可证状态、对我们的约束; - `lib/anthropic-body.js` 的常量头部补了同样的出处注释。 ### 与无往来同源项目的关系(审计时被问过) 存在同源兄弟项目(双方参考同一个 bonus-plan),**代码零往来**:它走"ZCode 壳内桥接" (部署开源 ZCode 实例 + HTTP 桥),本插件走"进程内直连"。它声称"裸 HTTP 直连必 3012、 走会话链路是唯一解",与本插件直连成功的事实矛盾;合理解释是时间差(其实验集中于 2026-09-28 前后)与设备身份策略差异(它复用壳的真实 X-Device-Mid,本插件生成独立 mid + 官方身份块)。双方结论不宜互相套用。 ## 0.5.2 — 额度到期时间(绝对时间 + 相对时长) ### 需求 面板只知道「还剩多少」,不知道「什么时候作废」。免费额度按自然日结算, 到点不清零就会被误当成 bug 排查。 ### 数据来源(实测) | 字段 | 位置 | 语义 | |---|---|---| | `balances[].expires_at` | 每个额度桶 | **该桶**的到期时间(主号两个桶都是当天 23:59:59 +08) | | `plans[].ends_at` | 计划级 | 未生效权益所在的 plan 整体作废时间(示例账号是次日 09:00) | ⚠ 两个都是**秒级**。0.4.x 的 `fetchBalance` 直接 `num(item.expires_at)` 存了原值, 毫秒语义的下游一用就会显示 1970 年 —— 这次统一用 `toEpochMs` 归一。 ### 实现 - `fetchBalance`:桶的 `expiresAt` 归一到毫秒;顶层 `expiresAt` **只汇总还有余额的 桶**的最早到期时间(把一个空桶的到期当成额度到期是误导); `pending[]` 带上所属 plan 的 `endsAt`; - `manage-routes`:`/summary` 与 `/balance` 透传 `expiresAt`(`pending` 里也带); - `client.js`: - `QuotaMeter` caption 变成 `剩余 4298278/8000000 · GLM-5.3 · 23:59 到期(1 小时 20 分后)`; 未生效的为 `23:00 起可用 · 10/27 09:00 到期(10 小时后)`; - 新增 `formatRemaining()`:`不到 1 分钟` / `N 分钟后` / `N 小时 M 分后` / `N 天后` / `已过期`。绝对时间靠 `formatEffective`(今天只给时刻、跨天带日期)。 ### 测试 - `fetchBalance` 3 个:秒→毫秒、只用有余额的桶汇总、全用光时不给顶层 `expiresAt`、 未生效权益带 plan 的 `endsAt`; - `formatRemaining` 8 个边界;`QuotaMeter` 3 个(含「没有到期信息时不能出现 undefined / NaN / 1970」)。共 132/132。 ### 实测 ``` 主号 : 剩余 4298278/8000000 · 到期 2026/10/26 23:59:59 账号 B: pending GLM-5.3-Flash 3亿 @ 23:00 起 → 10/27 09:00 到期 pending GLM-5.3-Flash 1亿 @ 23:00 起 → 10/27 09:00 到期 ``` ## 0.5.1 — 面板白屏:ClaimSection 的 hook 顺序(0.5.0 自己引入的) ### 症状 升到 0.5.0 后整个插件面板不渲染(Web UI 里那片区域空了)。服务端一切正常: `/summary` 200、`adapterRegistered: true`、两个账号都在。 ### 根因 0.5.0 在 `ClaimSection` 里把 `restart` 的 `useCallback` 加在了 ```js if (!Array.isArray(accounts) || accounts.length === 0) return null ``` **之后**。React 的规则是「每次渲染的 hook 数量与顺序必须一致」: | 情形 | 调用的 hook 数 | |---|---| | 账号为空 → 走到早退 | 4(state×3 + callback×2 中的 2 个) | | 有账号 → 继续往下 | 5(多一个 `restart`) | 宿主是异步加载的:**首次渲染必定是 `accounts === []`**(数据未到), 数据到达后变成有账号 → hook 数从 4 变 5 → React 抛 `Rendered fewer hooks than expected` → **整块面板卸载成空白**。 ### 修复 把 `restart` 移到早退**之前**,并加注释说明「所有 hooks 必须在任何早退之前」。 ### 回归闸 `tests/client-panel.test.mjs` 的 fake React 改成**带 hook 计数**的桩: 同一个组件先后渲染时 hook 数不一致就抛错(复刻 React 的行为)。 新增用例「空账号池与有账号两次渲染,hook 数量必须一致」—— 它精确复现宿主「先空后有数据」的加载序列。 已验证该闸门不是摆设:单独构造「有账号时多调一个 hook」的组件, 桩会抛出 `hook 数变化 1→2`。 ## 0.5.0 — 面板一键「以调试模式重启」(领取 captcha 不再靠手抄) ### 背景(一个常被误读的约束) **调用模型不需要调试端口** —— captcha 在推理链路里是**按需触发**的 (`plan-client` 只在被上游 `3007` 拒过、且 2 分钟记忆窗口内才 mint)。 实测:`captcha.reachable=false` 且 `captchaRequiredAccounts=0` 时,对话完全正常。 只有**领取赠送额度**必须有 captcha:claim 接口硬性要求 `x-aliyun-captcha-verify-param`, 每个 plan 一个新的。所以 0.4.9 那行「DSH 需以 --remote-debugging-port 启动」 容易被读成全局前提,文案改为明确「仅领取需要,日常调用不受影响」。 ### 新增 - `POST /api/zcode-start-plan/restart-cdp {debugPort?, waitSeconds?}`: 分离进程启动 `scripts/restart-dsh.ps1`,立刻回 200(不等重启完成); - `client.js` 的「赠送额度」区:`captcha.reachable !== true` 时直接显示 **「以调试模式重启」**按钮,点了给出「窗口会关闭并自动重开」的提示; - `GET /summary` 新增 `captcha` 字段(`mode` / `debugPort` / `reachable` / `reason`), **每次现探不缓存** —— 重启回来刷新必须立刻看到新状态; - `scripts/restart-dsh.ps1`:`-Exe` 改为可自动定位(显式路径 → 运行中主进程的 `ExecutablePath` → 常见安装位置),且**必须在杀进程之前**解析(进程一没就查不到)。 ### 为什么必须分离进程 脚本的逻辑是「先睡 N 秒 → 杀掉宿主 → 拉起带参数的新实例」。若它挂在宿主的进程树里, 宿主一死脚本跟着死,就没人拉起新实例了 —— 用户会得到一个关掉的窗口。 故 `spawn(..., { detached: true, stdio: 'ignore' })` + `child.unref()`。 脚本本身带**失败回退**:CDP 端口起不来就不带参数重启,保证宿主仍可用, 所以这个按钮不会把用户锁在坏状态里。 ### 测试 `tests/claim-routes.test.mjs` 增加 4 个:参数透传、非法端口 400 且**不触发**重启、 缺实现给 501(不假装成功)、实现抛错透出原因。共 123/123。 ## 0.4.9 — 面板内领取赠送额度(`/claimable` + `/claim`) ### 之前的状况 `bin/cli.mjs claim` 已有完整领取逻辑,但面板没接 —— 用户必须在命令行点。 而且 `fetchClaimablePlans` 前**必须先 `reportActivation`**(不补活跃信号时 `preview` 恒空),这条约束只写在 CLI 里,面板侧一旦漏掉就会永远显示「没有活动」。 ### 新增 - `GET /api/zcode-start-plan/claimable?id=`:先补活跃上报,再查可领列表; - `POST /api/zcode-start-plan/claim {id, planId?}`:逐 plan 领取,`planId` 省略 = 领全部; - `client.js` 的 `ClaimSection`:面板底部「赠送额度」区,**按需展开** (点「查看可领取」才打上游),每账号一行:`#序号 昵称 · 可领列表 · [领取] [重新查询]`。 ### 三条硬约束(都在 `/claim` 里落实) | 约束 | 后果 | 做法 | |---|---|---| | captcha **一次性** | 复用同一个 param → 必得 `3007` | 每个 plan 循环内重新 `mintCaptcha()` | | `1003` = 已领过 | 报红让用户以为失败 | 视为**幂等成功**(`alreadyClaimed: true`) | | mint 失败不能重试 | 连环重试烧设备信誉(有冷却惩罚) | captcha 失败立刻 `break`,且**不打 claim** | ⚠ captcha 走 `deps.client.mintCaptcha`(`ZcodePlanClient` 的串行队列)而不是 新起一个 minter:面板点击与推理链路可能同时要 captcha,产出是**设备级**的, 两份会话会互相踩 CDP 页面。 ### 测试 `tests/claim-routes.test.mjs`(7 个用例):补上报后再查 preview、每 plan 换 param、`1003` 幂等、指定 planId、captcha 失败即停且不打 claim、未知账号 404、缺 id 400。 反向验证:把 captcha 提到循环外复用 → 3 条用例变红;恢复后 119/119。 ### 实测 真账号跑 `/claimable`:`status 200 ok:true plans:[]` —— 上游确实没有可领活动 (不是链路错误)。有活动时面板会列出,点「领取」即可。 ## 0.4.8 — 未生效额度改为「同款进度条 + 底部启用时间」 ### 变更 0.4.7 把未生效额度渲染成一枚 chip;用户期望与已生效额度**视觉同构**: 一条进度条,未生效的那条底部写启用时间。故: - 抽出 `QuotaMeter` 组件,已生效 / 未生效**共用同一形状**(同 `zsp-bar` + caption 结构),差别只有两处: - 未生效的 `data-pending` → 条纹背景 + 半透明填充(「还没到点的钱」不能看成满格实色); - caption 文案:已生效 `剩余 X/Y · 计划名`;未生效 `23:00 起可用`; - 未生效的百分比固定 **100%**:尚未启用的额度必然是满的,不参与分母计算; - 额度大小(`GLM-5.3-Flash 3亿 tokens`)放在条下方的小字里,不再挤进 chip。 ### 测试 新增 `tests/client-panel.test.mjs`:宿主注入 `React`,所以用最小 `createElement` 桩把 `client.js` 里的组件**真渲染出来**做结构断言(不是字符串匹配), 并展开函数组件节点。6 个用例覆盖:单条实色条 / 双条(一条条纹、底部带时间)/ 多条 pending / 缺 total 按 0% / 企业版与错误态不画条 / 跨天时间带日期。 反向验证:删掉 `data-pending` → 2 条用例变红;恢复后 112/112。 ## 0.4.7 — 面板显示「未生效的领取额度」(`balances` 不含未来权益) ### 症状 某个账号领到 `ZCODE GLOBAL BUILD`(3 亿 GLM-5.3-Flash tokens,23:00 起用), 面板卡片却显示 `剩余 0/0`——像领取失败,实际只是还没到点。 ### 根因(端点没缺数据,是解析丢了) `GET /billing/balance` 的 `data` 有**两个**集合: - `balances[]` —— 只列**当前生效**的桶(面板原来看的就是它); - `plans[].entitlements[]` —— plan 的权益明细,未生效的条目带 `effective_at`(**秒级**)与 `grant_units`。 23 点才启用的包在 22 点的 `balances` 里不存在 → 汇总自然是 0/0。 `preview`(可领活动)为空与此无关,别混淆。 ### 修复 - `fetchBalance`:扫 `plans[].entitlements[]`,`effective_at > server_time` (缺省本地现在)且 `grant_units > 0` 的进 `pending[]` (`{ planId, showName, unitType, grantUnits, effectiveAt }`,按到点时间升序); 已生效的不收(`balances` 已覆盖);秒级时间戳用 `toEpochMs` 归一; - `manage-routes` 的 `/summary` 与 `/balance`:`pending` 非空才下发该键; - `client.js`:卡片余额行下追加 chip `GLM-5.3-Flash 3亿 tokens · 23:00 起可用`(跨天带日期)。 ### 实测 ``` --- 账号 B: remaining=0 total=0 plan=- pending: GLM-5.3-Flash 300000000 tokens @ 2026/10/26 23:00:00 (zcode-v3-start-plan-nt-1026) ``` ⚠ `effective_at` 语义是**到点即可用**,不需要再点领取;到期自动并入 `balances`。 ## 0.4.6 — 面板 404:管理路由改用可选注入(`ctx.inject(['webServer'])`) ### 症状 设置 → 插件 → ZCode Start Plan 详情页,「账号池管理」下面是空白 + 红色 `HTTP 404`; 插件本身显示「运行中」、模型可选、状态工具正常。宿主日志里**一个字都没有**。 ### 定位(实测,不是推测) - `GET /api/zcode-start-plan/summary` 直接落到宿主认证层:无 cookie 时 401 `unauthorized`(`dsh-client-connection` 的 `/api` **prefix 路由**先匹配), **说明这不是 401 而是路由从未注册**; - 已安装的 0.4.4 `index.js` 把 `webServer` 从 `inject` 里移除了 (`inject = ['llm','tools','timer']`),却又在 `assemble.js` 里用 `ctx.webServer?.register` 探测 —— 宿主 facade **只挂声明过的服务**, 探测必然拿到 `undefined`,整个 `if` 块静默跳过; - 上游端点全部正常(`balance` 200、`messages` 200+业务码、`client/configs` 200), 与本问题无关。 ### 根因(两个方向都是错的) | 写法 | 后果 | |---|---| | `webServer` 进硬 `inject` | headless profile 没有该服务 → 整个插件 fiber 挂起,连模型都没了 | | 从 facade 探测 `ctx.webServer` | 未声明就拿不到 → 路由静默不注册 → 面板永远 404,日志零输出 | ### 修复 - `index.js`:`inject` 只留 `['llm','tools','timer']`;新增 `ctx.inject(['webServer'], (webCtx) => assembled.registerManageRoutes(webCtx))` —— cordis **可选注入**:服务出现才注册,消失时回调内 effect 自动撤销; 宿主 ctx 没有 `inject` 时**明确 warn**,不再静默; - `lib/assemble.js`:`register()` 里删掉探测块,改为导出 `registerManageRoutes(webCtx)`; 路由注册统一走 `webCtx.webServer.register`,并把每条注册结果打日志 (`管理路由已注册:12 条`),`webServer`/`pool`/`adapter` 缺任何一个都会 warn 出具体原因; - `runtime.adapter` / `runtime.syncAdapterRoutes`:可见性开关的回调需要在 `register()` 之后仍能拿到适配器,故挂到 runtime 上而不是闭包私藏。 ### 回归闸 `tests/e2e.test.mjs` 的 apply 用例不再假装 `ctx.webServer` 在 facade 上, 改为提供 `ctx.inject` 模拟宿主可选注入,并断言 **12 条 exact 路由**真的进了路由表 (其中 `/summary`、`/login/start`、`/login/poll` 必须存在)。 反向验证:把注册那行注释掉 → 断言报「实际 0」;恢复后 93/93。 ## 0.4.5 — 面板开关列对齐 + tooltip 去动态化 ### 症状 三颗「显示在模型列表」开关(各账号栏 + 「统一选号」行)不在同一条竖线上: 卡片内的开关贴卡片内边距,统一行的开关贴整行右缘,视觉错位。 ### 修复 - `.zsp-toggle` 加 `margin-left:auto`:开关统一由行内右侧推齐,不再依赖调用点 传入的布局; - `.zsp-unified`(统一行)补 `padding-right:13px`:统一行比卡片少一层 「卡片边框 + 卡片内边距」(卡片侧 1+12+1+12=26px,统一行侧 13px),补差后 三颗开关落到同一条竖线; - `Toggle` 组件去掉按状态切换的动态 `title`(「点此隐藏/点此恢复」两套文案), 固定为 `aria-label: 在模型列表中显示这一栏`。动态 tooltip 在开关密集时反而 制造噪音,固定 label 对读屏与键盘用户行为可预期。 ## 0.4.4 — 兜底表档位元数据 + 目录失败先用过期缓存(GLM-5.3「不支持当前档位」根因) ### 症状 给 GLM-5.3(或 Flash)选思考档位 → 立刻报「不支持当前档位」、无输出; 「暂时切换」(只选模型不动档位)就能继续。 ### 根因 不是 wire 映射(`output_config.effort` 对 GLM-5.3 同样被上游接受,已实测), 而是**目录元数据瞬间缺失**:`fetchModels` 间歇失败 → `loadModels` 直接落兜底表 → 兜底表无 `reasoningLevels` → 宿主 `resolveCallWithInfo` 对显式 effort 抛 `UNSUPPORTED_REASONING_EFFORT`。selectModel 时机只弹 UI,**不进会话日志**; 诊断靠「下一个成功请求头」的 `adapterDefaults`(只有 `{"maxTokens":true}` 而无 `reasoningEffort:true` = 宿主当时手里是无档位元数据的兜底目录)。 ### 修复(两处独立生效,缺一仍有窗口) - `lib/upstream.js`:兜底表补齐 `reasoningLevels: ['low','high','max']` / `defaultReasoningLevel: 'max'`(与上游目录 2026-10-25 实测一致)。⚠ 以后改兜底表 必须带档位元数据 —— `fallback-models.test.mjs` 看门(含宿主校验链仿真); - `lib/assemble.js`:拉取失败时**先返回过期缓存再落兜底表**。过期缓存元数据结构 完整(只是内容旧),兜底表只给从未成功过的冷启动用。 ### 诊断手法(备查) - 会话日志 zstd 是**多帧拼接**,按 magic `28 B5 2F FD` 分帧逐段解压; - `request/header` 的 `adapterDefaults.reasoningEffort: true` 表示宿主把该档位 当作**默认值**注入(目录有元数据且用户未显式选档);显式选档时它在 `config` 里。 ## 0.4.3 — 图片输入:宿主持久引用 → 附件服务字节 → Anthropic base64 ### 症状 Web UI 输入框上传图片后,模型表现「看不见图」(GLM-5.3-Flash 报当前模型不支持, 或带图消息发出后回答与图片无关)。 ### 根因(两层,都源自宿主契约误判) 1. **图片块被静默丢弃(主因)**。宿主传给适配器的 image 块是**持久引用**形态 `{ type: 'image', attachment: ImageAttachmentRef }`(`dsh-llm` 的 `ImageBlock`, rc.3 `.d.ts` 钉死),ref = `{ attachmentId:'sha256:', mediaType, width, height, bytes, name? }` (`dsh-attachment-local` 实测)。而 `convertContent()` 只认 `{ source: { type: 'base64' | 'url' } }` 内联形态 —— 引用块两个分支都不命中, **图片无声消失**,上游收到的请求根本没有图。官方 `dsh-llm-deepseek` 的同款链路是 `ctx.get('attachments')` → `attachments.readImageRequest(ref, target)` 拿确定性请求 版本字节 → `Buffer.from(version.data).toString('base64')` 编成 Anthropic 块。 2. **能力声明缺口**。模型目录 `capabilities.vision` 决定 `inputModalities`: 实测 `GLM-5.3-Flash` 为 `vision:true`,`GLM-5.3` 为空。宿主 `dsh-api-session-controller` 在 prompt 准入时对声明不含 `image` 的模型直接拒 (`MODEL_DOES_NOT_SUPPORT_IMAGES` → UI「当前模型不支持图片」)。GLM-5.3 发图 必被宿主拒绝 —— 这是目录事实,不是本插件 bug;带图请改用 GLM-5.3-Flash。 ### 修复 - `lib/anthropic-body.js`:新增 `imageOccurrences()`(识别引用/内联两种形态)、 `imageBlockFromAttachment()`(字节 → base64 块,带无 Buffer 兜底)、 `resolveImages()`(经附件服务解析引用,单张失败只降级不拖垮请求); `convertContent()` 对查不到字节的引用给**占位文本**(不再静默丢弃); - `lib/plan-client.js`:组 body 前先 `resolveImages()`,字节经 `resolvedImages` 传入 `buildUpstreamBody()`; - `lib/assemble.js`:惰性回调 `() => ctx.get('attachments')` 交给 plan-client。 ⚠ 附件服务**刻意不进 `inject`**:cordis 的 inject 是硬依赖(声明的服务缺席时 fiber INACTIVE,插件整体不加载);沙箱 façade 的 `ctx.get(name)` 允许未声明服务 的只读查询(`dsh-cordis-host-runner` 的 `sandboxContext` 实证),缺席时图片降级 占位文本,主功能不受影响。 ### 测试 101 个用例全过(新增:两种图片形态识别、base64 编码、解析失败占位、附件服务 缺席/单张失败容错、端到端引用图片进入上游请求体);`smoke-install` 26 项 PASS。 真实账号端到端:1×1 PNG 经完整链路发上游,HTTP 200 + 全事件流,模型正确报出 图片颜色("Maroon")。 ## 0.4.2 — 面板布局:统一行置顶 + 账号区方块 - 「统一选号」行移到面板最顶上(标题行之下、添加账号之上),右侧开关与 账号卡片的开关右对齐(`padding-right` 补偿卡片边框+内边距); - 全部账号卡片包进一个带边框的方块(`zsp-group`),与「添加账号」虚线框 同一套边框样式; - 去掉统一行的说明文本与卡片上「模型栏」文字标签:开关语义由 title 悬停提示承担; - 账号卡片的「使用/删除」操作按钮行尾部加一个 30px 占位(与开关同宽), 按钮右缘与标题行右侧开关右对齐。 ## 0.4.1 — 每栏右侧的「模型栏」开关 0.4.0 的统一栏位开关是一颗按钮;本版改成**每一栏最右侧一颗开关** (iOS 风格 toggle),各自控制自己是否出现在模型选择器里: - 面板布局 = 选择器布局:每个账号卡片标题行最右侧一颗「模型栏」开关; 底部「统一选号」说明卡最右侧同款开关; - 隐藏的卡片整体降透明度(`data-hidden`),状态一目了然; - 数据通道:`POST /model-list-visible {id, visible}`(单账号栏位)、 `POST /unified-list {visible}`(统一栏位,0.4.0 已有); `/summary` 的每账号视图带 `modelListVisible`; - 持久化:`settings.json` 新增 `hiddenModelLists`(被隐藏栏位的账号 id 数组,默认空 = 全显示),重启后恢复; - **隐藏 ≠ 停用**:栏位隐藏只是从模型列表里收起(空目录),路由仍在、 钉定请求照常 —— 已选着该栏的旧会话不中断;停用仍走账号卡片的启停。 测试:93 个用例全过(新增 `setAccountVisible` 空目录/请求不受影响、 `/model-list-visible` 路由回调与校验)。 ## 2026-10-25 — zcode-cli-adapter:接入 ZCode CLI + 非流式端点截断修复 ### 新增 `zcode-cli-adapter/`(三个独立脚本,零新协议代码) 把 ZCode CLI(开源版,本机 v0.17.0)接到本项目的免费 plan 通道。原理与 bonus-plan-4-open-zcode 一致(本地 Anthropic 端点 + ZCode 自定义供应商),但 全部复用现有 `lib/`: - `server.mjs`:独立进程跑 `lib/local-endpoint.js` 的 `/v1/messages` + `ZcodePlanClient`(无需 DSH 在跑)。端口 17899 被占自动回退随机并落盘 `cli-endpoint-port.txt`;token 走 `resolveEndpointToken`(环境变量 → 文件 → 新生成),**必须持久化** —— CLI 配置里的 baseURL/apiKey 指向它们; - `register-provider.mjs`:往 `~/.zcode/v2/config.json` 的 `provider` 表原子 写入 `custom:dsh-zcode-startplan`(`kind: "anthropic"` + `options.baseURL` + `models`,schema 已从官方 bundle 的 Zod 定义核实,passthrough 容错)。 只动 provider 表、`.bak` 备份 + 同目录 rename、幂等; - `import-zcode-credential.mjs`:从 CLI 登录态(`credentials.json` / config 里 JWT 形 apiKey)导入账号入池,`pool.add` 按 user_id 去重, device_mid 现场生成(独立设备标识,与 CLI 原生通道互不干扰)。 数据目录默认与 DSH 插件**共用**(`~/.dsh/zcode-start-plan`,SQLite WAL 多进程 安全);DSH 插件的本地端点与本适配器默认都抢 17899,**同一时间只开一个**。 ### 修复:本地端点非流式响应被截断成开头几个字(`lib/local-endpoint.js`) `plan-client.call()` 返回的 `head` 只是**首帧切片**(读到首帧就返回,好尽早 判定零内容/业务错误),正文增量全在 `decoder` 里。流式分支正确消费了 decoder, 非流式分支只折叠了 head —— 回答被截成开头几个字(实测 `"The"`),usage 全 0。 pi-ai 路线几乎总走流式所以一直没暴露;ZCode CLI 的非流式请求一发即中。 修复:非流式先读干 decoder 余量再折叠。测试套件 92/92 通过。 ### 实测(真实账号端到端) 非流式 200 完整回答 + usage;流式 SSE 事件序列完整(上游 keepalive `ping` 帧正确透传);`--status` / 401 / 空池 500 + Anthropic 形错误均符合预期。 ## 0.4.0 — 每账号一个 provider 栏位(workbuddy 模式)+ 统一选号开关 0.3.0 把钉定做在模型 id 上(`GLM-5.3@acct:`);本版改成 workbuddy 的 形态:**每个账号一个独立 provider 栏位**(`ZCode Start Plan · 主号`), 模型选择器里一栏一个账号,栏内模型固定消耗该账号额度。统一选号栏位可在 插件面板一键隐藏/恢复。 ### 多路由注册(`lib/assemble.js`) - **一个 adapter 实例服务整组路由**:`zcode-start-plan`(统一选号)+ 每个启用账号一条 `zcode-start-plan-acct-<账号id>`; - 照抄 workbuddy 的可见性机制(`@venshell/dsh-workbuddy-connect` 源码实证): provider **无条件注册**,隐藏栏位 = `listModels` 返回**空数组** (宿主过滤没有模型的分组)—— 路由始终可达,老会话不断,登录/开关在 启动后发生也不需要重新注册; - 账号增删/启停/统一栏位开关 → `registerAdapter` 返回的 **`handle.replace(routes)` 原子换路由**(rc.3 的 `AdapterRegistrationHandle.replace`,`commitRoutes` 内自动广播 `llm/adapters-updated`,模型选择器随即重拉)。 ### 路由级钉定(`lib/adapter.js` / `lib/plan-client.js`) - `stream(options)` 按 `options.provider` 判定账号栏位,向 plan-client 传 `pinnedAccountId`;钉定优先级:**provider 路由 > `@acct:` 后缀**; - `providerInfo` 对账号路由返回「ZCode Start Plan · 昵称」(宿主 `prepareRoutes` 硬契约:`info.id === provider` 且 name 非空 —— 已删账号 也回退出合法 name); - `listModels(provider)`:账号栏位 = 基础模型目录 + description 带该账号的 额度/限流状态;停用/删除的账号 = 空目录(栏位隐藏); - `resolveModel` 的 `id` 与请求**原样一致**(`normalizeModelInfo` 的 `INVALID_MODEL_INFO` 硬契约),0.3.0 的 `@acct:` 后缀 id 仍原样放行; - plan-client 路由级钉定与 0.3.0 语义一致:绝不换号、额度尽报 QUOTA (消息带账号昵称)、先标「账号×模型」限流再报错。 ### 统一选号开关(面板) - 插件详情页新增「隐藏/显示统一选号栏位」按钮;隐藏后模型列表只留 每账号一栏,统一路由保留(空目录),老会话不受影响; - 状态持久化在 `/settings.json`(`unifiedModelList`),坏文件按 默认值(显示)处理;管理路由新增 `POST /unified-list {visible}`, `/summary` 返回 `unifiedModelList` 字段。 ### 配置更名 - `pinnedAccounts` → `accountProviders`(同义更名;**旧名仍被接受**, 新名优先)。0.3.0 的 `@acct:` 模型 id 编码不变,老会话继续可用。 ### 测试 - 92 个用例全过(多路由派生互逆、providerInfo 契约、空目录可见性、 路由级钉定发对凭据、`@acct:` 兼容、QUOTA 不换号、开关路由回调)。 ## 0.3.0 — 模型列表直选账号额度(钉定账号条目) 在 DSH 模型选择器里为每个启用账号生成「模型 · 账号昵称」条目(如 `GLM-5.3 · 主号`),选中即只消耗该账号的额度 —— 多会话可以同时各吃各的账号, 不再只有一个「置顶顺序」。 ### 模型 id 编码(`lib/pinned-model.js`) - 钉定条目 id:`<基础模型id>@acct:<账号id>`(如 `GLM-5.3@acct:`); - 上游只见基础模型 id;`@acct:` 对上游/宿主/持久化三方无歧义, **编码一经发布不可更改**(会话里保存的模型名会失效); - 宿主契约(rc.3 `dsh-llm` 源码核实):`listModels` 只要求 `id` 非空且 全表不重复(`INVALID_CATALOG`),`normalizeModelInfo` 要求 `resolveModel` 返回的 `id` 与请求原样一致(`INVALID_MODEL_INFO`)—— 复合 id 两条都满足。 ### 选号(`lib/plan-client.js`) - 钉定模式:`#pickPinned` 只取指定账号,**绝不换号**;账号缺失 / 停用 / 凭据不可用 / 该模型限流中都以明确报错收场(各自给出路),不静默回退 —— 回退会让「钉定」名存实亡; - 额度耗尽(1005/1113、200 但零内容、body 为空)→ 标记该账号×模型限流 (与面板一致)→ 报 QUOTA,消息里带账号昵称与上游原文; - 自动模式行为不变(手动顺序、逐个试到有产出)。 ### 目录与刷新(`lib/adapter.js` / `lib/assemble.js` / `lib/manage-routes.js`) - `listModels` = 基础模型 +(启用账号 × 基础模型)钉定条目;description 带「只消耗某账号额度 / 限流至 HH:mm」提示;停用账号不出条目; - 账号变更(面板增删/启停/置顶/改名/登录入池)→ 管理路由 `onChanged` → `ctx.llm.emitAdaptersUpdated()` → 选择器自动重拉模型目录; - `AccountPool.get(id, { withCredentials })` 新增(plan-client 按钉定 id 取号)。 ### 配置 - `config.pinnedAccounts`(默认 `true`):关掉后模型列表回到只有基础模型。 ### 测试 - 88 个用例全过(新增 `tests/pinned-models.test.mjs`:编解码、目录契约、 resolveModel 幂等、钉定只打一发、额度尽报 QUOTA 不换号、账号已删不回退)。 ## 2026-10-24 — 适配 dsh 0.2.1-rc.3:finish chunk 形状修正 ### rc.2 → rc.3 差异审查结论 对插件触达的全部宿主包(`dsh-llm` / `dsh-tools` / `cordis` / `cordis-plugin-loader` / `cordis-plugin-timer` / `dsh-host-webserver` / `dsh-llm-pi-ai` / `dsh-llm-retry` / `dsh-client-ui-renderer` / `dsh-client-ui-plugin-manager` / `schemastery` / `cosmokit`) 做了 rc.2 与 rc.3 的全量 `.js` SHA256 对比:**运行时零差异**。rc.3 只是补齐 `.d.ts` 类型声明 —— 所以本次「适配」的实质是:用官方类型把此前靠猜的契约钉死, 修正其中一处不一致。`peerDependencies` 抬到 `^0.2.1-rc.3`(includePrerelease 语义下 该区间继续覆盖同族的后续 rc)。 ### finish chunk 形状(修复点) 官方 `FinishReasonMap`(`dsh-llm/lib/types/types.d.ts`,rc.3 新增)钉死终止块契约: ``` { type: 'finish', reason: FinishReason } FinishReason = { kind: 'stop' } | { kind: 'tool-calls' } | { kind: 'max-tokens' } | { kind: 'aborted', failure } | { kind: 'error', failure } ``` - 宿主 `BlockAssembler` 读 **`chunk.reason`**(`this._finish = chunk.reason`), agent-loop 再按 `finish.kind === 'error' | 'aborted'` 判错。 - 之前发的是 `{ kind: 'error', failure }`(kind 在顶层、没有 `reason` 包裹): 宿主读到 `reason === undefined` → 默认 `{ kind: 'stop' }` —— **流内错误被静默吞成正常结束**,重试与错误上报全部失效。正常路径碰巧能跑 (`undefined.kind` 不落入错误分支),所以一直没暴露。 - 修复:`stream-translator.js` 的 `finish()` / `mapStopReason()` 与 `adapter.js` 的 catch 路径统一改为 `reason` 包裹对象; `mapStopReason` 返回 `{ kind: 'max-tokens' }` 等对象(此前返回 `'length'` 这类字符串,且 `'length'` 不是合法 kind —— 宿主的合法值是 `'max-tokens'`)。 - 契约级测试(`tests/assembler-contract.test.mjs`)新增断言: `finish.kind` 必须为 undefined(kind 只能在 `reason` 里)。 ### rc.3 类型文档揭示的可选能力(暂不用,留档) - `AdapterRegistrationHandle.replace(providers)`:原子换路由,将来配置热改可省 「先撤销再注册」的空窗; - `LlmResolvedModelInfo.systemPromptUpdate: 'in-history'`:若上游确认改 system 不破缓存,声明它可改善长会话缓存命中; - `TokenUsage.totalTokens` / `reasoningTokens`:可选补全用量字段。 ## 2026-10-24 — 工具调用参数保真 - 历史回放中,DSH 保存的工具参数是原始 JSON 字符串;发给 Anthropic Messages 上游前必须转成 `tool_use.input` 对象。 - 流式 `tool_use` 的 `content_block_start.input` 可以直接携带完整参数;没有后续 JSON 增量时必须保留它,有增量时以增量为准。 - 定向回归测试覆盖上述两种形态。长会话中的实际效果仍需在 DSH 运行时验收。 ## 0.2.0 — 账号池管理面板 在 Web UI 的官方插件详情页提供账号池的可视化管理:账号列表(渠道/状态/限流/最近错误)、 余额条、**使用中账号选择**、删除,以及面板内双渠道登录(bigmodel / z.ai)。 ### Host 侧(`lib/manage-routes.js`,经 `lib/assemble.js` ④ 接线) - 照抄 `@linxin666/dsh-client-ui-plugin-manager` 的实证宿主契约: `inject: ['webServer']` + `ctx.webServer.register({ kind: 'exact', path, handler })`; - loopback 栅栏(socket 地址 + Host 头都校验)。**不用** `node:net.isLoopbackAddress`—— Node 22 没有该导出,静态 import 加载即崩;用 `isIP` 本地实现; - **安全边界:任何响应不含凭据**(zcode_jwt / device_mid 不出网关), 仅暴露脱敏视图(id / 昵称 / 渠道 / user_id 尾 6 位 / 限流标记 / 最近错误); - 端点:`/summary`(全量+逐账号余额)、`/balance?id=`(单账号)、 `/set-enabled`、`/remove`、`/reset-limits`、`/reorder`、`/rename`、`/use`、 `/login/start`、`/login/poll`(均 POST JSON,`/balance` 除外); - `/use` = 把账号置顶 —— 池子的既有约定是**手动顺序即选号优先级**(`pick()` 取第一个 启用且未限流的账号),置顶即「下次请求用它」,复用 `/reorder` 的集合校验; - 登录流:两段式设备授权(与 CLI 同一对底层函数),**`flowSecret` 只留在 Host 内存** (每流 10 分钟 TTL、Map 上限 8 条);授权 URL 只接受 https;ready 时凭据组装 与 `beginZcodeLogin` 逐字段一致,凭据不出网关; - `AccountPool.patchAccount(id, {nickname})`:白名单只放行 `nickname` (`lib/pool.js`),凭据不可经此路改。 ### Client 侧(`client.js`) - `window.__ModuleLoader__.load({ id, factory })` + `require('react')`(宿主模块表); - 挂官方插件详情页的 `plugins.detail.section` 座位,subject 不指向本插件时返回 null (hooks 全部先于早退调用,顺序约束不破); - 样式只用宿主主题 token(`--dsw-alias-*`),组件局部 `