dsh-connect-workbuddy 4.9.1 → 4.9.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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.9.3 (2026-10-19)
4
+
5
+ > 版本号是 **patch**:新增一个**可选**的查询参数(`detail=fast`)和一条**只读**的本地取数路径;不带参数的老请求逐字节不变,发往上游的请求体、路由 id、模型 id、凭据与配置项一律未改。但**它修的是一个用户可见的等待**——打开设置页后要等半分钟到一分钟才出现账号列表。
6
+
7
+ ### Fixes
8
+
9
+ - **issue #77:打开设置页要等 30–60 秒才出现各个账号**
10
+ - **原来错在哪**:卡片的账号列表来自**一份**用量文档,而这份文档的**末尾**要等一串上游读取——账号池每个成员一次 `fetchCredits`、每个账号一次 `fetchCheckinStatus`、当前账号自己再一次 `fetchCredits` + `fetchCheckinStatus`。每次上游调用超时是 `JSON_TIMEOUT_MS = 30_000`(`src/upstream.ts`)且**没有重试**,于是慢一点的上游把等待叠成 30–60 秒。更糟的是这段时间里卡片渲染的是 `signed-out` 占位(说是「未登录」并给出「重新检测账号」按钮)——**报告者截图里看到的正是这个占位**,一个已经登录、池里有一堆账号的用户被告知未登录。
11
+ - **为什么 4.6.7 显得快**:那版还没有账号池的逐账号读数和签到读数(`poolMembersOf` 的逐成员积分读、`checkedInToday` 的逐账号签到读都是更早的提交引入的,但**卡片等整份文档**这个形状把它们的代价直接变成了首屏代价);不是 4.9.x 新增了扇出,而是 4.9.x 的功能让这份文档变得更长。所以修法不是微调超时,而是**把首屏和计费读解耦**。
12
+ - **现在的形状**:
13
+ - **服务端**:用量路由接受 `detail=fast`(`src/status-paths.ts` 的 `withWorkBuddyRegionFastDetail` / `fastDetailOf`),快速文档**只读本地状态**——凭据用 `store.current()`(不触发 token 刷新),实时数字(`credits` / `checkin` / `credentialRejected` / `recovery`)整块省略,账号池成员取自新增的 `localMembers`(就是请求路径本来就在用的 `localPoolMembers`,**零上游调用**),签到状态取自本进程已有的测量缓存 `cachedCheckedInToday`。**不带参数的行为一字未变**:未知或拼错的 `detail` 值一律退回完整文档,绝不会变成一份更空的卡片。
14
+ - **客户端**:先取 `detail=fast` 把账号表格画出来,再照旧取完整文档补上积分与签到。快速文档**永远不会覆盖**已经拿到完整文档的区域(它更穷,覆盖会把已显示的列抹成「—」)。在这之前(两份都还没回来)渲染 `row.accountsLoading`「正在加载账号…」,**不再假称未登录**。
15
+ - **为什么快速文档仍然带账号池块**:`AccountPool` 在没有 `pool` 块时返回 `null`,账号行就来自那里——首屏要「出现各个账号」,这个块就是必须的。它现在由本地花名册生成,不再等积分读数。
16
+ - **测试**:`tests/issue77-fast-first-paint.spec.ts` 在**路由层**断言快速文档**一次上游调用都不发**(积分/签到客户端被换成会抛错的桩,快速文档仍然成功),以及 `detail` 的解析只认精确的 `fast`、其余值全部退回完整文档,并保留完整文档行为不变的用例。`tests/issue77-card-first-paint.spec.tsx` 在**渲染层**用「完整文档永不返回」的桩挂载真实卡片,断言账号行**照样出现**、且不出现「未登录」文案;同时断言全部未返回时显示加载行,以及迟到的快速文档不会覆盖已加载的完整文档。删掉快速路径或让卡片等完整文档,这两组都会失败——已实测确认判别力。
17
+ - **刻意没做的**:没有给上游调用加重试或缩短超时(那只是把 30 秒变成另一种 30 秒);没有把 `poolMembersOf` 从完整文档里拿掉(完整文档仍然需要真实积分);没有让快速文档在宿主不支持时触发那次昂贵读取(缺 `localMembers` 就省略账号池块,宁可少画一行也不拖慢首屏)。
18
+
19
+ ## 4.9.2 (2026-10-19)
20
+
21
+ > 版本号是 **patch**:只改 `profiles` 里多登记一个注册表键,外加文档与注释;发往上游的请求体、路由 id、模型 id、模型选择器、目录与凭据一律未变。但**它修的是一个整区不可用的故障**——4.9.0/4.9.1 期间国际版(`workbuddy-global`)的每一个请求都在发出前失败。
22
+
23
+ ### Fixes
24
+
25
+ - **issue #75:国际版整区报 `Unknown provider: workbuddy`,任何请求都不可能成功**
26
+ - **原来错在哪**:4.9.0 为修 issue #59 把两条路由的模型身份统一成 `workbuddy`(`WORKBUDDY_MODEL_IDENTITY`),理由是「只有让 `isSameModel` 判等,跨区的 thinking 才不会降级成正文」。当时的论证是:pi-ai 的 `getModel(provider, id)` 取 `providers.get(provider).getModels()`(`models.js:116–118`),**查表的键**与**模型上盖的 `provider` 字段**是分开的,所以盖上 `workbuddy` 无害。**这条论证只对按参数查表的那条路径成立,漏掉了真正跑聊天的那条**:`models.streamSimple(model, …)` 的第一帧就是 `requireProvider(model)` = `this.providers.get(model.provider)`(`models.js:351–355`),查的正是模型上盖的那个字段。而每条路由的注册表里只登记过它自己的路由 id(宿主 `dsh-llm-pi-ai` 对每个 profile 调一次 `models.setProvider(profile.piProvider)`,键取 **provider 对象的 `id`**)。于是国内版(路由 `workbuddy`、盖 `workbuddy`)自洽,国际版(路由 `workbuddy-global`、盖 `workbuddy`)在一个只有 `workbuddy-global` 的注册表里查 `workbuddy` —— 抛 `ModelsError("provider", "Unknown provider: workbuddy")`,**在请求发出之前**。
27
+ - **为什么测试没抓住**:#59 配的守卫测试断言的是 `getModel(provider, id)` 这种**按参数**的查表,恰好是不看盖上字段的那一种形状;于是它可以全绿,而聊天路径已经整区死掉。报错文案里也不含 "WorkBuddy" 字样,用户看到的是宿主状态一个无关的 provider 名。
28
+ - **现在的形状**:新增 `modelIdentityKeysOf(providerId, identity)`(`src/adapter.ts`),返回一条路由要在自己的注册表里登记的全部键。每条路由把**同一个** shim 的 provider **按两个 id 各建一个对象**登记:路由 id(`workbuddy-global`,含它自己的 shim 地址、目录、凭据)+共享身份(`workbuddy`)。国内版路由 id 与身份相同,去重后仍只登记一个。这段构造抽成 `buildProfiles()`,初始发布与 `invalidate()`(目录刷新后重建)**共用同一份**——旧代码在 `invalidate()` 里按单键重建,刷新一次就会把国际版重新打回故障。
29
+ - **一个必须说破的细节**:`Models.setProvider(provider)` 存的是 `this.providers.set(provider.id, provider)`(`models.js:35–38`),键取的是**provider 对象的 `id`**,不是 `profiles` 映射的键。因此「把同一个 provider 对象发布两次」是**静默无效**的,必须给身份那个键一个 `id` 改为身份的 provider 对象。`ResolvedPiAiProviderProfile.provider` 上游注释称自己就是「`Models` 集合的键」,只有 `piProvider.id === profile.provider` 时才成立——按对象拆开后这个不变式回来了。
30
+ - **顺带覆盖的第二处失败点**:`Models.getAuth(providerOrModel)` 同样用 `providers.get(providerOrModel.provider)` 取 provider(`models.js:275–280`,宿主侧 `:334–341`),修之前国际版即使过了 `requireProvider` 也会以 `Provider is not configured` 倒在取凭据一步;同一个键一并覆盖。
31
+ - **刻意没做的**:报告者提议的一行改法(`modelIdentity = options.modelIdentity ?? providerId`)**不能采纳**——它把身份拆回按路由区分,等于回退 #59(跨区 thinking 又整批降级成正文、计费跳变重现)。也没必要把身份做成「第二条 provider 条目」:`Models.providers` 本来就是 `Map`,多一个键不影响路由集合,模型选择器、目录、凭据、`registerAdapter` 的 routes 数组全都没有变化。上游的真修法仍是让 `isSameModel` 认「provider 等价组」声明(`earendil-works/pi#6167`)。
32
+ - **测试**:新增 `tests/issue75-global-provider-dispatch.spec.ts`,不用 `profiles()` 的字面量断言,而是**照宿主的构造方式**建注册表(`createModels()` + 每个 profile 一次 `setProvider`)再走 `streamSimple`。其中包含:国际版真实服务的模型能在国际版注册表里被 `requireProvider` 解析且拿到**国际版自己的** `baseUrl`(不是国内版的);国内版不被连累;**一个反证用例**手工重建 4.9.1 的单键注册表,断言它仍然失败于 `Unknown provider: workbuddy`,防止上面几条变成空转通过;`invalidate()` 之后上述性质仍在;以及 #59 的两条性质(两边盖同一个身份、跨区 thinking 仍是 `thinking`)不回退。把 `buildProfiles` 临时改回单键重跑,这组测试会以 `ModelsError: Unknown provider: workbuddy` 失败——已实测确认判别力。
33
+ - **文档**:`README.md` 的 issue #59 那一段原文把上述错误论证当成了依据,已改正并写明 #75 的成因与 4.9.2 的做法;`docs/audit/ISSUE59-OPTIONS.md` 的「O1′ SHIPPED」一节补了「#75 CORRECTION」,说明当时为什么论证错了、以及为什么「按你推理的那条查表路径写的测试,无法证伪从同一条路径得出的结论」。
34
+
3
35
  ## 4.9.1 (2026-10-19)
