dsh-connect-agnes-token-plan 0.13.2 → 0.13.3

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
@@ -23,6 +23,15 @@
23
23
 
24
24
  ## [Unreleased]
25
25
 
26
+ ## [0.13.3] — 2026-10-26
27
+
28
+ 本版改一件事:**出图 / 视频工具开关即时生效**——工具本体就地注册 / 撤下,不再等下一次 Host 启动。
29
+
30
+ ### 变更:出图 / 视频工具随开关即时注册 / 撤下
31
+
32
+ - **改(接入 API tab)**:出图与视频工具的开关、勾选与模型偏好,此前对工具本体的生效要等**下一次 Host 启动**(或重装插件)。现在翻一次开关就地把 `agnes_draw_image` / `agnes_video_generate` 注册或撤下,改模型偏好即时换模型;无需重启 `dsh web`、无需重装插件。
33
+ - **说明**:宿主不提供卸载句柄时与旧版行为一致(工具对本进程粘性,保持首次注册值);面板上开关状态的读写始终即时。机制与边界见 `docs/TOOL-HOT-RELOAD.md`。
34
+
26
35
  ## [0.13.2] — 2026-10-23
27
36
 
28
37
  本版两件事:**网关新上的模型默认就能发图**,以及**余额胶囊上会多一段「· N 新模型」**。两处都只影响 AgnesCode(桌面端)这条线。
package/README.md CHANGED
@@ -46,7 +46,7 @@
46
46
  - **语言模型**:显示当前 Key 实际能调哪些模型,其中哪些能看图(按平台 `input_modalities` 判定,不靠名字猜)。
47
47
  - **出图工具**:Host 给 agent 注册 `agnes_draw_image` ,出图模型按 catalog 的 `output_modalities` 结构化判定。
48
48
  - **视频工具**:Host 给 agent 注册 `agnes_video_generate`,按选中模型分派 V2.0 / 2.5 两套互斥参数。
49
- - 开关与勾选都在面板热生效,无需重启。但工具的实际挂载 / 缺席发生在**下一次 Host 启动**(agent tools 没有 unregister 语义)。细节见 [docs/SETUP.md](docs/SETUP.md) §3、[docs/PROVIDER-HOT-RELOAD.md](docs/PROVIDER-HOT-RELOAD.md) 与 [docs/AGNES-API.md](docs/AGNES-API.md) §7.5。
49
+ - 开关、勾选与工具挂载都热生效,无需重启:翻一次开关,出图 / 视频工具就地注册或撤下(0.13.x 起;宿主不给卸载句柄时工具对本进程粘性,见 [docs/TOOL-HOT-RELOAD.md](docs/TOOL-HOT-RELOAD.md))。细节见 [docs/SETUP.md](docs/SETUP.md) §3、[docs/PROVIDER-HOT-RELOAD.md](docs/PROVIDER-HOT-RELOAD.md) 与 [docs/AGNES-API.md](docs/AGNES-API.md) §7.5。
50
50
 
51
51
  ![「接入 API」tab](assets/panel-API-provider.png)
52
52
 
