dsh-connect-agnes-token-plan 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,69 +2,95 @@
2
2
 
3
3
  本文件只记**公开行为变化**(新增能力、破坏性改动、重要修复)。实现细节、重构与测试加固请直接看 `git log`。
4
4
 
5
- ## [0.11.0] — 2026-10-16
5
+ **本文件就是市场面**:DSH 插件市场渲染「每个版本发布了什么」只读打进 npm 包的这份文件(`files` 白名单内),不解析 git 提交、不抓 GitHub Release。因此每条默认读者是市场页前的用户,发版前先把条目写好再 `npm publish`(见 `RELEASING.md` §3)。
6
6
 
7
- 本版两条主线:新能力**对话框用量胶囊**(输入框右侧的第二条读数,按 provider 门控),与**面板操作体感**批次(ADR-014)落地。
7
+ 读者只有一个:**已经装了上一版、正在决定要不要升级的用户**。他读一条时心里问的是「升上去之后,我的面板 / 选择器 / 胶囊会有什么不一样」。写任何一条之前先答这个问题——答不出来,这条就不该进本文件。
8
8
 
9
- ### 对话框用量胶囊:输入框右侧的第二条读数(两个上游、一个座席,provider 门控)
9
+ ## 写作纪律
10
10
 
11
- - **新:注册 `conversation.input.right` composer 座席并挂载 `ComposerUsagePill`**——读数按当前会话模型的 provider 门控:`agnes-token-plan` 模型画控制台 5 小时 / 每周请求窗口的使用百分比,`agnescode` 模型画桌面端积分余额(画存量、不造百分比:网关只发积分不给上限,把余额换算成百分比需要发明一个它占谁的份额);**其它 provider 的会话一律不挂载**(无读取、无定时器、无读数)——别的插件的会话绝不该带这些数字,`agnescode` 的额度宇宙也是桌面网关自己的,不能给它画控制台的窗口。
12
- - **门控坐在组件边界而非 hook 之后**:外层组件只决定挂哪个内层组件,轮询 hook 全在内层里——先调 hook 再早退会违反 Rules of Hooks,React 会把组件丢弃到会话结束(姊妹插件有专门钉住这条的回归 scenario)。
13
- - **读数与面板同源、不造第二把尺**:百分比走 `windowPercent`(平台自己的 `usagePct` 优先、`used/limit` 只作兜底、**从不钳制**——超额窗口报 >100 是事实不是渲染 bug),严重度点走 `usageTier`(90 / 70,与面板进度条同源);AgnesCode 侧 `0` 是「耗尽」读数、字段缺席是「读不到」(破折号),不是零。
14
- - **软失败(§5 不变量②)**:读取失败保留最后读数并标记过期,不清空、不抛错;注册失败面板照常工作、该模块缺席。
15
- - 影响面:`src/client/{composer-usage-pill,const,apply,cards,agnescode-tab,i18n,styles,index}.ts` + `client.js`(随 src 同 commit 重建);`test/composer-slot-gate.mjs`(新增:钉住座席 / 渲染器 / projection / 物化契约与两个 provider id 的双侧拼写)、`test/dom-render.test.mjs`(胶囊真 DOM scenario)、`test/render.test.mjs` / `test/contract.test.mjs`(§11c);文档与 CI 门禁同步。
11
+ **唯一判据(自检法)**:把这条念给那个没读过本仓库的用户听。他要是得先知道「承重面」「基线冻结」「state 窝点」这些词才能听懂,这条就写给了错的人——它属于 `docs/ADR.md` / `docs/PITFALLS.md`,本文件最多留**一行指针**(形如「内部:…详见 `docs/XXX.md`」)。
16
12
 
17
- ### 面板操作体感:快照取数并行 + 保存回显不再拖全量快照(ADR-014)
13
+ - **删的是维护者腔,留的是用户锚点**。可以删:自证式内心戏(「这正是…唯一能站住的地方」)、比喻、金句、给改代码的人看的精确行号。用户拿错误码 / 配置名来查这条时,需要看到那个码、那个名——端点路径、配置名、字段名、错误码、版本号照留;内部标识符(模块名、测试文件名、`ADR-xxx` 编号)只在用户排障真的会 grep 到它时才留,否则进指针。
14
+ - **一整节都写给维护者 = 整节删**,不是压缩。判据是该节「影响面」只列 `docs/*` 与「本文件」、且不含任何用户可见行为。
15
+ - **影响面清单最多一行**:只列文件名 / 测试名,不展开、不夹根因论证——根因去 `docs/PITFALLS.md`,决策去 `docs/ADR.md`,这里一行指针就够。一行变三行、开始解释「为什么改这些文件」,就是这条正在变成维护者交接文档的信号。
16
+ - **长句拆开**。一句里塞三个转折、两个破折号补语、一个括号说明,就是一次检索失败。数字与结论放句首,理由放后面。
18
17
 
19
- - **可感变化(账号失效 / 控制台降级场景)**:① 快照五源取数全部并行(原 `overview` 先行再批量;失效账号下两条 15 s 超时链叠加,单次快照最坏 30 s 起,现压到 ~15 s;死令牌锁号不重新引入——`getToken` 单飞 + `rejected` 集 + 逐请求 401 处置,测试 B 组按新形状重钉:每源至多一次出示 / 每 poll 一次登录);② 接入 API tab 的 provider 开关 / 模型清单 / 出图·视频工具卡的「保存后全量快照 reload」改为把 POST 响应体就地并入快照 `llm` 块——关模型、切开关的反馈不再等下一轮慢轮询;③ AgnesCode 三动作(开关 / 检测本机登录态 / 保存模型清单)直接应用 POST 的完整响应,harvest 成功即时翻出已关联卡片(此前要再排一次最长 30 s 的 GET);④ `/agnescode` 路由的 balance / catalog 两个 30 s 上游读改并行(最坏 60 s → 30 s)。工具卡「实际生效模型」徽标与额度数字仍随 30 s 轮询更新(Host 计算值,本地不造数)。
20
- - **归位说明**:此批次(①–④)在 0.10.2 发布(2026-10-10)之后的补记提交里被记在 `[0.10.2]` 节下,但已发布的 0.10.2 tarball 里**没有其中任何一条**(0.10.2 的 `overview` 仍先行单独取数、客户端半边尚无 `LlmEchoPatch`);`[0.10.2]` 下的「面板操作体感」节移入本节订正。
21
- - 影响面:`src/host/{snapshot-aggregate,routes/agnescode}.ts`、`src/client/{panel-page,provider-controls,model-picker,wire,agnescode-tab}.ts`、`test/routes.test.mjs`、`docs/`(ADR-014、ARCHITECTURE §3、PROVIDER-HOT-RELOAD、AGNES-API、PITFALLS)、`lib/` + `client.js`(随 src 同 commit 重建)。
18
+ ## 本文件的豁免与代价
22
19
 
23
- ## [0.10.2] — 2026-10-10
20
+ `test/docs.test.mjs` 对本文件**整文件豁免**(行号锚守卫、计数护栏、跨文件表格去重等一律跳过),理由是「不可变正是它们的价值」——历史记录不该被活文档的规矩追着改。
24
21
 
25
- ### 修复:视频状态轮询断点续查、额度渲染与模型花名册的几处真实缺陷 + 凭据隔离加固
22
+ **代价是没有任何门禁会拦住上面那些错误**,包括把账本条目整段抄进来。所以纪律靠本节的判据自查;改动本文件后手工跑一次 `node test/docs.test.mjs`,确认没碰坏其它检查(如发布版本陈述)。
26
23
 
27
- - **修(视频工具):异步视频轮询被拒时原本不带 `video_id` 且只走「超时 / 超轮次」两路径**,agent 首次查询拿不到 task id 就无法续查,任务卡在「查不到」。`video-client` 现改为携带 `video_id` 并有限重试(`queryRetries=2` → 初查 + 2 次重试 = 3 次调用),与超时 / 超轮次两条路径对齐。新增 `video.test.mjs` 覆盖耗尽路径与 `queryRetries=0` 边界。
28
- - **修(额度渲染):`usagePct` 有值而 `used` 缺失时,`count(null)` 被渲染成 `0`,面板会打印「已用 0 / N」**(F1)。`UsageTotals` 四个主计数另加 `typeof` 门:缺席字段不再画成「0」——真实数据流下此门不触发(`countOf` 已把缺席平台字段压成数字 0、`overview` 失败时 `quota.totals:null` 见红线⑥ / ADR-007),属渲染层自防御,防未来 partial 对象被构造出来。
29
- - **修(AgnesCode 花名册):两处目录正确性缺陷**。(F5)排除别名 `id === EXCLUDED_IDS[0]` 改为 `.includes(id)`,第二个别名不再静默失效、继续混进花名册;(F10)`provider-controls` 的 `idList` 用 `String(id)` 强转,`null` 会变成字符串 `"null"` 这个假模型 id 出现在候选列表里,改用 `models.ts` 的 `rosterIds`(只收非空字符串)。
30
- - **修(凭据隔离):AgnesCode 的 DPAPI PowerShell 子进程不再继承含凭据的父进程 env**,新增 `dpapiChildEnv` 剥除四枚凭据名(`AGNES_PASSWORD` / `AGNES_USERNAME` / `AGNES_TOKEN_PLAN_API_KEY` / `AGNESCODE_CREDENTIAL`),与全仓凭据 REF 集合逐字一致——降低解密第三方桌面端会话文件那一步的凭据泄漏半径(红线①)。`harvest attempts[].detail` 与 `state().error` 出口统一过 `redactSecrets`。
31
- - **内部加固 / 清理(不属公开行为变化,详见 `git log`)**:收敛三轮四域代码审查的约 22 项缺陷与计数器 / 方法归一化 / 单飞泄漏 / JSDoc 归位等低危项;删除 25 个零读者 style token(`details*` / `grant*` / `models*` / `trend*` / `poolsGrid` / `primaryHover`)与全仓零引用的孤儿 `jsqr` devDependency;统一 `countOf` 与 `Number(x)||0` 两处数值惯用法、`requestMethod` 六路由共用、BFF 门控头收敛为单一 `AGNESCODE_BASE_HEADERS`;CI `build-gate` 确保暂存目录存在再 `mkdtemp`。上述均以新增断言钉住,无破坏性改动。
32
- - 影响面:`src/{host,client}/`(多文件)、`lib/`、`client.js`(随 src 同 commit 重建,构建零漂移)、`test/`(多套件 + 断言)、`package.json`(版本 + 去孤儿依赖)。`npm test` 全绿:28 套件 + tsc / build / dup / e2e 四门禁通过,e2e 66/66 PASSED。
24
+ ## [Unreleased]
33
25
 
34
- ## [0.10.1] — 2026-10-06
26
+ ## [0.11.2] — 2026-10-17
35
27
 
36
- ### 修复:AgnesCode 截图里的真实账号昵称已脱敏(该图曾随 0.10.0 发布)
28
+ ### 修复:AgnesCode 写操作不再被在途的读盖回、胶囊在后台页面上不再轮询
37
29
 
38
- - **修(资产泄漏):`assets/panel-AgnesCode.png` 此前把面板上的真实关联账号昵称原样截进了市场页截图**——面板那一行渲染的是登录账号的昵称字段(`i18n` 的 `agnescode.loggedIn` 槽位 `已关联:{nick}`),截图取景时该值未做任何遮挡。昵称指向的是一个真实账号,**这不是示例数据**,因此 0.10.0 的 tarball 里那张图等同于把该账号名随包分发。图已重截,该行改为中性占位文案,账号名不再出现在任何像素里。
39
- - **这是 0.10.1 头号动机,优先级高于本节其余条目**:0.10.0 已发布且**不可覆盖**(npm 同版本号唯一,`unpublish` 后同号也不可复用),所以修复只能随新版本到达用户——这也是本版应当尽快发布的原因。
40
- - **处置边界要说清,避免误判风险面**:泄漏面**仅此一个二进制资源**。已核对 0.10.0 tarball 内全部文本类文件(`.md` / `.json` / `.yml`),账号名**无任何文本残留**;其余两张截图不含账号名。本仓红线条目(凭据不入库、错误文本脱敏)针对的是凭据与令牌,昵称属个人信息、不在凭据服务内,故**不触发凭据层面的处置流程**,但仍按泄漏修掉。
41
- - **`screenshots.json` 与文件名未变**:只换了图片内容,未增删文件,因此市场页的清单与 `files` 白名单不需跟着改(`files` 含 `assets`,重截自动随包发布)。
42
- - 影响面:`assets/panel-AgnesCode.png`(仅此一个文件,无代码改动)。
30
+ - **修(AgnesCode 面板)**:切换「启用 AgnesCode 提供方」开关、点「检测本机登录态」、保存模型清单之后,你看到的一直是操作后的状态。此前若操作发出前还有一次例行刷新在途,它的结果(操作前的)会在最长一个 60 秒周期里把操作后的状态盖回去——面板上会闪回旧值。
31
+ - **修(用量胶囊)**:页面处于后台(不可见)时挂载的胶囊,不再在没人看的时候持续轮询后端——每次轮询都占着一条连接;页面回到前台的瞬间立即读一次并恢复节奏。
32
+ - 影响面:`src/client/{agnescode-tab,composer-usage-pill}.ts`、`client.js`、`test/dom-render.test.mjs`。
43
33
 
44
- ### 登录表单服从 Host 下发的节流窗口:刷新页面不再让按钮在锁定期内可点
34
+ ## [0.11.1] — 2026-10-16
45
35
 
