dsh-connect-workbuddy 5.4.0 → 5.5.1

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/lib/client.js CHANGED
@@ -111,6 +111,19 @@ window.__ModuleLoader__.load({
111
111
  * arbitrary user data and export carries token material.
112
112
  */
113
113
  const WORKBUDDY_TRANSFER_PATH = "/plugins/dsh-connect-workbuddy/transfer";
114
+ /**
115
+ * The CLI route's live turns, for the composer readout.
116
+ *
117
+ * A GET with no parameters and no region: a live turn is not scoped to a
118
+ * credential region (it is one spawned process), and the chip polls this on an
119
+ * interval, so it must stay cheap — the handler reads an in-memory board and
120
+ * serializes it, touching no upstream and no disk.
121
+ *
122
+ * The document is `{ turns: LiveTurnView[], serverTime: number }`. `serverTime`
123
+ * is the host's clock at serialization so the browser can derive an age from
124
+ * `startedAt` without trusting the two clocks to agree.
125
+ */
126
+ const WORKBUDDY_CLI_LIVE_PATH = "/plugins/dsh-connect-workbuddy/cli-live";
114
127
  /** Address one region's pool endpoint with an action. */
115
128
  function withWorkBuddyRegionAndAction(path, region, action) {
116
129
  return `${withWorkBuddyRegion(path, region)}&${WORKBUDDY_POOL_ACTION_PARAM}=${action}`;
@@ -179,14 +192,178 @@ window.__ModuleLoader__.load({
179
192
  * it is then `undefined`: the enabled flag reads back as "on" forever (the very
180
193
  * symptom described above), and a merge that spreads this map silently drops
181
194
  * the region it was not editing. Unwrap before narrowing.
195
+ *
196
+ * THE HOP IS CONDITIONAL, and that is the whole point (a saved manual pool
197
+ * order "disappearing"). "Has a `regions` key" is NOT sufficient evidence that
198
+ * the caller passed the whole section: the `regions` MAP can legitimately carry
199
+ * a `regions` key of its own, because an earlier build could write a sibling
200
+ * field name into it — a live profile was found with `accounts`, `regions` and
201
+ * `cliBridge` sitting beside `cn` / `global`. Hopping there means reading a
202
+ * DIFFERENT, older copy of the same region: measured, the same document returned
203
+ * `['c','a','b']` when read as a section and `[]` when read as a map. So a value
204
+ * counts as the whole section only when it shows a key that is neither a region
205
+ * id nor a known sibling — i.e. it looks like a settings document. A plain (or
206
+ * dirty) `cn` / `global` map is returned as-is.
207
+ *
208
+ * THE RESULT IS HEALED, not merely narrowed — see {@link healedRegionsMap}.
209
+ * Returning the map "as-is" is what let a corrupted document keep reading as
210
+ * half-empty: the field held `cn` at level 0 and `global` only one level down,
211
+ * so every `global` question answered "never configured" even though the file
212
+ * still had the answer. Reads and writes both come through here, so healing
213
+ * here fixes both halves at once (the Host's `regionStateOf`, the card's
214
+ * `nextRegion*` write bases, and the catalog repair's write).
182
215
  */
183
216
  function regionsMapOf(value) {
217
+ const peeled = peelLiveReference(value);
218
+ if (typeof peeled === "object" && peeled !== null && !Array.isArray(peeled)) {
219
+ const candidate = peeled;
220
+ if (!isRecord(candidate["regions"]) && !carriesForeignKey(candidate)) return candidate;
221
+ }
184
222
  const unwrapped = unwrapVolatileDeep(value);
185
223
  if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
186
224
  const record = unwrapped;
187
225
  const nested = record["regions"];
188
- if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
189
- return record;
226
+ if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return healedRegionsMap(record);
227
+ return healedRegionsMap(hasRegionKey(record) ? record : nested);
228
+ }
229
+ /** `value` with ONE `{get(): T}` live reference peeled, exactly like a read. */
230
+ function peelLiveReference(value) {
231
+ if (value === null || typeof value !== "object") return value;
232
+ const get = value.get;
233
+ return typeof get === "function" ? get() : value;
234
+ }
235
+ /** Whether the object carries a sibling-field name where a region id belongs. */
236
+ function carriesForeignKey(record) {
237
+ return Object.keys(record).some((key) => REGION_MAP_FOREIGN_KEYS.has(key));
238
+ }
239
+ /**
240
+ * Names that belong to the settings SECTION (`Config` in `src/index.ts`) or to
241
+ * one region SLOT (`regionStateConfig`) — never to the `regions` map.
242
+ *
243
+ * They are the signature of a data-loss bug and the list that undoes it. From
244
+ * 5.4.0 to 5.5.0 `mergeRegionCatalog` was handed the WHOLE settings section but
245
+ * read it as the `regions` field, so every boot's directory repair wrote
246
+ * `{ accounts, regions, cliBridge }` INTO the field — replacing the region map
247
+ * and burying the real one under a `regions` key. A live profile ended up five
248
+ * levels deep (one per startup) and 258 KB of duplicated slots.
249
+ *
250
+ * Neither a region id (`cn` / `global`) nor any future one can collide with
251
+ * these names, so removing them from the map is safe, idempotent, and does not
252
+ * discard a region that a newer build might add.
253
+ */
254
+ const REGION_MAP_FOREIGN_KEYS = /* @__PURE__ */ new Set([
255
+ "authFile",
256
+ "accountId",
257
+ "accounts",
258
+ "regions",
259
+ "cliBridge",
260
+ "enabled",
261
+ "showCreditsInMainUi",
262
+ "creditPosition",
263
+ "lastCatalog",
264
+ "enabledModelIds",
265
+ "imageModelIds",
266
+ "imageToolEnabled",
267
+ "offModelIds",
268
+ "contextBudgets",
269
+ "pool"
270
+ ]);
271
+ /** A non-null, non-array object: the only shape a slot or a level can have. */
272
+ function isRecord(value) {
273
+ return typeof value === "object" && value !== null && !Array.isArray(value);
274
+ }
275
+ /** Whether a map carries at least one real region id. */
276
+ function hasRegionKey(map) {
277
+ return Object.keys(map).some((key) => WORKBUDDY_REGIONS.includes(key));
278
+ }
279
+ /**
280
+ * How much USER CONFIG one candidate slot actually carries.
281
+ *
282
+ * Used only when the map lost a region and the copies left at different depths
283
+ * disagree — and they do: the bug's own directory repair wrote a slot that held
284
+ * nothing but `lastCatalog` (it started from a map where the region was already
285
+ * missing), while the copy further down still holds the pool the user had
286
+ * checked in. "Shallowest wins" would therefore resurrect the EMPTY one and
287
+ * throw away the real configuration. Depth is not age in this wreckage; CONTENT
288
+ * is the only usable signal.
289
+ *
290
+ * Deliberately counts only non-default, user-authored values: resolved slots
291
+ * carry every schema default, so a slot's key count says nothing.
292
+ */
293
+ function slotConfigWeight(slot) {
294
+ let weight = 0;
295
+ const pool = slot["pool"];
296
+ if (isRecord(pool)) {
297
+ if (pool["enabled"] === true) weight += 4;
298
+ if (Array.isArray(pool["memberAccountIds"]) && pool["memberAccountIds"].length > 0) weight += 4;
299
+ if (Array.isArray(pool["orderAccountIds"]) && pool["orderAccountIds"].length > 0) weight += 2;
300
+ if (typeof pool["targetModelId"] === "string" && pool["targetModelId"] !== "") weight += 2;
301
+ if (typeof pool["probeInputTokens"] === "number" && pool["probeInputTokens"] > 0) weight += 1;
302
+ }
303
+ for (const key of [
304
+ "enabledModelIds",
305
+ "imageModelIds",
306
+ "offModelIds"
307
+ ]) {
308
+ const list = slot[key];
309
+ if (Array.isArray(list) && list.length > 0) weight += 1;
310
+ }
311
+ const budgets = slot["contextBudgets"];
312
+ if (isRecord(budgets) && Object.keys(budgets).length > 0) weight += 1;
313
+ if (slot["enabled"] === false) weight += 1;
314
+ if (slot["showCreditsInMainUi"] === false) weight += 1;
315
+ if (slot["imageToolEnabled"] === true) weight += 1;
316
+ return weight;
317
+ }
318
+ /**
319
+ * The `regions` map with a corrupted document's wreckage removed.
320
+ *
321
+ * Two repairs, both keyed on the bug's signature (a foreign key or a nested
322
+ * `regions` copy) so a HEALTHY map passes through untouched:
323
+ *
324
+ * 1. DROP every key that names a section field or a slot field. Those are the
325
+ * copies the bug wrote; keeping them means the next save carries them
326
+ * forward forever.
327
+ * 2. RECOVER a region this level does not have from the copies buried beneath
328
+ * it. A region that IS here is never replaced — the outer copy is the one
329
+ * the surviving writers have been updating, and re-reading a stale inner
330
+ * copy over it is exactly the defect the shape rule above was fixed for.
331
+ *
332
+ * Recovery exists because `global` in the measured profile lived ONLY one level
333
+ * down: without it, the host reads that region as "never configured" and the
334
+ * first write after the fix would delete the user's international pool outright.
335
+ *
336
+ * Among the buried copies of a MISSING region, the one carrying the most user
337
+ * configuration wins rather than the shallowest — see {@link slotConfigWeight}:
338
+ * the bug's own directory repair left `{ lastCatalog }` slots that look newer
339
+ * but hold nothing, one level above the copy with the pool still in it.
340
+ */
341
+ function healedRegionsMap(map) {
342
+ const healed = {};
343
+ for (const [key, slot] of Object.entries(map)) {
344
+ if (REGION_MAP_FOREIGN_KEYS.has(key)) continue;
345
+ healed[key] = slot;
346
+ }
347
+ const recovered = {};
348
+ let deeper = map;
349
+ for (let depth = 0; depth < 8; depth += 1) {
350
+ const nested = deeper["regions"];
351
+ if (!isRecord(nested)) break;
352
+ for (const region of WORKBUDDY_REGIONS) {
353
+ if (region in map) continue;
354
+ const candidate = nested[region];
355
+ if (!isRecord(candidate)) continue;
356
+ const weight = slotConfigWeight(candidate);
357
+ const best = recovered[region];
358
+ if (best === void 0 || weight > best.weight) recovered[region] = {
359
+ slot: candidate,
360
+ weight
361
+ };
362
+ }
363
+ deeper = nested;
364
+ }
365
+ for (const [region, entry] of Object.entries(recovered)) healed[region] = entry.slot;
366
+ return healed;
190
367
  }
191
368
  /** One region's stored slot as a plain object; any other shape reads as empty. */
192
369
  function regionSlotOf(value, region) {
@@ -511,7 +688,9 @@ window.__ModuleLoader__.load({
511
688
  * value, so reading it wrong silently deleted the untouched region.
512
689
  */
513
690
  function fieldSnapshotOf(scope, field) {
514
- const value = unwrapVolatileDeep(scope.getSnapshot().value?.[field]);
691
+ const stored = scope.getSnapshot().value?.[field];
692
+ if (field === "regions") return regionsMapOf(stored);
693
+ const value = unwrapVolatileDeep(stored);
515
694
  return typeof value === "object" && value !== null ? value : {};
516
695
  }
517
696
  /**
@@ -858,12 +1037,18 @@ window.__ModuleLoader__.load({
858
1037
  * `orderAccountIds` may be anything at all. Every shape that is not a list of
859
1038
  * strings means "no manual order", which is also an older profile's spelling —
860
1039
  * never a crash.
1040
+ *
1041
+ * The map comes from `regionsMapOf` rather than a bare `value.regions` lookup:
1042
+ * it accepts the section OR the field and heals a field an older build
1043
+ * corrupted (the "restart reset" report). That is not cosmetic here — on the
1044
+ * measured profile the INTERNATIONAL slot survived only one level down, so a
1045
+ * direct lookup reported "no manual order" for the region whose order the user
1046
+ * had actually saved, and the credit panel listed those accounts in the Host's
1047
+ * order instead.
861
1048
  */
862
1049
  function readOrderAccountIds(value, region) {
863
1050
  if (region === void 0) return [];
864
- const regions = value?.regions;
865
- if (typeof regions !== "object" || regions === null) return [];
866
- const slot = regions[region];
1051
+ const slot = regionsMapOf(value)[region];
867
1052
  if (typeof slot !== "object" || slot === null) return [];
868
1053
  const order = slot.pool;
869
1054
  if (typeof order !== "object" || order === null) return [];
@@ -2726,7 +2911,7 @@ window.__ModuleLoader__.load({
2726
2911
  * Returns null when there is nothing to say, so the caller can fall back to the
2727
2912
  * static explanation instead of rendering an empty "current:" prefix.
2728
2913
  */
2729
- const targetProvenance = pool.targetModelSource === "preferred" ? t("row.poolTargetCurrentPreferred", { model: pool.targetModelId ?? "" }) : pool.targetModelSource === "free" ? t("row.poolTargetCurrentFree", { model: pool.targetModelId ?? "" }) : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : pool.targetModelSource === "none" ? t("row.poolTargetNone") : null;
2914
+ const targetProvenance = pool.targetModelSource === "free" ? t("row.poolTargetCurrentFree", { model: pool.targetModelId ?? "" }) : null;
2730
2915
  /**
2731
2916
  * The account the next request will start from.
2732
2917
  *
@@ -2736,15 +2921,19 @@ window.__ModuleLoader__.load({
2736
2921
  */
2737
2922
  const currentAccount = pool.accounts.find((account) => account.current);
2738
2923
  const currentName = currentAccount === void 0 ? void 0 : currentAccount.accountName === "" ? t("row.accountUnnamed") : currentAccount.accountName;
2739
- return (0, react.createElement)("section", { className: "dsm-workbuddy-pool" }, rowBusyAction === "remove" ? (0, react.createElement)(BusyOverlay, {
2740
- visible: true,
2741
- message: t("row.poolRowRemoving"),
2742
- hint: t("row.veilHintList")
2743
- }) : null, saving ? (0, react.createElement)(BusyOverlay, {
2744
- visible: true,
2745
- message: t("row.poolSaving"),
2746
- hint: t("row.veilHintList")
2747
- }) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolManualAccountLabel")), (0, react.createElement)("span", null, t("row.poolManualAccountHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
2924
+ /**
2925
+ * The manual-mode picker: shown ONLY with the pool off, and it is the whole of
2926
+ * "manual mode" — with the pool off nothing else chooses who is billed.
2927
+ *
2928
+ * It lives in the HEAD line, sharing one slot with the 「current: X」 badge,
2929
+ * instead of in a row of its own further down. That row said the same account
2930
+ * name for the third time on one screen (badge, picker, member row), and the
2931
+ * repetition is what made the page read as generated rather than designed.
2932
+ * The two states are the two halves of one question — "who serves?" — so they
2933
+ * get one place to answer it: a picker when the answer is yours, a badge when
2934
+ * the pool decides.
2935
+ */
2936
+ const manualPicker = !poolRunning && onSelectAccount !== void 0 && pool.accounts.length > 0 ? (0, react.createElement)("label", { className: "dsm-workbuddy-pool-head-pick" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-head-pick-label" }, t("row.poolManualAccountLabel")), (0, react.createElement)("select", {
2748
2937
  className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-account-select",
2749
2938
  value: currentAccount?.accountId ?? "",
2750
2939
  disabled: selectingAccount === true,
@@ -2757,27 +2946,45 @@ window.__ModuleLoader__.load({
2757
2946
  }, t("row.poolManualAccountNone")), ...pool.accounts.map((account) => (0, react.createElement)("option", {
2758
2947
  key: account.accountId,
2759
2948
  value: account.accountId
2760
- }, account.accountName === "" ? t("row.accountUnnamed") : account.accountName))))) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-actions" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-actions-group" }, pool.checkinSupported ? (0, react.createElement)("button", {
2761
- type: "button",
2762
- className: "dsm-btn dsm-btn-primary",
2763
- disabled: busy !== void 0 || effectiveMembers.length === 0,
2764
- onClick: () => {
2765
- runAction("checkin");
2766
- }
2767
- }, busy === "checkin" ? t("row.poolCheckingIn") : t("row.poolCheckinAll")) : null, (0, react.createElement)("button", {
2768
- type: "button",
2769
- className: "dsm-btn dsm-btn-outline",
2770
- disabled: busy !== void 0 || effectiveMembers.length === 0 || pool.targetModelSource === "none" || pool.targetModelSource === "stale",
2771
- title: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
2772
- onClick: () => {
2773
- runAction("test");
2949
+ }, account.accountName === "" ? t("row.accountUnnamed") : account.accountName)))) : null;
2950
+ /**
2951
+ * The full ordering rule, collapsed — pure reference text.
2952
+ *
2953
+ * It used to sit between 「本池包含的账号」 and the first account row, which put
2954
+ * a paragraph of explanation ahead of the accounts it explains and pushed the
2955
+ * table down. Reference text belongs BELOW the data it describes; here it is,
2956
+ * at the top of 「更多设置」, next to the controls it explains.
2957
+ *
2958
+ * A <details>/<summary> pair rather than a stateful button: it is
2959
+ * keyboard-operable and screen-reader-expanded natively, and cannot fall out
2960
+ * of sync with what is on screen. `open` is controlled so a background refresh
2961
+ * cannot collapse a section the user just opened.
2962
+ */
2963
+ const orderRules = (0, react.createElement)("details", {
2964
+ className: "dsm-workbuddy-pool-order-rules",
2965
+ open: orderRulesOpen,
2966
+ onToggle: (event) => setOrderRulesOpen(event.currentTarget.open)
2967
+ }, (0, react.createElement)("summary", { className: "dsm-workbuddy-pool-order-rules-summary" }, t("row.poolOrderRulesTitle")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-order-rules-body" }, (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolOrderRulesIntro")), (0, react.createElement)("ol", { className: "dsm-workbuddy-pool-order-rules-list" }, ...ORDER_RULE_KEYS.map((key) => (0, react.createElement)("li", { key }, t(key)))), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-order-rules-why" }, t("row.poolOrderWhy")), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolOrderBadgeNote"))));
2968
+ return (0, react.createElement)("section", { className: "dsm-workbuddy-pool" }, rowBusyAction === "remove" ? (0, react.createElement)(BusyOverlay, {
2969
+ visible: true,
2970
+ message: t("row.poolRowRemoving"),
2971
+ hint: t("row.veilHintList")
2972
+ }) : null, saving ? (0, react.createElement)(BusyOverlay, {
2973
+ visible: true,
2974
+ message: t("row.poolSaving"),
2975
+ hint: t("row.veilHintList")
2976
+ }) : null, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolTitle")), manualPicker !== null ? manualPicker : currentName === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-badge" }, t("row.poolCurrentHeader", { account: currentName }))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings dsm-workbuddy-pool-modebar" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-modebar-row" }, (0, react.createElement)("label", { className: "sw" }, (0, react.createElement)("input", {
2977
+ type: "checkbox",
2978
+ checked: active.enabled,
2979
+ disabled: !canEditPool,
2980
+ onChange: (event) => {
2981
+ const next = event.currentTarget.checked;
2982
+ editDraft((current) => ({
2983
+ ...current,
2984
+ enabled: next
2985
+ }));
2774
2986
  }
2775
- }, busy === "test" ? t("row.poolTesting") : t("row.poolTestAll")), busy === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, t("row.poolBusyHint"))), onRescan === void 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-actions-group" }, (0, react.createElement)("button", {
2776
- type: "button",
2777
- className: "dsm-btn dsm-btn-outline",
2778
- disabled: rescanning === true,
2779
- onClick: onRescan
2780
- }, rescanning === true ? t("row.accountsScanning") : t("row.accountsRescan")))), !poolRunning ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolEnabledHint")) : null, poolRunning && usable.length === 0 ? allMembersBlockedByModel(pool?.accounts ?? [], effectiveMembers, active.memberAccountIds) ? (0, react.createElement)("p", {
2987
+ }), (0, react.createElement)("span", { className: "sw-track" })), (0, react.createElement)("b", { className: "dsm-workbuddy-pool-modebar-label" }, t("row.poolEnabled")), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-modebar-state" }, poolRunning ? t("row.poolModeAuto") : t("row.poolModeManual")), dirty ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolDirty")) : null), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolEnabledHint"))), poolRunning && currentAccount !== void 0 && !effectiveMembers.includes(currentAccount.accountId) ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note dsm-workbuddy-pool-serving-note" }, t("row.poolServingOutside", { account: currentName ?? "" })) : null, poolRunning && effectiveMembers.length > 0 && usable.length === 0 ? allMembersBlockedByModel(pool?.accounts ?? [], effectiveMembers, active.memberAccountIds) ? (0, react.createElement)("p", {
2781
2988
  className: "dsm-workbuddy-pool-warn",
2782
2989
  role: "status"
2783
2990
  }, `${t("row.poolModelWideBusy")} ${t("row.poolModelWideBusyHint")}`) : (0, react.createElement)("p", {
@@ -2796,11 +3003,27 @@ window.__ModuleLoader__.load({
2796
3003
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
2797
3004
  disabled: effectiveMembers.length === 0 || !canEditPool,
2798
3005
  onClick: () => selectAll(false)
2799
- }, t("row.poolSelectNone")))), (0, react.createElement)("details", {
2800
- className: "dsm-workbuddy-pool-order-rules",
2801
- open: orderRulesOpen,
2802
- onToggle: (event) => setOrderRulesOpen(event.currentTarget.open)
2803
- }, (0, react.createElement)("summary", { className: "dsm-workbuddy-pool-order-rules-summary" }, t("row.poolOrderRulesTitle")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-order-rules-body" }, (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolOrderRulesIntro")), (0, react.createElement)("ol", { className: "dsm-workbuddy-pool-order-rules-list" }, ...ORDER_RULE_KEYS.map((key) => (0, react.createElement)("li", { key }, t(key)))), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-order-rules-why" }, t("row.poolOrderWhy")), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolOrderBadgeNote")))), (0, react.createElement)("div", { className: `dsm-workbuddy-pool-table${pool.checkinSupported ? " dsm-workbuddy-pool-table-checkin" : ""}` }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null, (0, react.createElement)("span", null)), ...(() => {
3006
+ }, t("row.poolSelectNone")))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-actions" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-actions-group" }, pool.checkinSupported ? (0, react.createElement)("button", {
3007
+ type: "button",
3008
+ className: "dsm-btn dsm-btn-primary",
3009
+ disabled: busy !== void 0 || effectiveMembers.length === 0,
3010
+ onClick: () => {
3011
+ runAction("checkin");
3012
+ }
3013
+ }, busy === "checkin" ? t("row.poolCheckingIn") : t("row.poolCheckinAll")) : null, (0, react.createElement)("button", {
3014
+ type: "button",
3015
+ className: "dsm-btn dsm-btn-outline",
3016
+ disabled: busy !== void 0 || effectiveMembers.length === 0 || pool.targetModelSource === "none" || pool.targetModelSource === "stale",
3017
+ title: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
3018
+ onClick: () => {
3019
+ runAction("test");
3020
+ }
3021
+ }, busy === "test" ? t("row.poolTesting") : t("row.poolTestAll")), busy !== void 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, t("row.poolBusyHint")) : effectiveMembers.length === 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-hint" }, t("row.poolNoneSelectedHint")) : null), onRescan === void 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-actions-group" }, (0, react.createElement)("button", {
3022
+ type: "button",
3023
+ className: "dsm-btn dsm-btn-outline",
3024
+ disabled: rescanning === true,
3025
+ onClick: onRescan
3026
+ }, rescanning === true ? t("row.accountsScanning") : t("row.accountsRescan")))), (0, react.createElement)("div", { className: `dsm-workbuddy-pool-table${pool.checkinSupported ? " dsm-workbuddy-pool-table-checkin" : ""}` }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-row dsm-workbuddy-pool-row-head" }, (0, react.createElement)("span", null, t("row.poolColumnAccount")), (0, react.createElement)("span", null, t("row.poolColumnCredits")), (0, react.createElement)("span", null, t("row.poolColumnProbe")), pool.checkinSupported ? (0, react.createElement)("span", null, t("row.poolColumnCheckin")) : null, (0, react.createElement)("span", null)), ...(() => {
2804
3027
  const ordered = sortAccountsByOrder(pool.accounts, orderIds);
2805
3028
  let memberSeen = 0;
2806
3029
  let memberTotal = 0;
@@ -2824,6 +3047,7 @@ window.__ModuleLoader__.load({
2824
3047
  claimBusy: rowBusyAccountId === account.accountId,
2825
3048
  testDisabled: pool.targetModelSource === "none" || pool.targetModelSource === "stale",
2826
3049
  targetHint: pool.targetModelSource === "none" ? t("row.poolTargetNone") : pool.targetModelSource === "stale" ? t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" }) : void 0,
3050
+ poolModelId: pool.targetModelId ?? "",
2827
3051
  onTest: () => {
2828
3052
  runRowTest(account.accountId);
2829
3053
  },
@@ -2835,7 +3059,7 @@ window.__ModuleLoader__.load({
2835
3059
  },
2836
3060
  orderIndex: isMember && orderIsManual ? left + 1 : void 0,
2837
3061
  orderIsManual,
2838
- moveEnabled: pool.enabled === true && isMember && canEditPool,
3062
+ moveEnabled: active.enabled && isMember && canEditPool,
2839
3063
  onMoveUp: () => {
2840
3064
  moveMember(account.accountId, -1);
2841
3065
  },
@@ -2846,10 +3070,7 @@ window.__ModuleLoader__.load({
2846
3070
  moveDownDisabled: right <= 0
2847
3071
  });
2848
3072
  });
2849
- })()), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
2850
- className: "dsm-workbuddy-pool-warn",
2851
- role: "status"
2852
- }, `${t("row.poolNoneSelected")} ${t("row.poolNoneSelectedHint")}`) : null, ghostMembers$1.length === 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-ghosts" }, (0, react.createElement)("p", {
3073
+ })()), ghostMembers$1.length === 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-ghosts" }, (0, react.createElement)("p", {
2853
3074
  className: "dsm-workbuddy-pool-warn",