package/client.js CHANGED
@@ -2558,8 +2558,9 @@ var dsh_connect_agnes_token_plan_client = (function() {
2558
2558
  /**
2559
2559
  * The shared body of an agent-tool card (`DrawSwitch`, `VideoSwitch`).
2560
2560
  *
2561
- * Both tools are mounted by the SAME Host ladder (`mountAgentTool` in
2562
- * `lifecycle.ts`) and both routes are served by the SAME handler shape
2561
+ * Both tools are mounted by the SAME Host ladder (`createToolMount` in
2562
+ * `lifecycle.ts`, each a live handle the switch route re-syncs on every
2563
+ * successful write) and both routes are served by the SAME handler shape
2563
2564
  * (`registerToolSwitchRoute` in `routes.ts`), so their cards differ only in
2564
2565
  * which route they post to, which `llm.*` fields they read, and which
2565
2566
  * dictionary prefix labels them. Writing the card once is what keeps the two
package/docs/API.md CHANGED
@@ -115,7 +115,7 @@
115
115
  // quotaBlockedModelIds 是交给选择器剔除的 id 集。Agnes 下**恒为空**——
116
116
  // 平台不按模型分配额,账号级用尽由面板明说,而不是静默摘模型。
117
117
  "quotaBlockedModelIds": [],
118
- // 出图工具开关生效值与来源(0.4.2);工具实际挂载在下一个 Host 启动时发生
118
+ // 出图工具开关生效值与来源(0.4.2);工具实际挂载随之即时收敛(0.13.x 起,见 TOOL-HOT-RELOAD.md)
119
119
  "drawEnabled": false, "drawSource": "config",
120
120
  // 一次出图调用实际会寻址的模型 id:用与工具本身相同的 pickDrawModel
121
121
  // 优先级(调用参数 > 配置的 drawModelId > 目录首个出图模型)从同一份
@@ -224,9 +224,9 @@
224
224
 
225
225
  ### `POST /api/dsh-connect-agnes-token-plan/draw`
226
226
 
227
- `{ "enabled": true|false }` —— 把出图开关写入插件私有状态文件(`$DSH_HOME/state/<profile>/<plugin>/draw.json`,按 profile 分段、见 [PITFALLS.md](./PITFALLS.md) §23),与 `/provider` 走的是同一套「存私有状态」机制,但**不触发任何即时发布**——agent 工具的实际注册/缺席发生在下一个 Host 启动(或重新安装)时,由 `lifecycle.js` 的 `startSideEffects` 重读生效值。优先级:面板保存的值 > `cordis.patch.yml` 的 `drawEnabled`。非布尔 `enabled` 返回 400;跨域返回 403。
227
+ `{ "enabled": true|false }` —— 把出图开关写入插件私有状态文件(`$DSH_HOME/state/<profile>/<plugin>/draw.json`,按 profile 分段、见 [PITFALLS.md](./PITFALLS.md) §23),与 `/provider` 走的是同一套「存私有状态」机制,但**不发布 provider**——写成功后立即收敛**工具挂载**:协调器 `sync()` 就地注册 / 撤下 / 换模型(0.13.x 起,机制见 [TOOL-HOT-RELOAD.md](./TOOL-HOT-RELOAD.md);宿主不给卸载句柄时工具粘性,保持首次注册值)。优先级:面板保存的值 > `cordis.patch.yml` 的 `drawEnabled`。非布尔 `enabled` 返回 400;跨域返回 403。
228
228
 
229
- `{ "drawModelId": "agnes-image-2.5-flash" }`(或 `null` = 自动选择)—— 把出图模型偏好写入同一个 `draw.json`。生效时机与开关相同:`startSideEffects` 在下一次挂载时用它覆盖 `cordis.patch.yml` 的 `drawModelId`(优先级:面板 > 配置;面板清除后回落配置,配置也为空则自动取目录第一个出图模型)。非空字符串之外的非 null 值返回 400;跨域返回 403。`{ "forget": true }` 同时清除开关与模型偏好的面板保存值。
229
+ `{ "drawModelId": "agnes-image-2.5-flash" }`(或 `null` = 自动选择)—— 把出图模型偏好写入同一个 `draw.json`。生效时机与开关相同:写成功后协调器就地收敛(已挂载则先撤旧再注新,见 [TOOL-HOT-RELOAD.md](./TOOL-HOT-RELOAD.md));优先级:面板 > 配置,面板清除后回落 `cordis.patch.yml` 的 `drawModelId`,配置也为空则自动取目录第一个出图模型。非空字符串之外的非 null 值返回 400;跨域返回 403。`{ "forget": true }` 同时清除开关与模型偏好的面板保存值。
230
230
 
231
231
  ### `GET /api/dsh-connect-agnes-token-plan/video`
232
232
 
@@ -25,7 +25,7 @@ DSH 插件是一段在 **Host**(桌面版或 `dsh web`)进程内运行的代
25
25
  {
26
26
  "name": "dsh-connect-agnes-token-plan",
27
27
  "icon": "./icon.svg", // Plugins 页插件卡图标:Host 读此字段按 SVG/PNG/JPEG/WebP 渲染(相对路径、落在 files 内、≤256 KiB,任一不满足会让整条元数据报错)
28
- "version": "0.13.2",
28
+ "version": "0.13.3",
29
29
  "main": "./lib/index.js", // Host 半边入口:src/host/*.ts 经 tsdown 打成的单条 bundle
30
30
  "exports": {
31
31
  ".": "./lib/index.js", // 源码在 src/host/,lib/ 为纯构建产物(随库提交,git/市场直装零构建)
@@ -77,9 +77,9 @@ trae/workbuddy 的 volatile 路线(把 `registerProvider` 标成 Config schema
77
77
  | `snapshot-aggregate.ts` | 快照 `llm.drawEnabled` / `llm.drawSource` 回显生效值与来源 |
78
78
  | `panel-page.ts` | 独立 SectionCard 承载「出图工具」卡片,含 `DrawSwitch` 控件(`ProviderForm` 已移到 `provider-controls.ts`,见其脚注) |
79
79
 
80
- 与 provider 开关的一个**语义差异**需要说明:provider 开关改的是「当前请求立刻重新发布注册对」,改完立即生效;draw 开关改的是「挂载时是否注册 agent 工具」,**当前 Host 进程里已经注册的工具不会因为改开关而消失或出现**——要真正生效需要在**下一个 Host (re)mount**(即重启 `dsh web` 或重新安装插件)时,`lifecycle.ts` 的 `startSideEffects` 重新读生效值。面板开关本身是「立即生效、无需重启」的**状态读写**;工具的实际挂载/卸载要等到下次 Host 启动。面板文案里「立即生效」指的是**开关值**本身,不是 agent 工具的实时性。
80
+ 与 provider 开关在 0.4.2 时期存在一个**语义差异**(该差异现已解除,见 §9):provider 开关改的是「当前请求立刻重新发布注册对」,改完立即生效;而当时 draw 开关改的是「挂载时是否注册 agent 工具」,`mountAgentTool` 丢弃了宿主返回的卸载句柄,**已注册的工具不会随开关在进程内消失或出现**——要真正生效需等**下一个 Host (re)mount**(重启 `dsh web` 或重装插件)时 `startSideEffects` 重新读生效值。面板文案里「立即生效」当时仅指**开关值**本身的读写。0.13.x 起补齐了热挂载/卸载协调层,draw 与 video 开关也随之**无需重启即时生效**(机制与边界见 §9)。
81
81
 
82
- **已知边界**:两个 Host 进程共享同一状态目录时,后写者胜(与 provider / throttle / catalog 文件语义一致)。`POST /draw` 只改开关值,不直接操作 tools registry——这是有意的:tools registry 没有 `unregister` 语义(见 `lifecycle.ts` 注释),强行卸载要等 Host 生命周期自然结束。
82
+ **已知边界**:两个 Host 进程共享同一状态目录时,后写者胜(与 provider / throttle / catalog 文件语义一致)。`POST /draw` 在 0.4.x 时期只改开关值、不直接操作 tools registry——因为当时宿主未提供可捕获的卸载句柄,工具卸载要等 Host 生命周期自然结束。这一限制**已在 0.13.x 解除**:`POST /draw`(与 `/video`)现会触发即时再同步(`createToolMount.sync()`),机制与「宿主不给卸载路径则工具粘性挂载」的边界见 §9。
83
83
 
84
84
  ## 8. 0.4.3 增量:视频工具开关(videoEnabled)
85
85
 
@@ -101,9 +101,23 @@ trae/workbuddy 的 volatile 路线(把 `registerProvider` 标成 Config schema
101
101
 
102
102
  ---
103
103
 
104
- > **结构沿革注记**:本节两张改动表是 0.4.x 各增量落地时的定格记录,表中「新增」文件此后又经历过收敛与拆分。查现行结构以 [ARCHITECTURE.md](./ARCHITECTURE.md) §2 为准;与上表直接相关的三处:
104
+ ## 9. 0.13.x 增量:出图 / 视频工具的「真热开关」(disposer 保留的即时挂载/卸载)
105
+
106
+ §7 里那句「工具的实际挂载/卸载要等到下次 Host 启动」在 0.4.x 时期是对的——当时 `mountAgentTool` 把宿主 `tools.register()` 返回的卸载句柄直接丢弃,所以 agent 工具只能「挂载一次」。现在改掉了:
107
+
108
+ 1. **宿主本就支持卸载**。`tools.register(definition)` 返回一个 `() => void` 的 disposer(同族插件 workbuddy 的 image/video 工具已长期以「常驻注册 + 保留句柄 + 开关切换时 live 卸载/换模型」的形态跑在生产),`types.ts` 把 `HostCtx.tools.register` 的类型从 `unknown` 收紧成 `(() => void) | undefined`。不给卸载路径的宿主返回 `undefined`,工具退化为粘性「挂载一次」——这是宿主能力边界,不是缺陷。
109
+ 2. **每工具一个「热挂载协调器」** `createToolMount`(`lifecycle.ts`,替代旧的一次性 `mountAgentTool`)。它持有闭包状态(缓存的宿主 `tools` 服务引用、`registered` / `disposer` / `regModel`)并把**所有**协调串在**同一条 promise 链**上,使「开关 POST / 模型 POST / forget / 挂载种子」的并发触发不会重复注册同一工具:链上每跑一次都**重读当前生效值**再收敛,所以排队的旧协调照样落到最新态。收敛规则:关 → 有 disposer 就卸载(没有则保持粘性,不动);开且与已注册模型一致 → 幂等不动;开但模型变了 → 有 disposer 先卸旧再注新(模型偏好也即时生效)。
110
+ 3. **句柄落在接线袋**(`wiring.drawToolMount` / `wiring.videoToolMount`),开关路由(`routes/tool-switch.ts` 的 `registerToolSwitchRoute`,draw 与 video 共用)在**每次成功的写入后**(`enabled` / `forget` / 模型偏好)调用协调器 `sync()`。协调是 fire-and-forget、不阻塞 HTTP 响应;GET 只读路径**绝不**协调——读不应改写宿主注册表。
111
+ 4. **teardown 顺序**(PITFALLS §18)在路由 `off()` 之前释放两个工具协调器:`stop → dispose → release → tools → off`,让在途的开关 POST 无法再往一个正在离场的宿主里同步工具。
112
+
113
+ **已知边界**:宿主不提供卸载路径时(`register` 返回 `undefined`),工具对本进程粘性——开关/模型改动能被协调器读到,但没有句柄可卸,注册保持首次值;此时行为与 0.4.x 完全一致。面板开关的状态读写永远即时生效,工具注册跟不跟上取决于宿主能力。
114
+
115
+ ---
116
+
117
+ > **结构沿革注记**:本节两张改动表是 0.4.x 各增量落地时的定格记录,表中「新增」文件此后又经历过收敛与拆分。查现行结构以 [ARCHITECTURE.md](./ARCHITECTURE.md) §2 为准;与上表直接相关的四处:
105
118
  >
106
119
  > - **video 逻辑层已拆分**:表中的单文件 `video.ts` 现在只保留 `agnes_video_generate` 工具定义与兼容 barrel;建任务/轮询状态机在 `video-client.ts`,V2.0 与 2.5 两套请求体分别在 `video-protocol.ts` / `video-protocol-25.ts`,目录花名册在 `video-models.ts`。
107
120
  > - **两个开关 store 不再逐字镜像**:重复体已收敛到 `switch-store.ts`(provider / draw / video / AgnesCode 共用一层),`draw-store.ts` 与 `video-store.ts` 只剩各自的文件名、形状版本、model wire key 与报错措辞。事故与收敛过程见 [PITFALLS.md](./PITFALLS.md) §34。
108
121
  > - **draw / video 共用的路由处理器**现在落在 `routes/tool-switch.ts`,由 `routes.ts` 门面统一注册。
122
+ > - **§7/§8 表格里指名的共享私有函数 `mountAgentTool` 已于 0.13.x 换成 `createToolMount`**(热挂载协调器,§9):工具从「挂载一次、卸载等 Host 重启」变成「开关/模型写入后即时挂载、卸载、换模型」。表格里 `lifecycle.ts` 一行的 `registerDrawTool` / `registerVideoTool` 名字不变,现在它们返回并保留热句柄而不是 fire-and-forget 地丢弃。
109
123
 
package/docs/README.md CHANGED
@@ -17,6 +17,7 @@ AI 协作会话从根目录 [AGENTS.md](../AGENTS.md) 进入:验证怎么跑
17
17
  | [AUTH.md](./AUTH.md) | 一跳账号密码登录、无 refresh token(重登即续期)、密码明文过 TLS 与不落盘纪律、登录 trace、防锁号节流 | 改登录/重登、排查登录失败 |
18
18
  | [API.md](./API.md) | 本地路由(`snapshot` / `account` / `api-key` / `provider` / `models` / `draw` / `video` / `agnescode` 共 8 组)、控制台端点、配置端点清单 | 对接路由、看返回结构、调端点 |
19
19
  | [PROVIDER-HOT-RELOAD.md](./PROVIDER-HOT-RELOAD.md) | 提供方注册开关:从「配置字段 + 重启」到「面板开关 + 立即生效」的设计决策与同类插件调研 | 改 provider 注册、理解开关语义 |
20
+ | [TOOL-HOT-RELOAD.md](./TOOL-HOT-RELOAD.md) | 出图 / 视频 agent 工具热挂载机制(0.13.x):disposer 保留、每工具协调器、开关写入触发 `sync()`、teardown 顺序、宿主不给卸载句柄的粘性边界 | 改工具挂载 / 开关语义、排「翻了开关工具没变」 |
20
21
  | [TESTING.md](./TESTING.md) | 离线测试体系、`panel-decision.js` 机制、已知缺口 | 跑测试、理解测试为什么这样写 |
21
22
  | [AGNES-API.md](./AGNES-API.md) | **现行接口事实源**:控制台额度侧(端点表、响应信封、四窗口额度模型、套餐目录与 uuid 匹配)+推理侧(provider `agnes-token-plan`、key `AGNES_TOKEN_PLAN_API_KEY`、思考档位 safe-set、live-contract 护栏) | 改额度/推理接口、排「打到错网关」、对 live-contract 基线 |
22
23
  | [PITFALLS.md](./PITFALLS.md) | 真实踩坑(现象→根因→修法);**条数以文件内 `## N.` 编号为准,别在此写死** | 改代码前避坑、理解防御性代码的来由 |
@@ -0,0 +1,67 @@
1
+ # 出图 / 视频工具热挂载(Tool Hot-Mount)
2
+
3
+ 本文记录 0.13.x 起出图 / 视频 agent 工具(`agnes_draw_image` / `agnes_video_generate`)的**热挂载机制**:面板开关、模型偏好的每次写入都**就地**收敛工具的注册 / 撤下 / 换模型,无需重启 Host。**路由与开关的现行契约以 [API.md](./API.md) §1(`/draw` / `/video` 路由段)为准,provider 注册开关的决策记录见 [PROVIDER-HOT-RELOAD.md](./PROVIDER-HOT-RELOAD.md),本篇侧重机制与边界。**
4
+
5
+ ---
6
+
7
+ ## 1. 为什么 0.4.x 时期不热
8
+
9
+ 出图(0.4.2)与视频(0.4.3)工具开关落地时,「面板开关 + 立即生效」的语义只兑现了一半——**开关值本身**即时读写,工具的实际挂载 / 缺席要等**下一个 Host 启动**时 `startSideEffects` 重读生效值。三个成因,全部是本插件自己的接线选择,不是宿主能力边界:
10
+
11
+ 1. 开关状态存插件私有状态文件(`draw-store.ts` / `video-store.ts`),由自有路由 `POST /api/<name>/draw|video` 写入——路由只做**持久化 + 回显**,从不同步宿主注册表。
12
+ 2. 旧的一次性 `mountAgentTool` 把宿主 `tools.register()` 返回的卸载句柄直接丢弃(fire-and-forget),工具「挂载一次」后进程内无法撤下。
13
+ 3. 一条过时的注释(「the tools registry has no unregister call」)让「宿主没有卸载能力」当成了已知事实——**这是错的**。宿主 `tools.register(definition)` 本就返回一个 `() => void` 的 disposer;同族插件 workbuddy 的 image / video 工具长期以「常驻注册 + 保留句柄 + 开关切换时 live 卸载 / 换模型」的形态跑在生产。
14
+
15
+ ## 2. 机制
16
+
17
+ ### 2.1 宿主能力
18
+
19
+ `HostCtx.tools.register(definition)` 返回 `(() => void) | undefined`(`types.ts` 把返回类型从 `unknown` 收紧为 `(() => void) | undefined`)。返回 `undefined` 的宿主(不提供卸载路径)下工具退化为**粘性挂载一次**——那是宿主能力边界,行为与 0.4.x 一致,不是缺陷。
20
+
21
+ ### 2.2 每工具一个热挂载协调器
22
+
23
+ `createToolMount`(`src/host/lifecycle.ts`,替代旧的一次性 `mountAgentTool`;`registerDrawTool` / `registerVideoTool` 是它的两个薄包装,差异只有开关键、模型键、fetch 与工厂,降级阶梯写一份,两个工具**不可能降级不同**)。每个协调器持有自己的闭包状态:
24
+
25
+ | 状态 | 含义 |
26
+ |---|---|
27
+ | `tools` | 缓存的宿主 tools 服务(`resolveServiceWithRetry` 找到一次后复用;没找到时每次 `sync()` 重新探测,晚到的服务能被后续协调接住) |
28
+ | `registered` | 宿主是否真的持有该工具的一份副本 |
29
+ | `disposer` | 撤下句柄;宿主不给卸载路径时为 `null`(粘性) |
30
+ | `regModel` | 当前注册所用模型偏好(面板保存值 > 配置默认) |
31
+ | `chain` | **同一条 promise 链**:所有协调(挂载种子、开关 POST、模型 POST、forget、teardown 前在途调用)串在这条链上,并发触发不会重复注册同一工具;链上每跑一次都**重读当前生效值**再收敛,排队的旧协调照样落到最新态 |
32
+
33
+ 收敛规则(`sync()` 每跑一次):
34
+
35
+ - **关** → 有 disposer 就撤下(清状态后 `safeCall(disposer)`);没有则保持粘性,不动。
36
+ - **开且与已注册模型一致** → 幂等,不动。
37
+ - **开但模型变了** → 有 disposer 先撤旧再注新(模型偏好也即时生效);无 disposer 保持首次注册值。
38
+ - 挂载路径:读 tools 服务 → 懒加载 tools peer(`defineTool`)→ 工厂构造工具定义(目录**调用时**读取:活目录缺席回落 `catalogStore.list()`,目录刷新落地不需要重注册)→ `tools.register(definition)`(catch:拒绝的注册表降级为工具缺席)→ 捕获 disposer。
39
+
40
+ **双降级纪律不变**:无 tools 服务 / peer 装载失败 / 注册表拒绝——每一档都是**静默**降级:面板与 provider 照常,工具只是缺席,没有任何一行日志把失败打成配置错误。
41
+
42
+ ### 2.3 句柄与触发点
43
+
44
+ 句柄落在接线袋(`wiring.drawToolMount` / `wiring.videoToolMount`,`startSideEffects` 填充)。开关路由(`src/host/routes/tool-switch.ts` 的 `registerToolSwitchRoute`,draw 与 video 共用一份处理器)在**每次成功的 POST 写入后**(`enabled` / `forget` / 模型偏好三处)调用协调器 `sync()`:
45
+
46
+ - 协调是 **fire-and-forget**,不阻塞 HTTP 响应;失败自吞(面板状态永远是对的,工具注册跟不跟上取决于宿主能力)。
47
+ - **GET 只读路径绝不协调**——读不应改写宿主注册表。
48
+
49
+ ## 3. teardown 顺序
50
+
51
+ 卸载顺序按 [PITFALLS.md](./PITFALLS.md) §18 钉死:`stop → dispose → release → tools → off`。两个工具协调器的 `dispose()` 在路由 `off()` **之前**执行(撤下活工具 + 关闭句柄),让在途的开关 POST 无法再往一个正在离场的宿主里同步工具;句柄关闭后,队列里未跑的 `sync()` 变成 no-op 而不是迟到的注册。
52
+
53
+ ## 4. 边界与失败形状
54
+
55
+ - **宿主不给卸载句柄**(`register` 返回 `undefined`):工具对本进程**粘性**——开关 / 模型改动能被协调器读到,但没有句柄可撤,注册保持首次值;行为与 0.4.x 完全一致。面板开关的状态读写永远即时生效。
56
+ - **两个 Host 进程共享同一状态目录**:后写者胜(与 provider / throttle / catalog 状态文件语义一致)。
57
+ - **协调失败自静默**:tools 服务缺席、peer 装载失败、注册表拒绝——面板与 provider 零影响,工具缺席即可;没有任何一行日志为它点名。
58
+
59
+ ## 5. 测试锚点
60
+
61
+ - `test/routes.test.mjs`:开关路由的无 tools 服务降级路径(假宿主 ctx 无 `tools` 服务)与键名隔离(组 S 交叉投递 / 各自回读,[API.md](./API.md) §1 `/draw` / `/video` 段引注)。
62
+ - **已知缺口**:disposer 路径(挂载 / 撤下 / 换模型)尚无离线套件——需要向挂载缝注入一个返回 disposer 的假 `tools` 服务;补套件前先冻结现状。
63
+ - 工具纯逻辑(失败分诊 / 冷却 / hints)由 `test/draw.test.mjs` 等覆盖,与挂载无关。
64
+
65
+ ---
66
+
67
+ > 跨链:决策记录(为何不走 volatile、开关语义沿革)见 [PROVIDER-HOT-RELOAD.md](./PROVIDER-HOT-RELOAD.md) §7–§9;出图对接点(ARCHITECTURE §5.4 接法 B)见 [ARCHITECTURE.md](./ARCHITECTURE.md) §5.4;「为何自己造工具」的 runtime 取证见 [DSH-UNIFIED-INTERFACE-AUDIT.md](./DSH-UNIFIED-INTERFACE-AUDIT.md)。
@@ -76,7 +76,7 @@
76
76
  - **没有「登出」按钮**:能停的是 provider 开关,不是令牌。「删除已存 JWT」是隐私动作,被刻意移出日常操作;真正吊销只能在桌面 App 里登出 / 改密码。见 [SETUP.md](./SETUP.md) §3。
77
77
  - **「可看图」清单为空**:Agnes 目录条目**不带** `input_modalities`(实测只有 `id`/`object`/`created`/`owned_by`/`supported_endpoint_types` 五个字段),`identifyVisionModel` 只能读目录 → 恒 false。这是**「读不到」而不是「没有」**——官方文档(`AGNES-API-docs/`)明说 `agnes-3.0-flash` / `agnes-2.5-pro` / `agnes-2.5-flash` 支持「文本 + 图像 URL 输入」,所以空清单不能当作「真没有可看图模型」的证据。见 [AGNES-API.md](./AGNES-API.md) §7.1。
78
78
  - **「可看图」整行消失**:不是被裁掉,是没有 API Key → 目录没拉 → 视觉清单无从生成。见 §1.3 第一行。
79
- - **改了设置不重启也生效,但工具挂载要等下次启动**:开关与勾选走热生效;agent 工具没有 unregister 语义,挂载 / 缺席只发生在 Host 启动时。见 [PROVIDER-HOT-RELOAD.md](./PROVIDER-HOT-RELOAD.md)。
79
+ - **翻了开关出图 / 视频工具却没变**:开关值读写永远即时;工具挂载跟不跟得上取决于宿主能力——宿主不给卸载句柄时工具对本进程**粘性**(保持首次注册值,0.13.x 起的行为边界),机制与失败形状见 [TOOL-HOT-RELOAD.md](./TOOL-HOT-RELOAD.md) §4。
80
80
  - **拼错的配置键只记日志、不报错**:老 patch 里的历史键不该把面板打成配置错误。见 [SETUP.md](./SETUP.md) §3。
81
81
  - **429 与额度耗尽不是一件事**:402 是额度不足,429 多为速率上限(订阅用尽也可能回 429)。见 [AGNES-API.md](./AGNES-API.md)。
82
82
 
package/lib/index.js CHANGED
@@ -5490,9 +5490,12 @@ const VIDEO_PATH = `/api/${name}/video`;
5490
5490
  * @param {string} options.modelSourceKey - the response key carrying its source.
5491
5491
  * @param {string} options.configModelId - the config default for the model id.
5492
5492
  * @param {Set<string>} options.allowedHosts - the trust fence.
5493
+ * @param {ToolMount} [options.mount] - the live tool mount handle; each
5494
+ * successful POST write converges it (hot reload). Absent = the route only
5495
+ * persists state, the mount is reconciled elsewhere (mount seed / teardown).
5493
5496
  * @returns {Function} the `off()` unregister callback.
5494
5497
  */
5495
- function registerToolSwitchRoute(ctx, { path, label, store, enabledKey, enabledSourceKey, configEnabled, modelKey, modelSourceKey, configModelId, allowedHosts }) {
5498
+ function registerToolSwitchRoute(ctx, { path, label, store, enabledKey, enabledSourceKey, configEnabled, modelKey, modelSourceKey, configModelId, allowedHosts, mount }) {
5496
5499
  return ctx.webServer.register({
5497
5500
  kind: "exact",
5498
5501
  path,
@@ -5516,6 +5519,9 @@ function registerToolSwitchRoute(ctx, { path, label, store, enabledKey, enabledS
5516
5519
  ...extra
5517
5520
  }, { "cache-control": "no-store" });
5518
5521
  };
5522
+ const commit = () => {
5523
+ if (mount !== null && mount !== void 0) mount.sync().catch(() => {});
5524
+ };
5519
5525
  if (method === "GET") {
5520
5526
  await answer();
5521
5527
  return;
@@ -5543,6 +5549,7 @@ function registerToolSwitchRoute(ctx, { path, label, store, enabledKey, enabledS
5543
5549
  });
5544
5550
  return;
5545
5551
  }
5552
+ commit();
5546
5553
  await answer();
5547
5554
  return;
5548
5555
  }
@@ -5571,6 +5578,7 @@ function registerToolSwitchRoute(ctx, { path, label, store, enabledKey, enabledS
5571
5578
  });
5572
5579
  return;
5573
5580
  }
5581
+ commit();
5574
5582
  await answer();
5575
5583
  return;
5576
5584
  }
@@ -5597,12 +5605,14 @@ function registerToolSwitchRoute(ctx, { path, label, store, enabledKey, enabledS
5597
5605
  });
5598
5606
  return;
5599
5607
  }
5608
+ commit();
5600
5609
  await answer();
5601
5610
  }