46
- - **修:跨进程 / 跨 profile 的锁定期内在面板上按一次就可能撞锁**。登录节流写在插件自己的 `throttle.json` 里,**机器级共享**(desktop profile 吃到的 429,在 web profile 的 Host 上同样真实),而 Host 一直把「剩余窗口」与「已停车」两个事实下发到快照的 `auth` 块。面板此前**不读它们**:倒计时从组件本地 state 起步,只有本进程那次登录失败才会被填上。刷新页面、换 profile、换 Host 进程这三种再正常不过的操作,都会让提交按钮在一个 Host 仍在服务的窗口里显示为可用——而 `POST /account` 是**刻意**清节流的(理由是「用户是在按面板的指示操作」),那次点击于是不止白费:它抹掉另一个进程正在遵守的记录,并把一次真实尝试打进可能已锁的账号。倒计时现由 `servedWaitMs` 判定、`servedWaitUntil` 换算,表单用前者播种并单调吸收后续窗口(只延后不缩短,避免把读者已在等的窗口提前解锁)。
47
- - **新增:已停车(`needsUserAction`)时给一句话而不是倒计时**。错密码、需人工验证、自造退避用尽这三类拒绝**没有**可等待的期限——倒计时数到零不会有任何东西重试,所以那个数字是撒谎。面板现在明说「已停止自动重试,请核对账号与密码后手动登录」并保持提交按钮可用(重新输入才是出路),与 `auth.locked`(平台说账号本身被锁)分作两句。
48
- - **修:验证码类拒绝会同时渲染两句矛盾的话**。`POST /account` 的失败体在「平台给了 `Retry-After`」时会同时带一个整段窗口与 `needsUserAction: true`,于是旧渲染路径上「已停止自动重试」与一个两小时倒计时同时出现。`submit` 现在让 parked 优先于窗口——`AccountForm` 与 `snapshot.ts` 的规则同源,此前只有后者。
49
- - **`API.md` 补上 `auth` 块两个节流字段的契约**(单位是**剩余**毫秒不是整段窗口;parked 时不许倒计时;面板必须服从它们而非只认自己那次失败),`AUTH.md` §7 与 `ARCHITECTURE.md` §3 数据流图同步接入。字段此前只有两个 JSON 值、没有任何语义说明,按字面读会把「parked 也倒计时」这类 bug 写出来。
50
- - 影响面:`src/client/{snapshot,account-form,i18n}.ts`、`test/{panel.test.mjs,panel-decision.js}`、`docs/{API,AUTH,ARCHITECTURE,ADR}.md`、`docs/PITFALLS.md`(新增 §52–§54)、`docs/ADR.md`(新增 ADR-010)、`client.js`(重建产物)。`lib/` 未改——本次全部改动在 client 半边。
36
+ ### 修复:用量胶囊首读失败不再卡死到会话结束、保存不再被旧轮询盖回
51
37
 
52
- ### 插件卡补上图标:Plugins 页不再裸显示包名 + 一段英文
38
+ - **修(用量胶囊)**:控制台额度的首次读取失败时,胶囊不再停在失败帧直到会话结束——现在按 30 秒节奏持续重试,一读成功即显示正常读数;轮询间隔自动以 Host 报出的值为准。
39
+ - **修(用量胶囊)**:模型选择器的 provider 被清空(空值)时,胶囊不再在恰好转帧上丢掉读数——上一次的选择仍可用就继续用它。
40
+ - **修(面板)**:保存提供方开关 / 模型清单 / 出图·视频工具卡后,刚保存的值不再被在途的旧轮询快照盖回旧值。
41
+ - 影响面:`src/client/{composer-usage-pill,panel-page}.ts`、`client.js`、`test/{dom-render,composer-slot-gate,config}.test.mjs`。
42
+
43
+ ## [0.11.0] — 2026-10-16
44
+
45
+ 本版两条主线:新能力**对话框用量胶囊**(输入框右侧的第二条读数),与一批**面板操作体感**改进(几处面板操作的等待时间缩短)。
46
+
47
+ ### 对话框用量胶囊:输入框右侧的第二条读数
48
+
49
+ - **新**:使用本插件模型的会话,输入框右侧出现一枚小胶囊。Token Plan 模型显示「5 小时 / 每周请求窗口」的使用百分比——与面板进度条同一把尺子,超额(>100%)照实显示不钳制;AgnesCode 模型显示桌面端积分余额——余额是存量、不造百分比(`0` 是「已耗尽」的读数,破折号是「读不到」)。其它 provider 模型的会话一律不显示,胶囊不发起任何读取。
50
+ - **软失败**:读数失败不影响面板与其它功能,胶囊保留最后一次成功读数并标记「上次数据」;胶囊本身注册失败时整个模块缺席,面板照常工作。
51
+ - 影响面:`src/client/{composer-usage-pill,const,apply,cards,agnescode-tab,i18n,styles,index}.ts`、`client.js`、`test/{composer-slot-gate,dom-render,render,contract}.test.mjs`。
52
+
53
+ ### 面板操作体感:几处面板操作不再等下一轮轮询
54
+
55
+ 可感变化(账号失效 / 控制台降级场景),四项:
56
+
57
+ - ① 快照取数全部并行:失效账号下面板取数最坏情形从 30 s 起压到 ~15 s。死令牌每源至多出示一次、每轮自动登录一次——不重新引入锁号。
58
+ - ② 保存提供方开关 / 模型清单 / 出图·视频工具卡不再触发全量快照 reload,关模型、切开关的效果不再等下一轮慢轮询。
59
+ - ③ AgnesCode 三个动作(开关 / 检测本机登录态 / 保存模型清单)即时生效,采集成功立刻翻出已关联卡片,不再多等一次最长 30 s。
60
+ - ④ AgnesCode 侧的两个 30 s 上游读(积分余额 / 模型清单)改并行,最坏 60 s → 30 s。
61
+ - 工具卡「实际生效模型」徽标与额度数字仍随 30 s 轮询更新(Host 计算值,本地不造数)。
62
+ - **归位说明**:以上四项曾被记在 `[0.10.2]` 节下的补记里,但已发布的 0.10.2 包内并不含其中任何一条;它们随本版实际落地,原节移入本节订正。
63
+ - 内部:快照并行的决策依据见 `docs/ADR.md` ADR-014。
64
+ - 影响面:`src/host/{snapshot-aggregate,routes/agnescode}.ts`、`src/client/{panel-page,provider-controls,model-picker,wire,agnescode-tab}.ts`、`lib/`、`client.js`、`test/routes.test.mjs`。
53
65
 