2854
3075
  role: "status"
2855
3076
  }, t("row.poolGhostMembers", {
@@ -2899,6 +3120,7 @@ window.__ModuleLoader__.load({
2899
3120
  onDiscard: discard,
2900
3121
  appendLog,
2901
3122
  targetProvenance,
3123
+ orderRules,
2902
3124
  ...accountTools === void 0 ? {} : { accountTools }
2903
3125
  }), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolRegionNote")), renderLog({
2904
3126
  t,
@@ -2912,7 +3134,7 @@ window.__ModuleLoader__.load({
2912
3134
  }
2913
3135
  /** One account's row. */
2914
3136
  function renderAccountRow(input) {
2915
- const { t, account, checked, canEdit, checkinSupported, onToggle, busy, rowBusy, testDisabled, targetHint, onTest, onRemove, onClaim, rowBusyAction, claimBusy, orderIndex, orderIsManual, moveEnabled, onMoveUp, onMoveDown, moveUpDisabled, moveDownDisabled } = input;
3137
+ const { t, account, checked, canEdit, checkinSupported, onToggle, busy, rowBusy, testDisabled, targetHint, onTest, onRemove, onClaim, rowBusyAction, claimBusy, orderIndex, orderIsManual, moveEnabled, onMoveUp, onMoveDown, moveUpDisabled, moveDownDisabled, poolModelId } = input;
2916
3138
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
2917
3139
  const excluded = exclusionText(t, account.excludedBy);
2918
3140
  const probe = account.probe;
@@ -2962,7 +3184,7 @@ window.__ModuleLoader__.load({
2962
3184
  "aria-label": t("row.poolMoveDownAria", { accountName: name }),
2963
3185
  disabled: moveDownDisabled,
2964
3186
  onClick: onMoveDown
2965
- }, "↓")) : null) : null, account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShortClock(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShortClock(account.nearestExpiryMs) })), account.benefits === void 0 || account.benefits.length === 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-benefits" }, account.benefits.map((benefit, index) => (0, react.createElement)("span", {
3187
+ }, "↓")) : null) : null, account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, checked ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-not-member" }, t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShortClock(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShortClock(account.nearestExpiryMs) })), account.benefits === void 0 || account.benefits.length === 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-benefits" }, account.benefits.map((benefit, index) => (0, react.createElement)("span", {
2966
3188
  key: benefit.kind,
2967
3189
  className: benefit.urgent ? "dsm-workbuddy-pool-benefit dsm-workbuddy-pool-benefit-urgent" : "dsm-workbuddy-pool-benefit"
2968
3190
  }, index === 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-benefit-tag" }, benefit.urgent ? benefit.daysLeft === void 0 || benefit.daysLeft === 0 ? t("row.poolBenefitDueToday", {
@@ -2988,7 +3210,7 @@ window.__ModuleLoader__.load({
2988
3210
  }, (0, react.createElement)("span", null, claimBusy && rowBusyAction === "claim" ? t("row.poolBenefitClaiming") : t("row.poolBenefitClaim"))))))), (0, react.createElement)("span", {
2989
3211
  className: "dsm-workbuddy-pool-probe",
2990
3212
  ...probe?.message === void 0 ? {} : { title: probe.message }
2991
- }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age), probe === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-model" }, probe.modelId === void 0 ? t("row.poolProbeModelUnknown") : t("row.poolProbeModel", { model: probe.modelId })))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
3213
+ }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age), probe !== void 0 && probe.modelId !== poolModelId ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-model" }, probe.modelId === void 0 ? t("row.poolProbeModelUnknown") : t("row.poolProbeModel", { model: probe.modelId })) : null)), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
2992
3214
  className: "dsm-workbuddy-pool-unknown",
