dsh-connect-workbuddy 3.6.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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-Cg2ahl1n.js";
1
+ import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-C4TXu8ew.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";
@@ -2337,7 +2337,7 @@ async function checkinOne(target, deps) {
2337
2337
  return {
2338
2338
  ...base,
2339
2339
  status: "failed",
2340
- message: messageOf(error)
2340
+ message: messageOf$1(error)
2341
2341
  };
2342
2342
  }
2343
2343
  if (credential === void 0) return {
@@ -2367,7 +2367,7 @@ async function checkinOne(target, deps) {
2367
2367
  return {
2368
2368
  ...base,
2369
2369
  status: "failed",
2370
- message: messageOf(error)
2370
+ message: messageOf$1(error)
2371
2371
  };
2372
2372
  }
2373
2373
  }
@@ -2413,7 +2413,7 @@ async function testOne(target, modelId, deps) {
2413
2413
  result: {
2414
2414
  modelId,
2415
2415
  outcome: "credential-rejected",
2416
- message: messageOf(error)
2416
+ message: messageOf$1(error)
2417
2417
  }
2418
2418
  };
2419
2419
  }
@@ -2477,7 +2477,7 @@ function defaultWait(ms) {
2477
2477
  return new Promise((resolve) => setTimeout(resolve, ms));
2478
2478
  }
2479
2479
  /** A safe message from an unknown throwable, bounded in length. */