54
- - **新增 `icon.svg`**(`icon: "./icon.svg"`):Plugins 页的插件卡此前只有文字,卡片图标由 DSH Host 读 `package.json` 的 `icon` 字段渲染——不提供就缺一块。沿用面板的「积分币」母题(渐变圆盘 + 白色记数笔画),配色对齐本插件自己的品牌蓝 `--agnes-brand`(#1E40AF),与面板开关 / tab 下边框 / 额度进度条同一套 token,不跟 DSH 的中性色走。几何与兄弟插件 `dsh-connect-sensenova-token-plan` 保持一致,避免同系列插件卡片形态各异。
55
- - **读取端是硬校验,不是降级**:`icon` 必须是相对路径、扩展名限 SVG/PNG/JPEG/WebP、文件必须落在 `files` 白名单目录内、体积 ≤256 KiB——任一不满足会让整条插件元数据报错(该经验的出处是 `dsh-connect-sensenova-token-plan` CHANGELOG 0.4.4,已写进 `docs/PITFALLS.md` §27)。因此 `icon.svg` 同时进 `files` 白名单,`exports` 无需新增子路径(图标由 Host 直接按仓库路径读,不走包导出)。
56
- - 影响面:`icon.svg`、`package.json`、`docs/DSH-PLUGIN.md`(教学快照同步 `icon` 字段与 `files` 数组)。
66
+ ## [0.10.2] — 2026-10-10
67
+
68
+ ### 修复:视频任务续查、额度渲染与模型花名册的几处真实缺陷 + 凭据隔离加固
69
+
70
+ - **修(视频工具)**:异步视频任务首次查询被拒时现在携带任务 id、有限重试,任务可以续查——此前卡在「查不到」。
71
+ - **修(额度渲染)**:平台缺席某字段时不再显示「已用 0 / N」——缺席画成破折号,不画成 0。
72
+ - **修(AgnesCode 花名册)**:第二个排除别名不再静默失效;不再出现名为 `"null"` 的假模型候选。
73
+ - **修(凭据隔离)**:解密桌面 App 会话文件的本机子进程不再继承父进程的凭据环境(剥除 `AGNES_PASSWORD` / `AGNES_USERNAME` / `AGNES_TOKEN_PLAN_API_KEY` / `AGNESCODE_CREDENTIAL` 四枚凭据名),凭据可能出现的日志出口统一脱敏。
74
+ - **内部加固 / 清理**(不属公开行为变化,约 22 项低危缺陷与一个孤儿依赖的删除):详见 `git log`。
75
+ - 影响面:`src/{host,client}/`(多文件)、`lib/`、`client.js`、`test/`(多套件)、`package.json`。
76
+
77
+ ## [0.10.1] — 2026-10-06
78
+
79
+ > **如果你装过 0.10.0,请升级到本版。** 0.10.0 的市场页截图 `assets/panel-AgnesCode.png` 里含一个真实账号昵称(面板「已关联:{昵称}」那一行,截图时未遮挡)。该图随 0.10.0 发布,已发布版本不可覆盖,因此只能随新版替换。图已重截,该行改为中性占位文案,昵称不再出现在任何像素里;泄漏面仅此一个图片文件,其余两张截图与包内全部文本文件均无该账号名残留。
57
80
 
58
- ### 评审整改:红线④文档措辞更正 + 新增 ADR-012「大统一」爆炸半径权衡
81
+ ### 登录节流窗口改为全机共享:刷新页面不再让按钮在锁定期内可点
59
82
 
60
- - **修(文档 / 红线④):`AGENTS.md` 红线④称「`AUTH_DEFAULTS.consoleOrigin` 是唯一出处」与代码不符**。代码里后端源站字面量实际有两处——`agnes-auth.ts:47` 的 `AUTH_DEFAULTS.consoleOrigin` 与 `host-config.ts:41` 的 `CONFIG_DEFAULTS.consoleBase`——两者值一致,且由 `host-config.ts:323`(`resolveAuthOverrides` 在 `:384` 以 `consoleOrigin: consoleBase` 汇流)合并为单一 `consoleOrigin`,运行时是一条源、非漂移 bug。红线④现改为「两处字面量一致、配置边界汇流为单一 `consoleOrigin`,别写第三处也别让两处分叉」。
61
- - **新增(决策账本):`ADR-012` 把「大统一」的结构性爆炸半径列为已知权衡**。ADR-001 把额度面板升级为商汤全过程集成单点入口;实测同进程现承载 LLM provider 注册、出图执行、视频异步状态机,以及解密第三方桌面端 AgnesCode 的 os_crypt/DPAPI 会话文件(攻击面最敏感的一块)。隔离纪律真实(agnescode 与主线 publisher 共用 `publish-core.ts` 机制、不共用状态;`kind:"grant"` 单点构造/判定;throttle 与凭据 grant 刻意共享不统一),但结构性爆炸半径被重新集中——这正是对 PITFALLS §6「私有状态误进凭据服务会炸整台机器」那次教训的现代重演形态。判定:属设计张力非缺陷;主动缓解是 §5 不变量(五源软失败 / 一个模块缺席不埋别的模块)与 ADR-005 准入门槛 + 拆分蓝图把结构债从事后锐评变事前决策点。
62
- - 影响面:`AGENTS.md`(红线④措辞)、`docs/ADR.md`(新增 ADR-012)、本文件。
83
+ - **修**:登录节流窗口现在**机器级共享**——一个 profile 撞上的 429,其它 profile 的 Host 同样算数。此前倒计时只从本进程的失败起步,刷新页面、换 profile、重启 Host 三种正常操作都会让提交按钮在仍被节流的窗口里显示为可点,而那次点击不止白费——它还会抹掉另一个进程正在遵守的节流记录。现在倒计时由 Host 下发的共享窗口播种,只延长、不缩短。
84
+ - **新增**:「等下去也没用」的已停车情形(错密码、需人工验证、重试次数用尽),面板明说「已停止自动重试,请核对账号与密码后手动登录」,不再给一个数到零也不会重试的倒计时;与「账号被平台锁定」分作两句。
85
+ - **修**:验证码类拒绝同时带回等待窗口与「需人工处理」时,面板不再同时显示「已停止自动重试」与两小时倒计时这两句矛盾的话(人工处理优先)。
86
+ - 内部:节流窗口机制(`auth` 块两字段、parked 不倒计时、播种只延后)见 `docs/PITFALLS.md` §52 与 `docs/ADR.md` ADR-010;契约文档见 `docs/API.md`。
87
+ - 影响面:`src/client/{snapshot,account-form,i18n}.ts`、`test/{panel.test.mjs,panel-decision.js}`、`client.js`。
63
88
 
64
- ### 评审整改(续):ADR-012 债务控制阀落地为可执行术前自查
89
+ ### 插件卡补上图标:Plugins 页不再裸显示包名 + 一段英文
65
90
 
66
- - **把 ADR-012 的「债务控制阀」从账本里的已知权衡变成吸收决策的实际闸门**。原条目只说「后续新能力吸收先对照这条」,现在钉死成 ADR-005「申报容器」步骤里必填的一张**爆炸半径对照表**——四问:① 承重面归类(落在四类既有承重面之一,还是引入第五类共享承重面 → 自动触发拆分蓝图);② 新增共享状态(写盘 / 凭据引用 / 解密密钥访问是否复用既有机制,不得新开 state 窝点又不走蓝图);③ 失败域边界(会不会撞 ADR-004/007「一个模块缺席不许埋掉别的模块」);④ 基线可冻结(行为基线能否写进 store/routes 测试)。四问任一命中「引入新承重面 / 新 state 窝点 / 失败域外溢 / 基线不可冻」,先回 ADR-005 拆分蓝图或降级改造,而非带债落地。
67
- - 影响面:`docs/ADR.md`(ADR-012 债务控制阀段扩充)、本文件。
91
+ - **新增 `icon.svg`**:Plugins 页的插件卡此前只有文字;卡片图标由 DSH Host 读 `package.json` 的 `icon` 字段渲染,不提供就缺一块。图标沿用面板的「积分币」母题(品牌蓝),几何与兄弟插件 `dsh-connect-sensenova-token-plan` 保持一致。
92
+ - **图标读取是硬校验**:必须是相对路径、限 SVG/PNG/JPEG/WebP、落在 `files` 白名单内、体积 ≤ 256 KiB,任一不满足会让整条插件元数据报错——因此 `icon.svg` 同时进 `files` 白名单。
93
+ - 影响面:`icon.svg`、`package.json`。
68
94
 
69
95
  ## [0.10.0] — 2026-10-04
70
96
 
@@ -313,9 +339,9 @@
313
339
 
314
340
  - **面板第三个 tab「小浣熊」**(`src/client/raccoon-tab.ts`):开关、微信扫码登录、积分余额、模型花名册四块。文案中英双语(`raccoon.*` 17 键)。
315
341
  - **Host 半边 peer-free 协议层**(`src/host/raccoon.ts`):扫码登录走查、信封解析(成功判据是 `code === 0`,用普通 `Number` 比较——共享的 `num` 助手会把 `0` 当假值拒掉)、一次性 refresh-token 轮换、余额与目录读取、两态思考档位映射。
316
- - **凭据走 DSH 凭据服务**(`src/host/raccoon-store.ts`):存的是凭据服务引用而非明文;轮换后的新 pair 在续期时**回写**——这正是与「读桌面端文件」路线相比唯一能站住的地方。
342
+ - **凭据走 DSH 凭据服务**(`src/host/raccoon-store.ts`):存的是凭据服务引用而非明文;轮换后的新 pair 在续期时**回写**。
317
343
  - **开关按 profile 分段**(`src/host/raccoon-switch-store.ts`):与 provider/draw 同一套文件-backed 纪律。
318
- - **第二个独立 publisher**(`src/host/raccoon-publish.ts` + `raccoon-llm-adapter.ts`):与 Token Plan 的注册完全隔离,小浣熊侧怎么折腾都churn 不到主注册。
344
+ - **第二个独立 publisher**(`src/host/raccoon-publish.ts` + `raccoon-llm-adapter.ts`):与 Token Plan 的注册完全隔离。
319
345
  - **自包含 QR 编码器**(`src/client/qr.ts`,455 行,零外部依赖):byte mode、纠错等级 M、v1–10,登录 URL 直接在面板内编码成图。
320
346
  - **测试**:`test/raccoon.test.mjs`(离线,覆盖协议、两个 store、描述符映射、publisher 状态机、QR 编码器、开关 store),已进 `npm test` 与 CI offline 档。
321
347
 
@@ -326,24 +352,24 @@
326
352
  - **QR 要等下一轮 60s 轮询才出现**:登录入口像没反应。
327
353
  - **`fontSize` / `marginTop` 泄漏成 DOM 属性而非样式**:样式静默失效。
328
354
  - **扫码登录拿到的昵称没存**:面板无法称呼用户,补存后正常问候。
329
- - **小浣熊的积分倍率没进模型选择器**:面板花名册显示的 ×0.75 / free / ×0.2,在选择器里看不到(pi-ai 没有计费元数据通道,选择器只渲染名字)。倍率改挂进显示名——`GLM-5.3(×0.75)`、`SenseNova 6.8 Flash(free)`,倍率 1 保持裸名。两处同源,不会打架。
355
+ - **小浣熊的积分倍率没进模型选择器**:面板花名册显示的 ×0.75 / free / ×0.2,在选择器里看不到(pi-ai 没有计费元数据通道,选择器只渲染名字)。倍率改挂进显示名——`GLM-5.3(×0.75)`、`SenseNova 6.8 Flash(free)`,倍率 1 保持裸名。
330
356
 
331
357
  ### 面板从侧边栏归位到 Plugins 页
332
358
 
333
359
  - **去掉 `sidebar.panellist` 与 `main` 槽位注册**,只留 `plugins.bundle.config`:面板改为 Plugins 页内的**内联卡片**,不再占侧边栏一行(`src/client/panel-page.ts`、`src/client/index.ts`)。同时摘掉 layout 服务依赖与 onClose 按钮(Plugins 页自己管导航),`PanelPage` 的 `onClose` 改为按传入与否决定渲染。
334
- - 这解决了「装了 5 个 connect 插件 = 侧边栏 5 行噪音」的问题,与 workbuddy 的形态对齐。
360
+ - 解决了「装了 5 个 connect 插件 = 侧边栏 5 行噪音」的问题,与 workbuddy 的形态对齐。
335
361
 
336
362
  ### 小浣熊模型列表:行形态与 Token Plan 花名册对齐
337
363
 
338
- 原来每个模型只有「名字 + 一个 English 徽章」,参数行整块缺席,读起来像个半成品。数据其实**早就在手上**——网关的目录行带 `context_window` / `max_output_tokens`,`fetchRaccoonCatalog` 自首个版本就归一化了这两个字段,`raccoonRoster` 也一路保留着,只有这个渲染组件把它们丢了:
364
+ 原来每个模型只有「名字 + 一个 English 徽章」,参数行整块缺席。数据其实**早就在手上**——网关的目录行带 `context_window` / `max_output_tokens`,`fetchRaccoonCatalog` 自首个版本就归一化了这两个字段,只有渲染组件把它们丢了:
339
365
 
340
- - **补上参数行**:「256K 上下文 · 最大输出 64K」,复用 Token Plan 花名册的 `S.modelMeta` 与同两条文案模板(`llm.contextBadge` / `llm.metaOutput`)。目录没声明该字段时**不画这一段**(`num()` 对缺失值返回 `undefined`,不兜底编数)——面板只引述平台真给的东西。
366
+ - **补上参数行**:「256K 上下文 · 最大输出 64K」,复用 Token Plan 花名册的 `S.modelMeta` 与同两条文案模板(`llm.contextBadge` / `llm.metaOutput`)。目录没声明该字段时**不画这一段**(`num()` 对缺失值返回 `undefined`,不兜底编数)。
341
367
  - **倍率改为独立 chip**(`S.modelRate`,与 Token Plan 同款),`0` 显示 `free` 而不是 `×0`(零倍率是对模型的事实陈述,不是一个为零的费率);新增 `raccoon.free` 键补齐 i18n。
342
368
  - **倍率 tooltip 不复用 `llm.rosterRateTitle`**:那句话写的是「积分消耗**伪**倍率(自定义对比用,**非官方**)」,因为 Token Plan 的倍率确实是操作者在插件里配的;而小浣熊的倍率是**网关目录直接声明的字段**,借那句 tooltip 等于给真数据贴上"编的"标签。新增 `raccoon.rateTitle`「网关目录声明的积分倍率(0 为免费)」,并加断言钉住别再用错。
343
369
  - **`· vision` 硬编码英文改为 `llm.rosterVision`「可看图」徽章**,靠右对齐——原先那个 `" · vision"` 是拼在倍率徽章里的中文字典里的英文裸串,既没走 i18n 也没走统一徽章样式。
344
370
  - **给列表一个框**(新增 `S.modelPanel`):与同 tab 里「已登录」块(`S.card`)同级的容器。行的分隔线样式**不变**——仓库既有原则是「section card 持有唯一的框、行不重画框」,缺的是列表这一层的容器,不是给每行描边。
345
371
 
346
- `test/render.test.mjs` 补 5 条断言(150 → 155):参数行在有字段时出现、在没字段时不出现为空行、费率 chip 渲染、free 不带 `×0`,以及**思考档位必须缺席**——该 provider 注册的是 `reasoning: false`(pi-ai 发不出网关唯一有效的 `extra_body.thinking`),照抄 Token Plan 那行「思考 关闭/低/中/高」等于承诺一个 DSH 选择器永远不会给的档位。均做过「删参数行 / 填入思考档位 → 红 → 还原 → 绿」验证。
372
+ `test/render.test.mjs` 补 5 条断言(150 → 155):参数行在有字段时出现、在没字段时不出现为空行、费率 chip 渲染、free 不带 `×0`,以及**思考档位必须缺席**——该 provider 注册的是 `reasoning: false`(pi-ai 发不出网关唯一有效的 `extra_body.thinking`),照抄 Token Plan 那行「思考 关闭/低/中/高」等于承诺一个 DSH 选择器永远不会给的档位。
347
373
 
348
374
  ### 「接入 API」tab:卡片改名、重排、默认展开
349
375
 
@@ -355,7 +381,7 @@
355
381
 
356
382
  同时修掉一处被这次重排坑到的文案漂移:`llm.rosterEmpty` 原写「在**上方**保存一次 API Key」,而它所指引的 API Key 卡已经换到**下方**——方向词在换序后必然指错,改为直接点名「API Key」卡片(中英两侧同步;`draw.off`、`llm.noKey` 那两处「上方」指的是**同卡片内**的开关与输入框,不跨卡,保持不变)。
357
383
 
358
- `test/panel.test.mjs` 补三条断言把这次的决定钉住:三张卡的渲染顺序、默认展开集合、跨卡指引必须点卡片名而非方向词(均做过「故意改回旧序 / 改回 false → 红 → 还原 → 绿」验证)。
384
+ `test/panel.test.mjs` 补三条断言:三张卡的渲染顺序、默认展开集合、跨卡指引必须点卡片名而非方向词。
359
385
 
360
386
  ### 截图与市场清单对齐(`docs` 门禁新增检查 `SCREENSHOTS`)
361
387
 
@@ -363,13 +389,13 @@
363
389
 
364
390
  - **清单对齐当前两张**:`assets/panel-credit.png`(「积分额度」tab)、`assets/panel-API-provider.png`(「接入 API」tab);「小浣熊」那张待补。
365
391
  - **`docs.test.mjs` 新增检查 `SCREENSHOTS`**:清单声明的每条路径必须**真实存在于磁盘**、是图片扩展名、条目数 1–8、且为仓库根相对路径。三种破坏实测红过再还原复验绿(换回旧名 / `..` 逃逸 / 拿 `README.md` 当条目)。
366
- - **`PITFALLS` 新增 §26**(条目数 25 → 26,三处引用由门禁当场抓出并同步):**清单类文件(`.json` / `.yml` / `.toml`)不是文档,但同样是契约**——改资产名前先问「还有谁按名字引用它」;本仓库按名字引用 `assets/` 的机器消费方只有 `screenshots.json` 一处,正是「只有一处 + 又不叫 `.md`」让它成了盲区。
367
- - **`CONTRIBUTING` §8** 那句「重截并推送后截图自动生效(**文件名不变**)」已修正:它藏了个未言明的前提——**改名就必须同步清单**,而这次恰恰是换了名。
392
+ - **`PITFALLS` 新增 §26**(条目数 25 → 26,三处引用由门禁当场抓出并同步):**清单类文件(`.json` / `.yml` / `.toml`)不是文档,但同样是契约**——改资产名前先问「还有谁按名字引用它」。本仓库按名字引用 `assets/` 的机器消费方只有 `screenshots.json` 一处,正是「只有一处 + 又不叫 `.md`」让它成了盲区。
393
+ - **`CONTRIBUTING` §8** 那句「重截并推送后截图自动生效(**文件名不变**)」已修正:它藏了个未言明的前提——**改名就必须同步清单**。
368
394
 
369
395
  ### 模型花名册重排(WorkBuddy 形态)
370
396
 
371
397
  - **行去卡内框、改用分隔线**:`modelRow` 由横向 flex 行改为纵向列(头行 + 缩进的参数行),新增 `modelRowHead` 包裹头行;徽章只标 notable 态(删「纯文本」徽章、补「额度耗尽」徽章)。工具行计数成组右置,批量按钮 32px 与搜索框等高。
372
- - **每行只留会变的 per-model 事实**:参数段从「上下文 · 最大输出 · 默认思考强度」改为**本模型实际可选档位**——「思考 关闭/低/中/高/极高[/最高]」,由 Host 新导出的 `supportedThinkingLevels` 按 pi-ai `getSupportedThinkingLevels` 同一规则过滤(与 DSH 选择器同源),glm-5.2 独显「最高」。provider 级常数(默认思考强度)收进花名册头部只说一次(`llm.rosterThinkingDefault`),**删掉逐行重复的恒定默认档**——用户锐评「恒定默认档逐行重复=噪音」,该反模式记于 `docs/ARCHIVE-IMPROVEMENTS-2026-09.md` §7(2026-10-05 归档)。
398
+ - **每行只留会变的 per-model 事实**:参数段从「上下文 · 最大输出 · 默认思考强度」改为**本模型实际可选档位**——「思考 关闭/低/中/高/极高[/最高]」,由 Host 新导出的 `supportedThinkingLevels` 按 pi-ai `getSupportedThinkingLevels` 同一规则过滤(与 DSH 选择器同源),glm-5.2 独显「最高」。provider 级常数(默认思考强度)收进花名册头部只说一次(`llm.rosterThinkingDefault`),**删掉逐行重复的恒定默认档**。
373
399
  - **`tokenSize` 修正 1049k → 1M**:千整走十进制、纯二进制走 1024、≥1M 归 M。Host 侧新增 `maxOutputLength` 投影(`0` = 未声明则整段不画,绝不猜)。
374
400
  - **`matchMultiplier` 单一匹配器**:趋势行 ×N 与花名册 ×N 同源同值,两处显示不可能不一致。
375
401
 
@@ -383,36 +409,23 @@
383
409
 
384
410
  - 账号表单标题下与 API Key 表单底部的官网链接**两种状态都显示**:无账号时「前往官网注册」,已有账号时「前往官网管理额度 / 获取 API Key」(新键 `auth.portalHint`,中英双语;URL 入 `const.ts`)。
385
411
 
386
- ### 思考档位收敛到「实测过 200 才画」(面板不再过度承诺 low/medium/xhigh)
387
-
388
- 面板花名册每行原来把 `low/medium/xhigh` 对**所有**商汤模型都画出来,但冻结契约表
389
- `test/baselines/sensenova-contract.json` 里 `xhigh` 只实测过 deepseek-v4-flash(200)、
390
- `max` 只实测过 glm-5.2(200),`low`/`medium` 对任何模型都**从未实测**(代码依据只是
391
- 平台 400 报错文案里出现过这两个词,而那串列表是**并集**、各模型支持面不同)——用户点了某家不支持的档就可能 400。
392
- 现把 `thinkingLevelMapFor` 改为**逐模型门控**(新常量 `PROBED_EFFORT`,数据抄自同一张
393
- 冻结契约,覆盖 low/medium/high/xhigh/max 五格):`high`(平台默认,全家族实测过)保持
394
- 开,`xhigh` 只对 deepseek-v4-flash 开,`max` 只对 glm-5.2 开;未收录模型默认全关。
395
-
396
- **2026-09-30 探针补测后**:`test/live-contract.mjs` 扩了 `reasoning_effort:low/medium`
397
- 探针(2s 退避;429 记 INDEFINITE 不当判读——限流是节奏问题不是参数拒绝,只有 4xx 才
398
- 算"不支持"的负证据)。首跑实锤 200 的格子:sensenova-6.8-flash-lite(low+medium)、
399
- **deepseek-v4-flash(low+medium,你原问的那行——低中档平台真支持)**、glm-5.2(low+medium)、
400
- deepseek-flash(medium)、kimi-k3(medium)→ 这些格已翻 `true`,对应面板行放出低/中档。
401
- 仍 INDEFINITE 的(429 rpm 窗口,非 400,保持关、重跑再翻):deepseek-v4-pro 的 low+medium、
402
- deepseek-flash 的 low、kimi-k3 的 low。
403
-
404
- 测试同步:`contract` §2 加逐模型 low/medium 断言(读契约表,`true`→开、`indefinite`/`false`→关)、
405
- `provider` §1(v4-flash/glm-5.2/flash-lite 的 low+medium 开、INDEFINITE 格关)、`retry` §5、
406
- `routes` Q2、`render` G4(v4-flash 那行 = 关闭/低/中/高/极高)。文档同步:`API.md` 样例
407
- 行与字段注记、`IMPROVEMENTS.md` §7 修订、本条。
412
+ ### 思考档位收敛到「实测过 200 才画」
413
+
414
+ 面板花名册每行原来把 `low/medium/xhigh` 对**所有**商汤模型都画出来,但冻结契约表 `test/baselines/sensenova-contract.json` 里 `xhigh` 只实测过 deepseek-v4-flash(200)、`max` 只实测过 glm-5.2(200),`low`/`medium` 对任何模型都**从未实测**(代码依据只是平台 400 报错文案里出现过这两个词,而那串列表是**并集**、各模型支持面不同)——用户点了某家不支持的档就可能 400。
415
+
416
+ 现把 `thinkingLevelMapFor` 改为**逐模型门控**(新常量 `PROBED_EFFORT`,数据抄自同一张冻结契约,覆盖 low/medium/high/xhigh/max 五格):`high`(平台默认,全家族实测过)保持开,`xhigh` 只对 deepseek-v4-flash 开,`max` 只对 glm-5.2 开;未收录模型默认全关。
417
+
418
+ **2026-09-30 探针补测后**:`test/live-contract.mjs` 扩了 `reasoning_effort:low/medium` 探针(2s 退避;429 记 INDEFINITE 不当判读——限流是节奏问题不是参数拒绝,只有 4xx 才算"不支持"的负证据)。首跑实锤 200 的格子:sensenova-6.8-flash-lite(low+medium)、deepseek-v4-flash(low+medium)、glm-5.2(low+medium)、deepseek-flash(medium)、kimi-k3(medium)→ 这些格已翻 `true`,对应面板行放出低/中档。仍 INDEFINITE 的(429 rpm 窗口,非 400,保持关、重跑再翻):deepseek-v4-pro 的 low+medium、deepseek-flash 的 low、kimi-k3 的 low。
419
+
420
+ 测试同步:`contract` §2 加逐模型 low/medium 断言(读契约表,`true`→开、`indefinite`/`false`→关)、`provider` §1(v4-flash/glm-5.2/flash-lite 的 low+medium 开、INDEFINITE 格关)、`retry` §5、`routes` Q2、`render` G4(v4-flash 那行 = 关闭/低/中/高/极高)。文档同步:`API.md` 样例行与字段注记、`IMPROVEMENTS.md` §7 修订、本条。
408
421
 
409
422
  ### 可见性修复三连:登录态常显 + provider 注册 pending 态 + 小浣熊错误不再被开关藏起
410
423
 
411
424
  同族问题——「状态翻成某个值后,操作入口/错误行跟着消失,用户拿不到重入路」:
412
425
 
413
- - **登录态与账号编辑器无条件可见**(中间态不再是死路):此前「连接商汤控制台」区卡只在 `hasAccount || needsAccount` 时出现——中间态(已点过「清除账号」、grant 仍在静默续期)下整卡消失,要等 refresh_token 彻底失效才重新拿到重输入口。现改为**只要快照携带 auth 块就常显**,积分额度页底部永远有该卡(默认折叠、一键展开):随时可核对登录态、重输账号改指仍有效的 grant、或再清一次账号。头部「需要重新登录」chip 的 tooltip 在 Host 没给 reason 时回落 guidance 文案,不再留空白。
414
- - **provider 注册 pending 态**:开关已开、服务在、无 error,但注册没落地时,原文案谎报「未注册——勾选上方开关」(指向已勾选的开关);新增 `llm.registeredPending` 直说 pending。同时修分支序:具体失败(providerError)优先于能力缺失(noService),两者同时成立时不再吞错误。
415
- - **小浣熊 tab 注册状态行不再被开关门控**:providerError 原来只在 `enabled === true` 时渲染,开关一关报错就消失;现 `state !== null` 即渲染,失败行与开关解耦。
426
+ - **登录态与账号编辑器无条件可见**(中间态不再是死路):此前「连接商汤控制台」区卡只在 `hasAccount || needsAccount` 时出现——中间态(已点过「清除账号」、grant 仍在静默续期)下整卡消失,要等 refresh_token 彻底失效才重新拿到重输入口。现改为**只要快照携带 auth 块就常显**,积分额度页底部永远有该卡(默认折叠、一键展开)。头部「需要重新登录」chip 的 tooltip 在 Host 没给 reason 时回落 guidance 文案。
427
+ - **provider 注册 pending 态**:开关已开、服务在、无 error,但注册没落地时,原文案谎报「未注册——勾选上方开关」(指向已勾选的开关);新增 `llm.registeredPending` 直说 pending。同时修分支序:具体失败(providerError)优先于能力缺失(noService)。
428
+ - **小浣熊 tab 注册状态行不再被开关门控**:providerError 原来只在 `enabled === true` 时渲染,开关一关报错就消失;现 `state !== null` 即渲染。
416
429
  - **构建修复**:`qr.ts` 内 8 处 `x?.y = v`(可选链左值赋值)是解析错误,tsdown 直接挂掉——改为先索引后赋值。
417
430
  - **测试**:`test/panel.test.mjs` B2 组翻转(中间态/死 grant 态都钉 `canManageAccount === true`);`panel-decision.js` 镜像同步去门控;`render.test.mjs` 新增 pending 态与 error 优先序 2 项 pin。
418
431
 
@@ -420,24 +433,22 @@ deepseek-flash 的 low、kimi-k3 的 low。
420
433
 
421
434
  上报现象:全新装好插件打开面板,看不到三个 tab,整页只有一个登录表单——**API Key tab 和小浣熊 tab 也一起消失了**,尽管它们一个根本不读控制台、一个连的是另一个上游。
422
435
 
423
- 根因是**结构性设计缺陷,不是移植失误**(源版同样存在):控制台探针 `overview` 被当作**唯一致命源**,失败即整个快照走 `ok:false`;客户端又把 tab 栏放在 `data` 分支里,于是「控制台没登录」这一件事把三个互相独立的模块全埋了。这正是 `AGENTS.md` 模块独立性不变量禁止的形状。
424
-
425
- 两层修复 + 一条诚实性补丁:
436
+ 根因是**结构性设计缺陷,不是移植失误**(源版同样存在):控制台探针 `overview` 被当作**唯一致命源**,失败即整个快照走 `ok:false`;客户端又把 tab 栏放在 `data` 分支里,于是「控制台没登录」这一件事把三个互相独立的模块全埋了。
426
437
 
427
- - **Host(`snapshot-aggregate.ts`)**:`overview` 从致命源降级为五个可降级源之一(`soft()`),新增 `quota.consoleConnected`;失败原因(`not_configured` / `auth_error` / `console_error`)走 `quota.error.code`。它仍**串行先取**——401 时先换新令牌,后面批量才拿活令牌出门(`test/routes.test.mjs` B 组的「死令牌只被出示一次」仍是绿的)。
438
+ - **Host(`snapshot-aggregate.ts`)**:`overview` 从致命源降级为五个可降级源之一(`soft()`),新增 `quota.consoleConnected`;失败原因(`not_configured` / `auth_error` / `console_error`)走 `quota.error.code`。它仍**串行先取**——401 时先换新令牌,后面批量才拿活令牌出门。
428
439
  - **客户端(`panel-page.ts`)**:tab 栏从 `data` 分支提到顶层,**第一帧就渲染**;`data === null` 时登录表单变成额度 tab 的**内容**而不是整页。控制台未连接时额度 tab 内点名原因,并一次性自动展开账号卡(用 `useRef` + `useEffect`,因为 `open` 是受控 prop,写成 `open: openSections.account || needsSetup` 会让卡片再也收不起来)。
429
- - **诚实性补丁**:`quota.totals` 在读不到时是 **`null`,不是零值块**。`parseUsageOverview(null)` 的每个计数器都经 `countOf` 落到 `0`,直接渲染会把一次读取失败伪装成「0 次请求 / 0 token」——把失败变成测量。面板本就有 `null` → 「暂未读到」的分支,所以这一条只改 Host 侧。
440
+ - **诚实性补丁**:`quota.totals` 在读不到时是 **`null`,不是零值块**。`parseUsageOverview(null)` 的每个计数器都经 `countOf` 落到 `0`,直接渲染会把一次读取失败伪装成「0 次请求 / 0 token」——把失败变成测量。
430
441
  - **顺带暴露的副作用**:未登录的 Host 现在也会去取**公开套餐目录**(它是控制台静默时唯一还剩的额度内容),`test/wiring.test.mjs` 此前从未 stub 过它,已补。
431
442
  - **测试**:`routes` +8、`wiring` +2、`e2e` +2、`render` +2(含「第一帧就带三个 tab」的回归);`docs/API.md` / `ARCHITECTURE.md` §3 §5 同步。客户端两个 tab 的行为未变,只是重新可达。
432
443
 
433
444
  **上线实测又挖出一个缺口(同日补)**:重启 web 后快照确实变成 `ok:true` / `consoleConnected:false` / `totals:null`,但 `quota.error.code` 是 **`login_rejected`**——而 `GUIDANCE_BY_CODE` 只覆盖了 5 个码。根因是这次降级的连带效应:`AUTH_FAILURE_CODES` 里 13 个码现在**都能**以 `quota.error.code` 抵达面板(以前它们只会变成整份 `ok:false`,面板只读顶层 `code`),表却没跟着长。后果是面板退化成泛泛的「控制台未连接」,丢掉「账号需要重新输入」这个唯一有用的信息。
434
445
 
435
446
  - `GUIDANCE_BY_CODE` 补齐到 `AUTH_FAILURE_CODES ∪ NO_LOGIN_CODES` 全覆盖:令牌侧(`auth_error` / `jwt_expired` / `no_refresh_token` / `refresh_rejected` + 四个已死的 OIDC 期码)统一给「令牌失效,重登一次」;账号侧 `not_configured` / `missing_credentials` 给「还没配账号」;四种凭据拒绝**复用表单自己的文案**(`auth.badCredentials` / `auth.locked` / `auth.rateLimited` / `auth.verification`)——它们的下一步动作各不相同(重输 / 等解锁 / 等限频 / 完成人工验证),塌成一句「登录失败」会对其中三种给出错误建议。
436
- - **补上反向检查**(`test/panel.test.mjs`):此前只断言「表里的 key 都是真码」,现在同时断言「能抵达面板的码都有文案」。缺的正是这一半,而且是被线上抓到的,不是被测试抓到的。
447
+ - **补上反向检查**(`test/panel.test.mjs`):此前只断言「表里的 key 都是真码」,现在同时断言「能抵达面板的码都有文案」。
437
448
 
438
449
  ### 修复:浏览器把保存的商汤账号填进模型搜索框
439
450
 
440
- 上报现象:密码管理器把 SenseNova 控制台的账号自动填进插件的输入框,**连「接入 API」tab 的模型搜索框也被塞了账号**,看着像浏览器按关键词猜中了什么。
451
+ 上报现象:密码管理器把 SenseNova 控制台的账号自动填进插件的输入框,**连「接入 API」tab 的模型搜索框也被塞了账号**。
441
452
 
442
453
  **不是关键词,是我们主动声明的,而且同 origin 上还有第二个一模一样的表单**:
443
454
 
@@ -447,8 +458,8 @@ deepseek-flash 的 low、kimi-k3 的 low。
447
458
 
448
459
  修法:给模型搜索框加 `autoComplete="off"` 与 `name="model-search"`。**账号表单的 `username`/`current-password` 语义刻意保留**(浏览器记住 Agnes 账号是想要的能力),本次只堵「去表单外找用户名」这条路径。
449
460
 
450
- - `render.test.mjs` 新增 I2 组:渲染全部 7 个可能含输入框的组件,断言每个可填文本输入框要么 `autoComplete="off"`、要么声明凭据角色,并把「唯一声明凭据角色的是账号表单」钉死。该检查实测会红再复绿。
451
- - 机制与证据写进 `docs/PITFALLS.md` §27(同 origin 多插件会把这一点放大成「凭据互相串门」)。
461
+ - `render.test.mjs` 新增 I2 组:渲染全部 7 个可能含输入框的组件,断言每个可填文本输入框要么 `autoComplete="off"`、要么声明凭据角色,并把「唯一声明凭据角色的是账号表单」钉死。
462
+ - 机制与证据写进 `docs/PITFALLS.md` §27。
452
463
 
453
464
  ### 修复:额度已经正常显示,头部却一直挂「需要重新登录」
454
465
 
@@ -457,45 +468,46 @@ deepseek-flash 的 low、kimi-k3 的 low。
457
468
  - **根因**:`state.lastError` 由 `getToken()` 的 `acquire()` 路径写入、也由那条路径清除,但 `getToken()` 在缓存令牌仍然新鲜时会**提前 return**,根本不进 `acquire()`;而面板上的手动登录是**直接存令牌**的,从不经过 `acquire()`。一次失败的自动重登写上这个字符串,用户手动登录成功后节流记录被清了、**这个字符串没清**,新令牌又足够新鲜——清除路径再也不会跑到。
458
469
  - **修法**:不逐个写入点补清除,而是把上报钉在结果上——`state()` 里 `error: isFresh(stored) ? null : …`。`auth.error` 的契约是「面板该向用户要账号」,持有一个控制台没拒绝过的令牌时就不存在当前错误。
459
470
  - **同族**:`AccountForm` 的「已保存并登录,正在读取额度…」也从不落地(`saved` 只被「清除账号」复位),改由父组件传 `hasSnapshot` 门控——独立表单照旧显示,面板内嵌不再显示。**这条没有测试覆盖**(测试替身的 `useState` 只返回初值,进不去 `saved`),是记录在案的缺口。
460
- - **测试**:`store.test.mjs` 8c 组补两处断言(parked 期间必须报错、改正之后必须不报),实测会红并精确报出用户看到的那句原文,再还原复绿;冻结基线零漂移。机制写进 `docs/PITFALLS.md` §28。
471
+ - **测试**:`store.test.mjs` 8c 组补两处断言(parked 期间必须报错、改正之后必须不报);冻结基线零漂移。机制写进 `docs/PITFALLS.md` §28。
461
472
 
462
473
  ### 运维诊断 doctor(PITFALLS §22 的欠账)
463
474
 
464
475
  回答「这台机器的 provider / 出图开关到底开没开」——此前唯一答案在一个 JSON 状态文件里,不在任何配置文件、任何路由、任何 CLI。
465
476
 
466
- - **新增 `src/host/doctor.ts`(peer-free 纯读层)+ `tools/doctor.mjs` CLI**:读 `$DSH_HOME/state/<name>/` 与每个 `<profile>/<name>/` 下的 `provider.json` / `draw.json` / `catalog.json`,报「面板保存值 > 部署默认值」的生效开关、模型允许清单、以及「文件存在但读不成」时**点名**是哪个文件(损坏/外来版本不会静默变空)。`npm run doctor`(人读)/ `npm run doctor:json`(机器读)。
467
- - 与「大统一」定位对齐:单点入口必须可查,doctor 是第一条查询通道;它不写任何文件、不碰凭据,只在磁盘上读,Host 没起也能跑。
468
- - **测试**:`test/doctor.test.mjs`(23 项,离线门禁)——三套 payload 解析器的「损坏/外来版本读作未设」方向、profile 分段与 shared 布局互不误认、干净机器/损坏文件的人读文案;进 `npm test` 与 CI offline 档。
477
+ - **新增 `src/host/doctor.ts`(peer-free 纯读层)+ `tools/doctor.mjs` CLI**:读 `$DSH_HOME/state/<name>/` 与每个 `<profile>/<name>/` 下的 `provider.json` / `draw.json` / `catalog.json`,报「面板保存值 > 部署默认值」的生效开关、模型允许清单、以及「文件存在但读不成」时**点名**是哪个文件。`npm run doctor`(人读)/ `npm run doctor:json`(机器读)。
478
+ - 它不写任何文件、不碰凭据,只在磁盘上读,Host 没起也能跑。
479
+ - **测试**:`test/doctor.test.mjs`(23 项,离线门禁),进 `npm test` 与 CI offline 档。
469
480
 
470
481
  ### 边界裁定:第二上游正式纳入界内(不变量 3 修订)
471
482
 
472
483
  Token Plan 与小浣熊同为商汤旗下产品线,`sensenova` 这个名头名副其实,第二上游不再是「待定方向的破例」。
473
484
 
474
- - **`ARCHITECTURE.md` §5 不变量 3 修订**:划线依据从「只吸收与商汤 **Key/账号线**强相关的能力」改为「只吸收与**商汤(SenseTime)产品线**强相关的能力」。原划法按 Key 域名 / 认证域划线,会把同一厂商的姐妹产品线误判到界外——第二上游由此从「撞边界、须先定方向」变为**界内**。另两条不变量(opt-in 默认关、凭据红线)不变。
475
- - **新增 §5.5** 记录裁定本身,**三条独立举证**而非印象:中证网 2026-07-19(U1 Pro 能力「在**商汤旗下的**产业级 AI『小浣熊』及 Seko 中已得到深度验证」)、商汤官方稿件 2026-09-21(「**商汤小浣熊 Raccoon Work**」由商汤科技打造)、以及本仓库既有措辞(ROADMAP §6.1 早写着「接入**商汤小浣熊**桌面 App 模型」——边界这次才承认,事实一直在那儿)。
485
+ - **`ARCHITECTURE.md` §5 不变量 3 修订**:划线依据从「只吸收与商汤 **Key/账号线**强相关的能力」改为「只吸收与**商汤(SenseTime)产品线**强相关的能力」。原划法按 Key 域名 / 认证域划线,会把同一厂商的姐妹产品线误判到界外。另两条不变量(opt-in 默认关、凭据红线)不变。
486
+ - **新增 §5.5** 记录裁定本身,**三条独立举证**:中证网 2026-07-19(U1 Pro 能力「在**商汤旗下的**产业级 AI『小浣熊』及 Seko 中已得到深度验证」)、商汤官方稿件 2026-09-21(「**商汤小浣熊 Raccoon Work**」由商汤科技打造)、以及本仓库既有措辞(ROADMAP §6.1 早写着「接入**商汤小浣熊**桌面 App 模型」)。
476
487
  - **它与 Token Plan 的确切关系**(后来人据此判断还能不能再放宽):**同一厂商、不同产品线、认证域互不相通、积分口径互不算**。所以实现上必须继续是独立凭据 / 独立 publisher / 独立 provider id——共享任何一样都等于把两条产品线焊死。它是**第二上游**,与 §6.1.1 判死的**第二登录路径**不是同一件事。
477
- - **防止口子被开成无限大**:本次放宽只覆盖「同一厂商下的产品线」,**跨厂商聚合**(codearts 那类一套形态融 N 家)依旧在界外。以后每纳入一条新产品线必须同时落三条——① 外部可核的厂商举证;② 与 Token Plan 的具体关系,尤其是积分与认证域是否通用;③ 齐全后才允许以 opt-in 默认关的形态进入树干。缺一条就退回 publisher 隔离那条路。
478
- - **同步**:`ROADMAP.md` §6.1.1 的「须先定方向——不默认吸收」改为**方向已定**;`AGENTS.md` 第三条事实改为「同属商汤旗下,但认证域不相通」。§6.1.1 判死第二**登录路径**的两次复测结论**不变**——它与「第二上游属不属界内」不是同一件事,别混为一谈。
488
+ - **防止口子被开成无限大**:本次放宽只覆盖「同一厂商下的产品线」,**跨厂商聚合**(codearts 那类一套形态融 N 家)依旧在界外。以后每纳入一条新产品线必须同时落三条——① 外部可核的厂商举证;② 与 Token Plan 的具体关系,尤其是积分与认证域是否通用;③ 齐全后才允许以 opt-in 默认关的形态进入树干。
489
+ - **同步**:`ROADMAP.md` §6.1.1 的「须先定方向——不默认吸收」改为**方向已定**;`AGENTS.md` 第三条事实改为「同属商汤旗下,但认证域不相通」。
479
490
 
480
491
  ### 第二上游补复测 + 文档追平(一次自审掀开的三处「文档与代码分头走路」)
481
492
 
482
- - **README 不再说谎**(它进 npm 的 `files` 白名单,是用户装完后读到的唯一说明书):此前 ① **零处**提到当期头条特性——第三个 tab「小浣熊」;② 三处仍写「侧边栏」,而代码早已迁到 Plugins 页,第 31 行「打开侧边栏「积分面板」」会让人**找不到入口**(操作级失效,不是文字洁癖);③ 仍挂着「面板**只读**」的承诺,而它已经能注册推理通道、挂出图工具、写 DSH settings。三处全部改正,并补齐「出图工具」与「第二个上游:小浣熊」两个真正的能力小节(含 provider id `sensenova-raccoon`、工具挂载需重启的事实)。
493
+ - **README 不再说谎**(它进 npm 的 `files` 白名单,是用户装完后读到的唯一说明书):此前 ① **零处**提到当期头条特性——第三个 tab「小浣熊」;② 三处仍写「侧边栏」,而代码早已迁到 Plugins 页,会让人**找不到入口**;③ 仍挂着「面板**只读**」的承诺,而它已经能注册推理通道、挂出图工具、写 DSH settings。三处全部改正,并补齐「出图工具」与「第二个上游:小浣熊」两个真正的能力小节(含 provider id `sensenova-raccoon`、工具挂载需重启的事实)。
483
494
  - **`cordis.patch.yml` 头注释的同类漂移**:第 3 行同款地写着 "sidebar panel",同样进 npm 包,一并改掉。
484
- - **第二上游的网关契约补复测**(新增 `docs/ROADMAP.md` §6.1.2):2026-10-01 的四次无凭据只读请求——本插件实际依赖的 `/model_catalog` 与 `/chat/completions` 均回 `401 authorization_empty_error`(已抵鉴权层,路由健在);09-30 记录里那个 404 的是参考件 `dsh-raccoon-work` 的 `/models`,**本插件不使用**。此前据前缀相同就判「实现的常量正是被标注 404 的那份清单」属**误判,已更正**。附带教训收进 PITFALLS §24。
495
+ - **第二上游的网关契约补复测**(新增 `docs/ROADMAP.md` §6.1.2):2026-10-01 的四次无凭据只读请求——本插件实际依赖的 `/model_catalog` 与 `/chat/completions` 均回 `401 authorization_empty_error`(已抵鉴权层,路由健在);09-30 记录里那个 404 的是参考件 `dsh-raccoon-work` 的 `/models`,**本插件不使用**。此前据前缀相同就判「实现的常量正是被标注 404 的那份清单」属**误判,已更正**。
485
496
  - **护栏:`docs.test.mjs` 新增两条语义检查**——原有的全是形式校验(链接 / 表格去重 / 行数上限),对面「文档断言的事是否与代码一致」毫无感知:
486
- - **检查 `README_TABS`**:README 必须覆盖面板的每一个 tab。tab 名单从 `panel-page.ts` 的 `activeTab` 联合类型派生、文案从 `i18n.ts` 的 `tab.<id>` 派生,**不写死任何名字**,于是「加 tab 忘了告诉用户」和「改 tab 名不跟」都必然红。
497
+ - **检查 `README_TABS`**:README 必须覆盖面板的每一个 tab。tab 名单从 `panel-page.ts` 的 `activeTab` 联合类型派生、文案从 `i18n.ts` 的 `tab.<id>` 派生,**不写死任何名字**。
487
498
  - **检查 `SELF_DESCRIPTION`**:README 与 `cordis.patch.yml` 声称的 UI 位置必须与 `src/client/*.ts` 实际注册的槽位一致。
488
499
  - 两条都做过「故意破坏 → 必须红 → 还原 → 复绿」的双向验证(删光「小浣熊」精确报出 `raccoon(面板文案「小浣熊」)`;改回事故原文报「声称面板在侧边栏但没有 sidebar 槽位注册」)。
489
500
  - **PITFALLS 新增 §24(GET 探活会误判)与 §25(形式门禁全绿 ≠ 文档说实话)**,总数 23 → 25;三处散落的过期引用(`README.md` / `AGENTS.md` / `docs/README.md`)同步——这批数字正是由新门禁当场揪出来的。
490
501
 
491
502
  ### 文档修正:撤销 ROADMAP §0 被证伪的前提
492
503
 
493
- - [ROADMAP.md](docs/ROADMAP.md) §0 与 §2 引言原写「本插件已是双 profile 的 `agent-default-model`——即这台机器的**默认推理通道**,故障域已升级为推理可用性」。该论断 2026-09-29 已被研究原稿 §1.2 撤销(`agent-default-model` 是宿主的选择记录服务,原引用不可复现;撤销原文现见 [ARCHIVE-BOUNDARY-DECISIONS.md](docs/ARCHIVE-BOUNDARY-DECISIONS.md) A1),但 ROADMAP 未同步。现改为「**能力事实**:可注册 provider `sensenova-token-plan`;是否默认通道由 profile 与用户模型选择决定;一旦某 profile 选它作默认,故障域才从面板升级为推理可用性(条件性爆炸半径)」。两份文档不再正面矛盾。
504
+ - [ROADMAP.md](docs/ROADMAP.md) §0 与 §2 引言原写「本插件已是双 profile 的 `agent-default-model`——即这台机器的**默认推理通道**,故障域已升级为推理可用性」。该论断 2026-09-29 已被研究原稿 §1.2 撤销(`agent-default-model` 是宿主的选择记录服务,原引用不可复现;撤销原文现见 [ARCHIVE-BOUNDARY-DECISIONS.md](docs/ARCHIVE-BOUNDARY-DECISIONS.md) A1),但 ROADMAP 未同步。现改为「**能力事实**:可注册 provider `sensenova-token-plan`;是否默认通道由 profile 与用户模型选择决定;一旦某 profile 选它作默认,故障域才从面板升级为推理可用性(条件性爆炸半径)」。
494
505
 
495
506
  ### 文档与仓库纪律
496
507
 
497
508
  - **消灭「离线 N 套件」数字漂移源**:文档不再背书套件数量与枚举(`AGENTS.md` 曾写十七套件、`DSH-PLUGIN.md` 曾列 17 项清单,raccoon/doctor 套件加入后双双过期)——套件清单与链的唯一事实源收敛到 `package.json scripts.test`(已有 `package.test.mjs` 钉磁盘 ↔ 链 ↔ CI 一致性),文档只指事实源不复制数字。
498
509
  - **现状模块引用 `.js` → `.ts` 全量对齐**(64 处 / 9 文件):只改「现状引用」,保留「历史动作 / 研究档案 / 产物 / 外部」四类原文。
510
+ - **反模式归档**:用户锐评「恒定默认档逐行重复 = 噪音」,该反模式记于 `docs/ARCHIVE-IMPROVEMENTS-2026-09.md` §7(2026-10-05 归档)。
499
511
 
500
512
  ### 仓库结构规范化:src/ 全源码,lib/ 纯产物
501
513
 
@@ -504,7 +516,7 @@ Token Plan 与小浣熊同为商汤旗下产品线,`sensenova` 这个名头名
504
516
 
505
517
  ### 模态判定地基(modality.ts):出图 / 视频模型识别统一为单一出处
506
518
 
507
- 出图工具认不出模型的根因是「模态判定」两路分家、又都没拿到字段:Agnes 网关的 `/v1/models` 条目只带 `id/object/created/owned_by/supported_endpoint_types`,**没有任何模态字段**(真机确证 2026-10-01)。同一缺失让两个判定朝相反方向失手——`isImageGenModel` 严格方向(缺字段 = 不是出图模型)导致 `agnes_draw_image` 选不出候选,面板报「暂无出图模型」;`isChatModel` 宽松方向(缺字段 = 对话)把 4 个 image/video 模型全挂进对话选择器,一选就 400「是 image 模型,请使用 /v1/images/generations」。
519
+ 出图工具认不出模型的根因是「模态判定」两路分家、又都没拿到字段:Agnes 网关的 `/v1/models` 条目只带 `id/object/created/owned_by/supported_endpoint_types`,**没有任何模态字段**(真机确证 2026-10-01)。同一缺失让两个判定朝相反方向失手——`isImageGenModel` 严格方向(缺字段 = 不是出图模型)导致 `agnes_draw_image` 选不出候选,面板报「暂无出图模型」;`isChatModel` 宽松方向(缺字段 = 对话)把 4 个 image/video 模型全挂进对话选择器,一选就 400。
508
520
 
509
521
  - **新增 `src/host/modality.ts` 作为模态判定的唯一出处**,三级解析:`output_modalities` 字段优先(平台补字段即自动生效)→ `agnes-image-*` / `agnes-video-*` 名称段兜底(只匹配完整段,不重蹈 `dsh-draw-router` 的 `/u1-fast/i` 子串漏判)→ 默认 `text`。`isImageGenModel` 与 `isChatModel` 改为共用这一个函数,矛盾由构造消除而非靠约定维持。
510
522
  - **真实目录验证**:出图候选 0 → 2(自动选中 `agnes-image-2.1-flash`);对话名单 7 → 3。出图端点本身无需改动——真机实测 `POST /v1/images/generations` 返回 `data[0].url`,与 `parseDrawResponse` 已对齐。
@@ -513,22 +525,22 @@ Token Plan 与小浣熊同为商汤旗下产品线,`sensenova` 这个名头名
513
525
 
514
526
  ### 出图工具面板开关(drawEnabled)
515
527
 
516
- 与 provider 开关同机制的「面板开关 + 立即生效」,出图吸收(§5.4 接法 B)不再需要改配置重启:
528
+ 与 provider 开关同机制的「面板开关 + 立即生效」:
517
529
 
518
530
  - **面板新增「出图工具」卡片**(`client.js` 的 `DrawSwitch` 控件):勾选保存后写入插件私有状态文件 `$DSH_HOME/state/<plugin>/draw.json`,与 `provider-store.js` 走完全相同的完整性纪律。优先级:面板保存值 > `cordis.patch.yml` 的 `drawEnabled`;从未动过面板的部署,行为与 `false` 一致。
519
531
  - **新增 `POST /api/<name>/draw` 路由**(`routes.js`):与 `/provider` 同一信任形状(同源围栏 + 4 KB body 上限),`{ enabled: true|false }` 或 `{ forget: true }`。
520
- - **快照 `llm.drawEnabled` / `llm.drawSource`**:`snapshot-aggregate.js` 在 llm 块里回显出图开关的生效值与来源,面板无需单独调 `/draw` 就能读到当前状态。
532
+ - **快照 `llm.drawEnabled` / `llm.drawSource`**:`snapshot-aggregate.js` 在 llm 块里回显出图开关的生效值与来源。
521
533
  - **`lifecycle.js` 的 `registerDrawTool` 改读生效值**:不再直接读 `settings.drawEnabled`,而是「面板保存值 ?? 配置默认值」。工具的实际挂载/缺席发生在**下一个 Host 启动**时(agent tools 没有 unregister 语义),开关值本身是立即生效的。
522
534
  - **测试**:`test/provider.test.mjs` 新增 draw-store 纯逻辑组(归一、读写、版本拒绝、损坏忽略、forget);`test/routes.test.mjs` 新增 R 组(8 项,覆盖 GET/POST/forget/跨域围栏/跨 remount 持久化/配置回退);`test/wiring.test.mjs` 路由计数从 5 更新为 6。
523
535
 
524
536
  ### 视频工具面板开关(videoEnabled):V2.0 单体系,异步任务制
525
537
 
526
- 与出图对称,新增可选的视频生成 agent 工具(默认关),接入全链路与出图共用同一套纪律:
538
+ 与出图对称,新增可选的视频生成 agent 工具(默认关):
527
539
 
528
540
  - **面板新增「视频工具」卡片**(`client.js` 的 `VideoSwitch` 控件):与 `DrawSwitch` 共用同一私有 `ToolSwitch` 体,保证两个工具的降级阶梯与状态不可能分叉。勾选保存写入 `$DSH_HOME/state/<plugin>/video.json`,优先级:面板保存值 > `cordis.patch.yml` 的 `videoEnabled`。
529
541
  - **新增 `POST /api/<name>/video` 路由**(`routes.js`):与 `/draw` 共用同一处理器 `registerToolSwitchRoute`,区别只在闭包捕获的 store 与请求体键名(`drawModelId` / `videoModelId`)——键名承重,写错会让两工具静默共用一个模型;`test/routes.test.mjs` 组 S 专门钉隔离。
530
- - **Host 半边 `src/host/video.ts`(V2.0 单体系,不无脑移植上游)**:走 `POST {apiBase}/videos` 建任务 → 轮询 `{host}/agnesapi` → 取 `url` 的完整状态机;`VIDEO_MAX_POLLS = 1000` 防时钟不前进时空转;**刻意不设 30s 冷却门**(协议往返延迟已远宽于冷却窗口,且一次视频尝试分钟级、共用视频限频池)。`buildVideoBody` 对非法显式值**抛错而非夹取**(夹取帧数会静默改变视频时长,分钟级生成后才发现)。
531
- - **只覆盖 V2.0 参数体系**(`width`/`height`/`num_frames`(须 8n+1)/`frame_rate`/`seed`/`negative_prompt`/`image`),与 2.5 系列(`mode`/`seconds`/`size`/`aspect_ratio`)互斥;`pickVideoModel` 只在 V2.0 家族选,2.5 进 `video25ModelIds` 单独上报面板说明,不进对话选择器。
542
+ - **Host 半边 `src/host/video.ts`(V2.0 单体系,不无脑移植上游)**:走 `POST {apiBase}/videos` 建任务 → 轮询 `{host}/agnesapi` → 取 `url` 的完整状态机;`VIDEO_MAX_POLLS = 1000` 防时钟不前进时空转;**刻意不设 30s 冷却门**。`buildVideoBody` 对非法显式值**抛错而非夹取**(夹取帧数会静默改变视频时长,分钟级生成后才发现)。
543
+ - **只覆盖 V2.0 参数体系**(`width`/`height`/`num_frames`(须 8n+1)/`frame_rate`/`seed`/`negative_prompt`/`image`),与 2.5 系列(`mode`/`seconds`/`size`/`aspect_ratio`)互斥;`pickVideoModel` 只在 V2.0 家族选,2.5 进 `video25ModelIds` 单独上报面板说明。
532
544
  - **快照新增视频键**:`llm.videoEnabled` / `videoSource` / `videoModel` / `videoCandidateCount` / `videoCandidateIds` / `video25ModelIds`;`progress: 0` 走 `parseVideoQuery` 直接 `Number()` 读取(不能用坚持正值的 `num()`,否则合法 0% 被读成缺失)。
533
545
  - **配套配置**:`cordis.patch.yml` 新增 `videoEnabled` / `videoModelId` / `videoTimeoutMs`(≥30000) / `videoWidth`(1152) / `videoHeight`(768) / `videoNumFrames`(121) / `videoFrameRate`(24) 七项;`src/host/types.ts` 的 `HostDeps` 补 `videoFetch`。
534
546
  - **文档**:`AGNES-API.md` §7.5 整段重写为 V2.0 请求体字段与校验规则;`API.md` 补快照视频键与 `/video` 路由;`SETUP.md` 补 7 个视频配置字段;`PROVIDER-HOT-RELOAD.md` 新增 §8;`ARCHITECTURE.md` §5.4 补「视频吸收」;`README.md` 补视频工具段;`PITFALLS.md` 新增 §29(两站凭据不互通)/ §30(视频异步任务制)/ §31(`progress:0` 不能用 `num()`)。
@@ -538,8 +550,8 @@ Token Plan 与小浣熊同为商汤旗下产品线,`sensenova` 这个名头名
538
550
 
539
551
  几处「不报错、但会误导或压平层级」的显示。
540
552
 
541
- - **每模型消耗柱状图补图例**:柱子按「最高消耗者」归一化,top 模型永远填满轨道——它回答的是「谁在烧积分」,但满格会被误读成「这个模型快触顶」。卡片底部补一行说明「柱长按最高消耗相对显示,非占总额度比例」,图表不再靠省略说谎。
542
- - **返赠余额从药丸改为指标数字**:`返赠余额 327,904` 此前裹在与「通用池/专属池」同款的圆角药丸里,一个大数字被压成和静态类型标签同等权重的装饰。改为卡头右侧的 tabular 数字,读作可花余额指标。
553
+ - **每模型消耗柱状图补图例**:柱子按「最高消耗者」归一化,top 模型永远填满轨道——它回答的是「谁在烧积分」,但满格会被误读成「这个模型快触顶」。卡片底部补一行说明「柱长按最高消耗相对显示,非占总额度比例」。
554
+ - **返赠余额从药丸改为指标数字**:`返赠余额 327,904` 此前裹在与「通用池/专属池」同款的圆角药丸里,一个大数字被压成和静态类型标签同等权重的装饰。改为卡头右侧的 tabular 数字。
543
555
  - **注册成功不再常驻绿色**:「已向 DSH 注册提供方…」是静止常态,长期亮 `state-success-primary` 会让人误以为刚发生了好事。收敛为中性次要文字,绿色只留给「已保存」这类瞬时反馈。
544
556
  - **去掉最内层子卡的冗余边框**:section 卡→池卡→双子窗三层等宽 `border-l1` 互相抵消、压平层级。最内层子窗改为纯靠更深的背景面(layer-2)浮起,边框只留两层。
545
557
  - **API Key 字段标题去重**:「模型接入(API Key)」区块头与卡内输入框标签原本同名重复,标签改为「API Key」。
package/client.js CHANGED
@@ -3188,6 +3188,10 @@ var dsh_connect_agnes_token_plan_client = (function() {
3188
3188
  setError("unable to reach the Host");
3189
3189
  }
3190
3190
  }, []);
