dsh-connect-workbuddy 4.3.4 → 4.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.4.2 (2026-10-13)
4
+
5
+ ### Fixes
6
+
7
+ - **DSH 0.2.1-alpha.2 下 workbuddy 模型工具调用全失效:把 pi-ai 主版本 1 判为 modern。**(PR #52,issues #24/#25/#26 的同源延伸)
8
+ - **现象**:DSH `0.2.1-alpha.2` 起,**所有** workbuddy / workbuddy-global 模型都无法调用工具(本地模型与 deepseek 账号组不受影响),且**界面上没有任何报错**。会话事件流停在「只有思考块」→「整轮 completed」,全程没有 `tool-call` 事件;模型有时还会把 DSML 标记当**正文**吐出来。
9
+ - **根因**:`src/pi-ai-runtime.ts` 的 `generationOf()` 用 `/^0\.(\d+)\./u` 解析版本,**只认 `0.x`**。DSH 0.2.1-alpha.2 随包的是 `@earendil-works/pi-ai@1.0.2`,`'1.0.2'` 匹配不到 → 返回 `'legacy'`。`legacy` 会让 `withLegacyContext()` 用 `adaptLegacyPiAiContext()` 把上下文折成 pi-ai **0.85** 形状:system 消息抽出去当 `systemPrompt`,工具声明搬到**顶层 `tools`**。而 pi-ai 1.x 从 `context.messages` 里 system 消息的 `toolsAdded` 读工具(`resolveTranscriptTools` / `getCurrentTools`),**不看顶层 `tools`**——请求里一个工具声明都没有,模型自然发不出 `toolCall`。国内网关容忍无 system 的请求,模型就把 DSML 写进正文。更隐蔽的是 shim 的 DSML 恢复也一并失效(`gate.declaredNames` 为空 → 整段禁用),所以整套缺陷**完全静默**。
10
+ - **为什么在作者 checkout 上不复现**:源码注释里写着 "this checkout resolves 0.85.1 today"——解析到 0.85.1 时 `legacy` 分支是**正确**的。另外 `doctor` 在宿主**之外**跑会显示 `modern`,那是 `resolvedLocally: false` 的默认值,与宿主内的真实判定无关。
11
+ - **修法**:只特判**主版本 1**,`>= 87` 的 minor 判定保持不变。已逐包对照 **pi-ai 0.87.1、1.0.2、1.1.0、1.2.0** 四个版本:三者的 `openai-completions` 都从 `context.messages` 调 `resolveTranscriptTools()`,`getCurrentTools()` 都读 system 消息的 `toolsAdded`/`toolsRemoved`,契约一致。**刻意是 `=== 1` 而不是 `>= 1`**:未核验未来 2.x 的上下文契约,不替未验证的版本做兼容承诺;测试里 `2.0.0` 明确断言仍为 `legacy`。
12
+ - **顺带记录的边界事实**(本版**未**改动 peer 范围):宿主 `dsh-app-boot` 的 `evaluatePluginCompatibility()` 只校验 `@deepseek-ai/dsh*` 命名空间,所以 `"@earendil-works/pi-ai": ">=0.85.0 <0.88.0"` 这条 peer **从不参与校验**,插件会对着越界的 1.x 静默运行。peer 范围的取舍留给维护者单独决定。
13
+ - **守卫**:`tests/pi-ai-runtime.spec.ts` 的版本→代际用例改为数据驱动,并用临时目录里伪造的 `package.json` 驱动 `piAiRuntimeFor()`,**不依赖本机解析到哪个版本**,因此在解析到 0.85.1 的机器上也能跑。覆盖 `0.85.0/0.85.1/0.86.3 → legacy`、`0.87.0/0.87.1/1.0.0/1.0.2/1.1.0 → modern`、`2.0.0 → legacy`、不可解析版本保守判 `legacy`。变异验证(还原修复)后恰好 4 个 1.x 用例变红,其余 13 个不变。
14
+
15
+ - **上游流被截断时,恢复层把补写的 `[DONE]` 粘在半截帧之后,客户端 SSE 解析崩溃(PI_AI_ERROR)。**(PR #54,issue #53)
16
+ - **现象**:对话中途偶发整轮直接失败,报错文本 `Unexpected non-whitespace character after JSON at position 319 (line 2 column 1)`,宿主归到兜底码 `PI_AI_ERROR`。该码**不在宿主的原地重试集**里(只认 `EMPTY_RESPONSE`/`RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`),所以一旦出现,这一轮直接结束、不重试——用户看到的就是「突然断了」。已确认与具体模型、工作区无关,且**紧跟在一次 transport 故障之后**。
17
+ - **根因**:是恢复层自己制造了非法的 SSE 帧,不是上游发的。两处叠加——① `consume()` 把流尾巴的残余帧直接透传,不补帧分隔空行;② `finish()` 补写合成终止帧时,没有确保前面有帧分隔。于是客户端实际收到的是**同一个 SSE 事件里的两条 `data:` 行**(中间只有一个 `\n`,不是空行)。openai SDK 的 `SSEDecoder` 会把一个事件内的多条 `data:` 行用 `\n` 拼起来再解析,拼出来就是「一个完整 JSON + 换行 + 第二个 JSON」,`JSON.parse` 在第二行行首抛错。**报错位置恰好是 319,因为第一段 chunk 的长度正好是 318**——这不是巧合。
18
+ - **修法**:把「每个帧必须自带空行终止符」变成不变量,而不是在 `finish()` 里特判——`RecoveryStream` 有 6 处直接 `res.write`,逐个补分隔容易漏,而 `handleFrame` 里 JSON 解析失败的原帧透传路径同样会写出不带空行的帧。现在类内所有帧写出收敛到单一出口 `emitFrame()`,由它补齐帧尾;`consume()` 对 `carry` 剩下的半帧先归一化再交给 `handleFrame`。**缺换行和缺空行是两种情形**:前者补一个 `\n` 只结束「行」不结束「事件」,必须补 `\n\n` 才能让随后的 `[DONE]` 不粘上去。
19
+ - **顺带修掉一个静默丢内容**:`finish()` **不**补 `[DONE]` 时,半截帧没有空行收尾,SDK 永远等不到分隔,最后那一小段内容被静默丢弃。归一化帧尾后这段内容能正常 dispatch 出来。
20
+ - **不影响普通路径**:`push()` 切出的帧本就带终止符,经 `emitFrame` 原样通过,仍是 byte-for-byte 透传——「帧顺序永不改变」的既有承诺不受影响。
21
+ - **守卫**:新增 `tests/issue53-sse-terminator.spec.ts`,断言在**字节层**:按 SSE 规范切事件,每个事件内多条 `data:` 行拼接后必须是单个合法 JSON。缺陷本质是字节粘连,只有字节级断言能抓住它。变异验证(还原两处修复)后 3 个缺陷用例全部变红,「完整流字节不变」的守卫用例保持绿色——说明没有过度归一化。
22
+
23
+ - **保存/删除/导入时的忙碌遮罩:改用宿主主题令牌,spinner 换成宿主同款弧线。**
24
+ - **现象**:遮罩卡片是一块近黑的板子,上面的字(尤其是副标题「完成后列表会立即刷新。」)几乎看不清;spinner 的转法也和外壳里其他转圈明显不同拍。
25
+ - **根因**:三处都出在自造值上。① 卡片底色用 `--dsw-alias-bg-primary`,而这个变量在宿主的令牌集里**根本不存在**(宿主只有 `bg-base` / `bg-layer-1..3` / `bg-module-platform` / `bg-overlay` 等),于是一路回退到硬编码的近黑 `#1b1c20`。② 遮罩底色写死 `rgba(0,0,0,.38)`,既不跟随主题、也没有宿主的模糊。③ 副标题用 `label-tertiary`——那是宿主最暗的一档(亮色 `neutral-bluish-600`、暗色 `neutral-bluish-400`),给元数据用的,不该承载一句要等的人必须读的话。
26
+ - **修法**:卡片改用宿主给弹层用的 `--dsw-alias-bg-layer-2`(亮色白 / 暗色 `neutral-bluish-850`,各自成立);遮罩改用宿主自己遮罩用的 `--dsw-alias-bg-mask-1`(亮 `.24` / 暗 `.5`)+ `--dsw-mask-blur`,与外壳里其他遮罩一致;副标题改用 `label-secondary`。阴影也从手写的 `0 8px 28px rgba(0,0,0,.35)` 换成宿主的 `--dsw-elevation-prominent`。
27
+ - **spinner**:原本是 18px 的 CSS border 环、`.7s` 匀速转,比宿主自己的状态转圈(`_dsh-state-dot-spin`,SVG 弧线 + dash 呼吸、`1.5s`)快了近一倍。比起形状本身,**这个速度差才是它看着廉价的主因**。现在改为同款弧线几何与 `1.5s` 节奏,`currentColor` 继承所在层级,reduced-motion 降级为静态弧线(与宿主降级方式一致),并补 `aria-hidden` 免得 `role=status` 之外多报一次。
28
+ - **顺带记录一个会静默损坏产物的坑**:`WORKBUDDY_CARD_CSS` 是模板字符串,注释里写反引号会**提前截断整个样式表**,而 typecheck 与 build **都不报错**(截断后的尾部仍是合法 TS,被当成一堆散落的表达式语句)。本次改注释时真的踩到了。新增一条守卫:按顶层语句数断言(正常 2 条变量声明;一旦截断会炸成几十条表达式语句)。变异验证:塞回一个反引号后该守卫变红,恢复后全绿。
29
+ - **守卫**:`tests/client-host-contract.spec.ts` 由 8 例增至 10 例。
30
+
31
+ ### Docs
32
+
33
+ - 无。本版含上述三处修复与配套测试。
34
+
35
+ ## 4.4.1 (2026-10-12)
36
+
37
+ ### Fixes
38
+
39
+ - **登录成功后「正在添加账号…」的遮罩不再无限转圈;导入的遮罩同样有上限。**(issue #51)
40
+ - **现象**:报告者描述「登录成功后这里会转很久,等一分钟也没等完,每次都是直接退出 DSH 重新打开」,截图里账号表**已经有三条账号、其中一条正在服务**,而遮罩仍停在「正在添加账号…|完成后列表会立即刷新。」。这两个事实合起来就是全部分析:凭据在 Host 答 `done` **之前**就已写入,所以表格有数据说明**登录本身是成功的**,卡住的是它的下一步。
41
+ - **根因**:`OAuthSignIn` 的 `done` 分支里,`setPhase({ kind: 'syncing' })` 之后跟着一句 `await onAdded?.()`,而遮罩的显示条件正是 `phase.kind === 'syncing'`。也就是说**遮罩唯一的退出条件是一个 promise 落地**——既没有超时,也没有 `catch`。重读(卡片那次同源 usage 请求)只要永不 settle,`phase` 就永远停在 `syncing`。上一段的 `waiting` 阶段**有**自己的本地超时,`syncing` 两者都没有,这是本缺陷的形状。
42
+ - **为什么代码评审看不见**:这是本次修复中最值得记下的一点。`phase` 是轮询 effect 的依赖项,`setPhase({ kind: 'syncing' })` 会让该 effect 重跑,React **先执行上一次的 cleanup**,而 cleanup 会把旧闭包的 `cancelled` 置为 `true`。React 在 `await` 恢复之前就提交了这次 cleanup,所以**恢复后总会看到 `cancelled === true` 并直接 return**。于是——**只有当重读快到不与 React 提交相交时**(即最快的那条路径),它才在 cleanup 之前落地、通过守卫并显示成功行。**慢到跨过一次 React 提交的顺序(也就是报告者遇到的那种)反而必然失败。缺陷藏在它自己的常见路径背后**,这也是第一版「把竞态写在原处」的修法**实测无效**的原因(不是推断,是跑出来的)。
43
+ - **修法**:把这个等待搬进一个以 `phase` 为键的独立 effect。它的 cleanup 只在阶段**离开** `syncing` 时才跑,也就是在它已经做完事之后,因此不存在自我取消。新加的账号名走 `addedAccountName` state(而不是 ref),这样第二次登录(再添一个账号)会正确地重新计时,而不是复用上一轮的闭包值。
44
+ - **两个出口都报成功,且刻意不做区分**:账号**确实**已经添加(Host 在答 `done` 之前就存好了凭据),所以诚实的说法是成功;超时只是不让屏幕无限期地声称相反。真正失败的 usage 路由仍由卡片自己以 `refreshError` 如实报告——那部分数据的拥有者是卡片,不是这里。
45
+ - **导入路径一并设上限**:`AccountTransfer` 的 `await onChanged?.()` 是同一形态、同一个遮罩,唯一的区别是它没有把 `phase` 放进任何 effect 依赖,所以原地加超时即可生效。两条路径共用同一个 `settlesWithin` 实现,正是为了不再各自漂移。**一个上限值 `5_000ms`**:远长于一次健康的回环重读,又远短于轮询节奏给人的预期。
46
+ - **守卫**:新增 `tests/issue51-sync-veil.spec.tsx`(5 例)。断言的是**可观察结果**而不是实现细节——重读仍在 pending 时遮罩必须已经落下、成功行必须已经出现;快路径不得因此被拖到超时;重读**抛错**按成功处理(它若逃逸进轮询的 `catch` 会重试轮询并再次报告同一个已添加的账号);只在超时窗口内推进时钟时遮罩必须仍在。
47
+ - **变异验证**:把 `src/client/OAuthSignIn.tsx` 改回原来的 `await onAdded?.()` 并删掉新 effect(即完全恢复旧逻辑),5 例中**3 例变红**;改回修复版 5 例全绿。这条记录有意写下来,因为**第一版修法本身是无效的**——只有经过这次还原才确认了守卫真的能抓住它。
48
+ - **测试基建注意**:`tests/pool-render-helpers.tsx` 的 `settle()` 用 `setTimeout` 排空异步工作,而假时钟会冻结它,所以本文件不能沿用 `settle()`(会一直挂到测试超时,表现为「找不到按钮」这类级联报错)。本文件改用 `act` + `advanceTimersByTimeAsync` 手动驱动时钟。另:登录轮询间隔是 `OAUTH_POLL_INTERVAL_MS = 2000`,**不是**卡片 usage 的一分钟——写作过程中一度按 60 秒推进时钟,等价于把整个流程跑完,因此这一处边界在测试里以注释标明。
49
+ - **未改动**:`window.open('')` 的占位窗口与「被拦则降级为弹窗内手动链接」这套行为一字未动。报告者说的「从来没有成功打开过浏览器」在该桌面外壳里 `window.open` 返回 `null`,走的是**已文档化的降级路径**,功能不损失(弹窗里始终有可复制的链接与「重新打开页面」按钮);把它改成别的行为属于另一个议题,不在本次范围内。
50
+
51
+ ## 4.4.0 (2026-10-12)
52
+
53
+ ### Features
54
+
55
+ - **「在对话框显示积分」开关移进「账号池」标题行,少占一行。**
56
+ - **改动**:该开关原本单独占一行(在 tab 栏下方),一个复选框换来一整行高度。现在它与「账号池」标题在同一行:标题在左,开关紧随其后,右侧仍是「当前账号」徽标。**账号池区块不会因此变高**。
57
+ - **没有采用「干脆删掉这行、只在别处留开关」的做法**:`AccountPool` 在宿主不提供 pool 状态时(旧宿主,或该路由缺字段)会 `return null`,把开关交给它会让**整个设置项随之消失**——一个设置控件不能因为另一个区块没渲染就不可达。因此有两条渲染点,**同一时刻只有一条生效**:有 pool 时进标题行,没有 pool 时回落到它自己那行(`dsm-workbuddy-credits-switch-row`,间距与移动前一致)。守卫按**祖先关系**断言(开关在 `.dsm-workbuddy-pool-head` 之内,且回落行不存在),而不是只数复选框个数——两条同时渲染也能通过「存在性」断言,却把省下的那一行又还了回去。
58
+ - **开关不因区域被关闭而隐藏**:区域关掉时它照旧渲染为**禁用**状态。藏起来会让用户看不到「为什么侧边栏没有那行」,而卡片自己的提示指向的是供应商复选框,不是一个凭空消失的控件。
59
+ - **文案随位置微调(不是为了好看)**:标签从「在对话框显示 WB CN 积分 / Show WB CN credits in the composer」缩到「对话框显示 WB CN 积分 / WB CN credits in composer」,省掉的是「在」与「Show」这类可以由上下文承接的词,**区域区分保留**——开关现在与「账号池」同排、不再紧贴 tab 栏,丢掉区域名会让 CN 与国际版两个开关在文案上无法区分。完整解释仍在 `title` 提示里,一字未减。
60
+ - **徽标改用 `margin-left:auto` 而非整行的 `space-between`**:三个子元素下 `space-between` 会把开关甩到中间而不是贴着标题;只有两个子元素(无开关)时 `margin-left:auto` 让徽标仍留在右边缘,与改动前一致。
61
+ - **联动清理**:`AccountPool` 新增 `headerExtra` 插槽(与既有 `accountTools` 同款「传现成节点」约定),开关的写入路径、禁用规则与「与卡片其他区域写入者串行」的逻辑**全部留在卡片里**——它们依赖设置 scope 与保存状态,搬进池区块等于把同一份写入与串行化逻辑再实现一遍。
62
+ - **守卫**:`tests/card-credits-switch.spec.tsx` 由 11 例增至 14 例,新增「开关在账号池标题行内且回落行不存在」「没有 pool 时回落到自己那行」「标签仍带区域名」三条。**变异验证**:把开关改回「永远单独成行」→ 第一条变红(`expected null not to be null`,即标题行里找不到开关)。
63
+
3
64
  ## 4.3.4 (2026-10-12)
4
65
 
5
66
  ### Fixes
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-DkdNZKKY.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-CBFAiYPg.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
@@ -668,6 +668,45 @@ window.__ModuleLoader__.load({
668
668
  //#endregion
669
669
  //#region src/client/BusyOverlay.tsx
670
670
  /**
671
+ * The shared busy veil's spinner: an arc that breathes while it turns.
672
+ *
673
+ * Deliberately the host's own state-spinner shape (dsh's `_dsh-state-dot-spin`)
674
+ * rather than a border-ring: a two-tone CSS ring at .7s read as a loading
675
+ * indicator from another decade and was out of step with every spinner in the
676
+ * shell. The motion lives in CSS (`dsm-workbuddy-veil-spin` /
677
+ * `-dash`), so this element only describes the geometry; `currentColor` lets it
678
+ * inherit whatever tier it sits in.
679
+ *
680
+ * `aria-hidden` because the veil's `role="status"` already announces the
681
+ * message — a second announcement from a decorative arc is noise.
682
+ */
683
+ function VeilSpinner() {
684
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
685
+ className: "dsm-workbuddy-veil-spinner",
686
+ viewBox: "0 0 20 20",
687
+ "aria-hidden": "true",
688
+ focusable: "false",
689
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
690
+ className: "dsm-workbuddy-veil-spinner-track",
691
+ cx: "10",
692
+ cy: "10",
693
+ r: "8",
694
+ fill: "none",
695
+ stroke: "currentColor",
696
+ strokeWidth: "2"
697
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
698
+ className: "dsm-workbuddy-veil-spinner-arc",
699
+ cx: "10",
700
+ cy: "10",
701
+ r: "8",
702
+ fill: "none",
703
+ stroke: "currentColor",
704
+ strokeWidth: "2",
705
+ strokeLinecap: "round"
706
+ })]
707
+ });
708
+ }
709
+ /**
671
710
  * The veil. Deliberately NOT a modal dialog: it carries no focusable control
672
711
  * and no dismissal — there is nothing for the user to decide, only a step to
673
712
  * wait through. `aria-busy` plus a polite live region state that to screen
@@ -683,10 +722,7 @@ window.__ModuleLoader__.load({
683
722
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
684
723
  className: "dsm-workbuddy-veil-card",
685
724
  children: [
686
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
687
- className: "dsm-workbuddy-veil-spinner",
688
- "aria-hidden": "true"
689
- }),
725
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VeilSpinner, {}),
690
726
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
691
727
  className: "dsm-workbuddy-veil-text",
692
728
  children: message
@@ -1177,7 +1213,7 @@ window.__ModuleLoader__.load({
1177
1213
  * section implying the feature exists.
1178
1214
  */
