dsh-connect-workbuddy 5.3.0 → 5.4.0

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,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.4.0 (2026-10-22)
4
+
5
+ > 少数版本:修四处真实缺陷,**请求路径、provider 路由、计费与凭据一律未变**。其中三处是「代码写好了但没被走到」的缺陷——功能早就存在,只是有一整类真实报文从未进入它们。
6
+
7
+ ### Fixes
8
+
9
+ - **模型目录不再被陈旧快照锁死(#114 + #115,同一处缺陷的两面)**
10
+ - **病因**:`configuredModels()` / `displayModels()` 把「已保存目录 `lastCatalog`」当作第一优先级,而它**唯一的写入者**是卡片保存、写回的又是**它自己读到的目录**——闭环成立,实时目录被永久压制。于是上游新增的模型(CN 的 `deepseek-v4.1-flash`)永远进不来;更糟的是**冷启动播种**会在上游尚未应答时把静态兜底表写进 `lastCatalog`,而那张表**不含 `reasoning`**,每个模型因此以 `reasoning: false` 发布,宿主只给 `Default` / `Off`。
11
+ - **修法**:目录解析改为「**实时 > 已保存 > 静态兜底**」。已保存目录只承担**名字与勾选**,能力字段(`reasoning` / `creditMultiplier` / `supportsImages`)一律取自实时目录(新增 `src/catalog-sync.ts`)。上游已撤回但用户仍在用的模型继续保留(#32 的规则不变)。
12
+ - **自愈**:实时目录到达后按**内容指纹**比对回写 `lastCatalog`,只在真有差异时写、幂等(新增 `src/catalog-write.ts`),所以升级后无需手动清 profile。与兜底表逐项相同且不含能力字段的旧快照被识别为「从未配置」,不再当真。
13
+ - **播种不再写兜底表**:`src/web-status.ts` 播种前先判断「这份目录是不是兜底表本身」,是则跳过。
14
+ - **`busyLevel` 绝不进入卡片目录**:它是「这一分钟」的事实(`src/busy.ts`),且显式 `undefined` 会被设置写入的严格 JSON 编解码器拒绝,因此按 key 删除而非赋 `undefined`。
15
+
16
+ - **`11128` 一族按 code 整体识别,成员由插件自己的证据点名(#113)**
17
+ - **病因**:旧 `isChannelRejectBody()` 要求「带渠道措辞」**且**「不带另两个门的措辞」。而 #113 实测到 `msg: "conversation id missing"` 出现在**携带全部三个会话头**的请求上——措辞不可信。后果不是文案问题:该 body 被判成 `client`,而 `src/shim.ts` 的两级救援阶梯(`forceNeutralHead` → `perturbChannelFingerprints`)是**按 kind 门控**的,`client` 根本不进那个分支。**门 A、门 B 的救援都写好了,但这一整类真实报文从未走到它们面前**——这解释了「升级了却没变化」的反馈。
18
+ - **修法**:`isChannelRejectBody()` 现在回答「是否属于 11128 一族」,只按 `"code":11128` 判定;新增 `channelGateMemberOf()`,用**请求侧证据**(是否带了会话头 / 首条是否 system)点名成员 `prompt` / `headers` / `first_message` / `unknown`,只影响**建议文案**、不影响分类。回复会回显上游原始 `msg`(新增 `UpstreamErrorDetail.rawMsg`)并说明为何可信或不可信;不认识的措辞明确写「没点名是哪一道门」,而不是猜一个诊断。
19
+
20
+ - **扰动补上词内断点(#113 暴露的真实缺口)**
21
+ - 原来只在 `.` `!` `?` `,` `;` 之后插入零宽字符,所以**结尾没有标点的裸产品名完全不会被改动**——例如 DSH agent 提示的首行 `You are an AI agent powered by DeepSeek Harness` 正好在文末,`perturbChannelFingerprints()` 直接返回 `undefined`(「无可改动」),救援空转。现在按「大写词 + 大写词」加词内断点(`DeepSeek|Harness`),仍只在**上游已拒绝之后**作为最后一级重试发生,正常请求一个字节都不动。
22
+
23
+ - **`alignAssistantIdentities` 只改本插件产生的消息(#111)**
24
+ - **病因**:原实现对**每一条** `role: 'assistant'` 消息重写 `provider` / `api`,其 docblock 断言「transcript 里不会有别的 provider 的 assistant 轮次」。**该前提不成立**:`dsh-llm` 的 `forAdapter()` 只剥掉外来消息的 `replayState`,消息本身留在列表里。于是切换过 provider 的会话会把别人的轮次改写成 `workbuddy` / `openai-completions`——等于把宿主刚关上的「可重放」那道门重新打开,并让 transcript 宣称一个不成立的来源。
25
+ - **修法**:重写范围收敛到本 bundle 的两个路由(`workbuddy` / `workbuddy-global`)或已带 identity 的消息,其余**原对象返回**。#102(全球区 thinking 被压平)与 #59(跨区 identity)的修复均不受影响。`HOST_FOREIGN_API` 改为导出以便测试钉住。
26
+
27
+ ### Docs
28
+
29
+ - 订正三处此前写错的论证:`reasoning` **不会**被设置写入路径剥掉(实测 `projectForm` / standard validate 都完整保留;`simplify()` 只是表单投影、不在写入路径上);「回显的 `You are a helpful assistant.` 就是被拒的开头」实为插件自己垫的中性头挡住了真实开头(4.15.x 已改为从 `raw` 读证据);`systemOpeningsOfBody()` 只读字符串 content 的数组形状盲区已由 `openingEvidenceOfBody()` 取代。
30
+
31
+ ### Tests
32
+
33
+ - 新增 `tests/catalog-sync.spec.ts`(22 条)、`tests/issue113-channel-gate-evidence.spec.ts`(17 条,含一条端到端「用 #113 的真实报文作第一次应答,断言插件真的发出了第二次请求」)、`tests/issue111-align-scope.spec.ts`(9 条)。
34
+ - 订正 `tests/issue90-channel-reject.spec.ts`、`tests/issue98-channel-gate.spec.ts`、`tests/issue98-shim.spec.ts` 里四处**钉住旧行为**的断言(它们把「11128 的另两个门归类为 `client`」当成契约,而那正是 #113 要修的东西)。
35
+ - 全量:**121 文件 / 1996 条通过(另 2 条 skip)**;两个 tsconfig 均 exit 0;build 成功。
36
+
37
+ ## 5.3.1 (2026-10-22)
38
+
39
+ > 补丁版本:5.3.0 已经发布了负载徽标,这一版修它的**消失**问题,并把它的**呈现方式**换成更好认的样子。 **没有新增任何功能**:模型目录、请求路径、provider 路由、计费与凭据一律未变,5.3.0 的全部能力原样保留。
40
+
41
+
42
+ ### Fixes
43
+
44
+ - **负载徽标不再「刚出现就消失」**:等级以前只活在**运行时目录**里,而任何一次配置变更(切区、改模型选择、改思考档、开关、换号)都会触发 `applySelection()` 用**已保存的 `lastCatalog` 重算目录**——而 `lastCatalog` 里没有 `busyLevel`(负载**刻意不落盘**:`src/busy.ts` 写着「存下来的负载等级是谎言」)。于是你一动任何设置,徽标就被抹掉,上游其实什么都没变。 **现在改为「重放后重新问一次」**:探针只读、免费、整份名单约 190 ms,徽标因此在设置变更后活下来。 不落盘这件事仍然保留——负载是**这一分钟**的事实,跨重启后那个数字可能早就过期了,比不显示更危险。 探针失败照旧只是少一枚徽标,绝不会连模型列表一起丢。
45
+
46
+ - **负载徽标改用图标**:拥堵不再是四个字,而是一枚一眼能认的标记。
47
+ - **四种状态、四种标记**:**正常 `normal` = 1 个黄人**、**轻微拥堵 `busy` = 2 个橙人**、**极度拥堵 `congested` = 3 个红人**、**空闲 `idle` = 一枚绿色火箭**(没人排队 = 可以全速跑)。 **人数 = 排队的人**,**颜色 = 严重程度**(绿→黄→橙→红是眼睛本来就认识的顺序,扫一眼就能比出两行谁更堵)。 空闲之所以是**另一种形状**而不是「绿色的 1 个人」:在 12px 下「没人等」和「有 1 个人在等」必须一眼分开,光靠颜色不够。
48
+ - **小人跟 `🧍` emoji 是同一个人**:黄头发(带刘海)、肤色脸和白上衣、灰裤、黑鞋。 **换色只改「上衣+裤子」**,头发和皮肤保留本色,所以每一档都是「同一个人换了件衣服」。 **五官不画**——模型行里高度只有 12px,眼睛画出来只有 0.2px,是噪点不是脸,靠轮廓认人。 横向步进 9.6 个单位(约半个身位),肩挨肩,**高度固定、只向右变宽**,不会被压扁。
49
+ - **模型选择器里也用彩色小人**:宿主提供了 `conversation.input.model.label` 插槽(契约原文:「渲染非交互的内联内容,例如带颜色的名称或徽标」;官方自己就用它挂 DeepSeek 的「1017」发布徽标),插件在那里注册了 SVG 标记。 **模型名里不再挂 emoji**——那会变成同一件事用两套视觉语言说两遍;emoji 的颜色本来就由字体决定(永远是黄的),拿不到绿/黄/橙/红。 插槽里**保留目录里的模型名**,只在旁边加徽标;**别人的提供方一个徽标都不画、连请求都不发**;徽标按 provider 取**对应区域**的等级(`workbuddy`→cn、`workbuddy-global`→global,否则会把一个网关的负载贴到另一个网关的模型上)。
50
+ - 颜色跟随主题,并**保留 `title` 悬停文字**(「极度拥堵」四个字还在),所以省地方但不丢信息。
51
+
52
+ ### Tests
53
+
54
+ - 新增 `tests/busy-badge.spec.ts`(8 条)、`tests/busy-icon-card.spec.tsx`(11 条)与 `tests/model-label-slot.spec.tsx`(5 条),覆盖徽标的合并语义、图标的人数与颜色、以及插槽的「别人的提供方不画、模型名要保留、没等级就不画」。
55
+ - 全量:**119 文件 / 1946 条通过(另 2 条 skip)**;两个 tsconfig 均 exit 0;build 成功。
56
+
3
57
  ## 5.3.0 (2026-10-22)
4
58
 
5
59
  > 新增能力属 **minor**,本次定版 **5.3.0**。**默认关闭,关闭时插件行为与 5.2.3 完全一致**:不多一个工具、不多一条指令、不多一个请求,provider 路由、模型、凭据与计费一律未变。
@@ -68,7 +122,7 @@
68
122
  - 换号:只有 401 才换、**超时/崩溃不换**、最后一条 401 也不重试、空凭据列表报 `no credential worked`
69
123
  - 脱敏:token 与 `Bearer …` 形态既不进正文也不进 errors
70
124
  - 主 spec 的新增/重写部分:`resolveWorkingDir` 的 9 条真目录断言(不存在 / 是文件不是目录 / 相对路径 / 允许根之外 / `allowedDirs` 放行 / 前缀相同的兄弟目录拒绝)、`TurnGate` 的 7 条(假时钟注入、并发 busy、配额拒绝、窗口滑过恢复、`0` = 不限、`clear()`)、`resolveCliEntry` 的新签名与「显式路径失败不得回退 PATH」、`looksLikeAuthFailure` 的新签名。
71
- - 全量:**116 文件 / 1920 条通过(另 2 条 skip)**;`tsc -p tsconfig.json` 与 `tsc -p tsconfig.client.json` 均 exit 0;`tsdown` build 成功(`lib/index.js` 446.54 kB)。
125
+ - 全量:**118 文件 / 1935 条通过(另 2 条 skip)**;`tsc -p tsconfig.json` 与 `tsc -p tsconfig.client.json` 均 exit 0;`tsdown` build 成功(`lib/index.js` 451.31 kB)。
72
126
  - **另做了一轮真机联调**(`@tencent-ai/codebuddy-code` 2.171.0,真实 vault 账号,非 mock),六项全过:①`dontAsk` 默认 9.9 秒一轮,Glob/Bash 放行、Write 被拒且如实汇报;②`acceptEdits` 下 Bash 被拒后 CLI 换 PowerShell 完成任务(12.2 秒、`ok:true`),验证 `interrupt` 不能对所有工具开;③`plan` 的 `ExitPlanMode` 被带 `interrupt:true` 拒掉后 12.8 秒结束,不再挂满 `timeoutSecs`;④`acceptEdits` 真的写文件,且落在我们指定的 cwd(`hello.txt` 内容一字不差);⑤6 秒超时 → 6.0 秒抛 `codebuddy exceeded its 6s budget`,**事后无残留 codebuddy 进程**(`taskkill /T` 生效);⑥凭据链真实可用:global 令牌得到 `401 Authentication required`、换 cn 后 8.1 秒成功,且全程 token 未进正文/errors。
73
127
 
74
128
  ### Tests(同期并入 main:issue #102 / #108)
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutableWithSource, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, _t as WORKBUDDY_APP_EXECUTABLE_ENV, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, jt as hostPeersInfo, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, w as defaultDesktopAuthDirs, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-_EvGiAek.js";
2
+ import { C as defaultDesktopAuthCandidates, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as hostPeersInfo, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, wt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-D5FWM_8k.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
@@ -3952,6 +3952,221 @@ window.__ModuleLoader__.load({
3952
3952
  });
3953
3953
  }
3954
3954
  //#endregion
3955
+ //#region src/client/busy-icon.tsx
3956
+ /** The word for a level, in the card's language, for the tooltip. */
3957
+ const TITLES_ZH = {
3958
+ idle: "空闲",
3959
+ normal: "正常",
3960
+ busy: "轻微拥堵",
3961
+ congested: "极度拥堵",
3962
+ unavailable: "不可用"
3963
+ };
3964
+ const TITLES_EN = {
3965
+ idle: "Idle",
3966
+ normal: "Normal",
3967
+ busy: "Slightly congested",
3968
+ congested: "Extremely congested",
3969
+ unavailable: "Unavailable"
3970
+ };
3971
+ /**
3972
+ * The CLOTHES colour per level, as a CSS custom property.
3973
+ *
3974
+ * These are the ramp: green → yellow → orange → red. The hair and the skin
3975
+ * keep their own colours in every level, so a row is recognisably the same
3976
+ * person in a different outfit — which is what makes the COUNT readable as
3977
+ * "more people, not different people".
3978
+ */
3979
+ const COLORS = {
3980
+ idle: "var(--dsm-workbuddy-busy-idle, #3aa76d)",
3981
+ normal: "var(--dsm-workbuddy-busy-normal, #d9b300)",
3982
+ busy: "var(--dsm-workbuddy-busy-busy, #e08a1e)",
3983
+ congested: "var(--dsm-workbuddy-busy-congested, #d93a2b)",
3984
+ unavailable: "var(--dsm-workbuddy-busy-unavailable, #8a8f98)"
3985
+ };
3986
+ /** How many people each level draws. */
3987
+ const PEOPLE = {
3988
+ normal: 1,
3989
+ busy: 2,
3990
+ congested: 3
3991
+ };
3992
+ /**
3993
+ * The icon for a level, or `undefined` when the level is not drawn.
3994
+ *
3995
+ * Only `unknown` is left out: it is the plugin's own "the probe did not
3996
+ * answer", and a badge that guesses is worse than no badge. Every level the
3997
+ * upstream DID report is drawn — `idle` included, in green, because "the model
3998
+ * is free right now" is useful information.
3999
+ */
4000
+ function busyIconOf(level) {
4001
+ if (level === void 0) return void 0;
4002
+ if (level === "unknown") return void 0;
4003
+ return level;
4004
+ }
4005
+ /**
4006
+ * One person, on the icon's own grid: 7.9 units wide, 19.3 units tall.
4007
+ *
4008
+ * The five parts are the emoji's own — hair, face, shirt, trousers, shoes —
4009
+ * because a stick figure would not read as the same mark as the `🧍` the
4010
+ * picker shows.
4011
+ *
4012
+ * NO FACE FEATURES. At the 12px the model row actually uses, an eye is 0.2px
4013
+ * wide: drawing it produces a smudge, not a face. So the face is a plain
4014
+ * skin-toned shape and the character is carried by the silhouette.
4015
+ */
4016
+ function person(x, key, shirt, shirtEdge, trousers) {
4017
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", {
4018
+ transform: `translate(${x} 0)`,
4019
+ children: [
4020
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4021
+ d: "M2.7 3.1c0-1.6 1-2.6 2.5-2.6s2.5 1 2.5 2.6c0 .8-.2 1.4-.6 1.9-.2-1-.7-1.5-1.3-1.7-.5.6-1.3.8-2 .7-.7-.1-1.2-.6-1.3-1.4-.5.1-.8.2-.8.2z",
4022
+ fill: "#f0c04a"
4023
+ }),
4024
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4025
+ d: "M3 3.4h3.4v2.1c0 1.15-.76 2-1.7 2s-1.7-.85-1.7-2z",
4026
+ fill: "#ffd9a0"
4027
+ }),
4028
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4029
+ d: "M4.4 6.6h.6v.9h-.6z",
4030
+ fill: "#f3c493"
4031
+ }),
4032
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4033
+ d: "M1.75 8.9c-.45.45-.65 1.25-.65 2.15v2.9c0 .4.3.7.7.7s.7-.3.7-.7v-2.8c0-.6.1-1 .25-1.35zM7.55 8.9c.45.45.65 1.25.65 2.15v2.9c0 .4-.3.7-.7.7s-.7-.3-.7-.7v-2.8c0-.6-.1-1-.25-1.35z",
4034
+ fill: "#ffd9a0"
4035
+ }),
4036
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4037
+ d: "M2.8 7.7h3.7c.75 0 1.3.55 1.3 1.3v5.1H1.5V9c0-.75.55-1.3 1.3-1.3z",
4038
+ fill: shirt
4039
+ }),
4040
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4041
+ d: "M3.9 7.7l.75 1 .75-1",
4042
+ stroke: shirtEdge,
4043
+ strokeWidth: ".45",
4044
+ fill: "none",
4045
+ strokeLinejoin: "round"
4046
+ }),
4047
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4048
+ d: "M1.9 14.1h2.05l-.3 4.3H1.6zM5.85 14.1h2.05l.3 4.3H5.5z",
4049
+ fill: trousers
4050
+ }),
4051
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4052
+ d: "M2.15 18.4h1.4v.9H1.7c-.3 0-.45-.3-.3-.6zM5.85 18.4h1.4l.55.55c.2.2 0 .35-.3.35H5.85z",
4053
+ fill: "#2f3136"
4054
+ })
4055
+ ]
4056
+ }, key);
4057
+ }
4058
+ /**
4059
+ * A ROCKET, for the `idle` level: nobody is queuing, so the model is free to
4060
+ * run at full speed.
4061
+ *
4062
+ * A different SHAPE, not a different colour, and that is the point: the level
4063
+ * has to be readable at 12px without reading a word, and a rocket next to one,
4064
+ * two or three people separates "nobody is waiting" from "one person is
4065
+ * waiting" instantly. The people count the queue; the rocket says there is
4066
+ * none.
4067
+ *
4068
+ * SOLID, not outlined: a stroked rocket at this size turns into a smudge. The
4069
+ * body is filled in the level's colour, the window is white so it reads at
4070
+ * 12px, and the flame is a fixed orange (a flame is a flame).
4071
+ */
4072
+ function rocket(color) {
4073
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
4074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4075
+ d: "M6.1 15.6c.2 1.6.8 2.8 1.9 3.7 1.1-.9 1.7-2.1 1.9-3.7z",
4076
+ fill: "#f5a623"
4077
+ }),
4078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4079
+ d: "M4.4 11.8 2 16.1h2.5zM11.6 11.8l2.4 4.3h-2.5z",
4080
+ fill: shadeOf(color)
4081
+ }),
4082
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4083
+ d: "M8 .9c2.7 2.9 4.2 6.6 4.2 10.4 0 1.7-.4 3.2-1 4.5H4.8c-.6-1.3-1-2.8-1-4.5 0-3.8 1.5-7.5 4.2-10.4z",
4084
+ fill: color
4085
+ }),
4086
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4087
+ cx: "8",
4088
+ cy: "6.6",
4089
+ r: "2.1",
4090
+ fill: "#ffffff"
4091
+ }),
4092
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4093
+ d: "M6.4 3.6c-.7 1.2-1.2 2.6-1.4 4.1",
4094
+ stroke: "#ffffff",
4095
+ strokeOpacity: ".35",
4096
+ strokeWidth: ".7",
4097
+ fill: "none",
4098
+ strokeLinecap: "round"
4099
+ })
4100
+ ] });
4101
+ }
4102
+ /** The shaded edge of the clothes, one step darker than the clothes. */
4103
+ function shadeOf(color) {
4104
+ return `color-mix(in srgb, ${color} 78%, #000)`;
4105
+ }
4106
+ /**
4107
+ * The load-level icon for one model row.
4108
+ *
4109
+ * Returns `null` for a level that is not drawn — including `undefined`, which
4110
+ * means "not measured", and `unknown`, which must never be turned into a
4111
+ * claim the upstream did not make.
4112
+ */
4113
+ function busyIcon(level, language = "zh") {
4114
+ const icon = busyIconOf(level);
4115
+ if (icon === void 0) return null;
4116
+ const color = COLORS[icon];
4117
+ const title = (language.toLowerCase().startsWith("en") ? TITLES_EN : TITLES_ZH)[icon];
4118
+ if (icon === "unavailable") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4119
+ className: "dsm-workbuddy-busy dsm-workbuddy-busy-unavailable",
4120
+ title,
4121
+ style: { "--dsm-workbuddy-busy-color": color },
4122
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
4123
+ viewBox: "0 0 19.3 19.3",
4124
+ width: 12,
4125
+ height: 12,
4126
+ "aria-hidden": "true",
4127
+ focusable: "false",
4128
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("g", {
4129
+ opacity: ".5",
4130
+ children: person(0, "p", "#8a8f98", "#6f747c", "#6f747c")
4131
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4132
+ d: "M0.6 19.4 19.4 0.6",
4133
+ stroke: "currentColor",
4134
+ strokeWidth: "1.8",
4135
+ strokeLinecap: "round",
4136
+ fill: "none"
4137
+ })]
4138
+ })
4139
+ });
4140
+ if (icon === "idle") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4141
+ className: "dsm-workbuddy-busy dsm-workbuddy-busy-idle",
4142
+ title,
4143
+ style: { "--dsm-workbuddy-busy-color": color },
4144
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4145
+ viewBox: "0 0 16 19.3",
4146
+ width: 12,
4147
+ height: 12,
4148
+ "aria-hidden": "true",
4149
+ focusable: "false",
4150
+ children: rocket(color)
4151
+ })
4152
+ });
4153
+ const count = PEOPLE[icon] ?? 1;
4154
+ const width = (count - 1) * 9.6 + 7.9;
4155
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4156
+ className: `dsm-workbuddy-busy dsm-workbuddy-busy-${icon}`,
4157
+ title,
4158
+ style: { "--dsm-workbuddy-busy-color": color },
4159
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4160
+ viewBox: `0 0 ${width} 19.3`,
4161
+ width: 12,
4162
+ height: 12,
4163
+ "aria-hidden": "true",
4164
+ focusable: "false",
4165
+ children: Array.from({ length: count }, (_, index) => person(index * 9.6, `p${index}`, color, shadeOf(color), shadeOf(color)))
4166
+ })
4167
+ });
4168
+ }
4169
+ //#endregion
3955
4170
  //#region src/client/promo-badge.ts