3191
+ const retireInFlight = () => {
3192
+ generation.current += 1;
3193
+ inFlight.current?.abort?.();
3194
+ };
3191
3195
  useEffect(() => {
3192
3196
  alive.current = true;
3193
3197
  let timer = null;
@@ -3239,7 +3243,10 @@ var dsh_connect_agnes_token_plan_client = (function() {
3239
3243
  action: "switch",
3240
3244
  enabled
3241
3245
  });
3242
- if (alive.current) setState(body);
3246
+ if (alive.current) {
3247
+ retireInFlight();
3248
+ setState(body);
3249
+ }
3243
3250
  } catch (why) {
3244
3251
  if (alive.current) setNote(format(tt("agnescode.switchError"), { error: why instanceof Error ? why.message : String(why) }));
3245
3252
  }
@@ -3250,6 +3257,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
3250
3257
  try {
3251
3258
  const body = await postJson(AGNESCODE_PATH, { action: "harvest" });
3252
3259
  if (alive.current) {
3260
+ retireInFlight();
3253
3261
  if (body !== null) setState(body);
3254
3262
  if (body?.ok === true) setNote(tt("agnescode.harvestOk"));
3255
3263
  else setNote(tt("agnescode.harvestFail"));
@@ -3265,7 +3273,10 @@ var dsh_connect_agnes_token_plan_client = (function() {
3265
3273
  action: "saveModels",
3266
3274
  enabledModelIds: ids
3267
3275
  });
3268
- if (alive.current) setState(body);
3276
+ if (alive.current) {
3277
+ retireInFlight();
3278
+ setState(body);
3279
+ }
3269
3280
  }, []);
3270
3281
  const enabled = state?.enabled === true;
3271
3282
  const view = agnescodeView(state, error);
@@ -3703,8 +3714,12 @@ var dsh_connect_agnes_token_plan_client = (function() {
3703
3714
  }
3704
3715
  }, []);
