dsh-connect-workbuddy 4.7.0 → 4.8.1

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,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.8.1 (2026-10-18)
4
+
5
+ > 版本号是 **patch**:只改卡片界面的样式与排版,不改请求、响应、计费或任何配置项。发往上游的请求体与本版之前逐字节相同。
6
+
7
+ ### Changes
8
+
9
+ - **积分读数区重排为「偏好行」,样式与排版对齐 dsh-connect-trae**
10
+ - **原来错在哪**:这个区块在卡片上换过三个位置 —— 先是 tab 栏下两个并排的复选框(读起来像**一个**共享设置),再被塞进「账号池」标题行(与标题、当前账号徽章挤在同一条基线,最宽的下拉框把徽章推到卡片最右缘,读起来像下拉框的标签),最后成了带独立标题的小节(`justify-content:space-between` 又把每句话劈成「左边缘的标签 + 右边缘的控件」,中间隔着半张卡的空隙)。三次都是**归属**问题,不是间距问题。
11
+ - **现在的形状**(与参考实现逐条一致):一块**没有标题的偏好行组**,直接挂在 tab 块下面 —— 一个复选框 + 标题 + 其下一行说明,然后**缩进 25px**(15px 方框 + 10px 行距)的「显示位置」行,标签与下拉框相邻、从左侧打包,行内没有任何元素被拉伸到边缘。
12
+ - **顺带修掉一个真漏洞**:这个区块此前通过 `AccountPool` 的 `headerExtra` 插槽渲染,而 `AccountPool` 在宿主不报告账号池状态时**整段返回 null** —— 开关会跟着一起消失,用户在那个宿主上**根本没有**打开读数的入口,唯一的线索是一行读数不见了。现在它渲染在 `AccountPool` 之外,与该区块是否存在无关。`headerExtra` 这个插槽已从 `AccountPool` 整个删除,不只是不再传。
13
+ - **文案随之改为「说明」而非「复述」**:主开关的标题由区域相关(「对话框显示 WB CN 积分」)改为区域无关的「在输入框工具栏显示积分读数」—— 它现在带一行自己的说明,且紧邻的下拉框就叫「输入框工具栏」,旧标题会与它重复。新增两段说明:开关下面那句讲**读数是什么**,下拉框下面那句讲**选位置是什么意思**;主开关关闭时后者换成一句点名原因和做法的提示,而不是留一个灰框让用户猜。
14
+ - **勾选框交回平台**:区域卡里的勾选框曾用 `appearance:none` 手绘(15px 方块、3px 圆角、两条旋转边框拼的勾)。参考实现没有手绘 —— 它只写 `accent-color`,方框的尺寸/圆角/勾形全由平台决定。截图实测:2x 屏上那个方块是 26 设备像素 = **13 CSS px**,正是引擎自带复选框的默认尺寸,不是任何人写下的数字。已改为与参考实现相同的两条声明,手绘规则全部删除。
15
+ - **下拉框退回原生外观**:曾手绘成白底细边 + 两条渐变拼的箭头(照一张**浅色**设置页截图),在深色卡片里成了唯一自带填充/边框/箭头的元素,读起来像贴进段落里的一个表单控件。现在用原生控件 + 一条细边,尺寸由它自己的两个选项决定,不再有 `min-width:132px` 这个为「与徽章共享标题行」而设的地板。
16
+ - **README**(中英同步):关掉读数的那句话改为新标签,并补上「显示位置」这一行的说明。
17
+
18
+ ## 4.8.0 (2026-10-18)
19
+
20
+ > 版本号是 **minor**:这不是只改内部的 patch —— 它**会改动会话历史**。跨区/跨窗口切换后原本必然卡死的那一类会话,本版能把上下文折回去继续用。没有溢出时行为完全不变。
21
+
22
+ ### Fixes
23
+
24
+ - **切到窗口更小的路由后,会话永久卡在 `400 code 11115 prompt is too long`(#59 后半)**
25
+ - **症状**:会话在一个 1M 窗口的模型上跑得很长,切成国际区(`workbuddy-global`,窗口 1,048,576)后,**每一条**请求都返回 `prompt is too long: 1374460 tokens > 1048576 maximum`。换回原区域能用,再切过去还是卡死;手动压缩同样 400。会话实质报废。
26
+ - **根因(宿主侧,两处叠加)**:
27
+ 1. **压力阈值按「当前模型」窗口现算**,`compaction-basic/src/config.ts:188-192` 的 `thresholdTokens = min(contextWindow × thresholdRatio, messageBudgetTokens)`。1M 窗口下阈值是 800,000,而 707,801 未到 —— 会话在**切区那一刻就已经超了新模型的窗口**,可自动压缩的触发点还在旧模型的尺度上,永远等不到。
28
+ 2. **溢出兜底把整段历史塞进一次摘要请求**,`compaction-basic/src/index.ts:299` 的 `selectCompactableRange(session, measurement, 0)` 保留量为 0 → `region.ts:150-154` 选出「除最后一条外的全部历史」;`summarizer.ts:141-151` 又把这份摘要发给**当前那条小窗口路由**。于是救援请求和它要救援的请求一样溢出 —— 死锁。
29
+ 宿主侧已按 deepseek-ai/deepseek-harness#9814 提交,上面两处即该 issue 的 A、B 两点。
30
+ - **本版做的事**:适配器在溢出错误**仍然持有 session id** 的那一刻接手,用宿主**自己的**压缩事务(`ctx.compaction.compactRegion`)把历史折回去,而不是另发一次摘要请求。
31
+ - 分块上限 = 模型窗口的 **35%**,因此摘要请求本身**按构造**一定装得下(宿主默认那一次是 131%)。
32
+ - 折叠目标是窗口的 **60%**,逐块折叠直到低于目标;块边界来自宿主自己的 `tokenMeter`。
33
+ - **绝不折掉当前请求携带的那条消息**(`nodes.at(-1)` 永远保留)。
34
+ - 宿主拒绝某个区间(切断了 tool call 与 result 的配对、或正有压缩在跑)时,把尾边向内收一格重试;仍是拒绝就放弃,绝不空转。
35
+ - 折完仍降不下去(单条消息本身就超过窗口)时立刻停手 —— 这是结构性问题,重试只会烧掉重试预算。
36
+ - **实测(真实 GL 400 载荷)**:`isContextOverflow` 对 `prompt is too long: 1374460 tokens > 1048576 maximum` 与 `{"code":11115,...,"type":"CONTEXT_WINDOW_EXCEEDED"}` 均判定为真,对 `rate limit exceeded` 判定为假 —— 只在真溢出时动手。
37
+ - **降级路径**:宿主没装压缩包(`compaction` 服务不存在)、或请求没带 `sessionId`(非主循环调用)时,适配器原样返回,行为与本版之前**逐字节相同**。
38
+
39
+ ### 已知未修
40
+
41
+ - **跨区 prompt token 计数漂移**(#59 前半):同一份 3.6 MB 请求体,CN 计 642,800、GL 计 447,306。这不是网关数错 —— 历史里的 thinking 块按上游当初实际返回的字段名(`reasoning` / `reasoning_content`)原样回放(宿主 `pi-ai` 0.85.1 `openai-completions.js:428` → `:1006`),同一批字节因此落进不同计费桶。归属宿主,本插件没有改写历史的权限,详见 #59 与 `src/body-report.ts` 头部说明。
42
+
3
43
  ## 4.7.0 (2026-10-17)
4
44
 
5
45
  > 版本号是 **minor**:这不是只改内部的 patch —— 它**改变了发往上游的请求体**。DSH Desktop 0.2.1-rc.1 随包 pi-ai 1.1.0,本版修的是该组合下「请求静默丢掉 system prompt 与全部工具声明」。若你停留在更早的宿主,行为不变。
package/README.en.md CHANGED
@@ -29,7 +29,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
29
29
 
30
30
  **Two things to know.** ① **It really does spend credits** — one measured probe reported `credit` 0.02 (a refused one 429s and costs almost nothing). So testing is **manual and per-model only**: there is deliberately no “Test selected” batch button, because sweeping the whole list from one click is an uncontrolled spend — and the Host enforces the same rule, **accepting a single model only** and refusing multi-model requests. ② The upstream advertises a 1M context yet throttles far below it, and the plugin advertises each model's **native** window to DSH (no longer clamped to 200K as of 3.5.0), so DSH compacts very late — hitting the throttle in a long session follows directly from that. To make DSH compact sooner, set a “DSH context budget” cap on the model's row (200K / 500K / the model's native window).
31
31
  - **Local account switching** — discovers the multiple sign-in credentials WorkBuddy's desktop app leaves behind and lets you switch per region. Tokens are never written to DSH settings.
32
- - **Credit readout in the composer toolbar (follows the selected model, per region)** — a clickable readout sits at the left of the composer toolbar (e.g. `WB CN · 1,072`) and **switches region automatically with the model this session is using**: pick a WB CN model and it shows the domestic balance, pick a WB AI model and it shows the international one; with any other provider's model it does not appear at all. It refreshes every 5 minutes and can also be refreshed by hand. Clicking it opens a panel: a **table of balances per account**, where clicking a row switches to that account (the same setting the card's account picker writes), and an account that cannot serve right now is **labelled in its row** (e.g. rate-limited, out of credits) using the same wording as the account-pool table. The per-account figures are fetched only while the panel is open, so the 5-minute background refresh never multiplies by the account count. To turn a side off, untick “Show WB CN / WB AI credits in the composer” in that region's tab on the card; while a region's provider is switched off, the readout disappears and its switch is disabled.
32
+ - **Credit readout in the composer toolbar (follows the selected model, per region)** — a clickable readout sits at the left of the composer toolbar (e.g. `WB CN · 1,072`) and **switches region automatically with the model this session is using**: pick a WB CN model and it shows the domestic balance, pick a WB AI model and it shows the international one; with any other provider's model it does not appear at all. It refreshes every 5 minutes and can also be refreshed by hand. Clicking it opens a panel: a **table of balances per account**, where clicking a row switches to that account (the same setting the card's account picker writes), and an account that cannot serve right now is **labelled in its row** (e.g. rate-limited, out of credits) using the same wording as the account-pool table. The per-account figures are fetched only while the panel is open, so the 5-minute background refresh never multiplies by the account count. To turn a side off, untick “Show credits in the composer toolbar” under that region's tab on the card; while a region's provider is switched off, the readout disappears and its switch is disabled. The same block carries a “Position” line: by default the readout sits in the composer toolbar (the row with the model picker); choosing “below the input box” moves it to the turn/step/token stats row instead — useful when another plugin has taken the toolbar seat.
33
33
 
34
34
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="Composer credit panel: a per-account balance table for the domestic region, with a refresh button and a last-refreshed time" />
35
35
  - **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then expiring-credits-first, higher credits, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
package/README.md CHANGED
@@ -36,7 +36,7 @@
36
36
 
37
37
  **两点需要知道**:① **它真的花钱**——实测一次报 `credit` 0.02(被限流时直接 429,几乎不花)。因此**只能按单个模型手工点**,没有「测试已勾选」这类批量按钮:一次覆盖整个列表就是一次不可控支出(服务端也据此**只接受单个模型**,多模型请求会被拒)。② 上游虽宣称 1M 上下文,但节流线远低于此;插件按模型的**原生窗口**向 DSH 宣称(3.5.0 起不再默认压到 200K),所以 DSH 很晚才压缩对话——长会话撞限流是这条机制的直接结果。想让 DSH 更早压缩,就在模型行上手动设一个「DSH 上下文预算」上限(可选 200K / 500K / 模型原生窗口)。
38
38
  - **本机账号切换** —— 自动发现 WorkBuddy 桌面端留下的多个登录凭据,可按区域切换账号;Token 不写入 DSH 设置。
39
- - **对话输入框积分读数(国内版 / 国际版自动切换)** —— 对话输入框左侧工具栏常驻一行可点击的读数(如 `WB CN · 1,072`),**按本会话当前选中的模型自动切换区域**:选中 WB CN 的模型就显示国内版余额,选中 WB AI 的模型就显示国际版余额;用其它 provider 的模型时该读数完全不出现。每 5 分钟自动刷新,也可点开手动刷新。点击读数弹出面板:**按账号列出的余额表格**,点某一行即可切换到该账号(与卡片上的账号选择是同一个设置);**当前不能用的账号会在行内标出原因**(如「被限流」「积分耗尽」),标注口径与账号池表格一致。面板里的余额只在打开时才去查,因此不会让 5 分钟的后台刷新按账号数翻倍。要关掉某一边,就在卡片对应区域的 tab 里取消勾选「在对话框显示 WB CN / WB AI 积分」;该区域的供应商被关闭时,读数不显示、开关也置灰。
39
+ - **对话输入框积分读数(国内版 / 国际版自动切换)** —— 对话输入框左侧工具栏常驻一行可点击的读数(如 `WB CN · 1,072`),**按本会话当前选中的模型自动切换区域**:选中 WB CN 的模型就显示国内版余额,选中 WB AI 的模型就显示国际版余额;用其它 provider 的模型时该读数完全不出现。每 5 分钟自动刷新,也可点开手动刷新。点击读数弹出面板:**按账号列出的余额表格**,点某一行即可切换到该账号(与卡片上的账号选择是同一个设置);**当前不能用的账号会在行内标出原因**(如「被限流」「积分耗尽」),标注口径与账号池表格一致。面板里的余额只在打开时才去查,因此不会让 5 分钟的后台刷新按账号数翻倍。要关掉某一边,就在卡片对应区域的 tab 下取消勾选「在输入框工具栏显示积分读数」;该区域的供应商被关闭时,读数不显示、开关也置灰。同一个开关下面还有「显示位置」这一行:默认在输入框工具栏(与模型选择同一行),改成「输入框下方」后读数落到轮次/步数/token 统计那一行——装了别的插件把工具栏座位占掉时用得上。
40
40
 
41
41
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、刷新积分按钮与最近刷新时间" />
42
42
  - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-Dd9fsFMR.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BfAvb1_d.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -1403,7 +1403,7 @@ window.__ModuleLoader__.load({
1403
1403
  * section implying the feature exists.
1404
1404
  */
1405
1405
  function AccountPool(props) {
1406
- const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools, headerExtra } = props;
1406
+ const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools } = props;
1407
1407
  const [draft, setDraft] = (0, react.useState)(null);
1408
1408
  const [saving, setSaving] = (0, react.useState)(false);
1409
1409
  const [saveError, setSaveError] = (0, react.useState)(void 0);
@@ -1863,7 +1863,7 @@ window.__ModuleLoader__.load({
1863
1863
  visible: true,
1864
1864
  message: t("row.poolSaving"),
1865
1865
  hint: t("row.veilHintList")
1866
- }) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), headerExtra === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-head-extra" }, headerExtra), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
1866
+ }) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
1867
1867
  className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