2480
- function messageOf(error) {
2480
+ function messageOf$1(error) {
2481
2481
  return (error instanceof Error ? error.message : String(error)).slice(0, 300);
2482
2482
  }
2483
2483
  /**
@@ -2689,6 +2689,469 @@ function resolveTargetModel(catalog, preferredId) {
2689
2689
  };
2690
2690
  }
2691
2691
  //#endregion
2692
+ //#region src/oauth.ts
2693
+ /**
2694
+ * OAuth QR sign-in: add a WorkBuddy account by scanning a code, without the
2695
+ * desktop app.
2696
+ *
2697
+ * 参考:WorkBuddy OAuth 采集的通行实现(对照官网 cockpit 的
2698
+ * `oauth_start` / `oauth_poll`):
2699
+ * 向 `{billing_base}{API_PREFIX}/auth/state?platform={platform}` 申请一个
2700
+ * state,用户在浏览器打开授权页完成登录,客户端轮询
2701
+ * `{billing_base}{API_PREFIX}/auth/token?state={state}` 换取凭据,再以
2702
+ * Bearer 调 `{API_PREFIX}/login/account` 拉取账号资料。三步的 URL 形态、
2703
+ * 响应字段双拼写(camelCase/snake_case)容错、以及「区域必须绑定会话」的
2704
+ * 约定均沿用该实现。
2705
+ * 改动:端点按本插件 `upstream.ts` 的区域约定路由 —— CN 为
2706
+ * `https://www.codebuddy.cn`;国际版按凭据域分家(`workbuddy.ai` 与
2707
+ * `codebuddy.ai` 两把钥匙各自签发,混用会被网关拒绝),插件发起登录时
2708
+ * 还没有凭据,因此由用户显式传基址,缺省 `https://www.workbuddy.ai`。
2709
+ * 登录结果不落独立账号库,而是交给宿主写入统一凭据库(`src/auth.ts` 的
2710
+ * vault),与桌面端扫描到的账号共用一套发现/选择/刷新机制。
2711
+ *
2712
+ * 会话状态只在进程内(`LoginSession`):PKCE 类 state 与登录进度不落盘,
2713
+ * 拿到凭据即终结。刷新凭据仍走 `refreshToken`(上游 refresh 端点),此处
2714
+ * 只负责首次取凭据。
2715
+ *
2716
+ * @module dsh-connect-workbuddy/oauth
2717
+ */
2718
+ /** The CN billing base; matches `upstream.ts`'s CN_BILLING_BASE. */
2719
+ const CN_BILLING_BASE = "https://www.codebuddy.cn";
2720
+ /** Default international base; the desktop app's gateway. */
2721
+ const GLOBAL_BILLING_BASE = "https://www.workbuddy.ai";
2722
+ /** Upstream plugin API prefix shared by both regions. */
2723
+ const API_PREFIX = "/v2/plugin";
2724
+ /** In-process session map. Deliberately not persisted: a restart cancels. */
2725
+ const sessions = /* @__PURE__ */ new Map();
2726
+ /**
2727
+ * The billing base a login for `region` should start from.
2728
+ *
2729
+ * CN is single-homed. The international product signs in through either of
2730
+ * two brand domains (see `upstream.ts` `globalBase`); the caller may pin
2731
+ * one with `explicitBase` — a token issued at one domain is refused by the
2732
+ * other, so the choice must be made BEFORE the state is issued, not after.
2733
+ */
2734
+ function oauthBillingBase(region, explicitBase) {
2735
+ if (region === "cn") return CN_BILLING_BASE;
2736
+ const trimmed = explicitBase?.trim();
2737
+ if (trimmed !== void 0 && trimmed !== "") return normalizeBase(trimmed);
2738
+ return GLOBAL_BILLING_BASE;
2739
+ }
2740
+ /** trim + strip one trailing slash; no scheme invented (see upstream.ts). */
2741
+ function normalizeBase(base) {
2742
+ return base.trim().replace(/\/+$/, "");
2743
+ }
2744
+ /**
2745
+ * Start one login: ask the upstream for a state and return the URL the user
2746
+ * opens to scan/confirm. The session is bound to `region` AND `base`;
2747
+ * both are checked on every poll.
2748
+ *
2749
+ * Never throws for an upstream refusal — the error becomes the returned
2750
+ * `error` — because the card renders the reason inline rather than through
2751
+ * an exception path.
2752
+ */
2753
+ async function oauthStart(region, options) {
2754
+ const base = normalizeBase(oauthBillingBase(region, options?.base));
2755
+ const doFetch = options?.fetchImpl ?? fetch;
2756
+ let response;
2757
+ try {
2758
+ response = await doFetch(`${base}${API_PREFIX}/auth/state?platform=workbuddy`, {
2759
+ method: "POST",
2760
+ headers: {
2761
+ "Content-Type": "application/json",
2762
+ "Accept": "application/json"
2763
+ },
2764
+ body: "{}",
2765
+ signal: AbortSignal.timeout(3e4)
2766
+ });
2767
+ } catch (error) {
2768
+ return { error: `could not reach ${base} to start the sign-in (${messageOf(error)})` };
2769
+ }
2770
+ const document = await readJson(response);
2771
+ if (document === void 0) return { error: `the sign-in service answered with a non-JSON body (http ${response.status})` };
2772
+ const data = envelopeData(document);
2773
+ const state = firstString(data, ["state", "State"]);
2774
+ if (state === "") return { error: `the sign-in service did not issue a state (http ${response.status})` };
2775
+ const authUrl = firstString(data, [
2776
+ "authUrl",
2777
+ "auth_url",
2778
+ "url",
2779
+ "Url"
2780
+ ]) || `${base}/login?state=${encodeURIComponent(state)}`;
2781
+ const loginId = `wbo_${randomUUID().replaceAll("-", "")}`;
2782
+ sessions.set(loginId, {
2783
+ region,
2784
+ base,
2785
+ state,
2786
+ expiresAtMs: Date.now() + 6e5,
2787
+ done: false
2788
+ });
2789
+ return {
2790
+ loginId,
2791
+ verificationUri: authUrl,
2792
+ expiresIn: 600
2793
+ };
2794
+ }
2795
+ /**
2796
+ * Poll once for a login's outcome.
2797
+ *
2798
+ * Ordering matters and mirrors the reference implementation: the session's
2799
+ * region/base binding is checked BEFORE any I/O, so a poll that names the
2800
+ * wrong tab never reaches the network, and an unknown id fails without a
2801
+ * request too.
2802
+ */
2803
+ async function oauthPoll(loginId, region, options) {
2804
+ const session = sessions.get(loginId);
2805
+ if (session === void 0) return {
2806
+ done: true,
2807
+ error: "sign-in session not found; start a new one"
2808
+ };
2809
+ if (session.region !== region) return {
2810
+ done: true,
2811
+ error: "sign-in session belongs to another region tab"
2812
+ };
2813
+ if (session.done) return terminalOf(session);
2814
+ if (Date.now() > session.expiresAtMs) {
2815
+ session.done = true;
2816
+ session.error = "the sign-in timed out; start again";
2817
+ sessions.delete(loginId);
2818
+ return {
2819
+ done: true,
2820
+ error: session.error
2821
+ };
2822
+ }
2823
+ const doFetch = options?.fetchImpl ?? fetch;
2824
+ let response;
2825
+ try {
2826
+ response = await doFetch(`${session.base}${API_PREFIX}/auth/token?state=${encodeURIComponent(session.state)}`, {
2827
+ method: "GET",
2828
+ headers: { "Accept": "application/json" },
2829
+ signal: AbortSignal.timeout(3e4)
2830
+ });
2831
+ } catch {
2832
+ return { done: false };
2833
+ }
2834
+ const document = await readJson(response);
2835
+ if (document === void 0) return { done: false };
2836
+ const code = typeof document["code"] === "number" ? document["code"] : -1;
2837
+ if (code !== 0 && code !== 200) return { done: false };
2838
+ const data = envelopeData(document);
2839
+ const accessToken = firstString(data, [
2840
+ "accessToken",
2841
+ "access_token",
2842
+ "AccessToken"
2843
+ ]);
2844
+ if (accessToken === "") return { done: false };
2845
+ session.done = true;
2846
+ try {
2847
+ const account = await collectAccount(session, accessToken, data, doFetch);
2848
+ sessions.delete(loginId);
2849
+ return {
2850
+ done: true,
2851
+ account
2852
+ };
2853
+ } catch (error) {
2854
+ session.error = messageOf(error);
2855
+ sessions.delete(loginId);
2856
+ return {
2857
+ done: true,
2858
+ error: session.error
2859
+ };
2860
+ }
2861
+ }
2862
+ /** Terminal view of a session, for a poll that arrives after completion. */
2863
+ function terminalOf(session) {
2864
+ if (session.result !== void 0) return {
2865
+ done: true,
2866
+ account: session.result
2867
+ };
2868
+ return {
2869
+ done: true,
2870
+ error: session.error ?? "the sign-in ended without a result"
2871
+ };
2872
+ }
2873
+ /** Fetch the account profile and assemble the success value. */
2874
+ async function collectAccount(session, accessToken, tokenData, doFetch) {
2875
+ const headers = {
2876
+ "Authorization": `Bearer ${accessToken}`,
2877
+ "Accept": "application/json"
2878
+ };
2879
+ const domain = firstString(tokenData, ["domain", "Domain"]);
2880
+ if (domain !== "") headers["X-Domain"] = domain;
2881
+ let profile = {};
2882
+ try {
2883
+ const document = await readJson(await doFetch(`${session.base}${API_PREFIX}/login/account`, {
2884
+ method: "GET",
2885
+ headers,
2886
+ signal: AbortSignal.timeout(3e4)
2887
+ }));
2888
+ if (document !== void 0) profile = envelopeData(document);
2889
+ } catch {}
2890
+ const uid = firstString(profile, [
2891
+ "uid",
2892
+ "Uid",
2893
+ "userId",
2894
+ "UserID"
2895
+ ]);
2896
+ const nickname = optionalString(profile, [
2897
+ "nickname",
2898
+ "NickName",
2899
+ "nickname",
2900
+ "ScreenName"
2901
+ ]);
2902
+ const uin = optionalString(profile, ["uin", "Uin"]);
2903
+ const enterpriseId = optionalString(profile, ["enterpriseId", "EnterpriseId"]);
2904
+ const expiresAtMs = expiryMs(tokenData, ["expiresAt", "expires_at"], ["expiresIn", "expires_in"]) ?? 0;
2905
+ const refreshExpiresAtMs = expiryMs(tokenData, ["refreshExpiresAt", "refresh_expires_at"], ["refreshExpiresIn", "refresh_expires_in"]);
2906
+ return {
2907
+ accessToken,
2908
+ refreshToken: firstString(tokenData, [
2909
+ "refreshToken",
2910
+ "refresh_token",
2911
+ "RefreshToken"
2912
+ ]),
2913
+ expiresAtMs,
2914
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
2915
+ domain: domain !== "" ? domain : firstString(profile, ["domain", "Domain"]),
2916
+ uid,
2917
+ ...nickname === void 0 ? {} : { nickname },
2918
+ ...uin === void 0 ? {} : { uin },
2919
+ ...enterpriseId === void 0 ? {} : { enterpriseId }
2920
+ };
2921
+ }
2922
+ /** Parse a JSON body; undefined for non-JSON (the caller decides severity). */
2923
+ async function readJson(response) {
2924
+ try {
2925
+ const text = await response.text();
2926
+ const parsed = JSON.parse(text);
2927
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
2928
+ } catch {}
2929
+ }
2930
+ /** The envelope's `data` object; a non-object data is an empty one. */
2931
+ function envelopeData(document) {
2932
+ const data = document["data"];
2933
+ return typeof data === "object" && data !== null && !Array.isArray(data) ? data : {};
2934
+ }
2935
+ /** First non-empty string among `keys`, else ''. */
2936
+ function firstString(source, keys) {
2937
+ for (const key of keys) {
2938
+ const value = source[key];
2939
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
2940
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
2941
+ }
2942
+ return "";
2943
+ }
2944
+ /** First non-empty string among `keys`, else undefined. */
2945
+ function optionalString(source, keys) {
2946
+ const value = firstString(source, keys);
2947
+ return value === "" ? void 0 : value;
2948
+ }
2949
+ /**
2950
+ * An expiry field that may arrive as an absolute timestamp (s or ms) or as a
2951
+ * relative TTL in seconds — the same tolerance `auth.ts` `expiryToMs` and
2952
+ * the reference implementation's `norm_ts` apply.
2953
+ */
2954
+ function expiryMs(source, absoluteKeys, ttlKeys) {
2955
+ for (const key of absoluteKeys) {
2956
+ const value = source[key];
2957
+ if (typeof value === "number" && value > 0) return value > 0xe8d4a51000 ? value : value * 1e3;
2958
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
2959
+ const parsed = Number(value);
2960
+ return parsed > 0xe8d4a51000 ? parsed : parsed * 1e3;
2961
+ }
2962
+ }
2963
+ for (const key of ttlKeys) {
2964
+ const value = source[key];
2965
+ if (typeof value === "number" && value > 0) return Date.now() + value * 1e3;
2966
+ }
2967
+ }
2968
+ /** Human message from a thrown error, for the card's inline display. */
2969
+ function messageOf(error) {
2970
+ return error instanceof Error ? error.message : String(error);
2971
+ }
2972
+ //#endregion
2973
+ //#region src/transfer.ts
2974
+ /**
2975
+ * Account transfer: the export / import file format shared with sibling
2976
+ * credential managers for the same upstream.
2977
+ *
2978
+ * 参考:通行实现 —— 文件是一个 JSON 数组,每项为一条凭据记录,顶层字段
2979
+ * snake_case(`access_token` / `refresh_token` / `uid` / `nickname` /
2980
+ * `email` / `domain` / `expiresAt`(ms) / `refreshExpiresAt` / `auth_raw`),
2981
+ * 导入按 `uid` 去重(同 uid 覆盖),缺 `access_token` 的记录跳过;预览只
2982
+ * 回脱敏字段(含 `hasToken` 布尔量),不回明文。解析函数不依赖文件系统,
2983
+ * 便于无 UI 环境单测;落库由 Host 侧的 store 完成。
2984
+ * 互通:`expiresAt` 一族按契约是**毫秒**(由 auth_raw 的原始认证文档携带);
2985
+ * 本插件的 vault 文档 `expiresAt` 是秒 —— 换算只发生在导出/导入边界。
2986
+ * 导出时 `auth_raw` 直接携带本插件 vault 文档的 `{auth, account}` 双键块,
2987
+ * 与桌面认证文件同构,供写入桌面认证文件的工具原样消费。
2988
+ *
2989
+ * @module dsh-connect-workbuddy/transfer
2990
+ */
2991
+ /**
2992
+ * Parse transfer-file text: a JSON array, every element an object.
2993
+ *
2994
+ * The error strings carry the position (1-based item number) because a file
2995
+ * from another tool can be big, and "第 N 项" is what lets the user find the
2996
+ * broken record in their editor.
2997
+ */
2998
+ function parseTransferFile(text) {
2999
+ if (text.trim() === "") throw new Error("transfer file is empty");
3000
+ let parsed;
3001
+ try {
3002
+ parsed = JSON.parse(text);
3003
+ } catch (error) {
3004
+ throw new Error("transfer file is not valid JSON: " + (error instanceof Error ? error.message : String(error)));
3005
+ }
3006
+ if (!Array.isArray(parsed)) throw new Error("transfer file must be a JSON array of account records");
3007
+ for (const [index, item] of parsed.entries()) if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error("transfer file item " + (index + 1) + " is not an account object");
3008
+ return parsed;
3009
+ }
3010
+ /** One string field of a record, tolerating the sibling format's odd shapes. */
3011
+ function displayString(value) {
3012
+ if (typeof value === "string") return value;
3013
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
3014
+ return "";
3015
+ }
3016
+ /** Whether a record carries a usable token, envelope-shaped or plain. */
3017
+ function hasToken(record) {
3018
+ const token = record["access_token"];
3019
+ return typeof token === "string" && token !== "";
3020
+ }
3021
+ /**
3022
+ * The transfer file's desensitized preview: display fields only.
3023
+ *
3024
+ * Fields come from EXTERNAL files, so a display field may hold an object or an
3025
+ * array (a sibling tool can store the desktop app's encrypted-wrapper nickname
3026
+ * verbatim); every preview field is normalized to a string or emptied rather
3027
+ * than letting a non-renderable value reach the card.
3028
+ */
3029
+ function previewTransferFile(text) {
3030
+ const records = parseTransferFile(text);
3031
+ return {
3032
+ accounts: records.map((record, index) => ({
3033
+ index,
3034
+ uid: displayString(record["uid"]),
3035
+ nickname: displayString(record["nickname"]),
3036
+ email: displayString(record["email"]),
3037
+ hasToken: hasToken(record)
3038
+ })),
3039
+ total: records.length
3040
+ };
3041
+ }
3042
+ /**
3043
+ * Project one vault credential into the shared transfer record shape.
3044
+ *
3045
+ * `expiresAt` is emitted in MILLISECONDS — the transfer contract's unit and
3046
+ * the sibling tools' display field — while the vault document stores seconds.
3047
+ * `auth_raw` carries the vault's nested `{auth, account}` document so a tool
3048
+ * that writes desktop auth files can consume it verbatim.
3049
+ */
3050
+ function transferRecordOf(credential, vaultDocument) {
3051
+ return {
3052
+ access_token: credential.accessToken,
3053
+ ...credential.refreshToken !== "" ? { refresh_token: credential.refreshToken } : {},
3054
+ ...credential.uid !== "" ? { uid: credential.uid } : {},
3055
+ ...credential.nickname !== void 0 ? { nickname: credential.nickname } : {},
3056
+ ...credential.domain !== "" ? { domain: credential.domain } : {},
3057
+ ...credential.expiresAtMs > 0 ? { expiresAt: credential.expiresAtMs } : {},
3058
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: credential.refreshExpiresAtMs },
3059
+ auth_raw: vaultDocument
3060
+ };
3061
+ }
3062
+ /**
3063
+ * Recover `uin` from a record's `auth_raw` document.
3064
+ *
3065
+ * `uin` is the billing identity the account id derives from
3066
+ * (`uin ?? uid ?? nickname`), but it is NOT one of the transfer format's
3067
+ * contract fields — sibling exports carry it only inside `auth_raw`
3068
+ * (the original auth document, whose `account` object holds it). Restoring it
3069
+ * is what makes an exported-and-re-imported account converge on the SAME
3070
+ * vault id instead of duplicating under a uid-derived id. Both document
3071
+ * shapes are honored: the nested `{auth, account}` block this plugin
3072
+ * exports, and a record whose `auth_raw.account` came straight from a
3073
+ * desktop file.
3074
+ */
3075
+ function uinOfRaw(raw) {
3076
+ if (typeof raw !== "object" || raw === null) return void 0;
3077
+ const root = raw;
3078
+ const uin = displayString((typeof root["account"] === "object" && root["account"] !== null ? root["account"] : root)["uin"]);
3079
+ return uin === "" ? void 0 : uin;
3080
+ }
3081
+ /**
3082
+ * Project one transfer record into a vault credential, or undefined when the
3083
+ * record carries no token (the importer's skip rule).
3084
+ *
3085
+ * `uid` empty is tolerated at THIS layer because a token-only record still
3086
+ * describes a usable credential; the Host's region gate and the store's
3087
+ * account id (`uin ?? uid ?? nickname`) decide whether it is representable.
3088
+ * An unopenable `auth_raw` is ignored — the flat fields above are the
3089
+ * contract, `auth_raw` is extra fidelity for the write-back path.
3090
+ */
3091
+ function credentialOfTransferRecord(record) {
3092
+ const accessToken = record["access_token"];
3093
+ if (typeof accessToken !== "string" || accessToken === "") return void 0;
3094
+ const refreshToken = typeof record["refresh_token"] === "string" ? record["refresh_token"] : "";
3095
+ const expiresAtMs = typeof record["expiresAt"] === "number" && Number.isFinite(record["expiresAt"]) && record["expiresAt"] > 0 ? record["expiresAt"] : 0;
3096
+ const refreshExpiresAtMs = typeof record["refreshExpiresAt"] === "number" && Number.isFinite(record["refreshExpiresAt"]) && record["refreshExpiresAt"] > 0 ? record["refreshExpiresAt"] : void 0;
3097
+ const uin = uinOfRaw(record["auth_raw"]);
3098
+ return {
3099
+ accessToken,
3100
+ refreshToken,
3101
+ expiresAtMs,
3102
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
3103
+ domain: typeof record["domain"] === "string" ? record["domain"] : "",
3104
+ uid: displayString(record["uid"]),
3105
+ ...record["nickname"] !== void 0 ? { nickname: displayString(record["nickname"]) } : {},
3106
+ ...uin === void 0 ? {} : { uin }
3107
+ };
3108
+ }
3109
+ /**
3110
+ * Pure merge: fold selected records into a CHANGES map keyed by account id.
3111
+ *
3112
+ * Mirrors the sibling merger's contract: a record with the same account id
3113
+ * OVERWRITES the stored entry (the imported tokens replace what is stored —
3114
+ * re-importing a re-login must refresh it; the write layer stores the record
3115
+ * atomically, so "overwrite" needs no knowledge of the incumbent), and a
3116
+ * record without a token is skipped. The region gate lives HERE rather than
3117
+ * in the route so it is testable without a Host: a record whose login domain
3118
+ * maps to the other region's tab is skipped, because the two regions'
3119
+ * providers are parallel stacks and a cross-region import would cross-bill.
3120
+ *
3121
+ * The caller writes one vault file per map entry, so a selection that is
3122
+ * already stored rewrites those files with identical content — idempotent,
3123
+ * and the steady state of importing the same file twice.
3124
+ */
3125
+ function mergeTransferRecords(records, indexes, region, knownRegionOfDomain) {
3126
+ const changes = /* @__PURE__ */ new Map();
3127
+ let imported = 0;
3128
+ let skipped = 0;
3129
+ for (const index of indexes) {
3130
+ const record = records[index];
3131
+ if (record === void 0) {
3132
+ skipped += 1;
3133
+ continue;
3134
+ }
3135
+ const credential = credentialOfTransferRecord(record);
3136
+ if (credential === void 0) {
3137
+ skipped += 1;
3138
+ continue;
3139
+ }
3140
+ if (knownRegionOfDomain(credential.domain) !== region) {
3141
+ skipped += 1;
3142
+ continue;
3143
+ }
3144
+ const id = workbuddyAccountId(credential);
3145
+ changes.set(id, credential);
3146
+ imported += 1;
3147
+ }
3148
+ return {
3149
+ changes,
3150
+ imported,
3151
+ skipped
3152
+ };
3153
+ }
3154
+ //#endregion
2692
3155
  //#region src/account-pool-store.ts