3705
3716
  const dataRef = useRef(null);
3706
- dataRef.current = data;
3717
+ useEffect(() => {
3718
+ dataRef.current = data;
3719
+ }, [data]);
3707
3720
  const echoLlm = useCallback((patch) => {
3721
+ generation.current += 1;
3722
+ inFlight.current?.abort?.();
3708
3723
  if (dataRef.current === null) {
3709
3724
  load();
3710
3725
  return;
@@ -3984,10 +3999,12 @@ var dsh_connect_agnes_token_plan_client = (function() {
3984
3999
  * a picker switch, before it carries the new selection) must not shadow a
3985
4000
  * still-usable `lastUsed`. A bare `next ?? lastUsed` reads a provider off `{}`,
3986
4001
  * finds none, and hides the pill for OWNED models on exactly the frames a switch
3987
- * produces.
4002
+ * produces. "Empty" includes the frame where the provider was CLEARED but the
4003
+ * field is still present: a candidate without a non-empty `provider` cannot
4004
+ * gate a mount — the gate keys on the provider — and all it can do is shadow.
3988
4005
  */
3989
4006
  function selectedProviderOf(projection) {
3990
- const usable = (candidate) => candidate !== null && candidate !== void 0 && (typeof candidate.provider === "string" || typeof candidate.model === "string");
4007
+ const usable = (candidate) => candidate !== null && candidate !== void 0 && typeof candidate.provider === "string" && candidate.provider !== "";
3991
4008
  const p = projection ?? {};
3992
4009
  const provider = (usable(p.next) ? p.next : usable(p.lastUsed) ? p.lastUsed : void 0)?.provider;
3993
4010
  return typeof provider === "string" && provider !== "" ? provider : void 0;
@@ -4058,7 +4075,12 @@ var dsh_connect_agnes_token_plan_client = (function() {
4058
4075
  * @param route - module-scope stable: a fresh literal per render would rebuild
4059
4076
  * `load` and restart the poll interval with it.
4060
4077
  * @param initialCadenceMs - the rate to follow before any answer arrives;
4061
- * `null` means "no timer until something states one".
4078
+ * `null` means "no timer until something states one" — which installs
4079
+ * NEITHER interval nor visibility wake, so it is only safe for a route
4080
+ * whose first read cannot fail. A route whose first read CAN fail (the
4081
+ * console one: the Host may be busy, or the link may hiccup, at mount)
4082
+ * passes a retry fallback instead, or a failed first read stalls the
4083
+ * reader for the whole session.
4062
4084
  */
4063
4085
  function useRemoteRead(route, initialCadenceMs) {
4064
4086
  const [reading, setReading] = useState({
@@ -4069,7 +4091,9 @@ var dsh_connect_agnes_token_plan_client = (function() {
4069
4091
  failed: false
4070
4092
  });
4071
4093
  const cadenceRef = useRef(initialCadenceMs);
4072
- cadenceRef.current = reading.cadenceMs;
4094
+ useEffect(() => {
4095
+ cadenceRef.current = reading.cadenceMs;
4096
+ }, [reading.cadenceMs]);
4073
4097
  const generation = useRef(0);
4074
4098
  const inFlight = useRef(null);
4075
4099
  const load = useCallback(async () => {
@@ -4118,6 +4142,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
4118
4142
  }
4119
4143
  }, [route]);
4120
4144
  useEffect(() => {
4145
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
4121
4146
  load();
4122
4147
  }, [load]);
4123
4148
  useEffect(() => {
@@ -4135,7 +4160,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
4135
4160
  timer = null;
4136
4161
  }
4137
4162
  };
4138
- start();
4163
+ if (!(typeof document !== "undefined" && document.visibilityState === "hidden")) start();
4139
4164
  const onVisibility = () => {
4140
4165
  if (typeof document === "undefined") return;
4141
4166
  if (document.visibilityState === "hidden") stop();
@@ -4170,7 +4195,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
4170
4195
  }
4171
4196
  /** The console reader (the pill's `/snapshot` loop). */
4172
4197
  function useConsoleQuota() {
4173
- return useRemoteRead(SNAPSHOT_ROUTE, null);
4198
+ return useRemoteRead(SNAPSHOT_ROUTE, SNAPSHOT_RETRY_FALLBACK_MS);
4174
4199
  }
4175
4200
  /** The desktop-app reader (the pill's `/agnescode` loop). */
4176
4201
  function useAgnescodeState() {
@@ -4329,7 +4354,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
4329
4354
  if (provider === "agnescode") return h(AgnescodeBalancePill, { tt });
4330
4355
  return null;
4331
4356
  }
4332
- var PILL_CLASS, WINDOW_5H, WINDOW_WEEKLY, TIER_INK, SNAPSHOT_ROUTE, AGNESCODE_ROUTE;
4357
+ var PILL_CLASS, WINDOW_5H, WINDOW_WEEKLY, TIER_INK, SNAPSHOT_ROUTE, AGNESCODE_ROUTE, SNAPSHOT_RETRY_FALLBACK_MS;
4333
4358
  var init_composer_usage_pill = __esmMin((() => {
4334
4359
  init_runtime();
4335
4360
  init_styles();
@@ -4372,6 +4397,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
4372
4397
  };
4373
4398
  }
4374
4399
  };
4400
+ SNAPSHOT_RETRY_FALLBACK_MS = 3e4;
4375
4401
  }));
4376
4402
 
4377
4403
  //#endregion
@@ -1,4 +1,4 @@
1
- import { L as str, M as obj, T as name, i as PROBED_VISION, j as numZeroOk, k as num } from "./llm-models.js";
1
+ import { M as obj, R as str, T as name, i as PROBED_VISION, j as numZeroOk, k as num } from "./llm-models.js";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as numOrNull, C as inject, D as resolveSettings, E as resolveAuthOverrides, F as redactSecrets, I as retryBounded, L as str, M as obj, N as pluginError, O as forget, P as redactError, R as verbatim, S as hostName, T as name, _ as parseUsageOverview, b as readSubscriptionExpiry, c as summarizeCatalog, d as isVideoGenModel, f as checkShape, g as parseSubscriptionUsage, h as parsePlans, j as numZeroOk, k as num, l as visionOf, m as matchCurrentPlan, n as LLM_DISPLAY_NAME, o as filterByEnabled, p as countOf, r as LLM_PROVIDER_ID, s as rosterWithAvailability, t as DEFAULT_REASONING_EFFORT, u as isImageGenModel, v as parseUsageSeries, w as isAdmitted, x as CONFIG_DEFAULTS, y as quotaWindows } from "./llm-models.js";
1
+ import { A as numOrNull, C as inject, D as resolveSettings, E as resolveAuthOverrides, F as redactSecrets, I as resolveToolInputs, L as retryBounded, M as obj, N as pluginError, O as forget, P as redactError, R as str, S as hostName, T as name, _ as parseUsageOverview, b as readSubscriptionExpiry, c as summarizeCatalog, d as isVideoGenModel, f as checkShape, g as parseSubscriptionUsage, h as parsePlans, j as numZeroOk, k as num, l as visionOf, m as matchCurrentPlan, n as LLM_DISPLAY_NAME, o as filterByEnabled, p as countOf, r as LLM_PROVIDER_ID, s as rosterWithAvailability, t as DEFAULT_REASONING_EFFORT, u as isImageGenModel, v as parseUsageSeries, w as isAdmitted, x as CONFIG_DEFAULTS, y as quotaWindows, z as verbatim } from "./llm-models.js";
2
2
  import { S as writeStateFile, _ as profileSegment, a as filterAgnescodeRows, b as stateDir, c as fetchAgnescodeBalance, d as trustAgnescodeBffBase, f as createFileCatalogStore, g as ensureStateDir, h as createStateReadCache, l as fetchAgnescodeCatalog, m as STATE_READ_TTL_MS, n as AGNESCODE_PROVIDER_ID, o as AGNESCODE_FALLBACK_MODELS, p as normalizeEnabledIds, s as decodeAgnescodeJwtExpMs, t as AGNESCODE_DISPLAY_NAME, u as harvestAgnescodeLocalSession, v as profileStateDir, x as temporaryOf, y as readStateJson } from "./agnescode-models.js";
3
3
  import { rm } from "node:fs/promises";
4
4
  import { join } from "node:path";
@@ -3595,6 +3595,58 @@ function sweepCache(cache) {
3595
3595
  for (const [key, entry] of cache) if (nowMs - entry.at > MAX_CACHE_AGE_MS) cache.delete(key);
3596
3596
  }
3597
3597
  /**
3598
+ * The cache + single-flight protocol every console fetch in this module uses.
3599
+ *
3600
+ * All three callers — {@link fetchConsole}, {@link fetchPlans},
3601
+ * {@link fetchModelCatalog} — need the same four moves in the same order, and
3602
+ * each used to spell them out: a TTL read off `cache`, a shared in-flight
3603
+ * promise per URL, the write-back, and the bounded sweep. The difference
3604
+ * between them is only what a REQUEST does (`produce`), never how an answer is
3605
+ * remembered. Keeping the protocol here means a fourth endpoint cannot pick up
3606
+ * a subtly different variant of it (the same "hand-copied rule drifts" failure
3607
+ * `switch-precedence.ts` was extracted to end).
3608
+ *
3609
+ * Two properties are load-bearing and are why `produce` is a callback rather
3610
+ * than a pre-made promise:
3611
+ *
3612
+ * 1. **Nothing is cached until the answer has been judged.** `produce` throws
3613
+ * for a refusal instead of returning one, so a transient 401 never gets
3614
+ * written down and left to stick for the whole TTL — which would leave the
3615
+ * panel reading "not signed in" long after the store had healed.
3616
+ * 2. **A failed flight leaves no trace.** The `finally` drops the in-flight
3617
+ * entry on the error path too, so the next poll genuinely retries rather
3618
+ * than awaiting a rejected promise it never saw.
3619
+ *
3620
+ * @param {string} url - the fully built request URL; also the cache and
3621
+ * in-flight key, which is what makes the de-duplication per-endpoint.
3622
+ * @param {number} cacheMs - how long a successful answer stays fresh.
3623
+ * @param {CacheMap} cache - the shared response cache.
3624
+ * @param {InflightMap} inflight - the shared in-flight map.
3625
+ * @param {() => Promise<unknown>} produce - performs the request and returns
3626
+ * the value to cache; throws to refuse, and a throw caches nothing.
3627
+ * @returns {Promise<unknown>} the cached, shared, or freshly produced value.
3628
+ */
3629
+ async function sharedFetch(url, cacheMs, cache, inflight, produce) {
3630
+ const cached = cache.get(url);
3631
+ if (cached !== void 0 && Date.now() - cached.at < cacheMs) return cached.body;
3632
+ const pending = inflight.get(url);
3633
+ if (pending !== void 0) return pending;
3634
+ const run = async () => {
3635
+ const body = await produce();
3636
+ cache.set(url, {
3637
+ body,
3638
+ at: Date.now()
3639
+ });
3640
+ sweepCache(cache);
3641
+ return body;
3642
+ };
3643
+ const flight = run().finally(() => {
3644
+ inflight.delete(url);
3645
+ });
3646
+ inflight.set(url, flight);
3647
+ return flight;
3648
+ }
3649
+ /**
3598
3650
  * Whether an envelope code means "the token is no longer good".
3599
3651
  *
3600
3652
  * Agnes reports an expired session as `code: 401` inside an HTTP 401, but the
@@ -3670,11 +3722,7 @@ function unwrapEnvelope(body, label) {
3670
3722
  async function fetchConsole(settings, path, params, cacheMs, cache, inflight, tokenStore) {
3671
3723
  const query = params && Object.keys(params).length > 0 ? `?${new URLSearchParams(params).toString()}` : "";
3672
3724
  const url = `${settings.consoleBase}${path}${query}`;
3673
- const cached = cache.get(url);
3674
- if (cached !== void 0 && Date.now() - cached.at < cacheMs) return cached.body;
3675
- const pending = inflight.get(url);
3676
- if (pending !== void 0) return pending;
3677
- const run = async () => {
3725
+ return sharedFetch(url, cacheMs, cache, inflight, async () => {
3678
3726
  /** One attempt: `{data}` on success, `{authRefused: true}` when renewing helps. */
3679
3727
  const attempt = async (token) => {
3680
3728
  const response = await fetch(url, {
@@ -3706,18 +3754,8 @@ async function fetchConsole(settings, path, params, cacheMs, cache, inflight, to
3706
3754
  error.code = CODE.JWT_EXPIRED;
3707
3755
  throw error;
3708
3756
  }
3709
- cache.set(url, {
3710
- body: result.data,
3711
- at: Date.now()
3712
- });
3713
- sweepCache(cache);
3714
3757
  return result.data;
3715
- };
3716
- const flight = run().finally(() => {
3717
- inflight.delete(url);
3718
3758
  });
3719
- inflight.set(url, flight);
3720
- return flight;
3721
3759
  }
3722
3760
  /**
3723
3761
  * Fetch the public plan catalogue — the only quota source that needs no login.
@@ -3736,29 +3774,14 @@ async function fetchConsole(settings, path, params, cacheMs, cache, inflight, to
3736
3774
  */
3737
3775
  async function fetchPlans(settings, cacheMs, cache, inflight) {
3738
3776
  const url = `${settings.consoleBase}${PLANS_PATH}`;
3739
- const cached = cache.get(url);
3740
- if (cached !== void 0 && Date.now() - cached.at < cacheMs) return cached.body;
3741
- const pending = inflight.get(url);
3742
- if (pending !== void 0) return pending;
3743
- const run = async () => {
3777
+ return sharedFetch(url, cacheMs, cache, inflight, async () => {
3744
3778
  const response = await fetch(url, {
3745
3779
  headers: { accept: "application/json" },
3746
3780
  signal: AbortSignal.timeout(settings.consoleTimeoutMs)
3747
3781
  });
3748
3782
  if (!response.ok) throw new Error(`${PLANS_PATH} returned HTTP ${response.status}`);
3749
- const body = unwrapEnvelope(await response.json(), PLANS_PATH);
3750
- cache.set(url, {
3751
- body,
3752
- at: Date.now()
3753
- });
3754
- sweepCache(cache);
3755
- return body;
3756
- };
3757
- const flight = run().finally(() => {
3758
- inflight.delete(url);
3783
+ return unwrapEnvelope(await response.json(), PLANS_PATH);
3759
3784
  });
3760
- inflight.set(url, flight);
3761
- return flight;
3762
3785
  }
3763
3786
  /**
3764
3787
  * Fetch the API-key model catalog: the models this key can actually call.
@@ -3777,11 +3800,7 @@ async function fetchPlans(settings, cacheMs, cache, inflight) {
3777
3800
  */
3778
3801
  async function fetchModelCatalog(settings, cacheMs, cache, inflight, apiKey) {
3779
3802
  const url = `${settings.apiBase}/models`;
3780
- const cached = cache.get(url);
3781
- if (cached !== void 0 && Date.now() - cached.at < cacheMs) return cached.body;
3782
- const pending = inflight.get(url);
3783
- if (pending !== void 0) return pending;
3784
- const run = async () => {
3803
+ return sharedFetch(url, cacheMs, cache, inflight, async () => {
3785
3804
  const response = await fetch(url, {
3786
3805
  headers: {
3787
3806
  authorization: `Bearer ${apiKey}`,
@@ -3791,25 +3810,14 @@ async function fetchModelCatalog(settings, cacheMs, cache, inflight, apiKey) {
3791
3810
  });
3792
3811
  if (!response.ok) throw new Error(`/v1/models returned HTTP ${response.status}`);
3793
3812
  const body = await response.json();
3794
- const models = Array.isArray(body?.data) ? body.data.map((entry) => {
3813
+ return Array.isArray(body?.data) ? body.data.map((entry) => {
3795
3814
  const source = obj(entry);
3796
3815
  return {
3797
3816
  id: str(source.id, ""),
3798
3817
  ...source
3799
3818
  };
3800
3819
  }).filter((entry) => entry.id !== "") : [];
3801
- cache.set(url, {
3802
- body: models,
3803
- at: Date.now()
3804
- });
3805
- sweepCache(cache);
3806
- return models;
3807
- };
3808
- const flight = run().finally(() => {
3809
- inflight.delete(url);
3810
3820
  });
3811
- inflight.set(url, flight);
3812
- return flight;
3813
3821
  }
3814
3822
 
3815
3823
  //#endregion
@@ -4135,15 +4143,10 @@ function defineDrawTool({ defineTool, resolveApiKey, getEntries, settings, fetch
4135
4143
  const prompt = str(params?.prompt, "").trim();
4136
4144
  if (prompt === "") throw new Error("prompt is required");
4137
4145
  if (cooldown.blocked()) throw new Error(`draw cooldown: 上一次出图失败,${Math.ceil(cooldown.remainingMs() / 1e3)}s 后再试`);
4138
- const apiKey = await resolveApiKey();
4139
- if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("AGNES_TOKEN_PLAN_API_KEY 未配置:在面板「模型接入」粘贴 API Key(免费版 sk- 或 Token Plan cpk-),或设置该环境变量");
4140
- let picked;
4141
- try {
4142
- picked = await getEntries?.() ?? [];
4143
- } catch {
4144
- picked = [];
4145
- }
4146
- const entries = Array.isArray(picked) ? picked : [];
4146
+ const { apiKey, entries } = await resolveToolInputs({
4147
+ resolveApiKey,
4148
+ getEntries
4149
+ });
4147
4150
  const model = pickDrawModel(entries, params?.model, settings?.drawModelId);
4148
4151
  if (model === null || model === void 0) throw new Error("catalog 中没有出图模型(`output_modalities` 字段与 `agnes-image-*` 名称判定均为空):确认 Key 已配置、面板已至少轮询一次,且套餐含出图模型");
4149
4152
  const body = buildDrawBody({
@@ -4603,15 +4606,66 @@ async function applyCatalogEffects({ catalogStore, publisher, providerState, cat
4603
4606
  };
4604
4607
  }
4605
4608
  /**
4606
- * Fetch the four console sources plus the model catalog and aggregate them
4607
- * into the snapshot body the route writes.
4609
+ * Fetch the four console sources plus the model catalogue — the NETWORK half of
4610
+ * the poll, kept apart from the projection that shapes the answer.
4611
+ *
4612
+ * All five go out through the shared `cache` + `inflight` maps (a single-flight
4613
+ * per URL so concurrent polls share one call) and the `tokenStore` (so a 401
4614
+ * triggers one renewal before the call). Only the catalogue is authenticated by
4615
+ * the API key instead; a missing key degrades the model lists, not the quota —
4616
+ * resolved per poll so a key that arrives after the plugin mounted still lights
4617
+ * the lists on the next poll.
4618
+ *
4619
+ * Extracted from `buildSnapshotBody`, which is now "fetch, then shape": this was
4620
+ * the only part of it that touches the network, and the reasoning about WHY the
4621
+ * five requests share one batch belongs next to the batch rather than in the
4622
+ * middle of the field assembly. The outcomes are passed straight through —
4623
+ * `soft` has already turned every failure into `{value: null, error}`.
4624
+ *
4625
+ * @param {object} context
4626
+ * @param {Settings} context.settings - the resolved settings row.
4627
+ * @param {CacheMap} context.cache - the console-response cache (shared across polls).
4628
+ * @param {InflightMap} context.inflight - the single-flight map (shared across polls).
4629
+ * @param {object} context.tokenStore - the `createTokenStore` instance.
4630
+ * @param {object} context.apiKeyStore - the `createApiKeyStore` instance.
4631
+ * @returns {Promise<{overview: object, series: object, subscription: object, plans: object, catalog: unknown}>}
4632
+ * one `{value, error}` outcome per console source, plus the raw catalogue.
4633
+ */
4634
+ async function fetchQuotaSources({ settings, cache, inflight, tokenStore, apiKeyStore }) {
4635
+ const resolveApiKey = async () => (await apiKeyStore.resolve()).value;
4636
+ const { startDate, endDate } = usageWindow(settings.usageDays);
4637
+ const [overview, series, subscription, plans, catalog] = await Promise.all([
4638
+ soft(() => fetchConsole(settings, USAGE_OVERVIEW_PATH, void 0, settings.cacheSeconds * 1e3, cache, inflight, tokenStore)),
4639
+ soft(() => fetchConsole(settings, USAGE_SERIES_PATH, {
4640
+ range: "custom",
4641
+ start_date: startDate,
4642
+ end_date: endDate
4643
+ }, Math.max(settings.cacheSeconds, 300) * 1e3, cache, inflight, tokenStore)),
4644
+ soft(() => fetchConsole(settings, SUBSCRIPTION_PATH, void 0, settings.cacheSeconds * 1e3, cache, inflight, tokenStore)),
4645
+ soft(() => fetchPlans(settings, 36e5, cache, inflight)),
4646
+ (async () => {
4647
+ const apiKey = await resolveApiKey();
4648
+ return apiKey === "" ? null : fetchModelCatalog(settings, 36e5, cache, inflight, apiKey).catch(() => null);
4649
+ })()
4650
+ ]);
4651
+ return {
4652
+ overview,
4653
+ series,
4654
+ subscription,
4655
+ plans,
4656
+ catalog
4657
+ };
4658
+ }
4659
+ /**
4660
+ * Assemble the snapshot body the route writes, from the five sources the poll
4661
+ * fetched plus the stores' current state.
4608
4662
  *
4609
- * All five are fetched in parallel through the shared `cache` + `inflight`
4610
- * maps (a single-flight per URL so concurrent polls share one call) and the
4611
- * `tokenStore` (so a 401 triggers one renewal before the call). Only the
4612
- * catalog is authenticated by the API key instead; a missing key degrades the
4613
- * model lists, not the quota — resolved per poll so a key that arrives after
4614
- * the plugin mounted still lights the lists on the next poll.
4663
+ * The NETWORK half is {@link fetchQuotaSources}: this function starts from its
4664
+ * `{value, error}` outcomes, so every field below is either a degraded source
4665
+ * or a value read off a store, and the only side effects it performs are the
4666
+ * catalogue persist + provider republish isolated in `applyCatalogEffects`.
4667
+ * The five requests' parallelism, caching and 401 handling are that function's
4668
+ * concern, not this one's.
4615
4669
  *
4616
4670
  * @param {object} context
4617
4671
  * @param {object} context.settings - the resolved settings row.
@@ -4636,22 +4690,13 @@ async function applyCatalogEffects({ catalogStore, publisher, providerState, cat
4636
4690
  */
4637
4691
  async function buildSnapshotBody({ settings, cache, inflight, tokenStore, apiKeyStore, publisher, catalogStore, panelSwitch, drawSwitch, drawModelId, videoSwitch, videoModelId }) {
4638
4692
  const providerState = publisher.state;
4639
- const resolveApiKey = async () => (await apiKeyStore.resolve()).value;
4640
- const { startDate, endDate } = usageWindow(settings.usageDays);
4641
- const [overview, series, subscription, plans, catalog] = await Promise.all([
4642
- soft(() => fetchConsole(settings, USAGE_OVERVIEW_PATH, void 0, settings.cacheSeconds * 1e3, cache, inflight, tokenStore)),
4643
- soft(() => fetchConsole(settings, USAGE_SERIES_PATH, {
4644
- range: "custom",
4645
- start_date: startDate,
4646
- end_date: endDate
4647
- }, Math.max(settings.cacheSeconds, 300) * 1e3, cache, inflight, tokenStore)),
4648
- soft(() => fetchConsole(settings, SUBSCRIPTION_PATH, void 0, settings.cacheSeconds * 1e3, cache, inflight, tokenStore)),
4649
- soft(() => fetchPlans(settings, 36e5, cache, inflight)),
4650
- (async () => {
4651
- const apiKey = await resolveApiKey();
4652
- return apiKey === "" ? null : fetchModelCatalog(settings, 36e5, cache, inflight, apiKey).catch(() => null);
4653
- })()
4654
- ]);
4693
+ const { overview, series, subscription, plans, catalog } = await fetchQuotaSources({
4694
+ settings,
4695
+ cache,
4696
+ inflight,
4697
+ tokenStore,
4698
+ apiKeyStore
4699
+ });
4655
4700
  const quota = buildQuotaBlock(overview, series, subscription, plans);
4656
4701
  const usage = series.value === null ? null : (() => {
4657
4702
  const parsed = parseUsageSeries(series.value, settings.usageDays);
@@ -6934,15 +6979,11 @@ function defineVideoTool({ defineTool, resolveApiKey, getEntries, settings, fetc
6934
6979
  if (isDisposed()) throw new Error("Agnes video tool is no longer mounted");
6935
6980
  const prompt = str(params?.prompt, "").trim();
6936
6981
  if (prompt === "") throw new Error("prompt is required");
6937
- const apiKey = await resolveApiKey();
6938
- if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("AGNES_TOKEN_PLAN_API_KEY 未配置:在面板「模型接入」粘贴 API Key(免费版 sk- 或 Token Plan cpk-),或设置该环境变量");
6939
- let picked;
6940
- try {
6941
- picked = await getEntries?.() ?? [];
6942
- } catch {
6943
- picked = [];
6944
- }
6945
- const model = pickVideoModel(Array.isArray(picked) ? picked : [], params?.model, settings?.videoModelId);
6982
+ const { apiKey, entries } = await resolveToolInputs({
6983
+ resolveApiKey,
6984
+ getEntries
6985
+ });
6986
+ const model = pickVideoModel(entries, params?.model, settings?.videoModelId);
6946
6987
  if (model === null) throw new Error("catalog 中没有视频模型(`output_modalities` 字段与 `agnes-video-*` 名称判定均为空):确认 Key 已配置、面板已至少轮询一次,且套餐含视频模型");
6947
6988
  if (!isVideo25Family(model)) {
6948
6989
  const twentyFiveOnly = VIDEO25_ONLY_FIELDS.filter((field) => params?.[field] !== void 0 && params?.[field] !== null);
package/lib/llm-models.js CHANGED
@@ -130,6 +130,45 @@ async function retryBounded({ attempts, delayMs, run }) {
130
130
  return false;
131
131
  }
132
132
  /**
133
+ * The shared preamble every agent tool runs before it can build a request:
134
+ * resolve the live API key, refuse when there is none, then read the model
135
+ * catalogue defensively.
136
+ *
137
+ * `defineDrawTool` and `defineVideoTool` each spelled this out, which meant the
138
+ * key-missing message — the one line a user actually reads, naming the panel
139
+ * section and both key shapes — existed twice and could be fixed once. The
140
+ * ORDER is load-bearing and is why this is a function rather than two call
141
+ * sites: the key is resolved and validated BEFORE the catalogue is touched, so
142
+ * an unconfigured deployment costs no catalogue read, and a `getEntries` that
143
+ * throws degrades the picker to empty rather than failing the tool (the catalog
144
+ * is a cache-shaped input; a stale or missing one must not kill a call that the
145
+ * caller may still be able to make with an explicit model id).
146
+ *
147
+ * What it deliberately does NOT do is anything modality-specific: draw's
148
+ * cooldown gate and video's family-dispatch checks stay with their own tools
149
+ * because those are real domain differences, not ceremony.
150
+ *
151
+ * @param {{resolveApiKey: () => Promise<string|undefined>, getEntries?: () => Promise<unknown[]>}} wiring
152
+ * the two `ToolWiring` seams the tools are mounted with.
153
+ * @returns {Promise<{apiKey: string, entries: unknown[]}>} a non-empty key and
154
+ * the catalogue entries (empty array when the read is absent or throws).
155
+ * @throws {Error} when no key is configured — the message names the fix.
156
+ */
157
+ async function resolveToolInputs(wiring) {
158
+ const apiKey = await wiring.resolveApiKey();
159
+ if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("AGNES_TOKEN_PLAN_API_KEY 未配置:在面板「模型接入」粘贴 API Key(免费版 sk- 或 Token Plan cpk-),或设置该环境变量");
160
+ let picked;
161
+ try {
162
+ picked = await wiring.getEntries?.() ?? [];
163
+ } catch {
164
+ picked = [];
165
+ }
166
+ return {
167
+ apiKey,
168
+ entries: Array.isArray(picked) ? picked : []
169
+ };
170
+ }
171
+ /**
133
172
  * Run a promise to completion without awaiting it — and guarantee it cannot
134
173
  * become an unhandled rejection.
135
174
  *
@@ -1873,4 +1912,4 @@ function summarizeCatalog(entries) {
1873
1912
  }
1874
1913
 
1875
1914
  //#endregion
1876
- export { numOrNull as A, inject as C, resolveSettings as D, resolveAuthOverrides as E, redactSecrets as F, retryBounded as I, str as L, obj as M, pluginError as N, forget as O, redactError as P, verbatim as R, hostName as S, name as T, parseUsageOverview as _, buildDescriptors as a, readSubscriptionExpiry as b, summarizeCatalog as c, isVideoGenModel as d, checkShape as f, parseSubscriptionUsage as g, parsePlans as h, PROBED_VISION as i, numZeroOk as j, num as k, visionOf as l, matchCurrentPlan as m, LLM_DISPLAY_NAME as n, filterByEnabled as o, countOf as p, LLM_PROVIDER_ID as r, rosterWithAvailability as s, DEFAULT_REASONING_EFFORT as t, isImageGenModel as u, parseUsageSeries as v, isAdmitted as w, CONFIG_DEFAULTS as x, quotaWindows as y };
1915
+ export { numOrNull as A, inject as C, resolveSettings as D, resolveAuthOverrides as E, redactSecrets as F, resolveToolInputs as I, retryBounded as L, obj as M, pluginError as N, forget as O, redactError as P, str as R, hostName as S, name as T, parseUsageOverview as _, buildDescriptors as a, readSubscriptionExpiry as b, summarizeCatalog as c, isVideoGenModel as d, checkShape as f, parseSubscriptionUsage as g, parsePlans as h, PROBED_VISION as i, numZeroOk as j, num as k, visionOf as l, matchCurrentPlan as m, LLM_DISPLAY_NAME as n, filterByEnabled as o, countOf as p, LLM_PROVIDER_ID as r, rosterWithAvailability as s, DEFAULT_REASONING_EFFORT as t, isImageGenModel as u, parseUsageSeries as v, isAdmitted as w, CONFIG_DEFAULTS as x, quotaWindows as y, verbatim as z };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-agnes-token-plan",
3
3
  "displayName": "DSH Connect Agnes Token Plan",
4
4
  "icon": "./icon.svg",
5
- "version": "0.11.0",
5
+ "version": "0.11.2",
6
6
  "description": "Agnes connect bundle for the Harness Web UI: Token Plan quota panel (account-wide quota windows, cumulative usage, console sign-in) plus optional provider registration that brings Agnes models into DSH conversation, with host-side rate-limit self-healing.",
7
7
  "keywords": [
8
8
  "dsh-plugin",