5602
5611
  });
5603
5612
  }
5604
5613
  /**
5605
- * Register the draw switch route. Wiring subset: `settings`, `drawStore`.
5614
+ * Register the draw switch route. Wiring subset: `settings`, `drawStore`,
5615
+ * `drawToolMount` (the live handle the successful writes converge).
5606
5616
  * @param ctx - the host root context.
5607
5617
  * @param {object} wiring - as assembled by `apply()` in `index.ts`.
5608
5618
  * @returns {Function} the `off()` unregister callback.
@@ -5619,11 +5629,13 @@ function registerDrawRoute(ctx, wiring) {
5619
5629
  modelKey: "drawModelId",
5620
5630
  modelSourceKey: "drawModelSource",
5621
5631
  configModelId: settings.drawModelId,
5622
- allowedHosts: settings.allowedHosts
5632
+ allowedHosts: settings.allowedHosts,
5633
+ mount: wiring.drawToolMount ?? null
5623
5634
  });
5624
5635
  }
5625
5636
  /**
5626
- * Register the video switch route. Wiring subset: `settings`, `videoStore`.
5637
+ * Register the video switch route. Wiring subset: `settings`, `videoStore`,
5638
+ * `videoToolMount` (the live handle the successful writes converge).
5627
5639
  * @param ctx - the host root context.
5628
5640
  * @param {object} wiring - as assembled by `apply()` in `index.ts`.
5629
5641
  * @returns {Function} the `off()` unregister callback.
@@ -5640,7 +5652,8 @@ function registerVideoRoute(ctx, wiring) {
5640
5652
  modelKey: "videoModelId",
5641
5653
  modelSourceKey: "videoModelSource",
5642
5654
  configModelId: settings.videoModelId,
5643
- allowedHosts: settings.allowedHosts
5655
+ allowedHosts: settings.allowedHosts,
5656
+ mount: wiring.videoToolMount ?? null
5644
5657
  });
5645
5658
  }
5646
5659
 
@@ -7556,11 +7569,13 @@ function defineVideoTool({ defineTool, resolveApiKey, getEntries, settings, fetc
7556
7569
  * - the mount seed (`seedPublisherFromCatalog`): offer models before the
7557
7570
  * first poll, fire-and-forget;
7558
7571
  * - the two generation tools (opt-in `drawEnabled` / `videoEnabled`,
7559
- * doubly degraded through the shared {@link mountAgentTool} ladder);
7572
+ * doubly degraded through the shared {@link createToolMount} ladder —
7573
+ * each handle is stored on the wiring bag, so the switch route re-syncs it
7574
+ * live and teardown disposes it);
7560
7575
  * - vision step two: the settings-row writer filled for the snapshot route.
7561
7576
  *
7562
7577
  * and {@link teardown} for the unmount order that PITFALLS §18 pins
7563
- * (`dispose → release → off×5` — it must NOT be simplified).
7578
+ * (`stop → dispose → release → tools → off` — it must NOT be simplified).
7564
7579
  *
7565
7580
  * Peer-free discipline: no Host peer is imported here. The only lazy peer
7566
7581
  * loads (the adapter / tools modules) are injected from `apply` via `deps`.
@@ -7574,12 +7589,14 @@ const SERVICE_RETRY_DELAY_MS = 300;
7574
7589
  /**
7575
7590
  * Read an optional Host service with a bounded retry.
7576
7591
  *
7577
- * A service may register AFTER this plugin mounts, and a mount-time read is
7578
- * the only window for a capability the Host cannot later remove: the tools
7579
- * registry has no unregister call. A one-shot read therefore misses a service
7580
- * that arrives a moment late for the WHOLE session, silently — the draw tool
7581
- * would be absent with the switch visibly on, and no line on any panel naming
7582
- * the reason. The AgnesCode mount seed uses the same retry for its publish.
7592
+ * A service may register AFTER this plugin mounts: a one-shot read misses a
7593
+ * service that arrives a moment late, silently — the draw tool would be
7594
+ * absent with the switch visibly on, and no line on any panel naming the
7595
+ * reason. A late service is catchable LATER too, not only here: the tool
7596
+ * coordinator caches the service once found, but a `sync()` that has not
7597
+ * found one yet re-probes on every run (so a switch flip picks up a tools
7598
+ * service that registered late). The AgnesCode mount seed uses the same
7599
+ * retry for its publish.
7583
7600
  *
7584
7601
  * This retries the READ ONLY. Whatever it returns is used exactly once by the
7585
7602
  * caller: retrying a call that mutates (a tool registration) would register the
@@ -7616,7 +7633,8 @@ async function resolveServiceWithRetry(ctx, service, options = {}) {
7616
7633
  return found;
7617
7634
  }
7618
7635
  /**
7619
- * Mount one opt-in agent tool, through the shared degradation ladder.
7636
+ * The live mount handle for ONE opt-in agent tool, through the shared
7637
+ * degradation ladder.
7620
7638
  *
7621
7639
  * `registerDrawTool` and `registerVideoTool` differ only in which switch, which
7622
7640
  * model preference, which fetch and which factory they name — everything else
@@ -7626,42 +7644,84 @@ async function resolveServiceWithRetry(ctx, service, options = {}) {
7626
7644
  * degrade differently: a tool that vanished on a Host where the other survived
7627
7645
  * would be a bug with no visible cause.
7628
7646
  *
7629
- * Every rung returns silently rather than throwing. A Host without a tools
7630
- * service, or a peer that fails to load, must leave the panel and the provider
7631
- * untouched — the tool is simply absent.
7647
+ * The returned {@link ToolMount} is what makes the tool HOT: the panel switch
7648
+ * route re-runs `sync()` after it writes the state file, and `sync()` mounts
7649
+ * or unmounts the tool in place using the disposer the host's
7650
+ * `tools.register` returns. Every rung still returns silently rather than
7651
+ * throwing: a Host without a tools service, or a peer that fails to load,
7652
+ * leaves the panel and the provider untouched — the tool is simply absent.
7653
+ *
7654
+ * State is per-handle (a closure), shared by the mount-time seed, every switch
7655
+ * POST and teardown, so the two callers cannot drift into registering the same
7656
+ * tool twice: all reconciliations are serialized behind one promise chain,
7657
+ * each re-reading the CURRENT effective value before it acts.
7632
7658
  * @param {object} options - wiring.
7633
- * @param {object} options.ctx - the host root context.
7634
- * @param {object} options.wiring - see {@link startSideEffects}.
7635
- * @param {object} options.side - the test seams from `apply`'s `deps`.
7636
- * @param {object} [options.store] - the panel switch store (`draw-store` / `video-store`).
7637
- * @param {string} options.enabledKey - the settings key holding the config default.
7638
- * @param {string} options.modelKey - the settings key holding the configured model id.
7659
+ * @param {HostCtx} options.ctx - the host root context.
7660
+ * @param {HostWiring} options.wiring - see {@link startSideEffects}.
7661
+ * @param {ToolSide} options.side - the test seams from `apply`'s `deps`.
7662
+ * @param {SwitchStore|null|undefined} [options.store] - the panel switch store.
7663
+ * @param {"drawEnabled"|"videoEnabled"} options.enabledKey - the config-default switch key.
7664
+ * @param {"drawModelId"|"videoModelId"} options.modelKey - the config-default model key.
7639
7665
  * @param {Function} options.fetchImpl - the request fetch (stubbed in tests).
7640
7666
  * @param {Function} options.factory - `defineDrawTool` / `defineVideoTool`.
7641
- * @returns {Promise<void>}
7667
+ * @returns {ToolMount} the live handle; call `sync()` to reconcile.
7642
7668
  */
7643
- async function mountAgentTool({ ctx, wiring, side, store, enabledKey, modelKey, fetchImpl, factory }) {
7669
+ function createToolMount({ ctx, wiring, side, store, enabledKey, modelKey, fetchImpl, factory }) {
7644
7670
  const { settings, configError, providerState, catalogStore, resolveApiKey, publisher } = wiring;
7645
- if (configError !== null) return;
7646
- const { enabled: toolEnabled } = resolveSwitchEnabled(await readPanelValue(async () => await store?.enabled() ?? null), settings[enabledKey]);
7647
- if (toolEnabled !== true) return;
7648
- const panelModelId = await readPanelValue(async () => await store?.modelId() ?? null);
7649
- const effectiveSettings = panelModelId !== null ? {
7650
- ...settings,
7651
- [modelKey]: panelModelId
7652
- } : settings;
7653
- const tools = await resolveServiceWithRetry(ctx, "tools", { isDisposed: () => publisher.isDisposed() });
7654
- if (tools === null || typeof tools.register !== "function") return;
7655
- let defineTool;
7656
- try {
7657
- const mod = await Promise.resolve(side.loadToolsModule?.());
7658
- defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
7659
- } catch {
7660
- return;
7661
- }
7662
- if (typeof defineTool !== "function") return;
7663
- try {
7664
- tools.register(factory({
7671
+ let tools = null;
7672
+ let registered = false;
7673
+ let disposer = null;
7674
+ let regModel = null;
7675
+ let closed = false;
7676
+ let chain = Promise.resolve();
7677
+ const safeCall = (fn) => {
7678
+ if (fn === null) return;
7679
+ try {
7680
+ fn();
7681
+ } catch {}
7682
+ };
7683
+ async function syncImpl() {
7684
+ if (closed) return;
7685
+ if (configError !== null) return;
7686
+ const panelEnabled = await readPanelValue(async () => await store?.enabled() ?? null);
7687
+ const panelModel = await readPanelValue(async () => await store?.modelId() ?? null);
7688
+ const { enabled } = resolveSwitchEnabled(panelEnabled, settings[enabledKey]);
7689
+ const { value: targetModel } = resolveSwitchValue(panelModel, settings[modelKey]);
7690
+ if (enabled !== true) {
7691
+ if (registered) {
7692
+ const d = disposer;
7693
+ registered = false;
7694
+ disposer = null;
7695
+ regModel = null;
7696
+ safeCall(d);
7697
+ }
7698
+ return;
7699
+ }
7700
+ if (registered && regModel === targetModel) return;
7701
+ if (registered && disposer === null) return;
7702
+ if (registered && disposer !== null) {
7703
+ const d = disposer;
7704
+ registered = false;
7705
+ disposer = null;
7706
+ regModel = null;
7707
+ safeCall(d);
7708
+ }
7709
+ const service = tools ?? await resolveServiceWithRetry(ctx, "tools", { isDisposed: () => publisher.isDisposed() });
7710
+ if (service === null || typeof service.register !== "function") return;
7711
+ tools = service;
7712
+ let defineTool;
7713
+ try {
7714
+ const mod = await Promise.resolve(side.loadToolsModule?.());
7715
+ defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
7716
+ } catch {
7717
+ return;
7718
+ }
7719
+ if (typeof defineTool !== "function") return;
7720
+ const effectiveSettings = targetModel !== settings[modelKey] ? {
7721
+ ...settings,
7722
+ [modelKey]: targetModel
7723
+ } : settings;
7724
+ const definition = factory({
7665
7725
  defineTool,
7666
7726
  resolveApiKey,
7667
7727
  getEntries: async () => {
@@ -7671,21 +7731,48 @@ async function mountAgentTool({ ctx, wiring, side, store, enabledKey, modelKey,
7671
7731
  settings: effectiveSettings,
7672
7732
  fetchImpl,
7673
7733
  isDisposed: () => publisher.isDisposed()
7674
- }));
7675
- } catch {}
7734
+ });
7735
+ let newDisposer;
7736
+ try {
7737
+ newDisposer = service.register(definition);
7738
+ } catch {
7739
+ return;
7740
+ }
7741
+ registered = true;
7742
+ regModel = targetModel;
7743
+ disposer = typeof newDisposer === "function" ? newDisposer : null;
7744
+ }
7745
+ return {
7746
+ sync: () => {
7747
+ const run = chain.then(() => syncImpl());
7748
+ chain = run.then(() => void 0, () => void 0);
7749
+ return run;
7750
+ },
7751
+ dispose: () => {
7752
+ closed = true;
7753
+ const d = disposer;
7754
+ registered = false;
7755
+ disposer = null;
7756
+ regModel = null;
7757
+ safeCall(d);
7758
+ },
7759
+ isMounted: () => registered
7760
+ };
7676
7761
  }
7677
7762
  /**
7678
- * Register the `agnes_draw_image` agent tool (ARCHITECTURE.md §5.4, route B).
7679
- * Opt-in (`drawEnabled`, default off) and doubly degraded. Named as a separate
7680
- * export so a test can inject its own `ctx`/`wiring`; `startSideEffects` calls
7681
- * it when enabled.
7763
+ * Create the live mount handle for the `agnes_draw_image` agent tool
7764
+ * (ARCHITECTURE.md §5.4, route B). Opt-in (`drawEnabled`, default off) and
7765
+ * doubly degraded. The handle is stored on the wiring bag (`wiring.drawToolMount`)
7766
+ * so the switch route can re-sync it live and teardown can dispose it; the
7767
+ * initial `sync()` seeds the mount-time behavior (register now when the
7768
+ * effective value is on).
7682
7769
  * @param ctx - the host root context (reads `ctx.get("tools")` / `ctx.tools`).
7683
7770
  * @param {object} wiring - see {@link startSideEffects}.
7684
7771
  * @param {object} side - test seams from `apply`'s `deps`.
7685
- * @returns {Promise<void>}
7772
+ * @returns {ToolMount} the live handle, also stored at `wiring.drawToolMount`.
7686
7773
  */
7687
- async function registerDrawTool(ctx, wiring, side) {
7688
- await mountAgentTool({
7774
+ function registerDrawTool(ctx, wiring, side) {
7775
+ const mount = createToolMount({
7689
7776
  ctx,
7690
7777
  wiring,
7691
7778
  side,
@@ -7695,21 +7782,25 @@ async function registerDrawTool(ctx, wiring, side) {
7695
7782
  fetchImpl: side.drawFetch,
7696
7783
  factory: defineDrawTool
7697
7784
  });
7785
+ wiring.drawToolMount = mount;
7786
+ mount.sync();
7787
+ return mount;
7698
7788
  }
7699
7789
  /**
7700
- * Register the `agnes_video_generate` agent tool (`video.ts`).
7790
+ * Create the live mount handle for the `agnes_video_generate` agent tool
7791
+ * (`video.ts`).
7701
7792
  *
7702
7793
  * A SEPARATE opt-in from the draw tool (`videoEnabled`), sharing only the
7703
- * mounting ladder — see {@link mountAgentTool}. The protocol is the reason the
7794
+ * mounting ladder — see {@link createToolMount}. The protocol is the reason the
7704
7795
  * two are not one module: video is an asynchronous task, so this tool creates a
7705
7796
  * task and then polls it for minutes rather than issuing one request.
7706
7797
  * @param ctx - the host root context.
7707
7798
  * @param {object} wiring - see {@link startSideEffects}.
7708
7799
  * @param {object} side - test seams from `apply`'s `deps`.
7709
- * @returns {Promise<void>}
7800
+ * @returns {ToolMount} the live handle, also stored at `wiring.videoToolMount`.
7710
7801
  */
7711
- async function registerVideoTool(ctx, wiring, side) {
7712
- await mountAgentTool({
7802
+ function registerVideoTool(ctx, wiring, side) {
7803
+ const mount = createToolMount({
7713
7804
  ctx,
7714
7805
  wiring,
7715
7806
  side,
@@ -7719,6 +7810,9 @@ async function registerVideoTool(ctx, wiring, side) {
7719
7810
  fetchImpl: side.videoFetch,
7720
7811
  factory: defineVideoTool
7721
7812
  });
7813
+ wiring.videoToolMount = mount;
7814
+ mount.sync();
7815
+ return mount;
7722
7816
  }
7723
7817
  /**
7724
7818
  * Run the mount-time side effects: the persisted-catalog seed, the draw tool
@@ -7792,7 +7886,9 @@ function startSideEffects(ctx, wiring, side) {
7792
7886
  * must not register into a Host that is letting this plugin go;
7793
7887
  * 2. `release` — stop offering the provider first, so a request cannot be
7794
7888
  * routed to an adapter whose Host services are already half gone;
7795
- * 3. the route `off()` callbacks, each guarded (the web server may already
7889
+ * 3. the live agent tools (draw / video) are unmounted, so an in-flight
7890
+ * switch handler cannot re-sync a tool into a Host that is going away;
7891
+ * 4. the route `off()` callbacks, each guarded (the web server may already
7796
7892
  * be gone during shutdown).
7797
7893
  *
7798
7894
  * This order is a concurrency fix and must NOT be simplified.
@@ -7812,6 +7908,8 @@ function teardown(wiring, offs, stopTick) {
7812
7908
  agnescodePublisher?.dispose();
7813
7909
  agnescodePublisher?.release?.();
7814
7910
  releaseProvider();
7911
+ wiring.drawToolMount?.dispose();
7912
+ wiring.videoToolMount?.dispose();
7815
7913
  for (const off of offs) try {
7816
7914
  off();
7817
7915
  } catch {}
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.13.2",
5
+ "version": "0.13.3",
6
6
  "description": "One-tap connect Agnes language, image and video models into DSH (off by default), with image recognition and thinking level filled in automatically. After signing in to the Agnes console (re-login uses AGNES_PASSWORD), you can see the quota windows (5-hour requests, weekly requests, daily images, video), account-wide cumulative usage, and a usage pill beside the input box. AgnesCode desktop models can be connected too (Windows only), re-reading the desktop app login when the token expires, with rate-limit hits backing off on their own.",
7
7
  "keywords": [
8
8
  "dsh-plugin",