dsh-connect-workbuddy 3.5.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -583,16 +583,34 @@ const CN_CHAT_BASE = "https://copilot.tencent.com";
583
583
  const CN_BILLING_BASE = "https://www.codebuddy.cn";
584
584
  const GLOBAL_BASE = "https://www.workbuddy.ai";
585
585
  /**
586
- * Model-catalog path used by the CN region (and the global fallback). The CN
587
- * gateway answers it with the same bytes as its legacy
588
- * `/console/enterprises/personal/models` alias. See {@link GLOBAL_CONFIG_PATH}
589
- * for why the international region reads a different document.
586
+ * Legacy model-catalog path, kept only as the CN region's FALLBACK.
587
+ *
588
+ * This is the document the WorkBuddy *plugin* used to read, and it is NOT the
589
+ * document the WorkBuddy *app* reads. The gateway answers it with the CLI
590
+ * channel's roster, whose second slot is the paid `hy4-preview`
591
+ * (`credits: 'x0.29 credits'`), while the app's own config lists the free
592
+ * `hy4-preview-f` (`credits: 'x0.00 credits'`) in that slot under the SAME
593
+ * display name "Hy4 preview". Reading this path is therefore what makes the
594
+ * plugin's card disagree with the app about a model's price — both are
595
+ * correctly displaying a real record, just different ones.
590
596
  */
591
597
  const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
592
598
  /**
593
- * Remote product-config path on the global gateway. This is the document the
594
- * desktop channel receives; it is the only source that lists the account's
595
- * full international chat roster (see {@link DESKTOP_UA}).
599
+ * Remote product-config path; the CN region's PRIMARY catalog source and the
600
+ * global region's only one.
601
+ *
602
+ * It is field-compatible with {@link MODELS_CATALOG_PATH} for every key
603
+ * {@link parseUpstreamModel} reads, so one parser still serves both. On CN this
604
+ * path is what the app itself consumes, so the plugin's roster and the app's
605
+ * agree — that parity is the whole point of preferring it.
606
+ *
607
+ * The CLI user agent deliberately stays {@link CLIENT_UA} here. `/v3/config`
608
+ * serves a DIFFERENT roster per client channel: the desktop token yields a
609
+ * roster without the free `hy4-preview-f`, and the CLI token yields one with
610
+ * it. Measured 2026-10-04 — CN + `/v3/config`: CLI token = 17 models incl.
611
+ * `hy4-preview-f`(x0.00); desktop token = 29 models, no `hy4-preview-f` and no
612
+ * cheap free tier. That is why the global branch below pairs this path with
613
+ * {@link DESKTOP_UA} and the CN branch must NOT.
596
614
  */
597
615
  const GLOBAL_CONFIG_PATH = "/v3/config";
598
616
  const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