3956
4171
  /**
3957
4172
  * The UI's language, as the browser reports it.
@@ -4387,6 +4602,9 @@ window.__ModuleLoader__.load({
4387
4602
  one the brand colour stands in. "flex:none" keeps the badge whole while the
4388
4603
  model name beside it truncates with an ellipsis. The plain background comes
4389
4604
  first so an engine without color-mix still gets a readable chip. */
4605
+ .dsm-workbuddy-model-label{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;vertical-align:baseline}
4606
+ .dsm-workbuddy-busy{flex:none;display:inline-flex;align-items:center;block-size:12px;color:var(--dsm-workbuddy-busy-color);vertical-align:-2px}
4607
+ .dsm-workbuddy-busy svg{display:block;block-size:12px;inline-size:auto}
4390
4608
  .dsm-workbuddy-model-promo{flex:none;display:inline-flex;align-items:center;padding:0 7px;border-radius:999px;font-size:10px;line-height:16px;font-weight:600;white-space:nowrap;color:var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe));background:rgba(255,255,255,.08);background:color-mix(in srgb,var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe)) 16%,transparent);border:1px solid transparent;border-color:color-mix(in srgb,var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe)) 40%,transparent)}
4391
4609
  .dsm-workbuddy-model-id{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4392
4610
  .dsm-workbuddy-model-desc{margin:0;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
@@ -6001,6 +6219,15 @@ window.__ModuleLoader__.load({
6001
6219
  };
6002
6220
  const draft = drafts[activeRegion];
6003
6221
  const visibleModels = draft?.models ?? (status.status === "signed-in" ? status.models : []);
6222
+ /**
6223
+ * The LIVE load levels, keyed by model id.
6224
+ *
6225
+ * Read off the Host's answer rather than from the model list: the list is the
6226
+ * SAVED roster the user edits, and a saved roster has no load level in it
6227
+ * (load is a fact about this minute, not a setting — `src/busy.ts`). A model
6228
+ * the probe did not answer for simply has no entry, and draws nothing.
6229
+ */
6230
+ const busyLevels = status.status === "signed-in" ? status.busyLevels ?? {} : {};
6004
6231
  const savedEnabledIds = status.status === "signed-in" ? new Set(status.enabledModelIds) : /* @__PURE__ */ new Set();
6005
6232
  const activeEnabledIds = draft?.enabledIds ?? savedEnabledIds;
6006
6233
  const savedImageIds = status.status === "signed-in" ? new Set(status.imageModelIds) : /* @__PURE__ */ new Set();
@@ -6845,17 +7072,21 @@ window.__ModuleLoader__.load({
6845
7072
  }
6846
7073
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6847
7074
  className: "dsm-workbuddy-model-copy",
6848
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6849
- className: "dsm-workbuddy-model-name",
6850
- children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6851
- className: "dsm-workbuddy-model-name-rate",
6852
- children: [
6853
- "(",
6854
- model.creditMultiplier.toFixed(2),
6855
- "x)"
6856
- ]
6857
- })]
6858
- }), promoChip(model)]
7075
+ children: [
7076
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7077
+ className: "dsm-workbuddy-model-name",
7078
+ children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7079
+ className: "dsm-workbuddy-model-name-rate",
7080
+ children: [
7081
+ "(",
7082
+ model.creditMultiplier.toFixed(2),
7083
+ "x)"
7084
+ ]
7085
+ })]
7086
+ }),
7087
+ promoChip(model),
7088
+ busyIcon(busyLevels[model.id], uiLanguage())
7089
+ ]
6859
7090
  })]