2693
3156
  /**
2694
3157
  * The account pool's RUNTIME store: measured facts, on disk, outside settings.
@@ -2832,6 +3295,18 @@ async function writePoolProbes(region, updates) {
2832
3295
  */
2833
3296
  /** Plugin-owned usage endpoint consumed by its browser half. */
2834
3297
  const WORKBUDDY_USAGE_PATH = "/plugins/dsh-connect-workbuddy/usage";
3298
+ /**
3299
+ * Per-account credit balances for one region, read ONLY while the composer
3300
+ * panel is open.
3301
+ *
3302
+ * Separate from {@link WORKBUDDY_USAGE_PATH} because its cost is different in
3303
+ * kind: the usage document is one upstream read for the selected account, while
3304
+ * this is one read PER account in the region. Folding it into the usage route
3305
+ * would make every 5-minute refresh pay for a table nobody is looking at, so it
3306
+ * is fetched on demand instead — the same principle the pool applies to its
3307
+ * per-account figures.
3308
+ */
3309
+ const WORKBUDDY_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-workbuddy/account-credits";
2835
3310
  /** Plugin-owned live model refresh endpoint. */
2836
3311
  const WORKBUDDY_MODELS_REFRESH_PATH = "/plugins/dsh-connect-workbuddy/models/refresh";