2993
3215
  title: t("row.poolNeverTested")
2994
3216
  }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-row-actions" }, (0, react.createElement)("button", {
@@ -3008,18 +3230,9 @@ window.__ModuleLoader__.load({
3008
3230
  }
3009
3231
  /** The preference editor plus its save/discard actions. */
3010
3232
  function renderSettings(input) {
3011
- const { t, pool, active, dirty, saving, siblingBusy, saveError, settingsScope, onEdit, onSave, onDiscard, targetProvenance, accountTools } = input;
3233
+ const { t, pool, active, dirty, saving, siblingBusy, saveError, settingsScope, onEdit, onSave, onDiscard, targetProvenance, accountTools, orderRules } = input;
3012
3234
  const canEdit = settingsScope !== void 0;
3013
- const toggle = (label, hint, checked, onChange, disabled = false) => (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, label), (0, react.createElement)("span", null, hint)), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("label", { className: "sw" }, (0, react.createElement)("input", {
3014
- type: "checkbox",
3015
- checked,
3016
- disabled: disabled || !canEdit,
3017
- onChange: (event) => onChange(event.currentTarget.checked)
3018
- }), (0, react.createElement)("span", { className: "sw-track" }))));
3019
- return (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings-wrap" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-head" }, (0, react.createElement)("h3", { className: "dsm-workbuddy-pool-title" }, t("row.poolSettingsTitle")), dirty ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolDirty")) : null), accountTools === void 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-tools" }, accountTools), (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolSettingsHint")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings" }, toggle(t("row.poolEnabled"), t("row.poolEnabledHint"), active.enabled, (next) => onEdit((current) => ({
3020
- ...current,
3021
- enabled: next
3022
- }))), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolTargetLabel")), (0, react.createElement)("span", null, targetProvenance === null ? null : `${targetProvenance} `, t("row.poolTargetHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
3235
+ return (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings-wrap" }, accountTools === void 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-tools" }, accountTools), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-card-head" }, (0, react.createElement)("h4", { className: "dsm-workbuddy-pool-card-title" }, t("row.poolSettingsTitle"))), orderRules, (0, react.createElement)("p", { className: "dsm-workbuddy-pool-note" }, t("row.poolSettingsHint")), (0, react.createElement)("div", { className: "dsm-workbuddy-pool-settings" }, (0, react.createElement)("div", { className: "dsm-workbuddy-pool-set" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-copy" }, (0, react.createElement)("b", null, t("row.poolTargetLabel")), (0, react.createElement)("span", null, targetProvenance === null ? null : `${targetProvenance} `, t("row.poolTargetHint"))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-set-ctl" }, (0, react.createElement)("select", {
3023
3236
  className: "dsm-workbuddy-pool-select",
3024
3237
  value: active.targetModelId,
3025
3238
  disabled: !canEdit,
@@ -3504,10 +3717,6 @@ window.__ModuleLoader__.load({
3504
3717
  message: t("row.oauthSyncing"),
3505
3718
  hint: t("row.veilHintList")
3506
3719
  }),
3507
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3508
- className: "dsm-workbuddy-bar-label",
3509
- children: t(titleKey)
3510
- }),
3511
3720
  phase.kind === "finished" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3512
3721
  className: `dsm-workbuddy-bar-status ${phase.message === "added" ? "dsm-workbuddy-bar-ok" : "dsm-workbuddy-bar-error"}`,
3513
3722
  children: finishText
@@ -3810,10 +4019,6 @@ window.__ModuleLoader__.load({
3810
4019
  message: veilMessage,
3811
4020
  ...busy ? { hint: t("row.veilHintList") } : {}
3812
4021
  }),
3813
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3814
- className: "dsm-workbuddy-bar-label",
3815
- children: t("row.transferTitle")
3816
- }),
3817
4022
  phase.kind === "done" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3818
4023
  className: "dsm-workbuddy-bar-status dsm-workbuddy-bar-ok",
3819
4024
  children: phase.message
