dsh-connect-workbuddy 5.12.2 → 5.12.3

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,104 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.12.3 (2026-10-28)
4
+
5
+ > issue #155 的**两个独立 Bug**:**① 业务码按字节匹配 JSON**——`11128`/`14003`/`14018` 用
6
+ > `body.includes('"code":11128')` 判定,于是同一个信封 `{"code":11128}` 认得、多一个空格的
7
+ > `{"code": 11128}` 和写成字符串的 `{"code":"11128"}` 都不认,渠道闸被静默判成 `client`,
8
+ > **整条救援阶梯不再运行**(#113 的修复等于被一个空格关掉);**② 删账号只清内存、不清持久化选择**——
9
+ > 磁盘上 `accounts.cn` 仍写着死 id,下次 `applySelection` 把它读回来,CN 整块变「已保存的账号在本机
10
+ > 已不存在」且账号列表一条不渲染。
11
+
12
+ ### Fixes
13
+
14
+ - **渠道闸/限流/耗尽三族业务码按字节匹配,一个空格就漏判(#155)**
15
+ - **现象**:报告者贴回来的那条消息里**自相矛盾**——正文写着 `code 11128`,同一句却带
16
+ `"type":"client"`,收尾是「请按上面的提示调整后重试」。两半出自同一个信封、同一份 body。
17
+ - **根因**:分类器判族用 `body.includes('"code":11128')`——对**一种序列化**做字节测试;上游 JSON 里
18
+ 多一个空格、或把码写成字符串,判据就落空。而 `code 11128` 那半走 `parseUpstreamErrorDetail`
19
+ (`JSON.parse`),照旧读得到。**以下矩阵是把每行都跑在 HEAD 代码上测出来的**,不是推理:
20
+
21
+ | body 写法 | HEAD classify | HEAD detail.upstreamCode |
22
+ | --- | --- | --- |
23
+ | `{"code":11128}` | `channel_reject` | 11128 |
24
+ | `{"code": 11128}` | `client` ← 漏 | 11128 |
25
+ | `{"code":"11128"}` | `client` ← 漏 | `undefined` ← 也漏 |
26
+ | `{"error":{"code":11128}}` | `channel_reject` | `undefined` ← **反向**不一致 |
27
+ | `{"CODE":11128}` | `client` ← 漏 | `undefined` |
28
+ - **两种不同的错位**,报告者只能贴出第一种。第 2 行就是 #155 那条消息:分类器按 substring 说
29
+ `client`,展示那半按 `JSON.parse` 读到**顶层数字** `11128` 照旧打印 —— 一条回复里,诊断和它自己的
30
+ 证据互相打脸。第 3–5 行对支持更糟、对用户不可见:不但两个读取器不一致,**连要求用户提供的那个码都
31
+ 整个消失**,`channel_reject` 贴出来却没有 `11128` 可锚。第 4 行方向相反(substring 恰好命中嵌套的
32
+ `"code":11128`,解析器当时只认 `error.data.code`),说明这不是「换成解析器就完了」—— 两侧都得读同一个
33
+ 事实,才可能一致。
34
+ - **代价不只是文案**:`client` 是**唯一不进渠道救援阶梯**的类别(`src/shim.ts` 的门是
35
+ `result.kind === 'channel_reject'`),于是 `forceNeutralHead` 与两级扰动重试一次都不会跑 —— 用户被
36
+ 要求「调整请求后重试」,而插件根本没试过救援。`14003` 同族实测降级目标**随 HTTP 状态而变**:
37
+ `429 {"code": 14003}` → `soft_rate`、`400` → `client`,而 `model_busy` 是三个**不换号**的类别之一
38
+ (`isFailoverWorthy`)—— 落到 `soft_rate` 就是让整个账号池挨个烧一次真实请求去学一个模型级的事实。
39
+ `14018` spaced 单独看会掉成 `client`,实测只有当 `msg` 里带上了 `Credits exhausted` 那句话时才被
40
+ 措辞标记救回 `hard_credit` —— 措辞一改就失效,正是本仓库反复记录的那类脆弱判据。
41
+ - **修法**:新增 `statesBusinessCode(body, code)`,`CHANNEL_REJECT_CODE`/`MODEL_BUSY_CODE`/
42
+ `CREDITS_EXHAUSTED_CODE` 三个常量从字符串形态改成数字,6 处家族判断统一走它。**三段读取,顺序即安全性**:
43
+ ① 信封自己声明的码是最终答案,**绝不再做文本扫描** —— 否则 `extError` 里嵌套的 `"code":11128` 会盖掉外层
44
+ `14003` 的自判,正好是 #127 反过来要的精度;② 解析成功却没在已知三种形态里声明码的 body,仍走带引号锚点
45
+ 的文本扫描 —— 这条是**严格扩量**:#127 只留解析器,反而把 `{"error":{"code":11140}}` 这种旧文本匹配本来
46
+ 认得的写法**收窄**掉了;③ 扫描锚在 `"code":<n>` 而非裸数字,十六进制 requestId 恰好含 `11128` 仍不能把
47
+ 无关 body 提上来(原文本形态存在的理由,一并保留)。
48
+ - **一处先前例**:`CONTEXT_TOO_LONG_CODE`(`11115`,#101)早就写成 `/"code"\s*:\s*11115(?!\d)/u` —— 本仓库
49
+ **第三次**独立撞上同一个坑,前两次各修各的族,这就是为什么这次收成一个共享读取器,而不是第四处正则。
50
+ - **顺带修两处同源缺陷**:`envelopeError()` 已把 `code` 解析成数字,却只把 `msg` 交给分类器 —— 而 `msg`
51
+ 永远不含 `"code":…`,于是每个「靠业务码判定」的族在这里都不可见,答案一律落到 `client`(#144 那条
52
+ `workbuddy upstream client (http 400): 请求被拦截,请重新发送` 就是一个插件已经握有其码的信封);改为按
53
+ 规范形重建 body 再分类。`parseUpstreamErrorDetail()` 原先只认**顶层**且 `typeof === 'number'` 的 `code`,
54
+ 现与分类器共用 `businessCodeOfDocument`(补上 `{"error":{"code":…}}`,排在 `error.data.code` 之后以免
55
+ 细节盖掉判定)—— 不再出现「按码判了类、却不打印码」。
56
+ - **删掉一个账号后,该区域的持久化选择复活成死 id(#155 第二份报告)**
57
+ - **现象**:「删了一个账号就这样了」——CN 整块变「未登录」,提示「已保存的账号在本机已不存在」,而上方账号
58
+ 列表**一条都不渲染**;`usage?region=cn&detail=fast` 答 `status=signed-out selectionLost=true`,但同一份
59
+ 响应里明明还有 3 个 token 未过期的账号,国际版正常。报告者自己定位到 `cordis.patch.yml` 里
60
+ `accounts: cn: <本机已不存在的 id>`,并靠手发 `POST /__save {"field":"accounts","value":{"cn":""}}`
61
+ 恢复的 —— 本条就是把那个手动动作变成自动。
62
+ - **根因是「只清了一半」**:`removeAccount()` 确实清了内存里的 `this.accountId`,所以**本次会话内一切正常**、
63
+ 删除也回报成功;但设置文档里的死 id 一个字节都没动,下一次 `applySelection`(重启,或**任何一次设置保存**)
64
+ 把它读回来重新 pin,区域随即掉进死胡同:`current()` 找不到 → `resolve()` 抛 no signed-in account →
65
+ 走 `signed-out` 分支,而该状态下卡片的建议指向一个它自己并不渲染的列表。会话内看不出问题、故障延后到重启才
66
+ 现形,正是这两步之间的缝隙让报告者和「删账号」连不起来。**只存在于内存的忘记不是修复,是拖延。**
67
+ - **修法**:`removeOne` 之后调用 `clearRegionAccountSelection()`,**只在被删账号正是本区持久化 pin 时**
68
+ 写 `accounts[region] = ''`。判定与写入同在一个函数里,调用方无法只挑一半。四条约束都是这条修复的一部分:
69
+ - **写 `''`,绝不删键**:`''` 是「跟随 App 当前登录」的哨兵(也是全新安装的默认值,所以这是**恢复**而非
70
+ 发明状态);删掉的键含义是「从未配置」,会回落到旧版扁平 `accountId`,把用户**悄悄切到另一个账号**。
71
+ - **只清 pin 了该号的区域**:无关的删除必须零次写入 —— 内存里 `current()` 都拒绝回退到别的账号,
72
+ 删除不能绕过它、从设置文件里做同样的事(换账号等于换人计费)。
73
+ - **整字段合并**:`accounts` 是两个区域共用的一张平表,按调用方视角重建会抹掉兄弟区域的 pin;判定与写入
74
+ 都读 `describe()` 的权威文档,且先 `unwrapVolatileDeep` —— 把 volatile 的 `{get}` 活引用写进 payload,
75
+ settings 服务会整次拒绝(`found a function at $.ops[0].value.get`)。
76
+ - **同时覆盖旧版扁平 `accountId`**:pre-split 配置的 `accounts` 是空的、只靠扁平字段 pin,
77
+ `regionPinsAccount` 认这条;而哨兵会**终止**那次回落,所以一次写入同时 settle 两种写法。
78
+ - **fail-soft**:写设置失败只记 warn,绝不回报「删除失败」—— 凭证文件此刻已经真删了,说它还在是第二个谎。
79
+
80
+ ### Tests
81
+
82
+ - `tests/issue155-envelope-spelling.spec.ts` 新增 23 条(#155):紧凑/带空格/字符串码/嵌套四种写法 ×
83
+ 11128/14003/14018/11140 四族;`{"CODE":11128}` 归族(这一半是**恢复**不是保留:member 层先前对 body
84
+ 做了 `toLowerCase()` 因而认大写、`tests/issue98-channel-gate` 钉的是 member,而 family 层测的是原始 body、
85
+ 不认 —— 现在一个读取器同时回答两侧);十六进制 requestId 含 `11128` 不得被提上来;嵌套 `11128` 不得盖掉外层
86
+ `14003` 的自判;**分类器与展示给用户的那条码不许互相矛盾**。
87
+ - `tests/issue155-account-removal.spec.ts` 新增 17 条(#155):`regionPinsAccount` 四个方向(本区 pin 了=清 /
88
+ 兄弟区 pin 了别的=不清 / 已在跟随 App=不写 / 只靠扁平字段 pin=清,且扁平字段只归它被归属的那个区);
89
+ `nextAccountsSelection` 保兄弟区、只答 `accounts` 字段而非整个 section、`''` 作为数据保留(区别于缺键);
90
+ volatile 活引用不得漏进 payload;无服务/无命名空间时零写入。
91
+ - `tests/issue155-removal-e2e.spec.ts` 新增 5 条(#155 的**接线**):真实 `apply()` + 真实路由 + 真实 config
92
+ 对象(settings 双按引用答 `describe()`、`mutate` 原地提交并广播 `loader/volatile-update`),断言
93
+ 「删除 → 触发那次重读(等价于重启或任何一次设置保存的 `applySelection`)→ 区域仍 `signed-in` 且
94
+ `selectionLost` 消失」这条旧代码做不到的完整链条;另测兄弟区 pin 存活、无关删除零次写入、写失败时删除仍回报
95
+ 200。单独存在的理由与 `tests/pool-e2e.spec.ts` 相同:纯 helper 用例对着我自己写的 double,摘掉 `index.ts`
96
+ 里的接线它们全绿。
97
+ - 变异验证(每条都确认会让对应用例变红):分类器退回 `body.includes('"code":N')` → **11 条红**;删掉
98
+ 「本区是否 pin 了该号」的判定 → **4 条红**;写整个 section 而非 `accounts` 字段 → **4 条红**;
99
+ `removeOne` 退回只清内存 → **1 条红**(正是那条延后到重启才现形的链)。
100
+ - 全量:`tsc` 双配置 0 错误,**146 文件 / 2536 通过(2 条按平台跳过)**,`tsdown` 构建通过。
101
+
3
102
  ## 5.12.2 (2026-10-27)
4
103
 
5
104
  > 两处修复:**① 装完重启插件消失**(#151)——把宿主提供的 `@earendil-works/pi-ai` 标成 optional
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as WorkBuddyCredentialStore, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, S as WORKBUDDY_AUTH_FILE_ENV, Ut as findWorkbuddyAppExecutableWithSource, V as WorkBuddyUpstreamClient, Zt as hostPeersInfo, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-BdTV4Aoi.js";
2
+ import { C as WorkBuddyCredentialStore, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, S as WORKBUDDY_AUTH_FILE_ENV, Ut as findWorkbuddyAppExecutableWithSource, V as WorkBuddyUpstreamClient, Zt as hostPeersInfo, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-D1N-f8Q6.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
@@ -2430,14 +2430,14 @@ const HARD_CREDIT_MARKERS = [
2430
2430
  * this guards against. Marker tables rot in a way codes do not: the gateway can
2431
2431
  * reword its sentence without changing the contract it advertises, and the
2432
2432
  * failure is silent (the branch simply stops matching). A code cannot drift like
2433
- * that, and matching it in quoted-key form keeps a request id that happens to
2434
- * contain the digits from promoting an unrelated body.
2433
+ * that, and reading it (see {@link statesBusinessCode}) keeps a request id that
2434
+ * happens to contain the digits from promoting an unrelated body.
2435
2435
  *
2436
2436
  * ONLY a corroborating signal, never a substitute: the code is checked in
2437
2437
  * addition to the markers, so a body that names the code AND reads as a policy
2438
2438
  * refusal still resolves by the marker table's order.
2439
2439
  */
2440
- const CREDITS_EXHAUSTED_CODE = "\"code\":14018";
2440
+ const CREDITS_EXHAUSTED_CODE = 14018;
2441
2441
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
2442
2442
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
2443
2443
  /**
@@ -2474,7 +2474,21 @@ function businessCodeOf(body) {
2474
2474
  return match?.[1] === void 0 ? void 0 : Number(match[1]);
2475
2475
  }
2476
2476
  if (typeof parsed !== "object" || parsed === null) return void 0;
2477
- const raw = parsed;
2477
+ return businessCodeOfDocument(parsed);
2478
+ }
2479
+ /**
2480
+ * The business code a PARSED failure document states, or `undefined`.
2481
+ *
2482
+ * ONE READER FOR TWO CALLERS (issue #155's second half). The classifier needs
2483
+ * the code to decide a kind; `parseUpstreamErrorDetail` needs it to print
2484
+ * `code 11128` in the reply the user pastes into an issue. The display half used
2485
+ * to read only a top-level `code` that was `typeof === 'number'`, so for two of
2486
+ * the spellings below the SAME body produced a diagnosis from the code while
2487
+ * showing no code at all — and `client`/`channel_reject` is exactly the pair a
2488
+ * reporter needs to be able to cross-check. Extracted so the two cannot drift
2489
+ * again; `businessCodeOf` keeps the unparseable-body fallback on top of it.
2490
+ */
2491
+ function businessCodeOfDocument(raw) {
2478
2492
  const direct = asBusinessCode(raw["code"]);
2479
2493
  if (direct !== void 0) return direct;
2480
2494
  const error = raw["error"];
@@ -2484,6 +2498,8 @@ function businessCodeOf(body) {
2484
2498
  const nested = asBusinessCode(data["code"]);
2485
2499
  if (nested !== void 0) return nested;
2486
2500
  }
2501
+ const direct = asBusinessCode(error["code"]);
2502
+ if (direct !== void 0) return direct;
2487
2503
  }
2488
2504
  const data = raw["data"];
2489
2505
  if (typeof data === "object" && data !== null) return asBusinessCode(data["code"]);
@@ -2493,21 +2509,97 @@ function asBusinessCode(value) {
2493
2509
  if (typeof value === "number" && Number.isInteger(value)) return value;
2494
2510
  if (typeof value === "string" && /^\d{4,6}$/u.test(value.trim())) return Number(value.trim());
2495
2511
  }
2512
+ /**
2513
+ * Whether a failure body states this business code, in ANY of its spellings.
2514
+ *
2515
+ * THE BUG THIS REMOVES (issue #155). Three of the four code families below were
2516
+ * recognised by `body.includes('"code":11128')` — a byte test on ONE
2517
+ * serialization. Issue #127 had already proven that test unreliable, but it
2518
+ * converted `11140` ONLY: measured on `6bdba2a`, `businessCodeOf` has exactly
2519
+ * one call site, despite that commit's message claiming `11128` as well. So the
2520
+ * SAME envelope answered one thing to the parser and another to the classifier.
2521
+ * Measured row by row against the shipped code:
2522
+ *
2523
+ * body classify detail.upstreamCode
2524
+ * {"code":11128} channel_reject 11128
2525
+ * {"code": 11128} client ← 漏 11128
2526
+ * {"code":"11128"} client ← 漏 undefined ← 也漏
2527
+ * {"error":{"code":11128}} channel_reject undefined ← 反向不一致
2528
+ * {"CODE":11128} client ← 漏 undefined
2529
+ *
2530
+ * TWO DIFFERENT mismatches. Row 2 is what the reporter pasted into issue #155:
2531
+ * `code 11128` in the text (`parseUpstreamErrorDetail` reads a top-level NUMBER,
2532
+ * which JSON.parse delivers even for a spaced body) beside `"type":"client"`
2533
+ * (decided by the substring) — a diagnosis disagreeing with its own evidence
2534
+ * inside one message. Rows 3–5 are worse for support and invisible to the user:
2535
+ * the readers disagree AND the code support asks for is missing entirely. Row 4
2536
+ * runs the OTHER way (the substring happens to hit the nested `"code":11128`
2537
+ * while the parser only knew `error.data.code`), which is why this could not be
2538
+ * fixed by "just use the parser" — both readers must consult ONE fact.
2539
+ * That is not cosmetic — `client` is the one kind that does NOT
2540
+ * enter the channel rescue ladder (`src/shim.ts`, gated on
2541
+ * `kind === 'channel_reject'`), so `forceNeutralHead` and both perturbation
2542
+ * rungs never fired, and the user was told to "adjust the request" for a
2543
+ * refusal the plugin had not actually tried to rescue. Issue #113's whole fix
2544
+ * silently switches off depending on a space.
2545
+ *
2546
+ * THREE READS, IN THIS ORDER, and the ordering is the safety property:
2547
+ *
2548
+ * 1. A code the envelope STATES is final. `businessCodeOf` found one ⇒ that is
2549
+ * the answer, and the text scan below is never consulted. Without this rule,
2550
+ * OR-ing the two reads would let a nested sub-object's `"code":11128`
2551
+ * (inside `extError`, say) reclassify an envelope whose own verdict is
2552
+ * `14003` — promoting a detail over the verdict, the reverse of the precision
2553
+ * issue #127 bought.
2554
+ * 2. A body that PARSES but states no code in the three shapes `businessCodeOf`
2555
+ * knows still gets the quoted-key text scan. That is where this fix strictly
2556
+ * widens the old behaviour: an undocumented nesting (`{"error":{"code":…}}`)
2557
+ * used to be recognised by the substring, and dropping it here would be a
2558
+ * regression dressed up as a refactor.
2559
+ * 3. The scan anchors on `"code":<n>`, never the bare digits, so a hexadecimal
2560
+ * request id containing `11128` cannot promote an unrelated body — the hazard
2561
+ * the original quoted-key form existed to avoid, kept intact. The scan is
2562
+ * applied case-insensitively. This half RESTORES rather than preserves a
2563
+ * behaviour, and the two halves of the family disagreed about it: the member
2564
+ * predicates lower-cased the body before testing it (so `{"CODE":11128,…}`
2565
+ * matched, pinned by `tests/issue98-channel-gate`), while the family
2566
+ * predicate tested the RAW body and did not. One reader now answers both.
2567
+ *
2568
+ * The text form is derived from the number rather than declared beside it: two
2569
+ * constants for one code are two truths that drift, and a stale text form would
2570
+ * silently reinstate the bug above for exactly the spelling this function exists
2571
+ * to catch.
2572
+ */
2573
+ function statesBusinessCode(body, code) {
2574
+ const stated = businessCodeOf(body);
2575
+ if (stated !== void 0) return stated === code;
2576
+ return body.toLowerCase().includes(`"code":${code}`);
2577
+ }
2496
2578
  /** The `11140` content-policy family, whatever the envelope's JSON spelling is. */
2497
2579
  const CONTENT_POLICY_CODE = 11140;
2498
2580
  /**
2499
- * Content-policy refusal markers from the chat gateway. The quoted-key form
2500
- * avoids substring hits inside unrelated values (request ids are hex and can
2501
- * contain `11140` by coincidence).
2581
+ * Content-policy refusal markers from the chat gateway. The code is READ rather
2582
+ * than text-matched so a request id cannot contain `11140` by coincidence and
2583
+ * promote an unrelated body — and, since issue #155, so the ONE read is shared
2584
+ * with every other family here instead of being a second, subtly different rule.
2502
2585
  *
2503
2586
  * `request illegal` is deliberately absent: the live `11128` channel-gate body
2504
2587
  * uses the SAME `msg`, so this marker used to run first and classify that gate
2505
2588
  * as `policy_reject`. The consequence was severe — the channel rescue ladder
2506
2589
  * never ran, the reply changed 400 to 403, and users were told to switch
2507
2590
  * accounts for a request-side refusal (issue #127).
2591
+ *
2592
+ * THE NESTING GAP #127 LEFT BEHIND, and why this now reads through
2593
+ * {@link statesBusinessCode}. Issue #127 replaced the text match with the parser
2594
+ * alone, which is exact for the three shapes that parser knows (`code`,
2595
+ * `error.data.code`, `data.code`) — and therefore silently NARROWED coverage for
2596
+ * a body whose code sits anywhere else, such as `{"error":{"code":11140}}`, which
2597
+ * the old text match did catch. The helper keeps both: a code the envelope states
2598
+ * wins outright (the precision #127 was after), and the text scan runs only when
2599
+ * nothing was stated, where it can only restore a match the parser could not see.
2508
2600
  */
2509
2601
  function isContentPolicyBody(body) {
2510
- return businessCodeOf(body) === CONTENT_POLICY_CODE;
2602
+ return statesBusinessCode(body, CONTENT_POLICY_CODE);
2511
2603
  }
2512
2604
  /**
2513
2605
  * Whether a failure body is the account-restriction member of the `11140`
@@ -2607,25 +2699,32 @@ function isUndecodablePolicyBody(body) {
2607
2699
  /**
2608
2700
  * Business code of the MODEL-scoped throttle (`14003`, "too many requests").
2609
2701
  *
2610
- * The quoted-key form for the same reason as the policy markers: a request id is
2611
- * hexadecimal and can contain the digits `14003` by coincidence, and a bare
2612
- * substring search over an arbitrary failure body is how a red herring gets
2613
- * promoted into a routing decision.
2702
+ * A number rather than the quoted-key text form: the text match was exact for
2703
+ * one serialization only, so a spaced or string-quoted envelope lost the
2704
+ * `model_busy` kind and fell through to whatever its HTTP status says — measured
2705
+ * at HEAD, `429 {"code": 14003}` became `soft_rate` and `400` became `client`.
2706
+ * That is not a cosmetic rename: `model_busy` is one of the three NON-failover
2707
+ * kinds (`isFailoverWorthy` in `src/shim.ts`), because the throttle is a fact
2708
+ * about the MODEL and the next credential joins the same one — so `soft_rate`
2709
+ * walks the whole pool burning a real request per account to learn nothing,
2710
+ * while `client` prints neither the kind's advice nor its code (issue #155). See
2711
+ * {@link statesBusinessCode}.
2614
2712
  */
2615
- const MODEL_BUSY_CODE = "\"code\":14003";
2713
+ const MODEL_BUSY_CODE = 14003;
2616
2714
  /**
2617
2715
  * Business code of the gateway's CHANNEL gate (`11128`, "Illegal API invocation
2618
2716
  * from an unapproved channel").
2619
2717
  *
2620
- * The quoted-key form for the same reason as the markers above: a request id is
2621
- * hexadecimal and can contain the digits `11128` by coincidence.
2718
+ * A NUMBER, not the quoted-key text form it used to be. Matching the text was
2719
+ * exact only for one serialization, which is the whole of issue #155 — see
2720
+ * {@link statesBusinessCode}.
2622
2721
  *
2623
2722
  * A DIFFERENT gate from the one {@link conversationHeaders} works around, which
2624
2723
  * also answers `11128` (`conversation id missing`). The code is the same
2625
2724
  * because the gateway reuses it for every rule in that family; the BODY is what
2626
2725
  * tells them apart, so {@link isChannelRejectBody} requires both halves.
2627
2726
  */
2628
- const CHANNEL_REJECT_CODE = "\"code\":11128";
2727
+ const CHANNEL_REJECT_CODE = 11128;
2629
2728
  /**
2630
2729
  * Business code for "the request exceeded the model's context window" (issue #101).
2631
2730
  *
@@ -2723,24 +2822,23 @@ const FIRST_MESSAGE_GATE_MARKER = "first message is not system prompt";
2723
2822
  * SAME second. A 6004, by contrast, carries a `将在 … UTC+8 重置` sentence and
2724
2823
  * is per-account.
2725
2824
  *
2726
- * So the criterion is the CODE alone, matched in its quoted-key form so a
2727
- * request id that happens to contain the digits cannot promote it. Exported so
2728
- * the probe and the chat path cannot drift into two different answers about one
2729
- * body.
2825
+ * So the criterion is the CODE alone, read by {@link statesBusinessCode} so a
2826
+ * request id that happens to contain the digits cannot promote an unrelated
2827
+ * body. Exported so the probe and the chat path cannot drift into two different
2828
+ * answers about one body.
2730
2829
  */
2731
2830
  function isModelBusyBody(body) {
2732
- return body.includes(MODEL_BUSY_CODE);
2831
+ return statesBusinessCode(body, MODEL_BUSY_CODE);
2733
2832
  }
2734
2833
  /**
2735
2834
  * Whether a failure body belongs to the `11128` gate family AT ALL.
2736
2835
  *
2737
- * The code is matched in its quoted-key form (`"code":11128`): a request id is
2738
- * hexadecimal and can contain the digits `11128` by coincidence, and a bare
2739
- * substring search over an arbitrary failure body is how a red herring gets
2740
- * promoted into a diagnosis.
2836
+ * The code is READ, not text-matched (issue #155): a spaced or string-quoted
2837
+ * envelope used to fall out of the family here, and out of the rescue ladder
2838
+ * with it. {@link statesBusinessCode} keeps both reads.
2741
2839
  */
2742
2840
  function isChannelGateFamilyBody(body) {
2743
- return body.includes(CHANNEL_REJECT_CODE);
2841
+ return statesBusinessCode(body, CHANNEL_REJECT_CODE);
2744
2842
  }
2745
2843
  /**
2746
2844
  * Whether a failure body is the gateway's CHANNEL gate — i.e. whether it
@@ -2791,14 +2889,12 @@ function isChannelRejectBody(body) {
2791
2889
  * `msg` can. Hence the wording is the criterion and the code is the
2792
2890
  * corroboration, never the other way round.
2793
2891
  *
2794
- * BOTH halves are required, and the code is matched in its quoted-key form
2795
- * (`"code":11128`): a request id is hexadecimal and can contain the digits
2796
- * `11128` by coincidence, and a bare substring search over an arbitrary
2797
- * failure body is how a red herring gets promoted into a diagnosis.
2892
+ * BOTH halves are required, and the code is read by {@link statesBusinessCode}
2893
+ * rather than text-matched, so a spaced envelope names its member too (issue
2894
+ * #155). The wording half stays case-insensitive: it is prose.
2798
2895
  */
2799
2896
  function isConversationIdGateBody(body) {
2800
- const lower = body.toLowerCase();
2801
- return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(CONVERSATION_ID_GATE_MARKER);
2897
+ return statesBusinessCode(body, CHANNEL_REJECT_CODE) && body.toLowerCase().includes(CONVERSATION_ID_GATE_MARKER);
2802
2898
  }
2803
2899
  /**
2804
2900
  * Whether a failure body is the FIRST-MESSAGE gate (`11128`).
@@ -2811,11 +2907,11 @@ function isConversationIdGateBody(body) {
2811
2907
  *
2812
2908
  * Excluded from the prompt diagnosis for the same reason as
2813
2909
  * {@link isConversationIdGateBody}, and named by the same rule: the CODE plus
2814
- * the WORDING, case-insensitively, code in its quoted-key form.
2910
+ * the WORDING, the code read rather than text-matched (issue #155), the wording
2911
+ * case-insensitively.
2815
2912
  */
2816
2913
  function isFirstMessageGateBody(body) {
2817
- const lower = body.toLowerCase();
2818
- return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(FIRST_MESSAGE_GATE_MARKER);
2914
+ return statesBusinessCode(body, CHANNEL_REJECT_CODE) && body.toLowerCase().includes(FIRST_MESSAGE_GATE_MARKER);
2819
2915
  }
2820
2916
  /**
2821
2917
  * Which member of the `11128` family a body is, decided by the plugin's OWN
@@ -2885,7 +2981,7 @@ function classifyUpstreamError(status, body) {
2885
2981
  if (status === 402) return "hard_credit";
2886
2982
  const lower = body.toLowerCase();
2887
2983
  for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
2888
- if (body.includes(CREDITS_EXHAUSTED_CODE)) return "hard_credit";
2984
+ if (statesBusinessCode(body, CREDITS_EXHAUSTED_CODE)) return "hard_credit";
2889
2985
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
2890
2986
  if (isAccountRestrictedBody(body)) return "account_restricted";
2891
2987
  if (isContentPolicyBody(body)) return "policy_reject";
@@ -2931,7 +3027,8 @@ function parseUpstreamErrorDetail(body) {
2931
3027
  const displayMsg = localizedText(raw.displayMsg) ?? (typeof raw.msg === "string" ? raw.msg : void 0);
2932
3028
  const tips = localizedText(raw.displayTips);
2933
3029
  const detail = {};
2934
- if (typeof raw.code === "number") detail.upstreamCode = raw.code;
3030
+ const statedCode = businessCodeOfDocument(raw);
3031
+ if (statedCode !== void 0) detail.upstreamCode = statedCode;
2935
3032
  if (typeof raw.requestId === "string") detail.requestId = raw.requestId;
2936
3033
  if (typeof raw.msg === "string" && raw.msg !== "") detail.rawMsg = raw.msg;
2937
3034
  if (displayMsg !== void 0) detail.displayMsg = displayMsg;
@@ -4321,10 +4418,21 @@ function parseEnterpriseInstant(value) {
4321
4418
  * A JSON body on a 401/403 is still a credential refusal: the edge answered in
4322
4419
  * the business shape, but the token is just as unusable, so it maps to the same
4323
4420
  * actionable error instead of a generic "client" failure.
4421
+ *
4422
+ * THE CLASSIFIER IS HANDED THE CODE, NOT JUST THE SENTENCE (issue #155's sibling).
4423
+ * `readEnvelope` has already parsed `code` as a number, and this call used to
4424
+ * rebuild its diagnosis from `msg` alone — a sentence that never contains
4425
+ * `"code":…`. Every family that decides on its business code was therefore
4426
+ * invisible here, and the answer fell through to `client`: the report filed as
4427
+ * `workbuddy upstream client (http 400): 请求被拦截,请重新发送` (#144) is one
4428
+ * envelope whose code the Host was already holding. Re-stating the envelope in
4429
+ * its canonical form is what lets the shared classifier see both halves it is
4430
+ * written to read: the code, and the same `msg` wording it saw before. Strictly
4431
+ * more information than the text-only call, never less.
4324
4432
  */
4325
4433
  function envelopeError(status, envelope) {
4326
4434
  if (status === 401 || status === 403) return new WorkBuddyCredentialRejectedError(status);
4327
- const kind = classifyUpstreamError(status, envelope.msg);
4435
+ const kind = classifyUpstreamError(status, `{"code":${envelope.code},"msg":${JSON.stringify(envelope.msg)}}`);
4328
4436
  return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
4329
4437
  }
4330
4438
  /**
@@ -7566,7 +7674,7 @@ function describeRosterSeenPayload(raw) {
7566
7674
  //#endregion
7567
7675
  //#region src/version.ts
7568
7676
  /** The npm package version this build was produced from. */
7569
- const WORKBUDDY_CONNECT_VERSION = "5.12.2";
7677
+ const WORKBUDDY_CONNECT_VERSION = "5.12.3";
7570
7678
  //#endregion
7571
7679
  //#region src/host-heartbeat.ts
7572
7680
  /**
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as parseCreditMultiplier, $t as withFileLock, A as isEncryptedCredentialError, At as pickMediaModel, B as WorkBuddyCredentialRejectedError, Bt as deriveAtRestKeyId, C as WorkBuddyCredentialStore, Ct as IMAGE_TAG_TEXT_TO_IMAGE, D as defaultDesktopAuthDirs, Dt as imageMimeForPath, E as defaultDesktopAuthCandidates, Et as imageDataUrl, F as workbuddyAccountId, Ft as imageDefaultFor, G as forceNeutralHead, Gt as isWorkbuddyBundle, H as channelGateMemberOf, Ht as findWorkbuddyAppExecutable, I as workbuddyOwnAuthPath, It as nativeModalityOf, J as isCredentialRejectedError, Jt as openEncryptedField, K as isContextTooLongBody, Kt as macosBundleExecutable, L as CONTEXT_OVERFLOW_MARKER, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, Mt as parseQueueAnswer, N as parseWorkBuddyAuth, Nt as queueNoticeText, O as defaultDesktopAuthPath, Ot as isBareBase64Image, P as preferredAccountName, Pt as NATIVE_MODALITY_BY_MODEL_ID, Q as openingEvidenceOfBody, Qt as resolveDshHome, R as CREDENTIAL_REJECTED_CODE, Rt as clearAtRestKeyCache, S as WORKBUDDY_AUTH_FILE_ENV, St as IMAGE_TAG_IMAGE_TO_IMAGE, T as authFileName, Tt as WorkBuddyImageError, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, Vt as fetchAtRestKeyPayload, W as declaredTools, Wt as isEncryptedFieldWrapper, X as knownRegionOf, Xt as workbuddyAppExecutableCandidates, Y as isFirstMessageGateBody, Yt as readAtRestKey, Z as modelIdOfBody, _ as deriveCatalog, _t as WorkBuddyVideoError, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as resolveVideoParams, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, en as writeFileAtomic, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as VIDEO_TAG_TEXT_TO_VIDEO, h as WorkBuddyCatalog, ht as VIDEO_TAG_IMAGE_TO_VIDEO, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as sniffImageMime, k as hasEncryptedCredentialFields, kt as parseImageDataUrl, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, qt as macosNestedAppBundles, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseSupportedSecondsRange, w as WorkBuddyEncryptedCredentialError, wt as MAX_INPUT_IMAGE_BYTES, x as WORKBUDDY_AUTH_FILENAME, xt as DEFAULT_IMAGE_SIZE, y as withoutRouters, yt as pickVideoModel, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as deriveAtRestKey } from "./host-heartbeat-BdTV4Aoi.js";
1
+ import { $ as parseCreditMultiplier, $t as withFileLock, A as isEncryptedCredentialError, At as pickMediaModel, B as WorkBuddyCredentialRejectedError, Bt as deriveAtRestKeyId, C as WorkBuddyCredentialStore, Ct as IMAGE_TAG_TEXT_TO_IMAGE, D as defaultDesktopAuthDirs, Dt as imageMimeForPath, E as defaultDesktopAuthCandidates, Et as imageDataUrl, F as workbuddyAccountId, Ft as imageDefaultFor, G as forceNeutralHead, Gt as isWorkbuddyBundle, H as channelGateMemberOf, Ht as findWorkbuddyAppExecutable, I as workbuddyOwnAuthPath, It as nativeModalityOf, J as isCredentialRejectedError, Jt as openEncryptedField, K as isContextTooLongBody, Kt as macosBundleExecutable, L as CONTEXT_OVERFLOW_MARKER, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, Mt as parseQueueAnswer, N as parseWorkBuddyAuth, Nt as queueNoticeText, O as defaultDesktopAuthPath, Ot as isBareBase64Image, P as preferredAccountName, Pt as NATIVE_MODALITY_BY_MODEL_ID, Q as openingEvidenceOfBody, Qt as resolveDshHome, R as CREDENTIAL_REJECTED_CODE, Rt as clearAtRestKeyCache, S as WORKBUDDY_AUTH_FILE_ENV, St as IMAGE_TAG_IMAGE_TO_IMAGE, T as authFileName, Tt as WorkBuddyImageError, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, Vt as fetchAtRestKeyPayload, W as declaredTools, Wt as isEncryptedFieldWrapper, X as knownRegionOf, Xt as workbuddyAppExecutableCandidates, Y as isFirstMessageGateBody, Yt as readAtRestKey, Z as modelIdOfBody, _ as deriveCatalog, _t as WorkBuddyVideoError, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as resolveVideoParams, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, en as writeFileAtomic, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as VIDEO_TAG_TEXT_TO_VIDEO, h as WorkBuddyCatalog, ht as VIDEO_TAG_IMAGE_TO_VIDEO, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as sniffImageMime, k as hasEncryptedCredentialFields, kt as parseImageDataUrl, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, qt as macosNestedAppBundles, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseSupportedSecondsRange, w as WorkBuddyEncryptedCredentialError, wt as MAX_INPUT_IMAGE_BYTES, x as WORKBUDDY_AUTH_FILENAME, xt as DEFAULT_IMAGE_SIZE, y as withoutRouters, yt as pickVideoModel, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as deriveAtRestKey } from "./host-heartbeat-D1N-f8Q6.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -715,6 +715,82 @@ function nextRegionEnabled(value, region, enabled) {
715
715
  });
716
716
  }
717
717
  /**
718
+ * The per-region account SELECTIONS map (`Config.accounts`), as plain data.
719
+ *
720
+ * `value` may be the whole settings SECTION or the `accounts` FIELD: `describe()`
721
+ * hands back the section with volatile fields as live references, and a blind
722
+ * spread of that leaks the `get()` function into a write payload — the settings
723
+ * service then refuses the whole save with `found a function at $.ops[0].value.get`
724
+ * (measured at the card's save endpoint, `src/web-status.ts`). Unwrapping first is
725
+ * what makes the merge carry data and not behaviour.
726
+ *
727
+ * The map is recognised by its KEYS, not by its shape: only region ids can appear
728
+ * in it, while a section always carries at least one non-region key (`authFile`,
729
+ * `regions`, `cliBridge`). Same direction as {@link regionsMapOf} chose for the
730
+ * reason documented there — a nested copy is a DIFFERENT, older value, so the
731
+ * outer object's own keys decide what it is.
732
+ */
733
+ function accountsMapOf(value) {
734
+ const unwrapped = unwrapVolatileDeep(value);
735
+ if (!isRecord$1(unwrapped)) return {};
736
+ if (hasRegionKey(unwrapped)) return unwrapped;
737
+ const nested = unwrapVolatileDeep(unwrapped["accounts"]);
738
+ return isRecord$1(nested) ? nested : {};
739
+ }
740
+ /**
741
+ * Build the next `accounts` FIELD for one region's selection.
742
+ *
743
+ * ONLY the target region's key changes; the sibling region is carried over
744
+ * verbatim. `accounts` is a flat two-key map that BOTH regions write, so a
745
+ * whole-field write that rebuilt it from one caller's view would drop the other
746
+ * region's pin — the failure mode every other `next*` helper in this file exists
747
+ * to prevent, in a field that is easier to clobber because it looks trivial.
748
+ *
749
+ * The value is written as given, including `''`. That distinction is load-bearing
750
+ * and not a detail: `''` is the Clear sentinel ("this region follows the app's
751
+ * current sign-in"), while an ABSENT key means "never configured" and still falls
752
+ * back to the legacy flat `accountId` — see `selectAccountFor`. A helper that
753
+ * deleted the key to "clear" a region would silently re-bind it to whichever
754
+ * account the legacy field names.
755
+ */
756
+ function nextAccountsSelection(value, region, accountId) {
757
+ return {
758
+ ...accountsMapOf(value),
759
+ [region]: accountId
760
+ };
761
+ }
762
+ /**
763
+ * Whether one region's selection is PERSISTENTLY bound to this account id.
764
+ *
765
+ * THE QUESTION {@link nextAccountsSelection} MUST ANSWER BEFORE WRITING. Two
766
+ * persisted sources can pin an account, and clearing only the obvious one leaves
767
+ * the other in place:
768
+ *
769
+ * 1. `accounts[region]` — the per-region slot, the current spelling.
770
+ * 2. the deprecated flat `accountId`, which `selectAccountFor` falls back to for
771
+ * the ONE region the startup scan attributed it to (`legacyAccountRegion`) and
772
+ * only when that region's slot is ABSENT. A pre-split profile therefore pins
773
+ * its account through field 2 while field 1 holds nothing at all — and that
774
+ * is precisely a profile whose user can remove an account and inherit an
775
+ * orphaned pin.
776
+ *
777
+ * `accounts[region] === ''` answers `false` even when the id matches nothing: the
778
+ * sentinel means "this region follows the app's current sign-in", which already
779
+ * cannot point at a dead account, so there is nothing to clear and a write would
780
+ * be noise (and a needless settings round trip).
781
+ *
782
+ * @param value the resolved settings section, or the `accounts` field.
783
+ * @param region the region whose pin is being questioned.
784
+ * @param accountId the account about to be removed.
785
+ * @param legacyAccountId the deprecated flat `accountId`, read as persisted.
786
+ * @param legacyRegion which region the startup scan attributed that flat id to.
787
+ */
788
+ function regionPinsAccount(value, region, accountId, legacyAccountId, legacyRegion) {
789
+ const configured = accountsMapOf(value)[region];
790
+ if (configured !== void 0) return configured === accountId;
791
+ return legacyAccountId !== void 0 && legacyAccountId === accountId && legacyRegion === region;
792
+ }
793
+ /**
718
794
  * Project one card row into its persisted `lastCatalog` shape: the native
719
795
  * context window becomes the stored `contextWindow`, and the card-only
720
796
  * presentation fields (`nativeContextWindow`, `multimodal`) are removed BY
@@ -897,6 +973,64 @@ async function healRegionsField(service, ns) {
897
973
  }], void 0);
898
974
  return true;
899
975
  }
976
+ /**
977
+ * Drop one region's PERSISTED account selection, and only if it pinned this
978
+ * account.
979
+ *
980
+ * THE HOST-SIDE HALF OF ISSUE #155'S SECOND REPORT. A user removed one account
981
+ * and the whole CN tab turned into「已保存的账号在本机已不存在」, with no way back:
982
+ * the card's advice points at a list it does not render in that state, so the
983
+ * reporter escaped by hand-posting `{"field":"accounts","value":{"cn":""}}` to
984
+ * the save endpoint. This is that write, made automatic.
985
+ *
986
+ * WHY A WRITE AT ALL — the store already forgets. `removeAccount` clears the
987
+ * in-memory selection, which is why the region keeps working for the rest of the
988
+ * session. But the settings document still names the dead id, so the next
989
+ * `applySelection` — a restart, or any settings save — re-reads it, re-pins it,
990
+ * and the region falls back into the same dead end. An in-memory forget that is
991
+ * never persisted is not a fix; it is a delay.
992
+ *
993
+ * THE DECISION LIVES HERE, WITH THE WRITE, because the two must never be
994
+ * separated by a caller's guess. Clearing a region that did NOT pin this account
995
+ * would move the user off the account they chose, and「换账号等于换人计费」is the
996
+ * rule every writer in this plugin obeys: this function writes for exactly the
997
+ * region whose persisted pin names the account being removed, and no others.
998
+ *
999
+ * WRITES `''`, NEVER A DELETED KEY. The sentinel means "follow the app's current
1000
+ * sign-in" — the plugin's documented default, which is what a fresh install does,
1001
+ * so this restores a state rather than inventing one. An absent key means "never
1002
+ * configured" and falls through to the legacy flat `accountId` (`selectAccountFor`),
1003
+ * which would resurrect whichever account that field names: a silent switch
1004
+ * through another door. The sentinel also TERMINATES that lookup, which is why one
1005
+ * write settles both spellings — a pre-split profile pinned through the flat
1006
+ * field is cleared by this same write.
1007
+ *
1008
+ * FAIL-SOFT BY CONTRACT: a service that cannot answer, or a region that pinned
1009
+ * nothing, costs no write. The caller logs a failed mutation rather than failing
1010
+ * the removal, because the credential file is already gone by then and reporting
1011
+ * "could not remove" would be the lie.
1012
+ *
1013
+ * @param service The Host's settings service, or `undefined` to no-op.
1014
+ * @param ns The namespace this plugin's settings live under.
1015
+ * @param region The region being checked; the sibling is preserved by the merge.
1016
+ * @param accountId The account about to be removed.
1017
+ * @param legacyAccountId The deprecated flat `accountId`, as persisted.
1018
+ * @param legacyRegion Which region the startup scan attributed that field to.
1019
+ * @returns whether a write happened.
1020
+ */
1021
+ async function clearRegionAccountSelection(service, ns, region, accountId, legacyAccountId, legacyRegion) {
1022
+ if (service === void 0) return false;
1023
+ const row = service.describe().find((entry) => entry.ns === ns);
1024
+ if (row === void 0) return false;
1025
+ const section = row.value ?? {};
1026
+ if (!regionPinsAccount(section, region, accountId, legacyAccountId, legacyRegion)) return false;
1027
+ await service.mutate(ns, [{
1028
+ op: "set",
1029
+ path: ["accounts"],
1030
+ value: nextAccountsSelection(section, region, "")
1031
+ }], void 0);
1032
+ return true;
1033
+ }
900
1034
  //#endregion
901
1035
  //#region src/credential-recovery.ts
902
1036
  /** How long a verdict stays fresh by default. */
@@ -12017,8 +12151,31 @@ function apply(ctx, config) {
12017
12151
  * Forget ONE account's plugin-stored credential (the vault entry; the
12018
12152
  * desktop app's files are never touched). The card rescans afterwards,
12019
12153
  * so the row disappears the moment the write lands.
12154
+ *
12155
+ * AND DROP THE REGION'S PERSISTED PIN, when this was the pinned account
12156
+ * (issue #155's second report). `removeAccount` forgets the selection in
12157
+ * memory only, so the next `applySelection` — a restart, or any settings
12158
+ * save — re-reads the dead id out of `accounts.<region>`, re-pins it, and
12159
+ * the tab returns to「已保存的账号在本机已不存在」with the card's advice
12160
+ * pointing at a list that state does not render. The clear is decided from
12161
+ * the settings document rather than from the store, and is scoped to the
12162
+ * one region that pinned this account: an unrelated removal must not move
12163
+ * anybody off the account they chose.
12164
+ *
12165
+ * FAIL-SOFT, like the field repair above: the credential is already gone by
12166
+ * the time this runs, so a settings write that cannot land must not be
12167
+ * reported as a failed removal — that would tell the user the account is
12168
+ * still there when it is not.
12020
12169
  */
12021
- removeOne: async (region, accountId) => stacks[region].store.removeAccount(accountId)
12170
+ removeOne: async (region, accountId) => {
12171
+ const removed = await stacks[region].store.removeAccount(accountId);
12172
+ try {
12173
+ await clearRegionAccountSelection(hostSettings(), settingsNs, region, accountId, unwrapVolatile(current().accountId), legacyAccountRegion);
12174
+ } catch (error) {
12175
+ ctx.logger.warn("dsh-connect-workbuddy: the account was removed, but the region could not be un-pinned from settings; the selection may reappear as orphaned after a restart", error);
12176
+ }
12177
+ return removed;
12178
+ }
12022
12179
  },
12023
12180
  /**
12024
12181
  * OAuth QR sign-in for this card. The session lives in `src/oauth.ts`
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": "5.12.2",
5
+ "version": "5.12.3",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",