6860
7091
  }),
6861
7092
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -8040,6 +8271,110 @@ window.__ModuleLoader__.load({
8040
8271
  });
8041
8272
  }
8042
8273
  //#endregion
8274
+ //#region src/client/WorkBuddyModelLabel.tsx
8275
+ /**
8276
+ * The load-level badge in the HOST's model picker.
8277
+ *
8278
+ * WHY A SLOT. The host renders the model name as a plain string, so the only
8279
+ * thing a provider can put there is a character — and a character's colour is
8280
+ * the emoji font's business, not ours. The host therefore offers exactly one
8281
+ * seam for this: the `conversation.input.model.label` slot, whose contract is
8282
+ * "render non-interactive inline content such as a coloured name or badge"
8283
+ * while the picker keeps owning clicks, focus, check marks and the rest. The
8284
+ * shipped example is the official DeepSeek "1017" release badge — a coloured
8285
+ * marker next to a model name — which is the same job.
8286
+ *
8287
+ * WHY A SEPARATE FETCH. The slot is rendered by the host, outside the plugin's
8288
+ * card, so it cannot read the card's React state. It therefore asks the Host
8289
+ * for the load levels itself. The levels are a fact about this minute, so the
8290
+ * answer is cached briefly: a picker that re-fetches on every re-render would
8291
+ * be wasteful, and a level that is a few seconds old is still true.
8292
+ *
8293
+ * WHY THE HOST STILL OWNS THE NAME. The contract says to keep `model.name` as
8294
+ * the text and render the addition beside it, so this component returns the
8295
+ * name followed by the badge — it never replaces the name, and it renders
8296
+ * nothing at all for a provider that is not ours.
8297
+ */
8298
+ /** The provider ids this plugin registers, and the region each one serves. */
8299
+ const PROVIDER_REGIONS = /* @__PURE__ */ new Map([["workbuddy", "cn"], ["workbuddy-global", "global"]]);
8300
+ /**
8301
+ * The region a provider id serves, or `undefined` for a provider that is not
8302
+ * this plugin's.
8303
+ *
8304
+ * Exported because the mapping is the part that can be silently wrong: asking
8305
+ * the Host without the region answers for the DEFAULT region, which would put
8306
+ * one gateway's load on the other gateway's models.
8307
+ */
8308
+ function regionOfProvider(providerId) {
8309
+ return PROVIDER_REGIONS.get(providerId);
8310
+ }
8311
+ /** How long a fetched level set is trusted, in milliseconds. */
8312
+ const CACHE_MS = 6e4;
8313
+ /** The levels per region, by model id, plus when each set was read. */
8314
+ const caches = /* @__PURE__ */ new Map();
8315
+ /**
8316
+ * The levels, read from the Host once and reused briefly.
8317
+ *
8318
+ * `undefined` means "not known" — the caller renders nothing rather than
8319
+ * guessing, because a badge that claims a level the upstream never stated is
8320
+ * worse than no badge.
8321
+ */
8322
+ async function levelsNow(region) {
8323
+ const cached = caches.get(region);
8324
+ if (cached !== void 0 && Date.now() - cached.at < CACHE_MS) return cached.levels;
8325
+ try {
8326
+ const response = await fetch(withWorkBuddyRegion(WORKBUDDY_USAGE_PATH, region), {
8327
+ headers: { accept: "application/json" },
8328
+ credentials: "same-origin"
8329
+ });
8330
+ if (!response.ok) return void 0;
8331
+ const levels = (await response.json()).busyLevels;
8332
+ if (levels === void 0 || levels === null) return void 0;
8333
+ caches.set(region, {
8334
+ levels,
8335
+ at: Date.now()
8336
+ });
8337
+ return levels;
8338
+ } catch {
8339
+ return;
8340
+ }
8341
+ }
8342
+ /**
8343
+ * The label for one model row in the host's picker.
8344
+ *
8345
+ * @param props - The host's model identity and the placement of this label.
8346
+ * @returns The catalog name, followed by the load badge when there is one.
8347
+ */
8348
+ function WorkBuddyModelLabel(props) {
8349
+ const { provider, model } = props;
8350
+ const region = regionOfProvider(provider.id);
8351
+ if (region === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name });
8352
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WorkBuddyModelLabelInner, {
8353
+ modelId: model.id,
8354
+ name: model.name,
8355
+ region
8356
+ });
8357
+ }
8358
+ /** The stateful half: fetches the levels, then renders the badge. */
8359
+ function WorkBuddyModelLabelInner(props) {
8360
+ const { modelId, name, region } = props;
8361
+ const [level, setLevel] = (0, react.useState)(void 0);
8362
+ (0, react.useEffect)(() => {
8363
+ let live = true;
8364
+ levelsNow(region).then((levels) => {
8365
+ if (!live || levels === void 0) return;
8366
+ setLevel(levels[modelId]);
8367
+ });
8368
+ return () => {
8369
+ live = false;
8370
+ };
8371
+ }, [modelId, region]);
8372
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8373
+ className: "dsm-workbuddy-model-label",
8374
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }), level === void 0 ? null : busyIcon(level, "zh")]
8375
+ });
8376
+ }
8377
+ //#endregion
8043
8378
  //#region src/client/WorkBuddyQuotaNotice.tsx