@@ -4200,6 +4405,20 @@ window.__ModuleLoader__.load({
4200
4405
  return badge.label;
4201
4406
  }
4202
4407
  /**
4408
+ * The badge's wording where the chip has to fit a narrow row.
4409
+ *
4410
+ * The model picker's rows are one line wide and already carry the model name,
4411
+ * the credit rate and the load icon, so a four-character label ("夜间折扣") can
4412
+ * push the check mark out of the row. The upstream publishes a compact wording
4413
+ * for exactly this case (`shortLabel`), and when it does not, the full label is
4414
+ * still better than a cropped one: an ellipsis in a menu row reads as a
4415
+ * truncated word, while the full label is at worst tight.
4416
+ */
4417
+ function promoBadgeShortLabel(promo, language) {
4418
+ const badge = promo.badge;
4419
+ return (isEnglish(language) ? badge.shortLabelEn ?? badge.shortLabel : badge.shortLabel) ?? promoBadgeLabel(promo, language);
4420
+ }
4421
+ /**
4203
4422
  * The chip's colour, when the upstream published a usable one.
4204
4423
  *
4205
4424
  * Only a hex colour is accepted: the value reaches an inline style, and the
@@ -4560,6 +4779,11 @@ window.__ModuleLoader__.load({
4560
4779
  scrolling to the end of a long model list. */
4561
4780
  .dsm-workbuddy-models-dirty{margin-left:8px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#1b1d22;font-size:11px;font-weight:600;line-height:16px;vertical-align:1px;white-space:nowrap}
4562
4781
  .dsm-workbuddy-models-summary{margin:2px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
4782
+ /* The one grey line that states what a section is FOR, at the top of a panel.
4783
+ Kept as its own class instead of reusing the models-summary class: the same
4784
+ rule now heads the capability section, whose vocabulary has nothing to do with
4785
+ models, and a shared name would send the next reader to the wrong panel. */
4786
+ .dsm-workbuddy-panel-note{margin:2px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
4563
4787
  /* The free-model quota warning (see the tabFreeQuotaWarn locale key). Bold on
4564
4788
  purpose, and it keeps the summary's own grey rather than the primary label
4565
4789
  colour: it has to stand out from the three lines around it WITHOUT reading as
@@ -4602,7 +4826,15 @@ window.__ModuleLoader__.load({
4602
4826
  one the brand colour stands in. "flex:none" keeps the badge whole while the
4603
4827
  model name beside it truncates with an ellipsis. The plain background comes
4604
4828
  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}
4829
+ .dsm-workbuddy-model-label{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;vertical-align:baseline}
4830
+ /* The same chip, inside the HOST's picker. A model row there is one line wide
4831
+ and already carries the name, the check mark and the load icon, so the chip
4832
+ gets a hard ceiling and the TEXT inside it truncates — the chip itself must
4833
+ stay whole, because a half-drawn pill reads as a rendering bug. "6em" at the
4834
+ chip's 10px is about six characters: enough for "夜间折扣", not enough for a
4835
+ label to push the row's own controls out of view. */
4836
+ .dsm-workbuddy-model-label-promo{max-width:6em;overflow:hidden}
4837
+ .dsm-workbuddy-model-label-promo-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4606
4838
  .dsm-workbuddy-busy{flex:none;display:inline-flex;align-items:center;block-size:12px;color:var(--dsm-workbuddy-busy-color);vertical-align:-2px}
4607
4839
  .dsm-workbuddy-busy svg{display:block;block-size:12px;inline-size:auto}
4608
4840
  .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)}
@@ -4679,21 +4911,26 @@ window.__ModuleLoader__.load({
4679
4911
  .dsm-workbuddy-pool-current-badge{margin-left:auto;padding:2px 10px;border-radius:999px;background:var(--dsw-alias-state-success-primary,#22c55e);border:1px solid rgba(34,197,94,.55);color:#fff;font-size:12px;font-weight:600;line-height:18px;white-space:nowrap}
4680
4912
  /* Sits under the header block, before the batch buttons: account discovery is
4681
4913
  the first step of using the pool, so it reads above the actions it enables. */
4682
- /* Manual-mode account picker: shares the settings row layout (.pool-set), and
4683
- only the select itself needs a hint that it is a PICKER of accounts rather
4684
- than of models — the two sit near each other and used to be indistinguishable
4685
- to a selector query as well as to the eye. */
4914
+ /* THE HEAD SLOT — one place that answers "who is serving?".
4915
+ *
4916
+ * With the pool off it holds the account picker, with the pool on the badge.
4917
+ * Both are pushed right by the same rule so the header does not jump between
4918
+ * the two states. The picker used to live in a settings row of its own further
4919
+ * down the page, which meant the same account name appeared in the title row,
4920
+ * in that row, and again in the member table. */
4921
+ .dsm-workbuddy-pool-head-pick{margin-left:auto;display:flex;align-items:center;gap:8px;font-size:12px}
4922
+ .dsm-workbuddy-pool-head-pick-label{color:var(--dsw-alias-label-secondary,#c6c9d0);white-space:nowrap}
4686
4923
  .dsm-workbuddy-pool-account-select{min-width:200px}
4687
4924
  .dsm-workbuddy-pool-title{margin:0;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
4688
4925
  .dsm-workbuddy-pool-badge{flex:none;padding:2px 9px;border-radius:999px;font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-brand-primary,#5686fe);background:rgba(86,134,254,.12);border:1px solid rgba(86,134,254,.32)}
4689
- /* One row, two clusters: LEFT acts on the accounts you checked, RIGHT re-reads
4690
- which accounts exist. Before this the rescan button was on its own row pushed
4691
- right while the batch buttons were on the next row pushed left — two
4692
- alignments two lines apart, with nothing explaining the split. */
4693
- .dsm-workbuddy-pool-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:12px}
4926
+ /* No top rule: this bar now hangs directly under 「本池包含的账号 / 已选 N / M /
4927
+ 全选 / 全不选」 and acts on exactly that set, so a separator here would cut the
4928
+ buttons away from the count that explains them. The divider that matters is
4929
+ the one the member block has against 「更多设置」 below. */
4930
+ .dsm-workbuddy-pool-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
4694
4931
  .dsm-workbuddy-pool-actions-group{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
4695
- .dsm-workbuddy-pool-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999)}
4696
- .dsm-workbuddy-pool-note{margin:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary,#999)}
4932
+ .dsm-workbuddy-pool-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
4933
+ .dsm-workbuddy-pool-note{margin:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
4697
4934
  .dsm-workbuddy-pool-warn{margin:0;padding:9px 11px;border-radius:9px;font-size:12px;line-height:17px;color:var(--dsw-alias-state-warn-primary,#f59e0b);background:rgba(245,158,11,.09);border:1px solid rgba(245,158,11,.3)}
4698
4935
  /* Ghost members (issue #60): the warning plus one removable chip per id. The
4699
4936
  chips are what make the warning actionable — a ghost has no row in the table
@@ -4753,7 +4990,7 @@ window.__ModuleLoader__.load({
4753
4990
  its built-in keyboard and screen-reader behaviour. The summary is a full-width
4754
4991
  row because a 12px grey label on its own is a small click target, and this is
4755
4992
  the one place a confused user is meant to click. */
4756
- .dsm-workbuddy-pool-order-rules{border:1px solid var(--dsw-alias-border-l1,#2c2d31);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#232529);margin:0 0 10px;overflow:hidden}
4993
+ .dsm-workbuddy-pool-order-rules{border:1px solid var(--dsw-alias-border-l1,#2c2d31);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#232529);overflow:hidden}
4757
4994
  .dsm-workbuddy-pool-order-rules-summary{padding:8px 12px;cursor:pointer;user-select:none;color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;font-weight:600;line-height:18px}
4758
4995
  .dsm-workbuddy-pool-order-rules-summary:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}
4759
4996
  .dsm-workbuddy-pool-order-rules-summary:focus-visible{outline:2px solid rgba(86,134,254,.55);outline-offset:-2px}
@@ -4907,17 +5144,57 @@ window.__ModuleLoader__.load({
4907
5144
  .dsm-workbuddy-pool-check input:disabled{cursor:default;opacity:.5}
4908
5145
  .dsm-workbuddy-pool-checked{color:var(--dsw-alias-state-success-primary,#22a06b)}
4909
5146
  .dsm-workbuddy-pool-unknown{color:var(--dsw-alias-label-dimmed,#9aa0a6)}
4910
- .dsm-workbuddy-pool-member{color:var(--dsw-alias-brand-primary,#5686fe)}
4911
5147
  .dsm-workbuddy-pool-not-member{color:var(--dsw-alias-label-dimmed,#9aa0a6)}
4912
- .dsm-workbuddy-pool-settings-wrap{display:flex;flex-direction:column;gap:10px}
5148
+ /* A section break, not just a gap: below this line the page stops showing what
5149
+ the pool IS and starts showing the knobs that change it. Without the rule the
5150
+ heading floated under the table with nothing marking the change of subject. */
5151
+ .dsm-workbuddy-pool-settings-wrap{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:14px}
4913
5152
  .dsm-workbuddy-pool-dirty{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
4914
5153
  .dsm-workbuddy-pool-settings{display:flex;flex-direction:column;gap:1px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
4915
- /* QR sign-in + export/import, hosted inside the pool settings block.
4916
- The block's own bordered rows belong to the SAVED preferences, so these
4917
- get their own container: same rhythm (12px 14px) so the two read as one
4918
- group, but a dashed edge marking them as immediate actions rather than
4919
- drafts the Save button below would commit. */
4920
- .dsm-workbuddy-pool-tools{display:flex;flex-direction:column;gap:2px;border:1px dashed var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;padding:4px 10px}
5154
+ /* THE MODE BAR — the page's master control, directly under the title.
5155
+ *
5156
+ * It reuses the .dsm-workbuddy-pool-settings class for the bordered, stacked
5157
+ * look, and on purpose: the component tests locate 「启用账号池」 as the first
5158
+ * checkbox inside that class, and a user who has learned "settings live in a
5159
+ * bordered block" should not have to learn a second shape for the most
5160
+ * important one.
5161
+ *
5162
+ * What it adds is a brand-tinted edge and background, so it reads as "this
5163
+ * decides what the rest of the page means" instead of as one more settings
5164
+ * card — the switch used to sit at the BOTTOM, 400px below this point.
5165
+ */
5166
+ .dsm-workbuddy-pool-modebar{padding:12px 14px;gap:8px;border-color:rgba(86,134,254,.36);background:linear-gradient(180deg,rgba(86,134,254,.10),rgba(86,134,254,.02)),var(--dsw-alias-bg-layer-2,#24262c)}
5167
+ .dsm-workbuddy-pool-modebar-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
5168
+ .dsm-workbuddy-pool-modebar-label{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
5169
+ /* The mode in words. A switch's position is a 20px shape to decode; this says
5170
+ which of the two modes is in force, and therefore how to read the table. */
5171
+ .dsm-workbuddy-pool-modebar-state{flex:none;padding:1px 9px;border-radius:999px;font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-brand-primary,#5686fe);background:rgba(86,134,254,.14);border:1px solid rgba(86,134,254,.3)}
5172
+ .dsm-workbuddy-pool-modebar .dsm-workbuddy-pool-dirty{margin-left:auto}
5173
+ .dsm-workbuddy-pool-modebar .dsm-workbuddy-pool-note{line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
5174
+ /* Orthogonal concepts in one sentence when they collide ("in use" vs "in the
5175
+ pool"). A left rule marks it as an aside about the row above, not a warning. */
5176
+ .dsm-workbuddy-pool-serving-note{padding-left:10px;border-left:2px solid rgba(86,134,254,.5);color:var(--dsw-alias-label-secondary,#c6c9d0)}
5177
+ /* Subheading for "the rest of the settings": one step DOWN from the page title,
5178
+ so the panel has one title instead of two equally loud ones. */
5179
+ .dsm-workbuddy-pool-card-head{display:flex;align-items:center;gap:8px}
5180
+ .dsm-workbuddy-pool-card-title{margin:0;font-size:13px;font-weight:600;color:var(--dsw-alias-label-secondary,#c6c9d0)}
5181
+ /* QR sign-in + export/import, ABOVE the settings block.
5182
+ One toolbar row, not a column of two: with the captions gone (the buttons
5183
+ name themselves) there is nothing left to stack, and a row is where a user
5184
+ looks for instant actions. The dashed edge still separates them from the
5185
+ saved preferences below — that contrast is now the whole point, since the
5186
+ heading under this row is the one that says 「点保存才生效」.
5187
+ Left-aligned, not right: 扫码登录 is the primary action and it should read
5188
+ first, in the same left-hand column as 一键签到 above and 「更多设置」 below.
5189
+ Pushed to the right edge the three buttons looked like the tail of the row
5190
+ above rather than the start of this one. */
5191
+ .dsm-workbuddy-pool-tools{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:flex-start;gap:8px;border:1px dashed var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;padding:6px 10px}
5192
+ /* A bar inside the toolbar is a flex item, not a full-width row: it keeps only
5193
+ its own content width, and the toolbar decides where the group sits. */
5194
+ .dsm-workbuddy-pool-tools .dsm-workbuddy-bar{flex:0 1 auto;min-width:0;margin:0}
5195
+ /* Status text may wrap here instead of truncating: a toolbar row is short, and
5196
+ 「已导出」「等待扫码」 are the whole message — an ellipsis would eat it. */
5197
+ .dsm-workbuddy-pool-tools .dsm-workbuddy-bar-status{white-space:normal}
4921
5198
  .dsm-workbuddy-pool-set{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:12px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31)}
4922
5199
  .dsm-workbuddy-pool-set:first-child{border-top:0}
4923
5200
  .dsm-workbuddy-pool-set-copy{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
@@ -4927,7 +5204,12 @@ window.__ModuleLoader__.load({
4927
5204
  .dsm-workbuddy-pool-num{width:74px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:8px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);font:inherit;font-size:13px;text-align:center;font-variant-numeric:tabular-nums}
4928
5205
  .dsm-workbuddy-pool-select{max-width:260px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:8px;background:var(--dsw-alias-bg-layer-3,#2a2c33);color:var(--dsw-alias-label-primary,#e6e6e6);font:inherit;font-size:13px;cursor:pointer}
4929
5206
  .dsm-workbuddy-pool-select:disabled,.dsm-workbuddy-pool-num:disabled{opacity:.5;cursor:default}
4930
- .dsm-workbuddy-pool-save-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:12px}
5207
+ /* The commit row: pinned to the bottom of the viewport while the panel is on
5208
+ * screen. The panel is taller than a screen, and the two buttons that make any
5209
+ * edit real used to sit at its very bottom — off-screen at the moment the user
5210
+ * finished editing, and invisible entirely in a screenshot. Opaque, because
5211
+ * content scrolls under it. */
5212
+ .dsm-workbuddy-pool-save-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:12px;position:sticky;bottom:0;z-index:2;padding-bottom:8px;background:var(--dsw-alias-bg-layer-3,#2a2c33)}
4931
5213
  .dsm-workbuddy-pool-save-buttons{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex:none}
4932
5214
  .dsm-workbuddy-pool-conflict{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:11px 13px;border-radius:10px;background:rgba(245,158,11,.08);border:1px solid var(--dsw-alias-state-warn-primary,#f59e0b)}
4933
5215
  .dsm-workbuddy-pool-pending{flex:none;align-self:center;font-size:12px;line-height:17px;color:var(--dsw-alias-state-warn-primary,#f59e0b)}
@@ -4969,13 +5251,16 @@ window.__ModuleLoader__.load({
4969
5251
  .dsm-workbuddy-pool .sw input:disabled+.sw-track{opacity:.4}
4970
5252
  .dsm-workbuddy-pool .sw input:disabled{cursor:default}
4971
5253
  /* Account action bars: QR sign-in and export/import.
4972
- One row each — label, live status, actions — instead of a padded block
4973
- per feature. The bar is the resting height; only an open dialog (below)
4974
- grows vertically, so the card does not scroll past a wall of chrome to
4975
- reach the buttons. Status text shares the row rather than claiming its own,
4976
- and is what truncates first when the card is narrow. */
5254
+ One row each — live status, actions — instead of a padded block per feature.
5255
+ There is no caption: each bar holds buttons that name themselves, so a
5256
+ heading above them would repeat the button in a smaller font (「扫码添加账号」
5257
+ over 「扫码登录」). The section keeps an aria-label for the same reason a
5258
+ caption existed, so the name is still available to a screen reader.
5259
+ The bar is the resting height; only an open dialog (below) grows vertically,
5260
+ so the card does not scroll past a wall of chrome to reach the buttons.
5261
+ Status text shares the row rather than claiming its own, and is what
5262
+ truncates first when the card is narrow. */
4977
5263
  .dsm-workbuddy-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:8px 0;padding:0}
4978
- .dsm-workbuddy-bar-label{flex:none;margin:0;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#c6c9d0)}
4979
5264
  .dsm-workbuddy-bar-status{flex:1 1 auto;min-width:0;margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e8e9ed);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4980
5265
  .dsm-workbuddy-bar-hint{color:var(--dsw-alias-label-tertiary,#999)}
4981
5266
  .dsm-workbuddy-bar-actions{display:flex;gap:8px;flex:none;margin-left:auto}
@@ -5225,6 +5510,32 @@ window.__ModuleLoader__.load({
5225
5510
  }
5226
5511
  .dsm-workbuddy-composer-panel-empty{margin:0;color:var(--dsw-alias-label-tertiary,GrayText)}
5227
5512
  .dsm-workbuddy-composer-panel-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#d92d20);font-size:11px;line-height:16px}
5513
+
5514
+ /* The CLI live pill shares the composer dock row with the credit readout above
5515
+ and the shell's own ContextMeter, so it takes ITS metrics from the same place
5516
+ the credit trigger does: 12px/18px, label-tertiary, tabular numerals. Rendered
5517
+ with inline styles it inherited the dock's body font and read as stray text
5518
+ sitting next to two native-looking controls. */
5519
+ .dsm-workbuddy-cli-live{
5520
+ display:inline-flex;align-items:center;gap:6px;
5521
+ padding:2px 6px;border-radius:6px;
5522
+ font-size:12px;line-height:18px;
5523
+ color:var(--dsw-alias-label-tertiary,#999);
5524
+ white-space:nowrap;font-variant-numeric:tabular-nums;
5525
+ }
5526
+ /* A quiet pulse is the whole signal that the turn is still moving: the label's
5527
+ second counter also advances, but at one tick a second it is easy to miss. */
5528
+ .dsm-workbuddy-cli-live-dot{
5529
+ width:6px;height:6px;border-radius:50%;flex:0 0 auto;
5530
+ background:var(--dsw-alias-brand-primary,#5686fe);
5531
+ animation:dsm-workbuddy-cli-live-pulse 1.6s ease-in-out infinite;
5532
+ }
5533
+ @keyframes dsm-workbuddy-cli-live-pulse{0%,100%{opacity:.3}50%{opacity:1}}
5534
+ /* Respect the OS setting: a blinking dot is decoration, the numbers are the
5535
+ information, and they keep updating with the animation off. */
5536
+ @media (prefers-reduced-motion:reduce){
5537
+ .dsm-workbuddy-cli-live-dot{animation:none;opacity:.8}
5538
+ }
5228
5539
  `;
5229
5540
  //#endregion
5230
5541
  //#region src/client/searched-paths.ts
@@ -6871,6 +7182,18 @@ window.__ModuleLoader__.load({
6871
7182
  },
6872
7183
  children: t("row.panelModels")
6873
7184
  }),
7185
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7186
+ type: "button",
7187
+ role: "tab",
7188
+ id: "dsm-workbuddy-tab-capabilities",
7189
+ "aria-controls": "dsm-workbuddy-panel-capabilities",
7190
+ "aria-selected": panel === "capabilities",
7191
+ className: `dsm-workbuddy-panel-tab${panel === "capabilities" ? " dsm-workbuddy-panel-tab-active" : ""}`,
7192
+ onClick: () => {
7193
+ setPanel("capabilities");
7194
+ },
7195
+ children: t("row.panelCapabilities")
7196
+ }),
6874
7197
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6875
7198
  type: "button",
6876
7199
  role: "tab",
@@ -6904,7 +7227,6 @@ window.__ModuleLoader__.load({
6904
7227
  className: "dsm-workbuddy-tab-off-notice",
6905
7228
  children: t("row.tabOffNotice")
6906
7229
  }) : null,
6907
- creditPreferences,
6908
7230
  status.status === "error" || status.status === "signed-in" && status.creditsError !== void 0 ? status.status === "signed-in" && status.credentialRejected === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6909
7231
  className: "dsm-workbuddy-usage-hint",
6910
7232
  children: t("row.reloginHint")
@@ -7077,11 +7399,7 @@ window.__ModuleLoader__.load({
7077
7399
  className: "dsm-workbuddy-model-name",
7078
7400
  children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7079
7401
  className: "dsm-workbuddy-model-name-rate",
7080
- children: [
7081
- "(",
7082
- model.creditMultiplier.toFixed(2),
7083
- "x)"
7084
- ]
7402
+ children: ["x", model.creditMultiplier.toFixed(2)]
7085
7403
  })]
7086
7404
  }),
7087
7405
  promoChip(model),
@@ -7259,6 +7577,17 @@ window.__ModuleLoader__.load({
7259
7577
  })
7260
7578
  })
7261
7579
  }) : null,
7580
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7581
+ className: "dsm-workbuddy-panel",
7582
+ role: "tabpanel",
7583
+ id: "dsm-workbuddy-panel-capabilities",
7584
+ "aria-labelledby": "dsm-workbuddy-tab-capabilities",
7585
+ hidden: signedIn && panel !== "capabilities",
7586
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7587
+ className: "dsm-workbuddy-panel-note",
7588
+ children: t("row.panelCapabilitiesHint")
7589
+ }), creditPreferences]
7590
+ }),
7262
7591
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7263
7592
  className: "dsm-workbuddy-panel",
7264
7593
  role: "tabpanel",
@@ -8273,27 +8602,46 @@ window.__ModuleLoader__.load({
8273
8602
  //#endregion
8274
8603
  //#region src/client/WorkBuddyModelLabel.tsx
8275
8604
  /**
8276
- * The load-level badge in the HOST's model picker.
8605
+ * The HOST's model picker label: the catalog name, the upstream's promotion
8606
+ * badge, and the load-level icon.
8277
8607
  *
8278
8608
  * WHY A SLOT. The host renders the model name as a plain string, so the only
8279
8609
  * thing a provider can put there is a character — and a character's colour is
8280
8610
  * the emoji font's business, not ours. The host therefore offers exactly one
8281
8611
  * seam for this: the `conversation.input.model.label` slot, whose contract is
8282
8612
  * "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.
8613
+ * while the picker keeps owning clicks, focus, check marks and the accessibility
8614
+ * text. The shipped example is the official DeepSeek "1017" release badge — a
8615
+ * coloured marker next to a model name — which is the same job.
8616
+ *
8617
+ * WHY THE BADGE BELONGS HERE AT ALL. The picker is where the choice is made,
8618
+ * and a time-boxed promotion is a choice criterion: "free until the 25th" and
8619
+ * "half price at night" both change which row you click. The card already drew
8620
+ * these badges, but the card is the settings surface — by the time you read
8621
+ * them there you have already picked. The badge is not decoration added here;
8622
+ * it is the same upstream fact, moved to the place where it can still change a
8623
+ * decision.
8286
8624
  *
8287
8625
  * WHY A SEPARATE FETCH. The slot is rendered by the host, outside the plugin's
8288
8626
  * 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.
8627
+ * for the snapshot itself — and asks for the SAME route the card polls
8628
+ * (`WORKBUDDY_USAGE_PATH`), which carries both the load levels and each model's
8629
+ * unresolved promotions. One request, both facts. The answer is cached briefly:
8630
+ * a picker that re-fetched on every re-render would be wasteful, and a level
8631
+ * that is a few seconds old is still true.
8632
+ *
8633
+ * WHY THE CLOCK IS RE-READ ON EVERY RENDER. A promotion's badge is a function
8634
+ * of the clock (a night window opens and closes; a campaign expires). Resolving
8635
+ * it once at fetch time and holding it would leave "夜间折扣" on the composer
8636
+ * button the next morning — the exact overstatement `src/promo.ts` refuses to
8637
+ * make. The snapshot is therefore fetched once and the BADGE is resolved at
8638
+ * render time; a shared one-minute ticker re-renders the mounted labels so a
8639
+ * window edge is picked up without one timer per model row.
8292
8640
  *
8293
8641
  * WHY THE HOST STILL OWNS THE NAME. The contract says to keep `model.name` as
8294
8642
  * 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.
8643
+ * name first — it never replaces it, and it renders nothing at all for a
8644
+ * provider that is not ours.
8297
8645
  */
8298
8646
  /** The provider ids this plugin registers, and the region each one serves. */
8299
8647
  const PROVIDER_REGIONS = /* @__PURE__ */ new Map([["workbuddy", "cn"], ["workbuddy-global", "global"]]);
@@ -8308,70 +8656,381 @@ window.__ModuleLoader__.load({
8308
8656
  function regionOfProvider(providerId) {
8309
8657
  return PROVIDER_REGIONS.get(providerId);
8310
8658
  }
8311
- /** How long a fetched level set is trusted, in milliseconds. */
8659
+ /** How long a fetched snapshot is trusted, in milliseconds. */
8312
8660
  const CACHE_MS = 6e4;
8313
- /** The levels per region, by model id, plus when each set was read. */
8661
+ /** How often a mounted label re-reads the clock, in milliseconds. */
8662
+ const TICK_MS = 6e4;
8663
+ /** The snapshot per region, plus when it was read. */
8314
8664
  const caches = /* @__PURE__ */ new Map();
8315
8665
  /**
8316
- * The levels, read from the Host once and reused briefly.
8666
+ * The snapshot, read from the Host once and reused briefly.
8317
8667
  *
8318
8668
  * `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.
8669
+ * guessing, because a badge that claims a level or a promotion the upstream
8670
+ * never stated is worse than no badge.
8321
8671
  */
8322
- async function levelsNow(region) {
8672
+ async function statusNow(region) {
8323
8673
  const cached = caches.get(region);
8324
- if (cached !== void 0 && Date.now() - cached.at < CACHE_MS) return cached.levels;
8674
+ if (cached !== void 0 && Date.now() - cached.at < CACHE_MS) return cached.snapshot;
8325
8675
  try {
8326
8676
  const response = await fetch(withWorkBuddyRegion(WORKBUDDY_USAGE_PATH, region), {
8327
8677
  headers: { accept: "application/json" },
8328
8678
  credentials: "same-origin"
8329
8679
  });
8330
8680
  if (!response.ok) return void 0;
8331
- const levels = (await response.json()).busyLevels;
8332
- if (levels === void 0 || levels === null) return void 0;
8681
+ const body = await response.json();
8682
+ const promos = {};
8683
+ for (const model of body.models ?? []) if (model.promos !== void 0) promos[model.id] = model.promos;
8684
+ const snapshot = {
8685
+ levels: body.busyLevels ?? {},
8686
+ promos
8687
+ };
8333
8688
  caches.set(region, {
8334
- levels,
8689
+ snapshot,
8335
8690
  at: Date.now()
8336
8691
  });
8337
- return levels;
8692
+ return snapshot;
8338
8693
  } catch {
8339
8694
  return;
8340
8695
  }
8341
8696
  }
8697
+ /** One shared minute ticker, so N mounted labels cost one timer, not N. */
8698
+ const minuteTicker = {
8699
+ timer: void 0,
8700
+ listeners: /* @__PURE__ */ new Set()
8701
+ };
8702
+ /**
8703
+ * Re-render the caller once a minute while it is mounted.
8704
+ *
8705
+ * @param listener - The callback to run at each tick.
8706
+ * @returns The unsubscribe function, which stops the shared timer when the last
8707
+ * listener leaves.
8708
+ */
8709
+ function subscribeMinute(listener) {
8710
+ minuteTicker.listeners.add(listener);
8711
+ if (minuteTicker.timer === void 0) {
8712
+ minuteTicker.timer = setInterval(() => {
8713
+ for (const each of [...minuteTicker.listeners]) each();
8714
+ }, TICK_MS);
8715
+ minuteTicker.timer.unref?.();
8716
+ }
8717
+ return () => {
8718
+ minuteTicker.listeners.delete(listener);
8719
+ if (minuteTicker.listeners.size === 0 && minuteTicker.timer !== void 0) {
8720
+ clearInterval(minuteTicker.timer);
8721
+ minuteTicker.timer = void 0;
8722
+ }
8723
+ };
8724
+ }
8725
+ /**
8726
+ * The upstream's promotion badge, as the picker draws it.
8727
+ *
8728
+ * Reuses the card's chip class so the two surfaces cannot drift apart, and adds
8729
+ * one modifier for the two things a menu row changes: the chip must fit a row
8730
+ * that is one line wide (see `.dsm-workbuddy-model-label-promo` in the card's
8731
+ * stylesheet), and it is the only place the full tooltip is available.
8732
+ *
8733
+ * @param props - The resolved promotion, and whether space is tight.
8734
+ * @returns The chip.
8735
+ */
8736
+ function PromoBadge(props) {
8737
+ const { promo, compact } = props;
8738
+ const language = uiLanguage();
8739
+ const label = compact ? promoBadgeShortLabel(promo, language) : promoBadgeLabel(promo, language);
8740
+ const color = promoBadgeColor(promo);
8741
+ const tooltip = promoBadgeTooltip(promo, language);
8742
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8743
+ className: "dsm-workbuddy-model-promo dsm-workbuddy-model-label-promo",
8744
+ title: tooltip === "" ? void 0 : tooltip,
8745
+ style: color === void 0 ? void 0 : { "--dsm-workbuddy-promo-color": color },
8746
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8747
+ className: "dsm-workbuddy-model-label-promo-text",
8748
+ children: label
8749
+ })
8750
+ });
8751
+ }
8342
8752
  /**
8343
8753
  * The label for one model row in the host's picker.
8344
8754
  *
8345
8755
  * @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.
8756
+ * @returns The catalog name, the promotion chip when one holds, and the load
8757
+ * badge when the probe reported one.
8347
8758
  */
8348
8759
  function WorkBuddyModelLabel(props) {
8349
- const { provider, model } = props;
8760
+ const { provider, model, placement } = props;
8350
8761
  const region = regionOfProvider(provider.id);
8351
8762
  if (region === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name });
8352
8763
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WorkBuddyModelLabelInner, {
8353
8764
  modelId: model.id,
8354
8765
  name: model.name,
8355
- region
8766
+ region,
8767
+ compact: placement !== "trigger"
8356
8768
  });
8357
8769
  }
8358
- /** The stateful half: fetches the levels, then renders the badge. */
8770
+ /** The stateful half: fetches the snapshot, then renders what holds right now. */
8359
8771
  function WorkBuddyModelLabelInner(props) {
8360
- const { modelId, name, region } = props;
8361
- const [level, setLevel] = (0, react.useState)(void 0);
8772
+ const { modelId, name, region, compact } = props;
8773
+ const [snapshot, setSnapshot] = (0, react.useState)(void 0);
8774
+ const [, setTick] = (0, react.useState)(0);
8362
8775
  (0, react.useEffect)(() => {
8363
8776
  let live = true;
8364
- levelsNow(region).then((levels) => {
8365
- if (!live || levels === void 0) return;
8366
- setLevel(levels[modelId]);
8777
+ statusNow(region).then((next) => {
8778
+ if (!live || next === void 0) return;
8779
+ setSnapshot(next);
8367
8780
  });
8368
8781
  return () => {
8369
8782
  live = false;
8370
8783
  };
8371
8784
  }, [modelId, region]);
8785
+ (0, react.useEffect)(() => subscribeMinute(() => setTick((count) => count + 1)), []);
8786
+ const level = snapshot?.levels[modelId];
8787
+ const promo = snapshot === void 0 ? void 0 : selectPromoBadge(snapshot.promos[modelId], Date.now());
8372
8788
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8373
8789
  className: "dsm-workbuddy-model-label",
8374
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }), level === void 0 ? null : busyIcon(level, "zh")]
8790
+ children: [
8791
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }),
8792
+ promo === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PromoBadge, {
8793
+ promo,
8794
+ compact
8795
+ }),
8796
+ level === void 0 ? null : busyIcon(level, "zh")
8797
+ ]
8798
+ });
8799
+ }
8800
+ //#endregion
8801
+ //#region src/client/CodebuddyLive.tsx
8802
+ /**
8803
+ * The composer pill that says what the CodeBuddy CLI route is doing RIGHT NOW.
8804
+ *
8805
+ * WHY THIS EXISTS. A bridged turn is a spawned agent: minutes of reading files
8806
+ * and running commands, then one answer. The Host tool contract has no progress
8807
+ * channel — `execute()` returns when it returns — so without this pill the user
8808
+ * watches a spinner with no way to tell "still working" from "hung". The pill is
8809
+ * fed by the plugin's own read-only route (`WORKBUDDY_CLI_LIVE_PATH`), which
8810
+ * serializes the host-side live board (see `../cli-bridge/live.ts`).
8811
+ *
8812
+ * WHY IT RENDERS NOTHING WHEN IDLE. This is the ambient dock under the composer,
8813
+ * a row the shell already fills with round/token pills. A permanent "no turn
8814
+ * running" line would be a claim about a feature most users never switch on
8815
+ * (`cliBridge.enabled` defaults to false), so the empty state is `null`: the row
8816
+ * is untouched until there is something true to say.
8817
+ *
8818
+ * WHY IT ONLY DESCRIBES THIS SESSION'S TURN. The board is host-wide: one route
8819
+ * answers for every session in the process, so an unfiltered pill would show the
8820
+ * turn another tab started — a line about a directory the user is not looking at,
8821
+ * sitting under the composer they are typing in. The seat is declared
8822
+ * `scope: 'session'` (`conversation.composer.dock`), so the framework hands the
8823
+ * registration its `sessionId`; the pill keeps the rows that belong to it.
8824
+ *
8825
+ * WHY NOT THE PROVIDER GATE THE CREDIT READOUT USES. `ComposerCredits` renders
8826
+ * only while the session's model is a WorkBuddy provider, because it reports that
8827
+ * provider's quota. This pill reports a spawned CLI process, and the CLI route is
8828
+ * deliberately model-independent: the whole point is that it works from any
8829
+ * session, including one running DeepSeek. Gating it on the provider would hide
8830
+ * the readout exactly when the feature is being used the way it was designed.
8831
+ * `cliBridge.enabled` needs no gate either — with the bridge off no turn can
8832
+ * begin, so the board is empty and the pill renders nothing.
8833
+ *
8834
+ * WHY POLLING, AND WHY THE INTERVAL IS 5s. The Host has no push channel to a
8835
+ * session component, so a poll is the only option. Five seconds is the slowest
8836
+ * cadence at which a turn that just started still feels immediate; the elapsed
8837
+ * counter between polls is advanced locally from `startedAt` (a 1s tick that
8838
+ * costs no request), so the number moves every second without the network
8839
+ * traffic moving with it. Concurrent sessions share ONE in-flight request per
8840
+ * interval (see {@link readLive}), which is what keeps N open tabs from becoming
8841
+ * N polls per tick.
8842
+ *
8843
+ * WHY THE CLOCK OFFSET IS APPLIED. `startedAt` is the HOST's clock. A browser
8844
+ * whose clock is a minute behind would compute a negative age and the pill would
8845
+ * print nonsense. The document ships `serverTime`, so the offset is measured at
8846
+ * each successful read and applied to the local clock instead of trusting the
8847
+ * two machines to agree.
8848
+ *
8849
+ * @module dsh-connect-workbuddy/client/CodebuddyLive
8850
+ */
8851
+ /**
8852
+ * Inject the dock stylesheet on module load, once.
8853
+ *
8854
+ * `ComposerCredits` injects the same sheet for its own classes, and in the
8855
+ * shipped bundle both modules are static imports of the client entry, so in
8856
+ * practice the sheet is already there. This block is not redundant: it makes the
8857
+ * pill independent of whether the credit readout is mounted at all (the gate can
8858
+ * hide it for a non-WorkBuddy model), and the `data-plugin-css` guard means the
8859
+ * two never produce a second tag.
8860
+ */
8861
+ if (typeof document !== "undefined") {
8862
+ const cssId = "dsh-connect-workbuddy/composer-credits.css";
8863
+ if (!document.querySelector(`style[data-plugin-css="${cssId}"]`)) {
8864
+ const styleTag = document.createElement("style");
8865
+ styleTag.dataset.plugin = "dsh-connect-workbuddy";
8866
+ styleTag.dataset.pluginCss = cssId;
8867
+ styleTag.textContent = WORKBUDDY_COMPOSER_CSS;
8868
+ document.head.appendChild(styleTag);
8869
+ }
8870
+ }
8871
+ /** How often the route is asked. See the module comment for why 5s. */
8872
+ const CLI_LIVE_POLL_MS = 5e3;
8873
+ /**
8874
+ * The last document any component in this page saw, shared across sessions.
8875
+ *
8876
+ * A module-level cache is safe here because the document is host state, not
8877
+ * component state: every session polls the same board, so one request serves
8878
+ * them all. `inflight` collapses the case where several components mount in the
8879
+ * same tick — without it, opening three sessions at once would fire three
8880
+ * requests and each would overwrite the others' result.
8881
+ */
8882
+ let cached;
8883
+ let inflight;
8884
+ /**
8885
+ * Accept a document only if every number the pill does arithmetic on is a real
8886
+ * number.
8887
+ *
8888
+ * The route is our own, so this is not defence against an attacker — it is
8889
+ * defence against a HALF-AGREED wire: a field renamed on the host side, an
8890
+ * `undefined` that `JSON.stringify` drops, or a future route that answers a
8891
+ * different shape. Without the check `serverTime` becomes `NaN`, the clock
8892
+ * offset becomes `NaN`, and the pill renders `NaNs` — a number that is wrong in
8893
+ * the one place a user is looking for reassurance. Dropping the whole document
8894
+ * degrades to "no turn", which is the honest answer when the wire is unreadable.
8895
+ */
8896
+ function acceptDocument(value) {
8897
+ if (typeof value !== "object" || value === null) return void 0;
8898
+ const candidate = value;
8899
+ if (!Array.isArray(candidate.turns)) return void 0;
8900
+ if (typeof candidate.serverTime !== "number" || !Number.isFinite(candidate.serverTime)) return void 0;
8901
+ return {
8902
+ turns: candidate.turns.filter((turn) => {
8903
+ if (typeof turn !== "object" || turn === null) return false;
8904
+ const row = turn;
8905
+ return typeof row.startedAt === "number" && Number.isFinite(row.startedAt) && typeof row.toolCalls === "number" && Number.isFinite(row.toolCalls);
8906
+ }),
8907
+ serverTime: candidate.serverTime
8908
+ };
8909
+ }
8910
+ /** Read the board, reusing a recent document or a request already in flight. */
8911
+ async function readLive(fetchImpl, now = Date.now(), cacheMs = CLI_LIVE_POLL_MS) {
8912
+ if (cached !== void 0 && now - cached.at < cacheMs) return cached.document;
8913
+ if (inflight !== void 0) return inflight;
8914
+ inflight = (async () => {
8915
+ try {
8916
+ const response = await fetchImpl(WORKBUDDY_CLI_LIVE_PATH, { headers: { accept: "application/json" } });
8917
+ if (!response.ok) return void 0;
8918
+ const document = acceptDocument(await response.json());
8919
+ if (document === void 0) return void 0;
8920
+ cached = {
8921
+ at: Date.now(),
8922
+ document
8923
+ };
8924
+ return document;
8925
+ } catch {
8926
+ return;
8927
+ } finally {
8928
+ inflight = void 0;
8929
+ }
8930
+ })();
8931
+ return inflight;
8932
+ }
8933
+ /**
8934
+ * The text of one pill.
8935
+ *
8936
+ * Split out so the format is testable without a DOM, and so the same function
8937
+ * produces the visible label and the accessible one.
8938
+ */
8939
+ function liveLabel(turn, elapsedMs, t) {
8940
+ const seconds = Math.max(0, Math.round(elapsedMs / 1e3));
8941
+ const parts = [t("cliLive.running"), t("cliLive.toolCalls", { count: turn.toolCalls })];
8942
+ if (turn.phase === "starting") parts.push(t("cliLive.starting"));
8943
+ else if (turn.phase === "finishing") parts.push(t("cliLive.finishing"));
8944
+ else parts.push(t("cliLive.elapsed", { seconds }));
8945
+ if (turn.lastTool !== "") parts.push(t("cliLive.lastTool", { tool: turn.lastTool }));
8946
+ return parts.join(" · ");
8947
+ }
8948
+ /**
8949
+ * Keep the rows this pill is allowed to describe.
8950
+ *
8951
+ * A row with an empty `sessionId` is one the host could not attribute (the tool
8952
+ * ran without an agent on the execution object). Hiding those would make the pill
8953
+ * silently useless on such a host, so they are kept: an unattributed row is
8954
+ * evidence of real work, and the alternative is a readout that never appears.
8955
+ */
8956
+ function turnsForSession(turns, sessionId) {
8957
+ if (sessionId === void 0 || sessionId === "") return turns;
8958
+ return turns.filter((turn) => turn.sessionId === "" || turn.sessionId === sessionId);
8959
+ }
8960
+ /**
8961
+ * The pill itself.
8962
+ *
8963
+ * Shows the NEWEST turn (the one the last click started) and appends `+N` when
8964
+ * several are live, because the dock is a single line and a queue listing would
8965
+ * wrap. The count is the whole point of the `+N`: it tells the user there is
8966
+ * more work running than the line describes.
8967
+ */
8968
+ function CodebuddyLive(props) {
8969
+ const { t } = props;
8970
+ const fetchImpl = props.fetchImpl ?? (typeof fetch === "function" ? fetch : void 0);
8971
+ const pollMs = props.pollMs ?? 5e3;
8972
+ const tickMs = props.tickMs ?? 1e3;
8973
+ const [document, setDocument] = (0, react.useState)(void 0);
8974
+ const [, setTick] = (0, react.useState)(0);
8975
+ const offsetRef = (0, react.useRef)(0);
8976
+ const offsetForRef = (0, react.useRef)(void 0);
8977
+ const failuresRef = (0, react.useRef)(0);
8978
+ (0, react.useEffect)(() => {
8979
+ if (fetchImpl === void 0) return void 0;
8980
+ let live = true;
8981
+ const read = () => {
8982
+ readLive(fetchImpl, Date.now(), pollMs).then((next) => {
8983
+ if (!live) return;
8984
+ if (next === void 0) {
8985
+ failuresRef.current += 1;
8986
+ if (failuresRef.current >= 3) setDocument(void 0);
8987
+ return;
8988
+ }
8989
+ failuresRef.current = 0;
8990
+ if (next.serverTime !== offsetForRef.current) {
8991
+ offsetForRef.current = next.serverTime;
8992
+ offsetRef.current = next.serverTime - Date.now();
8993
+ }
8994
+ setDocument(next);
8995
+ });
8996
+ };
8997
+ read();
8998
+ const timer = setInterval(read, pollMs);
8999
+ return () => {
9000
+ live = false;
9001
+ clearInterval(timer);
9002
+ };
9003
+ }, [fetchImpl, pollMs]);
9004
+ const turns = turnsForSession(document?.turns ?? [], props.sessionId);
9005
+ const active = turns.length > 0;
9006
+ (0, react.useEffect)(() => {
9007
+ if (!active) return void 0;
9008
+ const timer = setInterval(() => {
9009
+ setTick((value) => value + 1);
9010
+ }, tickMs);
9011
+ return () => {
9012
+ clearInterval(timer);
9013
+ };
9014
+ }, [active, tickMs]);
9015
+ if (!active) return null;
9016
+ const turn = turns[turns.length - 1];
9017
+ const label = liveLabel(turn, Math.max(0, Date.now() + offsetRef.current - turn.startedAt), t);
9018
+ const extra = turns.length > 1 ? ` +${String(turns.length - 1)}` : "";
9019
+ const dirTail = turn.workingDir.replace(/[\\/]+$/u, "").split(/[\\/]/u).pop() ?? "";
9020
+ const title = dirTail === "" ? `${label}${extra}` : `${label}${extra} · ${dirTail}`;
9021
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
9022
+ role: "status",
9023
+ "aria-label": t("cliLive.aria", { detail: label }),
9024
+ title,
9025
+ className: "dsm-workbuddy-cli-live",
9026
+ children: [
9027
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
9028
+ "aria-hidden": "true",
9029
+ className: "dsm-workbuddy-cli-live-dot"
9030
+ }),
9031
+ label,
9032
+ extra
9033
+ ]
8375
9034
  });
8376
9035
  }
8377
9036
  //#endregion
@@ -8538,6 +9197,8 @@ window.__ModuleLoader__.load({
8538
9197
  "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.",
8539
9198
  "row.panelAccounts": "Accounts",
8540
9199
  "row.panelModels": "Models",
9200
+ "row.panelCapabilities": "Capabilities",
9201
+ "row.panelCapabilitiesHint": "What the plugin may do on your behalf. Each switch takes effect on the click — there is no Save button in this section.",
8541
9202
  "row.panelDiag": "Diagnostics",
8542
9203
  "row.panelAria": "Card sections",
8543
9204
  "row.diagHint": "What this card actually read, so a report can quote it. Nothing here changes a setting.",
@@ -8662,9 +9323,9 @@ window.__ModuleLoader__.load({
8662
9323
  "row.poolNoCandidateHint": "Members are rate-limited, out of credits, rejected, or unreachable. Rate limits, drained quotas and transient failures return to the pool on their own — at the time the upstream states, or after a 30-minute fallback. Only a rejected credential needs a new sign-in.",
8663
9324
  "row.poolModelWideBusy": "Every member is blocked on the same model — this is the model, not your accounts.",
8664
9325
  "row.poolModelWideBusyHint": "Switching accounts cannot help: the limit follows the model, so a different account hits the same wall. Wait about a minute, or switch to a less busy model in this session.",
8665
- "row.poolCheckinAll": "Check in all accounts",
9326
+ "row.poolCheckinAll": "Check in selected",
8666
9327
  "row.poolCheckingIn": "Checking in…",
8667
- "row.poolTestAll": "Test all accounts",
9328
+ "row.poolTestAll": "Test selected",
8668
9329
  "row.poolTesting": "Testing…",
8669
9330
  "row.poolBusyHint": "Running one account at a time…",
8670
9331
  "row.poolTargetAuto": "Auto",
@@ -8682,9 +9343,9 @@ window.__ModuleLoader__.load({
8682
9343
  "row.poolTargetNoneHint": "The domestic catalog only reveals its multipliers after a model refresh. Rather than quietly spending credits on a paid model, testing stays off until one appears — refresh the models, or pick a target model yourself.",
8683
9344
  "row.poolColumnAccount": "Account",
8684
9345
  "row.poolColumnCredits": "Credits",
8685
- "row.poolColumnProbe": "Target model",
9346
+ "row.poolColumnProbe": "Status",
8686
9347
  "row.poolColumnCheckin": "Check-in",
8687
- "row.poolNeverTested": "Not tested yet",
9348
+ "row.poolNeverTested": "Untested",
8688
9349
  "row.poolTestedAt": "Tested {at}",
8689
9350
  "row.poolCheckedIn": "Checked in",
8690
9351
  "row.poolNotCheckedIn": "Not checked in",
@@ -8724,15 +9385,16 @@ window.__ModuleLoader__.load({
8724
9385
  "row.poolProbeSourceUnknown": "source unknown",
8725
9386
  "row.poolProbeModel": "model {model}",
8726
9387
  "row.poolProbeModelUnknown": "model not recorded",
8727
- "row.poolSettingsTitle": "Account pool settings",
9388
+ "row.poolSettingsTitle": "More settings",
8728
9389
  "row.poolSettingsHint": "These are preferences: they take effect when you save (same as the model list). Test results are written automatically and are not affected by this button.",
8729
9390
  "row.poolEnabled": "Enable the account pool",
8730
- "row.poolEnabledHint": "While on, this region's pool decides who serves every request (usable first, then credits, soonest-expiring, freshest credential) and retries on another account when one fails. While off, the plugin does not pick or switch: the account you selected serves every request and a failure is reported as-is. Check-in and testing are MANUAL actions and stay available either way — the switch only controls whether the plugin decides by itself.",
9391
+ "row.poolEnabledHint": "On: the pool picks the account for every request and moves to the next one when it fails. Off: the account you select serves every request, and a failure is reported as it is.",
9392
+ "row.poolModeAuto": "picking automatically",
9393
+ "row.poolModeManual": "you pick",
8731
9394
  "row.poolManualAccountLabel": "Account in use",
8732
- "row.poolManualAccountHint": "Which account serves this region while the pool is off. Turn the pool on and the ranking picks instead.",
8733
9395
  "row.poolManualAccountNone": "No account in effect",
8734
- "row.poolTargetLabel": "Target test model",
8735
- "row.poolTargetHint": "Auto (default) picks a zero-multiplier model from this region's catalog, preferring the largest context window. The two regions can pick different models. A manual choice always wins.",
9396
+ "row.poolTargetLabel": "Test model (whole pool)",
9397
+ "row.poolTargetHint": "One model for every account here — the test measures each row with it. Auto picks a zero-multiplier model from this region's catalog, preferring the largest context window; a manual choice wins.",
8736
9398
  "row.poolTargetAutoOption": "Auto — pick a free model",
8737
9399
  "row.poolProbeSizeLabel": "Test request size",
8738
9400
  "row.poolProbeSizeHint": "How much text a test sends. The upstream refuses a request once it grows past roughly 20K–30K tokens, so a test smaller than your real conversations answers \"usable\" for an account that would refuse them. Bigger tests are more truthful but cost more credit each.",
@@ -8760,7 +9422,7 @@ window.__ModuleLoader__.load({
8760
9422
  "row.poolLogTestRowMalformed": "{accountName}: the host returned no result for this account",
8761
9423
  "row.poolRegionNote": "The domestic and international pools are fully independent: accounts, switches, intervals, and target models never affect each other.",
8762
9424
  "row.poolMembersTitle": "Accounts in this pool",
8763
- "row.poolMembersHint": "Only checked accounts are checked in, tested, and used for rotation. Nothing is selected by default — check the ones you want, then save.",
9425
+ "row.poolMembersHint": "Only checked accounts rotate, get checked in, and get tested.",
8764
9426
  "row.poolSelectAll": "Select all",
8765
9427
  "row.poolSelectNone": "Clear all",
8766
9428
  "row.poolSelectedCount": "{count} of {total} selected",
@@ -8778,16 +9440,17 @@ window.__ModuleLoader__.load({
8778
9440
  "row.poolOrderBadgeNote": "The badge shows the position you SAVED, not the live ranking. An account can read \"No. 2\" while being benched for a rate limit — the number is your saved preference, and the pool skips accounts it cannot bill right now.",
8779
9441
  "row.poolOrderRulesToggle": "Show the full ordering rule",
8780
9442
  "row.poolNoneSelected": "No account is checked into this pool.",
8781
- "row.poolNoneSelectedHint": "Check at least one account above and save. Until then both buttons stay disabled, because a pool with no members has nothing to run on.",
9443
+ "row.poolNoneSelectedHint": "Check at least one account to enable check-in and test.",
8782
9444
  "row.poolGhostMembers": "{count} pool member(s) are no longer signed in on this machine, so they are not counted and not used: {names}. Remove any you no longer need below.",
8783
9445
  "row.poolGhostRemove": "Remove {account} from this pool",
8784
9446
  "row.poolGhostRemoveNamed": "Remove {account} from this pool. This account is no longer signed in on this machine.",
8785
9447
  "row.poolGhostRemoveUnnamed": "Remove {account} from this pool. No name was ever recorded for this account, so what you see is the start of its id.",
8786
9448
  "row.poolSelectAria": "Include {accountName} in this region's account pool",
8787
- "row.poolMember": "In pool",
8788
9449
  "row.poolCurrentBadge": "In use",
8789
9450
  "row.poolCurrentHeader": "In use: {account}",
8790
9451
  "row.poolNotMember": "Not in pool",
9452
+ "row.poolNotMemberHint": "Not in the pool: rotation skips it, and batch check-in and test leave it alone.",
9453
+ "row.poolServingOutside": "“{account}” is serving right now but is not in this pool: rotation will not pick it, and batch check-in and test will not touch it.",
8791
9454
  "row.poolUnsavedMembers": "Selection changed — save to apply",
8792
9455
  "row.poolRankLabel": "No. {rank}",
8793
9456
  "row.poolRankManualTitle": "You placed this account at position {rank} in the pool manually.",
@@ -8845,7 +9508,15 @@ window.__ModuleLoader__.load({
8845
9508
  "sidebar.refreshing": "Refreshing…",
8846
9509
  "sidebar.lastRefresh": "Last refreshed {time}",
8847
9510
  "sidebar.refreshFailed": "Refresh failed",
8848
- "sidebar.signedOut": "Not signed in"
9511
+ "sidebar.signedOut": "Not signed in",
9512
+ "cliLive.running": "CodeBuddy",
9513
+ "cliLive.toolCalls": "{count} tools",
9514
+ "cliLive.elapsed": "{seconds}s",
9515
+ "cliLive.starting": "starting…",
9516
+ "cliLive.finishing": "finishing…",
9517
+ "cliLive.lastTool": "last: {tool}",
9518
+ "cliLive.chars": "{count} chars",
9519
+ "cliLive.aria": "A CodeBuddy turn is running: {detail}"
8849
9520
  };
8850
9521
  const zh = {
8851
9522
  "row.title": "接入使用 WorkBuddy 积分与模型(dsh-connect-workbuddy)",
@@ -8904,6 +9575,8 @@ window.__ModuleLoader__.load({
8904
9575
  "row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方列表中重新选择一个当前账号。重新登录桌面端 App 无法修复此问题。",
8905
9576
  "row.panelAccounts": "账号",
8906
9577
  "row.panelModels": "模型",
9578
+ "row.panelCapabilities": "能力",
9579
+ "row.panelCapabilitiesHint": "插件可以替你做什么。这些开关点一下立即生效 —— 这一区里没有保存按钮。",
8907
9580
  "row.panelDiag": "诊断",
8908
9581
  "row.panelAria": "卡片分区",
8909
9582
  "row.diagHint": "这张卡片实际读到了什么,方便贴进问题反馈。这里没有任何会改动设置的控件。",
@@ -9028,9 +9701,9 @@ window.__ModuleLoader__.load({
9028
9701
  "row.poolNoCandidateHint": "成员要么在限流冷却中、要么积分耗尽、要么凭据被拒、要么上游无响应。限流、积分耗尽与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要重新登录。",
9029
9702
  "row.poolModelWideBusy": "所有成员都卡在同一个模型上 —— 这是模型的问题,不是你的账号。",
9030
9703
  "row.poolModelWideBusyHint": "换账号解决不了:限制跟随模型,换一个账号撞的是同一堵墙。等大约 1 分钟,或把这个会话切到负载较低的模型。",
9031
- "row.poolCheckinAll": "一键签到所有账号",
9704
+ "row.poolCheckinAll": "签到勾选的账号",
9032
9705
  "row.poolCheckingIn": "签到中…",
9033
- "row.poolTestAll": "一键测试所有账号",
9706
+ "row.poolTestAll": "测试勾选的账号",
9034
9707
  "row.poolTesting": "测试中…",
9035
9708
  "row.poolBusyHint": "正在逐个账号执行…",
9036
9709
  "row.poolTargetAuto": "自动",
@@ -9048,9 +9721,9 @@ window.__ModuleLoader__.load({
9048
9721
  "row.poolTargetNoneHint": "国内版的倍率要等模型目录刷新后才会出现。为了不让你在不知情的情况下花掉积分,插件不会自动改用收费模型——请先刷新模型目录,或自己指定一个目标模型。",
9049
9722
  "row.poolColumnAccount": "账号",
9050
9723
  "row.poolColumnCredits": "积分",
9051
- "row.poolColumnProbe": "目标模型",
9724
+ "row.poolColumnProbe": "状态",
9052
9725
  "row.poolColumnCheckin": "签到",
9053
- "row.poolNeverTested": "尚未测试",
9726
+ "row.poolNeverTested": "未测试",
9054
9727
  "row.poolTestedAt": "测试于 {at}",
9055
9728
  "row.poolCheckedIn": "已签到",
9056
9729
  "row.poolNotCheckedIn": "未签到",
@@ -9090,15 +9763,16 @@ window.__ModuleLoader__.load({
9090
9763
  "row.poolProbeSourceUnknown": "来源未知",
9091
9764
  "row.poolProbeModel": "模型 {model}",
9092
9765
  "row.poolProbeModelUnknown": "未记录模型",
9093
- "row.poolSettingsTitle": "账号池设置",
9766
+ "row.poolSettingsTitle": "更多设置",
9094
9767
  "row.poolSettingsHint": "下面是偏好设置:点保存才生效(与「模型管理」一致)。测试结果由插件自动写入,不受这个保存按钮影响。",
9095
9768
  "row.poolEnabled": "启用账号池",
9096
- "row.poolEnabledHint": "开启后由本区域的账号池自动决定每一次请求用谁(可用优先,其次积分高、快过期的积分包、凭据更新的),失败时自动换下一个可用账号。关闭后插件不替你选、也不替你换:你选的账号服务每一次请求,失败就如实报错。签到与测试是手动动作,两种状态下都可用 —— 这个开关只管「插件要不要自己决定」。",
9769
+ "row.poolEnabledHint": "开启:由账号池为每次请求挑号,失败就换下一个。关闭:你选的账号服务每一次请求,失败如实报错。",
9770
+ "row.poolModeAuto": "自动挑号",
9771
+ "row.poolModeManual": "手动选号",
9097
9772
  "row.poolManualAccountLabel": "使用哪个账号",
9098
- "row.poolManualAccountHint": "账号池关闭时,由你在这里选定服务本区域的账号。开启账号池后改由排序自动决定。",
9099
9773
  "row.poolManualAccountNone": "当前没有生效的账号",
9100
- "row.poolTargetLabel": "目标测试模型",
9101
- "row.poolTargetHint": "「自动」(默认)会从本区域目录里挑一个倍率为 0 的免费模型,多个候选取上下文窗口最大的。国内版与国际版可能挑到不同的模型。手动指定优先于自动。",
9774
+ "row.poolTargetLabel": "测试用模型(全池统一)",
9775
+ "row.poolTargetHint": "全池共用这一个模型,测试就是拿它逐个去测。「自动」会从本区域目录里挑一个倍率为 0 的免费模型,多个候选取上下文窗口最大的;手动指定优先于自动。",
9102
9776
  "row.poolTargetAutoOption": "自动 —— 挑一个免费模型",
9103
9777
  "row.poolProbeSizeLabel": "测试请求体积",
9104
9778
  "row.poolProbeSizeHint": "测试时发送多少文本。上游在请求大约超过 2 万~3 万 token 时会开始拒绝,所以测试体积若小于你真实对话的体积,就会出现「测出可用、实际一发就被限」。体积越大测得越准,但每次测试消耗的积分也越多。",
@@ -9126,7 +9800,7 @@ window.__ModuleLoader__.load({
9126
9800
  "row.poolLogTestRowMalformed": "{accountName}:Host 未返回该账号的结果",
9127
9801
  "row.poolRegionNote": "国内版与国际版是完全独立的两个账号池:账号、开关、间隔、目标模型互不影响。",
9128
9802
  "row.poolMembersTitle": "本池包含的账号",
9129
- "row.poolMembersHint": "只有勾选的账号才会被签到、测试和轮换。默认一个都不勾 —— 请勾选你要的账号,然后保存。",
9803
+ "row.poolMembersHint": "只有勾选的账号参与轮换、签到与测试。",
9130
9804
  "row.poolSelectAll": "全选",
9131
9805
  "row.poolSelectNone": "全不选",
9132
9806
  "row.poolSelectedCount": "已选 {count} / {total}",
@@ -9143,17 +9817,18 @@ window.__ModuleLoader__.load({
9143
9817
  "row.poolOrderWhy": "为什么临期积分压过手动顺序:临期积分是马上要过期的钱,而手动顺序可能已经过时、也可能是手滑拖错了。如果让手动顺序赢,一次误拖就足以让积分白白过期。所以有 3 天内到期积分的账号,永远排在没有的账号前面 —— 哪怕你把它拖到了最后一位。",
9144
9818
  "row.poolOrderBadgeNote": "徽标显示的是你「保存」的位置,不是此刻的实际排序。一个账号可能写着「第 2 位」却因为被限流而暂时不被选中 —— 那个数字代表你的偏好,池子会跳过当下无法扣费的账号。",
9145
9819
  "row.poolOrderRulesToggle": "查看完整排序规则",
9146
- "row.poolNoneSelected": "这个池里还没有勾选任何账号。",
9147
- "row.poolNoneSelectedHint": "请在上方勾选至少一个账号并保存。在此之前两个按钮都不可用 —— 空池没有可执行的对象。",
9820
+ "row.poolNoneSelected": "还没有勾选任何账号。",
9821
+ "row.poolNoneSelectedHint": "勾选至少一个账号,才能签到与测试。",
9148
9822
  "row.poolGhostMembers": "有 {count} 个已选入本池的账号在本机已不再登录,因此不计入、也不会被使用:{names}。不需要的可在下方逐个移除。",
9149
9823
  "row.poolGhostRemove": "把 {account} 移出本池",
9150
9824
  "row.poolGhostRemoveNamed": "把 {account} 移出本池。该账号在本机已不再登录。",
9151
9825
  "row.poolGhostRemoveUnnamed": "把 {account} 移出本池。这个账号从未记录过名字,显示的是它 id 的开头几位。",
9152
9826
  "row.poolSelectAria": "把 {accountName} 加入本区域的账号池",
9153
- "row.poolMember": "已在池中",
9154
9827
  "row.poolCurrentBadge": "当前使用",
9155
9828
  "row.poolCurrentHeader": "当前使用:{account}",
9156
9829
  "row.poolNotMember": "未入池",
9830
+ "row.poolNotMemberHint": "不在池里:不参与轮换,也不进批量签到与测试。",
9831
+ "row.poolServingOutside": "「{account}」正在使用,但不在这个池里:轮换不会挑它,批量签到与测试也不会动它。",
9157
9832
  "row.poolUnsavedMembers": "勾选已改动 —— 保存后生效",
9158
9833
  "row.poolRankLabel": "第 {rank} 位",
9159
9834
  "row.poolRankManualTitle": "你在本池中手动把该账号排在第 {rank} 位。",
@@ -9211,7 +9886,15 @@ window.__ModuleLoader__.load({
9211
9886
  "sidebar.refreshing": "刷新中…",
9212
9887
  "sidebar.lastRefresh": "最近刷新 {time}",
9213
9888
  "sidebar.refreshFailed": "刷新失败",
9214
- "sidebar.signedOut": "未登录"
9889
+ "sidebar.signedOut": "未登录",
9890
+ "cliLive.running": "CodeBuddy",
9891
+ "cliLive.toolCalls": "{count} 次工具",
9892
+ "cliLive.elapsed": "{seconds} 秒",
9893
+ "cliLive.starting": "启动中…",
9894
+ "cliLive.finishing": "收尾中…",
9895
+ "cliLive.lastTool": "最近:{tool}",
9896
+ "cliLive.chars": "{count} 字",
9897
+ "cliLive.aria": "有一个 CodeBuddy 回合正在跑:{detail}"
9215
9898
  };
9216
9899
  //#endregion
9217
9900
  //#region src/client/index.tsx
@@ -9391,6 +10074,42 @@ window.__ModuleLoader__.load({
9391
10074
  }
9392
10075
  };
9393
10076
  /**
10077
+ * The live CodeBuddy turn pill, in the same ambient dock as the readout.
10078
+ *
10079
+ * `order` 11 puts it immediately after the credit readout (10) and still
10080
+ * right of the shell's own pills (0): the two numbers a user glances at while
10081
+ * a turn runs sit together. The component renders `null` whenever no turn is
10082
+ * live, which is the normal case — the bridge is off by default — so an
10083
+ * unused seat costs one poll interval and nothing on screen.
10084
+ *
10085
+ * THE SESSION ID IS THE PILL'S DISPLAY CONDITION. `conversation.composer.dock`
10086
+ * is declared `scope: 'session'`, so the registration's `inject` receives the
10087
+ * session id and the pill can keep only the rows that session started. The
10088
+ * board itself is host-wide (one route for every session in the process), so
10089
+ * without this the pill would describe whatever turn is newest anywhere —
10090
+ * including one from another tab, about a directory the reader is not in.
10091
+ * Unlike the credit readout there is deliberately no provider gate: the CLI
10092
+ * route is model-independent, and a turn started from a DeepSeek session is
10093
+ * exactly a case the readout has to cover.
10094
+ */
10095
+ const registerCliLive = () => {
10096
+ try {
10097
+ return ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
10098
+ name: "conversation.composer.dock",
10099
+ id: "dsh-connect-workbuddy-cli-live",
10100
+ order: 11,
10101
+ locale: namespace,
10102
+ inject: (sessionId) => ({
10103
+ t,
10104
+ sessionId
10105
+ })
10106
+ }, CodebuddyLive));
10107
+ } catch (error) {
10108
+ console.error("[dsh-connect-workbuddy] cli live slot failed to register (host provider unaffected):", error);
10109
+ return;
10110
+ }
10111
+ };
10112
+ /**
9394
10113
  * The frame-wide quota notice, DECLINED so the host keeps its own Toast
9395
10114
  * (issue #63 follow-up, the maintainer's call on a screenshot).
9396
10115
  *
@@ -9454,6 +10173,7 @@ window.__ModuleLoader__.load({
9454
10173
  ...WORKBUDDY_CARD_KEYS.map((key) => registerCard("plugins.row.config", `${key}#${key}`)),
9455
10174
  registerComposerCredits(),
9456
10175
  registerStatsCredits(),
10176
+ registerCliLive(),
9457
10177
  registerQuotaNotice(),
9458
10178
  registerModelLabel()
9459
10179
  ].filter((disposer) => disposer !== void 0);