dsh-connect-workbuddy 4.3.0 → 4.3.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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.3.2 (2026-10-12)
4
+
5
+ ### Fixes
6
+
7
+ - **独立 CLI 在干净安装上不再于加载期崩溃:`@deepseek-ai/dsh-atomic-write` / `@deepseek-ai/dsh-home-paths` 改为「宿主优先、按需加载」,并保留一份等价后备实现。**(issue #47)
8
+ - **现象(报告者 Fedora 真机,4.3.0)**:按 README 走完 `dsh plugin --profile web add dsh-connect-workbuddy`、`dsh plugin --profile dsh-tui add …` 之后,任何一条 `exec` 命令都直接崩,且**报错文件与所跑命令看起来无关**:
9
+ ```
10
+ Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@deepseek-ai/dsh-atomic-write'
11
+ imported from /home/liguolong/.dsh/profiles/dsh-tui/node_modules/dsh-connect-workbuddy/lib/host-heartbeat-jNlsUMZO.js
12
+ dsh: plugin command failed; diagnostics: …/.plugin-manager/logs/operation-KO6W9v/pnpm.log
13
+ ```
14
+ **环境**:Fedora 全新机器,`nvm install 24`(Node v24.21.0)、`pnpm 12.6.0`,`npm install -g @deepseek-ai/dsh @deepseek-harness-tui/dsh-tui`。
15
+ - **根因(本机以 `pnpm add` 装进同构的空 profile 后逐字复现,含同一个 chunk 名 `host-heartbeat-jNlsUMZO.js`)**:两个包声明在 **peerDependencies**,而 `dsh plugin … exec` 只是在 profile 目录里跑一次 `pnpm exec`(`plugin-manager` 的 `runProfilePnpm`,`execution: 'cli'`、继承环境)——**子进程用普通 Node 解析规则,且 DSH 从不设 `NODE_PATH`(`app-boot` 明确清洗它)**。宿主内的运行期拦截层只在**已启动的 DSH 进程**里存在,`exec` 的子进程没有它;而 profile 由 `PROFILE_PNPM_WORKSPACE` 创建(`nodeLinker: hoisted` + **`autoInstallPeers: false`**),peer 也永远不会被装进去。两者相加 → 裸包名解析失败。
16
+ - **为什么触发点藏在 `host-heartbeat-*.js` 里**:tsdown 把 `auth` / `at-rest` / `host-heartbeat` / `pi-ai-runtime` 合并成**同一个共享 chunk**,而 `lib/bin.js` 一 import 它,`auth.ts` 顶层那句 `import … from '@deepseek-ai/dsh-atomic-write'` 就被求值——**只读的 `status` 也一样**。静态 import 在**模块求值期**抛错,任何「先 import 再 try/catch」的兜底都来不及。
17
+ - **为什么本机看不出来**:两处巧合同时掩盖了它——① profile 里 `dsh-connect-workbuddy` 是指向本仓库的符号链接,而仓库自身的 `node_modules` 因 `devDependencies` 恰好带有这两个 peer;② `~/.dsh/profiles/node_modules` 里还留着 **0.1.5 时代 link-backend 的投影符号链接**(现已指向不存在的 `DSH Desktop.app`,且新版本已用运行期拦截层取代该物理投影)。报告者的机器两处都没有。
18
+ - **修法**:新增 `src/host-peers.ts`。`writeFileAtomic` / `withFileLock` **宿主优先**——用 `import()` 在**调用时**解析 `@deepseek-ai/dsh-atomic-write`,宿主在场就用宿主那份(保持与宿主共享同一套跨进程写协议);解析不到才启用后备实现。`resolveDshHome` 则改为**纯本地同步实现**:上游该函数是不做任何 I/O 的纯函数,而本插件**每一处调用都不传参**,零参形态只取决于 `$DSH_HOME`(非空白)否则 `~/.dsh`,逐字转写即等价——若改成「宿主优先」反而会把一批同步调用点(凭据路径、心跳路径)逼成异步,付出真实复杂度换取零行为差异。
19
+ - **后备实现转写而非重造**:锁文件命名(`<file>.lock`)、`wx` 独占创建、`EPERM` 需 `lstat` 复核才算争用、Windows 瞬时错误集合(`EACCES`/`EBUSY`/`EPERM`)与重试节奏(initial 20ms / max 200ms / limit 8,即 `docs/WINDOWS.md` §3 末已更正的 ≈1100ms 预算)全部照抄上游。两份实现若预算不同,同一文件上「谁先拿到锁」将取决于**进程身份**而不是锁本身。`isTransientWindowsRenameError` 同样在 `process.platform !== 'win32'` 时短路,平台语义与 §1-4 一致。
20
+ - **`doctor` 可见**(#26 的 `piAiRuntime` 同款做法):新增 `hostPeers.atomicWrite`(`host` | `fallback`)与说明行,`--json` 的 `schemaVersion` 3 → 4。**两种状态都以真实输出核对过**:干净目录(无任何 `@deepseek-ai/*`)→ `fallback`,且 `status`/`doctor` 均正常退出;仓库检出(peer 齐全)→ `host`。
21
+ - **本机实测(无 peer 的空目录,即报告者形态)**:修复前 `node …/lib/bin.js status` → 与 issue 逐字一致的 `ERR_MODULE_NOT_FOUND`;修复后 `status`、`doctor`、`doctor --json` 全部 0 退出并输出真实账号数据。
22
+ - **守卫(两层,均做过变异验证)**:`tests/host-peers.spec.ts` 共 9 例——① **扫描构建产物**:从 `lib/bin.js` 沿相对 import 走完整个依赖图,出现任何裸包名即红;对**真实 4.3.0 产物**跑它是**红的**,且逐条点出 `lib/host-heartbeat-jNlsUMZO.js` 与 issue 报告里一模一样的两个包名(含 chunk 名),恢复到修复版即绿。② **源码层同规则**(不依赖构建,`pnpm test` 单独跑也能拦住);把 `src/auth.ts` 改回静态 import → 立即红。③ 反面对照:钉住 `lib/index.js`(宿主半边)**必须**继续把 `@deepseek-ai/dsh-llm` / `@deepseek-ai/schemastery` 留作外部依赖——否则有人可能用「两边都内联」的方式把它「修」掉。④ 后备实现的行为:原子写往返并建父目录、无残留临时文件、写锁互斥(断言**互斥**而非**谁先拿到**——两者同时发起时顺序是调度细节,被断言的是绝不交错)、异常路径也释放锁、`DSH_HOME` 优先级。
23
+ - **`lib/index.js` 的外部依赖清单未变**(`dsh-llm`、`dsh-llm-pi-ai`、`schemastery`、`pi-ai` 仍为外部),宿主半边的接驳方式一行未改。
24
+
25
+ #### Docs
26
+
27
+ - `README.md` / `README.en.md`:命令行一节新增 issue #47 的成因说明(peer + `autoInstallPeers: false` + `exec` 子进程无拦截层 + chunk 合并导致报错文件名看似无关),并说明 4.3.2 起的解析顺序与 `doctor` 的 `Atomic writes:` 行;「已知限制」新增对应条目。
28
+ - `docs/WINDOWS.md` §3 末新增「同一预算的第二份实现」,登记 `src/host-peers.ts` 与上游共用同一锁协议与同一重试预算;§4 检查清单新增一条(改后备实现时必须与上游的锁命名、Windows 瞬时错误集合、重试节奏保持一致)。
29
+ - `THIRD_PARTY_NOTICES.md` §四:原文写「唯一一处代码移植:`src/dsml-recovery.ts`」,本次后备实现属于**行为契约转写**(来自排除项中的 `@deepseek-ai/*` 通用平台依赖),已就地补记,避免该句因本次改动而失实。
30
+ - `tsdown.config.ts` 的 `neverBundle` 上方补注释:说明**宿主包保持外部依赖是刻意的**,以及 `bin` 与 `index` 共用 chunk 这一事实对新增代码的约束(`bin.ts` 可达的代码必须用调用期 `import()`)。
31
+
32
+ ## 4.3.1 (2026-10-11)
33
+
34
+ ### Fixes
35
+
36
+ - **对话框积分读数现在关得掉:设置命名空间改为「跟随宿主」而非「猜名字」。**(issue #46)
37
+ - **现象**:设置卡片里取消勾选「在对话框显示 WBCN 积分」,勾选框随即显示为已关闭、`cordis.patch.yml` 里也确实落了 `regions.cn.showCreditsInMainUi: false`——**写入侧正常**——但切到该 provider 的会话,输入框左侧**仍然渲染** `WB CN · <余额>`。
38
+ - **根因(两段)**:① `src/client/index.tsx` 的命名空间解析只跑一次,且恰逢 describe 镜像未就绪(镜像由异步 `ctx.remote.settings.describe()` 填充)。旧代码把「镜像为空」当成「声明的名字一定对」,绑定 `forms.get('workbuddy')` 且此后永不复查;而 0.2.0 的设置命名空间**就是 Loader entry id**(`dsh-connect-workbuddy`),`ConfigFormController.derive()` 又是**精确匹配**,于是这个猜出来的表单永久停在 `{status:"unavailable", value:undefined}`。② `regionCreditsShownOf` 是 opt-out(`!== false` 即「显示」),`undefined` 于是被判为显示,读数照常渲染。**卡片本身显示正确**,因为 `WorkBuddyCard` 的 `creditsShownOn` 是 Host 优先——4.0.0 新增的读数 gate 没有继承这条规则。
39
+ - **修法一(治本)**:改用宿主现成范式 `configForms.whileServed([...])`(内部 `mirror.subscribe` + 立即 `sync`),把命名空间候选表 `WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES` 按**真实 entry id 优先、声明名最后**排序,并从 `served` 集合里现场挑选。**晚绑定必须重新注册**:渲染器按 entry 的**对象身份**永久缓存 inject 结果(`rootInjectCache` / `cachedRootInject` 里的 `if (!props) { props = runInject(...) }`),已注册 entry 不会再求值——所以旧注册被 dispose、新注册被创建。副作用是宿主不再服务该命名空间时绑定会被自动撤回。
40
+ - **修法二(治复发)**:让 gate 与卡片同源。Host 的 usage 载荷本就带 `showCreditsInMainUi`,现在它**压过**设置 scope(`composerCreditsRegionOf` 新增 `hostValue` 参数,`ComposerCredits` 在拿到文档后按它隐藏,并停掉 5 分钟轮询)。非布尔值(含 `error` 变体与旧文档)一律视为「宿主没说」,仍由 scope 定夺——这样镜像陈旧或未绑定时都不会再让读数复活。
41
+ - 回归测试 `tests/issue46-namespace-binding.spec.tsx` 14 条,覆盖:镜像后到时的重绑(原缺陷)、候选表顺序、`include:` 包裹的 entry id、声明名绝不被绑定、宿主停服时撤回、只开一个 watch;并钉住「死 scope 读作显示」这一现象本身与 Host 优先级。渲染层新增三条(Host 报关闭则隐藏、Host 报开启则显示、无该字段则沿用 scope)。**变异验证**:去掉 Host 优先级判断 → 1 红;把声明名排到候选表首位 → 3 红。
42
+
3
43
  ## 4.3.0 (2026-10-11)
4
44
 
5
45
  > **升级前请读这一条(行为变更)**:账号列表里的「删除」在 4.3.0 起是**「暂时隐藏」**——删掉后立即从列表消失,也不会被自动轮询带回来,但它是**可找回**的:点「重新检测账号」会把**所有**隐藏的账号一次性全部找回(不是只找回你刚删的那个),重新登录或导入同一账号也会清除该账号的隐藏记录。如果你的习惯是「删掉就永远不再看到它」,请在升级前确认这一语义可以接受。桌面 App 自身的登录**始终不被改动**——删除只是「本插件不再列出它」。上一版(4.2.0)不存在隐藏记录,因此**没有历史状态需要迁移**。
package/README.en.md CHANGED
@@ -215,6 +215,25 @@ dsh plugin --profile <web|desktop|dsh-tui> exec dsh-connect-workbuddy logout #
215
215
 
216
216
  `status` and `doctor` accept `--json` for machine-readable output.
217
217
 
218
+ > **Why these three commands work on a machine with nothing installed (issue #47)**
219
+ >
220
+ > The plugin declares `@deepseek-ai/dsh-atomic-write`, `@deepseek-ai/dsh-home-paths`, and the rest of the DSH packages as **peers**: the host supplies them and the plugin ships no copy. That works inside a **booted DSH process** (the host's runtime interception layer resolves those names against the installation), but `dsh plugin … exec` is not that — it runs one `pnpm exec` in the profile directory, and that child uses **stock Node resolution**. The profile is created with `autoInstallPeers: false`, so the peers are never installed there and the two packages cannot be found.
221
+ >
222
+ > Up to 4.3.1 this was a **load-time** crash (`ERR_MODULE_NOT_FOUND`), naming a file that looked unrelated to the command:
223
+ >
224
+ > ```
225
+ > Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@deepseek-ai/dsh-atomic-write'
226
+ > imported from …/profiles/dsh-tui/node_modules/dsh-connect-workbuddy/lib/host-heartbeat-*.js
227
+ > ```
228
+ >
229
+ > The reason is bundling: `auth`, `host-heartbeat`, `at-rest`, and `pi-ai-runtime` are emitted as **one shared chunk**, so the moment `lib/bin.js` imported it, the top-level import in `auth.ts` was evaluated — even for a read-only `status`. **From 4.3.2** those two packages are host-first and loaded on demand: the host's copy still wins where the host is present (keeping one cross-process write protocol shared with the host), and a small built-in implementation takes over where it is not. `exec` therefore works on a clean machine, and `doctor` prints which one is in use:
230
+ >
231
+ > ```
232
+ > Atomic writes: built-in copy (host peer not resolvable here; normal outside a booted DSH)
233
+ > ```
234
+ >
235
+ > Inside the repository checkout (peers present) it reports `host-provided (@deepseek-ai/dsh-atomic-write)` instead. The guard lives in `tests/host-peers.spec.ts`: it scans the **built output** and fails as soon as `lib/bin.js`'s dependency graph contains a bare package name again.
236
+
218
237
  ## Development
219
238
 
220
239
  ```sh
@@ -253,6 +272,7 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
253
272
  - Users upgrading from before 2.0.0 may still carry the old top-level `accountId` (the migration source). It applies only to its own region and **stops applying once that region is explicitly cleared (`accounts.<region> = ""`)** — otherwise the account you cleared would be silently restored at the next startup. The card shows whether a region is "using the saved account" or "following the app's current sign-in": the restored default is usually the very account you just cleared, so without that line there is no way to tell a successful clear from a no-op.
254
273
  - **On Windows, an account change may fail to save while the profile's `cordis.patch.yml` is held open.** DSH updates that file by writing a temp file and replacing the target; an antivirus scanner, a sync client (OneDrive), or an open editor can lock it briefly, and once the replacement retries are exhausted the failed write does **not** make `set()` reject. The plugin therefore reads the value back after writing: it shows "cleared" **only once the write is confirmed on disk**, and on failure it reports the error and keeps your previous choice (never a false confirmation that the old selection silently contradicts). Close whatever holds the file and retry.
255
274
  - Windows and Linux credential paths are derived from platform conventions. On a real Windows 11 machine, `%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth` and `%LOCALAPPDATA%\Programs\WorkBuddy\WorkBuddy.exe` **did resolve**, but **the remaining candidates and the Linux paths are still not verified on real hardware**; set `WORKBUDDY_AUTH_FILE` if yours lives elsewhere.
275
+ - **The standalone CLI (`dsh plugin … exec`) does not carry the host-supplied peer packages**, so outside the host the plugin falls back to a built-in atomic-write implementation (`src/host-peers.ts`). It transcribes the host's behavioural contract (the `<file>.lock` naming, the Windows `EACCES`/`EBUSY`/`EPERM` retries and their cadence) and is used **only** when those two packages cannot be resolved — inside the host, and in this checkout, the host's or the dependency's copy is still used. `doctor` reports which one is in effect. See the issue #47 note under [Command line](#command-line).
256
276
  - **Encrypted credentials require the desktop app to be present.** Newer desktop builds (**from 5.6.0, on macOS as well as Windows**) encrypt the token fields in the auth file, and the plugin needs that app's own native binding to obtain the field key. If the app is not installed, was uninstalled, or lives outside the probed paths (on Windows the install registration is consulted automatically, so a non-default install is found without configuration; set `WORKBUDDY_APP_EXECUTABLE` if that misses too), an encrypted file cannot be read — the account then shows as signed out, and the plugin **never** degrades into emitting a truncated token. `doctor` reports whether this capability is available. The key is a build-time constant that may rotate with app releases; the plugin fetches it from the local app each run and caches nothing on disk, so an app upgrade needs no plugin upgrade.
257
277
  - **The executable is located from the bundle's own declaration, not guessed from the app name.** The WorkBuddy macOS bundles declare `CFBundleExecutable` as `Electron` (not `WorkBuddy`), so the plugin reads each bundle's `Info.plist` to learn the binary's name. Both the domestic `WorkBuddy.app` and the international `WorkBuddy AI.app` are candidates, and an app filed into a subdirectory of an applications folder (e.g. `/Applications/IDE/WorkBuddy.app`) is found by a one-level scan that confirms each candidate's bundle identifier before use — so a different Electron app can never be launched by mistake.
258
278
  - **"Signed out" and "your sign-in cannot be read" are reported separately.** When an encrypted file cannot be read, the advice is to install the desktop app or point `WORKBUDDY_APP_EXECUTABLE` at it — **not** to sign in again, which cannot help: the credential is fine, the key is what is missing.
package/README.md CHANGED
@@ -225,6 +225,25 @@ dsh plugin --profile <web|desktop|dsh-tui> exec dsh-connect-workbuddy logout #
225
225
 
226
226
  `status` 与 `doctor` 支持 `--json` 输出机器可读格式。
227
227
 
228
+ > **为什么这三条命令能在「什么都没装」的机器上跑(issue #47)**
229
+ >
230
+ > 该插件把 `@deepseek-ai/dsh-atomic-write`、`@deepseek-ai/dsh-home-paths` 等一整套 DSH 包声明为 **peer**:由宿主提供,插件不自己带一份。这在**已启动的 DSH 进程内**工作正常(宿主有运行期拦截层负责按宿主安装位置解析这些名字),但 `dsh plugin … exec` 不是那种环境——它就是在 profile 目录里跑一次 `pnpm exec`,子进程用**普通 Node 解析规则**,而 profile 是按 `autoInstallPeers: false` 建的,peer 根本不会装进去。于是这两个包解析不到。
231
+ >
232
+ > 4.3.1 及更早版本会因此在**加载期**直接崩溃(`ERR_MODULE_NOT_FOUND`),且报错文件看起来和命令无关:
233
+ >
234
+ > ```
235
+ > Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@deepseek-ai/dsh-atomic-write'
236
+ > imported from …/profiles/dsh-tui/node_modules/dsh-connect-workbuddy/lib/host-heartbeat-*.js
237
+ > ```
238
+ >
239
+ > 原因是打包把 `auth` / `host-heartbeat` / `at-rest` / `pi-ai-runtime` 合并进了**同一个 chunk**,`lib/bin.js` 一 import 它,`auth.ts` 里那句顶层 import 就被求值了——连只读的 `status` 也一样。**4.3.2 起**这两个包改为「宿主优先、按需加载」:宿主在场时仍用宿主的那份(保持与宿主共享同一套跨进程写协议),不在场时回落到插件自带的小实现。所以现在 `exec` 在干净机器上可用,`doctor` 也会把当前用的是哪一份打印出来:
240
+ >
241
+ > ```
242
+ > Atomic writes: built-in copy (host peer not resolvable here; normal outside a booted DSH)
243
+ > ```
244
+ >
245
+ > 在仓库里跑(peer 齐全)则显示 `host-provided (@deepseek-ai/dsh-atomic-write)`。这条守卫写在 `tests/host-peers.spec.ts`:它直接扫描**构建产物**,一旦 `lib/bin.js` 的依赖图里再出现裸包名就变红。
246
+
228
247
  ## 开发
229
248
 
230
249
  ```sh
@@ -263,6 +282,7 @@ dsh plugin --profile desktop add /Users/dmh2002/DshProject/dsh-connect-workbuddy
263
282
  - 从 2.0.0 之前的版本升级而来的用户,设置里可能仍留有旧版的顶层 `accountId`(迁移来源)。它只作用于其所属区域,且**该区域被显式清除(`accounts.<区域> = ""`)后即不再生效**——否则清除掉的账号会在下次启动时被静默恢复。卡片会显示每个区域当前是「使用保存的账号」还是「跟随 App 当前登录」:清除后回到的默认账号往往与刚清掉的账号是同一个,没有这行提示就无法分辨清除是否生效。
264
283
  - **Windows:profile 的配置文件 `cordis.patch.yml` 被占用时,账号改动可能无法保存**。DSH 用「写临时文件 + 覆盖替换」的方式更新该文件,而杀毒软件、OneDrive 等同步盘或正在打开该文件的编辑器会短暂锁住它;重试耗尽后,写入失败**不会**让 `set()` 报错。插件因此会在写入后回读校验:**确认落盘才显示「已清除」**,失败则给出明确错误并保留原来的选择(不会显示一个旧选择默默反驳的假确认)。遇到该提示时,关闭占用该文件的程序后重试即可。
265
284
  - Windows / Linux 的凭据默认路径按平台约定推导。已在真实 Windows 11 上确认 `%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth` 与 `%LOCALAPPDATA%\Programs\WorkBuddy\WorkBuddy.exe` 命中,**其余候选与 Linux 路径仍未经真机验证**;必要时可通过环境变量 `WORKBUDDY_AUTH_FILE` 指定实际位置。
285
+ - **独立 CLI(`dsh plugin … exec`)不带宿主提供的 peer 包**,因此插件在**宿主之外**运行时会回落到自带的原子写实现(`src/host-peers.ts`)。它转写了宿主的行为契约(锁文件命名 `<file>.lock`、Windows 的 `EACCES`/`EBUSY`/`EPERM` 重试与节奏),并且只在这两个包解析不到时才启用——宿主内、以及仓库检出里都仍用宿主/依赖里的那份。`doctor` 会报告当前用的是哪一份。详见[命令行](#命令行)一节的 issue #47 说明。
266
286
  - **加密凭据依赖桌面 App 在场**:新版桌面端(**5.6.0 起,macOS 与 Windows 同样如此**)加密了 auth 文件里的 token 字段,插件需要调用该 App 自身的原生绑定才能取回字段密钥。因此 App 若未安装、被卸载,或安装在探测路径之外(**Windows 上会自动回退查安装注册表**,仍找不到时用 `WORKBUDDY_APP_EXECUTABLE` 指定),加密文件就读不出来——此时账号会显示为未登录,而**不会**退化成读出一个残缺的 token。`doctor` 会报告这项能力是否可用。该密钥是构建期常量,随 App 版本可能轮换;插件每次向本机 App 现取,不缓存到磁盘,所以 App 升级后无需升级插件。
267
287
  - **App 可执行文件按 bundle 自身声明定位,不按 App 名猜**:WorkBuddy 的 macOS bundle 里 `CFBundleExecutable` 是 `Electron`(不是 `WorkBuddy`),因此插件读取 bundle 的 `Info.plist` 来决定二进制名。国内版 `WorkBuddy.app` 与国际版 `WorkBuddy AI.app` 都在候选内;App 被归入 `/Applications` 子目录(如 `/Applications/IDE/WorkBuddy.app`)时,插件还会向下扫一层并用 bundle id 确认身份后才使用——**不会**因为同名的其他 Electron 应用而误启动它。
268
288
  - **「未登录」与「登录信息读不出来」是两件事**:加密文件读不出时,插件给出的建议是「安装桌面 App / 用 `WORKBUDDY_APP_EXECUTABLE` 指定其位置」,而**不是**「重新登录一次」——后者对这种情况无效(凭据本身是好的,缺的是密钥)。
@@ -49,7 +49,8 @@
49
49
  ## 四、本项目的使用方式
50
50
 
51
51
  - **借鉴设计思路 + 独立实现**:关键模块均为本项目独立编写,并在各源文件头部注释中标注所参考的具体项目与模式。
52
- - **唯一一处代码移植**:`src/dsml-recovery.ts` 是 [`hawklithm/workbuddy2api`](https://github.com/hawklithm/workbuddy2api) 的 `src/codebuddy_proxy/dsml_parser.py`(MIT,Copyright (c) 2026 Mayer)的 **TypeScript 移植**,不是思路参照。该文件的头部注释逐条列出了三处**有意偏离**(参考实现里重复定义的 `find_invoke_blocks`、其流式注入与自身解析器输出形状不一致、以及它没有的两道闸)。按 MIT 条款,本项目保留其版权与许可声明,并在上表与本条中同时标注。
52
+ - **唯一一处跨项目代码移植**:`src/dsml-recovery.ts` 是 [`hawklithm/workbuddy2api`](https://github.com/hawklithm/workbuddy2api) 的 `src/codebuddy_proxy/dsml_parser.py`(MIT,Copyright (c) 2026 Mayer)的 **TypeScript 移植**,不是思路参照。该文件的头部注释逐条列出了三处**有意偏离**(参考实现里重复定义的 `find_invoke_blocks`、其流式注入与自身解析器输出形状不一致、以及它没有的两道闸)。按 MIT 条款,本项目保留其版权与许可声明,并在上表与本条中同时标注。
53
+ - **`src/host-peers.ts` 的例外说明(issue #47)**:该文件里的 `writeFileAtomic` / `withFileLock` 两个后备实现**转写自 `@deepseek-ai/dsh-atomic-write`,`resolveDshHome` 转写自 `@deepseek-ai/dsh-home-paths`**——参数语义、锁文件命名(`<file>.lock`)、Windows 瞬时错误集合与重试节奏都照抄,只有这样才能与宿主进程共用同一个跨进程写协议。这两者属于上文排除项中的 **`@deepseek-ai/*` 通用平台依赖**(其版权与许可随各自 npm 包携带),且均为零依赖、无 cordis、无进程级状态的纯工具函数。此处转写的是**行为契约**而非源文件;之所以不直接 import,见该文件头部与 `tests/host-peers.spec.ts`(静态 import 会让独立 CLI 在缺 peer 的机器上于加载期崩溃)。
53
54
  - **除此之外不复制源码**:其余各参考项目均只借鉴设计思路,未复制、修改或再分发其源文件。
54
55
  - **版权声明保留**:所有参考项目的版权归各自作者所有。
55
56
  - **后续引入依赖时**:引入任何新的 WorkBuddy 相关外部依赖、或复用其他项目代码时,必须同步更新本文件,并遵守对应许可证的署名与声明要求。
package/docs/WINDOWS.md CHANGED
@@ -118,6 +118,8 @@ WMI 服务(`Win32_Process.Create`)创建的进程与 Electron 宿主同为
118
118
 
119
119
  **关于 issue #13 重试预算的更正**:本文件此前写作「重试预算(200+600ms)」。实测并核对上游实现(`@deepseek-ai/dsh-atomic-write`,rc.1 与 rc.2 常量相同:initial 20ms、max 200ms、limit 8)后,真实预算是**延迟序列 20+40+80+160+200×4 ≈ 1100ms**(实测端到端 1149–1165ms,含 `rename` 调用开销);**不存在 600ms 这一项**。真机实测的持有期-结果对照:持有 0/100/300/600/1000ms 后释放 → 全部成功;持有 60s(超出预算)→ `EPERM`,且目标文件**保持原内容不变**(不产生半写文件)。
120
120
 
121
+ **同一预算的第二份实现(issue #47,4.3.2)**:`src/host-peers.ts` 现在带一份后备的 `writeFileAtomic` / `withFileLock`,用于**宿主之外**(`dsh plugin … exec` 的子进程里 peer 解析不到时)。它把上文的四个常量(initial 20 / max 200 / limit 8)与 Windows 瞬时错误集合(`EACCES`/`EBUSY`/`EPERM`)**逐字转写**过来——两份实现若预算不同,同一个文件上 CLI 与宿主就会按不同节奏争锁,「谁先拿到」将取决于进程身份而不是锁本身。**平台语义与 §1-4 完全一致**:`isTransientWindowsRenameError` 同样在 `process.platform !== 'win32'` 时短路,因此 macOS/Linux 上这份后备同样**不进重试分支**。宿主在场时(宿主内、以及含 peer 的检出里)压根不会走到这份后备,改动它不影响已取证路径;改动**上游**那份时,这条要一起核对。守卫:`tests/host-peers.spec.ts` 钉住锁的互斥性与释放(含异常路径),但**不钉具体毫秒数**——预算是上游的行为契约,不是本插件的可调参数。
122
+
121
123
  ## 4. 改动检查清单(改任何平台相关代码前过一遍)
122
124
 
123
125
  - [ ] 路径是否全部经 `path.join`?有无写死 `/` 或 `\`?
@@ -126,6 +128,7 @@ WMI 服务(`Win32_Process.Create`)创建的进程与 Electron 宿主同为
126
128
  - [ ] 若走外部命令:Windows 上有无等价物?(`ps -o lstart=` → PowerShell;`which` → `where`)
127
129
  - [ ] **若 spawn 子进程:是否设了 `windowsHide: true`?** 宿主无控制台,漏设即闪黑框(§1-5)。注意**在终端里验证不出来**——那种形态下子进程只是继承控制台
128
130
  - [ ] **若测试起子进程占住文件:清场前是否 `await` 了它的 `exit`?** `kill()` 只投递信号,句柄在其后数毫秒才关闭;此窗口内 `rm` 必得 `EBUSY`(§3 末)。窗口是竞态,**CI 绿不代表真机绿**
131
+ - [ ] **若改动 `src/host-peers.ts` 的后备实现:是否与上游 `@deepseek-ai/dsh-atomic-write` 的锁命名、Windows 瞬时错误集合与重试节奏仍一致?** 两份实现按同一预算争同一个 `.lock`,任一侧漂移都会让「谁先拿到锁」取决于进程身份(§3 末的「同一预算的第二份实现」)
129
132
  - [ ] 新增测试是否**在任意主机上都能跑**(注入 `platform`/`home`/`env`),而不是断言「跑测试的机器是什么平台」?
130
133
  - [ ] 结论措辞是否与§2 的证据档位相称?(C 档不要写成「已验证」)
131
134
  - [ ] 本文件与两份 README 是否同步?
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, 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-jNlsUMZO.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-DyU_tIIj.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -19,7 +19,14 @@ import { fileURLToPath } from "node:url";
19
19
  *
20
20
  * @module dsh-connect-workbuddy/bin
21
21
  */
22
- const JSON_SCHEMA_VERSION = 3;
22
+ /**
23
+ * Version of the `doctor --json` document.
24
+ *
25
+ * 3 → 4: added `hostPeers` (issue #47). Consumers that switch on the version
26
+ * see the field appear rather than having to guess whether its absence means
27
+ * "old build" or "no peer".
28
+ */
29
+ const JSON_SCHEMA_VERSION = 4;
23
30
  /** Both regions, in reporting order. */
24
31
  const REGIONS = ["cn", "global"];
25
32
  /** Region labels for human output. */
@@ -81,6 +88,17 @@ async function doctor(jsonOutput) {
81
88
  * or reports key material.
82
89
  */
83
90
  const piAi = piAiRuntimeInfo();
91
+ /**
92
+ * Which atomic-write implementation this process would use (issue #47).
93
+ *
94
+ * The mirror of the `piAiRuntime` block below, for the same reason: the
95
+ * plugin declares `@deepseek-ai/dsh-atomic-write` as a PEER, so inside a
96
+ * booted host the host's copy is borrowed, while under `dsh plugin …
97
+ * exec` the child resolves with stock Node rules and the peer is absent. A
98
+ * missing peer used to be a load-time crash; it is now reported here so a
99
+ * user can tell the two states apart without reading a stack trace.
100
+ */
101
+ const hostPeers = await hostPeersInfo();
84
102
  const report = {
85
103
  schemaVersion: JSON_SCHEMA_VERSION,
86
104
  package: "dsh-connect-workbuddy",
@@ -93,6 +111,10 @@ async function doctor(jsonOutput) {
93
111
  resolvedLocally: piAi.resolvedLocally,
94
112
  adapterBehaviour: piAi.generation === "legacy" ? "folds 0.87 transcripts (system text + tool state) into the 0.85 context shape" : piAi.resolvedLocally ? "passes provider contexts through unchanged" : "passes provider contexts through unchanged (pi-ai comes from the DSH host; 0.2.0+ ships 0.87.1)"
95
113
  },
114
+ hostPeers: {
115
+ atomicWrite: hostPeers.atomicWrite,
116
+ behaviour: hostPeers.atomicWrite === "host" ? "using @deepseek-ai/dsh-atomic-write from the DSH host" : "using the built-in copy: the host peer did not resolve here, which is the normal state outside a booted DSH (e.g. `dsh plugin … exec`)"
117
+ },
96
118
  desktopAuthFile: {
97
119
  path: anyStore.desktopAuthPath() ?? "(no platform default; set WORKBUDDY_AUTH_FILE)",
98
120
  dir: defaultDesktopAuthDirs()[0] ?? "(no platform default)",
@@ -154,6 +176,7 @@ async function doctor(jsonOutput) {
154
176
  else process.stdout.write([
155
177
  `WorkBuddy Connect ${WORKBUDDY_CONNECT_VERSION} on ${process.version}`,
156
178
  ...piAi.resolvedLocally ? [`pi-ai runtime: ${piAi.generation}${piAi.version === void 0 ? "" : ` ${piAi.version}`} (${piAi.resolvedFrom}) — ${report.piAiRuntime.adapterBehaviour}`] : [`pi-ai runtime: ${piAi.generation}, host-provided (no local copy; DSH 0.2.0+ ships 0.87.1) — passes provider contexts through unchanged`],
179
+ `Atomic writes: ${hostPeers.atomicWrite === "host" ? "host-provided (@deepseek-ai/dsh-atomic-write)" : "built-in copy (host peer not resolvable here; normal outside a booted DSH)"}`,
157
180
  `Desktop auth file: ${report.desktopAuthFile.present ? "present" : "missing"} (${report.desktopAuthFile.path})`,
158
181
  `Encrypted-credential support: ${report.atRestDecryption.available ? "available" : "unavailable"} (${report.atRestDecryption.appExecutable}${report.atRestDecryption.appExecutableSource === void 0 ? "" : `, via ${report.atRestDecryption.appExecutableSource}`})`,
159
182
  `Host bundle: ${hostAlive ? `running (pid ${heartbeat.pid})` : heartbeat !== void 0 ? "stale heartbeat (process exited)" : "not started"}`,
package/lib/client.js CHANGED
@@ -309,6 +309,23 @@ window.__ModuleLoader__.load({
309
309
  * fallback and must not import Node-only host code.
310
310
  */
311
311
  const WORKBUDDY_SETTINGS_NS = "workbuddy";
312
+ /**
313
+ * Every namespace spelling the browser half follows when looking for the
314
+ * settings form the Host actually serves, in preference order.
315
+ *
316
+ * `describe()` keys a form by its Loader entry id, and the profile patch picks
317
+ * that id — so the plugin must ASK rather than assume. The observed spellings
318
+ * are the bare package name, the `include:`-wrapped id the live Desktop host
319
+ * uses, and the declared name as the last resort. `configForms.whileServed`
320
+ * follows all of them and re-runs when the mirror arrives, which is what keeps
321
+ * the binding correct on a host whose mirror populates after `apply` (the
322
+ * 4.3.0 readout defect, issue #46).
323
+ */
324
+ const WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES = [
325
+ "dsh-connect-workbuddy",
326
+ "include:dsh-connect-workbuddy",
327
+ WORKBUDDY_SETTINGS_NS
328
+ ];
312
329
  //#endregion
313
330
  //#region src/client/account-selection.ts
314
331
  /**
@@ -4563,6 +4580,17 @@ window.__ModuleLoader__.load({
4563
4580
  if (usage.status !== "signed-in") return void 0;
4564
4581
  return usage.credits?.total;
4565
4582
  }
4583
+ /**
4584
+ * The Host's per-region readout switch off a usage document, when it carries one.
4585
+ *
4586
+ * The `error` variant has no data at all, so it can only ever answer `undefined`
4587
+ * — "the Host did not say", which leaves the settings scope in charge.
4588
+ */
4589
+ function hostCreditsShownOf(usage) {
4590
+ if (usage === void 0 || usage.status === "error") return void 0;
4591
+ const value = usage.showCreditsInMainUi;
4592
+ return typeof value === "boolean" ? value : void 0;
4593
+ }
4566
4594
  /** Group the digits so a four-figure balance stays readable in a narrow row. */
4567
4595
  function formatCredits(value) {
4568
4596
  return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
@@ -4649,8 +4677,9 @@ window.__ModuleLoader__.load({
4649
4677
  if (Array.isArray(data.accounts)) setAccountCredits(data.accounts);
4650
4678
  } catch {}
4651
4679
  }, [activeRegion]);
4680
+ const hostHidden = hostCreditsShownOf(usage) === false;
4652
4681
  (0, react.useEffect)(() => {
4653
- if (owned === void 0) return void 0;
4682
+ if (owned === void 0 || hostHidden) return void 0;
4654
4683
  let cancelled = false;
4655
4684
  let timer;
4656
4685
  const controller = new AbortController();
@@ -4665,7 +4694,11 @@ window.__ModuleLoader__.load({
4665
4694
  if (timer !== void 0) window.clearInterval(timer);
4666
4695
  controller.abort();
4667
4696
  };
4668
- }, [owned, fetchUsage]);
4697
+ }, [
4698
+ owned,
4699
+ hostHidden,
4700
+ fetchUsage
4701
+ ]);
4669
4702
  (0, react.useEffect)(() => {
4670
4703
  if (!open || activeRegion === void 0) return void 0;
4671
4704
  const controller = new AbortController();
@@ -4714,6 +4747,22 @@ window.__ModuleLoader__.load({
4714
4747
  }
4715
4748
  };
4716
4749
  if (owned === void 0) return null;
4750
+ /**
4751
+ * The Host's own answer for this region, when the document carries one.
4752
+ *
4753
+ * The Host is the AUTHORITY on whether this readout is wanted, exactly as the
4754
+ * card's `creditsShownOn` treats it — and here it is also the fix for a defect
4755
+ * shipped in 4.3.0 (issue #46): the gate's decision rests on the settings
4756
+ * scope, which on a host whose describe mirror lands late was bound to a
4757
+ * namespace nothing serves. Every read of that scope is then `undefined`, and
4758
+ * the opt-out rule (`!== false`) reads "shown" forever — so unticking the
4759
+ * checkbox wrote correctly and changed nothing on screen.
4760
+ *
4761
+ * Checked AFTER the fetch rather than before it because the answer only
4762
+ * arrives with the document. The cost is one usage read for a readout the user
4763
+ * may have hidden, against a persistent wrong number on screen.
4764
+ */
4765
+ if (hostCreditsShownOf(usage) === false) return null;
4717
4766
  const signedIn = usage?.status === "signed-in" ? usage : void 0;
4718
4767
  const total = signedIn === void 0 ? void 0 : creditsOf(signedIn);
4719
4768
  const signedOut = usage !== void 0 && usage.status === "signed-out";
@@ -4900,11 +4949,22 @@ window.__ModuleLoader__.load({
4900
4949
  * The region's own switches are applied HERE rather than inside the component,
4901
4950
  * so a region that is switched off (or whose provider is off) starts no fetch
4902
4951
  * loop at all.
4952
+ *
4953
+ * `hostValue` is the Host's own `showCreditsInMainUi` answer for this region,
4954
+ * when a usage document carrying it is already in hand. It OUTRANKS the settings
4955
+ * scope, exactly as the card's `creditsShownOn` does and for the same measured
4956
+ * reason: the browser's settings mirror is documented as stale for this
4957
+ * namespace on the affected deployments, and — the shipped 4.3.0 defect (issue
4958
+ * #46) — the scope can be bound to a namespace nothing serves, where every read
4959
+ * is `undefined` and the opt-out rule then reads "shown" forever. The Host is
4960
+ * the authority; the scope is the fallback for a Host line that predates the
4961
+ * field.
4903
4962
  */
4904
- function composerCreditsRegionOf(projection, scope) {
4963
+ function composerCreditsRegionOf(projection, scope, hostValue) {
4905
4964
  const provider = selectedProviderOf(projection);
4906
4965
  const region = provider === void 0 ? void 0 : WORKBUDDY_COMPOSER_PROVIDERS[provider];
4907
4966
  if (region === void 0) return void 0;
4967
+ if (typeof hostValue === "boolean") return hostValue ? region : void 0;
4908
4968
  const value = scope === void 0 ? void 0 : unwrapVolatileDeep(scope.getSnapshot().value);
4909
4969
  if (!regionCreditsShownOf(value, region)) return void 0;
4910
4970
  if (!regionEnabledOf(value, region)) return void 0;
@@ -5510,17 +5570,10 @@ window.__ModuleLoader__.load({
5510
5570
  const softGet = (name) => ctx.get(name);
5511
5571
  let settingsScope;
5512
5572
  const forms = softGet("configForms");
5513
- if (forms !== void 0) {
5514
- let ns = WORKBUDDY_SETTINGS_NS;
5515
- try {
5516
- const served = (forms.describe().getSnapshot().view?.namespaces ?? []).find((entry) => entry.ns === "workbuddy" || /workbuddy/i.test(entry.ns));
5517
- if (served !== void 0) ns = served.ns;
5518
- } catch {}
5519
- settingsScope = forms.get(ns);
5520
- }
5573
+ /** Register one card slot; returns its disposer, or undefined if it failed. */
5521
5574
  const registerCard = (slotName, key) => {
5522
5575
  try {
5523
- ctx.slots.inject(slotName, () => ctx.slots.register({
5576
+ return ctx.slots.inject(slotName, () => ctx.slots.register({
5524
5577
  name: slotName,
5525
5578
  key,
5526
5579
  priority: 30,
@@ -5531,10 +5584,9 @@ window.__ModuleLoader__.load({
5531
5584
  }, WorkBuddyCard));
5532
5585
  } catch (error) {
5533
5586
  console.error(`[dsh-connect-workbuddy] card slot "${slotName}" failed to register (host provider unaffected):`, error);
5587
+ return;
5534
5588
  }
5535
5589
  };
5536
- registerCard("plugins.bundle.config", "dsh-connect-workbuddy");
5537
- registerCard("plugins.row.config", "dsh-connect-workbuddy#dsh-connect-workbuddy");
5538
5590
  /**
5539
5591
  * Credit readout in the composer tool row (`conversation.input.left`, a
5540
5592
  * session-scope list slot).
@@ -5555,7 +5607,7 @@ window.__ModuleLoader__.load({
5555
5607
  */
5556
5608
  const registerComposerCredits = () => {
5557
5609
  try {
5558
- ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
5610
+ return ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
5559
5611
  name: "conversation.input.left",
5560
5612
  id: "dsh-connect-workbuddy-composer-credits",
5561
5613
  order: 4,
@@ -5567,9 +5619,84 @@ window.__ModuleLoader__.load({
5567
5619
  }, ComposerCreditsGate));
5568
5620
  } catch (error) {
5569
5621
  console.error("[dsh-connect-workbuddy] composer credits slot failed to register (host provider unaffected):", error);
5622
+ return;
5623
+ }
5624
+ };
5625
+ /**
5626
+ * Every slot this plugin contributes, and how to (re)create them.
5627
+ *
5628
+ * Kept as one list because the binding of `settingsScope` and the slot
5629
+ * registrations must move TOGETHER: see {@link bindSettingsScope} for why a
5630
+ * late-arriving namespace has to produce fresh registrations rather than a
5631
+ * mutated closure.
5632
+ */
5633
+ /**
5634
+ * Disposers of the slots registered for the CURRENT namespace binding.
5635
+ *
5636
+ * Declared before {@link bindSettingsScope} because that function closes
5637
+ * over it and reassigns it; the binding is only armed at the end of `apply`,
5638
+ * so this is initialised by then either way — hoisting it keeps that
5639
+ * ordering a non-requirement rather than a trap.
5640
+ */
5641
+ let liveDisposers = [];
5642
+ const registerAllSlots = () => [
5643
+ registerCard("plugins.bundle.config", "dsh-connect-workbuddy"),
5644
+ registerCard("plugins.row.config", "dsh-connect-workbuddy#dsh-connect-workbuddy"),
5645
+ registerComposerCredits()
5646
+ ].filter((disposer) => disposer !== void 0);
5647
+ /**
5648
+ * Bind `settingsScope` to whichever namespace the Host really serves, and
5649
+ * RE-REGISTER every slot when that answer changes.
5650
+ *
5651
+ * Why this is not a one-shot read at startup — the shipped 4.3.0 defect
5652
+ * (issue #46): the mirror is filled by an async `remote.settings.describe()`,
5653
+ * so at `apply` time `view` is usually still absent. The old code read that
5654
+ * absence as "the declared name must be right", bound
5655
+ * `forms.get('workbuddy')`, and never looked again. `ConfigFormController.
5656
+ * derive()` matches the namespace EXACTLY, so the guessed form stayed
5657
+ * `unavailable` forever; the composer gate — reading only that scope —
5658
+ * judged the opt-out switch `undefined !== false` and kept rendering the
5659
+ * readout. Unticking the box wrote `false` correctly and changed nothing on
5660
+ * screen.
5661
+ *
5662
+ * `whileServed` is the Host's own idiom for this (`mirror.subscribe` plus an
5663
+ * immediate sync). It is strictly better than re-reading the snapshot by
5664
+ * hand because it ALSO withdraws the binding if the Host stops serving our
5665
+ * namespace, and it hands `register` the set of namespaces actually served
5666
+ * — so the choice is made from live data instead of a captured guess.
5667
+ *
5668
+ * `register` runs on the unserved→served EDGE, not on every mirror update
5669
+ * (see the service's `sync`), so the disposal below is not a churn loop: it
5670
+ * clears whatever a previous edge left registered. That replacement is NOT
5671
+ * optional, because the renderer caches each entry's inject result by object
5672
+ * identity (`rootInjectCache` / `cachedRootInject` in
5673
+ * `dsh-client-ui-renderer`: `if (!props) { props = runInject(...) }`), so an
5674
+ * entry registered before the mirror landed is never re-evaluated. Late
5675
+ * binding can therefore only work if the OLD registrations are disposed and
5676
+ * NEW ones created — which is what this callback does.
5677
+ */
5678
+ const bindSettingsScope = () => {
5679
+ if (forms === void 0 || typeof forms.whileServed !== "function") {
5680
+ settingsScope = forms?.get(WORKBUDDY_SETTINGS_NS);
5681
+ liveDisposers = registerAllSlots();
5682
+ return;
5570
5683
  }
5684
+ ctx.effect(() => forms.whileServed(WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES, (served) => {
5685
+ const ns = WORKBUDDY_SETTINGS_NAMESPACE_CANDIDATES.find((candidate) => served.has(candidate)) ?? [...served].find((entry) => /workbuddy/i.test(entry));
5686
+ settingsScope = ns === void 0 ? void 0 : forms.get(ns);
5687
+ for (const dispose of liveDisposers) try {
5688
+ dispose();
5689
+ } catch {}
5690
+ liveDisposers = registerAllSlots();
5691
+ return () => {
5692
+ for (const dispose of liveDisposers) try {
5693
+ dispose();
5694
+ } catch {}
5695
+ liveDisposers = [];
5696
+ };
5697
+ }), "dsh-connect-workbuddy: settings namespace binding");
5571
5698
  };
5572
- registerComposerCredits();
5699
+ bindSettingsScope();
5573
5700
  } catch (error) {
5574
5701
  console.error("[dsh-connect-workbuddy] client card failed to load (host provider unaffected):", error);
5575
5702
  }
@@ -1,12 +1,280 @@
1
- import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
- import { createDecipheriv, createHash } from "node:crypto";
1
+ import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { createDecipheriv, createHash, randomBytes } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join, win32 } from "node:path";
5
- import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
6
- import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
4
+ import { dirname, join, resolve, win32 } from "node:path";
7
5
  import { execFile, execFileSync } from "node:child_process";
8
6
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
7
  import { fileURLToPath } from "node:url";
8
+ //#region src/host-peers.ts
9
+ /**
10
+ * The two host utilities this plugin uses that must ALSO work in the
11
+ * standalone CLI (issue #47).
12
+ *
13
+ * WHY THIS EXISTS. `@deepseek-ai/dsh-atomic-write` and
14
+ * `@deepseek-ai/dsh-home-paths` are declared as **peer** dependencies: the DSH
15
+ * host carries them, and the plugin is supposed to borrow the host's copies.
16
+ * Inside a booted host that works — `packages/boot/app-boot`'s runtime
17
+ * interception layer answers those bare specifiers from the installation. But
18
+ * `dsh plugin --profile <p> exec dsh-connect-workbuddy …` is NOT a booted host:
19
+ * `plugin-manager`'s `runProfilePnpm` spawns `pnpm exec` in the profile
20
+ * directory with the plain environment, so the child resolves with stock Node
21
+ * rules and the interception layer is absent (DSH deliberately never sets
22
+ * `NODE_PATH`; `packages/boot/app-boot/src/index.ts` scrubs it).
23
+ *
24
+ * The profile is created with `autoInstallPeers: false`, so peers are never
25
+ * installed there either. The result on a clean machine is a load-time crash
26
+ * before any command runs:
27
+ *
28
+ * Error [ERR_MODULE_NOT_FOUND]: Cannot find package
29
+ * '@deepseek-ai/dsh-atomic-write' imported from
30
+ * …/profiles/<p>/node_modules/dsh-connect-workbuddy/lib/host-heartbeat-*.js
31
+ *
32
+ * The trigger is CHUNKING, which is why the import looked unrelated to the
33
+ * command: tsdown emits `host-heartbeat-*.js` as the shared chunk holding
34
+ * `auth`, `at-rest`, `host-heartbeat`, and `pi-ai-runtime`, so `lib/bin.js`
35
+ * reaches `auth.ts`'s atomic-write import even for a read-only `status`.
36
+ *
37
+ * WHY NOT a static import with a fallback: a static `import` of a missing
38
+ * package throws at MODULE EVALUATION time, before any fallback can run. The
39
+ * import itself has to be deferred, so both peers are loaded through
40
+ * `import()` inside the functions that need them.
41
+ *
42
+ * WHY NOT move them to `dependencies`: the host half genuinely runs against
43
+ * the host's copies, and duplicating them into every profile would add two
44
+ * packages to a market install to serve a CLI that runs a handful of times.
45
+ * More importantly these two are pure, stateless, cordis-free utilities — no
46
+ * service, no process-global state — so the host's copy and a local copy are
47
+ * interchangeable, which is exactly what makes a fallback safe here. (Contrast
48
+ * `@deepseek-ai/cordis` or pi-ai, where a second copy WOULD be wrong: cordis
49
+ * carries the service registry, and pi-ai's generation changes context shape.)
50
+ *
51
+ * WHY THE FALLBACK MIRRORS THE HOST. `writeFileAtomic` and `withFileLock`
52
+ * govern a cross-process protocol: the lock is a `wx`-created `<file>.lock`
53
+ * sibling and the commit is a same-directory temp file renamed over the
54
+ * target. A fallback with different timing or a different lock path would let
55
+ * a CLI process and the host race each other on the same file, so the retry
56
+ * cadence, the Windows transient-error set, and the `EPERM`-needs-`lstat`
57
+ * confirmation rule are transcribed from the host package rather than
58
+ * reinvented. `docs/WINDOWS.md` and `CHANGELOG.md` (2.0.x) already record the
59
+ * measured budget: delay sequence 20+40+80+160+200×4 ≈ 1100ms, not the
60
+ * "600ms" an earlier draft of the docs claimed.
61
+ *
62
+ * Inside DSH the host's own copy always wins — the dynamic import resolves it
63
+ * first, and the fallback is unreachable there. The fallback exists solely so
64
+ * the standalone CLI is self-sufficient on a machine that has only the plugin.
65
+ *
66
+ * @module dsh-connect-workbuddy/host-peers
67
+ */
68
+ const ATOMIC_WRITE = "@deepseek-ai/dsh-atomic-write";
69
+ /**
70
+ * Load one peer, preferring whatever the host provides.
71
+ *
72
+ * The host's copy is resolved through the same ESM resolver this module is
73
+ * loaded by, so the answer matches the import the plugin would otherwise have
74
+ * made. Resolution failure is not an error here: it is the documented state of
75
+ * a market install probed outside the host, and the caller falls back.
76
+ *
77
+ * Why ONE `catch` covers both "absent" and "broken": `import()` rejects with
78
+ * `ERR_MODULE_NOT_FOUND` for the first and rethrows the module's own error for
79
+ * the second, and there is nothing this plugin could do differently about
80
+ * either — it cannot repair the host's copy. Falling back is strictly better
81
+ * than crashing, and it is not SILENT: `doctor` reports which implementation
82
+ * is in use, so a host copy that is present but unloadable shows up as
83
+ * `fallback` on a machine where the host should have supplied it. Narrowing
84
+ * the catch to `ERR_MODULE_NOT_FOUND` would instead turn a corrupt host
85
+ * install into a hard crash inside a read-only `status`, which is exactly the
86
+ * failure mode issue #47 is about.
87
+ */
88
+ async function loadHostModule(specifier) {
89
+ try {
90
+ return await import(specifier);
91
+ } catch {
92
+ return;
93
+ }
94
+ }
95
+ let atomicWrite;
96
+ /** The host's atomic-write copy, or undefined when only the fallback is available. */
97
+ function hostAtomicWrite() {
98
+ atomicWrite ??= loadHostModule(ATOMIC_WRITE);
99
+ return atomicWrite;
100
+ }
101
+ /** Directory name for the default DeepSeek Harness home under the OS home. */
102
+ const DSH_HOME_DIR_NAME = ".dsh";
103
+ /** Environment variable that overrides the default DeepSeek Harness home. */
104
+ const DSH_HOME_ENV = "DSH_HOME";
105
+ /** Expand `~`, `~/`, and `~\` against the operating-system home. */
106
+ function expandHomePath(path) {
107
+ if (path === "~") return homedir();
108
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
109
+ return path;
110
+ }
111
+ /**
112
+ * Resolve the default harness home without the host package.
113
+ *
114
+ * Precedence, highest first: an explicit configured path, `$DSH_HOME`, then
115
+ * `~/.dsh`. A blank `$DSH_HOME` counts as unset, so an empty override can
116
+ * never resolve the home to the current working directory. This is the whole
117
+ * of `resolveDshHome`; the host package's other exports are not used here.
118
+ */
119
+ function fallbackResolveDshHome(configured, env = process.env) {
120
+ const fromEnv = env[DSH_HOME_ENV];
121
+ const chosen = configured ?? (fromEnv !== void 0 && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), DSH_HOME_DIR_NAME));
122
+ return resolve(expandHomePath(chosen));
123
+ }
124
+ /** Windows errors that mean temporary interference with an atomic replacement. */
125
+ const WINDOWS_TRANSIENT_RENAME_ERRORS = /* @__PURE__ */ new Set([
126
+ "EACCES",
127
+ "EBUSY",
128
+ "EPERM"
129
+ ]);
130
+ const WINDOWS_RENAME_RETRY_INITIAL_MS = 20;
131
+ const WINDOWS_RENAME_RETRY_MAX_MS = 200;
132
+ const WINDOWS_RENAME_RETRY_LIMIT = 8;
133
+ /** Whether Windows reported temporary interference with an atomic replacement. */
134
+ function isTransientWindowsRenameError(error) {
135
+ if (process.platform !== "win32") return false;
136
+ return WINDOWS_TRANSIENT_RENAME_ERRORS.has(error?.code ?? "");
137
+ }
138
+ /** Replace the target after bounded retries for transient Windows interference. */
139
+ async function renameAtomicTemp(temp, filename) {
140
+ let delay = WINDOWS_RENAME_RETRY_INITIAL_MS;
141
+ for (let retries = 0;; retries += 1) {
142
+ try {
143
+ await rename(temp, filename);
144
+ return;
145
+ } catch (error) {
146
+ if (!isTransientWindowsRenameError(error)) throw error;
147
+ if (retries >= WINDOWS_RENAME_RETRY_LIMIT) throw error;
148
+ }
149
+ await new Promise((resolve) => setTimeout(resolve, delay));
150
+ delay = Math.min(delay * 2, WINDOWS_RENAME_RETRY_MAX_MS);
151
+ }
152
+ }
153
+ /**
154
+ * Replace `filename` with `content` in one atomic step, creating parents.
155
+ *
156
+ * The content goes to a random-suffix sibling opened `wx`, so the open refuses
157
+ * to follow a symlink planted at the temp path, and the fresh inode carries
158
+ * `options.mode` through the rename. Same-directory keeps the rename on one
159
+ * filesystem; a failure removes the temp file and rethrows.
160
+ */
161
+ async function fallbackWriteFileAtomic(filename, content, options) {
162
+ await mkdir(dirname(filename), {
163
+ recursive: true,
164
+ ...options.dirMode === void 0 ? {} : { mode: options.dirMode }
165
+ });
166
+ const temp = `${filename}.${randomBytes(6).toString("hex")}.tmp`;
167
+ try {
168
+ await writeFile(temp, content, {
169
+ mode: options.mode,
170
+ flag: "wx"
171
+ });
172
+ await renameAtomicTemp(temp, filename);
173
+ } catch (error) {
174
+ await rm(temp, { force: true });
175
+ throw error;
176
+ }
177
+ }
178
+ /** Whether an exclusive create found an existing lock. */
179
+ async function isLockContention(error, lockPath) {
180
+ const code = error?.code;
181
+ if (code === "EEXIST") return true;
182
+ if (code !== "EPERM") return false;
183
+ try {
184
+ await lstat(lockPath);
185
+ return true;
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+ const LOCK_RETRY_INITIAL_MS = 20;
191
+ const LOCK_RETRY_MAX_MS = 200;
192
+ /** The host's own default: sized for a file-work-only critical section. */
193
+ const DEFAULT_LOCK_WAIT_MS = 2e3;
194
+ /**
195
+ * Hold the cross-process writer lock for `filename` around one operation.
196
+ *
197
+ * The lock is a `wx`-created `<filename>.lock` sibling; paired with the
198
+ * rename-based commit above, readers stay lock-free and only writers contend.
199
+ * An `EPERM` counts as contention only when a fresh `lstat` confirms the lock
200
+ * exists, which covers exclusive-create behavior on Windows. The contender
201
+ * never removes an existing lock: file age cannot prove its owner stopped.
202
+ */
203
+ async function fallbackWithFileLock(filename, operation, options) {
204
+ const lockPath = `${filename}.lock`;
205
+ const deadline = Date.now() + (options?.waitMs ?? DEFAULT_LOCK_WAIT_MS);
206
+ let delay = LOCK_RETRY_INITIAL_MS;
207
+ let retriedUnconfirmedPermissionError = false;
208
+ for (;;) {
209
+ try {
210
+ await writeFile(lockPath, `${process.pid}\n`, {
211
+ mode: 384,
212
+ flag: "wx"
213
+ });
214
+ break;
215
+ } catch (error) {
216
+ if (!await isLockContention(error, lockPath)) {
217
+ const code = error?.code;
218
+ if (process.platform !== "win32" || code !== "EPERM" || retriedUnconfirmedPermissionError) throw error;
219
+ retriedUnconfirmedPermissionError = true;
220
+ }
221
+ }
222
+ if (Date.now() >= deadline) throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`);
223
+ await new Promise((resolve) => setTimeout(resolve, delay));
224
+ delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS);
225
+ }
226
+ try {
227
+ return await operation();
228
+ } finally {
229
+ await rm(lockPath, { force: true });
230
+ }
231
+ }
232
+ /**
233
+ * Resolve the DeepSeek Harness home.
234
+ *
235
+ * Deliberately SYNCHRONOUS and self-contained rather than a host-first loader.
236
+ * The host package's `resolveDshHome` is a pure function of its arguments and
237
+ * `process.env` — it performs no I/O — and every call site in this plugin
238
+ * passes NO argument. The zero-argument form is `$DSH_HOME` (when non-blank)
239
+ * else `~/.dsh`, which is reproduced exactly below, so consulting the host
240
+ * copy could not change a single answer while it WOULD have forced the
241
+ * synchronous call sites (credential paths, the heartbeat path) to become
242
+ * async. The host import is therefore dropped outright.
243
+ *
244
+ * Cost of being wrong: nothing. Precedence is transcribed, not guessed, and
245
+ * the two branches are pinned by `tests/host-peers.spec.ts`.
246
+ */
247
+ function resolveDshHome(configured) {
248
+ return fallbackResolveDshHome(configured);
249
+ }
250
+ /**
251
+ * Replace `filename` with `content` atomically, under the sibling write lock.
252
+ *
253
+ * Async only because the host module is loaded lazily; inside DSH this is the
254
+ * host's own implementation.
255
+ */
256
+ async function writeFileAtomic(filename, content, options) {
257
+ const host = await hostAtomicWrite();
258
+ if (host === void 0) return fallbackWriteFileAtomic(filename, content, options);
259
+ return host.writeFileAtomic(filename, content, options);
260
+ }
261
+ /** Hold the cross-process writer lock for `filename` around one operation. */
262
+ async function withFileLock(filename, operation, options) {
263
+ const host = await hostAtomicWrite();
264
+ if (host === void 0) return fallbackWithFileLock(filename, operation, options);
265
+ return host.withFileLock(filename, operation, options);
266
+ }
267
+ /**
268
+ * Which atomic-write implementation this process would use.
269
+ *
270
+ * `doctor` reports it so the "works inside DSH, crashed under `plugin exec`"
271
+ * split behind issue #47 is visible on demand instead of being inferred from a
272
+ * stack trace. `fallback` is the expected answer outside a booted host.
273
+ */
274
+ async function hostPeersInfo() {
275
+ return { atomicWrite: await hostAtomicWrite() === void 0 ? "fallback" : "host" };
276
+ }
277
+ //#endregion
10
278
  //#region src/at-rest.ts
11
279
  /**
12
280
  * WorkBuddy desktop "at-rest" credential decryption.
@@ -3439,7 +3707,7 @@ function generationOf(version) {
3439
3707
  //#endregion
3440
3708
  //#region src/version.ts
3441
3709
  /** The npm package version this build was produced from. */
3442
- const WORKBUDDY_CONNECT_VERSION = "4.3.0";
3710
+ const WORKBUDDY_CONNECT_VERSION = "4.3.2";
3443
3711
  //#endregion
3444
3712
  //#region src/host-heartbeat.ts
3445
3713
  /**
@@ -3597,4 +3865,4 @@ async function clearHostHeartbeat() {
3597
3865
  await rm(workbuddyHostHeartbeatPath(), { force: true });
3598
3866
  }
3599
3867
  //#endregion
3600
- export { isWorkbuddyBundle as $, workbuddyAccountId as A, parseCreditMultiplier as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, WORKBUDDY_APP_EXECUTABLE_ENV as G, parseUpstreamModel as H, classifyUpstreamError as I, deriveAtRestKeyId as J, clearAtRestKeyCache as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, isEncryptedFieldWrapper as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, prepareChatBody as U, parseReasoning as V, regionOf as W, findWorkbuddyAppExecutable as X, fetchAtRestKeyPayload as Y, findWorkbuddyAppExecutableWithSource as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, macosBundleExecutable as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, workbuddyAppExecutableCandidates as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, deriveCatalog as m, clearHostHeartbeat as n, openEncryptedField as nt, workbuddyHostHeartbeatPath as o, applyContextBudgets as p, deriveAtRestKey as q, isHeartbeatProcessAlive as r, readAtRestKey as rt, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, macosNestedAppBundles as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
3868
+ export { isWorkbuddyBundle as $, workbuddyAccountId as A, parseCreditMultiplier as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, WORKBUDDY_APP_EXECUTABLE_ENV as G, parseUpstreamModel as H, classifyUpstreamError as I, deriveAtRestKeyId as J, clearAtRestKeyCache as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, isEncryptedFieldWrapper as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, prepareChatBody as U, parseReasoning as V, regionOf as W, findWorkbuddyAppExecutable as X, fetchAtRestKeyPayload as Y, findWorkbuddyAppExecutableWithSource as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, hostPeersInfo as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, writeFileAtomic as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, macosBundleExecutable as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, workbuddyAppExecutableCandidates as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, deriveCatalog as m, clearHostHeartbeat as n, openEncryptedField as nt, workbuddyHostHeartbeatPath as o, resolveDshHome as ot, applyContextBudgets as p, deriveAtRestKey as q, isHeartbeatProcessAlive as r, readAtRestKey as rt, writeHostHeartbeat as s, withFileLock as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, macosNestedAppBundles as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
package/lib/index.js CHANGED
@@ -1,10 +1,8 @@
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, 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, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, 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-jNlsUMZO.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-DyU_tIIj.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";
5
5
  import { join } from "node:path";
6
- import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
7
- import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
8
6
  import { createProvider } from "@earendil-works/pi-ai";
9
7
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
10
8
  import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
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.0",
5
+ "version": "4.3.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",