8044
8379
  /**
8045
8380
  * The supplement this plugin adds to a quota failure (issue #63).
@@ -8993,6 +9328,29 @@ window.__ModuleLoader__.load({
8993
9328
  }
8994
9329
  };
8995
9330
  /**
9331
+ * The load badge in the HOST's model picker.
9332
+ *
9333
+ * The picker renders the model name as a plain string, so a provider can
9334
+ * only put a CHARACTER there — and a character's colour is the emoji font's
9335
+ * business, not ours. The host therefore offers exactly one seam for this:
9336
+ * the `conversation.input.model.label` slot, whose contract is "render
9337
+ * non-interactive inline content such as a coloured name or badge" while
9338
+ * the picker keeps owning clicks, focus and check marks. The shipped
9339
+ * example is the official DeepSeek "1017" release badge, which is the same
9340
+ * job — a coloured marker beside a model name.
9341
+ *
9342
+ * Registered unconditionally: the component itself renders nothing for a
9343
+ * provider that is not ours, so there is nothing to gate on.
9344
+ */
9345
+ const registerModelLabel = () => {
9346
+ try {
9347
+ return ctx.slots.inject("conversation.input.model.label", () => ctx.slots.register({ name: "conversation.input.model.label" }, WorkBuddyModelLabel));
9348
+ } catch (error) {
9349
+ console.error("[dsh-connect-workbuddy] model label slot failed to register (host provider unaffected):", error);
9350
+ return;
9351
+ }
9352
+ };
9353
+ /**
8996
9354
  * The SAME readout in the ambient dock below the composer card (issue #62).
8997
9355
  *
8998
9356
  * Registered unconditionally, like the composer seat, because the CHOICE
@@ -9096,7 +9454,8 @@ window.__ModuleLoader__.load({
9096
9454
  ...WORKBUDDY_CARD_KEYS.map((key) => registerCard("plugins.row.config", `${key}#${key}`)),
9097
9455
  registerComposerCredits(),
9098
9456
  registerStatsCredits(),
9099
- registerQuotaNotice()
9457
+ registerQuotaNotice(),
9458
+ registerModelLabel()
9100
9459
  ].filter((disposer) => disposer !== void 0);
9101
9460
  /**
9102
9461
  * Disposers of the slots registered for the CURRENT binding.