4
36
 
5
37
  > 版本号是 **patch**:只改账号池里两枚「当前使用」徽章的配色,外加一处 CHANGELOG 排序修正;不改请求、响应、计费、排序规则或任何配置项。发往上游的请求体与 4.9.0 逐字节相同。
package/README.md CHANGED
@@ -369,7 +369,7 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
369
369
  - **切换区域会让上下文「变大」,甚至一换区就 `code 11115` 超限——不是网关多算你的钱**:同一份历史在两个区域的计费口径不同,根子在宿主内置的 `@earendil-works/pi-ai` 重放历史的方式。它给每条 assistant 消息算一次 `isSameModel`(`transform-messages.js:68`,判据是 provider **且** api **且** model id 三者全等):不等的消息,其 thinking 会被**降级成普通正文**(`:88–96`,并不是丢弃),而正文是网关会数的,thinking 走的是另一个字段(字段名两边都是 `reasoning_content`)。**这一点已由 issue #59 报告者的同区增量实测证实**:`reasoning` 不计费,且同一侧自己的工具结果块按本插件实测的 455.5 tok/1k 就吃掉了它全部计费量的 97.7%。本插件把国内版与国际版注册成**两条 provider 路由**(`workbuddy` / `workbuddy-global`,见[工作原理](#工作原理)),而它们是同一个 model id——于是**换一次区,另一侧产生的全部 thinking 就整批从 thinking 桶挪进正文桶**。这就是 issue #59 里「同一份对话历史,国际网关算 1.374M、国内网关算 708K(1.94×)」的成因;反方向切回来则是同一公式的 0.696×。
370
370
  - 由此带来三个用户可见的后果:换区后 token 计数可能跳 ±30%~94%,**跳多少只取决于另一侧 thinking 的字符数,与历史总长无关**;在**同一个区域内换模型**也会触发同样的降级(`isSameModel` 的 model 那条腿),所以不换区也可能看到计数变化;跳变叠加在宿主当前的压缩阈值(`min(0.8×窗口, 窗口−128000−65536)`)上,就会让本来不超限的会话一换区立刻撞上 `code 11115 prompt is too long`。
371
371
  - **这是宿主与 pi-ai 之间的一处错配,不是本插件的 bug**:不跨 provider/模型重放 thinking 这件事本身是对的(别家模型的思考过程对当前模型没有上下文价值),错在把 `provider` 当作「是不是同一个模型」的必要条件,而这里两个 provider 字符串指的是**同一个模型的两个接入区域**。