2837
3312
  /** Plugin-owned local account rescan endpoint. */
@@ -2860,14 +3335,59 @@ const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
2860
3335
  * splitting them would duplicate the guards and invite them to drift apart.
2861
3336
  */
2862
3337
  const WORKBUDDY_POOL_PATH = "/plugins/dsh-connect-workbuddy/pool";
3338
+ /**
3339
+ * Plugin-owned OAuth sign-in endpoint: one session step per call.
3340
+ *
3341
+ * `?action=start` asks the upstream for a login state and returns the URL to
3342
+ * open; `?action=poll` (+ `loginId` in the body) reports whether the user
3343
+ * finished, and on success persists the account into the vault. POST +
3344
+ * loopback-only like the card's other mutations: the poll carries the login
3345
+ * binding, and the Host-side session state must not be settable by another
3346
+ * origin.
3347
+ */
3348
+ const WORKBUDDY_OAUTH_PATH = "/plugins/dsh-connect-workbuddy/oauth";
2863
3349
  /** Query parameter selecting which pool batch action a request means. */
2864
3350
  const WORKBUDDY_POOL_ACTION_PARAM = "action";
3351
+ /** Read the OAuth action off a request URL; unknown/absent means undefined. */
3352
+ function oauthActionOf(url) {
3353
+ const at = url.indexOf("?");
3354
+ if (at === -1) return void 0;
3355
+ const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACTION_PARAM);
3356
+ return value === "start" || value === "poll" ? value : void 0;
3357
+ }
2865
3358
  /** Read the pool action off a request URL; unknown/absent means undefined. */