@@ -1152,6 +1170,19 @@ function selectCliModels(rawModels, agents) {
1152
1170
  * the credential explicitly so token refreshes apply on the next call.
1153
1171
  */
1154
1172
  var WorkBuddyUpstreamClient = class {
1173
+ onFallback;
1174
+ /**
1175
+ * Reports a fallback the catalog reader had to take.
1176
+ *
1177
+ * Injected rather than logged here because this module holds no logger on
1178
+ * purpose — it is pure transport + parsing, so it stays importable from
1179
+ * tests and the CLI without a cordis context. The host wires its
1180
+ * `ctx.logger.warn` in; without a host the event is dropped, which is the
1181
+ * same silence the previous single-source reader had.
1182
+ */
1183
+ constructor(onFallback) {
1184
+ this.onFallback = onFallback;
1185
+ }
1155
1186
  /** POST the chat endpoint; a successful answer is the raw SSE response. */
1156
1187
  async chatStream(credential, bodyJson, signal) {
1157
1188
  let response;
@@ -1274,13 +1305,23 @@ var WorkBuddyUpstreamClient = class {
1274
1305
  /**
1275
1306
  * Read the model directory for the credential's region.
1276
1307
  *
1277
- * The two regions expose their chat roster through different documents:
1278
- * CN answers `/v2/enterprises/personal/models`, while the global gateway's
1279
- * personal-models path returns HTTP 500 and the CLI channel's `/v3/config`
1280
- * omits chat-usable models — so global reads `/v3/config` as the desktop
1281
- * channel (see {@link DESKTOP_UA}). Both documents share the entry shape, so
1282
- * one parser serves them. No user-side toggle is involved: the region comes
1283
- * from the credential's `domain`.
1308
+ * Both regions prefer `/v3/config`, the document the WorkBuddy app itself
1309
+ * consumes, so the plugin's roster and the app's agree. They differ in the
1310
+ * user agent that requests it, and that difference is load-bearing: `/v3`
1311
+ * serves a different roster per client channel. Global asks as the desktop
1312
+ * channel (see {@link DESKTOP_UA}); CN asks as the CLI channel
1313
+ * ({@link CLIENT_UA}), which is the only CN channel whose `/v3` answer
1314
+ * contains the free `hy4-preview-f` (see {@link GLOBAL_CONFIG_PATH}).
1315
+ *
1316
+ * CN falls back to the legacy {@link MODELS_CATALOG_PATH} when `/v3` fails,
1317
+ * returns a non-zero envelope, or resolves to no usable model, so a gateway
1318
+ * that stops serving the modern document degrades to the previous roster
1319
+ * instead of leaving the region empty. The fallback is silent when unused and
1320
+ * reported when it fires, because a roster that silently differs from the
1321
+ * app's is exactly the bug this ordering exists to fix.
1322
+ *
1323
+ * No user-side toggle is involved: the region comes from the credential's
1324
+ * `domain`.
1284
1325
  */
1285
1326
  async fetchModels(credential, signal) {
1286
1327
  const timeout = signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS);
@@ -1303,20 +1344,28 @@ var WorkBuddyUpstreamClient = class {
1303
1344
  const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
1304
1345
  return selectCliModels(data["models"], data["agents"]);
1305
1346
  }
1306
- const response = await fetch(`${chatBase(credential)}${MODELS_CATALOG_PATH}`, {
1307
- headers: {
1308
- "Authorization": `Bearer ${credential.accessToken}`,
1309
- "Accept": "application/json",
1310
- "Origin": originReferer(credential),
1311
- "Referer": `${originReferer(credential)}/`,
1312
- "User-Agent": CLIENT_UA
1313
- },
1314
- signal: timeout
1315
- });
1316
- const envelope = await readEnvelope(response);
1317
- if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1318
- const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
1319
- return selectCliModels(data["models"], data["agents"]);
1347
+ const readCatalog = async (path) => {
1348
+ const response = await fetch(`${chatBase(credential)}${path}`, {
1349
+ headers: {
1350
+ "Authorization": `Bearer ${credential.accessToken}`,
1351
+ "Accept": "application/json",
1352
+ "Origin": originReferer(credential),
1353
+ "Referer": `${originReferer(credential)}/`,
1354
+ "User-Agent": CLIENT_UA
1355
+ },
1356
+ signal: timeout
1357
+ });
1358
+ const envelope = await readEnvelope(response);
1359
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1360
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
1361
+ return selectCliModels(data["models"], data["agents"]);
1362
+ };
1363
+ try {
1364
+ return await readCatalog(GLOBAL_CONFIG_PATH);
1365
+ } catch (error) {
1366
+ this.onFallback?.(`CN model catalog ${GLOBAL_CONFIG_PATH} failed; falling back to ${MODELS_CATALOG_PATH}: ` + (error instanceof Error ? error.message : String(error)));
1367
+ return await readCatalog(MODELS_CATALOG_PATH);
1368
+ }
1320
1369
  }
1321
1370
  /** Query today's check-in status without changing account state. */
1322
1371
  async fetchCheckinStatus(credential) {
@@ -2806,7 +2855,7 @@ function generationOf(version) {
2806
2855
  //#endregion
2807
2856
  //#region src/version.ts
2808
2857
  /** The npm package version this build was produced from. */
2809
- const WORKBUDDY_CONNECT_VERSION = "3.5.0";
2858
+ const WORKBUDDY_CONNECT_VERSION = "4.0.0";
2810
2859
  //#endregion
2811
2860
  //#region src/host-heartbeat.ts
2812
2861
  /**
package/lib/index.d.ts CHANGED
@@ -628,6 +628,17 @@ declare function parseUpstreamModel(value: unknown): WorkBuddyUpstreamModel | un
628
628
  * the credential explicitly so token refreshes apply on the next call.
629
629
  */
630
630
  declare class WorkBuddyUpstreamClient {
631
+ private readonly onFallback?;
632
+ /**
633
+ * Reports a fallback the catalog reader had to take.
634
+ *
635
+ * Injected rather than logged here because this module holds no logger on
636
+ * purpose — it is pure transport + parsing, so it stays importable from
637
+ * tests and the CLI without a cordis context. The host wires its
638
+ * `ctx.logger.warn` in; without a host the event is dropped, which is the
639
+ * same silence the previous single-source reader had.
640
+ */
641
+ constructor(onFallback?: ((message: string) => void) | undefined);
631
642
  /** POST the chat endpoint; a successful answer is the raw SSE response. */
632
643
  chatStream(credential: WorkBuddyCredential, bodyJson: string, signal?: AbortSignal): Promise<WorkBuddyChatResult>;
633
644
  /**
@@ -662,13 +673,23 @@ declare class WorkBuddyUpstreamClient {
662
673
  /**
663
674
  * Read the model directory for the credential's region.
664
675
  *
665
- * The two regions expose their chat roster through different documents:
666
- * CN answers `/v2/enterprises/personal/models`, while the global gateway's
667
- * personal-models path returns HTTP 500 and the CLI channel's `/v3/config`
668
- * omits chat-usable models — so global reads `/v3/config` as the desktop
669
- * channel (see {@link DESKTOP_UA}). Both documents share the entry shape, so
670
- * one parser serves them. No user-side toggle is involved: the region comes
671
- * from the credential's `domain`.
676
+ * Both regions prefer `/v3/config`, the document the WorkBuddy app itself
677
+ * consumes, so the plugin's roster and the app's agree. They differ in the
678
+ * user agent that requests it, and that difference is load-bearing: `/v3`
679
+ * serves a different roster per client channel. Global asks as the desktop
680
+ * channel (see {@link DESKTOP_UA}); CN asks as the CLI channel
681
+ * ({@link CLIENT_UA}), which is the only CN channel whose `/v3` answer
682
+ * contains the free `hy4-preview-f` (see {@link GLOBAL_CONFIG_PATH}).
683
+ *
684
+ * CN falls back to the legacy {@link MODELS_CATALOG_PATH} when `/v3` fails,
685
+ * returns a non-zero envelope, or resolves to no usable model, so a gateway
686
+ * that stops serving the modern document degrades to the previous roster
687
+ * instead of leaving the region empty. The fallback is silent when unused and
688
+ * reported when it fires, because a roster that silently differs from the
689
+ * app's is exactly the bug this ordering exists to fix.
690
+ *
691
+ * No user-side toggle is involved: the region comes from the credential's
692
+ * `domain`.
672
693
  */
673
694
  fetchModels(credential: WorkBuddyCredential, signal?: AbortSignal): Promise<readonly WorkBuddyUpstreamModel[]>;
674
695
  /** Query today's check-in status without changing account state. */
@@ -1850,7 +1871,13 @@ interface WorkBuddyWebModel {
1850
1871
  name: string;
1851
1872
  /** Effective DSH context after applying the saved local budget. */
1852
1873
  contextWindow: number;
1853
- /** Native maximum advertised by WorkBuddy; models above 200K expose 200K/max. */
1874
+ /**
1875
+ * Native maximum advertised by WorkBuddy, never clamped by a default.
1876
+ *
1877
+ * Models above 200K expose the tier selector (200K / 500K / max); whether the
1878
+ * window is actually reduced is the user's stored `contextBudgets` decision
1879
+ * and nothing else — see issue #33.
1880
+ */
1854
1881
  nativeContextWindow: number;
1855
1882
  maxTokens: number;
1856
1883
  creditMultiplier?: number;
@@ -1956,6 +1983,17 @@ type WorkBuddyWebUsage =
1956
1983
  accounts: readonly WorkBuddyWebAccount[];
1957
1984
  /** Whether this region's provider is currently offered to DSH. */
1958
1985
  enabled?: boolean;
1986
+ /**
1987
+ * Whether this region's sidebar credit line is shown.
1988
+ *
1989
+ * Carried on the Host's answer rather than read from the browser settings
1990
+ * mirror, for the same measured reason as `contextBudgets`: on the affected
1991
+ * 0.1.7 deployment the mirror never picks up this plugin's writes (a save
1992
+ * through the Host endpoint leaves it stale), so a control reading it kept
1993
+ * showing the old value and looked like it could not be turned off — the
1994
+ * "关闭不了" report.
1995
+ */
1996
+ showCreditsInMainUi?: boolean;
1959
1997
  message?: string;
1960
1998
  /** The persisted account id matches no local account. */
1961
1999
  selectionLost?: boolean;
@@ -1984,6 +2022,12 @@ type WorkBuddyWebUsage =
1984
2022
  region: WorkBuddyWebRegion;
1985
2023
  /** Whether this region's provider is currently offered to DSH. */
1986
2024
  enabled?: boolean;
2025
+ /**
2026
+ * Whether this region's sidebar credit line is shown. See the signed-out
2027
+ * variant for why this travels on the Host's answer instead of the browser
2028
+ * settings mirror.
2029
+ */
2030
+ showCreditsInMainUi?: boolean;
1987
2031
  source?: 'desktop' | 'dsh';
1988
2032
  tokenExpiresAtMs: number;
1989
2033
  /** A saved per-region choice is in effect (false = following the app). */
@@ -2290,6 +2334,19 @@ interface WorkBuddyStatusRouteOptions {
2290
2334
  * committed settings value rather than a local guess.
2291
2335
  */
2292
2336
  regionEnabled(region: WorkBuddyRegion): boolean;
2337
+ /**
2338
+ * Whether the region's sidebar credit line is shown.
2339
+ *
2340
+ * Read from the COMMITTED settings value here rather than in the browser, for
2341
+ * the same reason as `regionEnabled` — and additionally because the browser's
2342
+ * settings mirror is documented as stale for this namespace on the affected
2343
+ * 0.1.7 deployments, which made the checkbox appear unable to turn the line
2344
+ * off ("关闭不了"). The card reads this field off the Host's answer.
2345
+ *
2346
+ * Optional so a Host that predates the setting still satisfies the interface;
2347
+ * absent reads as ON, matching the schema's `default(true)`.
2348
+ */
2349
+ showCreditsInMainUi?(region: WorkBuddyRegion): boolean;
2293
2350
  /**
2294
2351
  * Send one real-volume request to each named model of a region and report what
2295
2352
  * came back.
@@ -2332,6 +2389,18 @@ interface WorkBuddyStatusRouteOptions {
2332
2389
  * probe.
2333
2390
  */
2334
2391
  accountUsable?(region: WorkBuddyRegion, account: WorkBuddyRecoveryCandidate): Promise<boolean>;
2392
+ /**
2393
+ * Why each of the region's accounts cannot serve right now, keyed by account
2394
+ * id. Absent key (or an absent dep) means "no reason known" — the composer
2395
+ * panel then marks nothing, which is the honest answer for an account that has
2396
+ * never been measured.
2397
+ *
2398
+ * The rule is the SAME one the pool ranks by (`exclusionOf`), including its
2399
+ * cooldown-expiry handling, so an account the pool calls usable and one the
2400
+ * panel calls rate-limited can never be the same account at the same moment.
2401
+ * Reimplementing the expiry check here would let the two drift.
2402
+ */
2403
+ accountExclusions?(region: WorkBuddyRegion): Promise<Readonly<Record<string, WorkBuddyWebPoolExclusion>>>;
2335
2404
  /**
2336
2405
  * The region's account pool, assembled on the Host.
2337
2406
  *
@@ -2481,6 +2550,15 @@ interface WorkBuddyRegionState {
2481
2550
  * row (see `syncRegionRegistration`).
2482
2551
  */
2483
2552
  enabled?: boolean;
2553
+ /**
2554
+ * Whether this region's credit balance shows at the DSH sidebar foot.
2555
+ *
2556
+ * Per-region, independently switched: WB CN and WB AI are separate accounts
2557
+ * on separate gateways, so a user holding only one of them must be able to
2558
+ * show exactly that one. Opt-out — an absent value reads as ON, so a config
2559
+ * written before this switch existed keeps whatever the default was.
2560
+ */
2561
+ showCreditsInMainUi?: boolean;
2484
2562
  /** The last-refreshed directory for this region; what the card displays. */
2485
2563
  lastCatalog?: WorkBuddyModelInfo[];
2486
2564
  /** The user's selection in this region, as model ids. */
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as macosNestedAppBundles, A as workbuddyOwnAuthPath, B as parseUpstreamModel, C as defaultDesktopAuthDirs, D as legacyWorkbuddyOwnAuthPath, E as isEncryptedCredentialError, F as classifyUpstreamError, G as deriveAtRestKey, H as regionOf, I as declaredTools, J as findWorkbuddyAppExecutable, K as deriveAtRestKeyId, L as isCredentialRejectedError, M as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, N as WorkBuddyCredentialRejectedError, O as parseWorkBuddyAuth, P as WorkBuddyUpstreamClient, Q as macosBundleExecutable, R as parseCreditMultiplier, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as WORKBUDDY_APP_EXECUTABLE_ENV, V as prepareChatBody, W as clearAtRestKeyCache, X as isEncryptedFieldWrapper, Z as isWorkbuddyBundle, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as openEncryptedField, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, j as CREDENTIAL_REJECTED_CODE, k as workbuddyAccountId, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as workbuddyAppExecutableCandidates, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as fetchAtRestKeyPayload, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as readAtRestKey, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as parseReasoning } from "./host-heartbeat-C6G_goTD.js";
1
+ import { $ as macosNestedAppBundles, A as workbuddyOwnAuthPath, B as parseUpstreamModel, C as defaultDesktopAuthDirs, D as legacyWorkbuddyOwnAuthPath, E as isEncryptedCredentialError, F as classifyUpstreamError, G as deriveAtRestKey, H as regionOf, I as declaredTools, J as findWorkbuddyAppExecutable, K as deriveAtRestKeyId, L as isCredentialRejectedError, M as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, N as WorkBuddyCredentialRejectedError, O as parseWorkBuddyAuth, P as WorkBuddyUpstreamClient, Q as macosBundleExecutable, R as parseCreditMultiplier, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as WORKBUDDY_APP_EXECUTABLE_ENV, V as prepareChatBody, W as clearAtRestKeyCache, X as isEncryptedFieldWrapper, Z as isWorkbuddyBundle, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as openEncryptedField, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, j as CREDENTIAL_REJECTED_CODE, k as workbuddyAccountId, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as workbuddyAppExecutableCandidates, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as fetchAtRestKeyPayload, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as readAtRestKey, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as parseReasoning } from "./host-heartbeat-vvVjdY_q.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -2832,6 +2832,18 @@ async function writePoolProbes(region, updates) {
2832
2832
  */
2833
2833
  /** Plugin-owned usage endpoint consumed by its browser half. */
2834
2834
  const WORKBUDDY_USAGE_PATH = "/plugins/dsh-connect-workbuddy/usage";
2835
+ /**
2836
+ * Per-account credit balances for one region, read ONLY while the composer
2837
+ * panel is open.
2838
+ *
2839
+ * Separate from {@link WORKBUDDY_USAGE_PATH} because its cost is different in
2840
+ * kind: the usage document is one upstream read for the selected account, while
2841
+ * this is one read PER account in the region. Folding it into the usage route
2842
+ * would make every 5-minute refresh pay for a table nobody is looking at, so it
2843
+ * is fetched on demand instead — the same principle the pool applies to its
2844
+ * per-account figures.
2845
+ */
2846
+ const WORKBUDDY_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-workbuddy/account-credits";
2835
2847
  /** Plugin-owned live model refresh endpoint. */
2836
2848
  const WORKBUDDY_MODELS_REFRESH_PATH = "/plugins/dsh-connect-workbuddy/models/refresh";
2837
2849
  /** Plugin-owned local account rescan endpoint. */
@@ -3158,6 +3170,39 @@ function volatileFlagOf(field) {
3158
3170
  return Config.dict?.[field]?.meta?.volatile === true;
3159
3171
  }
3160
3172
  /**
3173
+ * Per-account credit balances for one region, for the composer panel's table.
3174
+ *
3175
+ * ONE upstream read per account, which is exactly why this is its own route:
3176
+ * the panel calls it only while it is open, so the 5-minute readout never
3177
+ * multiplies its cost by the account count.
3178
+ *
3179
+ * Every account is listed even when its balance cannot be read — a credential
3180
+ * that no longer resolves, or an upstream that refused — because the panel's
3181
+ * job is to show which accounts exist and let the user switch. Such a row
3182
+ * carries NO `credits` field rather than `0`, so the panel renders "—" instead
3183
+ * of telling the user they are out of credits.
3184
+ *
3185
+ * Failures are contained per account: one dead credential must not blank the
3186
+ * table for the accounts that are fine.
3187
+ */
3188
+ async function workBuddyAccountCredits(deps, region) {
3189
+ const store = deps.store(region);
3190
+ const accounts = await store.accounts();
3191
+ const exclusions = await deps.accountExclusions?.(region).catch(() => void 0) ?? {};
3192
+ return Promise.all(accounts.map(async (account) => {
3193
+ const credential = await store.credentialFor(account.id).catch(() => void 0);
3194
+ const credits = credential === void 0 ? void 0 : await deps.client.fetchCredits(credential).then((answer) => answer.total).catch(() => void 0);
3195
+ const excludedBy = exclusions[account.id];
3196
+ return {
3197
+ id: account.id,
3198
+ accountName: account.accountName,
3199
+ selected: account.selected,
3200
+ ...credits === void 0 ? {} : { credits },
3201
+ ...excludedBy === void 0 ? {} : { excludedBy }
3202
+ };
3203
+ }));
3204
+ }
3205
+ /**
3161
3206
  * Assemble one region's card document. `region` is the tab the card is on;
3162
3207
  * the region-scoped store already answers with only that region's accounts,
3163
3208
  * so the document's model slots and account list are that region's by
@@ -3169,13 +3214,15 @@ async function workBuddyWebStatus(deps, region) {
3169
3214
  const accounts = await store.accounts();
3170
3215
  deps.regionUsable?.(region, accounts.length > 0);
3171
3216
  const enabled = deps.regionEnabled(region);
3217
+ const showCreditsInMainUi = deps.showCreditsInMainUi?.(region) ?? true;
3172
3218
  const authStatus = await store.status();
3173
3219
  const selectionExplicit = store.hasExplicitSelection();
3174
3220
  if (authStatus.state !== "signed-out" && accounts.length === 0) return {
3175
3221
  status: "signed-out",
3176
3222
  accounts: [],
3177
3223
  selectionExplicit,
3178
- enabled
3224
+ enabled,
3225
+ showCreditsInMainUi
3179
3226
  };
3180
3227
  let credential;
3181
3228
  try {
@@ -3188,6 +3235,7 @@ async function workBuddyWebStatus(deps, region) {
3188
3235
  message: safeMessage(error),
3189
3236
  selectionExplicit,
3190
3237
  enabled,
3238
+ showCreditsInMainUi,
3191
3239
  ...await store.selectionLost() ? { selectionLost: true } : {},
3192
3240
  ...webAccounts.length === 0 ? await searchedPaths(store) : {}
3193
3241
  };
@@ -3202,6 +3250,7 @@ async function workBuddyWebStatus(deps, region) {
3202
3250
  tokenExpiresAtMs: credential.expiresAtMs,
3203
3251
  selectionExplicit,
3204
3252
  enabled,
3253
+ showCreditsInMainUi,
3205
3254
  accounts: accounts.map(toWebAccount),
3206
3255
  models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))),
3207
3256
  enabledModelIds: [...deps.enabledModelIds(region)],
@@ -3354,6 +3403,21 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
3354
3403
  }
3355
3404
  }
3356
3405
  });
3406
+ const disposeAccountCredits = ctx.webServer.register({
3407
+ kind: "exact",
3408
+ path: WORKBUDDY_ACCOUNT_CREDITS_PATH,
3409
+ handler: async (req, res) => {
3410
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
3411
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
3412
+ const region = requestRegion(req, res);
3413
+ if (region === void 0) return;
3414
+ try {
3415
+ json(res, 200, { accounts: await workBuddyAccountCredits(deps, region) });
3416
+ } catch (error) {
3417
+ json(res, 500, { error: safeMessage(error) });
3418
+ }
3419
+ }
3420
+ });
3357
3421
  const disposeAccounts = ctx.webServer.register({
3358
3422
  kind: "exact",
3359
3423
  path: WORKBUDDY_ACCOUNTS_REFRESH_PATH,
@@ -3586,6 +3650,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
3586
3650
  disposeCheckin();
3587
3651
  disposeAccounts();
3588
3652
  disposeUsage();
3653
+ disposeAccountCredits();
3589
3654
  disposeProbe();
3590
3655
  disposePool();
3591
3656
  disposeDiagWrite();
@@ -3785,6 +3850,22 @@ const poolConfig = z.object({
3785
3850
  });
3786
3851
  const regionStateConfig = z.object({
3787
3852
  enabled: z.boolean().default(true).description("Whether this region's provider is offered to DSH (opt-out; false withdraws it entirely)"),
3853
+ /**
3854
+ * Whether this region's credit line shows at the DSH sidebar foot.
3855
+ *
3856
+ * Per-REGION rather than one global switch, because the two sides are
3857
+ * independent providers the user may hold only one account on: a CN-only user
3858
+ * has nothing to read from the international line and vice versa, and a
3859
+ * single shared switch would force them to either show a permanently empty
3860
+ * row or lose the one that works.
3861
+ *
3862
+ * ON by default (opt-out). The plugin card is not reachable in every host —
3863
+ * a market plugin can own the Plugins tab and never render
3864
+ * `plugins.bundle.config` — so a feature that is off until discovered would be
3865
+ * off forever for those users. The line carries its own `×` in the sidebar,
3866
+ * which is reachable exactly where the feature renders.
3867
+ */
3868
+ showCreditsInMainUi: z.boolean().default(true).description("Show this region's remaining credits at the DSH sidebar foot (opt-out; the line carries its own hide button)"),
3788
3869
  lastCatalog: z.array(modelConfig).default([]),
3789
3870
  enabledModelIds: z.array(z.string()).default([]),
3790
3871
  imageModelIds: z.array(z.string()).default([]),
@@ -3871,7 +3952,7 @@ const Config = z.object({
3871
3952
  */
3872
3953
  function regionStateOf(config, region) {
3873
3954
  const stored = unwrapVolatile(config.regions)?.[region];
3874
- if (stored !== void 0) return stored;
3955
+ if (stored !== void 0 && stored !== null) return stored;
3875
3956
  if (region !== "cn") return {};
3876
3957
  const lastCatalog = unwrapVolatile(config.lastCatalog);
3877
3958
  const enabledModelIds = unwrapVolatile(config.enabledModelIds);
@@ -3959,7 +4040,9 @@ async function legacyAttributionRegion(value, accountsFor) {
3959
4040
  * upstream never leaves a provider empty.
3960
4041
  */
3961
4042
  function apply(ctx, config) {
3962
- const client = new WorkBuddyUpstreamClient();
4043
+ const client = new WorkBuddyUpstreamClient((message) => {
4044
+ ctx.logger.warn(`dsh-connect-workbuddy: ${message}`);
4045
+ });
3963
4046
  /**
3964
4047
  * How long a check-in reading is reused before asking the upstream again.
3965
4048
  *
@@ -4138,6 +4221,25 @@ function apply(ctx, config) {
4138
4221
  store: (region) => stacks[region].store,
4139
4222
  client,
4140
4223
  accountUsable: accountUsabilityProbe,
4224
+ /**
4225
+ * Why each of the region's accounts cannot serve right now, for the composer
4226
+ * panel's table.
4227
+ *
4228
+ * Runs the SAME `exclusionOf` the pool ranks by, against every account the
4229
+ * region knows — not only pool members. Deriving it any other way (a second
4230
+ * expiry comparison, or `probe.outcome` read raw) would let the panel call an
4231
+ * account rate-limited while the pool has already moved past its cooldown.
4232
+ */
4233
+ accountExclusions: async (region) => {
4234
+ const probes = await readPoolProbes(region);
4235
+ const nowMs = Date.now();
4236
+ const out = {};
4237
+ for (const [accountId, probe] of Object.entries(probes)) {
4238
+ const excludedBy = exclusionOf(probe, nowMs);
4239
+ if (excludedBy !== void 0) out[accountId] = excludedBy;
4240
+ }
4241
+ return out;
4242
+ },
4141
4243
  displayModels: (region) => displayModels(current(), region),
4142
4244
  enabledModelIds: (region) => regionStateOf(current(), region).enabledModelIds ?? [],
4143
4245
  imageModelIds: (region) => regionStateOf(current(), region).imageModelIds ?? [],
@@ -4146,6 +4248,13 @@ function apply(ctx, config) {
4146
4248
  discoverModels,
4147
4249
  regionEnabled: (region) => regionEnabled(current(), region),
4148
4250
  /**
4251
+ * The region's committed sidebar-credit flag, read from the same config the
4252
+ * card writes. Exposed on the Host's answer because the browser settings
4253
+ * mirror goes stale for this namespace on the affected 0.1.7 deployments,
4254
+ * which made the switch look unable to turn the line off ("关闭不了").
4255
+ */
4256
+ showCreditsInMainUi: (region) => regionStateOf(current(), region).showCreditsInMainUi !== false,
4257
+ /**
4149
4258
  * One minimal request per named model, through the region's own credential.
4150
4259
  *
4151
4260
  * Resolved here rather than in the route so the browser half never touches a
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "3.5.0",
5
+ "version": "4.0.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",
package/screenshots.json CHANGED
@@ -1,4 +1,5 @@
1
1
  [
2
2
  "docs/assets/dsh-connect-workbuddy-usage-card.png",
3
- "docs/assets/dsh-connect-workbuddy-account-pool.png"
3
+ "docs/assets/dsh-connect-workbuddy-account-pool.png",
4
+ "docs/assets/dsh-connect-workbuddy-composer-credits.png"
4
5
  ]