372
- - **本插件已在 4.9.0 起从插件侧绕开它**:两条路由注册的 **model 身份统一为 `workbuddy`**(`src/adapter.ts` 的 `WORKBUDDY_MODEL_IDENTITY`),也就是模型自己声明的 `provider` 字段两边相同,于是 `isSameModel` 成立、换区不再把 thinking 降级成正文。**路由 id 没有合并**——它仍是 `workbuddy` / `workbuddy-global`,各自指向自己的 shim、目录与凭据,所以一侧故障不会带走另一侧,模型选择器也仍是两组。依据是 pi-ai 自己的查表形状:`getModel(provider, id)` 取的是 `providers.get(provider).getModels()`(`models.js:116–118`),**查表用的键**与**模型上盖的 provider 字段**是分开的两件事。
372
+ - **本插件已在 4.9.0 起从插件侧绕开它**:两条路由注册的 **model 身份统一为 `workbuddy`**(`src/adapter.ts` 的 `WORKBUDDY_MODEL_IDENTITY`),也就是模型自己声明的 `provider` 字段两边相同,于是 `isSameModel` 成立、换区不再把 thinking 降级成正文。**路由 id 没有合并**——它仍是 `workbuddy` / `workbuddy-global`,各自指向自己的 shim、目录与凭据,所以一侧故障不会带走另一侧,模型选择器也仍是两组。**4.9.0/4.9.1 的这版修复漏了一半,国际版整区不可用(issue #75)**:上面的说法只对 `getModel(provider, id)` 这一条按**参数**查表的路径成立;真正跑聊天的 `models.streamSimple(model, …)` 第一步是 `requireProvider(model)` = `providers.get(model.provider)`(`models.js:351–355`),查的是**模型上盖的那个字段**。于是国内版(路由 `workbuddy`、盖 `workbuddy`)自洽,国际版(路由 `workbuddy-global`、盖 `workbuddy`)在只注册了 `workbuddy-global` 的注册表里查不到,**每一个请求都在发出前就抛 `Unknown provider: workbuddy`**——一次也会失败,且报错里不带任何 WorkBuddy 字样,看起来像宿主坏了。**4.9.2 起改为:每条路由把自己的注册表同时登记上「路由 id」和「共享身份」两个键**(`src/adapter.ts` 的 `modelIdentityKeysOf`),模型上盖的身份仍然两边相同(#59 不回退),而 `requireProvider` 在国际版自己的注册表里也能查到、并拿到**国际版自己的** provider 对象(含它自己的 shim 地址与凭据)。pi-ai 的注册表本来就是 `Map`(`models.js:37` 的 `providers.set(provider.id, provider)`,键取的是 **provider 对象的 `id`**,不是 profiles 映射的键),所以这是注册表原生支持的事;**路由集合、模型选择器、目录与凭据都没有变化**,多出来的那个键不出现在任何 UI 里。回归测试 `tests/issue75-global-provider-dispatch.spec.ts` 走的是宿主的真实构造(`createModels` + `setProvider` + `streamSimple`),并保留一个反证用例固定住旧的失败文案。详见 `docs/audit/ISSUE59-OPTIONS.md` 的「#75 CORRECTION」一节。
373
373
  - 升级后仍可能看到计数变化的部分:**在同一区域内换模型**仍会降级(`model` 那条腿是真的换了模型,thinking 签名未必对新模型有效,保留才是错的);**升级之前就录在历史里的消息**带着旧的按区域 id,重放时仍按旧规则降级——这种历史只会越聊越少,不会反过来影响新录的消息。
374
374
  - 真正的修法仍在上游(让 `isSameModel` 认「provider 等价组」声明,而不是让插件去伪装自己的身份);插件侧的兜底是 4.8.0 起的 `src/context-rescue.ts`——**一旦上游确认超限,就按块折叠历史后重发**,而不是把宿主要压缩的整段历史原样重放一遍(原样重放正是「压缩请求自己超限 → 压缩死锁」的成因)。想亲眼看到这件事发生在哪条消息上:设 `WORKBUDDY_BODY_REPORT` 抓一次 body 组成报告,它只记计数、不记正文。
375
375
 
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-DahwuDGO.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-BHaBzzOr.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
@@ -123,6 +123,33 @@ window.__ModuleLoader__.load({
123
123
  return `${path}?${WORKBUDDY_REGION_PARAM}=${region}`;
124
124
  }
125
125
  /**
126
+ * Query parameter selecting how much of a status document the Host assembles.
127
+ *
128
+ * The card's first paint only needs what local state already knows; live
129
+ * figures (credit, check-in, per-account readings) arrive through the SAME
130
+ * route once they have been measured. See {@link WORKBUDDY_DETAIL_FAST}.
131
+ */
132
+ const WORKBUDDY_DETAIL_PARAM = "detail";
133
+ /**
134
+ * The `detail` value asking for the first-paint document.
135
+ *
136
+ * Absent means the full document, which is what every earlier caller asks for
137
+ * and what the route keeps answering — the parameter only ever ADDS a cheap
138
+ * mode, it never changes an existing one.
139
+ */
140
+ const WORKBUDDY_DETAIL_FAST = "fast";
141
+ /**
142
+ * Address one region's status route for its first-paint document.
143
+ *
144
+ * The Host answers this from local state only (no upstream read, no token
145
+ * refresh), so a slow billing endpoint cannot decide when the account list
146
+ * appears. The card paints it first and then upgrades the same snapshot from
147
+ * the full document.
148
+ */
149
+ function withWorkBuddyRegionFastDetail(path, region) {
150
+ return `${withWorkBuddyRegion(path, region)}&${WORKBUDDY_DETAIL_PARAM}=${WORKBUDDY_DETAIL_FAST}`;
151
+ }
152
+ /**
126
153
  * Narrow a settings value to the `regions` map. Accepts EITHER the whole
127
154
  * settings section (the Host's resolved `Config`) OR the `regions` map itself,
128
155
  * and unwraps the former. This tolerance is deliberate: passing the whole
@@ -4612,23 +4639,16 @@ window.__ModuleLoader__.load({
4612
4639
  /**
4613
4640
  * Last-known usage per region, so tab dots survive tab switches.
4614
4641
  *
4615
- * The placeholder carries `selectionExplicit: false` because that is the
4616
- * state the plugin documents as the default before any choice is saved. It
4617
- * is never rendered: the account picker and its state line only appear once
4618
- * a real fetch has populated `accounts`.
4642
+ * EMPTY until a real document lands (issue #77). It used to be seeded with a
4643
+ * `signed-out` placeholder, which was then rendered: for as long as the usage
4644
+ * route took to answer — and that wait is exactly what #77 reports — a user
4645
+ * with several signed-in accounts was told they were not signed in, with a
4646
+ * "detect accounts" button as the only way out. An absent region now renders
4647
+ * the loading line instead, and the first paint itself arrives from the
4648
+ * `detail=fast` document (local state only, no upstream call), so the rows
4649
+ * appear before anything billing-side has answered.
4619
4650
  */
4620
- const [statusByRegion, setStatusByRegion] = (0, react.useState)({
4621
- cn: {
4622
- status: "signed-out",
4623
- accounts: [],
4624
- selectionExplicit: false
4625
- },
4626
- global: {
4627
- status: "signed-out",
4628
- accounts: [],
4629
- selectionExplicit: false
4630
- }
4631
- });
4651
+ const [statusByRegion, setStatusByRegion] = (0, react.useState)({});
4632
4652
  const [busy, setBusy] = (0, react.useState)(false);
4633
4653
  const [settingsRevision, setSettingsRevision] = (0, react.useState)(0);
4634
4654
  /** Per-region unsaved model edits; a draft on one tab is never dropped by
@@ -4763,19 +4783,86 @@ window.__ModuleLoader__.load({
4763
4783
  return;
4764
4784
  }
4765
4785
  }, [t]);
4786
+ /**
4787
+ * FIRST PAINT (issue #77): the same usage document, built from LOCAL state
4788
+ * only — no billing request, no per-account check-in read.
4789
+ *
4790
+ * Why this exists. The card used to paint nothing until ONE monolithic
4791
+ * document landed, and that document's tail awaited an upstream call per
4792
+ * account (credits for every pool member, check-in for every account, plus
4793
+ * the selected account's own two). Each of those has a 30-second timeout and
4794
+ * no retry, so a slow or throttled upstream stacked the waits and the panel
4795
+ * stayed empty for the reported 30–60 seconds — showing a "not signed in"
4796
+ * placeholder the whole time, because that is what an absent document
4797
+ * rendered as.
4798
+ *
4799
+ * The Host answers `detail=fast` from the credential store, the local pool
4800
+ * roster and the measurement caches, so this is one local round trip. It
4801
+ * carries the whole account table (the row list lives in the `pool` block),
4802
+ * which is the part the user came for; credits, check-in and the selected
4803
+ * account's live figures arrive with the full document that follows.
4804
+ *
4805
+ * Deliberately a SEPARATE callback instead of a `detail` argument on
4806
+ * `refreshUsage`: the ordering guard inside that callback is pinned by a
4807
+ * source test that counts its writes and freshness checks, and a second
4808
+ * write path in there would either break the pin or hide a real regression
4809
+ * behind it. Here the rules are simple and independent — this response never
4810
+ * downgrades a region that has already answered in full.
4811
+ */
4812
+ const refreshUsageFast = (0, react.useCallback)(async (region, signal) => {
4813
+ const stale = usageGuard.current.begin(region);
4814
+ try {
4815
+ const response = await fetch(withWorkBuddyRegionFastDetail(WORKBUDDY_USAGE_PATH, region), {
4816
+ headers: { accept: "application/json" },
4817
+ credentials: "same-origin",
4818
+ ...signal === void 0 ? {} : { signal }
4819
+ });
4820
+ if (!response.ok) return;
4821
+ const usage = await response.json().catch(() => void 0);
4822
+ if (usage === void 0) return;
4823
+ if (stale()) return;
4824
+ if (!mounted.current || signal?.aborted === true) return;
4825
+ setStatusByRegion((prev) => {
4826
+ const previous = prev[region];
4827
+ if (previous !== void 0 && previous.status === "signed-in") return prev;
4828
+ return {
4829
+ ...prev,
4830
+ [region]: usage
4831
+ };
4832
+ });
4833
+ } catch {}
4834
+ }, []);
4766
4835
  (0, react.useEffect)(() => {
4767
4836
  if (!open) return;
4768
4837
  const controller = new AbortController();
4769
- for (const region of WORKBUDDY_REGIONS) refreshUsage(region, controller.signal);
4838
+ (async () => {
4839
+ await Promise.all(WORKBUDDY_REGIONS.map(async (region) => {
4840
+ await refreshUsageFast(region, controller.signal);
4841
+ }));
4842
+ if (controller.signal.aborted) return;
4843
+ for (const region of WORKBUDDY_REGIONS) refreshUsage(region, controller.signal);
4844
+ })();
4770
4845
  return () => {
4771
4846
  controller.abort();
4772
4847
  };
4773
4848
  }, [
4774
4849
  open,
4775
4850
  activeRegion,
4776
- refreshUsage
4851
+ refreshUsage,
4852
+ refreshUsageFast
4777
4853
  ]);
4778
- const status = statusByRegion[activeRegion] ?? {
4854
+ /**
4855
+ * The document this tab is rendering, or `undefined` before ANY has landed.
4856
+ *
4857
+ * The distinction is the point of issue #77: an absent document used to be
4858
+ * rendered as the `signed-out` placeholder below, so for the whole time the
4859
+ * usage route was out the card told a signed-in user they were not signed in
4860
+ * and offered "detect accounts" as the only remedy. `loading` now gates that
4861
+ * branch, and the placeholder is only a safe shape for the reads below it.
4862
+ */
4863
+ const loaded = statusByRegion[activeRegion];
4864
+ const loading = loaded === void 0;
4865
+ const status = loaded ?? {
4779
4866
  status: "signed-out",
4780
4867
  accounts: [],
4781
4868
  selectionExplicit: false
@@ -5795,7 +5882,12 @@ window.__ModuleLoader__.load({
5795
5882
  })
5796
5883
  })
5797
5884
  ] }) : null,