2866
3359
  function poolActionOf(url) {
2867
3360
  const at = url.indexOf("?");
2868
3361
  if (at === -1) return void 0;
2869
3362
  const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACTION_PARAM);
2870
- return value === "checkin" || value === "test" ? value : void 0;
3363
+ return value === "checkin" || value === "test" || value === "remove" ? value : void 0;
3364
+ }
3365
+ /** Query parameter naming ONE pool account a per-row request addresses. */
3366
+ const WORKBUDDY_POOL_ACCOUNT_PARAM = "accountId";
3367
+ /**
3368
+ * Plugin-owned account transfer endpoint: the batch export / import of
3369
+ * credentials between machines and between tools of the SAME format family.
3370
+ *
3371
+ * `?action=export` POSTs account ids, answers a JSON ARRAY of credential
3372
+ * records; `?action=preview` POSTs file text, answers a token-free listing;
3373
+ * `?action=import` POSTs file text plus selected indexes, merges into the
3374
+ * vault. POST + loopback-only like every other mutation: the file content is
3375
+ * arbitrary user data and export carries token material.
3376
+ */
3377
+ const WORKBUDDY_TRANSFER_PATH = "/plugins/dsh-connect-workbuddy/transfer";
3378
+ /** Read the transfer action off a request URL; unknown/absent means undefined. */
3379
+ function transferActionOf(url) {
3380
+ const at = url.indexOf("?");
3381
+ if (at === -1) return void 0;
3382
+ const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACTION_PARAM);
3383
+ return value === "export" || value === "preview" || value === "import" ? value : void 0;
3384
+ }
3385
+ /** Read the per-row account id off a request URL, when the action takes one. */
3386
+ function poolAccountIdOf(url) {
3387
+ const at = url.indexOf("?");
3388
+ if (at === -1) return void 0;
3389
+ const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACCOUNT_PARAM);
3390
+ return value !== null && value !== "" ? value : void 0;
2871
3391
  }
2872
3392
  /** Query parameter naming the region a card request addresses. */
2873
3393
  const WORKBUDDY_REGION_PARAM = "region";
@@ -3027,6 +3547,23 @@ function json(res, status, body) {
3027
3547
  });
3028
3548
  res.end(payload);
3029
3549
  }
3550
+ /**
3551
+ * A JSON response that must never be cached.
3552
+ *
3553
+ * Used for every answer that carries live credentials — the transfer export
3554
+ * hands the browser real access and refresh tokens. Without `no-store` such a
3555
+ * response is eligible for the browser's HTTP cache, where a token can outlive
3556
+ * the session on disk.
3557
+ */
3558
+ function jsonPrivate(res, status, body) {
3559
+ const payload = JSON.stringify(body);
3560
+ res.writeHead(status, {
3561
+ "Content-Type": "application/json",
3562
+ "Content-Length": Buffer.byteLength(payload),
3563
+ "Cache-Control": "no-store"
3564
+ });
3565
+ res.end(payload);
3566
+ }
3030
3567
  /** Request-body cap for the probe route: model ids only, never content. */
3031
3568
  const PROBE_BODY_LIMIT = 65536;