1868
1868
  value: currentAccount?.accountId ?? "",
1869
1869
  disabled: selectingAccount === true,
@@ -3047,33 +3047,105 @@ window.__ModuleLoader__.load({
3047
3047
  .dsm-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
3048
3048
  .dsm-btn-primary:hover:not(:disabled){opacity:.9}
3049
3049
  .dsm-workbuddy-usage{display:flex;flex-direction:column;gap:16px;margin:0;padding:16px 0 4px}
3050
- .dsm-workbuddy-tabs{display:flex;gap:6px;padding:4px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;background:var(--dsw-alias-bg-layer-3,#2a2c33)}
3051
- .dsm-workbuddy-tab{appearance:none;font:inherit;cursor:pointer;flex:1;border:0;border-radius:7px;padding:7px 10px;color:var(--dsw-alias-label-tertiary,#999);font-size:13px;font-weight:500;line-height:18px;background:transparent;transition:color .15s,background .15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3050
+ /* The provider bar: one CARD per region, side by side.
3051
+ *
3052
+ * COPIED FROM dsh-connect-trae, rule for rule, because an approximation of it
3053
+ * read as a different component. An earlier revision marked the selected card
3054
+ * with 1px border PLUS an inset 1.5px hairline in the contrast colour, and
3055
+ * that is 2.5 CSS px of near-black on a 2x display — five device pixels of
3056
+ * frame against the reference's one. What the reference does instead:
3057
+ *
3058
+ * - selected = the accent COLOUR on the same 1px border, plus a slightly
3059
+ * darker fill. The LIGHTNESS carries the state, not the thickness.
3060
+ * - the switch is separated from the label by a hairline RULE inside the
3061
+ * card (border-left in border-l1), not by dead space. That rule is what
3062
+ * makes the switch read as a second control in the same box rather than
3063
+ * as a floating square at the card's right edge.
3064
+ * - the cell owns its padding (8px 12px) and the tab button has none, so
3065
+ * the label's text sits at a predictable inset no matter how the button's
3066
+ * focus ring draws.
3067
+ * - 10px corner radius, matching the reference's card shape.
3068
+ *
3069
+ * The row stays what it was semantically: role="tablist" with one button per
3070
+ * region, and the checkbox inside each card is a SEPARATE control (switch this
3071
+ * provider on/off) — never merged into the tab action. Both regions can be on
3072
+ * at once, so this must not become a radio group just because the cards look
3073
+ * like one.
3074
+ *
3075
+ * The checkbox is the PLATFORM'S OWN, styled only with accent-color — the
3076
+ * same three declarations the reference card has, no more. An earlier revision
3077
+ * drew it here (appearance:none, 15px square, 3px radius, a tick made of two
3078
+ * rotated borders) on the theory that pinning the geometry would match the
3079
+ * reference across platforms. It did not: the reference does not draw it, so
3080
+ * the drawn box was a second, invented control sitting in the same row as the
3081
+ * real one. Measured against the screenshot, its box is 26 device px on a 2x
3082
+ * display — 13 CSS px, which is the engine's own default size, not a number
3083
+ * anyone wrote down.
3084
+ *
3085
+ * accent-color hands size, radius and tick glyph to the platform. That is the
3086
+ * point: the box then follows the reader's OS and theme, which is what makes
3087
+ * it read as a checkbox rather than as a painted square. */
3088
+ .dsm-workbuddy-tabs{display:flex;gap:8px;align-items:stretch}
3089
+ .dsm-workbuddy-tab-cell{display:flex;align-items:center;gap:4px;flex:1;min-width:0;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;background:var(--dsw-alias-bg-layer-3,#2a2c33);transition:border-color .15s,background .15s}
3090
+ .dsm-workbuddy-tab-cell:hover{border-color:var(--dsw-alias-label-dimmed,#777)}
3091
+ .dsm-workbuddy-tab-cell:has(.dsm-workbuddy-tab-active){border-color:var(--dsw-alias-brand-primary,#5686fe);background:var(--dsw-alias-bg-layer-2,#232529)}
3092
+ .dsm-workbuddy-tab{appearance:none;font:inherit;cursor:pointer;flex:1;min-width:0;border:0;border-radius:7px;padding:0;color:var(--dsw-alias-label-tertiary,#999);font-size:13px;font-weight:600;line-height:20px;text-align:left;background:transparent;transition:color .15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3052
3093
  .dsm-workbuddy-tab:hover:not(:disabled):not(.dsm-workbuddy-tab-active){color:var(--dsw-alias-label-primary,#e6e6e6)}
3053
3094
  .dsm-workbuddy-tab:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
3054
- .dsm-workbuddy-tab-active{color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-2,#232529);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2,#3a3d45)}
3055
- .dsm-workbuddy-tab-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:baseline}
3056
- .dsm-workbuddy-tab-cell{display:flex;align-items:center;gap:2px;flex:1;min-width:0}
3057
- .dsm-workbuddy-tab-switch{display:inline-flex;align-items:center;flex:none;padding:0 8px 0 2px;cursor:pointer}
3095
+ .dsm-workbuddy-tab-active{color:var(--dsw-alias-label-primary,#e6e6e6)}
3096
+ .dsm-workbuddy-tab-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:7px;vertical-align:baseline}
3097
+ /* The hairline rule between the label and the switch. The auto left margin is
3098
+ what pushes the switch to the card's right edge; without it the rule would
3099
+ sit tight against the text. */
3100
+ .dsm-workbuddy-tab-switch{display:inline-flex;align-items:center;flex:none;margin-left:auto;padding-left:10px;border-left:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));cursor:pointer}
3058
3101
  .dsm-workbuddy-tab-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
3059
- .dsm-workbuddy-tab-switch input:disabled{opacity:.4;cursor:default}
3060
- /* One sidebar-credit switch, belonging to the ACTIVE tab's region. It rides in
3061
- the account-pool heading row (dsm-workbuddy-pool-head-extra) so one
3062
- checkbox no longer costs a full line of vertical space; the tab bar already
3063
- says which region is in scope, so it still reads as this tab's property
3064
- rather than as a shared pair of settings. No vertical margin: the heading row
3065
- owns the spacing. */
3066
- .dsm-workbuddy-credits-switch{display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3067
- /* The standalone fallback site: used only when the account-pool section did not
3068
- render (no Host pool state), so there is no heading row to share. This is the
3069
- spacing the control carried before it moved beside that heading. */
3070
- .dsm-workbuddy-credits-switch-row{display:flex;align-items:center;margin:8px 0 2px}
3071
- /* The credit readout's seat picker (issue #62). Narrower than the pool's model
3072
- select so the show/hide box and this control share one heading row, and it
3073
- wraps onto its own line rather than squashing the pool title. */
3074
- /* Its OWN label class, deliberately not the switch's: a query for "the
3075
- sidebar-credit switch" must not be able to pick this control up, and the two
3076
- are different widgets (a checkbox and a two-way picker). */
3102
+ .dsm-workbuddy-tab-switch input:disabled{cursor:default}
3103
+ /* PREFERENCE LINES: a checkbox on a line that commits on the click, packed from
3104
+ the LEFT — the shape the reference card uses for this exact block.
3105
+ *
3106
+ * They belong to the TAB block above and carry no heading of their own, because
3107
+ * both switches describe THIS tab. Earlier revisions gave them a headed section,
3108
+ * which meant a second 「积分」 heading one line under the tab hint; the reference
3109
+ * card never had one either.
3110
+ *
3111
+ * The picker used to sit at the row's far edge, pushed there by
3112
+ * justify-content:space-between. That split one sentence into two objects with
3113
+ * half a card of empty space between them, and left the reader to guess which
3114
+ * label the control belonged to. Here nothing in a row stretches: the words ask
3115
+ * the question and the control answers right beside them.
3116
+ *
3117
+ * align-items:flex-start, not center: the hint wraps to two lines at narrow
3118
+ * widths, and centring would float the checkbox against the middle of it.
3119
+ *
3120
+ * No horizontal inset: the reference card pads this block by the same 2px its
3121
+ * tab hint carries, so box and caption share one left edge. THIS card's tab hint
3122
+ * (the dsm-workbuddy-models-summary class) has no side padding, so copying the
3123
+ * reference's 2px would leave the two edges 2px apart — a difference too small
3124
+ * to name and just big enough to look unaligned. */
3125
+ .dsm-workbuddy-prefs{display:flex;flex-direction:column;gap:14px;padding:2px 0 0}
3126
+ .dsm-workbuddy-pref{display:flex;align-items:flex-start;gap:10px;font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:pointer}
3127
+ /* The platform's own checkbox, themed only with accent-color. A hand-drawn box
3128
+ was tried on this card and reverted: brand-primary resolves to the host's
3129
+ CONTRAST colour, so a ticked box painted itself as a heavy black pill — the
3130
+ loudest object in a section made of plain 13px text. */
3131
+ .dsm-workbuddy-pref input{flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
3132
+ .dsm-workbuddy-pref-check{inline-size:15px;block-size:15px;margin:3px 0 0}
3133
+ .dsm-workbuddy-pref input:disabled{cursor:default}
3134
+ .dsm-workbuddy-pref:has(input:disabled){opacity:.6;cursor:default}
3135
+ /* The NAME and what it does are TWO lines, not one wrapped blob: first what the
3136
+ switch does, then what it means. Running them together is what made this
3137
+ block read as a wall of text. */
3138
+ .dsm-workbuddy-pref-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
3139
+ .dsm-workbuddy-pref-label{font-weight:500;color:var(--dsw-alias-label-primary,#e6e6e6)}
3140
+ /* The picker's own line: indented by the checkbox's width plus the row gap
3141
+ (15px + 10px), so its label starts on the same left edge as the checkbox
3142
+ labels above it rather than under their boxes. */
3143
+ .dsm-workbuddy-pref-indent{padding-left:25px}
3144
+ /* Name then control, packed left — 显示位置 [输入框工具栏] — so the row reads as
3145
+ one sentence instead of a text column facing a control column. */
3146
+ .dsm-workbuddy-pref-lead{display:flex;align-items:center;justify-content:flex-start;gap:10px;min-width:0}
3147
+ .dsm-workbuddy-pref-lead .dsm-workbuddy-pref-label{flex:none}
3148
+ .dsm-workbuddy-pref-hint{color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:17px}
3077
3149
  /* The supplement to the host's fixed quota sentence (issue #63). The plugin no
3078
3150
  longer claims the host's shell.quota-notice chain seat, so this no longer
3079
3151
  paints into shell.overlay — that seat is the reason the previous build's
@@ -3084,11 +3156,34 @@ window.__ModuleLoader__.load({
3084
3156
  The body is one step quieter than the text it follows. */
3085
3157
  .dsm-workbuddy-quota-notice{display:inline}
3086
3158
  .dsm-workbuddy-quota-notice-body{color:var(--dsw-alias-label-tertiary,#8b8f98);font-size:12px}
3087
- .dsm-workbuddy-credit-position-switch{display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3088
- .dsm-workbuddy-credit-position{max-width:150px;padding:2px 6px;font-size:12px}
3089
- .dsm-workbuddy-credits-switch-row .dsm-workbuddy-credit-position-switch{margin-left:12px}
3090
- .dsm-workbuddy-credits-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
3091
- .dsm-workbuddy-credits-switch input:disabled{opacity:.4;cursor:default}
3159
+ /* The seat picker is the PLATFORM's select, sized by its own two options rather
3160
+ * than stretched: the control answers one short question and sits beside the
3161
+ * words that ask it.
3162
+ *
3163
+ * This was drawn by hand for one revision — appearance:none, a layer-2 panel and
3164
+ * a chevron built from two rotated gradients — copying a reference screenshot's
3165
+ * white field. That does not survive the trip: the screenshot was a LIGHT
3166
+ * settings page, this card is dark, and the drawn field ended up the only
3167
+ * element here with its own fill, border and arrow, so it read as a form control
3168
+ * pasted into a paragraph. The reference card's own dark build uses the native
3169
+ * control with a hairline border, which is what this rule is.
3170
+ *
3171
+ * Sized by content, not by a min-width: it used to carry min-width:132px so it
3172
+ * could share a heading row with the account badge, and that floor is now the
3173
+ * reason a two-word option would still leave a gap after the word it answers. */
3174
+ .dsm-workbuddy-credit-position{flex:none;max-width:none;margin-top:0;padding:3px 8px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:7px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);font:inherit;font-size:12px;line-height:16px;cursor:pointer}
3175
+ .dsm-workbuddy-credit-position:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed,#777)}
3176
+ .dsm-workbuddy-credit-position:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
3177
+ .dsm-workbuddy-credit-position:disabled{opacity:.5;cursor:default}
3178
+ /* A card whose provider is switched OFF dims its LABEL AND DOT. The checkbox
3179
+ * beside them must stay at full strength, because that checkbox is the only
3180
+ * way to switch the region back on — dimming it would grey out the control
3181
+ * that fixes the state it is reporting. Its own disabled rule still dims it
3182
+ * when writes are unavailable, which is a different condition and reads as a
3183
+ * different thing.
3184
+ *
3185
+ * That is also what the reference card does: it marks the off state on the
3186
+ * tab button and leaves the switch live. */
3092
3187
  .dsm-workbuddy-tab-off{opacity:.55}
3093
3188
  /* Re-detection keeps a copy in the signed-out branch (that branch is where
3094
3189
  "I just signed in over there" applies), so it needs its own placement: the
@@ -3187,14 +3282,19 @@ window.__ModuleLoader__.load({
3187
3282
 
3188
3283
  /* ---- Account pool ---- */
3189
3284
  .dsm-workbuddy-pool{display:flex;flex-direction:column;gap:12px;margin:0;padding:16px 0 4px;border-top:1px solid var(--dsw-alias-border-l2,#36373b)}
3190
- /* Title, then the inline extra (the sidebar-credit switch), then the
3191
- serving-account badge on the far edge. The badge is pushed right with
3192
- margin-left:auto rather than the row using space-between: with three
3193
- children the latter would strand the switch in the middle instead of binding
3194
- it to the title, and with only two (no switch) margin-left:auto keeps the
3195
- badge on the right edge exactly as before. */
3285
+ /* The block's heading: title on the left, serving-account badge on the right.
3286
+ *
3287
+ * It used to hold a THIRD child — the credit-readout switch, slotted in between
3288
+ * the title and the badge by the card. A control that configures something else
3289
+ * spent several revisions sitting inside this block's title, and it forced the
3290
+ * row onto a shared baseline with a picker whose width shoved the badge to the
3291
+ * far edge. The switch now has its own section above; what is left is the one
3292
+ * thing that is genuinely this block's title row.
3293
+ *
3294
+ * The badge is pushed right with margin-left:auto rather than the row using
3295
+ * space-between, so it stays on the right edge even if a future change adds a
3296
+ * child or removes one. */
3196
3297
  .dsm-workbuddy-pool-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
3197
- .dsm-workbuddy-pool-head-extra{display:inline-flex;align-items:center}
3198
3298
  .dsm-workbuddy-pool-current-badge{margin-left:auto;padding:2px 10px;border-radius:999px;background:rgba(86,134,254,.16);color:var(--dsw-alias-brand-primary,#5686fe);font-size:12px;font-weight:600;line-height:18px;white-space:nowrap}
3199
3299
  /* Sits under the header block, before the batch buttons: account discovery is
3200
3300
  the first step of using the pool, so it reads above the actions it enables. */
@@ -4614,39 +4714,6 @@ window.__ModuleLoader__.load({
4614
4714
  }))
4615
4715
  })] });
4616
4716
  /**
4617
- * Sidebar credit line switch — ONE control, for the ACTIVE tab's region only.
4618
- *
4619
- * The two regions are independent providers with independent accounts, so
4620
- * each tab owns its own switch and they are never shown as a pair: this one
4621
- * writes `regions.<activeRegion>.showCreditsInMainUi`, which decides only
4622
- * whether THAT region's line appears in the sidebar. That matches how every
4623
- * other control in this card is scoped to the selected tab (account, pool,
4624
- * model list), and it means a CN-only user is never shown a switch for an
4625
- * account they do not have. An earlier revision rendered both boxes side by
4626
- * side under the tab bar regardless of the active tab; that read as one
4627
- * shared setting and was replaced.
4628
- *
4629
- * RENDERED BESIDE THE POOL HEADING, not on its own row. It used to occupy a
4630
- * full line of vertical space for a single checkbox; the pool heading already
4631
- * had a title-left / badge-right row, so the control fits there without the
4632
- * block growing. It is handed to `AccountPool` as a ready-made node purely so
4633
- * the two can share a flex row — the write path, the disabled rules and the
4634
- * serialization against the card's other region writers all stay here, where
4635
- * the settings scope and the save state live.
4636
- */
4637
- const creditsSwitch = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4638
- className: "dsm-workbuddy-credits-switch",
4639
- title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
4640
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4641
- type: "checkbox",
4642
- checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
4643
- disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4644
- onChange: (event) => {
4645
- toggleCredits(activeRegion, event.target.checked);
4646
- }
4647
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal") })]
4648
- });
4649
- /**
4650
4717
  * WHERE the readout sits — a two-way choice, active tab's region only
4651
4718
  * (issue #62).
4652
4719
  *
@@ -4654,31 +4721,76 @@ window.__ModuleLoader__.load({
4654
4721
  * question together ("show it, and where"), and because a user only reaches
4655
4722
  * for this when the default seat is unavailable — typically after installing
4656
4723
  * another plugin that occupies the composer row. Rendered as a `<select>`
4657
- * rather than a pair of radios so it stays one compact row on the pool
4658
- * heading, matching the switch beside it.
4724
+ * rather than a pair of radios so it stays one compact row.
4725
+ *
4726
+ * LABEL LEFT, CONTROL RIGHT — the reverse of the first draft, which put the
4727
+ * select first. Under an always-visible caption the label is what the eye
4728
+ * scans for ("显示位置"), so it leads and the value follows; leading with the
4729
+ * control made it read as a second checkbox whose box happened to be a
4730
+ * dropdown. It also matches the reference settings screen, where the option
4731
+ * name sits left of its picker.
4659
4732
  *
4660
4733
  * Disabled with the region, exactly like the switch: a region that is off has
4661
4734
  * no readout to place.
4662
4735
  */
4663
- const creditPositionPicker = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4664
- className: "dsm-workbuddy-credit-position-switch",
4665
- title: t("row.creditPositionHint"),
4666
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
4667
- className: "dsm-workbuddy-pool-select dsm-workbuddy-credit-position",
4668
- value: activeRegionOn ? creditPositionOn(activeRegion) : "composer",
4669
- disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4670
- "aria-label": t("row.creditPositionAria", { region: activeRegion === "cn" ? t("row.tabCn") : t("row.tabGlobal") }),
4671
- onChange: (event) => {
4672
- changeCreditPosition(activeRegion, event.currentTarget.value === "stats" ? "stats" : "composer");
4673
- },
4674
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4675
- value: "composer",
4676
- children: t("row.creditPositionComposer")
4677
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4678
- value: "stats",
4679
- children: t("row.creditPositionStats")
4736
+ const creditPositionPicker = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
4737
+ className: "dsm-workbuddy-credit-position",
4738
+ value: activeRegionOn ? creditPositionOn(activeRegion) : "composer",
4739
+ disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4740
+ "aria-label": t("row.creditPositionAria", { region: activeRegion === "cn" ? t("row.tabCn") : t("row.tabGlobal") }),
4741
+ onChange: (event) => {
4742
+ changeCreditPosition(activeRegion, event.currentTarget.value === "stats" ? "stats" : "composer");
4743
+ },
4744
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4745
+ value: "composer",
4746
+ children: t("row.creditPositionComposer")
4747
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4748
+ value: "stats",
4749
+ children: t("row.creditPositionStats")
4750
+ })]
4751
+ });
4752
+ const creditPositionCaption = t("row.creditPositionHintInline");
4753
+ const creditPositionDisabledCaption = t("row.creditPositionDisabledHint");
4754
+ const creditPreferences = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4755
+ className: "dsm-workbuddy-prefs",
4756
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4757
+ className: "dsm-workbuddy-pref",
4758
+ title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
4759
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4760
+ type: "checkbox",
4761
+ className: "dsm-workbuddy-pref-check",
4762
+ checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
4763
+ disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4764
+ onChange: (event) => {
4765
+ toggleCredits(activeRegion, event.target.checked);
4766
+ }
4767
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4768
+ className: "dsm-workbuddy-pref-copy",
4769
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4770
+ className: "dsm-workbuddy-pref-label",
4771
+ children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal")
4772
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4773
+ className: "dsm-workbuddy-pref-hint",
4774
+ children: t("row.showCreditsHintInline")
4775
+ })]
4680
4776
  })]
4681
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.creditPositionLabel") })]
4777
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
4778
+ className: "dsm-workbuddy-pref dsm-workbuddy-pref-indent",
4779
+ title: t("row.creditPositionHint"),
4780
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4781
+ className: "dsm-workbuddy-pref-copy",
4782
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4783
+ className: "dsm-workbuddy-pref-lead",
4784
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4785
+ className: "dsm-workbuddy-pref-label",
4786
+ children: t("row.creditPositionLabel")
4787
+ }), creditPositionPicker]
4788
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4789
+ className: "dsm-workbuddy-pref-hint",
4790
+ children: activeRegionOn ? creditPositionCaption : creditPositionDisabledCaption
4791
+ })]
4792
+ })
4793
+ })]
4682
4794
  });
4683
4795
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
4684
4796
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
@@ -4768,6 +4880,7 @@ window.__ModuleLoader__.load({
4768
4880
  className: "dsm-workbuddy-tab-off-notice",
4769
4881
  children: t("row.tabOffNotice")
4770
4882
  }) : null,
4883
+ creditPreferences,
4771
4884
  status.status === "error" || status.status === "signed-in" && status.creditsError !== void 0 ? status.status === "signed-in" && status.credentialRejected === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4772
4885
  className: "dsm-workbuddy-usage-hint",
4773
4886
  children: t("row.reloginHint")
@@ -4824,16 +4937,11 @@ window.__ModuleLoader__.load({
4824
4937
  },
4825
4938
  selectingAccount: switchingAccount,
4826
4939
  accountTools,
4827
- ...status.pool === void 0 ? {} : { headerExtra: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [creditsSwitch, creditPositionPicker] }) },
4828
4940
  siblingBusy: saving || togglingRegion !== void 0,
4829
4941
  onBusyChange: setPoolBusy,
4830
4942
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
4831
4943
  onRefresh: () => refreshUsage(activeRegion)
4832
4944
  }),
4833
- status.pool === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4834
- className: "dsm-workbuddy-credits-switch-row",
4835
- children: [creditsSwitch, creditPositionPicker]
4836
- }) : null,
4837
4945
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
4838
4946
  className: "dsm-workbuddy-models",
4839
4947
  "aria-label": t("row.modelsTitle"),
@@ -6188,13 +6296,16 @@ window.__ModuleLoader__.load({
6188
6296
  "row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
6189
6297
  "row.poolRowRemoving": "Removing the account…",
6190
6298
  "row.poolRowRemoveDesktopOnly": "{accountName}: hidden from the list — this account comes from the desktop app's own sign-in, which is left untouched. 「Detect accounts again」 re-adds it.",
6191
- "row.showCreditsCn": "WB CN credits in composer",
6192
- "row.showCreditsGlobal": "WB AI credits in composer",
6299
+ "row.showCreditsCn": "Show credits in the composer toolbar",
6300
+ "row.showCreditsGlobal": "Show credits in the composer toolbar",
6193
6301
  "row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
6194
- "row.creditPositionLabel": "position",
6302
+ "row.creditPositionLabel": "Position",
6195
6303
  "row.creditPositionComposer": "composer toolbar",
6196
6304
  "row.creditPositionStats": "below the input box",
6197
6305
  "row.creditPositionAria": "Credit readout position for {region}",
6306
+ "row.creditPositionHintInline": "Where the readout sits. \"composer toolbar\" is the default — the row with the model picker.",
6307
+ "row.creditPositionDisabledHint": "Unavailable while the switch above is off: tick \"Show credits in the composer toolbar\" first.",
6308
+ "row.showCreditsHintInline": "The balance for the account currently serving this tab. Click for per-account balances; it also refreshes every 5 minutes.",
6198
6309
  "quotaNotice.fallbackHead": "The quota for this request is exhausted",
6199
6310
  "quotaNotice.supplement": " (some models also have a daily limit that is unrelated to your credits)",
6200
6311
  "row.creditPositionHint": "Where the credit readout sits. \"composer toolbar\" is the default (the row with the model picker). Choose \"below the input box\" — the row carrying the round / step / token statistics — if another plugin occupies the composer toolbar and the readout does not appear.",
@@ -6473,13 +6584,16 @@ window.__ModuleLoader__.load({
6473
6584
  "row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
6474
6585
  "row.poolRowRemoving": "正在删除账号…",
6475
6586
  "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
6476
- "row.showCreditsCn": "对话框显示 WB CN 积分",
6477
- "row.showCreditsGlobal": "对话框显示 WB AI 积分",
6587
+ "row.showCreditsCn": "在输入框工具栏显示积分读数",
6588
+ "row.showCreditsGlobal": "在输入框工具栏显示积分读数",
6478
6589
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
6479
- "row.creditPositionLabel": "位置",
6590
+ "row.creditPositionLabel": "显示位置",
6480
6591
  "row.creditPositionComposer": "输入框工具栏",
6481
6592
  "row.creditPositionStats": "输入框下方",
6482
6593
  "row.creditPositionAria": "{region} 的积分显示位置",
6594
+ "row.creditPositionHintInline": "读数显示在哪里。「输入框工具栏」是默认位置,与模型选择同一行。",
6595
+ "row.creditPositionDisabledHint": "主开关关闭时不可调整:先勾选上方「在输入框工具栏显示积分读数」。",
6596
+ "row.showCreditsHintInline": "当前为本 tab 服务的那个账号的余额。点一下能看各账号明细,另外每 5 分钟自动刷新一次。",
6483
6597
  "quotaNotice.fallbackHead": "当前请求的额度已用尽",
6484
6598
  "quotaNotice.supplement": "(部分模型另有每日额度,与积分无关)",
6485
6599
  "row.creditPositionHint": "积分显示在哪里。「输入框工具栏」是默认位置(与模型选择同一行)。如果装了别的插件后读数不显示,改成「输入框下方」—— 那是「轮次 / 步数 / token」统计所在的那一行。",
@@ -3957,7 +3957,7 @@ function generationOf(version) {
3957
3957
  //#endregion
3958
3958
  //#region src/version.ts
3959
3959
  /** The npm package version this build was produced from. */
3960
- const WORKBUDDY_CONNECT_VERSION = "4.7.0";
3960
+ const WORKBUDDY_CONNECT_VERSION = "4.8.1";
3961
3961
  //#endregion
3962
3962
  //#region src/host-heartbeat.ts
3963
3963
  /**
package/lib/index.d.ts CHANGED
@@ -1246,6 +1246,18 @@ declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddySh
1246
1246
  /** The resolved pi-ai generation, by context-shape capability. */
1247
1247
  type PiAiGeneration = 'modern' | 'legacy';
1248
1248
  //#endregion
1249
+ //#region src/context-rescue.d.ts
1250
+ interface ContextRescue {
1251
+ /**
1252
+ * Fold history until the next request fits the given window.
1253
+ *
1254
+ * @param sessionId - session whose history should be folded.
1255
+ * @param window - context window of the route that just overflowed.
1256
+ * @returns whether any history was folded.
1257
+ */
1258
+ rescue(sessionId: string, window: number): Promise<boolean>;
1259
+ }
1260
+ //#endregion
1249
1261
  //#region src/adapter.d.ts
1250
1262
  /** Provider route this bundle owns for the domestic (CN) gateway. */
1251
1263
  declare const WORKBUDDY_PROVIDER = "workbuddy";
@@ -1270,6 +1282,12 @@ interface WorkBuddyAdapterOptions {
1270
1282
  displayName?: string;
1271
1283
  /** Resolve the durable attachment service at request time, when present. */
1272
1284
  resolveAttachments?: () => AttachmentStore | undefined;
1285
+ /**
1286
+ * Resolve the host-side context-overflow rescue, when this host exposes one.
1287
+ * Omitted (or resolving to `undefined`) leaves the adapter exactly as it was:
1288
+ * a provider overflow is forwarded and the host's own recovery decides.
1289
+ */
1290
+ resolveContextRescue?: () => ContextRescue | undefined;
1273
1291
  /**
1274
1292
  * Override the pi-ai generation gate (see {@link piAiRuntimeInfo}); tests
1275
1293
  * inject both branches because this checkout only ever RESOLVES one of
package/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-Dd9fsFMR.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BfAvb1_d.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { join } from "node:path";
6
- import { createProvider } from "@earendil-works/pi-ai";
6
+ import { createAssistantMessageEventStream, createProvider, isContextOverflow } from "@earendil-works/pi-ai";
7
7
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
8
  import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
9
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
@@ -473,6 +473,51 @@ function withLegacyContext(api, generation) {
473
473
  };
474
474
  }
475
475
  /**
476
+ * Layer the host-side context-overflow rescue onto a provider's streams.
477
+ *
478
+ * pi-ai reports a provider failure as an `error` event rather than a throw, so
479
+ * the rescue runs while the failing assistant message is still in hand: history
480
+ * is folded before the event continues on its way, and the host's retry then
481
+ * rebuilds its request against a smaller surface. Nothing is swallowed — the
482
+ * error event is forwarded unchanged, because deciding whether to retry is the
483
+ * host's policy, not this transport's.
484
+ *
485
+ * Both gates are deliberate. Without `sessionId` there is no session to fold,
486
+ * and without a declared `contextWindow` there is no budget to fold against;
487
+ * either absence leaves the stream untouched.
488
+ */
489
+ function withContextRescue(api, resolve) {
490
+ if (resolve === void 0) return api;
491
+ const wrap = (inner) => (model, context, options) => {
492
+ const source = inner(model, context, options);
493
+ const rescue = resolve();
494
+ const sessionId = options?.sessionId;
495
+ const window = model.contextWindow;
496
+ if (rescue === void 0 || sessionId === void 0 || window === void 0) return source;
497
+ const out = createAssistantMessageEventStream();
498
+ let final;
499
+ (async () => {
500
+ try {
501
+ for await (const event of source) {
502
+ if (event.type === "done") final = event.message;
503
+ if (event.type === "error") final = event.error;
504
+ if (event.type === "error" && isContextOverflow(event.error)) await rescue.rescue(sessionId, window);
505
+ out.push(event);
506
+ }
507
+ out.end(final);
508
+ } catch {
509
+ out.end(final);
510
+ }
511
+ })();
512
+ return out;
513
+ };
514
+ return {
515
+ ...api,
516
+ stream: wrap(api.stream),
517
+ streamSimple: wrap(api.streamSimple)
518
+ };
519
+ }
520
+ /**
476
521
  * Assemble the adapter. The provider's `getModels` reads the live catalog,
477
522
  * and every model's `baseUrl` is re-resolved per read so the shim's
478
523
  * ephemeral port applies from the first snapshot after startup.
@@ -501,7 +546,7 @@ function createWorkBuddyAdapter(options) {
501
546
  }
502
547
  } },
503
548
  models: buildModels(),
504
- api: withLegacyContext(openAICompletionsApi(), generation)
549
+ api: withContextRescue(withLegacyContext(openAICompletionsApi(), generation), options.resolveContextRescue)
505
550
  }),
506
551
  getModels: () => buildModels()
507
552
  };
@@ -529,6 +574,104 @@ function createWorkBuddyAdapter(options) {
529
574
  };
530
575
  }
531
576
  //#endregion
577
+ //#region src/context-rescue.ts
578
+ /** Share of the window one fold chunk may consume, leaving room for the prompt. */
579
+ const CHUNK_FRACTION = .35;
580
+ /** Post-fold measurement target: comfortably below the window the route allows. */
581
+ const TARGET_FRACTION = .6;
582
+ /** A session is never folded more than this many times by one rescue. */
583
+ const MAX_ROUNDS = 8;
584
+ /** How many times one chunk may shrink its tail edge before giving up. */
585
+ const MAX_SHRINK = 24;
586
+ /** A single fold is abandoned after this long. */
587
+ const FOLD_TIMEOUT_MS = 18e4;
588
+ /**
589
+ * Create the rescue bound to a live host.
590
+ *
591
+ * @param resolve - resolves host services at call time.
592
+ * @returns the rescue handle the adapter calls on a provider overflow.
593
+ */
594
+ function createContextRescue(resolve) {
595
+ const inflight = /* @__PURE__ */ new Set();
596
+ return { async rescue(sessionId, window) {
597
+ if (inflight.has(sessionId)) return false;
598
+ inflight.add(sessionId);
599
+ try {
600
+ return await foldUntilFits(resolve(), sessionId, window);
601
+ } catch (error) {
602
+ resolve().log?.("dsh-connect-workbuddy: context rescue failed", error);
603
+ return false;
604
+ } finally {
605
+ inflight.delete(sessionId);
606
+ }
607
+ } };
608
+ }
609
+ async function foldUntilFits(deps, sessionId, window) {
610
+ const { meter, compaction, agents } = deps;
611
+ if (meter === void 0 || compaction === void 0 || agents === void 0) return false;
612
+ const agent = agents.get(sessionId);
613
+ if (agent === void 0) return false;
614
+ const target = Math.max(1, Math.floor(window * TARGET_FRACTION));
615
+ let foldedAny = false;
616
+ let previous = Number.POSITIVE_INFINITY;
617
+ for (let round = 0; round < MAX_ROUNDS; round += 1) {
618
+ const measurement = meter.measure(agent.session);
619
+ if (measurement.totalTokens <= target) return foldedAny;
620
+ if (measurement.totalTokens >= previous) return foldedAny;
621
+ previous = measurement.totalTokens;
622
+ if (!await foldOnce(deps, agent, measurement, window, target)) return foldedAny;
623
+ foldedAny = true;
624
+ }
625
+ return foldedAny;
626
+ }
627
+ /**
628
+ * Fold the oldest chunk that fits, shrinking its tail edge when the host
629
+ * refuses the range.
630
+ *
631
+ * The host rejects a range whose edges split a tool call from its result, and
632
+ * it rejects an active or missing range before appending anything — so a
633
+ * refused attempt costs no durable state and the edge can simply move inward.
634
+ *
635
+ * @returns whether one chunk was folded.
636
+ */
637
+ async function foldOnce(deps, agent, measurement, window, target) {
638
+ const nodes = measurement.nodes;
639
+ if (nodes.length < 2) return false;
640
+ const budget = Math.floor(window * CHUNK_FRACTION);
641
+ const excess = measurement.totalTokens - target;
642
+ const chunk = Math.max(1, Math.min(budget, excess));
643
+ const head = nodes[0];
644
+ const compaction = deps.compaction;
645
+ if (head === void 0 || compaction === void 0) return false;
646
+ let end = -1;
647
+ let accumulated = 0;
648
+ for (let index = 0; index < nodes.length - 1; index += 1) {
649
+ const node = nodes[index];
650
+ if (node === void 0) break;
651
+ accumulated += node.tokens;
652
+ end = index;
653
+ if (accumulated >= chunk) break;
654
+ }
655
+ if (end < 0) return false;
656
+ const signal = AbortSignal.timeout(FOLD_TIMEOUT_MS);
657
+ const floor = Math.max(0, end - MAX_SHRINK);
658
+ for (; end >= floor; end -= 1) {
659
+ const tail = nodes[end];
660
+ if (tail === void 0) break;
661
+ try {
662
+ await compaction.compactRegion(head.seq, tail.seq, agent, signal);
663
+ return true;
664
+ } catch (error) {
665
+ if (signal.aborted) {
666
+ deps.log?.("dsh-connect-workbuddy: context rescue timed out", error);
667
+ return false;
668
+ }
669
+ }
670
+ }
671
+ deps.log?.("dsh-connect-workbuddy: context rescue found no foldable range");
672
+ return false;
673
+ }
674
+ //#endregion
532
675
  //#region src/body-report.ts
533
676
  /**
534
677
  * Body-composition report: WHAT was sent upstream, in counts (issue #59).
@@ -541,16 +684,32 @@ function createWorkBuddyAdapter(options) {
541
684
  * - the gateways count identical bytes differently (upstream's business); or
542
685
  * - the two requests did not carry the same messages (ours to fix).
543
686
  *
544
- * The second world was left standing by two independent measurements. The
545
- * reporter ran this project's `--kind`/`--language` probe on BOTH regions and
546
- * got the same tokens-per-1000-chars on byte-identical prose, code and JSON
547
- * (ratios 0.98–1.06 across six cells), so identical bytes really do bill
548
- * identically. And a code trace showed historical `reasoning` never reaches the
549
- * wire at all: pi-ai only forwards `thinking` blocks as text when
550
- * `compat.requiresThinkingAsText` is set — this plugin never sets it — and the
551
- * `reasoning_details` route needs a signature the stream parser never writes.
552
- * With the counting difference excluded and reasoning excluded, the remaining
553
- * candidate is that the two bodies differ in the first place.
687
+ * Neither world, it turned out — the answer was a third one, and this report is
688
+ * what produced it. A paired capture in one session (4.6.7) measured
689
+ * `bodyBytes` 3,628,068 vs 3,626,374, a ratio of 0.9995: both requests carried
690
+ * the same bytes. What differed was the BUCKET each byte was counted in — CN
691
+ * text 741,952 vs GL 184,821, CN reasoning 72,270 vs GL 627,994, with a
692
+ * complementary residual of 0.3%. The gateways did not miscount; the same
693
+ * history was labelled differently on the two sides.
694
+ *
695
+ * The labelling is pi-ai's replay, not this plugin's. `openai-completions.js`
696
+ * records the reasoning field name the upstream stream actually used into
697
+ * `thinkingSignature` (`:428`), and on the way back out writes
698
+ * `assistantMsg[<that name>] = <the thinking text>` (`:1006`), so a
699
+ * transcript produced against a gateway that says `reasoning` is replayed as
700
+ * `reasoning` and one that says `reasoning_content` as `reasoning_content`.
701
+ * This plugin sets only `compat.supportsReasoningEffort` (`src/adapter.ts:200`),
702
+ * so the `else` branch is taken and the field goes out raw rather than folded
703
+ * into text.
704
+ *
705
+ * An earlier revision of this comment claimed the opposite — that historical
706
+ * `reasoning` never reaches the wire, because `requiresThinkingAsText` is unset
707
+ * and the `reasoning_details` route needs a signature the parser never writes.
708
+ * Both halves are wrong under pi-ai 0.85.1: the unset flag is precisely what
709
+ * routes thinking out as a raw field, and the parser does write the
710
+ * `reasoning_details` signature (`:188–193`). The correction matters because it
711
+ * turns `reasoning` into a bucket that can be non-zero — and diffing it across
712
+ * regions is the direct measurement of the drift.
554
713
  *
555
714
  * This module exists to settle exactly that, and it can do it WITHOUT either
556
715
  * region having credit: the body is assembled locally, before the upstream ever
@@ -6609,6 +6768,15 @@ function apply(ctx, config) {
6609
6768
  Promise.all(REGION_KEYS.map((region) => stacks[region].shim.ready)).then(async () => {
6610
6769
  if (stopped) return;
6611
6770
  const adapters = {};
6771
+ const hostService = (name) => ctx.get(name);
6772
+ const contextRescue = createContextRescue(() => ({
6773
+ meter: hostService("tokenMeter"),
6774
+ compaction: hostService("compaction"),
6775
+ agents: hostService("agents"),
6776
+ log: (message, error) => {
6777
+ ctx.logger.warn(message, error);
6778
+ }
6779
+ }));
6612
6780
  try {
6613
6781
  for (const region of REGION_KEYS) adapters[region] = createWorkBuddyAdapter({
6614
6782
  shim: stacks[region].shim,
@@ -6616,7 +6784,8 @@ function apply(ctx, config) {
6616
6784
  catalog: stacks[region].catalog,
6617
6785
  provider: WORKBUDDY_PROVIDERS[region],
6618
6786
  displayName: WORKBUDDY_PROVIDER_DISPLAY_NAMES[region],
6619
- resolveAttachments: () => ctx.get("attachments")
6787
+ resolveAttachments: () => ctx.get("attachments"),
6788
+ resolveContextRescue: () => contextRescue
6620
6789
  });
6621
6790
  invalidateCatalog = () => {
6622
6791
  for (const region of REGION_KEYS) adapters[region].invalidate();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.7.0",
5
+ "version": "4.8.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",