5798
- status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
5885
+ status.status === "signed-out" && loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5886
+ className: "dsm-workbuddy-usage-text",
5887
+ role: "status",
5888
+ children: t("row.accountsLoading")
5889
+ }) : null,
5890
+ status.status === "signed-out" && !loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
5799
5891
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5800
5892
  className: "dsm-workbuddy-usage-text",
5801
5893
  children: signedOutText(notice, t)
@@ -6854,6 +6946,7 @@ window.__ModuleLoader__.load({
6854
6946
  "row.accountsHint": "Choose from locally detected sign-ins. Tokens are never shown or saved in DSH settings.",
6855
6947
  "row.accountsRescan": "Detect accounts again",
6856
6948
  "row.accountsScanning": "Detecting…",
6949
+ "row.accountsLoading": "Loading your accounts…",
6857
6950
  "row.accountsWriteFailed": "The account change was not saved: {message} — the active profile's configuration file could not be updated (on Windows it may be locked by an antivirus scanner or a sync client). Your previous choice is still in effect; close whatever holds that file and try again (see docs/WINDOWS.md in the plugin repo for the exact path).",
6858
6951
  "row.selectionLostHint": "The saved account no longer exists locally.",
6859
6952
  "row.selectionLostMessage": "The account saved for this region is no longer among the local sign-ins (WorkBuddy replaced its login or cleaned up backups). Your other sign-ins are fine — pick a current one from the list above. Signing in again in the desktop app will not fix this.",
@@ -7166,6 +7259,7 @@ window.__ModuleLoader__.load({
7166
7259
  "row.accountsHint": "选择本机检测到的登录账号;Token 不会显示,也不会保存到 DSH 设置。",
7167
7260
  "row.accountsRescan": "重新检测账号",
7168
7261
  "row.accountsScanning": "正在检测…",
7262
+ "row.accountsLoading": "正在加载账号…",
7169
7263
  "row.accountsWriteFailed": "账号改动未保存:{message} —— 当前 profile 的配置文件写入失败(Windows 上可能被杀毒软件或同步盘占用)。当前仍是原来保存的选择,请先关闭占用该文件的程序再重试(文件的确切路径见插件仓库的 docs/WINDOWS.md)。",
7170
7264
  "row.selectionLostHint": "已保存的账号在本机已不存在。",
7171
7265
  "row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方列表中重新选择一个当前账号。重新登录桌面端 App 无法修复此问题。",
@@ -3966,7 +3966,7 @@ function generationOf(version) {
3966
3966
  //#endregion
3967
3967
  //#region src/version.ts
3968
3968
  /** The npm package version this build was produced from. */
3969
- const WORKBUDDY_CONNECT_VERSION = "4.9.1";
3969
+ const WORKBUDDY_CONNECT_VERSION = "4.9.3";
3970
3970
  //#endregion
3971
3971
  //#region src/host-heartbeat.ts
3972
3972
  /**
package/lib/index.d.ts CHANGED
@@ -1284,6 +1284,10 @@ interface WorkBuddyAdapterOptions {
1284
1284
  * Model identity stamped on every model this route serves; defaults to
1285
1285
  * {@link WORKBUDDY_MODEL_IDENTITY}. Tests override it to model a transcript
1286
1286
  * recorded by a host that still stamped per-region ids.
1287
+ *
1288
+ * It also decides the extra registry key this adapter publishes alongside
1289
+ * its route (issue #75) — see {@link modelIdentityKeysOf}, which is what
1290
+ * keeps an overridden stamp resolvable exactly like the production one.
1287
1291
  */
1288
1292
  modelIdentity?: string;
1289
1293
  /** Resolve the durable attachment service at request time, when present. */
@@ -3075,6 +3079,23 @@ interface WorkBuddyPoolDeps {
3075
3079
  };
3076
3080
  /** One region's CHECKED accounts, plus their credits and last measurements. */
3077
3081
  members(region: WorkBuddyRegion): Promise<readonly WorkBuddyPoolMember[]>;
3082
+ /**
3083
+ * One region's CHECKED accounts from LOCAL state only — the first-paint
3084
+ * source (issue #77).
3085
+ *
3086
+ * Cheap by contract in the strongest sense: no upstream read at all. It
3087
+ * returns the saved members with whatever credit reading a previous full
3088
+ * document already recorded (a snapshot, possibly minutes old, possibly
3089
+ * absent), so the card can paint the account table before any billing
3090
+ * endpoint answers. `credits` being absent is normal here and renders as "—",
3091
+ * which is why {@link WorkBuddyWebPoolAccount} makes every figure optional.
3092
+ *
3093
+ * A Host built without it degrades to omitting the pool block from a
3094
+ * first-paint document (rather than paying {@link members} on that path):
3095
+ * the card then paints its account list from the full document, exactly as
3096
+ * before this contract existed.
3097
+ */
3098
+ localMembers?(region: WorkBuddyRegion): Promise<readonly WorkBuddyPoolMember[]>;
3078
3099
  /**
3079
3100
  * The checked accounts that actually RESOLVE to a local sign-in.
3080
3101
  *
@@ -3122,6 +3143,16 @@ interface WorkBuddyPoolDeps {
3122
3143
  * Only the CN region has a check-in to report; see {@link checkinSupported}.
3123
3144
  */
3124
3145
  checkedInToday?(region: WorkBuddyRegion): Promise<Readonly<Record<string, boolean>>>;
3146
+ /**
3147
+ * Today's check-in state, from the cells already measured this process.
3148
+ *
3149
+ * The first-paint counterpart of {@link checkedInToday}: reads the Host's
3150
+ * measurement cache and nothing else, so a first paint never waits on one
3151
+ * upstream request per account. An account it has not measured is simply
3152
+ * absent, which the card renders as unknown — the same meaning as a failed
3153
+ * per-account read in the full document.
3154
+ */
3155
+ cachedCheckedInToday?(region: WorkBuddyRegion): Readonly<Record<string, boolean>>;
3125
3156
  /**
3126
3157
  * Every local sign-in the pool does NOT cover, so the card can offer them to
3127
3158
  * check in. Absent means the card lists only pool members.
@@ -3168,14 +3199,36 @@ interface WorkBuddyPoolDeps {
3168
3199
  /** The region's live catalog, used to resolve the free target model. */
3169
3200
  catalog?(region: WorkBuddyRegion): readonly WorkBuddyModelInfo[];
3170
3201
  }
3202
+ /**
3203
+ * How much live measurement a status document carries (issue #77).
3204
+ *
3205
+ * `full` is the original answer: the selected account's credit and check-in
3206
+ * plus every pool account's credit and check-in, all read from upstream now.
3207
+ * It is what the card's 60-second poll, the composer panel and every
3208
+ * measurement-driven control expect.
3209
+ *
3210
+ * `fast` is the FIRST PAINT. It answers from local state only — no token
3211
+ * refresh, no billing endpoint, no check-in endpoint — so the account list can
3212
+ * render in the first round trip and cannot be held up by a slow or
3213
+ * rate-limited upstream. Live figures are absent from it by construction; the
3214
+ * card then asks for the full document and upgrades the same snapshot.
3215
+ */
3216
+ type WorkBuddyStatusDetail = 'full' | 'fast';
3171
3217
  /**
3172
3218
  * Assemble one region's card document. `region` is the tab the card is on;
3173
3219
  * the region-scoped store already answers with only that region's accounts,
3174
3220
  * so the document's model slots and account list are that region's by
3175
3221
  * construction. Sign-in state is read-only; credit is a live billing answer
3176
3222
  * whose failure degrades to `creditsError` rather than failing the document.
3223
+ *
3224
+ * `options.detail` selects the first-paint variant: it uses the credential
3225
+ * already known locally instead of resolving (resolving may refresh a token
3226
+ * over the network) and skips {@link liveFigures} entirely, so nothing about
3227
+ * the account list depends on how fast a billing endpoint answers.
3177
3228
  */
3178
- declare function workBuddyWebStatus(deps: WorkBuddyStatusRouteOptions, region: WorkBuddyRegion): Promise<WorkBuddyWebUsage>;
3229
+ declare function workBuddyWebStatus(deps: WorkBuddyStatusRouteOptions, region: WorkBuddyRegion, options?: {
3230
+ detail?: WorkBuddyStatusDetail;
3231
+ }): Promise<WorkBuddyWebUsage>;
3179
3232
  /**
3180
3233
  * Mount the read-only routes on a context where `webServer` is available.
3181
3234
  * The caller uses `ctx.inject(['webServer'], ...)`, so Desktop startup order
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-DahwuDGO.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-BHaBzzOr.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -119,25 +119,60 @@ const WORKBUDDY_PROVIDERS = {
119
119
  * byte-identical history, and a compression request that could then never fit
120
120
  * the window it was computed for.
121
121
  *
122
- * `provider` is also the KEY pi-ai dispatches on (`models.js:410–416`
123
- * `requireProvider`), so the identity cannot simply be the shared string: the
124
- * registry has to keep resolving per region. Both halves of that are already
125
- * satisfied by pi-ai's own lookup shape — `getModel(provider, id)` is
126
- * `getModels(provider).find(...)` (`models.js:116–118`), i.e. the KEY is the
127
- * route id while the STAMPED field is whatever the provider put there — so the
128
- * route stays `workbuddy`/`workbuddy-global` (each with its own shim, catalog
129
- * and credential store) while every model it serves advertises THIS id. This
130
- * plugin's provider registers no `fetchModels`, so nothing downstream derives
131
- * anything from the stamped field; the openai-completions api uses
132
- * `model.provider` only for provider-specific quirks (none of which match
133
- * these ids) and as an error-message label.
134
- *
135
- * Deliberately NOT the same string as either route: a message is only
122
+ * The stamp is NOT inert, and reading it as inert is what broke issue #75.
123
+ * `provider` is the KEY pi-ai dispatches on: `requireProvider` resolves
124
+ * `this.providers.get(model.provider)` and throws
125
+ * `Unknown provider: ${model.provider}` when it is absent (`models.js:410–416`
126
+ * on the host's 1.1.0, `:351–357` on the 0.85.1 this repo resolves), and
127
+ * `getAuth` resolves the same key, answering `Provider is not configured`
128
+ * instead (`:334–341` and `:429`; `:275–280` and `:366` on 0.85.1). Both sit on
129
+ * the LIVE chat path: `dsh-llm-pi-ai` streams through
130
+ * `snapshot.models.streamSimple(model, …)` (`lib/index.js:1983`), which calls
131
+ * `requireProvider` before a byte is sent (`models.js:454–459`). Each region
132
+ * builds its OWN registry from its own adapter — `current()` registers
133
+ * `profile.piProvider` per profile (`lib/index.js:1852–1862`) — so the
134
+ * `workbuddy-global` region registered only `workbuddy-global` and could not
135
+ * dispatch a model stamped `workbuddy`: every request failed with
136
+ * `Unknown provider: workbuddy` before reaching the gateway.
137
+ * {@link modelIdentityKeysOf} is the correction: the identity has to be a
138
+ * registry key of its own in every region, or the stamp cannot be shared.
139
+ *
140
+ * Lookup by ROUTE is a separate question and stays intact: `getModel(provider,
141
+ * id)` is `getModels(provider).find(...)` (`models.js:116–118`), i.e. it goes
142
+ * through the map key, while the STAMPED field is whatever the provider put
143
+ * there. So the route stays `workbuddy`/`workbuddy-global` (each with its own
144
+ * shim, catalog and credential store) while every model it serves advertises
145
+ * THIS id. The openai-completions api reads `model.provider` only for
146
+ * provider-specific quirks (none of which match these ids) and as an
147
+ * error-message label.
148
+ *
149
+ * Deliberately NOT the same string as the global route: a message is only
136
150
  * same-model when provider, api AND model id all match, so the shared id must
137
151
  * not be reachable as a route a user could pick — it names one model, not one
138
- * gateway.
152
+ * gateway. It IS reachable as a pi-ai registry key, which is the point: those
153
+ * are internal and never surface as a route, because `registerAdapter` takes
154
+ * the route list explicitly (`dsh-llm/lib/index.js:1842–1861`,
155
+ * `prepareRoutes` `:1867–1885`) and this plugin passes exactly the two region
156
+ * ids (`src/index.ts:2069–2086`).
139
157
  */
140
158
  const WORKBUDDY_MODEL_IDENTITY = "workbuddy";
159
+ /**
160
+ * Registry keys an adapter serving `providerId` must publish so that every
161
+ * stamp its models carry resolves inside its own region (issue #75).
162
+ *
163
+ * The route id first, then the model identity, de-duplicated — route-first so
164
+ * `profiles()` opens with the route the adapter actually serves. CN yields one
165
+ * key (`workbuddy` is both its route and the shared identity) and the global
166
+ * region yields two (`workbuddy-global`, `workbuddy`); the two regions
167
+ * therefore publish different key sets, and each must publish the shared one.
168
+ *
169
+ * `identity` is a parameter because {@link WorkBuddyAdapterOptions.modelIdentity}
170
+ * can override the stamp: the keys have to follow the stamp, or a test that
171
+ * stamps a per-region id would get the production keys back.
172
+ */
173
+ function modelIdentityKeysOf(providerId, identity = WORKBUDDY_MODEL_IDENTITY) {
174
+ return [...new Set([providerId, identity].filter((key) => key !== void 0))];
175
+ }
141
176
  /** Region a provider route id belongs to. */
142
177
  function regionOfProvider(provider) {
143
178
  for (const [region, id] of Object.entries(WORKBUDDY_PROVIDERS)) if (id === provider) return region;
@@ -250,7 +285,9 @@ function workBuddyThinkingLevelMap(info) {
250
285
  *
251
286
  * `identity` — not the route id — is what `provider` carries; see
252
287
  * {@link WORKBUDDY_MODEL_IDENTITY} for why the two must differ. The route the
253
- * model is REACHED by is the registry key the adapter serves, not this field.
288
+ * model is REACHED by is the registry key the adapter serves, while this
289
+ * stamped field is what pi-ai resolves the provider BY (issue #75) — so every
290
+ * key in {@link modelIdentityKeysOf} must be a key this route registers.
254
291
  */
255
292
  function toPiModel(info, baseUrl, identity = WORKBUDDY_MODEL_IDENTITY) {
256
293
  const thinkingLevelMap = workBuddyThinkingLevelMap(info);
@@ -589,17 +626,21 @@ function createWorkBuddyAdapter(options) {
589
626
  }),
590
627
  getModels: () => buildModels()
591
628
  };
592
- const profile = {
593
- provider: providerId,
629
+ const profileFor = (key) => ({
630
+ provider: key,
594
631
  displayName: providerName,
595
632
  streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
596
633
  retryPolicy: resolveRetryPolicy(void 0, "dsh-connect-workbuddy retryPolicy"),
597
634
  configuredMaxTokens: /* @__PURE__ */ new Map(),
598
635
  modelErrors: /* @__PURE__ */ new Map(),
599
636
  ...REQUEST_IMAGE_BUDGETS,
600
- piProvider: provider
601
- };
602
- let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
637
+ piProvider: key === providerId ? provider : {
638
+ ...provider,
639
+ id: key
640
+ }
641
+ });
642
+ const buildProfiles = () => new Map(modelIdentityKeysOf(providerId, modelIdentity).map((key) => [key, profileFor(key)]));
643
+ let profiles = buildProfiles();
603
644
  return {
604
645
  adapter: new PiAiAdapter({
605
646
  profiles: () => profiles,
@@ -608,7 +649,7 @@ function createWorkBuddyAdapter(options) {
608
649
  ...resolveAttachments === void 0 ? {} : { resolveAttachments }
609
650
  }),
610
651
  invalidate: () => {
611
- profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
652
+ profiles = buildProfiles();
612
653
  }
613
654
  };
614
655
  }
@@ -4788,6 +4829,34 @@ function withWorkBuddyRegion(path, region) {
4788
4829
  return `${path}?${WORKBUDDY_REGION_PARAM}=${region}`;
4789
4830
  }
4790
4831
  /**
4832
+ * Query parameter selecting how much of a status document the Host assembles.
4833
+ *
4834
+ * The card's first paint only needs what local state already knows; live
4835
+ * figures (credit, check-in, per-account readings) arrive through the SAME
4836
+ * route once they have been measured. See {@link WORKBUDDY_DETAIL_FAST}.
4837
+ */
4838
+ const WORKBUDDY_DETAIL_PARAM = "detail";
4839
+ /**
4840
+ * The `detail` value asking for the first-paint document.
4841
+ *
4842
+ * Absent means the full document, which is what every earlier caller asks for
4843
+ * and what the route keeps answering — the parameter only ever ADDS a cheap
4844
+ * mode, it never changes an existing one.
4845
+ */
4846
+ const WORKBUDDY_DETAIL_FAST = "fast";
4847
+ /**
4848
+ * Whether a status-route URL asks for the first-paint document.
4849
+ *
4850
+ * Only the exact `fast` value is recognized: an unknown or misspelled value
4851
+ * degrades to the full document, which is what every earlier caller already
4852
+ * gets. A typo must never produce an emptier panel than no parameter at all.
4853
+ */
4854
+ function fastDetailOf(url) {
4855
+ const at = url.indexOf("?");
4856
+ if (at === -1) return false;
4857
+ return new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_DETAIL_PARAM) === WORKBUDDY_DETAIL_FAST;
4858
+ }
4859
+ /**
4791
4860
  * Read the region parameter off a status-route URL. Absent means the domestic
4792
4861
  * tab (`cn`); a present-but-unknown value returns undefined so the route can
4793
4862
  * answer 400 instead of guessing.
@@ -5115,13 +5184,42 @@ async function workBuddyAccountCredits(deps, region) {
5115
5184
  }));
5116
5185
  }
5117
5186
  /**
5187
+ * Read the selected account's live figures, and classify a refusal.
5188
+ *
5189
+ * Extracted so the first-paint path can omit it as a unit: the two calls here
5190
+ * are the ones that used to gate the account list (each up to the upstream
5191
+ * client's 30-second ceiling, and the pool adds one pair per member on top).
5192
+ */
5193
+ async function liveFigures(deps, region, credential, selected) {
5194
+ const [creditsResult, checkinResult] = await Promise.allSettled([deps.client.fetchCredits(credential), deps.client.fetchCheckinStatus(credential)]);
5195
+ const rejected = [creditsResult, checkinResult].some((result) => result.status === "rejected" && isCredentialRejectedError(result.reason));
5196
+ const recovery = !rejected ? void 0 : await resolveCredentialRecovery({
5197
+ region,
5198
+ store: deps.store(region),
5199
+ ...selected === void 0 ? {} : { rejectedAccountId: selected.id },
5200
+ probe: deps.accountUsable ?? (async () => false)
5201
+ });
5202
+ return {
5203
+ ...creditsResult.status === "fulfilled" ? { credits: toCredits(creditsResult.value) } : { creditsError: safeMessage(creditsResult.reason) },
5204
+ ...checkinResult.status === "fulfilled" ? { checkin: checkinResult.value } : { checkinError: safeMessage(checkinResult.reason) },
5205
+ ...!rejected ? {} : { credentialRejected: true },
5206
+ ...recovery === void 0 ? {} : { recovery }
5207
+ };
5208
+ }
5209
+ /**
5118
5210
  * Assemble one region's card document. `region` is the tab the card is on;
5119
5211
  * the region-scoped store already answers with only that region's accounts,
5120
5212
  * so the document's model slots and account list are that region's by
5121
5213
  * construction. Sign-in state is read-only; credit is a live billing answer
5122
5214
  * whose failure degrades to `creditsError` rather than failing the document.
5215
+ *
5216
+ * `options.detail` selects the first-paint variant: it uses the credential
5217
+ * already known locally instead of resolving (resolving may refresh a token
5218
+ * over the network) and skips {@link liveFigures} entirely, so nothing about
5219
+ * the account list depends on how fast a billing endpoint answers.
5123
5220
  */
5124
- async function workBuddyWebStatus(deps, region) {
5221
+ async function workBuddyWebStatus(deps, region, options = {}) {
5222
+ const fast = options.detail === "fast";
5125
5223
  const store = deps.store(region);
5126
5224
  const accounts = await store.accounts();
5127
5225
  deps.regionUsable?.(region, accounts.length > 0);
@@ -5139,7 +5237,8 @@ async function workBuddyWebStatus(deps, region) {
5139
5237
  };
5140
5238
  let credential;
5141
5239
  try {
5142
- credential = await store.resolve();
5240
+ credential = fast ? await store.current() : await store.resolve();
5241
+ if (credential === void 0) credential = await store.resolve();
5143
5242
  } catch (error) {
5144
5243
  const webAccounts = accounts.map(toWebAccount);
5145
5244
  return {
@@ -5172,14 +5271,7 @@ async function workBuddyWebStatus(deps, region) {
5172
5271
  imageModelIds: [...deps.imageModelIds(region)],
5173
5272
  offModelIds: [...deps.offModelIds(region)]
5174
5273
  };
5175
- const [creditsResult, checkinResult] = await Promise.allSettled([deps.client.fetchCredits(credential), deps.client.fetchCheckinStatus(credential)]);
5176
- const rejected = [creditsResult, checkinResult].some((result) => result.status === "rejected" && isCredentialRejectedError(result.reason));
5177
- const recovery = !rejected ? void 0 : await resolveCredentialRecovery({
5178
- region,
5179
- store: deps.store(region),
5180
- ...selected === void 0 ? {} : { rejectedAccountId: selected.id },
5181
- probe: deps.accountUsable ?? (async () => false)
5182
- });
5274
+ const figures = fast ? {} : await liveFigures(deps, region, credential, selected);
5183
5275
  return {
5184
5276
  status: "signed-in",
5185
5277
  contextBudgets: Object.fromEntries(Object.entries(deps.contextBudgets(region)).filter(([, value]) => typeof value === "number")),
@@ -5189,18 +5281,22 @@ async function workBuddyWebStatus(deps, region) {
5189
5281
  authFile: volatileFlagOf("authFile")
5190
5282
  },
5191
5283
  ...account,
5192
- ...creditsResult.status === "fulfilled" ? { credits: toCredits(creditsResult.value) } : { creditsError: safeMessage(creditsResult.reason) },
5193
- ...checkinResult.status === "fulfilled" ? { checkin: checkinResult.value } : { checkinError: safeMessage(checkinResult.reason) },
5194
- ...!rejected ? {} : { credentialRejected: true },
5195
- ...recovery === void 0 ? {} : { recovery },
5196
- ...await poolSectionOf(deps, region)
5284
+ ...figures,
5285
+ ...await poolSectionOf(deps, region, fast)
5197
5286
  };
5198
5287
  }
5199
- /** The pool block for a status document, or nothing when it cannot be built. */
5200
- async function poolSectionOf(deps, region) {
5288
+ /**
5289
+ * The pool block for a status document, or nothing when it cannot be built.
5290
+ *
5291
+ * `fast` asks for the first-paint variant: local state only, no upstream call.
5292
+ * A Host without a local member source then omits the block rather than paying
5293
+ * the full read, so a first-paint document never regresses into the expensive
5294
+ * path this mode exists to avoid.
5295
+ */
5296
+ async function poolSectionOf(deps, region, fast) {
5201
5297
  if (deps.pool === void 0) return {};
5202
5298
  try {
5203
- const pool = await workBuddyWebPool(deps, region);
5299
+ const pool = await workBuddyWebPool(deps, region, fast);
5204
5300
  return pool === void 0 ? {} : { pool };
5205
5301
  } catch {
5206
5302
  return {};
@@ -5219,11 +5315,12 @@ async function poolSectionOf(deps, region) {
5219
5315
  * SHOULD serve, `current` says who IS serving. Marking the winner as current
5220
5316
  * would make the card claim a switch that may not have been applied yet.
5221
5317
  */
5222
- async function workBuddyWebPool(deps, region) {
5318
+ async function workBuddyWebPool(deps, region, fast = false) {
5223
5319
  const pool = deps.pool;
5224
5320
  if (pool === void 0) return void 0;
5225
5321
  const preferences = pool.preferences(region);
5226
- const members = await pool.members(region);
5322
+ const members = fast ? await pool.localMembers?.(region) : await pool.members(region);
5323
+ if (members === void 0) return void 0;
5227
5324
  const ranked = rankPool(members, Date.now(), preferences.orderAccountIds);
5228
5325
  const catalog = pool.catalog?.(region) ?? [];
5229
5326
  const target = resolveTargetModel(catalog, preferences.targetModelId);
@@ -5231,7 +5328,7 @@ async function workBuddyWebPool(deps, region) {
5231
5328
  const byId = new Map(members.map((member) => [member.account.id, member]));
5232
5329
  const rankedIds = new Set(ranked.map((row) => row.account.id));
5233
5330
  const others = await pool.otherAccounts?.(region) ?? [];
5234
- const checkedIn = await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
5331
+ const checkedIn = fast ? pool.cachedCheckedInToday?.(region) ?? {} : await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
5235
5332
  const rows = [...ranked.map((row) => ({
5236
5333
  account: row.account,
5237
5334
  member: true,
@@ -5338,8 +5435,9 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
5338
5435
  }
5339
5436
  const region = requestRegion(req, res);
5340
5437
  if (region === void 0) return;
5438
+ const detail = fastDetailOf(req.url ?? "") ? "fast" : "full";
5341
5439
  try {
5342
- json(res, 200, await workBuddyWebStatus(deps, region));
5440
+ json(res, 200, await workBuddyWebStatus(deps, region, { detail }));
5343
5441
  } catch (error) {
5344
5442
  json(res, 500, { error: safeMessage(error) });
5345
5443
  }
@@ -6410,6 +6508,19 @@ function apply(ctx, config) {
6410
6508
  preferences: (region) => poolPreferencesOf(current(), region),
6411
6509
  members: (region) => poolMembersOf(region),
6412
6510
  /**
6511
+ * The first-paint member list (issue #77): local state only.
6512
+ *
6513
+ * This is `localPoolMembers`, i.e. the SAME list the request path already
6514
+ * consults — saved members, their last credit snapshot (if it is still
6515
+ * fresh), and their last probe result. No upstream call, so the card's
6516
+ * account table can paint before any billing endpoint answers; live
6517
+ * figures arrive with the full document a moment later.
6518
+ *
6519
+ * A member whose credit snapshot has expired simply carries no `credits`,
6520
+ * which the card renders as "—" rather than as zero.
6521
+ */
6522
+ localMembers: (region) => localPoolMembers(region),
6523
+ /**
6413
6524
  * The checked accounts that resolve to a local sign-in.
6414
6525
  *
6415
6526
  * A local scan only — no upstream calls — so the route's empty-pool guard
@@ -6486,6 +6597,25 @@ function apply(ctx, config) {
6486
6597
  }));
6487
6598
  return Object.fromEntries(entries.filter((entry) => entry !== void 0));
6488
6599
  },
6600
+ /**
6601
+ * Today's check-in state from the cells already measured this process.
6602
+ *
6603
+ * The first-paint counterpart (issue #77) of `checkedInToday`: it reads
6604
+ * `checkinStateCache` and nothing else, so painting the table never waits
6605
+ * on one request per account. An unmeasured account is absent, which the
6606
+ * card already renders as unknown rather than as "not checked in".
6607
+ */
6608
+ cachedCheckedInToday(region) {
6609
+ if (!poolPreferencesOf(current(), region).enabled) return {};
6610
+ const now = Date.now();
6611
+ const state = checkinStateCache[region] ?? {};
6612
+ const cached = {};
6613
+ for (const [accountId, entry] of Object.entries(state)) {
6614
+ if (now - entry.atMs >= CHECKIN_CACHE_MS) continue;
6615
+ cached[accountId] = entry.checkedIn;
6616
+ }
6617
+ return cached;
6618
+ },
6489
6619
  async currentAccountId(region) {
6490
6620
  await applyPoolSelection(region).catch(() => void 0);
6491
6621
  const credential = await stacks[region].store.current().catch(() => void 0);
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.9.1",
5
+ "version": "4.9.3",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",