3032
3569
  /**
@@ -3158,6 +3695,39 @@ function volatileFlagOf(field) {
3158
3695
  return Config.dict?.[field]?.meta?.volatile === true;
3159
3696
  }
3160
3697
  /**
3698
+ * Per-account credit balances for one region, for the composer panel's table.
3699
+ *
3700
+ * ONE upstream read per account, which is exactly why this is its own route:
3701
+ * the panel calls it only while it is open, so the 5-minute readout never
3702
+ * multiplies its cost by the account count.
3703
+ *
3704
+ * Every account is listed even when its balance cannot be read — a credential
3705
+ * that no longer resolves, or an upstream that refused — because the panel's
3706
+ * job is to show which accounts exist and let the user switch. Such a row
3707
+ * carries NO `credits` field rather than `0`, so the panel renders "—" instead
3708
+ * of telling the user they are out of credits.
3709
+ *
3710
+ * Failures are contained per account: one dead credential must not blank the
3711
+ * table for the accounts that are fine.
3712
+ */
3713
+ async function workBuddyAccountCredits(deps, region) {
3714
+ const store = deps.store(region);
3715
+ const accounts = await store.accounts();
3716
+ const exclusions = await deps.accountExclusions?.(region).catch(() => void 0) ?? {};
3717
+ return Promise.all(accounts.map(async (account) => {
3718
+ const credential = await store.credentialFor(account.id).catch(() => void 0);
3719
+ const credits = credential === void 0 ? void 0 : await deps.client.fetchCredits(credential).then((answer) => answer.total).catch(() => void 0);
3720
+ const excludedBy = exclusions[account.id];
3721
+ return {
3722
+ id: account.id,
3723
+ accountName: account.accountName,
3724
+ selected: account.selected,
3725
+ ...credits === void 0 ? {} : { credits },
3726
+ ...excludedBy === void 0 ? {} : { excludedBy }
3727
+ };
3728
+ }));
3729
+ }
3730
+ /**
3161
3731
  * Assemble one region's card document. `region` is the tab the card is on;
3162
3732
  * the region-scoped store already answers with only that region's accounts,
3163
3733
  * so the document's model slots and account list are that region's by
@@ -3169,13 +3739,15 @@ async function workBuddyWebStatus(deps, region) {
3169
3739
  const accounts = await store.accounts();
3170
3740
  deps.regionUsable?.(region, accounts.length > 0);
3171
3741
  const enabled = deps.regionEnabled(region);
3742
+ const showCreditsInMainUi = deps.showCreditsInMainUi?.(region) ?? true;
3172
3743
  const authStatus = await store.status();
3173
3744
  const selectionExplicit = store.hasExplicitSelection();
3174
3745
  if (authStatus.state !== "signed-out" && accounts.length === 0) return {
3175
3746
  status: "signed-out",
3176
3747
  accounts: [],
3177
3748
  selectionExplicit,
3178
- enabled
3749
+ enabled,
3750
+ showCreditsInMainUi
3179
3751
  };
3180
3752
  let credential;
3181
3753
  try {
@@ -3188,6 +3760,7 @@ async function workBuddyWebStatus(deps, region) {
3188
3760
  message: safeMessage(error),
3189
3761
  selectionExplicit,
3190
3762
  enabled,
3763
+ showCreditsInMainUi,
3191
3764
  ...await store.selectionLost() ? { selectionLost: true } : {},
3192
3765
  ...webAccounts.length === 0 ? await searchedPaths(store) : {}
3193
3766
  };
@@ -3202,6 +3775,7 @@ async function workBuddyWebStatus(deps, region) {
3202
3775
  tokenExpiresAtMs: credential.expiresAtMs,
3203
3776
  selectionExplicit,
3204
3777
  enabled,
3778
+ showCreditsInMainUi,
3205
3779
  accounts: accounts.map(toWebAccount),
3206
3780
  models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))),
3207
3781
  enabledModelIds: [...deps.enabledModelIds(region)],
@@ -3354,6 +3928,21 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
3354
3928
  }
3355
3929
  }
3356
3930
  });
3931
+ const disposeAccountCredits = ctx.webServer.register({
3932
+ kind: "exact",
3933
+ path: WORKBUDDY_ACCOUNT_CREDITS_PATH,
3934
+ handler: async (req, res) => {
3935
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
3936
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
3937
+ const region = requestRegion(req, res);
3938
+ if (region === void 0) return;
3939
+ try {
3940
+ json(res, 200, { accounts: await workBuddyAccountCredits(deps, region) });
3941
+ } catch (error) {
3942
+ json(res, 500, { error: safeMessage(error) });
3943
+ }
3944
+ }
3945
+ });
3357
3946
  const disposeAccounts = ctx.webServer.register({
3358
3947
  kind: "exact",
3359
3948
  path: WORKBUDDY_ACCOUNTS_REFRESH_PATH,
@@ -3444,12 +4033,60 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
3444
4033
  const region = requestRegion(req, res);
3445
4034
  if (region === void 0) return;
3446
4035
  const action = poolActionOf(req.url ?? "/");
3447
- if (action === void 0) return json(res, 400, { error: "action must be checkin or test" });
4036
+ if (action === void 0) return json(res, 400, { error: "action must be checkin, test, or remove" });
3448
4037
  const pool = deps.pool;
3449
4038
  if (pool === void 0) return json(res, 503, {
3450
4039
  reason: "pool-unavailable",
3451
4040
  error: "account pool unavailable"
3452
4041
  });
4042
+ const rowAccountId = poolAccountIdOf(req.url ?? "/");
4043
+ if (action === "remove") {
4044
+ if (pool.removeOne === void 0) return json(res, 503, {
4045
+ reason: "pool-unavailable",
4046
+ error: "per-account removal unavailable"
4047
+ });
4048
+ if (rowAccountId === void 0) return json(res, 400, { error: "accountId must name the account to remove" });
4049
+ if (!isVaultAccountId(rowAccountId)) return json(res, 400, { error: "accountId is not a well-formed account id" });
4050
+ try {
4051
+ json(res, 200, {
4052
+ action: "remove",
4053
+ accountId: rowAccountId,
4054
+ removed: await pool.removeOne(region, rowAccountId)
4055
+ });
4056
+ } catch (error) {
4057
+ json(res, 500, {
4058
+ reason: "pool-failed",
4059
+ error: safeMessage(error)
4060
+ });
4061
+ }
4062
+ return;
4063
+ }
4064
+ if (action === "test" && rowAccountId !== void 0) {
4065
+ if (pool.testOne === void 0) return json(res, 503, {
4066
+ reason: "pool-unavailable",
4067
+ error: "per-account test unavailable"
4068
+ });
4069
+ try {
4070
+ const row = await pool.testOne(region, rowAccountId);
4071
+ if (row === void 0) return json(res, 409, {
4072
+ action: "test",
4073
+ modelId: void 0,
4074
+ rows: [],
4075
+ reason: "no-free-model",
4076
+ error: "no zero-multiplier model in this region; refresh the catalog or set a target model"
4077
+ });
4078
+ json(res, 200, {
4079
+ action: "test",
4080
+ rows: [row]
4081
+ });
4082
+ } catch (error) {
4083
+ json(res, 500, {
4084
+ reason: "pool-failed",
4085
+ error: safeMessage(error)
4086
+ });
4087
+ }
4088
+ return;
4089
+ }
3453
4090
  try {
3454
4091
  const savedMembers = pool.preferences(region).memberAccountIds;
3455
4092
  if ((await pool.effectiveMemberAccountIds?.(region).catch(() => void 0) ?? savedMembers).length === 0) {
@@ -3581,13 +4218,79 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
3581
4218
  }
3582
4219
  }
3583
4220
  });
4221
+ const disposeOAuth = ctx.webServer.register({
4222
+ kind: "exact",
4223
+ path: WORKBUDDY_OAUTH_PATH,
4224
+ handler: async (req, res) => {
4225
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4226
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
4227
+ const region = requestRegion(req, res);
4228
+ if (region === void 0) return;
4229
+ const action = oauthActionOf(req.url ?? "/");
4230
+ if (action === void 0) return json(res, 400, { error: "action must be start or poll" });
4231
+ const oauth = deps.oauth;
4232
+ if (oauth === void 0) return json(res, 503, {
4233
+ reason: "oauth-unavailable",
4234
+ error: "OAuth sign-in is not available in this build"
4235
+ });
4236
+ try {
4237
+ if (action === "start") {
4238
+ json(res, 200, await oauth.start(region));
4239
+ return;
4240
+ }
4241
+ const body = await readJsonBody(req);
4242
+ if (typeof body.loginId !== "string" || body.loginId === "") return json(res, 400, { error: "loginId must be a non-empty string" });
4243
+ json(res, 200, await oauth.poll(region, body.loginId));
4244
+ } catch (error) {
4245
+ json(res, 500, { error: safeMessage(error) });
4246
+ }
4247
+ }
4248
+ });
4249
+ const disposeTransfer = ctx.webServer.register({
4250
+ kind: "exact",
4251
+ path: WORKBUDDY_TRANSFER_PATH,
4252
+ handler: async (req, res) => {
4253
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4254
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
4255
+ const region = requestRegion(req, res);
4256
+ if (region === void 0) return;
4257
+ const action = transferActionOf(req.url ?? "/");
4258
+ if (action === void 0) return json(res, 400, { error: "action must be export, preview, or import" });
4259
+ const transfer = deps.transfer;
4260
+ if (transfer === void 0) return json(res, 503, {
4261
+ reason: "transfer-unavailable",
4262
+ error: "account transfer is not available in this build"
4263
+ });
4264
+ try {
4265
+ if (action === "export") {
4266
+ const body = await readJsonBody(req);
4267
+ const accountIds = Array.isArray(body.accountIds) ? body.accountIds.filter((id) => typeof id === "string" && id !== "") : [];
4268
+ if (accountIds.length === 0) return json(res, 400, { error: "accountIds must be a non-empty array of strings" });
4269
+ const answer = await transfer.exportAccounts(region, accountIds);
4270
+ if (answer.accounts.length === 0) return json(res, 409, { error: "none of the named accounts was found in this region" });
4271
+ return jsonPrivate(res, 200, answer);
4272
+ }
4273
+ const body = await readJsonBody(req);
4274
+ if (typeof body.text !== "string" || body.text.trim() === "") return json(res, 400, { error: "text must be the transfer file's JSON content" });
4275
+ if (action === "preview") return json(res, 200, await transfer.previewImport(region, body.text));
4276
+ const indexes = Array.isArray(body.indexes) ? body.indexes.filter((value) => typeof value === "number" && Number.isInteger(value) && value >= 0) : [];
4277
+ if (indexes.length === 0) return json(res, 400, { error: "indexes must name at least one file entry to import" });
4278
+ return json(res, 200, await transfer.importAccounts(region, body.text, indexes));
4279
+ } catch (error) {
4280
+ json(res, 500, { error: safeMessage(error) });
4281
+ }
4282
+ }
4283
+ });
3584
4284
  return () => {
3585
4285
  disposeRefresh();
3586
4286
  disposeCheckin();
3587
4287
  disposeAccounts();
3588
4288
  disposeUsage();
4289
+ disposeAccountCredits();
3589
4290
  disposeProbe();
3590
4291
  disposePool();
4292
+ disposeOAuth();
4293
+ disposeTransfer();
3591
4294
  disposeDiagWrite();
3592
4295
  };
3593
4296
  }, "dsh-connect-workbuddy: Web status route");
@@ -3785,6 +4488,22 @@ const poolConfig = z.object({
3785
4488
  });
3786
4489
  const regionStateConfig = z.object({
3787
4490
  enabled: z.boolean().default(true).description("Whether this region's provider is offered to DSH (opt-out; false withdraws it entirely)"),
4491
+ /**
4492
+ * Whether this region's credit line shows at the DSH sidebar foot.
4493
+ *
4494
+ * Per-REGION rather than one global switch, because the two sides are
4495
+ * independent providers the user may hold only one account on: a CN-only user
4496
+ * has nothing to read from the international line and vice versa, and a
4497
+ * single shared switch would force them to either show a permanently empty
4498
+ * row or lose the one that works.
4499
+ *
4500
+ * ON by default (opt-out). The plugin card is not reachable in every host —
4501
+ * a market plugin can own the Plugins tab and never render
4502
+ * `plugins.bundle.config` — so a feature that is off until discovered would be
4503
+ * off forever for those users. The line carries its own `×` in the sidebar,
4504
+ * which is reachable exactly where the feature renders.
4505
+ */
4506
+ 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
4507
  lastCatalog: z.array(modelConfig).default([]),
