dsh-connect-workbuddy 4.3.3 → 4.4.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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.4.1 (2026-10-12)
4
+
5
+ ### Fixes
6
+
7
+ - **登录成功后「正在添加账号…」的遮罩不再无限转圈;导入的遮罩同样有上限。**(issue #51)
8
+ - **现象**:报告者描述「登录成功后这里会转很久,等一分钟也没等完,每次都是直接退出 DSH 重新打开」,截图里账号表**已经有三条账号、其中一条正在服务**,而遮罩仍停在「正在添加账号…|完成后列表会立即刷新。」。这两个事实合起来就是全部分析:凭据在 Host 答 `done` **之前**就已写入,所以表格有数据说明**登录本身是成功的**,卡住的是它的下一步。
9
+ - **根因**:`OAuthSignIn` 的 `done` 分支里,`setPhase({ kind: 'syncing' })` 之后跟着一句 `await onAdded?.()`,而遮罩的显示条件正是 `phase.kind === 'syncing'`。也就是说**遮罩唯一的退出条件是一个 promise 落地**——既没有超时,也没有 `catch`。重读(卡片那次同源 usage 请求)只要永不 settle,`phase` 就永远停在 `syncing`。上一段的 `waiting` 阶段**有**自己的本地超时,`syncing` 两者都没有,这是本缺陷的形状。
10
+ - **为什么代码评审看不见**:这是本次修复中最值得记下的一点。`phase` 是轮询 effect 的依赖项,`setPhase({ kind: 'syncing' })` 会让该 effect 重跑,React **先执行上一次的 cleanup**,而 cleanup 会把旧闭包的 `cancelled` 置为 `true`。React 在 `await` 恢复之前就提交了这次 cleanup,所以**恢复后总会看到 `cancelled === true` 并直接 return**。于是——**只有当重读快到不与 React 提交相交时**(即最快的那条路径),它才在 cleanup 之前落地、通过守卫并显示成功行。**慢到跨过一次 React 提交的顺序(也就是报告者遇到的那种)反而必然失败。缺陷藏在它自己的常见路径背后**,这也是第一版「把竞态写在原处」的修法**实测无效**的原因(不是推断,是跑出来的)。
11
+ - **修法**:把这个等待搬进一个以 `phase` 为键的独立 effect。它的 cleanup 只在阶段**离开** `syncing` 时才跑,也就是在它已经做完事之后,因此不存在自我取消。新加的账号名走 `addedAccountName` state(而不是 ref),这样第二次登录(再添一个账号)会正确地重新计时,而不是复用上一轮的闭包值。
12
+ - **两个出口都报成功,且刻意不做区分**:账号**确实**已经添加(Host 在答 `done` 之前就存好了凭据),所以诚实的说法是成功;超时只是不让屏幕无限期地声称相反。真正失败的 usage 路由仍由卡片自己以 `refreshError` 如实报告——那部分数据的拥有者是卡片,不是这里。
13
+ - **导入路径一并设上限**:`AccountTransfer` 的 `await onChanged?.()` 是同一形态、同一个遮罩,唯一的区别是它没有把 `phase` 放进任何 effect 依赖,所以原地加超时即可生效。两条路径共用同一个 `settlesWithin` 实现,正是为了不再各自漂移。**一个上限值 `5_000ms`**:远长于一次健康的回环重读,又远短于轮询节奏给人的预期。
14
+ - **守卫**:新增 `tests/issue51-sync-veil.spec.tsx`(5 例)。断言的是**可观察结果**而不是实现细节——重读仍在 pending 时遮罩必须已经落下、成功行必须已经出现;快路径不得因此被拖到超时;重读**抛错**按成功处理(它若逃逸进轮询的 `catch` 会重试轮询并再次报告同一个已添加的账号);只在超时窗口内推进时钟时遮罩必须仍在。
15
+ - **变异验证**:把 `src/client/OAuthSignIn.tsx` 改回原来的 `await onAdded?.()` 并删掉新 effect(即完全恢复旧逻辑),5 例中**3 例变红**;改回修复版 5 例全绿。这条记录有意写下来,因为**第一版修法本身是无效的**——只有经过这次还原才确认了守卫真的能抓住它。
16
+ - **测试基建注意**:`tests/pool-render-helpers.tsx` 的 `settle()` 用 `setTimeout` 排空异步工作,而假时钟会冻结它,所以本文件不能沿用 `settle()`(会一直挂到测试超时,表现为「找不到按钮」这类级联报错)。本文件改用 `act` + `advanceTimersByTimeAsync` 手动驱动时钟。另:登录轮询间隔是 `OAUTH_POLL_INTERVAL_MS = 2000`,**不是**卡片 usage 的一分钟——写作过程中一度按 60 秒推进时钟,等价于把整个流程跑完,因此这一处边界在测试里以注释标明。
17
+ - **未改动**:`window.open('')` 的占位窗口与「被拦则降级为弹窗内手动链接」这套行为一字未动。报告者说的「从来没有成功打开过浏览器」在该桌面外壳里 `window.open` 返回 `null`,走的是**已文档化的降级路径**,功能不损失(弹窗里始终有可复制的链接与「重新打开页面」按钮);把它改成别的行为属于另一个议题,不在本次范围内。
18
+
19
+ ## 4.4.0 (2026-10-12)
20
+
21
+ ### Features
22
+
23
+ - **「在对话框显示积分」开关移进「账号池」标题行,少占一行。**
24
+ - **改动**:该开关原本单独占一行(在 tab 栏下方),一个复选框换来一整行高度。现在它与「账号池」标题在同一行:标题在左,开关紧随其后,右侧仍是「当前账号」徽标。**账号池区块不会因此变高**。
25
+ - **没有采用「干脆删掉这行、只在别处留开关」的做法**:`AccountPool` 在宿主不提供 pool 状态时(旧宿主,或该路由缺字段)会 `return null`,把开关交给它会让**整个设置项随之消失**——一个设置控件不能因为另一个区块没渲染就不可达。因此有两条渲染点,**同一时刻只有一条生效**:有 pool 时进标题行,没有 pool 时回落到它自己那行(`dsm-workbuddy-credits-switch-row`,间距与移动前一致)。守卫按**祖先关系**断言(开关在 `.dsm-workbuddy-pool-head` 之内,且回落行不存在),而不是只数复选框个数——两条同时渲染也能通过「存在性」断言,却把省下的那一行又还了回去。
26
+ - **开关不因区域被关闭而隐藏**:区域关掉时它照旧渲染为**禁用**状态。藏起来会让用户看不到「为什么侧边栏没有那行」,而卡片自己的提示指向的是供应商复选框,不是一个凭空消失的控件。
27
+ - **文案随位置微调(不是为了好看)**:标签从「在对话框显示 WB CN 积分 / Show WB CN credits in the composer」缩到「对话框显示 WB CN 积分 / WB CN credits in composer」,省掉的是「在」与「Show」这类可以由上下文承接的词,**区域区分保留**——开关现在与「账号池」同排、不再紧贴 tab 栏,丢掉区域名会让 CN 与国际版两个开关在文案上无法区分。完整解释仍在 `title` 提示里,一字未减。
28
+ - **徽标改用 `margin-left:auto` 而非整行的 `space-between`**:三个子元素下 `space-between` 会把开关甩到中间而不是贴着标题;只有两个子元素(无开关)时 `margin-left:auto` 让徽标仍留在右边缘,与改动前一致。
29
+ - **联动清理**:`AccountPool` 新增 `headerExtra` 插槽(与既有 `accountTools` 同款「传现成节点」约定),开关的写入路径、禁用规则与「与卡片其他区域写入者串行」的逻辑**全部留在卡片里**——它们依赖设置 scope 与保存状态,搬进池区块等于把同一份写入与串行化逻辑再实现一遍。
30
+ - **守卫**:`tests/card-credits-switch.spec.tsx` 由 11 例增至 14 例,新增「开关在账号池标题行内且回落行不存在」「没有 pool 时回落到自己那行」「标签仍带区域名」三条。**变异验证**:把开关改回「永远单独成行」→ 第一条变红(`expected null not to be null`,即标题行里找不到开关)。
31
+
32
+ ## 4.3.4 (2026-10-12)
33
+
34
+ ### Fixes
35
+
36
+ - **移除一条只会在 Windows 上失败的测试断言,CI 恢复全绿。**(PR #49)
37
+ - **现象**:4.3.2 与 4.3.3 的 CI 在 `windows-latest` 上红、`ubuntu-latest` 全绿,失败点只有一处:
38
+ ```
39
+ AssertionError: expected 'D:\explicit\home' to be '/explicit/home'
40
+ ```
41
+ - **根因**:`tests/host-peers.spec.ts` 把 `resolveDshHome('/explicit/home')` 与**字面量** `/explicit/home` 比较。而 `resolveDshHome` 会对入参跑 `path.resolve`,win32 下 `/explicit/home` 被规范成 `\explicit\home`——这条断言把「哪个平台在跑测试」写进了期望值,正是 `docs/WINDOWS.md` §4 那条「断言行为,不要断言跑测试的机器」要拦的形态。
42
+ - **修法**:改为与 `path.resolve` **自身**比较(两侧同一个解析器),因此在任何平台都成立;并补一条不含分隔符的用例,证明胜出的是「显式入参」而不是某个字符串的写法。守卫本身(解析优先级、后备实现行为)一条未减。
43
+ - **验证**:先在本地按 win32 路径语义逐形态核对(`/explicit/home`、`relative-ish`、`C:\…`、`~/dsh` 两侧全部一致),再经 **CI 的 `windows-latest` 真机跑通**——本次缺陷恰恰属于「本机(macOS)永远绿」的那一类,所以最终判据只能是在 Windows 上跑出来的结果。
44
+ - **注意**:本次只改了 `tests/`,**`lib/` 与 `package.json` 的发布产物逐字未变**(`tests/` 不在 `files` 白名单内,从不随包分发)。因此 4.3.2 / 4.3.3 对用户而言没有问题,本版存在只为让 tag 上的 CI 结论与事实一致。
45
+
46
+ #### Docs
47
+
48
+ - **README 增补「在服务器上使用:搬运已登录的凭据」一节**(承接 issue #47 报告者 tuoyuangui 在 #47 评论中贡献的做法)。
49
+ - **内容**:给出从「已登录 WorkBuddy 的机器」打包 `CodeBuddyExtension` 目录 → 传到服务器 → 解到插件实际读取的目录 → 用 `status` 验证的完整三步命令;中英双语同步(`README.md` / `README.en.md`)。
50
+ - **更正了报告者原话里的一处平台细节**:报告者写的是 `cp -r 其他位置的 ~/.local/share[~/.config]/CodeBuddyExtension …`,其中 `~/.local/share` 在 **Linux 上插件不会读取**。`src/auth.ts` 的 `defaultDesktopAuthDirs()` 在 Linux 只返回 `${XDG_CONFIG_HOME:-~/.config}/CodeBuddyExtension/Data/Public/auth` 一项。README 因此列出三平台**实际**读取路径的对照表,并明确「只解到 `~/.local/share` 不会被读到」,同时给出 `WORKBUDDY_AUTH_FILE` 这条兜底(显式指定时同级备份仍会被扫描,账号切换照常可用)。
51
+ - **补了两条报告者未提及、但读者必须知道的边界**:其一,**5.6.0 起的加密凭据无法靠搬运解决**——密钥在运行时由 App 原生绑定现取、不落盘,所以没有 App 的服务器上即使拿到加密后的 auth 文件也读不出来(插件会如实报未登录,不会退化成读出一个残缺 token,该行为见 `src/at-rest.ts` 与[已知限制](README.md#已知限制));其二,凭据文件本身**等同于账号**,必须走加密通道传输并 `chmod 600`。
52
+ - **本版只改文档**,未触动 `src/`、`tests/` 与构建产物。措辞中的每条行为断言(候选路径、加密凭据的失败形态、`doctor` 的输出文本)均对照 `src/auth.ts`、`src/at-rest.ts`、`src/bin.ts` 逐条核过,不是照抄 issue 评论。
53
+
3
54
  ## 4.3.3 (2026-10-12)
4
55
 
5
56
  ### Fixes
package/README.en.md CHANGED
@@ -205,6 +205,49 @@ All three platforms are supported targets; the differences below are **implement
205
205
  2. **Credential paths are partly verified on real Windows hardware.** The candidate list is derived from platform conventions and pinned by unit tests (including both the unset and the blank-string fallback for `LOCALAPPDATA` / `APPDATA`). On a real Windows 11 machine, `%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth` and `%LOCALAPPDATA%\Programs\WorkBuddy\WorkBuddy.exe` **did resolve**, and the encrypted credential's `keyId` matched the registered vector verbatim. **The remaining candidates are still not verified per-item** — if your credential or app lives elsewhere, point `WORKBUDDY_AUTH_FILE` / `WORKBUDDY_APP_EXECUTABLE` at it and please report what you see.
206
206
  3. **Encrypted credentials (from 5.6.0) require the desktop app to be present**, on Windows exactly as on macOS; if the app lives in a custom directory (e.g. `E:\WorkBuddy\WorkBuddy.exe`), point `WORKBUDDY_APP_EXECUTABLE` at it.
207
207
 
208
+ ### Using it on a server: carry the credential over
209
+
210
+ On a headless server (or any machine where you would rather not install the WorkBuddy desktop app), the plugin has no local credential to read. The way around it is to **copy the credential directory over from a machine that has already signed in to WorkBuddy** — the plugin only reads that file and does not require the app to be present (**provided the credential itself is not encrypted**, see below):
211
+
212
+ ```sh
213
+ # 1) On the machine that is already signed in to WorkBuddy, locate and pack the credential dir
214
+ # macOS: ~/Library/Application Support/CodeBuddyExtension
215
+ # Linux: ${XDG_CONFIG_HOME:-~/.config}/CodeBuddyExtension
216
+ # Windows: %LOCALAPPDATA%\CodeBuddyExtension and %APPDATA%\CodeBuddyExtension
217
+ tar czf codebuddy-cred.tgz -C <the parent directory above> CodeBuddyExtension
218
+
219
+ # 2) Move codebuddy-cred.tgz to the server and unpack it into the directory the plugin reads
220
+ # Linux:
221
+ mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}"
222
+ tar xzf codebuddy-cred.tgz -C "${XDG_CONFIG_HOME:-$HOME/.config}"
223
+
224
+ # 3) Confirm the plugin sees it
225
+ dsh plugin --profile web exec dsh-connect-workbuddy status
226
+ ```
227
+
228
+ > ⚠️ **On Linux there is no `~/.local/share` path.** The candidate directories come from `defaultDesktopAuthDirs()` in `src/auth.ts`, and **Linux reads only `$XDG_CONFIG_HOME` (defaulting to `~/.config`)**:
229
+ >
230
+ > | Platform | The credential directory the plugin actually reads |
231
+ > | --- | --- |
232
+ > | macOS | `~/Library/Application Support/CodeBuddyExtension/Data/Public/auth` |
233
+ > | Linux | `${XDG_CONFIG_HOME:-~/.config}/CodeBuddyExtension/Data/Public/auth` |
234
+ > | Windows | `CodeBuddyExtension\Data\Public\auth` under `%LOCALAPPDATA%` and `%APPDATA%` |
235
+ >
236
+ > So **unpacking into `~/.local/share` alone will not be seen** — either unpack into the directories listed above, or point `WORKBUDDY_AUTH_FILE` straight at the auth file (when set explicitly, sibling backup files in that directory are still scanned, so account switching keeps working).
237
+
238
+ > 🔐 **Encrypted credentials (WorkBuddy 5.6.0 and later) cannot be carried over this way.** Recent desktop builds encrypt the token fields inside the auth file, and the key is fetched at runtime from the app's native binding and never written to disk. On a server without the app, copying the encrypted auth file over **will not be readable** (the plugin reports not-signed-in honestly rather than degrading into reading a truncated token). What works: carry the credential from a machine still running a pre-5.6.0 version or an unencrypted credential, or install the app on that server to obtain the key.
239
+ >
240
+ > Two `doctor` lines tell you which case you are in:
241
+ >
242
+ > ```
243
+ > Desktop auth file: present (…/CodeBuddyExtension/Data/Public/auth/.workbuddy-auth.json)
244
+ > Encrypted-credential support: unavailable (…)
245
+ > ```
246
+ >
247
+ > `Desktop auth file: present` means the file was found; if `status` still reports not-signed-in while `Encrypted-credential support` is `unavailable` (no app on the server), that is the encrypted-credential-without-key case — carrying the file over cannot fix it.
248
+
249
+ > 🔒 **The credential is the account**: that file is a directly usable session. Transfer it over an encrypted channel (`scp` / private network), tighten it with `chmod 600` once on the server, and keep it out of anything that gets packaged or committed.
250
+
208
251
  ## Command line
209
252
 
210
253
  ```sh
package/README.md CHANGED
@@ -215,6 +215,49 @@ dsh plugin --profile dsh-tui add dsh-connect-workbuddy # TUI
215
215
  2. **凭据路径已部分经真机验证**。候选列表按平台约定推导并有单元测试钉住(含 `LOCALAPPDATA` / `APPDATA` 缺省与空白串两种回落)。已在真实 Windows 11 上确认:`%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth` 与 `%LOCALAPPDATA%\Programs\WorkBuddy\WorkBuddy.exe` **命中**,加密凭据的 `keyId` 与登记向量逐字一致。**其余候选仍未经真机逐项验证**——若你的凭据或 App 不在默认位置,用 `WORKBUDDY_AUTH_FILE` / `WORKBUDDY_APP_EXECUTABLE` 指定,并欢迎回报现场。
216
216
  3. **加密凭据(5.6.0 起)需要桌面 App 在场**,Windows 与 macOS 同样如此;App 装在自定义目录(如 `E:\WorkBuddy\WorkBuddy.exe`)时用 `WORKBUDDY_APP_EXECUTABLE` 指定。
217
217
 
218
+ ### 在服务器上使用:搬运已登录的凭据
219
+
220
+ 在无桌面环境的服务器(或任何不想安装 WorkBuddy 桌面 App 的机器)上,插件读不到本机凭据。办法是**从一台已经登录过 WorkBuddy 的机器上,把凭据目录整体拷过来**——插件只读这份文件,不要求 App 在场(**前提是凭据本身未加密**,见下):
221
+
222
+ ```sh
223
+ # 1) 在「已登录 WorkBuddy 的机器」上,找到并打包凭据目录
224
+ # macOS: ~/Library/Application Support/CodeBuddyExtension
225
+ # Linux: ${XDG_CONFIG_HOME:-~/.config}/CodeBuddyExtension
226
+ # Windows:%LOCALAPPDATA%\CodeBuddyExtension 以及 %APPDATA%\CodeBuddyExtension
227
+ tar czf codebuddy-cred.tgz -C <上面的父目录> CodeBuddyExtension
228
+
229
+ # 2) 把 codebuddy-cred.tgz 传到服务器,解到「插件会读的那个目录」
230
+ # Linux:
231
+ mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}"
232
+ tar xzf codebuddy-cred.tgz -C "${XDG_CONFIG_HOME:-$HOME/.config}"
233
+
234
+ # 3) 确认插件读到了
235
+ dsh plugin --profile web exec dsh-connect-workbuddy status
236
+ ```
237
+
238
+ > ⚠️ **Linux 上没有 `~/.local/share` 这条路。** 插件的候选目录由 `src/auth.ts` 的 `defaultDesktopAuthDirs()` 决定,**Linux 只读 `$XDG_CONFIG_HOME`(缺省 `~/.config`)**:
239
+ >
240
+ > | 平台 | 插件实际读取的凭据目录 |
241
+ > | --- | --- |
242
+ > | macOS | `~/Library/Application Support/CodeBuddyExtension/Data/Public/auth` |
243
+ > | Linux | `${XDG_CONFIG_HOME:-~/.config}/CodeBuddyExtension/Data/Public/auth` |
244
+ > | Windows | `%LOCALAPPDATA%` 与 `%APPDATA%` 下的 `CodeBuddyExtension\Data\Public\auth` |
245
+ >
246
+ > 所以**只把凭据解到 `~/.local/share` 是不会被读到的**——要么解到上面列出的目录,要么用 `WORKBUDDY_AUTH_FILE` 直接指向那份 auth 文件(显式指定时该目录下的同级备份文件仍会一并扫描,账号切换照常可用)。
247
+
248
+ > 🔐 **加密凭据(WorkBuddy 5.6.0 起)不能靠搬运解决。** 新版桌面端把 auth 文件里的 token 字段加密了,密钥由 App 的原生绑定在**运行时现取**、不落盘。因此一台没有 App 的服务器上,即使把加密后的 auth 文件拷过去也**读不出来**(插件会如实报告未登录,而不会退化成读出一个残缺 token)。可行的做法是**从一台仍在运行 5.6.0 之前版本、或凭据未加密的机器上搬运**,或者在该服务器上安装 App 以取得密钥。
249
+ >
250
+ > 判断当前处于哪种情况,看 `doctor` 的两行输出即可:
251
+ >
252
+ > ```
253
+ > Desktop auth file: present (…/CodeBuddyExtension/Data/Public/auth/.workbuddy-auth.json)
254
+ > Encrypted-credential support: unavailable (…)
255
+ > ```
256
+ >
257
+ > `Desktop auth file: present` 说明文件找到了;此时若 `status` 仍报未登录、而 `Encrypted-credential support` 为 `unavailable`(服务器上没有 App),那就是加密凭据 + 缺密钥的场景——搬运救不了它。
258
+
259
+ > 🔒 **凭据等同于账号**:这份文件里是可直接使用的登录态。传输时请走加密通道(`scp` / 私网),落到服务器后用 `chmod 600` 收紧权限,别放进任何会被打包或提交的目录。
260
+
218
261
  ## 命令行
219
262
 
220
263
  ```sh
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-DE8M7vXk.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-DROtSemV.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
@@ -1177,7 +1177,7 @@ window.__ModuleLoader__.load({
1177
1177
  * section implying the feature exists.
1178
1178
  */
1179
1179
  function AccountPool(props) {
1180
- const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools } = props;
1180
+ const { t, region, pool, settingsScope, siblingBusy, onBusyChange, onSaved, onRefresh, onRescan, rescanning, onSelectAccount, selectingAccount, accountTools, headerExtra } = props;
1181
1181
  const [draft, setDraft] = (0, react.useState)(null);
1182
1182
  const [saving, setSaving] = (0, react.useState)(false);
1183
1183
  const [saveError, setSaveError] = (0, react.useState)(void 0);
@@ -1614,7 +1614,7 @@ window.__ModuleLoader__.load({
1614
1614
  visible: true,
1615
1615
  message: t("row.poolSaving"),
1616
1616
  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", {
1617
+ }) : 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
1618
  className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
1619
1619
  value: currentAccount?.accountId ?? "",
1620
1620
  disabled: selectingAccount === true,
@@ -1893,7 +1893,53 @@ window.__ModuleLoader__.load({
1893
1893
  function elapsedSeconds(sinceMs, nowMs) {
1894
1894
  return Math.max(0, Math.floor((nowMs - sinceMs) / 1e3));
1895
1895
  }
1896
- /** Copy the sign-in URL to the clipboard; true on success. */
1896
+ /**
1897
+ * How long the `syncing` phase may hold the veil before it lets go (issue #51).
1898
+ *
1899
+ * Shorter than a poll interval would be pointless; a few seconds is far longer
1900
+ * than a healthy loopback re-read takes, and anything past that is
1901
+ * indistinguishable from "never".
1902
+ *
1903
+ * What sits below this decision is an asymmetry worth spelling out. The
1904
+ * `waiting` phase has its own local deadline (see the waiting-clock effect), so
1905
+ * a login whose `loginId` died still ends. The `syncing` phase reached the veil
1906
+ * and awaited the card's re-read with NEITHER a deadline NOR a `catch` — and the
1907
+ * veil's visibility is `phase.kind === 'syncing'` and nothing else. So a re-read
1908
+ * that never settled held the veil up forever, while the credential was already
1909
+ * in the vault: reopening the card (or force-quitting DSH) showed the account,
1910
+ * which is exactly the "half usable" report — sign-in succeeded, the screen
1911
+ * never said so.
1912
+ */
1913
+ const SYNC_SETTLE_TIMEOUT_MS = 5e3;
1914
+ /**
1915
+ * Await `work` but give up after `timeoutMs`; resolves `false` on timeout.
1916
+ *
1917
+ * The timer is cleared in a `finally`, so the common case (the re-read settles
1918
+ * first) leaves no pending timer holding the event loop — the reason this is a
1919
+ * race with an explicit cleanup rather than `Promise.race` alone.
1920
+ *
1921
+ * The timeout arm never rejects: a re-read that FAILS is not a failed sign-in,
1922
+ * and the caller must not be able to confuse the two. Rejection and timeout
1923
+ * stand for the same thing here — "the list did not prove it refreshed" — so
1924
+ * they take the same path.
1925
+ *
1926
+ * EXPORTED because `AccountTransfer`'s veil awaits the same card re-read and had
1927
+ * the same unbounded wait; sharing one implementation is what stops the two
1928
+ * veils from drifting apart again.
1929
+ */
1930
+ async function settlesWithin(work, timeoutMs) {
1931
+ let timer;
1932
+ try {
1933
+ return await Promise.race([Promise.resolve(work).then(() => true, () => false), new Promise((resolve) => {
1934
+ timer = setTimeout(() => resolve(false), timeoutMs);
1935
+ })]);
1936
+ } finally {
1937
+ if (timer !== void 0) clearTimeout(timer);
1938
+ }
1939
+ }
1940
+ /**
1941
+ * Copy the sign-in URL to the clipboard; true on success.
1942
+ */
1897
1943
  async function copyUrl(url) {
1898
1944
  try {
1899
1945
  await navigator.clipboard.writeText(url);
@@ -1913,6 +1959,17 @@ window.__ModuleLoader__.load({
1913
1959
  const [copied, setCopied] = (0, react.useState)(false);
1914
1960
  const [pollTick, setPollTick] = (0, react.useState)(0);
1915
1961
  const [nowMs, setNowMs] = (0, react.useState)(() => Date.now());
1962
+ /**
1963
+ * The account the Host just added, held for the sync effect.
1964
+ *
1965
+ * The poll that answers `done` names the account; the effect that ends the
1966
+ * `syncing` phase needs that name to write the success line, and it cannot
1967
+ * read it off the phase because the phase is what it is about to replace.
1968
+ * State rather than a ref: the effect is keyed on it, so a second sign-in
1969
+ * (adding a second account) re-arms the deadline properly instead of reusing
1970
+ * a stale closure value.
1971
+ */
1972
+ const [addedAccountName, setAddedAccountName] = (0, react.useState)("");
1916
1973
  const mounted = (0, react.useRef)(true);
1917
1974
  (0, react.useEffect)(() => {
1918
1975
  mounted.current = true;
@@ -1983,13 +2040,7 @@ window.__ModuleLoader__.load({
1983
2040
  kind: "syncing",
1984
2041
  accountName
1985
2042
  });
1986
- await onAdded?.();
1987
- if (cancelled || !mounted.current) return;
1988
- setPhase({
1989
- kind: "finished",
1990
- message: "added",
1991
- accountName
1992
- });
2043
+ setAddedAccountName(accountName);
1993
2044
  }
1994
2045
  } catch {
1995
2046
  setPollTick((tick) => tick + 1);
@@ -2007,6 +2058,69 @@ window.__ModuleLoader__.load({
2007
2058
  region
2008
2059
  ]);
2009
2060
  /**
2061
+ * End the `syncing` phase — on the re-read, or on the deadline, whichever
2062
+ * comes first (issue #51).
2063
+ *
2064
+ * ## Why this is a separate effect and not an `await` in the poll
2065
+ *
2066
+ * The first attempt at this fix put the race inside the poll's `done` branch:
2067
+ *
2068
+ * setPhase({ kind: 'syncing', accountName })
2069
+ * await settlesWithin(onAdded?.(), SYNC_SETTLE_TIMEOUT_MS)
2070
+ * if (cancelled || !mounted.current) return
2071
+ * setPhase({ kind: 'finished', … })
2072
+ *
2073
+ * and it did not work — measured, not assumed. `phase` is a dependency of the
2074
+ * poll effect, so `setPhase({kind:'syncing'})` re-runs that effect, and React
2075
+ * runs the PREVIOUS cleanup first. That cleanup sets the old closure's
2076
+ * `cancelled = true`. React commits the cleanup before the `await` it is
2077
+ * inside ever resumes, so the resume always sees `cancelled === true` and
2078
+ * returns: the veil stayed up exactly as it had before.
2079
+ *
2080
+ * This also explains why the defect was invisible in review. A FAST re-read
2081
+ * resolves inside the same microtask batch, before React commits the cleanup,
2082
+ * so it passes the guard and the success line appears. Only a re-read slow
2083
+ * enough to cross a React commit — i.e. the reported case — loses the race
2084
+ * against its own cleanup. The bug hid behind its own common path.
2085
+ *
2086
+ * Running the wait in an effect keyed on the phase sidesteps the whole
2087
+ * interaction: this effect's own cleanup runs when the phase LEAVES
2088
+ * `syncing`, which is after it has already done its job, so there is no
2089
+ * self-cancellation to lose.
2090
+ *
2091
+ * ## The two outcomes
2092
+ *
2093
+ * Both end in `finished`/`added` and the difference is deliberately not shown
2094
+ * to the user. The account IS added — the Host stored the credential before it
2095
+ * answered the poll — so the honest report is success either way; the deadline
2096
+ * only stops the screen from claiming otherwise indefinitely. (`refreshUsage`
2097
+ * already surfaces a failed re-read as `refreshError` on the card itself, so
2098
+ * a genuinely broken usage route is still reported, by the component that
2099
+ * owns that data.)
2100
+ */
2101
+ (0, react.useEffect)(() => {
2102
+ if (phase.kind !== "syncing") return;
2103
+ let cancelled = false;
2104
+ const finish = () => {
2105
+ if (cancelled || !mounted.current) return;
2106
+ setPhase({
2107
+ kind: "finished",
2108
+ message: "added",
2109
+ accountName: addedAccountName
2110
+ });
2111
+ };
2112
+ settlesWithin(Promise.resolve(onAdded?.()), SYNC_SETTLE_TIMEOUT_MS).then(finish);
2113
+ const timer = setTimeout(finish, SYNC_SETTLE_TIMEOUT_MS);
2114
+ return () => {
2115
+ cancelled = true;
2116
+ clearTimeout(timer);
2117
+ };
2118
+ }, [
2119
+ addedAccountName,
2120
+ onAdded,
2121
+ phase
2122
+ ]);
2123
+ /**
2010
2124
  * Open a placeholder window WITHOUT `noopener`, and deliberately so.
2011
2125
  *
2012
2126
  * `noopener` is the correct feature for a link whose destination never needs
@@ -2227,6 +2341,17 @@ window.__ModuleLoader__.load({
2227
2341
  *
2228
2342
  * @module dsh-connect-workbuddy/client/AccountTransfer
2229
2343
  */
2344
+ /**
2345
+ * How long the veil may wait on the card's re-read before it comes down.
2346
+ *
2347
+ * Same ceiling and same reasoning as the sign-in path's
2348
+ * `SYNC_SETTLE_TIMEOUT_MS` (issue #51): the veil is the only feedback this
2349
+ * operation gives, so its exit condition must not be a promise that can stay
2350
+ * pending. The `refreshing` phase has no other way out — no deadline, no
2351
+ * failure branch — so an unsettled re-read left the operator staring at
2352
+ * 「正在导入…」 over an import that had already succeeded.
2353
+ */
2354
+ const REFRESH_SETTLE_TIMEOUT_MS = 5e3;
2230
2355
  /** Build the default export file name: workbuddy-accounts-YYYY-MM-DD.json. */
2231
2356
  function exportFileName() {
2232
2357
  const pad = (value) => String(value).padStart(2, "0");
@@ -2385,7 +2510,7 @@ window.__ModuleLoader__.load({
2385
2510
  kind: "refreshing",
2386
2511
  message
2387
2512
  });
2388
- await onChanged?.();
2513
+ await settlesWithin(Promise.resolve(onChanged?.()), REFRESH_SETTLE_TIMEOUT_MS);
2389
2514
  if (!mounted.current) return;
2390
2515
  setPhase({
2391
2516
  kind: "done",
@@ -2665,10 +2790,17 @@ window.__ModuleLoader__.load({
2665
2790
  .dsm-workbuddy-tab-switch{display:inline-flex;align-items:center;flex:none;padding:0 8px 0 2px;cursor:pointer}
2666
2791
  .dsm-workbuddy-tab-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
2667
2792
  .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)}
2793
+ /* One sidebar-credit switch, belonging to the ACTIVE tab's region. It rides in
2794
+ the account-pool heading row (dsm-workbuddy-pool-head-extra) so one
2795
+ checkbox no longer costs a full line of vertical space; the tab bar already
2796
+ says which region is in scope, so it still reads as this tab's property
2797
+ rather than as a shared pair of settings. No vertical margin: the heading row
2798
+ owns the spacing. */
2799
+ .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)}
2800
+ /* The standalone fallback site: used only when the account-pool section did not
2801
+ render (no Host pool state), so there is no heading row to share. This is the
2802
+ spacing the control carried before it moved beside that heading. */
2803
+ .dsm-workbuddy-credits-switch-row{display:flex;align-items:center;margin:8px 0 2px}
2672
2804
  .dsm-workbuddy-credits-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
2673
2805
  .dsm-workbuddy-credits-switch input:disabled{opacity:.4;cursor:default}
2674
2806
  .dsm-workbuddy-tab-off{opacity:.55}
@@ -2754,8 +2886,15 @@ window.__ModuleLoader__.load({
2754
2886
 
2755
2887
  /* ---- Account pool ---- */
2756
2888
  .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}
2889
+ /* Title, then the inline extra (the sidebar-credit switch), then the
2890
+ serving-account badge on the far edge. The badge is pushed right with
2891
+ margin-left:auto rather than the row using space-between: with three
2892
+ children the latter would strand the switch in the middle instead of binding
2893
+ it to the title, and with only two (no switch) margin-left:auto keeps the
2894
+ badge on the right edge exactly as before. */
2895
+ .dsm-workbuddy-pool-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
2896
+ .dsm-workbuddy-pool-head-extra{display:inline-flex;align-items:center}
2897
+ .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
2898
  /* Sits under the header block, before the batch buttons: account discovery is
2760
2899
  the first step of using the pool, so it reads above the actions it enables. */
2761
2900
  /* Manual-mode account picker: shares the settings row layout (.pool-set), and
@@ -3979,6 +4118,39 @@ window.__ModuleLoader__.load({
3979
4118
  accountName: account.accountName
3980
4119
  }))
3981
4120
  })] });
4121
+ /**
4122
+ * Sidebar credit line switch — ONE control, for the ACTIVE tab's region only.
4123
+ *
4124
+ * The two regions are independent providers with independent accounts, so
4125
+ * each tab owns its own switch and they are never shown as a pair: this one
4126
+ * writes `regions.<activeRegion>.showCreditsInMainUi`, which decides only
4127
+ * whether THAT region's line appears in the sidebar. That matches how every
4128
+ * other control in this card is scoped to the selected tab (account, pool,
4129
+ * model list), and it means a CN-only user is never shown a switch for an
4130
+ * account they do not have. An earlier revision rendered both boxes side by
4131
+ * side under the tab bar regardless of the active tab; that read as one
4132
+ * shared setting and was replaced.
4133
+ *
4134
+ * RENDERED BESIDE THE POOL HEADING, not on its own row. It used to occupy a
4135
+ * full line of vertical space for a single checkbox; the pool heading already
4136
+ * had a title-left / badge-right row, so the control fits there without the
4137
+ * block growing. It is handed to `AccountPool` as a ready-made node purely so
4138
+ * the two can share a flex row — the write path, the disabled rules and the
4139
+ * serialization against the card's other region writers all stay here, where
4140
+ * the settings scope and the save state live.
4141
+ */
4142
+ const creditsSwitch = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4143
+ className: "dsm-workbuddy-credits-switch",
4144
+ title: activeRegion === "cn" ? t("row.showCreditsCnHint") : t("row.showCreditsGlobalHint"),
4145
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4146
+ type: "checkbox",
4147
+ checked: activeRegionOn ? creditsShownOn(activeRegion) : false,
4148
+ disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4149
+ onChange: (event) => {
4150
+ toggleCredits(activeRegion, event.target.checked);
4151
+ }
4152
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal") })]
4153
+ });
3982
4154
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
3983
4155
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
3984
4156
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -4063,18 +4235,6 @@ window.__ModuleLoader__.load({
4063
4235
  className: "dsm-workbuddy-tab-off-notice",
4064
4236
  children: t("row.tabOffNotice")
4065
4237
  }) : 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
4238
  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
4239
  className: "dsm-workbuddy-usage-hint",
4080
4240
  children: t("row.reloginHint")
@@ -4131,11 +4291,16 @@ window.__ModuleLoader__.load({
4131
4291
  },
4132
4292
  selectingAccount: switchingAccount,
4133
4293
  accountTools,
4294
+ ...status.pool === void 0 ? {} : { headerExtra: creditsSwitch },
4134
4295
  siblingBusy: saving || togglingRegion !== void 0,
4135
4296
  onBusyChange: setPoolBusy,
4136
4297
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
4137
4298
  onRefresh: () => refreshUsage(activeRegion)
4138
4299
  }),
4300
+ status.pool === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4301
+ className: "dsm-workbuddy-credits-switch-row",
4302
+ children: creditsSwitch
4303
+ }) : null,
4139
4304
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
4140
4305
  className: "dsm-workbuddy-models",
4141
4306
  "aria-label": t("row.modelsTitle"),
@@ -5234,8 +5399,8 @@ window.__ModuleLoader__.load({
5234
5399
  "row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
5235
5400
  "row.poolRowRemoving": "Removing the account…",
5236
5401
  "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",
5402
+ "row.showCreditsCn": "WB CN credits in composer",
5403
+ "row.showCreditsGlobal": "WB AI credits in composer",
5239
5404
  "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
5405
  "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
5406
  "composer.pointsCn": "WB CN",
@@ -5494,8 +5659,8 @@ window.__ModuleLoader__.load({
5494
5659
  "row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
5495
5660
  "row.poolRowRemoving": "正在删除账号…",
5496
5661
  "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
5497
- "row.showCreditsCn": "在对话框显示 WB CN 积分",
5498
- "row.showCreditsGlobal": "在对话框显示 WB AI 积分",
5662
+ "row.showCreditsCn": "对话框显示 WB CN 积分",
5663
+ "row.showCreditsGlobal": "对话框显示 WB AI 积分",
5499
5664
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
5500
5665
  "row.showCreditsGlobalHint": "在对话输入框工具栏显示国际版(WB AI)剩余积分,仅当本会话使用 WB AI 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
5501
5666
  "composer.pointsCn": "WB CN",
@@ -3709,7 +3709,7 @@ function generationOf(version) {
3709
3709
  //#endregion
3710
3710
  //#region src/version.ts
3711
3711
  /** The npm package version this build was produced from. */
3712
- const WORKBUDDY_CONNECT_VERSION = "4.3.3";
3712
+ const WORKBUDDY_CONNECT_VERSION = "4.4.1";
3713
3713
  //#endregion
3714
3714
  //#region src/host-heartbeat.ts
3715
3715
  /**
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-DE8M7vXk.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-DROtSemV.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";
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.3",
5
+ "version": "4.4.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",