1179
1215
  function AccountPool(props) {
1180
- const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools } = props;
1216
+ const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools, headerExtra } = props;
1181
1217
  const [draft, setDraft] = (0, react.useState)(null);
1182
1218
  const [saving, setSaving] = (0, react.useState)(false);
1183
1219
  const [saveError, setSaveError] = (0, react.useState)(void 0);
@@ -1614,7 +1650,7 @@ window.__ModuleLoader__.load({
1614
1650
  visible: true,
1615
1651
  message: t("row.poolSaving"),
1616
1652
  hint: t("row.veilHintList")
1617
- }) : 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", {
1653
+ }) : 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", {
1618
1654
  className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
1619
1655
  value: currentAccount?.accountId ?? "",
1620
1656
  disabled: selectingAccount === true,
@@ -1893,7 +1929,53 @@ window.__ModuleLoader__.load({
1893
1929
  function elapsedSeconds(sinceMs, nowMs) {
1894
1930
  return Math.max(0, Math.floor((nowMs - sinceMs) / 1e3));
1895
1931
  }
1896
- /** Copy the sign-in URL to the clipboard; true on success. */
1932
+ /**
1933
+ * How long the `syncing` phase may hold the veil before it lets go (issue #51).
1934
+ *
1935
+ * Shorter than a poll interval would be pointless; a few seconds is far longer
1936
+ * than a healthy loopback re-read takes, and anything past that is
1937
+ * indistinguishable from "never".
1938
+ *
1939
+ * What sits below this decision is an asymmetry worth spelling out. The
1940
+ * `waiting` phase has its own local deadline (see the waiting-clock effect), so
1941
+ * a login whose `loginId` died still ends. The `syncing` phase reached the veil
1942
+ * and awaited the card's re-read with NEITHER a deadline NOR a `catch` — and the
1943
+ * veil's visibility is `phase.kind === 'syncing'` and nothing else. So a re-read
1944
+ * that never settled held the veil up forever, while the credential was already
1945
+ * in the vault: reopening the card (or force-quitting DSH) showed the account,
1946
+ * which is exactly the "half usable" report — sign-in succeeded, the screen
1947
+ * never said so.
1948
+ */
1949
+ const SYNC_SETTLE_TIMEOUT_MS = 5e3;
1950
+ /**
1951
+ * Await `work` but give up after `timeoutMs`; resolves `false` on timeout.
1952
+ *
1953
+ * The timer is cleared in a `finally`, so the common case (the re-read settles
1954
+ * first) leaves no pending timer holding the event loop — the reason this is a
1955
+ * race with an explicit cleanup rather than `Promise.race` alone.
1956
+ *
1957
+ * The timeout arm never rejects: a re-read that FAILS is not a failed sign-in,
1958
+ * and the caller must not be able to confuse the two. Rejection and timeout
1959
+ * stand for the same thing here — "the list did not prove it refreshed" — so
1960
+ * they take the same path.
1961
+ *
1962
+ * EXPORTED because `AccountTransfer`'s veil awaits the same card re-read and had
1963
+ * the same unbounded wait; sharing one implementation is what stops the two
1964
+ * veils from drifting apart again.
1965
+ */
1966
+ async function settlesWithin(work, timeoutMs) {
1967
+ let timer;
1968
+ try {
1969
+ return await Promise.race([Promise.resolve(work).then(() => true, () => false), new Promise((resolve) => {
1970
+ timer = setTimeout(() => resolve(false), timeoutMs);
1971
+ })]);
1972
+ } finally {
1973
+ if (timer !== void 0) clearTimeout(timer);
1974
+ }
1975
+ }
1976
+ /**
1977
+ * Copy the sign-in URL to the clipboard; true on success.
1978
+ */
1897
1979
  async function copyUrl(url) {
1898
1980
  try {
1899
1981
  await navigator.clipboard.writeText(url);
@@ -1913,6 +1995,17 @@ window.__ModuleLoader__.load({
1913
1995
  const [copied, setCopied] = (0, react.useState)(false);
1914
1996
  const [pollTick, setPollTick] = (0, react.useState)(0);
1915
1997
  const [nowMs, setNowMs] = (0, react.useState)(() => Date.now());
1998
+ /**
1999
+ * The account the Host just added, held for the sync effect.
2000
+ *
2001
+ * The poll that answers `done` names the account; the effect that ends the
2002
+ * `syncing` phase needs that name to write the success line, and it cannot
2003
+ * read it off the phase because the phase is what it is about to replace.
2004
+ * State rather than a ref: the effect is keyed on it, so a second sign-in
2005
+ * (adding a second account) re-arms the deadline properly instead of reusing
2006
+ * a stale closure value.
2007
+ */
2008
+ const [addedAccountName, setAddedAccountName] = (0, react.useState)("");
1916
2009
  const mounted = (0, react.useRef)(true);
1917
2010
  (0, react.useEffect)(() => {
1918
2011
  mounted.current = true;
@@ -1983,13 +2076,7 @@ window.__ModuleLoader__.load({
1983
2076
  kind: "syncing",
1984
2077
  accountName
1985
2078
  });
1986
- await onAdded?.();
1987
- if (cancelled || !mounted.current) return;
1988
- setPhase({
1989
- kind: "finished",
1990
- message: "added",
1991
- accountName
1992
- });
2079
+ setAddedAccountName(accountName);
1993
2080
  }
1994
2081
  } catch {
1995
2082
  setPollTick((tick) => tick + 1);
@@ -2007,6 +2094,69 @@ window.__ModuleLoader__.load({
2007
2094
  region
2008
2095
  ]);
2009
2096
  /**
2097
+ * End the `syncing` phase — on the re-read, or on the deadline, whichever
2098
+ * comes first (issue #51).
2099
+ *
2100
+ * ## Why this is a separate effect and not an `await` in the poll
2101
+ *
2102
+ * The first attempt at this fix put the race inside the poll's `done` branch:
2103
+ *
2104
+ * setPhase({ kind: 'syncing', accountName })
2105
+ * await settlesWithin(onAdded?.(), SYNC_SETTLE_TIMEOUT_MS)
2106
+ * if (cancelled || !mounted.current) return
2107
+ * setPhase({ kind: 'finished', … })
2108
+ *
2109
+ * and it did not work — measured, not assumed. `phase` is a dependency of the
2110
+ * poll effect, so `setPhase({kind:'syncing'})` re-runs that effect, and React
2111
+ * runs the PREVIOUS cleanup first. That cleanup sets the old closure's
2112
+ * `cancelled = true`. React commits the cleanup before the `await` it is
2113
+ * inside ever resumes, so the resume always sees `cancelled === true` and
2114
+ * returns: the veil stayed up exactly as it had before.
2115
+ *
2116
+ * This also explains why the defect was invisible in review. A FAST re-read
2117
+ * resolves inside the same microtask batch, before React commits the cleanup,
2118
+ * so it passes the guard and the success line appears. Only a re-read slow
2119
+ * enough to cross a React commit — i.e. the reported case — loses the race
2120
+ * against its own cleanup. The bug hid behind its own common path.
2121
+ *
2122
+ * Running the wait in an effect keyed on the phase sidesteps the whole
2123
+ * interaction: this effect's own cleanup runs when the phase LEAVES
2124
+ * `syncing`, which is after it has already done its job, so there is no
2125
+ * self-cancellation to lose.
2126
+ *
2127
+ * ## The two outcomes
2128
+ *
2129
+ * Both end in `finished`/`added` and the difference is deliberately not shown
2130
+ * to the user. The account IS added — the Host stored the credential before it
2131
+ * answered the poll — so the honest report is success either way; the deadline
2132
+ * only stops the screen from claiming otherwise indefinitely. (`refreshUsage`
2133
+ * already surfaces a failed re-read as `refreshError` on the card itself, so
2134
+ * a genuinely broken usage route is still reported, by the component that
2135
+ * owns that data.)
2136
+ */
2137
+ (0, react.useEffect)(() => {
2138
+ if (phase.kind !== "syncing") return;
2139
+ let cancelled = false;
2140
+ const finish = () => {
2141
+ if (cancelled || !mounted.current) return;
2142
+ setPhase({
2143
+ kind: "finished",
2144
+ message: "added",
2145
+ accountName: addedAccountName
2146
+ });
2147
+ };
2148
+ settlesWithin(Promise.resolve(onAdded?.()), SYNC_SETTLE_TIMEOUT_MS).then(finish);
2149
+ const timer = setTimeout(finish, SYNC_SETTLE_TIMEOUT_MS);
2150
+ return () => {
2151
+ cancelled = true;
2152
+ clearTimeout(timer);
2153
+ };
2154
+ }, [
2155
+ addedAccountName,
2156
+ onAdded,
2157
+ phase
2158
+ ]);
2159
+ /**
2010
2160
  * Open a placeholder window WITHOUT `noopener`, and deliberately so.
2011
2161
  *
2012
2162
  * `noopener` is the correct feature for a link whose destination never needs
@@ -2227,6 +2377,17 @@ window.__ModuleLoader__.load({
2227
2377
  *
2228
2378
  * @module dsh-connect-workbuddy/client/AccountTransfer
2229
2379
  */
2380
+ /**
2381
+ * How long the veil may wait on the card's re-read before it comes down.
2382
+ *
2383
+ * Same ceiling and same reasoning as the sign-in path's
2384
+ * `SYNC_SETTLE_TIMEOUT_MS` (issue #51): the veil is the only feedback this
2385
+ * operation gives, so its exit condition must not be a promise that can stay
2386
+ * pending. The `refreshing` phase has no other way out — no deadline, no
2387
+ * failure branch — so an unsettled re-read left the operator staring at
2388
+ * 「正在导入…」 over an import that had already succeeded.
2389
+ */
2390
+ const REFRESH_SETTLE_TIMEOUT_MS = 5e3;
2230
2391
  /** Build the default export file name: workbuddy-accounts-YYYY-MM-DD.json. */
2231
2392
  function exportFileName() {
2232
2393
  const pad = (value) => String(value).padStart(2, "0");
@@ -2385,7 +2546,7 @@ window.__ModuleLoader__.load({
2385
2546
  kind: "refreshing",
2386
2547
  message
2387
2548
  });
2388
- await onChanged?.();
2549
+ await settlesWithin(Promise.resolve(onChanged?.()), REFRESH_SETTLE_TIMEOUT_MS);
2389
2550
  if (!mounted.current) return;
2390
2551
  setPhase({
2391
2552
  kind: "done",
@@ -2665,10 +2826,17 @@ window.__ModuleLoader__.load({
2665
2826
  .dsm-workbuddy-tab-switch{display:inline-flex;align-items:center;flex:none;padding:0 8px 0 2px;cursor:pointer}
2666
2827
  .dsm-workbuddy-tab-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
2667
2828
  .dsm-workbuddy-tab-switch input:disabled{opacity:.4;cursor:default}
2668
- /* One sidebar-credit switch, belonging to the ACTIVE tab's region. It sits on
2669
- its own line so it reads as a property of this tab rather than as a shared
2670
- pair of settings — the tab bar already says which region is in scope. */
2671
- .dsm-workbuddy-credits-switch{display:flex;align-items:center;gap:6px;margin:8px 0 2px;cursor:pointer;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
2829
+ /* One sidebar-credit switch, belonging to the ACTIVE tab's region. It rides in
2830
+ the account-pool heading row (dsm-workbuddy-pool-head-extra) so one
2831
+ checkbox no longer costs a full line of vertical space; the tab bar already
2832
+ says which region is in scope, so it still reads as this tab's property
2833
+ rather than as a shared pair of settings. No vertical margin: the heading row
2834
+ owns the spacing. */
2835
+ .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)}
2836
+ /* The standalone fallback site: used only when the account-pool section did not
2837
+ render (no Host pool state), so there is no heading row to share. This is the
2838
+ spacing the control carried before it moved beside that heading. */
2839
+ .dsm-workbuddy-credits-switch-row{display:flex;align-items:center;margin:8px 0 2px}
2672
2840
  .dsm-workbuddy-credits-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
2673
2841
  .dsm-workbuddy-credits-switch input:disabled{opacity:.4;cursor:default}
2674
2842
  .dsm-workbuddy-tab-off{opacity:.55}
@@ -2754,8 +2922,15 @@ window.__ModuleLoader__.load({
2754
2922
 
2755
2923
  /* ---- Account pool ---- */
2756
2924
  .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)}
2757
- .dsm-workbuddy-pool-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
2758
- .dsm-workbuddy-pool-current-badge{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}
2925
+ /* Title, then the inline extra (the sidebar-credit switch), then the
2926
+ serving-account badge on the far edge. The badge is pushed right with
2927
+ margin-left:auto rather than the row using space-between: with three
2928
+ children the latter would strand the switch in the middle instead of binding
2929
+ it to the title, and with only two (no switch) margin-left:auto keeps the
2930
+ badge on the right edge exactly as before. */
2931
+ .dsm-workbuddy-pool-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
2932
+ .dsm-workbuddy-pool-head-extra{display:inline-flex;align-items:center}
2933
+ .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}
2759
2934
  /* Sits under the header block, before the batch buttons: account discovery is
2760
2935
  the first step of using the pool, so it reads above the actions it enables. */
2761
2936
  /* Manual-mode account picker: shares the settings row layout (.pool-set), and
@@ -2916,17 +3091,48 @@ window.__ModuleLoader__.load({
2916
3091
  "here is why nothing moved yet". Fixed positioning so it covers the card
2917
3092
  regardless of which section started the operation, and a semi-transparent
2918
3093
  backdrop so the table stays visible underneath — the user is watching a
2919
- specific row change, and hiding it would defeat the point. */
2920
- .dsm-workbuddy-veil{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);cursor:progress}
2921
- .dsm-workbuddy-veil-card{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:min(340px,80vw);padding:16px 20px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;background:var(--dsw-alias-bg-primary,#1b1c20);box-shadow:0 8px 28px rgba(0,0,0,.35)}
2922
- .dsm-workbuddy-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#e8e9ed);text-align:center}
2923
- .dsm-workbuddy-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999);text-align:center}
2924
- /* The spinner is the one thing that proves the UI is alive when the row under
2925
- it has not changed yet — which is precisely the confusion the veil exists to
2926
- remove. Reduced-motion users get the static ring instead of a rotation. */
2927
- .dsm-workbuddy-veil-spinner{width:18px;height:18px;border:2px solid var(--dsw-alias-border-l2,#3a3d45);border-top-color:var(--dsw-alias-brand-primary,#5686fe);border-radius:50%;animation:dsm-workbuddy-veil-spin .7s linear infinite}
3094
+ specific row change, and hiding it would defeat the point.
3095
+
3096
+ The mask and the card surface are the HOST's own tokens, not this file's
3097
+ invention. --dsw-alias-bg-mask-1 + --dsw-mask-blur is exactly what the
3098
+ host's own overlays use (see dsh-client-ui-settings-general's Dws9Sa_mask),
3099
+ so the veil now dims the way every other overlay in the shell does instead
3100
+ of laying its own black wash on top. The previous hardcoded rgba(0,0,0,.38)
3101
+ was tuned against neither theme: too weak next to the host's .24 mask in
3102
+ light mode, and it ignored the host's blur entirely.
3103
+
3104
+ bg-layer-2 (not bg-primary, which does not exist in the host's token set
3105
+ at all and therefore always fell through to this file's near-black #1b1c20)
3106
+ is the same surface the host puts behind its panels — that near-black fallback
3107
+ is what made the veil read as a black slab with unreadable text on it. */
3108
+ .dsm-workbuddy-veil{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.32));backdrop-filter:var(--dsw-mask-blur);cursor:progress}
3109
+ /* --dsw-elevation-prominent is the host's own panel shadow (a hairline stroke
3110
+ plus two soft layers); the previous 0 8px 28px rgba(0,0,0,.35) was a
3111
+ hand-rolled drop shadow that read as a heavy grey box. */
3112
+ .dsm-workbuddy-veil-card{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:min(340px,80vw);padding:16px 20px;border:1px solid var(--dsw-alias-border-l2,#0000001a);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:var(--dsw-elevation-prominent,0 8px 28px rgba(0,0,0,.35))}
3113
+ .dsm-workbuddy-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#0f1115);text-align:center}
3114
+ /* label-secondary, not label-tertiary: this line carries the operation's
3115
+ detail and must stay legible. tertiary is the host's dimmest tier
3116
+ (neutral-bluish-600 on light, neutral-bluish-400 on dark) and is meant
3117
+ for metadata, not for a sentence the user has to read while waiting. */
3118
+ .dsm-workbuddy-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);text-align:center}
3119
+ /* The spinner proves the UI is alive when the row under it has not changed yet
3120
+ — which is precisely the confusion the veil exists to remove. It follows the
3121
+ host's own state spinner (dsh's _dsh-state-dot-spin): an SVG arc whose
3122
+ dash breathes while it turns, currentColor so it inherits the tier it sits
3123
+ in, and the same 1.5s beat. The previous 18px border-ring at .7s was roughly
3124
+ twice the host's speed and visibly out of step with every other spinner in
3125
+ the shell — that mismatch, more than the shape itself, is what made it look
3126
+ cheap.
3127
+
3128
+ Reduced-motion users get the static arc rather than a frozen ring, matching
3129
+ how the host degrades its own spinner. */
3130
+ .dsm-workbuddy-veil-spinner{width:20px;height:20px;color:var(--dsw-alias-label-secondary,#61666b);animation:dsm-workbuddy-veil-spin 1.5s linear infinite}
3131
+ .dsm-workbuddy-veil-spinner-track{opacity:.25}
3132
+ .dsm-workbuddy-veil-spinner-arc{stroke-dasharray:12 150;animation:dsm-workbuddy-veil-dash 1.5s ease-in-out infinite}
2928
3133
  @keyframes dsm-workbuddy-veil-spin{to{transform:rotate(360deg)}}
2929
- @media (prefers-reduced-motion: reduce){.dsm-workbuddy-veil-spinner{animation:none}}
3134
+ @keyframes dsm-workbuddy-veil-dash{0%{stroke-dasharray:12 150;stroke-dashoffset:0}50%{stroke-dasharray:24 150;stroke-dashoffset:-6}to{stroke-dasharray:12 150;stroke-dashoffset:0}}
3135
+ @media (prefers-reduced-motion: reduce){.dsm-workbuddy-veil-spinner{animation:none}.dsm-workbuddy-veil-spinner-arc{animation:none;stroke-dasharray:18 150;stroke-dashoffset:-3}}
2930
3136
  `;
2931
3137
  /**
2932
3138
  * Composer credit readout styles, injected by the readout component itself.
@@ -3979,6 +4185,39 @@ window.__ModuleLoader__.load({
3979
4185
  accountName: account.accountName
3980
4186
  }))
3981
4187
  })] });
4188
+ /**
4189
+ * Sidebar credit line switch — ONE control, for the ACTIVE tab's region only.
4190
+ *
4191
+ * The two regions are independent providers with independent accounts, so
4192
+ * each tab owns its own switch and they are never shown as a pair: this one
4193
+ * writes `regions.<activeRegion>.showCreditsInMainUi`, which decides only
4194
+ * whether THAT region's line appears in the sidebar. That matches how every
4195
+ * other control in this card is scoped to the selected tab (account, pool,
4196
+ * model list), and it means a CN-only user is never shown a switch for an
4197
+ * account they do not have. An earlier revision rendered both boxes side by
4198
+ * side under the tab bar regardless of the active tab; that read as one
4199
+ * shared setting and was replaced.
4200
+ *
4201
+ * RENDERED BESIDE THE POOL HEADING, not on its own row. It used to occupy a
4202
+ * full line of vertical space for a single checkbox; the pool heading already
4203
+ * had a title-left / badge-right row, so the control fits there without the
4204
+ * block growing. It is handed to `AccountPool` as a ready-made node purely so
4205
+ * the two can share a flex row — the write path, the disabled rules and the
4206
+ * serialization against the card's other region writers all stay here, where
4207
+ * the settings scope and the save state live.
4208
+ */
4209
+ const creditsSwitch = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4210
+ className: "dsm-workbuddy-credits-switch",
4211
+ title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
4212
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4213
+ type: "checkbox",
4214
+ checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
4215
+ disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4216
+ onChange: (event) => {
4217
+ toggleCredits(activeRegion, event.target.checked);
4218
+ }
4219
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal") })]
4220
+ });
3982
4221
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
3983
4222
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
3984
4223
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -4063,18 +4302,6 @@ window.__ModuleLoader__.load({
4063
4302
  className: "dsm-workbuddy-tab-off-notice",
4064
4303
  children: t("row.tabOffNotice")
4065
4304
  }) : null,
4066
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4067
- className: "dsm-workbuddy-credits-switch",
4068
- title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
4069
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4070
- type: "checkbox",
4071
- checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
4072
- disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4073
- onChange: (event) => {
4074
- toggleCredits(activeRegion, event.target.checked);
4075
- }
4076
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal") })]
4077
- }),
4078
4305
  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", {
4079
4306
  className: "dsm-workbuddy-usage-hint",
4080
4307
  children: t("row.reloginHint")
@@ -4131,11 +4358,16 @@ window.__ModuleLoader__.load({
4131
4358
  },
4132
4359
  selectingAccount: switchingAccount,
4133
4360
  accountTools,
4361
+ ...status.pool === void 0 ? {} : { headerExtra: creditsSwitch },
4134
4362
  siblingBusy: saving || togglingRegion !== void 0,
4135
4363
  onBusyChange: setPoolBusy,
4136
4364
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
4137
4365
  onRefresh: () => refreshUsage(activeRegion)
4138
4366
  }),
4367
+ status.pool === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4368
+ className: "dsm-workbuddy-credits-switch-row",
4369
+ children: creditsSwitch
4370
+ }) : null,
4139
4371
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
4140
4372
  className: "dsm-workbuddy-models",
4141
4373
  "aria-label": t("row.modelsTitle"),
@@ -5234,8 +5466,8 @@ window.__ModuleLoader__.load({
5234
5466
  "row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
5235
5467
  "row.poolRowRemoving": "Removing the account…",
5236
5468
  "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.",
5237
- "row.showCreditsCn": "Show WB CN credits in the composer",
5238
- "row.showCreditsGlobal": "Show WB AI credits in the composer",
5469
+ "row.showCreditsCn": "WB CN credits in composer",
5470
+ "row.showCreditsGlobal": "WB AI credits in composer",
5239
5471
  "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).",
5240
5472
  "row.showCreditsGlobalHint": "Show the international (WB AI) credit balance in the composer toolbar, but only while this session uses a WB AI model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
5241
5473
  "composer.pointsCn": "WB CN",
@@ -5494,8 +5726,8 @@ window.__ModuleLoader__.load({
5494
5726
  "row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
5495
5727
  "row.poolRowRemoving": "正在删除账号…",
5496
5728
  "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
5497
- "row.showCreditsCn": "在对话框显示 WB CN 积分",
5498
- "row.showCreditsGlobal": "在对话框显示 WB AI 积分",
5729
+ "row.showCreditsCn": "对话框显示 WB CN 积分",
5730
+ "row.showCreditsGlobal": "对话框显示 WB AI 积分",
5499
5731
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
5500
5732
  "row.showCreditsGlobalHint": "在对话输入框工具栏显示国际版(WB AI)剩余积分,仅当本会话使用 WB AI 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
5501
5733
  "composer.pointsCn": "WB CN",
@@ -3611,9 +3611,10 @@ var WorkBuddyCatalog = class {
3611
3611
  * pi-ai into the standalone `doctor` CLI, which must also run from market
3612
3612
  * installs where NO local pi-ai exists and the module is only provided inside
3613
3613
  * the DSH host — a static import there is a load-time crash. Resolution
3614
- * itself does not need the module: within the peer range, the manifest
3615
- * version and the `normalizeContext` feature (present from 0.87 on, not
3616
- * before) agree.
3614
+ * itself does not need the module: the manifest version and the
3615
+ * `normalizeContext` feature (present from 0.87 on, not before) agree across
3616
+ * `0.87+` AND `1.x` — 1.x is 0.87's successor and keeps the same normalized
3617
+ * transcript, so the split is `>= 0.87` rather than "within 0.x".
3617
3618
  *
3618
3619
  * When resolution FAILS — the normal state of a market install probed outside
3619
3620
  * the host — there is no local copy, so inside DSH the host supplies pi-ai.
@@ -3695,13 +3696,36 @@ function readVersion(moduleFile) {
3695
3696
  }
3696
3697
  }
3697
3698
  /**
3698
- * The generation a manifest version belongs to. Within the peer range
3699
- * (<0.88.0) the split is exactly `0.87+` versus earlier; an unreadable
3700
- * version conservatively reports `legacy` (and doctor shows it as
3701
- * unreadable, so the guess is visible rather than silent).
3699
+ * The generation a manifest version belongs to.
3700
+ *
3701
+ * `0.87+` consumes normalized transcripts natively; earlier releases want the
3702
+ * 0.85 shape, so within the declared peer range the split is exactly `0.87+`
3703
+ * versus earlier. An unreadable version conservatively reports `legacy` (and
3704
+ * doctor shows it as unreadable, so the guess is visible rather than silent).
3705
+ *
3706
+ * The 1.x line MUST land on `modern`, and it is not a hypothetical: DSH
3707
+ * 0.2.1-alpha.2 ships `@earendil-works/pi-ai@1.0.2`, while npm currently
3708
+ * publishes 1.1.0. Both 0.87.1 and 1.0.2/1.1.0 read tools from the same place
3709
+ * (`context.messages` → the leading system message's `toolsAdded`; see
3710
+ * pi-ai's `resolveTranscriptTools`). Matching only `0.<minor>.` classified
3711
+ * 1.0.2 as `legacy`, so `withLegacyContext` folded the transcript into the
3712
+ * 0.85 shape — which moves the tool declarations to a top-level `tools` field
3713
+ * pi-ai 1.x never reads. Every request then went upstream with NO tools
3714
+ * declared: no model could emit a `toolCall`, and the domestic gateway, which
3715
+ * tolerates a prompt-less body, had the model write DSML markup into the answer
3716
+ * text instead. The host's compatibility gate does not catch this: it only
3717
+ * evaluates peers in the `@deepseek-ai/dsh*` namespace, so an out-of-range
3718
+ * `@earendil-works/pi-ai` peer is never enforced.
3719
+ *
3720
+ * Deliberately match major 1 only. This establishes compatibility for the
3721
+ * observed 1.x line (including current 1.1.0) without assuming an uninspected
3722
+ * future major such as 2.x keeps the same transcript contract. The declared
3723
+ * peer range remains the plugin's supported-version boundary.
3702
3724
  */
3703
3725
  function generationOf(version) {
3704
3726
  if (version === void 0) return "legacy";
3727
+ const major = /^(\d+)\./u.exec(version);
3728
+ if (major !== null && Number(major[1]) === 1) return "modern";
3705
3729
  const match = /^0\.(\d+)\./u.exec(version);
3706
3730
  if (match === null) return "legacy";
3707
3731
  return Number(match[1]) >= 87 ? "modern" : "legacy";
@@ -3709,7 +3733,7 @@ function generationOf(version) {
3709
3733
  //#endregion
3710
3734
  //#region src/version.ts
3711
3735
  /** The npm package version this build was produced from. */
3712
- const WORKBUDDY_CONNECT_VERSION = "4.3.4";
3736
+ const WORKBUDDY_CONNECT_VERSION = "4.4.2";
3713
3737
  //#endregion
3714
3738
  //#region src/host-heartbeat.ts
3715
3739
  /**
package/lib/index.d.ts CHANGED
@@ -1112,9 +1112,10 @@ declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddySh
1112
1112
  * pi-ai into the standalone `doctor` CLI, which must also run from market
1113
1113
  * installs where NO local pi-ai exists and the module is only provided inside
1114
1114
  * the DSH host — a static import there is a load-time crash. Resolution
1115
- * itself does not need the module: within the peer range, the manifest
1116
- * version and the `normalizeContext` feature (present from 0.87 on, not
1117
- * before) agree.
1115
+ * itself does not need the module: the manifest version and the
1116
+ * `normalizeContext` feature (present from 0.87 on, not before) agree across
1117
+ * `0.87+` AND `1.x` — 1.x is 0.87's successor and keeps the same normalized
1118
+ * transcript, so the split is `>= 0.87` rather than "within 0.x".
1118
1119
  *
1119
1120
  * When resolution FAILS — the normal state of a market install probed outside
1120
1121
  * the host — there is no local copy, so inside DSH the host supplies pi-ai.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, 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-DkdNZKKY.js";
1
+ import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, 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-CBFAiYPg.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1308,6 +1308,15 @@ function readBody(req) {
1308
1308
  * Once a native `delta.tool_calls` appears, recovery switches off for the
1309
1309
  * rest of the response — two live call channels for one answer is worse
1310
1310
  * than either one alone.
1311
+ *
1312
+ * 5. EVERY FRAME LEAVES SELF-CONTAINED. An SSE event ends at a blank line,
1313
+ * and a frame written without its terminator merges into the NEXT event
1314
+ * the client decodes — whose `data:` lines are then joined and parsed as
1315
+ * one document (issue #53). Every write therefore goes through
1316
+ * {@link emitFrame}, which completes the terminator; `push()` cuts frames
1317
+ * that already carry it, and the one source of unterminated bytes — the
1318
+ * half-frame left in `carry` when the upstream dies — is normalized in
1319
+ * {@link consume}.
1311
1320
  */
1312
1321
  var RecoveryStream = class {
1313
1322
  res;
@@ -1382,7 +1391,7 @@ var RecoveryStream = class {
1382
1391
  finish_reason: ""
1383
1392
  };
1384
1393
  this.windowOpened = true;
1385
- this.res.write(contentFrame(chunk, choiceRecord, notice));
1394
+ this.emitFrame(contentFrame(chunk, choiceRecord, notice));
1386
1395
  }
1387
1396
  /** True when a call or real prose has reached the client. */
1388
1397
  get delivered() {
@@ -1392,6 +1401,43 @@ var RecoveryStream = class {
1392
1401
  get residueSeen() {
1393
1402
  return this.sawResidue;
1394
1403
  }
1404
+ /**
1405
+ * The single exit every byte of a FRAME passes through.
1406
+ *
1407
+ * SSE dispatches one event per blank-line-terminated run of `data:` lines.
1408
+ * A frame written WITHOUT its terminator leaves the client's decoder
1409
+ * waiting for a blank line that never arrives, so the NEXT write is
1410
+ * appended to the SAME event — and a client that joins the several `data:`
1411
+ * lines of one event with `\n` and parses the result as one JSON document
1412
+ * (openai's `SSEDecoder` does exactly that) then reads two payloads as one
1413
+ * and fails. Issue #53 is that failure: the upstream died between a `data:`
1414
+ * line and its blank line, `consume()` forwarded the half-frame verbatim,
1415
+ * and `finish()` glued its synthetic `[DONE]` onto it, producing
1416
+ * `data: {...}\ndata: [DONE]\n\n` — parsed as one event whose data is
1417
+ * `"{...}\n[DONE]"`, which throws "Unexpected non-whitespace character
1418
+ * after JSON at position N". The whole turn is then classified as a
1419
+ * non-retryable error, so the user simply sees the answer stop.
1420
+ *
1421
+ * Completing the terminator here fixes both halves at once: the truncated
1422
+ * frame becomes a self-contained event, and the `[DONE]` that follows can no
1423
+ * longer be joined to it. It is idempotent for the frames `push()` cuts —
1424
+ * those always carry the terminator already, so they pass through
1425
+ * byte-for-byte, which keeps guarantee 1 (frame order is never changed)
1426
+ * intact.
1427
+ *
1428
+ * Note that a missing NEWLINE is also completed: `data: {…}` cut mid-line
1429
+ * needs the full `\n\n`, not a single `\n`, to close the event. Such a frame
1430
+ * is upstream-corrupted JSON that cannot be repaired here; closing it at
1431
+ * least keeps the corruption confined to its own event instead of taking
1432
+ * the terminator down with it.
1433
+ */
1434
+ emitFrame(frame) {
1435
+ if (frame.endsWith("\n\n")) {
1436
+ this.res.write(frame);
1437
+ return;
1438
+ }
1439
+ this.res.write(frame.endsWith("\n") ? `${frame}\n` : `${frame}\n\n`);
1440
+ }
1395
1441
  /** Record that text the gates refused was seen (markup residue, not prose). */
1396
1442
  noteResidue(seen) {
1397
1443
  this.sawResidue ||= seen;
@@ -1407,7 +1453,10 @@ var RecoveryStream = class {
1407
1453
  }
1408
1454
  const tail = this.carry;
1409
1455
  this.carry = "";
1410
- if (tail !== "") this.handleFrame(tail);
1456
+ if (tail !== "") {
1457
+ const lineTerminated = tail.endsWith("\n");
1458
+ this.handleFrame(lineTerminated ? `${tail}\n` : `${tail}\n\n`);
1459
+ }
1411
1460
  }
1412
1461
  /**
1413
1462
  * Close the response: emit the block still unfinished at end-of-stream, then
@@ -1420,7 +1469,7 @@ var RecoveryStream = class {
1420
1469
  this.emitContent(flushed.text);
1421
1470
  }
1422
1471
  this.closeWindow();
1423
- if (!this.sawDone && this.res.writable) this.res.write("data: [DONE]\n\n");
1472
+ if (!this.sawDone && this.res.writable) this.emitFrame("data: [DONE]\n\n");
1424
1473
  if (this.res.writable) this.res.end();
1425
1474
  }
1426
1475
  /** Drop held bytes without writing them (used when a retry replaced them). */
@@ -1498,7 +1547,7 @@ var RecoveryStream = class {
1498
1547
  this.windowOpened = true;
1499
1548
  this.flushNotice();
1500
1549
  this.closeWindow();
1501
- this.res.write(frame);
1550
+ this.emitFrame(frame);
1502
1551
  this.wroteContent = true;
1503
1552
  return;
1504
1553
  }
@@ -1509,7 +1558,7 @@ var RecoveryStream = class {
1509
1558
  this.send(callsFrame(chunk, choiceRecord, outcome.calls), false);
1510
1559
  this.wroteContent = true;
1511
1560
  this.closeWindow();
1512
- if (outcome.text !== "") this.res.write(contentFrame(chunk, choiceRecord, outcome.text));
1561
+ if (outcome.text !== "") this.emitFrame(contentFrame(chunk, choiceRecord, outcome.text));
1513
1562
  return;
1514
1563
  }
1515
1564
  this.noteResidue(!outcome.prose && outcome.text !== "");
@@ -1573,7 +1622,7 @@ var RecoveryStream = class {
1573
1622
  /** Queue a frame while the window is open, or write it once it has closed. */
1574
1623
  send(frame, final) {
1575
1624
  if (this.windowClosed || !this.windowOpened) {
1576
- this.res.write(frame);
1625
+ this.emitFrame(frame);
1577
1626
  return;
1578
1627
  }
1579
1628
  const entry = {
@@ -1597,7 +1646,7 @@ var RecoveryStream = class {
1597
1646
  this.windowClosed = true;
1598
1647
  const pending = this.held;
1599
1648
  this.held = [];
1600
- for (const entry of pending) this.res.write(entry.frame);
1649
+ for (const entry of pending) this.emitFrame(entry.frame);
1601
1650
  }
1602
1651
  };
1603
1652
  /** The SSE `data:` payload of one frame, or nothing when it carries none. */
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.3.4",
5
+ "version": "4.4.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",