3789
4508
  enabledModelIds: z.array(z.string()).default([]),
3790
4509
  imageModelIds: z.array(z.string()).default([]),
@@ -4140,6 +4859,25 @@ function apply(ctx, config) {
4140
4859
  store: (region) => stacks[region].store,
4141
4860
  client,
4142
4861
  accountUsable: accountUsabilityProbe,
4862
+ /**
4863
+ * Why each of the region's accounts cannot serve right now, for the composer
4864
+ * panel's table.
4865
+ *
4866
+ * Runs the SAME `exclusionOf` the pool ranks by, against every account the
4867
+ * region knows — not only pool members. Deriving it any other way (a second
4868
+ * expiry comparison, or `probe.outcome` read raw) would let the panel call an
4869
+ * account rate-limited while the pool has already moved past its cooldown.
4870
+ */
4871
+ accountExclusions: async (region) => {
4872
+ const probes = await readPoolProbes(region);
4873
+ const nowMs = Date.now();
4874
+ const out = {};
4875
+ for (const [accountId, probe] of Object.entries(probes)) {
4876
+ const excludedBy = exclusionOf(probe, nowMs);
4877
+ if (excludedBy !== void 0) out[accountId] = excludedBy;
4878
+ }
4879
+ return out;
4880
+ },
4143
4881
  displayModels: (region) => displayModels(current(), region),
4144
4882
  enabledModelIds: (region) => regionStateOf(current(), region).enabledModelIds ?? [],
4145
4883
  imageModelIds: (region) => regionStateOf(current(), region).imageModelIds ?? [],
@@ -4148,6 +4886,13 @@ function apply(ctx, config) {
4148
4886
  discoverModels,
4149
4887
  regionEnabled: (region) => regionEnabled(current(), region),
4150
4888
  /**
4889
+ * The region's committed sidebar-credit flag, read from the same config the
4890
+ * card writes. Exposed on the Host's answer because the browser settings
4891
+ * mirror goes stale for this namespace on the affected 0.1.7 deployments,
4892
+ * which made the switch look unable to turn the line off ("关闭不了").
4893
+ */
4894
+ showCreditsInMainUi: (region) => regionStateOf(current(), region).showCreditsInMainUi !== false,
4895
+ /**
4151
4896
  * One minimal request per named model, through the region's own credential.
4152
4897
  *
4153
4898
  * Resolved here rather than in the route so the browser half never touches a
@@ -4284,7 +5029,138 @@ function apply(ctx, config) {
4284
5029
  await writePoolProbes(region, probeUpdatesOf(rows));
4285
5030
  return rows;
4286
5031
  },
4287
- catalog: (region) => displayModels(current(), region)
5032
+ catalog: (region) => displayModels(current(), region),
5033
+ /**
5034
+ * Test ONE account against the region's target model.
5035
+ *
5036
+ * The per-row button's Host half. Reuses the batch's probe dependency
5037
+ * (same client, same probe size, same quota-refresh input) so a row
5038
+ * result and a batch result are the SAME measurement — only the
5039
+ * persistence is scoped to one id instead of every member.
5040
+ *
5041
+ * Returns undefined when the region cannot name a target model (the
5042
+ * same refusal the batch route reports as 409) — the card says so
5043
+ * rather than pretending a test ran.
5044
+ */
5045
+ testOne: async (region, accountId) => {
5046
+ const target = regionTargetModel(region);
5047
+ if (target === void 0) return void 0;
5048
+ const store = stacks[region].store;
5049
+ const credential = await store.credentialFor(accountId).catch(() => void 0);
5050
+ if (credential === void 0) return {
5051
+ accountId,
5052
+ accountName: "",
5053
+ result: {
5054
+ modelId: target,
5055
+ outcome: "credential-rejected",
5056
+ status: 0,
5057
+ message: "no stored credential for this account"
5058
+ }
5059
+ };
5060
+ const accountName = (await store.accounts().catch(() => [])).find((account) => account.id === accountId)?.accountName ?? "";
5061
+ const result = await poolRunnerDeps(region).probe(credential, target);
5062
+ await writePoolProbes(region, probeUpdatesOf([{
5063
+ accountId,
5064
+ accountName,
5065
+ result
5066
+ }]));
5067
+ return {
5068
+ accountId,
5069
+ accountName,
5070
+ result
5071
+ };
5072
+ },
5073
+ /**
5074
+ * Forget ONE account's plugin-stored credential (the vault entry; the
5075
+ * desktop app's files are never touched). The card rescans afterwards,
5076
+ * so the row disappears the moment the write lands.
5077
+ */
5078
+ removeOne: async (region, accountId) => stacks[region].store.removeAccount(accountId)
5079
+ },
5080
+ /**
5081
+ * OAuth QR sign-in for this card. The session lives in `src/oauth.ts`
5082
+ * (in-process, keyed by loginId); the Host's only extra duty is
5083
+ * persisting the finished credential into the region's vault — the same
5084
+ * store the request path reads, so the account is selectable the moment
5085
+ * the poll answers.
5086
+ */
5087
+ oauth: {
5088
+ async start(region) {
5089
+ return oauthStart(region);
5090
+ },
5091
+ async poll(region, loginId) {
5092
+ const answer = await oauthPoll(loginId, region);
5093
+ if (!answer.done) return answer;
5094
+ if ("error" in answer) return answer;
5095
+ const store = stacks[region].store;
5096
+ const accountId = await store.addOAuthAccount({
5097
+ accessToken: answer.account.accessToken,
5098
+ refreshToken: answer.account.refreshToken,
5099
+ expiresAtMs: answer.account.expiresAtMs,
5100
+ ...answer.account.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: answer.account.refreshExpiresAtMs },
5101
+ domain: answer.account.domain,
5102
+ uid: answer.account.uid,
5103
+ ...answer.account.nickname === void 0 ? {} : { nickname: answer.account.nickname },
5104
+ ...answer.account.uin === void 0 ? {} : { uin: answer.account.uin },
5105
+ ...answer.account.enterpriseId === void 0 ? {} : { enterpriseId: answer.account.enterpriseId }
5106
+ }).catch((error) => {
5107
+ throw new Error("the account was signed in but could not be saved: " + (error instanceof Error ? error.message : String(error)));
5108
+ });
5109
+ return {
5110
+ done: true,
5111
+ accountId,
5112
+ accountName: (await store.accounts().catch(() => [])).find((account) => account.id === accountId)?.accountName ?? answer.account.nickname ?? ""
5113
+ };
5114
+ }
5115
+ },
5116
+ /**
5117
+ * Batch credential export / import, in the transfer file format the
5118
+ * sibling credential managers share. The Host owns every byte of the
5119
+ * vault; the route only ferries parsed answers.
5120
+ */
5121
+ transfer: {
5122
+ async exportAccounts(region, accountIds) {
5123
+ const store = stacks[region].store;
5124
+ const accounts = await store.accounts().catch(() => []);
5125
+ const wanted = new Set(accountIds);
5126
+ const records = [];
5127
+ for (const account of accounts) {
5128
+ if (!wanted.has(account.id)) continue;
5129
+ const credential = await store.credentialFor(account.id).catch(() => void 0);
5130
+ if (credential === void 0) continue;
5131
+ records.push(transferRecordOf(credential, {
5132
+ auth: {
5133
+ accessToken: credential.accessToken,
5134
+ refreshToken: credential.refreshToken,
5135
+ expiresAt: credential.expiresAtMs > 0 ? Math.floor(credential.expiresAtMs / 1e3) : 0,
5136
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: Math.floor(credential.refreshExpiresAtMs / 1e3) },
5137
+ ...credential.lastRefreshAtMs === void 0 ? {} : { lastRefreshTime: Math.floor(credential.lastRefreshAtMs / 1e3) },
5138
+ domain: credential.domain
5139
+ },
5140
+ account: {
5141
+ uid: credential.uid,
5142
+ ...credential.enterpriseId === void 0 ? {} : { enterpriseId: credential.enterpriseId },
5143
+ ...credential.nickname === void 0 ? {} : { nickname: credential.nickname },
5144
+ ...credential.uin === void 0 ? {} : { uin: credential.uin }
5145
+ }
5146
+ }));
5147
+ }
5148
+ return { accounts: records };
5149
+ },
5150
+ async previewImport(region, text) {
5151
+ return previewTransferFile(text);
5152
+ },
5153
+ async importAccounts(region, text, indexes) {
5154
+ const { changes, imported, skipped } = mergeTransferRecords(parseTransferFile(text), indexes, region, (domain) => knownRegionOf(domain));
5155
+ const store = stacks[region].store;
5156
+ for (const [id, credential] of changes) await store.addOAuthAccount(credential).catch((error) => {
5157
+ throw new Error("imported account " + id + " could not be stored: " + (error instanceof Error ? error.message : String(error)));
5158
+ });
5159
+ return {
5160
+ imported,
5161
+ skipped
5162
+ };
5163
+ }
4288
5164
  }
4289
5165
  }));
4290
5166
  /**
@@ -4512,6 +5388,15 @@ function apply(ctx, config) {
4512
5388
  creditsSnapshotAt[region] = Date.now();
4513
5389
  return rows;
4514
5390
  }
5391
+ /**
5392
+ * The model a per-row test would use right now: the SAME resolution the
5393
+ * batch route performs (`resolveTargetModel` over the region's displayed
5394
+ * catalog and the saved preference), so a row test and a batch test never
5395
+ * measure different models. Undefined mirrors the route's 409 states.
5396
+ */
5397
+ const regionTargetModel = (region) => {
5398
+ return resolveTargetModel(displayModels(current(), region), poolPreferencesOf(current(), region).targetModelId).modelId;
5399
+ };
4515
5400
  /** One region's pool preferences, with the schema's defaults applied. */
4516
5401
  const poolPreferencesOf = (config, region) => {
4517
5402
  const pool = regionStateOf(config, region).pool;
@@ -4693,4 +5578,4 @@ function apply(ctx, config) {
4693
5578
  });
4694
5579
  }
4695
5580
  //#endregion
4696
- export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
5581
+ export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };