dsh-connect-workbuddy 4.6.0 → 4.6.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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.2 (2026-10-16)
4
+
5
+ > 版本号是 **patch**:修的是一个启动期缺陷,没有接口、配置或界面变更。
6
+
7
+ ### Fixes
8
+
9
+ - **shim 启动时会避开「HTTP 客户端自己拒绝」的端口**
10
+ - **症状**:某个区域(`cn` 或 `global`)的模型请求**全部失败**,报错只有一句 `Connection error.`,而 shim 本身完全健康 —— `curl`、`node:http`、裸 TCP 连上去都是 200。重启后可能好、可能照样坏。
11
+ - **根因**:`server.listen(0)` 让内核挑端口,而 Fetch 规范定义了一张**必须拒绝**的端口清单(`fetch.spec.whatwg.org#block-bad-port`,undici 实现为 `badPorts`,当前最大到 `10080`)。`fetch` 在**开 socket 之前**就拒绝这些端口,所以请求根本没上过网线。pi-ai 驱动官方 `openai` SDK,而它的 `getDefaultFetch()` 返回 `globalThis.fetch` —— **生产路径走的就是这一层**,不是只有测试才会中招(已实测 `openai@6.40.0`:坏端口报 `Connection error. → fetch failed → bad port`,正常端口正常返回)。端口只在启动时 bind 一次,不会自己重掷,所以一旦抽中就是**该区域持续失败直到插件重新加载**。
12
+ - **只在特定机器上发生**:`listen(0)` 的抽取范围是系统的动态端口范围,三平台默认值(Windows/macOS `49152-65535`、Linux `32768-60999`)**整体高于**清单最大值 `10080`,交集为空 —— 默认机器**结构上不可能中招**。只有当范围被下调到触及 `10080` 以下才会发生,例如本机的遗留注册表值 `MaxUserPort = 15000`(实测 `netsh` 报 `1024-15000`,范围内有 **19 个**被禁端口,约 `0.136%`/次绑定;每次插件加载建 2 个 shim,约 `0.27%`/次加载)。这一条**已按三方独立实测核对**:`0.105%`(60000 次采样)、`0.115%`(20000 次采样)、理论值 `0.136%`。
13
+ - **为什么难自查**:错误链上真正的 `cause`(`bad port`)被 SDK 归一化时丢弃,界面和日志都只剩 `Connection error.`,读不出是端口问题;用户唯一能做的「换个账号」恰好**救不了**(与账号无关)。顺带确认:这个拒绝与代理无关(把 `HTTP_PROXY` 指向死端口、清空 `NO_PROXY`,回环好端口仍返回 200 —— 说明回环流量根本不经过代理),也不是 `ECONNREFUSED`(后者带 `code`,而它是 `cause.message === 'bad port'`、无 `code`)。
14
+ - **改了什么**:新增 `clientRefusesPort()`(绑定后用自己的 `fetch` 打 `/healthz` 探测:**返回任何 HTTP 码都算客户端接受**,含 401;只有抛异常才算被拒)与 `listenOnClientReachablePort()`(有界重掷,上限 `PORT_ROLL_LIMIT = 5`)。用客户端自身探测而**不是**比对一张抄来的端口表:清单是规范的一部分,抄本会随规范漂移,而这里要满足的规则**本身就是客户端的**。绑定方式仍是 `listen(0)` + 仅回环(随机性不变),端口在 `ready` 之前定格,因此 `baseUrl()` 的消费方一行未改。
15
+ - **顺带修掉一个自己引入的缺陷**:`close()` 落在 `listen()` 未完成期间时,Node **既不触发 `listening` 也不触发 `error`,只触发 `close`**(实测确认)——最初的 `listenOnce` 只监听前两者,会让 `ready` 永久 pending、卡住插件卸载。已补 `close` 监听。
16
+ - **守卫**:`tests/shim.spec.ts` 新增 6 例(真实客户端识别被禁端口、健康端口不误判、拒绝与真实传输错误可区分、重掷后确实服务、上界生效不空转、首抽即通过时不重掷),外加 2 例生命周期守卫(启动期被处置不得挂起、并发 `close()` 安全)。**变异验证(4 个变异全部转红,且各自只打中应打中的用例)**:① 探测恒真(永不重绑,等价修复前)→ 红 3 例;② 探测恒假 → 红 1 例;③ 移除 `close` 监听 → 红 1 例(30s 超时,正是挂起);④ 上界失效 → 红 1 例。
17
+ - **端到端验证(跑在真实 `createWorkBuddyShim` 上,非 mock)**:启动 **500 个**真实 shim,落在被禁端口的 **0 个**、客户端不可达的 **0 个**(修复前期望约 0.68 个);劫持 `fetch` 让首次探测抛出与真实 `bad port` **同形状**的错误后,真实对象从 `4512` 重掷到 `4513` 并在新端口正常服务。全量 `79` 文件/`1308` 用例通过,与改动前的 `1300` 相比净增 `8` 例。
18
+
19
+ ## 4.6.1 (2026-10-16)
20
+
21
+ > 版本号是 **patch**:只加了一行提示文案,没有行为、接口或配置变更。
22
+
23
+ ### Fixes
24
+
25
+ - **模型区补一句「免费模型有时间段内定额 Token 上限,并非无限制使用。」(加粗)**
26
+ - **为什么单独一句、而不是接在上面那句说明后面**:目录里有一批 `creditMultiplier: 0` 的模型,卡片把它们显示为免费;而上游仍会在**某一时段内的 token 定额**用完时用 `6004` / `14003` 拒掉 —— **那个定额不是账单余额,充钱也解决不了**。把「免费=无限」当成事实的人,会把 `6004` 读成「插件坏了」进而去找一个没问题的账号(#56 的报告者就为这个误读花掉过时间),或者去充值(`#63` 报的正是这个读法)。上面那句是描述**事实**、灰度的说明文字,这一句纠正的是**预期**,所以它独立成行并加粗。
27
+ - **颜色刻意不变**:仍是三级灰,不换成主色也不换成错误色 —— 它陈述的是正常产品行为,不是故障;换成红色会读成报错横幅,而真正该被当成错误的信息在别处。
28
+ - **中英 1:1**:`row.tabFreeQuotaWarn`(`免费模型有时间段内定额 Token 上限,并非无限制使用。` / `Free models have a per-window token quota — they are not unlimited.`)。
29
+ - **守卫**:`tests/pool-card-render.spec.tsx` 新增 2 例 —— ① 提示会渲染、且带的是**加粗类**而不是又一条灰度提示;② 字重必须写在样式表里(断言 `.dsm-workbuddy-free-quota-warn{font-weight:600}`),防止有人改成内联 `style={{fontWeight:600}}` 之后这条规则在 CSS 里查不到。**变异验证**:① 删掉那段提示 → 红;② 从 CSS 删掉加粗规则 → 红。
30
+ - **顺带踩到并记录一个老坑**:这行的样式注释里写了反引号(引用 locale 键),而 `styles.ts` 是**模板字面量** —— 反引号会直接截断字符串,表现为 `tsc` 报 `',' expected`。同一个错在本次发布周期里犯了两次,已改为不带反引号的写法。
31
+
3
32
  ## 4.6.0 (2026-10-15)
4
33
 
5
34
  > 版本号是 **minor** 而不是 patch:这一版新增了**用户可见的界面结论与控件**(池级「模型整体繁忙」判断、积分弹窗的打开期自动刷新、额度提示的呈现位置调整),按语义化版本属于特性。主线是**三处「界面上说了错话」的修正** —— 窗口左上角那个本来没有的方框、池卡片把模型问题说成账号问题、以及同一个账号在两个界面上状态不一致。三处都不会让测试或 CI 变红,只有人看界面才会发现,这也是它们能一路发出去的原因。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BxBvvAZq.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-De95rlXS.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
@@ -3129,6 +3129,12 @@ window.__ModuleLoader__.load({
3129
3129
  scrolling to the end of a long model list. */
3130
3130
  .dsm-workbuddy-models-dirty{margin-left:8px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#1b1d22;font-size:11px;font-weight:600;line-height:16px;vertical-align:1px;white-space:nowrap}
3131
3131
  .dsm-workbuddy-models-summary{margin:2px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
3132
+ /* The free-model quota warning (see the tabFreeQuotaWarn locale key). Bold on
3133
+ purpose, and it keeps the summary's own grey rather than the primary label
3134
+ colour: it has to stand out from the three lines around it WITHOUT reading as
3135
+ an error banner, because the fact it states is normal product behaviour
3136
+ rather than a failure. Weight is the only signal it needs. */
3137
+ .dsm-workbuddy-free-quota-warn{font-weight:600}
3132
3138
  .dsm-workbuddy-model-list{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:10px;overflow:hidden}
3133
3139
  .dsm-workbuddy-model{display:grid;grid-template-columns:minmax(0,1fr);gap:7px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2,#232529);transition:opacity .16s}
3134
3140
  .dsm-workbuddy-model-disabled{opacity:.55}
@@ -4750,6 +4756,10 @@ window.__ModuleLoader__.load({
4750
4756
  className: "dsm-workbuddy-models-summary",
4751
4757
  children: t("row.tabHint")
4752
4758
  }),
4759
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4760
+ className: "dsm-workbuddy-models-summary dsm-workbuddy-free-quota-warn",
4761
+ children: t("row.tabFreeQuotaWarn")
4762
+ }),
4753
4763
  !activeRegionOn ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4754
4764
  className: "dsm-workbuddy-tab-off-notice",
4755
4765
  children: t("row.tabOffNotice")
@@ -5930,6 +5940,7 @@ window.__ModuleLoader__.load({
5930
5940
  "row.tabCn": "Domestic",
5931
5941
  "row.tabGlobal": "Global",
5932
5942
  "row.tabHint": "Each tab is a separate provider (workbuddy / workbuddy-global) with its own account, credits, and models. Both sides are live at once: different sessions can pick from either side, and changes on one tab never touch the other.",
5943
+ "row.tabFreeQuotaWarn": "Free models have a per-window token quota — they are not unlimited.",
5933
5944
  "row.tabSwitchHint": "Uncheck to switch this provider off. Its models disappear from the DSH model picker; your account, credits, and model settings are kept and return when you check it again.",
5934
5945
  "row.tabSwitchAria": "Offer the {region} provider to DSH",
5935
5946
  "row.tabOffNotice": "This provider is switched off: none of its models are offered to DSH. Your account, credits, and model settings are kept — check the box above the tab to bring it back.",
@@ -6214,6 +6225,7 @@ window.__ModuleLoader__.load({
6214
6225
  "row.tabCn": "国内版",
6215
6226
  "row.tabGlobal": "国际版",
6216
6227
  "row.tabHint": "每个 tab 是一个独立供应商(workbuddy / workbuddy-global),各有自己的账号、积分与模型。两边同时生效:不同会话可各选一边,一侧的改动不影响另一侧。",
6228
+ "row.tabFreeQuotaWarn": "免费模型有时间段内定额 Token 上限,并非无限制使用。",
6217
6229
  "row.tabSwitchHint": "取消勾选即关闭该供应商:它的模型不再出现在 DSH 模型选择器里;账号、积分与模型设置都会保留,重新勾选即恢复。",
6218
6230
  "row.tabSwitchAria": "是否向 DSH 提供{region}供应商",
6219
6231
  "row.tabOffNotice": "该供应商已关闭:它的模型不会提供给 DSH。账号、积分与模型设置均已保留——勾选上方复选框即可恢复。",
@@ -3825,7 +3825,7 @@ function generationOf(version) {
3825
3825
  //#endregion
3826
3826
  //#region src/version.ts
3827
3827
  /** The npm package version this build was produced from. */
3828
- const WORKBUDDY_CONNECT_VERSION = "4.6.0";
3828
+ const WORKBUDDY_CONNECT_VERSION = "4.6.2";
3829
3829
  //#endregion
3830
3830
  //#region src/host-heartbeat.ts
3831
3831
  /**
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BxBvvAZq.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-De95rlXS.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1111,8 +1111,12 @@ function finish(parts, calls) {
1111
1111
  * JSON、bearer 必须匹配进程内随机 secret)、常量时间比对、
1112
1112
  * 随机端口绑定、body 上限、上游错误分类到 HTTP 状态码的映射,
1113
1113
  * 均由该项目设计并验证。
1114
- * 改动:无。安全相关代码不做「改善」,原样沿用。
1115
-
1114
+ * 改动:随机端口绑定加了「先确认客户端能用」一步(`listenOnClientReachablePort`)。
1115
+ * 绑定方式仍是 `listen(0)` + 仅回环,随机性不变;改的只是抽到 HTTP 客户端
1116
+ * 禁用端口(fetch 规范的 block-bad-port 清单)时重抽,而不是带着一个自己
1117
+ * 客户端连不上的端口启动。四重校验、常量时间比对、body 上限、
1118
+ * 错误分类均未改动。
1119
+ *
1116
1120
  * @module dsh-connect-workbuddy/shim
1117
1121
  */
1118
1122
  const REQUEST_BODY_LIMIT = 67108864;
@@ -1776,6 +1780,147 @@ function contentFrame(chunk, choice, content) {
1776
1780
  return `data: ${JSON.stringify(next)}\n\n`;
1777
1781
  }
1778
1782
  /**
1783
+ * Whether this runtime's `fetch` refuses `port` WITHOUT ever connecting.
1784
+ *
1785
+ * `server.listen(0)` lets the kernel pick the port, and the Fetch standard bans
1786
+ * a list of ports that `fetch` must reject before opening a socket
1787
+ * (`fetch.spec.whatwg.org#block-bad-port`, implemented as undici's `badPorts`).
1788
+ * Every client of this shim goes through that same `fetch`: pi-ai drives the
1789
+ * OpenAI SDK, whose `getDefaultFetch()` returns `globalThis.fetch`. So a shim
1790
+ * that lands on a banned port is unreachable to its own client while being
1791
+ * perfectly healthy on the wire — measured on port 6000: `fetch` refuses,
1792
+ * while `node:http`, a raw TCP socket and `curl` all connect and get 200.
1793
+ *
1794
+ * That combination is what makes it worth preventing rather than diagnosing: it
1795
+ * is silent (the SDK surfaces only `Connection error.`, with the `bad port`
1796
+ * cause dropped), total (every request in the region fails), and it does not
1797
+ * re-roll on its own.
1798
+ *
1799
+ * Whether it can happen at all depends on the machine's dynamic port range,
1800
+ * because that is the set `listen(0)` draws from:
1801
+ *
1802
+ * Windows default 49152-65535, Linux default 32768-60999, macOS 49152-65535
1803
+ * — every banned port is <= 10080 (the current list ends there), so the
1804
+ * intersection is EMPTY and these machines cannot be affected.
1805
+ * A range lowered to reach below 10080 — e.g. the legacy `MaxUserPort`
1806
+ * registry value pinned to 15000, which yields 1024-15000 — draws from a
1807
+ * set that CONTAINS banned ports. Measured there: 19 of 13977 ports are
1808
+ * banned, i.e. ~0.14% per bind and ~0.27% per plugin load (two regions,
1809
+ * one bind each).
1810
+ *
1811
+ * Probing with the client itself, rather than comparing against a copy of the
1812
+ * banned list, is deliberate: a hard-coded list drifts the moment the standard
1813
+ * gains a port, whereas the rule being satisfied here IS the client's own, so
1814
+ * asking the client cannot disagree with it. `tests/shim.spec.ts` pins the
1815
+ * error shape this relies on, so a Node change that renames it fails loudly
1816
+ * instead of silently disabling the guard.
1817
+ */
1818
+ async function clientRefusesPort(port) {
1819
+ try {
1820
+ await fetch(`http://127.0.0.1:${port}/healthz`);
1821
+ return false;
1822
+ } catch (error) {
1823
+ return isBadPortRefusal(error);
1824
+ }
1825
+ }
1826
+ /**
1827
+ * Whether `error` is the client's PORT REFUSAL rather than a real transport
1828
+ * failure.
1829
+ *
1830
+ * The two must never be conflated: a refused port is worth re-rolling, while
1831
+ * `ECONNREFUSED`/`ECONNRESET` are ordinary transport errors that re-rolling
1832
+ * would only paper over. Measured shapes on Node 24:
1833
+ *
1834
+ * refused port : TypeError('fetch failed') -> cause Error('bad port') (no `code`)
1835
+ * closed port : TypeError('fetch failed') -> cause Error('connect ECONNREFUSED …') (code `ECONNREFUSED`)
1836
+ *
1837
+ * The cause chain is walked, with a bound, rather than read one level deep:
1838
+ * the OpenAI SDK wraps this same refusal in its own `APIConnectionError`, and a
1839
+ * caller should not have to know how deep the active client happens to nest it.
1840
+ */
1841
+ function isBadPortRefusal(error) {
1842
+ let current = error;
1843
+ for (let depth = 0; depth < 4; depth++) {
1844
+ if (!(current instanceof Error)) return false;
1845
+ if (current.message === "bad port") return true;
1846
+ current = current.cause;
1847
+ }
1848
+ return false;
1849
+ }
1850
+ /**
1851
+ * Listen on an ephemeral loopback port, rejecting on failure.
1852
+ *
1853
+ * Also settles on `close`, which is NOT redundant: measured on Node 24, a
1854
+ * `close()` that lands while `listen()` is still pending fires neither
1855
+ * `listening` nor `error` — only `close`. Without that third listener a dispose
1856
+ * during startup would leave this promise (and therefore the shim's `ready`)
1857
+ * pending forever, wedging plugin unload. Resolving there is deliberate:
1858
+ * disposal is a satisfied end state, not a startup failure.
1859
+ */
1860
+ function listenOnce(server) {
1861
+ return new Promise((resolve, reject) => {
1862
+ const settle = () => {
1863
+ server.off("listening", onListening);
1864
+ server.off("error", onError);
1865
+ server.off("close", onClose);
1866
+ };
1867
+ const onListening = () => {
1868
+ settle();
1869
+ resolve();
1870
+ };
1871
+ const onError = (error) => {
1872
+ settle();
1873
+ reject(error);
1874
+ };
1875
+ const onClose = () => {
1876
+ settle();
1877
+ resolve();
1878
+ };
1879
+ server.once("listening", onListening);
1880
+ server.once("error", onError);
1881
+ server.once("close", onClose);
1882
+ server.listen(0, "127.0.0.1");
1883
+ });
1884
+ }
1885
+ /** Stop listening, resolving once the listener is down. */
1886
+ function closeOnce(server) {
1887
+ return new Promise((resolve) => server.close(() => resolve()));
1888
+ }
1889
+ /**
1890
+ * Bind an ephemeral loopback port that the CLIENT can actually reach.
1891
+ *
1892
+ * `listen(0)` stays the right way to get a port — the kernel picks a free one
1893
+ * instead of this plugin guessing a number — but the first pick is not always
1894
+ * usable, so the roll repeats while the client refuses it. See
1895
+ * {@link clientRefusesPort} for why the client's own answer is the criterion,
1896
+ * and {@link PORT_ROLL_LIMIT} for why the loop is bounded.
1897
+ *
1898
+ * The probe is injected rather than imported so the re-roll path can be driven
1899
+ * deterministically: no test can make `listen(0)` return a banned port on
1900
+ * demand, so this seam is the only way to pin the behaviour that actually
1901
+ * matters.
1902
+ *
1903
+ * `ready` (the returned promise) therefore resolves only once a port is
1904
+ * settled, which is what lets every consumer keep reading `baseUrl()` as it
1905
+ * always has.
1906
+ */
1907
+ async function listenOnClientReachablePort(options) {
1908
+ const { server, clientRefuses, logger, cancelled } = options;
1909
+ for (let attempt = 1;; attempt++) {
1910
+ if (cancelled?.() === true) return;
1911
+ await listenOnce(server);
1912
+ const address = server.address();
1913
+ const port = address === null || typeof address === "string" ? void 0 : address.port;
1914
+ if (port === void 0 || !await clientRefuses(port)) return;
1915
+ if (attempt >= 5) {
1916
+ logger?.warn(`dsh-connect-workbuddy: port ${port} is on the HTTP client's refused list and 5 rolls did not avoid one; serving on it anyway`);
1917
+ return;
1918
+ }
1919
+ logger?.warn(`dsh-connect-workbuddy: port ${port} is refused by the HTTP client; re-rolling (attempt ${attempt})`);
1920
+ await closeOnce(server);
1921
+ }
1922
+ }
1923
+ /**
1779
1924
  * Start the loopback endpoint. Requests must carry the shim's shared secret;
1780
1925
  * the loopback bind alone is not a trust boundary.
1781
1926
  */
@@ -1804,11 +1949,27 @@ function createWorkBuddyShim(options) {
1804
1949
  const server = createServer((req, res) => {
1805
1950
  handle(req, res);
1806
1951
  });
1807
- const ready = new Promise((resolve, reject) => {
1808
- server.once("listening", () => resolve());
1809
- server.once("error", reject);
1952
+ let disposed = false;
1953
+ server.on("error", (error) => {
1954
+ if (server.listening) logger?.error("dsh-connect-workbuddy: loopback listener error", error);
1955
+ });
1956
+ /**
1957
+ * Resolves once the listener is up on a port the client can actually reach.
1958
+ *
1959
+ * The port is settled HERE — `listenOnClientReachablePort` re-rolls while the
1960
+ * client refuses the assigned one — so every consumer can keep resolving
1961
+ * `baseUrl()` exactly as before, with no invalidation or rebuild step. A
1962
+ * genuine bind failure still rejects, the one behaviour the caller relies on.
1963
+ */
1964
+ const ready = listenOnClientReachablePort({
1965
+ server,
1966
+ clientRefuses: clientRefusesPort,
1967
+ logger,
1968
+ cancelled: () => disposed
1969
+ }).catch((error) => {
1970
+ logger?.error("dsh-connect-workbuddy: loopback listener failed to start", error);
1971
+ throw error;
1810
1972
  });
1811
- server.listen(0, "127.0.0.1");
1812
1973
  const baseUrl = () => {
1813
1974
  const address = server.address();
1814
1975
  if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
@@ -2054,10 +2215,10 @@ function createWorkBuddyShim(options) {
2054
2215
  ready,
2055
2216
  baseUrl,
2056
2217
  token: () => SHARED_SECRET,
2057
- close: () => new Promise((resolve, reject) => {
2218
+ close: () => new Promise((resolve) => {
2219
+ disposed = true;
2058
2220
  server.close(() => resolve());
2059
2221
  server.closeAllConnections();
2060
- server.once("error", reject);
2061
2222
  })
2062
2223
  };
2063
2224
  }
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.6.0",
5
+ "version": "4.6.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",