dsh-xdpool 2.4.5 → 2.5.0

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
@@ -1,6 +1,166 @@
1
1
  # Changelog
2
2
  本项目的所有重要变更都会记录在此文件。
3
3
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
+
5
+ ## 2.5.0 — 十张 issue 一起收:失效账号、第三条指纹、CodeArts 上限、保存回滚、侧栏开关
6
+
7
+ > #68 到 #83 攒了十来张单子,其中三张(#72/#73/#74)是同一类毛病:代码把一个「没数据」和「数据是空」当成了同一件事。这批按这个线索统一收拾,另外把社区两个 PR 收了进来。
8
+
9
+ ### 最要紧的三条
10
+
11
+ | 症状 | 真实原因 |
12
+ | --- | --- |
13
+ | 一个失效账号吃掉整次请求的 8 次重试,最后报「API 密钥无效」 | 以「刷新拿到了新 token」判定账号仍可用。有一类账号 refresh 长期有效、chat 恒回 403,判死条件因此恒假 |
14
+ | buddy 某一轮被中断后,之后每一轮都失败(`a message has empty content`) | 中断留下的 assistant 消息只有 reasoning、没有正文,序列化时空字符串被原样发到 wire,国内网关 400/11151 且不在重试集合里 |
15
+ | zcode 全部模型勾选后,每次重启又变回全禁用 | 用户全开时黑名单是空对象,与「从未选过」无法区分,启动补静音据此又把它们全关一遍 |
16
+
17
+ ### #68 失效账号耗尽重试预算
18
+
19
+ 池车道以「刷新成功」当「账号可用」。对 refresh 长期有效而业务侧恒 403 的账号,`recovered` 与 `accessToken !== before` 恒真 ⇒ 永不 `penalizeCredentialDead` ⇒ `available()` 不排除 ⇒ `priority` 下 `acquire()` 一直返回同一个 ⇒ 8 次预算全花在它身上。
20
+
21
+ 改法:单次请求内只允许该账号换一次票(`refreshedThisRequest`),第二次仍报 `session_dead` 就直接判死并轮到下一个账号。压缩重试路径同步。取「第二次」而不是把惩罚提到刷新之前,是为了仍给真正过期的 token 一次机会。
22
+
23
+ ### #70 侧栏入口开关
24
+
25
+ 新增 `sidebarRow` 配置(默认开)。**只**控制左侧栏那一行:设置页的 XD Pool 卡片始终注册,模型池不受影响 —— 关掉入口不能把开关本身也锁在门外。开关写在卡片里,`apply()` 与卡片读同一份设置镜像,因此两边不会给出不同答案;宿主发布了变更通知的平台上无需重启。
26
+
27
+ ### #72 订阅包余额虚高 30 倍
28
+
29
+ `fetchCredits` 只把 `CapacityType === 4` 当月包,而实际有三种:`1`=一次性包、`2`=**周期订阅包**(12 期 × 4000,`CapacityRemain` 是整期剩余、`CycleCapacityRemain` 才是当期剩余)、`4`=月包。`type=2` 落进「一次性」分支,整期额度被当余额。三账号实测:官方 3,399.8 / 插件 102,396 → 修复后 3,366;另两个只有月包的账号(3,049 / 2,771)不变,所以长期没被发现。
30
+
31
+ `expiresAtMs` 对 `type=2` 取 `DeductionEndTime`,**不能用 `CycleEndTime`** —— 它每月都到,会让「会过期」天天报警。空包只在一次性包时丢弃,月包当期耗尽要留着显示「0/500 · 下月回满」。
32
+
33
+ Refs #72。
34
+
35
+ ### #73 中断回合留下的空 assistant 消息
36
+
37
+ `serializeMessages2` 出口加 `isBlankWireMessage` / `dropBlankWireMessages`,`serializeMessages4` 同步。三点取舍都用真实请求验证过:`user` 空消息不动(两网关都容忍);不伪造占位内容(会污染模型看到的对话);**有 `tool_calls` 的 assistant 消息绝不删**(它是 tool 结果的配对锚点,删了造孤儿)。
38
+
39
+ 实测矩阵(国内):空 assistant 400/11151;空 system 同样 400/11151;空 user 200;`content:null` + `tool_calls` 200;删掉空 assistant 后正常 SSE。国际网关对全部形态都 200,所以故障面目前只在 buddy 车道。
40
+
41
+ Refs #73。
42
+
43
+ ### #74 全开渠道每次重启被重新禁用
44
+
45
+ `setModelDisabled` 打开模型时是**删键**而不是写 `false`,于是「用户全开」与「从未选择」在磁盘上是同一个空对象。新增持久标记 `selectionTouched`:四个用户入口(`setModelDisabled` / `setModelsDisabled` / `clearModelsDisabled` / `clearDisabledModels`)都置位,启动补静音只在标记缺失时执行,且自身写黑名单时传 `{ mark: false }` 不置位。
46
+
47
+ 影响面是「所有模型都打开」的渠道,目录越小越必然(zcode 2 个、gemini/minimax/raccoon 同理);部分启用的渠道免疫,这解释了为什么只有 zcode 反复被报。
48
+
49
+ Refs #74。
50
+
51
+ ### #75 保存模型选择后界面回滚 30 秒
52
+
53
+ `saveModels` 收尾是 `setDraftFor(region, undefined)`,而它的语义是「退回池子最后报告的状态」,那份状态靠 30 秒轮询刷新 —— 于是保存后卡片画的是**保存前**的清单,用户在这 30 秒里再点一次保存,写入的就是回滚后的旧清单。另外 `settingsScope.set` 被声明成 `Promise<void>`,把宿主 revision 冲突时返回的 `false` 丢掉了,被拒绝也提示「已保存」。
54
+
55
+ 改法:先 `refresh(region)` 再清草稿(重读失败则保留草稿);`set` 返回类型放宽到 `boolean | void`,`=== false` 时保留草稿、报「设置已在别处被修改」、不显示已保存。
56
+
57
+ Refs #75。
58
+
59
+ ### #82 RPC 桥惰性解析
60
+
61
+ 2.4.6 的 CHANGELOG 声称这条已修,但发布产物里 `installPackRpc` 仍只捕获一次。现已改为每次 `callPack` 前 `if (packRpcBridge === undefined) packRpcBridge = resolvePackRpcBridge()`:`connection` 就绪晚于 `apply()` 时,桥不会再永久为空、退化成裸 `fetch('/api/channel-pack')` 吃 401 而让账号池整片空白。
62
+
63
+ Refs #82。
64
+
65
+ ### 同批合并的社区 PR
66
+
67
+ - **#81([@jmglsi](https://github.com/jmglsi))** 第三条内容指纹:`Relative paths resolve from the workspace root` → `are resolved against the workspace root`。用陈述句的过去分词形式,语义、长度、换行位置都不变,被网关字符串表收录的子串消失。
68
+ - **#84([@Refeain](https://github.com/Refeain))** CodeArts 输出上限见下节。
69
+
70
+ ### #69 / #71 尚未定性
71
+
72
+ `#69 CodeArts 提示密钥无效` 只有一句话。它至少有两条来源:本条 `InferHub.4001.200`(SSE 帧里的 200,不是 4xx)与真正的 401/403。前者已随 #83 修掉;后者需要失败时的原始响应才能确认。
73
+ `#71 kimi k3.1 pro 没有响应` 同样只有一句话,且目录里没有 `kimi-k3.1-pro` 这个 id(有 `kimi-k3-1`)。请在 issue 里补一下:是空回复、卡住不动、还是有报错文案。
74
+
75
+ ### 详情:CodeArts 的输出上限是上限,不是默认值
76
+
77
+ > 华为这条链接 65536 能用、131072 就空流,代码里也一直写着这个数字。但它是 `options.maxTokens ?? 65536` —— 只兜底,不设界。
78
+
79
+ ### 触发条件:宿主把别的 provider 的额度原样递过来
80
+
81
+ 宿主从模型目录里取 `maxTokens` 填进 `options.maxTokens`,而那份目录里 384000 是 deepseek-v4-pro、fireworks、huggingface 这些**真的能到 384000** 的条目的正常值。CodeArts 自己没声明上限时,这个 384000 就原样发出去了。
82
+
83
+ 后果不是 4xx:后端回 **HTTP 200**,把错误装在 SSE 帧里 ——
84
+
85
+ ```
86
+ InferHub.4001.200 The request param is invalid, Please check it
87
+ ```
88
+
89
+ 适配器把它映射成 `INVALID_REQUEST`。用户看到的是「这个账号好好的,突然发不出话」,而新建会话发「你好」就失败,第一反应必然是密钥坏了 —— 这也是 #69「CodeArts 提示密钥无效」的一条候选根因(#69 正文只有一句话,无法确认是这条还是 403 那条,故 #83 里保留判断)。
90
+
91
+ ### 实测(2026-10-25,失败会话的原文 system/user + 全部 133 个 tools 直连,只改 max_tokens)
92
+
93
+ | max_tokens | 结果 |
94
+ | --- | --- |
95
+ | 384000 | HTTP 200 + SSE 错误帧 `InferHub.4001.200` → INVALID_REQUEST |
96
+ | 131072 | HTTP 200,空流 |
97
+ | 65536 | 正常流式回复 |
98
+
99
+ ### 改法:三处对齐,线上请求做 clamp
100
+
101
+ - `listModels()` 每个模型带 `maxTokens`,宿主没有理由再自己猜一个更大的
102
+ - `resolveModel()` 带 `defaultMaxTokens`,`catalogFactsFor()` 会把它当 `fact.maxTokens` 交给宿主 —— 这是宿主唯一能知道上限的通道
103
+ - 线上 `max_tokens: Math.min(options.maxTokens ?? CODEARTS_MAX_OUTPUT_TOKENS, CODEARTS_MAX_OUTPUT_TOKENS)`,调用方给更大就收回,给更小仍保留
104
+
105
+ TRAE 与 Cline 早就这么做了(`clampTraeMaxTokens` / `clampClineMaxTokens`,也都写 `resolved.defaultMaxTokens`),CodeArts 是唯一的例外。
106
+
107
+ - 回归:`tests/codearts-max-tokens.test.ts` 新增 5 例(源码断言,与 `tests/codearts-resign-headers.test.ts` 同形状 —— `pack.js` 是另一个仓库的构建产物,无法 import 驱动)
108
+ - 反向对照:撤掉 `pack.js` 的补丁后 5 例全红,补上全绿
109
+
110
+ Refs #83, #69。
111
+
112
+ ### 详情:第三条内容指纹:文件引用指引里的 “resolve from the workspace root”
113
+
114
+ > 2.4.1/2.4.2 修好的两个指纹管了一周,10 月 28 日又整片 400/11128。这次被点名的不是产品名,而是一句普通说明。
115
+
116
+ ### 触发点在 read 工具的文件引用指引
117
+
118
+ DSH 在 agent 有 `read` 工具时会贡献一段文件引用指引,开头是「Tokens prefixed with @ are paths the user explicitly referenced.」,第二句是 **「Relative paths resolve from the workspace root」**。把当次真实系统提示(约 41k 字符)整段发上去稳定 400/11128,逐段二分到 204 字符命中这段,再逐句验证:两句**单独发都是 200**,两句同现才 400;改其中任意一处小改动也 200 —— 所以这是内容指纹,不是长度规则、也不是头部规则。
119
+
120
+ 这也解释了为什么它看起来「不像」前两条:前两条是识别别的客户端的文案,这一条只是被网关的字符串表收进去了,句子本身没有任何产品名。
121
+
122
+ ### 改法:把陈述句改成同一句的过去分词形式
123
+
124
+ `Relative paths resolve from the workspace root` → `Relative paths are resolved against the workspace root`。语义、长度量级、换行位置都不变,被列表收录的子串消失。同一账号上整段 41k 系统提示由 400 转 200;渠道车道与池车道同步补上,`pack.js` 的三处 `[xdpool-local]` 标记保持原样。
125
+
126
+ - 池车道:`BANNED_SYSTEM_PHRASES` 增加第三行
127
+ - 渠道车道:`vendor/channel-pack/pack.js` 的同一个 `replace` 链增加第三段
128
+ - 回归:`tests/system-fingerprint.test.ts` 新增 5 例(含「三处指纹同现」整段用例);`tests/chat-identity-headers.test.ts` 对已发布 bundle 的字节断言补上第三段,pack 刷新若把它冲掉会变红
129
+
130
+ ### 其他
131
+ - 全套 79 文件 1028 用例(1026 通过 + 2 跳过)。
132
+
133
+ ### 验证
134
+
135
+ - 全套 **80 文件 / 1033 用例**(含 #81 的 5 例、#84 的 5 例、本批新增)
136
+ - `pack.js` 每次改动后 `node --check` 通过
137
+ - server `tsc` 0 错;client `tsc` 的 6 条是既存问题(`accounts.ts` / `context-budget.ts` / `free-upstream.ts` 的 Node 类型不匹配),改前改后数量一致
138
+
139
+ ## 2.4.6 — 扫码账号每次打开都空白:一份数据卡在两个细节里
140
+
141
+ > 2.4.4/2.4.5 之后侧栏能进、能滚了,但扫码过的渠道在账号池里经常整片空白,重开也一样。磁盘上账号是齐的(`state.json` 里 15 条),所以问题全在读取链。
142
+
143
+ ### 16 秒的事实抓取把首屏挡掉了
144
+
145
+ `load()` 里先 `await fetchChannelModels(...)` 再 `setEntries(...)`。那次采样式采到了 **16.5 秒**——它要把每个模型逐个交给宿主适配器解析上下文窗口,冷启动时很慢。卡片区在这十几秒里是空的,看起来就像「没有账号」。
146
+
147
+ 改成两阶段:`provider.status` 与 `account.list` 先回来(几十毫秒),**卡片立刻渲染**;事实(窗口/识图)随后用 `setEntries(previous => ...)` 合并进去。
148
+
149
+ ### 失败被渲染成了「空」
150
+
151
+ `provider.status` 是「哪些渠道有账号」的唯一判据。它一旦整段返回 401,`scanProviders` 就是空的,`entries` 也是空的,组件走到 `if (entries.length === 0 && error === undefined) return <></>`——**静默渲染空白,一个字都不说**。读者无法区分「还没扫码」和「读取失败」。
152
+
153
+ 现在把原始信封留下来判 `ok`:真的读失败就把错误显示出来。
154
+
155
+ ### RPC 桥只捕获一次
156
+
157
+ `callPack` 必须走 `ctx.connection.rpc`(裸 `fetch('/api/channel-pack')` 被宿主 401,因为 `/api/*` 只允许经 admission 的客户端)。但 `installPackRpc(ctx)` 在 `apply()` 里只跑一次,而提供 `connection` 的是另一个 fiber——那一刻没就绪,桥就永久为空,此后所有 `callPack` 都回落裸 fetch 吃 401,正好触发上一条的静默空白。
158
+
159
+ 改为保存 ctx、每次调用前惰性解析,就绪后自动恢复。每一步仍各自 try:宿主给客户端 ctx 套的 Proxy 对未声明服务的属性访问会**抛异常**(并上报运行失败),`?.` 挡不住抛异常的 getter,只有 `ctx.get()` 豁免。
160
+
161
+ ### 其他
162
+ - 全套 79 文件 1023 用例。
163
+ - `tests/aggregate-probe-regression.test.ts` 全量并行时会因争抢跑到 60 秒超时,单独跑 26 秒通过——既有测试,与本次改动无关。
4
164
  ## 2.4.5 — 侧栏面板能滚动了
5
165
 
6
166
  > 2.4.4 把账号池搬到侧栏那行之后,页面在折叠处被切掉、怎么都翻不到下半页。这不是新功能缺失,是我把设置页里的组件原样塞进了另一个容器,而**那个容器不滚动**。
package/lib/bin.js CHANGED
@@ -339,10 +339,39 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
339
339
  * rewrite is applied to assistant history too — one past reply carrying a
340
340
  * fingerprint would otherwise fail every later request in the conversation.
341
341
  *
342
+ * A THIRD FINGERPRINT (measured against the live domestic gateway 2026-10-28)
343
+ *
344
+ * The file-reference guidance DSH contributes whenever the agent has a `read`
345
+ * tool reintroduced the same 400/11128 after the two rewrites above shipped:
346
+ *
347
+ * Tokens prefixed with @ are paths the user explicitly referenced.
348
+ * Relative paths resolve from the workspace root; absolute paths identify
349
+ * files or directories on the host.
350
+ *
351
+ * Ordinary phrasing throughout — the blocked region names no product — so the
352
+ * fingerprint is not "this prompt is not WorkBuddy's" but a literal substring
353
+ * list that happens to include this sentence. Neither sentence is the trigger
354
+ * on its own: each 200s in isolation, and the pair 400s; the smallest edit
355
+ * anywhere in the pair clears it, which is what marks it as content and not
356
+ * prompt length, and the matching half is the second sentence ("Tokens
357
+ * prefixed with @" alone stayed clean while the "Relative paths…" line was the
358
+ * one whose text the gateway recognised).
359
+ *
360
+ * The rewrite turns the indicative into the same statement's participle —
361
+ * "Relative paths resolve from the workspace root" becomes "Relative paths are
362
+ * resolved against the workspace root". Meaning, length class and line breaks
363
+ * are preserved; the substring the gateway lists is gone. Verified at 200 with
364
+ * the full runtime system prompt (≈41k chars) that reproduced the 400 on the
365
+ * same account, and with the pair alone.
366
+ *
342
367
  * The list is an ARRAY because these fingerprints are appended to over time; a
343
368
  * future rejection should be one more row here, not a new code path.
344
369
  */
345
- const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"], [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"]]);
370
+ const BANNED_SYSTEM_PHRASES = Object.freeze([
371
+ [/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"],
372
+ [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"],
373
+ [/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"]
374
+ ]);
346
375
  /**
347
376
  * Rewrite fingerprinted system/assistant content, leaving everything else
348
377
  * byte-identical.
@@ -1325,14 +1354,17 @@ var WorkBuddyUpstreamClient = class {
1325
1354
  if (typeof raw !== "object" || raw === null) continue;
1326
1355
  const account = raw;
1327
1356
  const num = (key) => typeof account[key] === "number" ? account[key] : 0;
1328
- const monthly = num("CapacityType") === 4;
1329
- const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
1330
- const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
1357
+ const capacityType = num("CapacityType");
1358
+ const monthly = capacityType === 4;
1359
+ const periodic = monthly || capacityType === 2;
1360
+ const size = periodic ? num("CycleCapacitySize") : num("CapacitySize");
1361
+ const remain = periodic ? num("CycleCapacityRemain") : num("CapacityRemain");
1331
1362
  const capped = remain < 0 ? 0 : remain;
1332
1363
  const cycleEndMs = parseDate(account["CycleEndTime"]);
1333
- const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1364
+ const expiresAtMs = monthly ? void 0 : capacityType === 2 ? parseDate(account["DeductionEndTime"]) : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1334
1365
  const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
1335
- if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
1366
+ if (expiresAtMs !== void 0 && expiresAtMs <= Date.now()) continue;
1367
+ if (!periodic && capped <= 0) continue;
1336
1368
  total += capped;
1337
1369
  if (expiresAtMs !== void 0) {
1338
1370
  if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
@@ -11274,7 +11306,8 @@ z.object({
11274
11306
  modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
11275
11307
  modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
11276
11308
  automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
11277
- automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
11309
+ automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
11310
+ sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)"))
11278
11311
  });
11279
11312
  /** Assemble the runtime objects without registering anything. */
11280
11313
  /**
package/lib/client.js CHANGED
@@ -7,6 +7,44 @@ window.__ModuleLoader__.load({
7
7
  //#endregion
8
8
  let react = require("react");
9
9
  let react_jsx_runtime = require("react/jsx-runtime");
10
+ //#region src/client/sidebar-row.ts
11
+ /**
12
+ * The `sidebarRow` switch, read and written from one place.
13
+ *
14
+ * Two independent owners need this flag: the page card draws the checkbox, and
15
+ * `apply()` decides whether to register the sidebar row before that card has ever
16
+ * mounted. Both therefore have to read the same source of truth — the settings
17
+ * mirror the host exposes — instead of one asking the server and the other
18
+ * guessing.
19
+ *
20
+ * The scope is typed structurally here rather than imported from `PoolCard.tsx`:
21
+ * this module is pulled into both tsconfigs (the server one has no JSX setting),
22
+ * and the only thing it needs from that component is `getSnapshot().value`.
23
+ *
24
+ * The default is ON, and the read is fail-open on purpose: "the mirror has no
25
+ * opinion yet" and "the user turned it off" must not be the same answer, or a
26
+ * host that has not populated the mirror would silently hide a feature nobody
27
+ * switched off. That is also why only the literal `false` disables the row.
28
+ */
29
+ /** Settings field the switch writes (declared in the plugin Config schema). */
30
+ const SIDEBAR_ROW_FIELD = "sidebarRow";
31
+ /**
32
+ * Whether the sidebar entry should exist.
33
+ *
34
+ * Never throws: a locked-down host makes `getSnapshot()` unusable, and the
35
+ * documented degradation there is the settings page, not a missing entry.
36
+ */
37
+ function readSidebarRow(scope) {
38
+ if (scope === void 0) return true;
39
+ try {
40
+ const value = scope.getSnapshot().value;
41
+ if (value === null || typeof value !== "object") return true;
42
+ return value[SIDEBAR_ROW_FIELD] !== false;
43
+ } catch {
44
+ return true;
45
+ }
46
+ }
47
+ //#endregion
10
48
  //#region src/status-paths.ts
11
49
  /**
12
50
  * Node-free constants and types shared by the Host and browser halves of the
@@ -518,6 +556,7 @@ window.__ModuleLoader__.load({
518
556
  .dsm-xdpool-usage-model{font-size:12px;font-weight:600;word-break:break-all}
519
557
  .dsm-xdpool-usage-numbers{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:17px;word-break:break-word}
520
558
  .dsm-xdpool-usage-note{margin:0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:17px}
559
+ .dsm-xdpool-sidebar-row-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px}
521
560
  .dsm-xdpool-models-actions{display:flex;align-items:center;gap:8px;flex:none}
522
561
  /* "Built-in list (offline)" chip: a warning tint, since the user is looking at
523
562
  a SHORTER roster than the gateway offers and may wonder where models went. */
@@ -8947,6 +8986,25 @@ window.__ModuleLoader__.load({
8947
8986
  let packRpcBridge;
8948
8987
  /** Capture the client ctx's connection service for {@link callPack}. */
8949
8988
  function installPackRpc(ctx) {
8989
+ packRpcContext = ctx;
8990
+ packRpcBridge = resolvePackRpcBridge();
8991
+ }
8992
+ /** The client ctx captured by {@link installPackRpc}, for late resolution. */
8993
+ let packRpcContext;
8994
+ /**
8995
+ * Read `connection.rpc.call` off the captured ctx, or `undefined` if the
8996
+ * service is still absent.
8997
+ *
8998
+ * Each step gets its own try. The host hands the client ctx a Proxy that
8999
+ * THROWS on direct property access to an undeclared service
9000
+ * (cordis-client-runner's dynamic ctx), and that throw also reports the run as
9001
+ * failed — so one shared try/catch would let a rejected `ctx.connection` skip
9002
+ * the exempt `ctx.get()` below and silently leave the bridge unset. `?.` does
9003
+ * not help: it guards null/undefined, not a throwing getter.
9004
+ */
9005
+ function resolvePackRpcBridge() {
9006
+ const ctx = packRpcContext;
9007
+ if (ctx === void 0 || ctx === null) return void 0;
8950
9008
  let connection;
8951
9009
  try {
8952
9010
  connection = ctx.connection;
@@ -8960,8 +9018,8 @@ window.__ModuleLoader__.load({
8960
9018
  }
8961
9019
  try {
8962
9020
  const call = connection?.rpc?.call;
8963
- if (typeof call !== "function") return;
8964
- packRpcBridge = { call: async (method, payload) => {
9021
+ if (typeof call !== "function") return void 0;
9022
+ return { call: async (method, payload) => {
8965
9023
  const bound = call.bind(connection);
8966
9024
  const controller = new AbortController();
8967
9025
  const timer = setTimeout(() => controller.abort(), 9e4);
@@ -8975,7 +9033,7 @@ window.__ModuleLoader__.load({
8975
9033
  }
8976
9034
  } };
8977
9035
  } catch {
8978
- packRpcBridge = void 0;
9036
+ return;
8979
9037
  }
8980
9038
  }
8981
9039
  /**
@@ -8989,6 +9047,7 @@ window.__ModuleLoader__.load({
8989
9047
  * message.
8990
9048
  */
8991
9049
  async function callPack(method, payload) {
9050
+ if (packRpcBridge === void 0) packRpcBridge = resolvePackRpcBridge();
8992
9051
  if (packRpcBridge !== void 0) try {
8993
9052
  const result = await packRpcBridge.call(method, payload);
8994
9053
  if (result !== null && typeof result === "object") {
@@ -11669,7 +11728,8 @@ window.__ModuleLoader__.load({
11669
11728
  const load = async () => {
11670
11729
  try {
11671
11730
  const channels = (await fetchChannels()).filter((channel) => !REGION_OWNED_CHANNELS.has(channel.id));
11672
- const statuses = providerStatusesFrom(await callPack("provider.status", { providers: channels.map((channel) => channel.id) }));
11731
+ const statusAnswer = await callPack("provider.status", { providers: channels.map((channel) => channel.id) });
11732
+ const statuses = providerStatusesFrom(statusAnswer);
11673
11733
  const scanProviders = channels.map((channel) => channel.id).filter((id) => (statuses.get(id)?.accountsTotal ?? 0) > 0);
11674
11734
  const perProvider = await Promise.all(scanProviders.map(async (provider) => {
11675
11735
  const wantBalances = channels.find((channel) => channel.id === provider)?.credits !== void 0;
@@ -11698,18 +11758,28 @@ window.__ModuleLoader__.load({
11698
11758
  }];
11699
11759
  }));
11700
11760
  const byProvider = new Map(perProvider);
11701
- const idsByProvider = {};
11702
- for (const [provider, entry] of perProvider) if (entry.models.length > 0) idsByProvider[provider] = entry.models.map((model) => model.id);
11703
- const facts = await fetchChannelModels(scanProviders, idsByProvider).catch(() => /* @__PURE__ */ new Map());
11704
11761
  const next = channels.filter((channel) => byProvider.has(channel.id)).map((channel) => ({
11705
11762
  channel,
11706
11763
  ...byProvider.get(channel.id),
11707
- facts: new Map((facts.get(channel.id) ?? []).map((model) => [model.id, model])),
11764
+ facts: /* @__PURE__ */ new Map(),
11708
11765
  status: statuses.get(channel.id)
11709
11766
  }));
11710
11767
  if (!mountedRef.current) return;
11711
11768
  setEntries(next);
11712
11769
  setError(void 0);
11770
+ if (next.length === 0) {
11771
+ if (!statusAnswer.ok) setError(statusAnswer.error?.message ?? "provider.status refused");
11772
+ return;
11773
+ }
11774
+ const idsByProvider = {};
11775
+ for (const [provider, entry] of perProvider) if (entry.models.length > 0) idsByProvider[provider] = entry.models.map((model) => model.id);
11776
+ const facts = await fetchChannelModels(next.map((entry) => entry.channel.id), idsByProvider).catch(() => /* @__PURE__ */ new Map());
11777
+ if (!mountedRef.current) return;
11778
+ if (facts.size === 0) return;
11779
+ setEntries((previous) => previous.map((entry) => ({
11780
+ ...entry,
11781
+ facts: new Map((facts.get(entry.channel.id) ?? []).map((model) => [model.id, model]))
11782
+ })));
11713
11783
  } catch (cause) {
11714
11784
  if (!mountedRef.current) return;
11715
11785
  setError(cause instanceof Error ? cause.message : String(cause));
@@ -12338,7 +12408,7 @@ window.__ModuleLoader__.load({
12338
12408
  *
12339
12409
  * @module dsh-xdpool/version
12340
12410
  */
12341
- const POOL_VERSION = "2.4.5";
12411
+ const POOL_VERSION = "2.5.0";
12342
12412
  //#endregion
12343
12413
  //#region src/client/PoolCard.tsx
12344
12414
  /**
@@ -12634,6 +12704,23 @@ window.__ModuleLoader__.load({
12634
12704
  /** Render pool health, per-account credits/cooldown, and the model directory. */
12635
12705
  function PoolCard({ t, settingsScope }) {
12636
12706
  const settingsWritable = settingsScope?.getSnapshot().writable === true;
12707
+ /**
12708
+ * Whether the plugin owns a row in the left sidebar (issue #70).
12709
+ *
12710
+ * Seeded from, and kept in step with, the settings mirror rather than from the
12711
+ * pool status: the row is registered by `apply()`, before any card exists, and
12712
+ * both sides must answer the same question the same way.
12713
+ */
12714
+ const [sidebarRow, setSidebarRowState] = (0, react.useState)(() => readSidebarRow(settingsScope));
12715
+ const [sidebarRowBusy, setSidebarRowBusy] = (0, react.useState)(false);
12716
+ (0, react.useEffect)(() => {
12717
+ const unsubscribe = settingsScope?.subscribe?.(() => {
12718
+ setSidebarRowState(readSidebarRow(settingsScope));
12719
+ });
12720
+ return () => {
12721
+ unsubscribe?.();
12722
+ };
12723
+ }, [settingsScope]);
12637
12724
  /** Which region tab is showing. A CN-only install never leaves this. */
12638
12725
  const [activeRegion, setActiveRegion] = (0, react.useState)("cn");
12639
12726
  /**
@@ -13294,11 +13381,18 @@ window.__ModuleLoader__.load({
13294
13381
  const imageModelIds = Object.entries(local).filter(([, e]) => e.images).map(([id]) => id);
13295
13382
  const contextBudgets = {};
13296
13383
  for (const [id, entry] of Object.entries(local)) if (entry.budget !== void 0) contextBudgets[id] = entry.budget;
13297
- await write.call(settingsScope, MODEL_SELECTION_KEY[region], {
13384
+ if (await write.call(settingsScope, MODEL_SELECTION_KEY[region], {
13298
13385
  enabledModelIds,
13299
13386
  imageModelIds,
13300
13387
  contextBudgets
13301
- });
13388
+ }) === false) {
13389
+ if (mounted.current) setError(t?.("row.modelsConflict") ?? "The settings changed elsewhere; review and save again");
13390
+ return;
13391
+ }
13392
+ if (await refresh(region) === void 0) {
13393
+ if (mounted.current) setFlash(t?.("row.modelsSaved") ?? "Saved");
13394
+ return;
13395
+ }
13302
13396
  setDraftFor(region, void 0);
13303
13397
  if (mounted.current) setFlash(t?.("row.modelsSaved") ?? "Saved");
13304
13398
  } catch (cause) {
@@ -13335,6 +13429,35 @@ window.__ModuleLoader__.load({
13335
13429
  }
13336
13430
  };
13337
13431
  /**
13432
+ * Turn the sidebar row on or off (issue #70).
13433
+ *
13434
+ * Written through the same settings field `apply()` reads, so the two agree
13435
+ * without a new endpoint. The row itself is owned by the bundle registration,
13436
+ * so the mirror's change notification is what makes this take effect without a
13437
+ * restart on hosts that publish one; everywhere else it lands on next launch,
13438
+ * which the hint text says rather than implies.
13439
+ */
13440
+ const setSidebarRow = async (next) => {
13441
+ const write = settingsScope?.set;
13442
+ if (write === void 0) {
13443
+ setError(t?.("row.modelsSaveError", { message: "settings scope is read-only" }) ?? "settings scope is read-only");
13444
+ return;
13445
+ }
13446
+ setSidebarRowBusy(true);
13447
+ setFlash(void 0);
13448
+ try {
13449
+ if (await write.call(settingsScope, "sidebarRow", next) === false) {
13450
+ if (mounted.current) setError(t?.("row.modelsConflict") ?? "The settings changed elsewhere; review and save again");
13451
+ return;
13452
+ }
13453
+ if (mounted.current) setSidebarRowState(next);
13454
+ } catch (cause) {
13455
+ if (mounted.current) setError(String(cause));
13456
+ } finally {
13457
+ if (mounted.current) setSidebarRowBusy(false);
13458
+ }
13459
+ };
13460
+ /**
13338
13461
  * Switch the daily-points automation on or off.
13339
13462
  *
13340
13463
  * The whole `automation` object is written as one key, because that is how the
@@ -13609,6 +13732,30 @@ window.__ModuleLoader__.load({
13609
13732
  })
13610
13733
  ]
13611
13734
  }),
13735
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
13736
+ className: "dsm-xdpool-card dsm-xdpool-sidebar-row",
13737
+ "aria-label": t?.("row.sidebarRowTitle") ?? "Sidebar entry",
13738
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
13739
+ className: "dsm-xdpool-sidebar-row-head",
13740
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
13741
+ className: "dsm-xdpool-usage-title",
13742
+ children: t?.("row.sidebarRowTitle") ?? "Sidebar entry"
13743
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
13744
+ type: "button",
13745
+ role: "switch",
13746
+ "aria-checked": sidebarRow,
13747
+ disabled: !settingsWritable || sidebarRowBusy,
13748
+ className: `dsm-xdpool-auto-switch${sidebarRow ? " dsm-xdpool-auto-switch-on" : ""}`,
13749
+ onClick: () => {
13750
+ setSidebarRow(!sidebarRow);
13751
+ },
13752
+ children: sidebarRowBusy ? t?.("row.autoBusy") ?? "Saving…" : sidebarRow ? t?.("row.autoOn") ?? "On" : t?.("row.autoOff") ?? "Off"
13753
+ })]
13754
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
13755
+ className: "dsm-xdpool-usage-note",
13756
+ children: sidebarRow ? t?.("row.sidebarRowOn") ?? "XD Pool has its own row in the left sidebar. Turn it off to open the pool from Settings instead." : t?.("row.sidebarRowOff") ?? "The sidebar row is hidden; the pool still opens from Settings → XD Pool, and models are unaffected."
13757
+ })]
13758
+ }),
13612
13759
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
13613
13760
  className: "dsm-xdpool-regions",
13614
13761
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -14929,6 +15076,10 @@ window.__ModuleLoader__.load({
14929
15076
  "row.modelsSaving": "Saving…",
14930
15077
  "row.modelsDiscard": "Discard",
14931
15078
  "row.modelsSaved": "Model selection saved",
15079
+ "row.sidebarRowTitle": "Sidebar entry",
15080
+ "row.sidebarRowOn": "XD Pool has its own row in the left sidebar. Turn it off to open the pool from Settings instead.",
15081
+ "row.sidebarRowOff": "The sidebar row is hidden; the pool still opens from Settings → XD Pool, and models are unaffected.",
15082
+ "row.modelsConflict": "The settings changed elsewhere; review and save again",
14932
15083
  "row.modelsSaveError": "Could not save: {message}",
14933
15084
  "row.modelsEmpty": "No model enabled — enable at least one before saving.",
14934
15085
  "row.catalogOffline": "built-in list (offline)",
@@ -15440,6 +15591,10 @@ window.__ModuleLoader__.load({
15440
15591
  "row.modelsSaving": "保存中…",
15441
15592
  "row.modelsDiscard": "放弃修改",
15442
15593
  "row.modelsSaved": "模型选择已保存",
15594
+ "row.sidebarRowTitle": "侧栏入口",
15595
+ "row.sidebarRowOn": "左侧栏会给 XD Pool 留一行入口。关掉后改为从「设置 → XD Pool」进入,模型池不受影响。",
15596
+ "row.sidebarRowOff": "侧栏入口已隐藏;仍可从「设置 → XD Pool」进入,模型池不受影响。",
15597
+ "row.modelsConflict": "设置已在别处被修改,请确认后重新保存",
15443
15598
  "row.modelsSaveError": "保存失败:{message}",
15444
15599
  "row.modelsEmpty": "至少要启用一个模型才能保存。",
15445
15600
  "row.catalogOffline": "内置列表(离线)",
@@ -15891,12 +16046,29 @@ window.__ModuleLoader__.load({
15891
16046
  }, PoolCard));
15892
16047
  installNavIcon(ctx, () => t("row.navLabel"));
15893
16048
  try {
15894
- ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
15895
- name: "sidebar.panellist",
15896
- id: "xdpool",
15897
- order: 20,
15898
- label: () => t("row.navLabel")
15899
- }, PanelIcon));
16049
+ let disposeRow;
16050
+ const syncSidebarRow = () => {
16051
+ const wanted = readSidebarRow(settingsScope);
16052
+ if (wanted && disposeRow === void 0) disposeRow = ctx.slots.register({
16053
+ name: "sidebar.panellist",
16054
+ id: "xdpool",
16055
+ order: 20,
16056
+ label: () => t("row.navLabel")
16057
+ }, PanelIcon);
16058
+ else if (!wanted && disposeRow !== void 0) {
16059
+ disposeRow();
16060
+ disposeRow = void 0;
16061
+ }
16062
+ };
16063
+ ctx.slots.inject("sidebar.panellist", () => {
16064
+ syncSidebarRow();
16065
+ const unsubscribe = settingsScope?.subscribe?.(syncSidebarRow);
16066
+ return () => {
16067
+ unsubscribe?.();
16068
+ disposeRow?.();
16069
+ disposeRow = void 0;
16070
+ };
16071
+ });
15900
16072
  ctx.slots.inject("main", () => ctx.slots.register({
15901
16073
  name: "main",
15902
16074
  key: "xdpool",
package/lib/index.js CHANGED
@@ -390,10 +390,39 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
390
390
  * rewrite is applied to assistant history too — one past reply carrying a
391
391
  * fingerprint would otherwise fail every later request in the conversation.
392
392
  *
393
+ * A THIRD FINGERPRINT (measured against the live domestic gateway 2026-10-28)
394
+ *
395
+ * The file-reference guidance DSH contributes whenever the agent has a `read`
396
+ * tool reintroduced the same 400/11128 after the two rewrites above shipped:
397
+ *
398
+ * Tokens prefixed with @ are paths the user explicitly referenced.
399
+ * Relative paths resolve from the workspace root; absolute paths identify
400
+ * files or directories on the host.
401
+ *
402
+ * Ordinary phrasing throughout — the blocked region names no product — so the
403
+ * fingerprint is not "this prompt is not WorkBuddy's" but a literal substring
404
+ * list that happens to include this sentence. Neither sentence is the trigger
405
+ * on its own: each 200s in isolation, and the pair 400s; the smallest edit
406
+ * anywhere in the pair clears it, which is what marks it as content and not
407
+ * prompt length, and the matching half is the second sentence ("Tokens
408
+ * prefixed with @" alone stayed clean while the "Relative paths…" line was the
409
+ * one whose text the gateway recognised).
410
+ *
411
+ * The rewrite turns the indicative into the same statement's participle —
412
+ * "Relative paths resolve from the workspace root" becomes "Relative paths are
413
+ * resolved against the workspace root". Meaning, length class and line breaks
414
+ * are preserved; the substring the gateway lists is gone. Verified at 200 with
415
+ * the full runtime system prompt (≈41k chars) that reproduced the 400 on the
416
+ * same account, and with the pair alone.
417
+ *
393
418
  * The list is an ARRAY because these fingerprints are appended to over time; a
394
419
  * future rejection should be one more row here, not a new code path.
395
420
  */
396
- const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"], [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"]]);
421
+ const BANNED_SYSTEM_PHRASES = Object.freeze([
422
+ [/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"],
423
+ [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"],
424
+ [/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"]
425
+ ]);
397
426
  /**
398
427
  * Rewrite fingerprinted system/assistant content, leaving everything else
399
428
  * byte-identical.
@@ -1485,14 +1514,17 @@ var WorkBuddyUpstreamClient = class {
1485
1514
  if (typeof raw !== "object" || raw === null) continue;
1486
1515
  const account = raw;
1487
1516
  const num = (key) => typeof account[key] === "number" ? account[key] : 0;
1488
- const monthly = num("CapacityType") === 4;
1489
- const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
1490
- const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
1517
+ const capacityType = num("CapacityType");
1518
+ const monthly = capacityType === 4;
1519
+ const periodic = monthly || capacityType === 2;
1520
+ const size = periodic ? num("CycleCapacitySize") : num("CapacitySize");
1521
+ const remain = periodic ? num("CycleCapacityRemain") : num("CapacityRemain");
1491
1522
  const capped = remain < 0 ? 0 : remain;
1492
1523
  const cycleEndMs = parseDate(account["CycleEndTime"]);
1493
- const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1524
+ const expiresAtMs = monthly ? void 0 : capacityType === 2 ? parseDate(account["DeductionEndTime"]) : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1494
1525
  const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
1495
- if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
1526
+ if (expiresAtMs !== void 0 && expiresAtMs <= Date.now()) continue;
1527
+ if (!periodic && capped <= 0) continue;
1496
1528
  total += capped;
1497
1529
  if (expiresAtMs !== void 0) {
1498
1530
  if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
@@ -19296,6 +19328,7 @@ function createWorkBuddyShim(options) {
19296
19328
  const conversationKey = conversationKeyOf(poolRaw);
19297
19329
  const tried = [];
19298
19330
  let last;
19331
+ const refreshedThisRequest = /* @__PURE__ */ new Set();
19299
19332
  const turnId = randomBytes(8).toString("hex");
19300
19333
  const usageContext = usageRecorder === void 0 ? void 0 : {
19301
19334
  recorder: usageRecorder,
@@ -19350,6 +19383,12 @@ function createWorkBuddyShim(options) {
19350
19383
  message: result.message
19351
19384
  };
19352
19385
  if (result.kind === "session_dead") {
19386
+ if (refreshedThisRequest.has(account.id)) {
19387
+ pool.penalizeCredentialDead(account.id);
19388
+ logger?.warn(`dsh-xdpool: ${account.label} still refused after a token refresh (attempt ${attempt + 1}/${maxAttempts}); rotating`);
19389
+ continue;
19390
+ }
19391
+ refreshedThisRequest.add(account.id);
19353
19392
  logger?.warn(`dsh-xdpool: ${account.label} session dead; refreshing token and retrying`);
19354
19393
  const before = account.credential.accessToken;
19355
19394
  if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) {
@@ -19607,6 +19646,7 @@ async function recoverFromContextOverrun(options) {
19607
19646
  };
19608
19647
  const retryBody = client.buildChatBody(parsed, compacted, region ?? "cn");
19609
19648
  const tried = [];
19649
+ const refreshedThisRequest = /* @__PURE__ */ new Set();
19610
19650
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
19611
19651
  if (controller.signal.aborted) return {
19612
19652
  ok: false,
@@ -19640,6 +19680,11 @@ async function recoverFromContextOverrun(options) {
19640
19680
  detail: "prompt still exceeded the window after compaction"
19641
19681
  };
19642
19682
  if (result.kind === "session_dead") {
19683
+ if (refreshedThisRequest.has(account.id)) {
19684
+ pool.penalizeCredentialDead(account.id);
19685
+ continue;
19686
+ }
19687
+ refreshedThisRequest.add(account.id);
19643
19688
  const before = account.credential.accessToken;
19644
19689
  if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) pool.penalizeCredentialDead(account.id);
19645
19690
  continue;
@@ -20699,7 +20744,8 @@ const Config = z.object({
20699
20744
  modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
20700
20745
  modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
20701
20746
  automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
20702
- automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
20747
+ automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
20748
+ sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)"))
20703
20749
  });
20704
20750
  /** Live API, published for the CLI. */
20705
20751
  let api;
@@ -21339,7 +21385,7 @@ function apply(ctx, config = {}) {
21339
21385
  }) };
21340
21386
  }).finally(() => {
21341
21387
  if (stopped) return;
21342
- adoptChannelPackAccounts(ctx, core.pool, ctx.logger);
21388
+ adoptChannelPackAccounts(scoped, core.pool, ctx.logger);
21343
21389
  });
21344
21390
  });
21345
21391
  mountBillionContext(ctx, ctx.logger).then((outcome) => {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-xdpool",
3
3
  "displayName": "DSH XD Pool",
4
4
  "description": "One DSH model pool for every free quota you have: merge all locally signed-in WorkBuddy accounts with auto-failover, sign in to 13 vendors by QR/web, aggregate 76 vendors' free tiers, plus a usage dashboard, model routing, a local OpenAI-compatible gateway and a points-automation suite.",
5
- "version": "2.4.5",
5
+ "version": "2.5.0",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-xdpool#readme",
@@ -29453,6 +29453,15 @@ var channelPackSchema = Schema.object({
29453
29453
  // **必须显式声明**:SettingsStore 的 `scope.replace()` 是整体覆盖,
29454
29454
  // schema 里没声明的键会被 schemastery 剥掉,写回时就此静默丢失。
29455
29455
  contextBudgets: Schema.dict(Schema.any()).default({}),
29456
+ // ── 本地扩展(dsh-xdpool,2026-10-26)────────────────────────────────
29457
+ // 记录「用户亲手动过这个 provider 的模型选择」,用来把「用户把模型全部
29458
+ // 打开」和「用户从未选择过」区分开。`disabledModels` 本身做不到这件事:
29459
+ // 打开模型时我们**删除**键而不是写 false(见 setModelDisabled 注释),
29460
+ // 所以「全开」与「从未碰过」都表现为空表。启动补静音原先拿
29461
+ // `disabledModelsFor(provider).size === 0` 当判据,于是把「全开」当成
29462
+ // 「从未选择」,每次重启又把整目录静音回去 —— 目录越小的渠道越必然中招
29463
+ // (zcode 只有两个模型,用户勾满之后每次重启都变回全禁用)。
29464
+ selectionTouched: Schema.dict(Schema.any()).default({}),
29456
29465
  loomyPermanentLocked: Schema.boolean().default(false),
29457
29466
  gatewayEnabled: Schema.boolean().default(true),
29458
29467
  providerOrder: Schema.array(Schema.string()).default([])
@@ -29511,6 +29520,14 @@ function sanitizeContextBudgets(raw) {
29511
29520
  }
29512
29521
  return result;
29513
29522
  }
29523
+ function sanitizeSelectionTouched(raw) {
29524
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
29525
+ const result = {};
29526
+ for (const [provider, value] of Object.entries(raw)) {
29527
+ if (value === true && provider.length > 0) result[provider] = true;
29528
+ }
29529
+ return result;
29530
+ }
29514
29531
  function sanitizeAccounts(raw) {
29515
29532
  if (!Array.isArray(raw)) return [];
29516
29533
  const accounts = [];
@@ -29542,6 +29559,7 @@ var SettingsStore = class {
29542
29559
  accounts: sanitizeAccounts(value.accounts),
29543
29560
  disabledModels: sanitizeDisabledModels(value.disabledModels),
29544
29561
  contextBudgets: sanitizeContextBudgets(value.contextBudgets),
29562
+ selectionTouched: sanitizeSelectionTouched(value.selectionTouched),
29545
29563
  // 只是**镜像**(权威表在 permanent-locks.json);老文档没这个键 → false。
29546
29564
  loomyPermanentLocked: value.loomyPermanentLocked === true,
29547
29565
  // 老文档没这个键 → 启用,与升级前行为一致。
@@ -29557,6 +29575,9 @@ var SettingsStore = class {
29557
29575
  // 预算表丢失的后果只是「下次选择器回到原生窗口」,用户重选一次即可
29558
29576
  // (纯偏好、可逆),故接受老版本重写时丢掉本键。
29559
29577
  contextBudgets: state.contextBudgets ?? {},
29578
+ // 丢了这个标记的后果是「下次启动把该渠道重新静音一次」,用户再勾一遍
29579
+ // 就会重新置位,属可逆,故与 contextBudgets 同样接受老版本重写时丢掉。
29580
+ selectionTouched: state.selectionTouched ?? {},
29560
29581
  // 镜像字段由 AccountPool 与独立文档**同源写出**:同机上只认这个字段的
29561
29582
  // 旧版本代码(其它 profile)读它、也会原样写回它,故两边不会脱节。
29562
29583
  loomyPermanentLocked: state.loomyPermanentLocked === true,
@@ -29661,6 +29682,7 @@ var FileStore = class {
29661
29682
  accounts: sanitizeAccounts(value.accounts),
29662
29683
  disabledModels: sanitizeDisabledModels(value.disabledModels),
29663
29684
  contextBudgets: sanitizeContextBudgets(value.contextBudgets),
29685
+ selectionTouched: sanitizeSelectionTouched(value.selectionTouched),
29664
29686
  // 只是镜像(权威表在 permanent-locks.json);老文档没这个键 → false。
29665
29687
  loomyPermanentLocked: value.loomyPermanentLocked === true,
29666
29688
  // 老文档没这个键 → 启用,与升级前行为一致。
@@ -29705,6 +29727,9 @@ var FileStore = class {
29705
29727
  accounts,
29706
29728
  disabledModels: {},
29707
29729
  contextBudgets: {},
29730
+ // 从凭据文件重建的文档不含任何选择历史 → 空表,让补静音照常生效
29731
+ // (这些账号确实是「从未选择过」,正是补静音要照顾的情形)。
29732
+ selectionTouched: {},
29708
29733
  // 凭据文件里没有任何锁定信息 → 镜像写 false(权威表另有其文档)。
29709
29734
  loomyPermanentLocked: false,
29710
29735
  // 恢复出的文档本来就不含任何开关信息 → 启用(与全新安装一致)。
@@ -29856,6 +29881,17 @@ var AccountPool = class {
29856
29881
  * 把窗口收窄到 200K 这类值」,不改变上游真实能力。
29857
29882
  */
29858
29883
  contextBudgetCache = {};
29884
+ /**
29885
+ * 「用户动过该 provider 的模型选择」标记的进程内权威副本。
29886
+ *
29887
+ * 存在的唯一理由是把「用户把模型全部打开」与「用户从未选择过」区分开:
29888
+ * 打开模型时黑名单里**删除**键(见 {@link setModelDisabled}),所以「全开」
29889
+ * 与「从未碰过」的 `disabledModels` 都是空表,单看黑名单无法分辨,启动补
29890
+ * 静音就会把「全开」当成「从未选择」再静音一次。
29891
+ *
29892
+ * ⚠️ 只记录已置位的 provider(缺键 = 从未选择)。
29893
+ */
29894
+ selectionTouchedCache = {};
29859
29895
  /**
29860
29896
  * 「锁定永久积分」开关表的**权威进程内副本**(与 {@link cache} 同理)。
29861
29897
  *
@@ -29907,6 +29943,7 @@ var AccountPool = class {
29907
29943
  this.cache = state.accounts;
29908
29944
  this.modelCache = state.disabledModels;
29909
29945
  this.contextBudgetCache = state.contextBudgets ?? {};
29946
+ this.selectionTouchedCache = state.selectionTouched ?? {};
29910
29947
  this.gatewayEnabledCache = state.gatewayEnabled !== false;
29911
29948
  this.providerOrderCache = state.providerOrder ?? [];
29912
29949
  }
@@ -29984,6 +30021,7 @@ var AccountPool = class {
29984
30021
  accounts: this.cache,
29985
30022
  disabledModels: this.modelCache,
29986
30023
  contextBudgets: this.contextBudgetCache,
30024
+ selectionTouched: this.selectionTouchedCache,
29987
30025
  ...this.lockFields(),
29988
30026
  // ⚠️ 必须与账号、黑名单取自**同一时刻**的快照(见本方法的注释):
29989
30027
  // 分两次快照会让同一份文档里的字段互相矛盾。
@@ -30105,6 +30143,9 @@ var AccountPool = class {
30105
30143
  else delete perProvider[modelId];
30106
30144
  if (Object.keys(perProvider).length === 0) delete next[provider];
30107
30145
  else next[provider] = perProvider;
30146
+ // 先置位再落盘:`writeModels` 排队的快照里已带上标记,一次写入同时完成
30147
+ // 「改黑名单」和「记录用户动过选择」,不会出现只落了一半的状态。
30148
+ this.selectionTouchedCache = { ...this.selectionTouchedCache, [provider]: true };
30108
30149
  await this.writeModels(next);
30109
30150
  }
30110
30151
  /**
@@ -30131,6 +30172,29 @@ var AccountPool = class {
30131
30172
  this.ensureLoaded();
30132
30173
  return { ...this.contextBudgetCache[provider] ?? {} };
30133
30174
  }
30175
+ /**
30176
+ * 该 provider 的模型选择是否被用户动过。
30177
+ *
30178
+ * 启动补静音据此判断「要不要补」:动过就尊重用户的当前状态(哪怕是全开,
30179
+ * 表现为空黑名单),没动过才补静音。
30180
+ */
30181
+ selectionTouched(provider) {
30182
+ this.ensureLoaded();
30183
+ return this.selectionTouchedCache[provider] === true;
30184
+ }
30185
+ /**
30186
+ * 置位「用户动过该 provider 的模型选择」并落盘。
30187
+ *
30188
+ * ⚠️ 幂等:已置位直接返回,不产生写入 —— 否则每次勾选都要重写整份文档。
30189
+ */
30190
+ async touchSelection(provider) {
30191
+ this.ensureLoaded();
30192
+ if (this.selectionTouchedCache[provider] === true) return;
30193
+ const next = { ...this.selectionTouchedCache, [provider]: true };
30194
+ this.selectionTouchedCache = next;
30195
+ if (this.store.kind === "memory") return;
30196
+ await this.queueStoreSave();
30197
+ }
30134
30198
  /**
30135
30199
  * 设置/清除单个模型的上下文预算。
30136
30200
  *
@@ -30160,13 +30224,18 @@ var AccountPool = class {
30160
30224
  * 注意这与 {@link clearDisabledModels} **不对称** —— 后者的语义是「清空」,
30161
30225
  * 即使传入空列表也仍有事可做(详见该方法注释)。
30162
30226
  */
30163
- async setModelsDisabled(provider, modelIds) {
30227
+ async setModelsDisabled(provider, modelIds, options) {
30164
30228
  if (modelIds.length === 0) return;
30165
30229
  this.ensureLoaded();
30166
30230
  const next = { ...this.modelCache };
30167
30231
  const perProvider = { ...next[provider] ?? {} };
30168
30232
  for (const id of modelIds) perProvider[id] = true;
30169
30233
  next[provider] = perProvider;
30234
+ // @param options.mark - 默认 true(用户动作);自动静音传 false,见
30235
+ // touchSelection 的说明 —— 它不是「用户动过选择」。
30236
+ if (options?.mark !== false) {
30237
+ this.selectionTouchedCache = { ...this.selectionTouchedCache, [provider]: true };
30238
+ }
30170
30239
  await this.writeModels(next);
30171
30240
  }
30172
30241
  /**
@@ -30199,6 +30268,9 @@ var AccountPool = class {
30199
30268
  const next = { ...this.modelCache };
30200
30269
  if (Object.keys(nextPerProvider).length === 0) delete next[provider];
30201
30270
  else next[provider] = nextPerProvider;
30271
+ // 注意这条路径也会让黑名单变成空表(用户把该组全开),正是「全开」最
30272
+ // 容易发生的场景,必须同样置位,否则启动补静音会把它们再关回去。
30273
+ this.selectionTouchedCache = { ...this.selectionTouchedCache, [provider]: true };
30202
30274
  await this.writeModels(next);
30203
30275
  }
30204
30276
  /**
@@ -30265,6 +30337,8 @@ var AccountPool = class {
30265
30337
  if (this.modelCache[provider] === void 0) return;
30266
30338
  const next = { ...this.modelCache };
30267
30339
  delete next[provider];
30340
+ // 「全部打开」的最高频入口,必须置位(见 selectionTouched 注释)。
30341
+ this.selectionTouchedCache = { ...this.selectionTouchedCache, [provider]: true };
30268
30342
  await this.writeModels(next);
30269
30343
  }
30270
30344
  /** 持久化模型黑名单(同时更新进程内权威副本)。 */
@@ -32970,6 +33044,13 @@ var DsmlContentExtractor = class {
32970
33044
  return { text: remaining, reasoning: "" };
32971
33045
  }
32972
33046
  };
33047
+ // CodeArts rejects an oversized max_tokens with an HTTP-200 SSE error frame
33048
+ // (`InferHub.4001.200 The request param is invalid`) rather than a 4xx, so it
33049
+ // surfaces to the user as an opaque INVALID_REQUEST. 65536 is the measured
33050
+ // ceiling; 131072 already produces an empty stream. Keep the advertised default
33051
+ // and the final wire request on that same ceiling - TRAE and Cline do the same
33052
+ // with clampTraeMaxTokens / clampClineMaxTokens.
33053
+ var CODEARTS_MAX_OUTPUT_TOKENS = 65536;
32973
33054
  var CodeArtsAdapter = class extends LlmAdapter {
32974
33055
  constructor(options) {
32975
33056
  super();
@@ -33043,6 +33124,7 @@ var CodeArtsAdapter = class extends LlmAdapter {
33043
33124
  id: m.id,
33044
33125
  name: m.name,
33045
33126
  inputModalities: modalitiesFor(m.id),
33127
+ maxTokens: CODEARTS_MAX_OUTPUT_TOKENS,
33046
33128
  ...contextWindow === void 0 ? {} : { contextWindow }
33047
33129
  };
33048
33130
  });
@@ -33052,7 +33134,15 @@ var CodeArtsAdapter = class extends LlmAdapter {
33052
33134
  const remoteModel = this.remoteModels?.find((m) => m.id === model);
33053
33135
  const name2 = remoteModel?.name ?? model;
33054
33136
  const contextWindow = contextWindowFor(model);
33055
- const resolved = { provider, id: model, name: name2, inputModalities: modalitiesFor(model) };
33137
+ const resolved = {
33138
+ provider,
33139
+ id: model,
33140
+ name: name2,
33141
+ inputModalities: modalitiesFor(model),
33142
+ // catalogFactsFor() forwards this as fact.maxTokens; without it the host
33143
+ // has no way to learn the ceiling and passes its own catalog value through.
33144
+ defaultMaxTokens: CODEARTS_MAX_OUTPUT_TOKENS
33145
+ };
33056
33146
  if (contextWindow !== void 0) resolved.context = { contextWindow };
33057
33147
  resolved.reasoning = {
33058
33148
  efforts: [
@@ -33134,9 +33224,11 @@ var CodeArtsAdapter = class extends LlmAdapter {
33134
33224
  // 大文件 write 工具参数(如 1000-2000+ 行文档)需要数万 token 的生成空间,
33135
33225
  // 若沿用后端默认输出上限,参数 JSON 会在中途被截断成非法 JSON,harness
33136
33226
  // 工具校验报 `invalid arguments: "arguments" must be an object`。
33137
- // 参考实现 e2e 实测:65536 可用,131072 反而触发空流被后端拒绝;
33138
- // 显式传入的 options.maxTokens 优先,未设置时默认 65536。
33139
- max_tokens: options.maxTokens ?? 65536,
33227
+ // 参考实现 e2e 实测:65536 可用,131072 反而触发空流被后端拒绝。
33228
+ // 因此这是上限而不只是默认值:调用方传来的更大值(宿主模型目录里别的
33229
+ // provider 带 384000)会被原样发出并触发 INVALID_REQUEST,必须收回;
33230
+ // 调用方主动设的较小值保留。
33231
+ max_tokens: Math.min(options.maxTokens ?? CODEARTS_MAX_OUTPUT_TOKENS, CODEARTS_MAX_OUTPUT_TOKENS),
33140
33232
  ...wireTools !== void 0 && wireTools.length > 0 ? { tools: wireTools } : {}
33141
33233
  });
33142
33234
  const url = `${CHAT_API_BASE}/chat/completions`;
@@ -34694,6 +34786,37 @@ function contentToText2(content) {
34694
34786
  return content.filter((block) => typeof block === "object" && block !== null && block.type === "text").map((block) => String(block.text)).join("");
34695
34787
  }
34696
34788
  var TOOL_RESULT_IMAGE_TEXT = "Attached image(s) from tool result:";
34789
+ /**
34790
+ * LOCAL ADAPTATION: a wire message that carries no payload at all.
34791
+ *
34792
+ * The domestic gateway rejects `{ role: "assistant", content: "" }` with
34793
+ * 400 / code 11151 "a message has empty content". That shape is reachable
34794
+ * without the model doing anything wrong: a turn interrupted while the model
34795
+ * was still thinking leaves an assistant message holding only reasoning, and
34796
+ * the serializers below emit it with an empty body because the domestic
34797
+ * gateway does not read `reasoning_content`. One such message then poisons
34798
+ * every later turn of that conversation, and retrying cannot help because the
34799
+ * offending message is history.
34800
+ *
34801
+ * `user` messages are deliberately NOT filtered: both gateways accept an empty
34802
+ * user message (verified against live endpoints), and dropping one would erase
34803
+ * a turn boundary the model is reading.
34804
+ */
34805
+ function isBlankWireMessage(entry) {
34806
+ if (entry === null || typeof entry !== "object") return false;
34807
+ if (entry.role !== "assistant" && entry.role !== "system") return false;
34808
+ // A tool call IS content: it anchors the matching tool result, so removing
34809
+ // the message would orphan that result and fail differently.
34810
+ if (Array.isArray(entry.tool_calls) && entry.tool_calls.length > 0) return false;
34811
+ const content = entry.content;
34812
+ if (typeof content === "string") return content.trim().length === 0;
34813
+ if (Array.isArray(content)) return content.length === 0;
34814
+ return false;
34815
+ }
34816
+ function dropBlankWireMessages(wire) {
34817
+ const kept = wire.filter((entry) => !isBlankWireMessage(entry));
34818
+ return kept.length === wire.length ? wire : kept;
34819
+ }
34697
34820
  function serializeMessages2(messages, imageUrls) {
34698
34821
  const wire = [];
34699
34822
  let pendingToolImages = [];
@@ -34772,7 +34895,7 @@ function serializeMessages2(messages, imageUrls) {
34772
34895
  }
34773
34896
  }
34774
34897
  flushToolImages();
34775
- return wire;
34898
+ return dropBlankWireMessages(wire);
34776
34899
  }
34777
34900
  function errorMessage2(error) {
34778
34901
  if (error instanceof Error) return error.message;
@@ -35326,9 +35449,13 @@ var BuddyAdapter = class _BuddyAdapter extends LlmAdapter2 {
35326
35449
  // pair (measured on a live account 2026-10-21; the pool's own copy of this
35327
35450
  // list is BANNED_SYSTEM_PHRASES in src/upstream.ts). Runs AFTER the prepend
35328
35451
  // above, because that is where the host's system text lands.
35452
+ // The third entry is the file-reference guidance DSH adds whenever the
35453
+ // agent has a `read` tool: the "Relative paths…" half is the substring the
35454
+ // gateway lists, and the sentence names no product, so it is rewritten for
35455
+ // its text rather than for claiming another client (measured 2026-10-28).
35329
35456
  for (const message of messages) {
35330
35457
  if ((message.role === "system" || message.role === "assistant") && typeof message.content === "string") {
35331
- message.content = message.content.replace(/powered by DeepSeek Harness/gi, "running on DeepSeek Harness").replace(/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface");
35458
+ message.content = message.content.replace(/powered by DeepSeek Harness/gi, "running on DeepSeek Harness").replace(/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface").replace(/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root");
35332
35459
  }
35333
35460
  }
35334
35461
  const tools = options.tools?.map((tool) => ({
@@ -38225,7 +38352,7 @@ function serializeMessages4(messages, imageUrls) {
38225
38352
  }
38226
38353
  }
38227
38354
  flushToolImages();
38228
- return wire;
38355
+ return dropBlankWireMessages(wire);
38229
38356
  }
38230
38357
  function errorMessage4(error) {
38231
38358
  if (error instanceof Error) return error.message;
@@ -68155,12 +68282,19 @@ function apply(ctx, config = {}) {
68155
68282
  all.map((account) => account.provider).filter((provider) => typeof provider === "string" && provider !== "" && !POOL_OWNED_PROVIDERS.has(provider))
68156
68283
  );
68157
68284
  for (const provider of providers) {
68285
+ // 「黑名单为空」有两种可能:用户从未选择过,或者用户把模型**全部打开**了
68286
+ // (打开时我们删除键,所以两者都是空表)。单看黑名单无法分辨,一律补静音
68287
+ // 就会把用户特意全开的渠道每次重启又关回去 —— 目录越小的渠道越必然中招。
68288
+ // 只有「用户没动过选择」才补。
68289
+ if (pool.selectionTouched(provider)) continue;
68158
68290
  if (pool.disabledModelsFor(provider).size !== 0) continue;
68159
68291
  const catalog = channelPackModelAdaptersRef?.[provider]?.listAllModels();
68160
68292
  if (!Array.isArray(catalog) || catalog.length === 0) continue;
68161
- await pool.setModelsDisabled(provider, catalog.map((model) => model.id));
68293
+ // mark: false —— 自动静音不是「用户动过选择」,不能让它把标记置位,
68294
+ // 否则此后用户全开也会因为标记已在而被永久跳过后续逻辑。
68295
+ await pool.setModelsDisabled(provider, catalog.map((model) => model.id), { mark: false });
68162
68296
  ctx.logger.info(
68163
- `[channel-pack] ${provider} 启动补静音(已有账号但黑名单为空):已默认静音 ${catalog.length} 个模型(可在账号池页勾选启用)`
68297
+ `[channel-pack] ${provider} 启动补静音(已有账号但从未选择过模型):已默认静音 ${catalog.length} 个模型(可在账号池页勾选启用)`
68164
68298
  );
68165
68299
  }
68166
68300
  } catch (error) {
@@ -68174,15 +68308,19 @@ function apply(ctx, config = {}) {
68174
68308
  const isFirstForProvider = pool.listAccountsByProvider(entry.provider).length === 0;
68175
68309
  const hadEmptyBlocklist = pool.disabledModelsFor(entry.provider).size === 0;
68176
68310
  const result = await rawAddAccount(entry);
68177
- if ((isFirstForProvider || hadEmptyBlocklist) && !POOL_OWNED_PROVIDERS.has(entry.provider)) {
68311
+ // 同上:`hadEmptyBlocklist` 分不清「从未选择」与「用户全开」,再加一道
68312
+ // `selectionTouched` 判据,否则给已有账号的渠道新加一个号就会把用户刚打开的
68313
+ // 整目录模型全部关掉。
68314
+ const untouched = !pool.selectionTouched(entry.provider);
68315
+ if ((isFirstForProvider || hadEmptyBlocklist) && untouched && !POOL_OWNED_PROVIDERS.has(entry.provider)) {
68178
68316
  void Promise.resolve().then(async () => {
68179
68317
  try {
68180
68318
  const adapters = channelPackModelAdaptersRef;
68181
68319
  const catalog = adapters?.[entry.provider]?.listAllModels();
68182
68320
  if (!Array.isArray(catalog) || catalog.length === 0) return;
68183
- await pool.setModelsDisabled(entry.provider, catalog.map((model) => model.id));
68321
+ await pool.setModelsDisabled(entry.provider, catalog.map((model) => model.id), { mark: false });
68184
68322
  ctx.logger.info(
68185
- `[channel-pack] ${entry.provider} ${isFirstForProvider ? "首次登录" : "补静音(黑名单为空)"}:已默认静音 ${catalog.length} 个模型(可在账号池页勾选启用)`
68323
+ `[channel-pack] ${entry.provider} ${isFirstForProvider ? "首次登录" : "补静音(从未选择过)"}:已默认静音 ${catalog.length} 个模型(可在账号池页勾选启用)`
68186
68324
  );
68187
68325
  } catch (error) {
68188
68326
  ctx.logger.warn(