@eddyskywalker/dsh-chatgpt-subscription 0.36.2 → 0.37.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +23 -3
  3. package/lib/client.js +340 -47
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +3043 -40
  6. package/lib/types/client/common/ComposerQuotaBadge.d.ts +7 -0
  7. package/lib/types/client/common/ComposerQuotaBadge.d.ts.map +1 -1
  8. package/lib/types/client/locales-ru/opendesign.d.ts.map +1 -1
  9. package/lib/types/client/locales-ru/progress-updates.d.ts.map +1 -1
  10. package/lib/types/client/locales.d.ts +4 -4
  11. package/lib/types/client/opendesign/OpenDesignComposerQuota.d.ts +6 -0
  12. package/lib/types/client/opendesign/OpenDesignComposerQuota.d.ts.map +1 -1
  13. package/lib/types/client/opendesign/OpenDesignSection.d.ts.map +1 -1
  14. package/lib/types/client/opendesign/locales.d.ts +54 -0
  15. package/lib/types/client/opendesign/locales.d.ts.map +1 -1
  16. package/lib/types/client/opendesign/styles.d.ts +5 -0
  17. package/lib/types/client/opendesign/styles.d.ts.map +1 -1
  18. package/lib/types/client/progress-updates/locales.d.ts +17 -2
  19. package/lib/types/client/progress-updates/locales.d.ts.map +1 -1
  20. package/lib/types/client/progress-updates/settings-binding.d.ts +13 -3
  21. package/lib/types/client/progress-updates/settings-binding.d.ts.map +1 -1
  22. package/lib/types/client/progress-updates/settings-section.d.ts +5 -5
  23. package/lib/types/client/progress-updates/settings-section.d.ts.map +1 -1
  24. package/lib/types/client/progress-updates/styles.d.ts.map +1 -1
  25. package/lib/types/host/opendesign/client.d.ts +103 -0
  26. package/lib/types/host/opendesign/client.d.ts.map +1 -1
  27. package/lib/types/host/opendesign/routes.d.ts.map +1 -1
  28. package/lib/types/host/opendesign/types.d.ts +31 -0
  29. package/lib/types/host/opendesign/types.d.ts.map +1 -1
  30. package/lib/types/host/progress-updates/index.d.ts +5 -0
  31. package/lib/types/host/progress-updates/index.d.ts.map +1 -1
  32. package/lib/types/host/progress-updates/prompt.d.ts +37 -3
  33. package/lib/types/host/progress-updates/prompt.d.ts.map +1 -1
  34. package/lib/types/host/progress-updates/tool.d.ts.map +1 -1
  35. package/lib/types/host/proxy-manager.d.ts +63 -1
  36. package/lib/types/host/proxy-manager.d.ts.map +1 -1
  37. package/lib/types/host/widget-board/line-contracts.d.ts +192 -0
  38. package/lib/types/host/widget-board/line-contracts.d.ts.map +1 -0
  39. package/lib/types/host/widget-board/line-quota.d.ts +284 -0
  40. package/lib/types/host/widget-board/line-quota.d.ts.map +1 -0
  41. package/lib/types/host/widget-board/line-registry.d.ts +402 -0
  42. package/lib/types/host/widget-board/line-registry.d.ts.map +1 -0
  43. package/lib/types/host/widget-board/provider.d.ts +140 -0
  44. package/lib/types/host/widget-board/provider.d.ts.map +1 -0
  45. package/lib/types/index.d.ts +10 -0
  46. package/lib/types/index.d.ts.map +1 -1
  47. package/lib/types/shared/opendesign-contracts.d.ts +47 -0
  48. package/lib/types/shared/opendesign-contracts.d.ts.map +1 -1
  49. package/package.json +2 -1
  50. package/widgets/line-flip/index.html +58 -0
  51. package/widgets/line-flip/main.js +1102 -0
  52. package/widgets/line-flip/style.css +729 -0
  53. package/widgets/line-flip/widget.json +555 -0
package/lib/index.js CHANGED
@@ -803,7 +803,7 @@ async function rehydrateSnapshot(key) {
803
803
  * so a backend that flattens the envelope does not read as an empty catalog.
804
804
  */
805
805
  function parseListing(payload) {
806
- const record = asRecord$16(payload);
806
+ const record = asRecord$18(payload);
807
807
  const models = Array.isArray(payload) ? payload : record?.models;
808
808
  if (!Array.isArray(models)) return [];
809
809
  return models.flatMap((item) => parseListingEntry$1(item));
@@ -817,11 +817,11 @@ function parseListing(payload) {
817
817
  * back to a conservative answer rather than to a guess.
818
818
  */
819
819
  function parseListingEntry$1(value) {
820
- const record = asRecord$16(value);
820
+ const record = asRecord$18(value);
821
821
  if (record === void 0) return [];
822
822
  const id = asString$25(record.slug) ?? asString$25(record.id);
823
823
  if (id === void 0) return [];
824
- const contextWindow = asNumber$6(record.context_window) ?? asNumber$6(record.contextWindow);
824
+ const contextWindow = asNumber$7(record.context_window) ?? asNumber$7(record.contextWindow);
825
825
  const modalities = parseModalities(record.input_modalities);
826
826
  const efforts = parseEfforts(record.supported_reasoning_levels);
827
827
  const fallback = CODEX_MODEL_CATALOG.find((entry) => entry.id === id);
@@ -853,7 +853,7 @@ function parseEfforts(value) {
853
853
  if (!Array.isArray(value)) return null;
854
854
  const out = [];
855
855
  for (const item of value) {
856
- const effort = asString$25(item) ?? asString$25(asRecord$16(item)?.effort);
856
+ const effort = asString$25(item) ?? asString$25(asRecord$18(item)?.effort);
857
857
  if (effort !== void 0 && !out.includes(effort)) out.push(effort);
858
858
  }
859
859
  return out.length > 0 ? out : null;
@@ -868,13 +868,13 @@ function parseEfforts(value) {
868
868
  function accountKeyFor$1(credentials) {
869
869
  return credentials.accountId || "default";
870
870
  }
871
- function asRecord$16(value) {
871
+ function asRecord$18(value) {
872
872
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
873
873
  }
874
874
  function asString$25(value) {
875
875
  return typeof value === "string" && value !== "" ? value : void 0;
876
876
  }
877
- function asNumber$6(value) {
877
+ function asNumber$7(value) {
878
878
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
879
879
  }
880
880
  //#endregion
@@ -8675,7 +8675,143 @@ function parseEnvProxy(env = process.env, options = {}) {
8675
8675
  } catch {}
8676
8676
  return null;
8677
8677
  }
8678
- function detectSystemProxy(platform = process.platform, env = process.env) {
8678
+ /**
8679
+ * The raw `NO_PROXY` list, or an empty string when none is configured.
8680
+ *
8681
+ * Read next to the proxy itself on purpose: a proxy that a line is *told* to use
8682
+ * and a bypass list that lives somewhere else are how "the local gateway
8683
+ * suddenly goes through Clash" happens.
8684
+ */
8685
+ function parseNoProxy(env = process.env, options = {}) {
8686
+ const direct = env.NO_PROXY ?? env.no_proxy;
8687
+ if (direct && direct.trim()) return direct.trim();
8688
+ if (options.envFile === null) return "";
8689
+ try {
8690
+ const dshHome = env.DSH_HOME || path.join(os.homedir(), ".dsh");
8691
+ const envFile = options.envFile ?? path.join(dshHome, ".env");
8692
+ if (fs$1.existsSync(envFile)) {
8693
+ const match = fs$1.readFileSync(envFile, "utf8").match(/^(?:export\s+)?(?:NO_PROXY|no_proxy)\s*=\s*["']?([^"'\r\n]*)["']?/m);
8694
+ if (match) return (match[1] ?? "").trim();
8695
+ }
8696
+ } catch {}
8697
+ return "";
8698
+ }
8699
+ /** {@link shouldBypassProxy} needs a port to honor a `host:port` entry. */
8700
+ function defaultPortFor(protocol) {
8701
+ if (protocol === "http:") return "80";
8702
+ if (protocol === "https:") return "443";
8703
+ return "";
8704
+ }
8705
+ function normalizeHostForMatch(hostname) {
8706
+ return hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
8707
+ }
8708
+ /**
8709
+ * Whether a host is loopback, and therefore never worth a proxy hop.
8710
+ *
8711
+ * The whole `127.0.0.0/8` block, not just `127.0.0.1`: a second loopback alias
8712
+ * is a normal way to run a local gateway, and a proxy cannot reach it any
8713
+ * better than the process itself can.
8714
+ */
8715
+ function isLoopbackHostname$1(hostname) {
8716
+ const host = normalizeHostForMatch(hostname);
8717
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
8718
+ if (host === "::1") return true;
8719
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
8720
+ return v4 !== null && v4[1] === "127";
8721
+ }
8722
+ /**
8723
+ * Standard `NO_PROXY` matching: `*`, exact hosts, domain suffixes, `host:port`.
8724
+ *
8725
+ * Deliberately not CIDR: a proxy configured for this plugin is a local GUI
8726
+ * proxy, and the entries that matter are the ones a user types.
8727
+ */
8728
+ function noProxyBypasses(hostname, port, list) {
8729
+ const host = normalizeHostForMatch(hostname);
8730
+ for (const raw of list.split(",")) {
8731
+ const entry = raw.trim().toLowerCase();
8732
+ if (!entry) continue;
8733
+ if (entry === "*") return true;
8734
+ const withoutScheme = entry.replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/\/.*$/, "");
8735
+ let entryHost = withoutScheme;
8736
+ let entryPort = "";
8737
+ const bracketed = withoutScheme.match(/^\[([^\]]+)\](?::(\d+))?$/);
8738
+ if (bracketed) {
8739
+ entryHost = bracketed[1];
8740
+ entryPort = bracketed[2] ?? "";
8741
+ } else if (withoutScheme.split(":").length === 2) {
8742
+ const [name, candidate] = withoutScheme.split(":");
8743
+ if (/^\d+$/.test(candidate)) {
8744
+ entryHost = name;
8745
+ entryPort = candidate;
8746
+ }
8747
+ }
8748
+ const suffix = entryHost.replace(/^\./, "").replace(/\.$/, "");
8749
+ if (!suffix) continue;
8750
+ if (entryPort && entryPort !== port) continue;
8751
+ if (host === suffix || host.endsWith("." + suffix)) return true;
8752
+ }
8753
+ return false;
8754
+ }
8755
+ /**
8756
+ * Whether a request target must skip the proxy.
8757
+ *
8758
+ * Loopback first: a local gateway behind a proxy hop is a request the proxy has
8759
+ * to refuse (or worse, forwards to itself), and the plugin's own loopback reads
8760
+ * already use a bare fetch for the same reason.
8761
+ */
8762
+ function shouldBypassProxy(target, noProxyList = "") {
8763
+ let url;
8764
+ try {
8765
+ url = typeof target === "string" ? new URL(target) : target;
8766
+ } catch {
8767
+ return false;
8768
+ }
8769
+ if (isLoopbackHostname$1(url.hostname)) return true;
8770
+ if (!noProxyList) return false;
8771
+ return noProxyBypasses(url.hostname, url.port || defaultPortFor(url.protocol), noProxyList);
8772
+ }
8773
+ /**
8774
+ * Read one GNOME proxy setting; `null` when `gsettings` is absent or the key is
8775
+ * unreadable (headless box, no dconf session).
8776
+ */
8777
+ function readGsettings(key) {
8778
+ try {
8779
+ return execSync(`gsettings get ${key}`, {
8780
+ timeout: 1500,
8781
+ encoding: "utf-8",
8782
+ stdio: [
8783
+ "ignore",
8784
+ "pipe",
8785
+ "ignore"
8786
+ ]
8787
+ }).trim();
8788
+ } catch {
8789
+ return null;
8790
+ }
8791
+ }
8792
+ /** gsettings prints strings single-quoted, ports bare, and absent keys as ''. */
8793
+ function gsettingsString(value) {
8794
+ if (value === null) return "";
8795
+ return value.replace(/^['"]|['"]$/g, "").trim();
8796
+ }
8797
+ /**
8798
+ * GNOME's manual proxy, the Linux equivalent of the Windows registry read.
8799
+ *
8800
+ * `auto` is a PAC URL, which cannot be applied by posting through an agent, and
8801
+ * `none` means no system proxy; both fall through to the environment.
8802
+ */
8803
+ function parseLinuxSystemProxy(read) {
8804
+ if (gsettingsString(read("org.gnome.system.proxy mode")).toLowerCase() !== "manual") return null;
8805
+ const scheme = (name) => {
8806
+ const host = gsettingsString(read(`org.gnome.system.proxy.${name} host`));
8807
+ const port = Number(gsettingsString(read(`org.gnome.system.proxy.${name} port`)));
8808
+ if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) return null;
8809
+ const authority = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
8810
+ return `${name === "socks" ? "socks5" : "http"}://${authority}:${port}`;
8811
+ };
8812
+ return scheme("https") ?? scheme("http") ?? scheme("socks");
8813
+ }
8814
+ function detectSystemProxy(platform = process.platform, env = process.env, readSettings = readGsettings) {
8679
8815
  try {
8680
8816
  if (platform === "win32") {
8681
8817
  const detected = parseWindowsProxyRegistry(execSync("reg query \"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\"", {
@@ -8699,17 +8835,33 @@ function detectSystemProxy(platform = process.platform, env = process.env) {
8699
8835
  ]
8700
8836
  }));
8701
8837
  if (detected) return detected;
8838
+ } else if (platform === "linux") {
8839
+ const detected = parseLinuxSystemProxy(readSettings);
8840
+ if (detected) return detected;
8702
8841
  }
8703
8842
  } catch {}
8704
8843
  return parseEnvProxy(env);
8705
8844
  }
8845
+ /**
8846
+ * The URL a fetch call targets, for the bypass decision.
8847
+ *
8848
+ * A `Request` exposes its URL without consuming the body, so reading it here
8849
+ * costs the request nothing.
8850
+ */
8851
+ function requestTarget(input) {
8852
+ if (typeof input === "string" || input instanceof URL) return input;
8853
+ return input.url;
8854
+ }
8706
8855
  var ProxyManager = class {
8707
8856
  getPreferences;
8708
8857
  baseFetch;
8709
8858
  systemProxyDetector;
8859
+ noProxyReader;
8710
8860
  logger;
8711
8861
  cachedSystemProxy = null;
8712
8862
  lastSystemProxyCheck = 0;
8863
+ cachedNoProxy = "";
8864
+ lastNoProxyCheck = 0;
8713
8865
  detected = false;
8714
8866
  proxyListeners = /* @__PURE__ */ new Set();
8715
8867
  agents = /* @__PURE__ */ new Map();
@@ -8717,6 +8869,7 @@ var ProxyManager = class {
8717
8869
  this.getPreferences = options.getPreferences;
8718
8870
  this.baseFetch = options.baseFetch ?? fetch;
8719
8871
  this.systemProxyDetector = options.systemProxyDetector ?? (() => detectSystemProxy());
8872
+ this.noProxyReader = options.noProxyReader ?? (() => parseNoProxy());
8720
8873
  this.logger = options.logger;
8721
8874
  }
8722
8875
  getSystemProxy(force = false) {
@@ -8738,6 +8891,24 @@ var ProxyManager = class {
8738
8891
  return this.cachedSystemProxy;
8739
8892
  }
8740
8893
  /**
8894
+ * The active `NO_PROXY` list, re-read at most once per TTL.
8895
+ *
8896
+ * A reader that throws (an unreadable `$DSH_HOME/.env`) reads as "no bypass
8897
+ * list", never as "bypass everything": the wrong guess in that direction would
8898
+ * take an active proxy away from every line at once.
8899
+ */
8900
+ getNoProxyList(force = false) {
8901
+ const now = Date.now();
8902
+ if (!force && now - this.lastNoProxyCheck < SYSTEM_PROXY_CACHE_TTL_MS) return this.cachedNoProxy;
8903
+ this.lastNoProxyCheck = now;
8904
+ try {
8905
+ this.cachedNoProxy = this.noProxyReader().trim();
8906
+ } catch {
8907
+ this.cachedNoProxy = "";
8908
+ }
8909
+ return this.cachedNoProxy;
8910
+ }
8911
+ /**
8741
8912
  * Observe the system proxy becoming known.
8742
8913
  *
8743
8914
  * A proxy that appears after startup — or a first detection that failed — otherwise leaves every
@@ -8775,6 +8946,7 @@ var ProxyManager = class {
8775
8946
  return async (input, init) => {
8776
8947
  const activeProxy = this.resolveActiveProxyUrl();
8777
8948
  if (!activeProxy) return this.baseFetch(input, init);
8949
+ if (shouldBypassProxy(requestTarget(input), this.getNoProxyList())) return this.baseFetch(input, init);
8778
8950
  try {
8779
8951
  const agent = this.getOrCreateAgent(activeProxy);
8780
8952
  return await fetch$1(input, {
@@ -12962,6 +13134,2295 @@ function startWebProviderReconciler(options) {
12962
13134
  };
12963
13135
  }
12964
13136
  //#endregion
13137
+ //#region src/host/widget-board/line-contracts.ts
13138
+ /**
13139
+ * Provider name this plugin registers under: the BARE package id, without the
13140
+ * `@eddyskywalker/` scope.
13141
+ *
13142
+ * A manifest names a data source or a capability as `<provider>:<id>`, and that
13143
+ * whole string has to match the board's id regular expression
13144
+ * `^[a-z][a-z0-9.-]{1,39}(?::[a-z][a-z0-9.-]{1,39})?$` — which accepts neither
13145
+ * `@` nor `/`. A scoped provider name would therefore be registrable on the host
13146
+ * and unreachable from every manifest, so the registration and the manifests
13147
+ * must agree on this literal.
13148
+ */
13149
+ const WIDGET_BOARD_PROVIDER = "dsh-chatgpt-subscription";
13150
+ /** Data source: shape C for the line named by \`props.line\`. */
13151
+ const LINE_CARD_DATA_SOURCE_ID = "line-card";
13152
+ /** Capability: refresh one line's quota, then answer shape C. */
13153
+ const LINE_REFRESH_CAPABILITY_ID = "line-refresh";
13154
+ /** Capability: pin one pooled account of one line, then answer shape C. */
13155
+ const LINE_ACCOUNT_SWITCH_CAPABILITY_ID = "line-account-switch";
13156
+ /** Capability: change one line's rotation strategy, then answer shape C. */
13157
+ const LINE_ACCOUNT_STRATEGY_CAPABILITY_ID = "line-account-strategy";
13158
+ /** A list of those windows. */
13159
+ const LINE_CARD_WINDOWS_SCHEMA = {
13160
+ type: "array",
13161
+ items: {
13162
+ type: "object",
13163
+ additionalProperties: false,
13164
+ required: [
13165
+ "label",
13166
+ "usedPercent",
13167
+ "windowMinutes",
13168
+ "resetsAt"
13169
+ ],
13170
+ properties: {
13171
+ label: { type: "string" },
13172
+ usedPercent: {
13173
+ type: "number",
13174
+ minimum: 0,
13175
+ maximum: 100
13176
+ },
13177
+ windowMinutes: { type: ["number", "null"] },
13178
+ resetsAt: { type: ["number", "null"] }
13179
+ }
13180
+ }
13181
+ };
13182
+ /**
13183
+ * The JSON Schema a capability's return value is validated against.
13184
+ *
13185
+ * Capability outputs only, like the two older shapes: the DATA SOURCE's answer
13186
+ * is validated against the \`data\` schema of the card that reads it, which
13187
+ * lives in \`widgets/<id>/widget.json\` and is therefore a second declaration of
13188
+ * this shape (kept aligned by a test that compares their key sets literally).
13189
+ */
13190
+ const LINE_CARD_SCHEMA = {
13191
+ type: "object",
13192
+ additionalProperties: false,
13193
+ required: [
13194
+ "line",
13195
+ "label",
13196
+ "signedIn",
13197
+ "installed",
13198
+ "planLabel",
13199
+ "accountLabel",
13200
+ "accounts",
13201
+ "activeAccountId",
13202
+ "strategy",
13203
+ "quota",
13204
+ "error"
13205
+ ],
13206
+ properties: {
13207
+ line: {
13208
+ type: "string",
13209
+ minLength: 1
13210
+ },
13211
+ label: { type: "string" },
13212
+ signedIn: { type: "boolean" },
13213
+ installed: { type: "boolean" },
13214
+ planLabel: { type: ["string", "null"] },
13215
+ accountLabel: { type: ["string", "null"] },
13216
+ accounts: {
13217
+ type: "array",
13218
+ items: {
13219
+ type: "object",
13220
+ additionalProperties: false,
13221
+ required: [
13222
+ "id",
13223
+ "label",
13224
+ "email",
13225
+ "primary",
13226
+ "plan",
13227
+ "cooldownUntil",
13228
+ "authStatus",
13229
+ "usedPercent",
13230
+ "resetsAt"
13231
+ ],
13232
+ properties: {
13233
+ id: { type: "string" },
13234
+ label: { type: "string" },
13235
+ email: { type: ["string", "null"] },
13236
+ primary: { type: "boolean" },
13237
+ plan: { type: ["string", "null"] },
13238
+ cooldownUntil: { type: ["number", "null"] },
13239
+ authStatus: { enum: [
13240
+ "ok",
13241
+ "expired",
13242
+ "invalid",
13243
+ null
13244
+ ] },
13245
+ usedPercent: {
13246
+ type: ["number", "null"],
13247
+ minimum: 0,
13248
+ maximum: 100
13249
+ },
13250
+ resetsAt: { type: ["number", "null"] },
13251
+ windows: LINE_CARD_WINDOWS_SCHEMA
13252
+ }
13253
+ }
13254
+ },
13255
+ activeAccountId: { type: ["string", "null"] },
13256
+ strategy: { enum: [
13257
+ "sequential",
13258
+ "round-robin",
13259
+ "sticky"
13260
+ ] },
13261
+ quota: { anyOf: [{
13262
+ type: "object",
13263
+ additionalProperties: false,
13264
+ required: [
13265
+ "windows",
13266
+ "credits",
13267
+ "resetCredits",
13268
+ "fetchedAt",
13269
+ "refreshing",
13270
+ "error"
13271
+ ],
13272
+ properties: {
13273
+ windows: LINE_CARD_WINDOWS_SCHEMA,
13274
+ credits: { anyOf: [{
13275
+ type: "object",
13276
+ additionalProperties: false,
13277
+ required: ["unlimited", "balance"],
13278
+ properties: {
13279
+ unlimited: { type: "boolean" },
13280
+ balance: { type: ["string", "null"] }
13281
+ }
13282
+ }, { type: "null" }] },
13283
+ resetCredits: { anyOf: [{
13284
+ type: "object",
13285
+ additionalProperties: false,
13286
+ required: ["available", "expiresAt"],
13287
+ properties: {
13288
+ available: {
13289
+ type: "integer",
13290
+ minimum: 0
13291
+ },
13292
+ expiresAt: { type: ["number", "null"] }
13293
+ }
13294
+ }, { type: "null" }] },
13295
+ fetchedAt: { type: ["number", "null"] },
13296
+ refreshing: { type: "boolean" },
13297
+ error: { type: ["string", "null"] }
13298
+ }
13299
+ }, { type: "null" }] },
13300
+ error: { type: ["string", "null"] }
13301
+ }
13302
+ };
13303
+ /** The \`line\` argument every line capability takes. */
13304
+ const LINE_ARGUMENT_PROPERTIES = { line: {
13305
+ type: "string",
13306
+ minLength: 1
13307
+ } };
13308
+ /** Arguments of the quota-refresh capability. */
13309
+ const LINE_REFRESH_INPUT_SCHEMA = {
13310
+ type: "object",
13311
+ additionalProperties: false,
13312
+ required: ["line"],
13313
+ properties: LINE_ARGUMENT_PROPERTIES
13314
+ };
13315
+ /** Arguments of the account-switch capability. */
13316
+ const LINE_ACCOUNT_SWITCH_INPUT_SCHEMA = {
13317
+ type: "object",
13318
+ additionalProperties: false,
13319
+ required: ["line", "accountId"],
13320
+ properties: {
13321
+ ...LINE_ARGUMENT_PROPERTIES,
13322
+ accountId: {
13323
+ type: "string",
13324
+ minLength: 1
13325
+ }
13326
+ }
13327
+ };
13328
+ /** Arguments of the account-strategy capability. */
13329
+ const LINE_ACCOUNT_STRATEGY_INPUT_SCHEMA = {
13330
+ type: "object",
13331
+ additionalProperties: false,
13332
+ required: ["line", "strategy"],
13333
+ properties: {
13334
+ ...LINE_ARGUMENT_PROPERTIES,
13335
+ strategy: { enum: [
13336
+ "sequential",
13337
+ "round-robin",
13338
+ "sticky"
13339
+ ] }
13340
+ }
13341
+ };
13342
+ //#endregion
13343
+ //#region src/host/widget-board/line-quota.ts
13344
+ /**
13345
+ * Anything past this instant is treated as "no reset stated".
13346
+ *
13347
+ * The lines are inconsistent about sentinels, not about arithmetic: qoder
13348
+ * reports `expiresAt: 253402214400000`, which is 9999-12-31 and means "never",
13349
+ * and a card that rendered it would print a reset date eight thousand years
13350
+ * out. No real window reopens after 2100, so that is the ceiling — applied when
13351
+ * a window is built, so every line inherits it rather than each one having to
13352
+ * remember.
13353
+ */
13354
+ const SANE_TIMESTAMP_CEILING_MS = 41024448e5;
13355
+ /**
13356
+ * The payload as a JSON object, or null.
13357
+ *
13358
+ * @param value - Candidate.
13359
+ * @returns The object, or null for anything else (arrays included).
13360
+ */
13361
+ function asRecord$17(value) {
13362
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
13363
+ }
13364
+ /**
13365
+ * The payload as a list of JSON objects, dropping anything that is not one.
13366
+ *
13367
+ * @param value - Candidate.
13368
+ * @returns The objects; empty for a non-array.
13369
+ */
13370
+ function asRecords(value) {
13371
+ if (!Array.isArray(value)) return [];
13372
+ const records = [];
13373
+ for (const entry of value) {
13374
+ const record = asRecord$17(entry);
13375
+ if (record !== null) records.push(record);
13376
+ }
13377
+ return records;
13378
+ }
13379
+ /**
13380
+ * A trimmed non-empty string, or null.
13381
+ *
13382
+ * @param value - Candidate.
13383
+ * @returns The trimmed text, or null.
13384
+ */
13385
+ function asText$4(value) {
13386
+ if (typeof value !== "string") return null;
13387
+ const trimmed = value.trim();
13388
+ return trimmed === "" ? null : trimmed;
13389
+ }
13390
+ /**
13391
+ * A finite number, or null.
13392
+ *
13393
+ * @param value - Candidate.
13394
+ * @returns The number, or null.
13395
+ */
13396
+ function asNumber$6(value) {
13397
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
13398
+ }
13399
+ /**
13400
+ * A boolean as stated, or null when the payload said something else.
13401
+ *
13402
+ * @param value - Candidate.
13403
+ * @returns The boolean, or null.
13404
+ */
13405
+ function asBoolean$2(value) {
13406
+ return typeof value === "boolean" ? value : null;
13407
+ }
13408
+ /**
13409
+ * A Unix MILLISECONDS instant, when the payload already states one.
13410
+ *
13411
+ * @param value - Candidate.
13412
+ * @returns Milliseconds rounded to a whole number, or null.
13413
+ */
13414
+ function asMs(value) {
13415
+ const number = asNumber$6(value);
13416
+ if (number === null || number <= 0) return null;
13417
+ return Math.round(number);
13418
+ }
13419
+ /**
13420
+ * A Unix SECONDS instant as milliseconds.
13421
+ *
13422
+ * @param value - Candidate.
13423
+ * @returns Milliseconds, or null when nothing usable was stated.
13424
+ */
13425
+ function asSecondsToMs(value) {
13426
+ const number = asNumber$6(value);
13427
+ if (number === null || number <= 0) return null;
13428
+ return Math.round(number * 1e3);
13429
+ }
13430
+ /**
13431
+ * An ISO 8601 instant as Unix milliseconds.
13432
+ *
13433
+ * @param value - Candidate.
13434
+ * @returns Milliseconds, or null for an absent or unparseable string.
13435
+ */
13436
+ function asIsoToMs(value) {
13437
+ const text = asText$4(value);
13438
+ if (text === null) return null;
13439
+ const parsed = Date.parse(text);
13440
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
13441
+ }
13442
+ /**
13443
+ * A reset instant, with the sentinel ceiling applied.
13444
+ *
13445
+ * @param value - Milliseconds, or null.
13446
+ * @returns The instant, or null when absent or implausible.
13447
+ */
13448
+ function asResetAt(value) {
13449
+ if (value === null || value <= 0) return null;
13450
+ return value > SANE_TIMESTAMP_CEILING_MS ? null : value;
13451
+ }
13452
+ /**
13453
+ * A share in percent, clamped into the bar it is drawn as.
13454
+ *
13455
+ * @param value - Candidate.
13456
+ * @returns The share between 0 and 100, or null when there was none.
13457
+ */
13458
+ function asPercent(value) {
13459
+ const number = asNumber$6(value);
13460
+ if (number === null) return null;
13461
+ return Math.min(100, Math.max(0, number));
13462
+ }
13463
+ /**
13464
+ * A fraction in [0, 1] as percent.
13465
+ *
13466
+ * @param value - Candidate.
13467
+ * @returns Percent, or null when there was none.
13468
+ */
13469
+ function asFractionPercent(value) {
13470
+ const number = asNumber$6(value);
13471
+ if (number === null) return null;
13472
+ return Math.min(100, Math.max(0, number * 100));
13473
+ }
13474
+ /**
13475
+ * A percent still available, inverted into the used share.
13476
+ *
13477
+ * @param value - Candidate.
13478
+ * @returns The used share, or null when there was none.
13479
+ */
13480
+ function remainingPercentToUsed(value) {
13481
+ const remaining = asPercent(value);
13482
+ return remaining === null ? null : 100 - remaining;
13483
+ }
13484
+ /**
13485
+ * A remaining fraction in [0, 1] inverted into percent used.
13486
+ *
13487
+ * @param value - Candidate.
13488
+ * @returns The used share, or null when there was none.
13489
+ */
13490
+ function remainingFractionToUsed(value) {
13491
+ const remaining = asFractionPercent(value);
13492
+ return remaining === null ? null : 100 - remaining;
13493
+ }
13494
+ /**
13495
+ * The conventional name of a window length.
13496
+ *
13497
+ * Used when the line itself states no label (minimax keys its windows as
13498
+ * `interval` / `weekly`, deliberately leaving the wording to the client) and as
13499
+ * a fallback under a label the line left blank. Only lengths that are
13500
+ * unambiguous get a name; anything else stays unnamed rather than mislabelled.
13501
+ *
13502
+ * @param minutes - Nominal window length.
13503
+ * @returns The label, or null when the length is not a conventional one.
13504
+ */
13505
+ function standardWindowLabel(minutes) {
13506
+ switch (minutes) {
13507
+ case 300: return "5 小时";
13508
+ case 1440: return "每日";
13509
+ case 10080: return "每周";
13510
+ case 43200: return "每月";
13511
+ default: return null;
13512
+ }
13513
+ }
13514
+ /** The window length each named window of the lines that name one stands for. */
13515
+ const NAMED_WINDOW_MINUTES = {
13516
+ "5h": 300,
13517
+ five_hour: 300,
13518
+ "five-hour": 300,
13519
+ interval: 300,
13520
+ daily: 1440,
13521
+ monthly: 43200,
13522
+ week: 10080,
13523
+ weekly: 10080,
13524
+ seven_day: 10080,
13525
+ "7d": 10080
13526
+ };
13527
+ /**
13528
+ * The window length a line's own window key stands for.
13529
+ *
13530
+ * @param key - The line's key (`5h`, `five-hour`, `weekly`, …).
13531
+ * @returns Minutes, or null when the key is not one this module knows.
13532
+ */
13533
+ function namedWindowMinutes(key) {
13534
+ const text = asText$4(key);
13535
+ if (text === null) return null;
13536
+ return NAMED_WINDOW_MINUTES[text.toLowerCase()] ?? null;
13537
+ }
13538
+ /**
13539
+ * One window, or null when nothing measured its consumption.
13540
+ *
13541
+ * A window with no used share is dropped rather than drawn as a full bar: the
13542
+ * lines say null for "the source told us nothing", and painting that as 100%
13543
+ * would report a limit nobody measured.
13544
+ *
13545
+ * @param seed - The mapped fields.
13546
+ * @returns The window, or null.
13547
+ */
13548
+ function windowOf(seed) {
13549
+ const usedPercent = seed.usedPercent === null || seed.usedPercent === void 0 ? null : Math.min(100, Math.max(0, seed.usedPercent));
13550
+ if (usedPercent === null) return null;
13551
+ const windowMinutes = seed.windowMinutes === null || seed.windowMinutes === void 0 ? null : seed.windowMinutes;
13552
+ return {
13553
+ label: asText$4(seed.label) ?? standardWindowLabel(windowMinutes) ?? "",
13554
+ usedPercent,
13555
+ windowMinutes,
13556
+ resetsAt: asResetAt(seed.resetsAt ?? null)
13557
+ };
13558
+ }
13559
+ /**
13560
+ * Every window the seeds offered, dropping the ones that measured nothing.
13561
+ *
13562
+ * @param seeds - Candidate windows.
13563
+ * @returns The windows that survived.
13564
+ */
13565
+ function windowsFrom(seeds) {
13566
+ const windows = [];
13567
+ for (const seed of seeds) {
13568
+ const window = windowOf(seed);
13569
+ if (window !== null) windows.push(window);
13570
+ }
13571
+ return windows;
13572
+ }
13573
+ /**
13574
+ * Shortest window first, unstated lengths last.
13575
+ *
13576
+ * @param left - First window.
13577
+ * @param right - Second window.
13578
+ * @returns The comparator result.
13579
+ */
13580
+ function byWindowLength(left, right) {
13581
+ if (left.windowMinutes === right.windowMinutes) return 0;
13582
+ if (left.windowMinutes === null) return 1;
13583
+ if (right.windowMinutes === null) return -1;
13584
+ return left.windowMinutes - right.windowMinutes;
13585
+ }
13586
+ /**
13587
+ * A quota, or null when the line answered nothing a card could draw.
13588
+ *
13589
+ * "Nothing to draw" is exactly: no window, no credit figure, no reset credit,
13590
+ * and no fetch instant. A payload in that state is reported as `quota: null`
13591
+ * so a card shows its placeholder instead of an empty frame that looks like a
13592
+ * measured zero.
13593
+ *
13594
+ * @param seed - The mapped fields.
13595
+ * @returns The quota, or null.
13596
+ */
13597
+ function quotaOf(seed) {
13598
+ const windows = [...seed.windows ?? []].sort(byWindowLength);
13599
+ const credits = seed.credits ?? null;
13600
+ const resetCredits = seed.resetCredits ?? null;
13601
+ const fetchedAt = asMs(seed.fetchedAt ?? null);
13602
+ if (windows.length === 0 && credits === null && resetCredits === null && fetchedAt === null) return null;
13603
+ return {
13604
+ windows,
13605
+ credits,
13606
+ resetCredits,
13607
+ fetchedAt,
13608
+ refreshing: seed.refreshing === true,
13609
+ error: asText$4(seed.error)
13610
+ };
13611
+ }
13612
+ /**
13613
+ * The facts one payload yields.
13614
+ *
13615
+ * A line's own failure message lands in `quota.error` when there IS a quota to
13616
+ * annotate, and in the card-level `error` when there is not — the same sentence
13617
+ * either way, placed where the card can actually render it.
13618
+ *
13619
+ * @param seed - The mapped fields.
13620
+ * @returns The facts.
13621
+ */
13622
+ function factsOf(seed) {
13623
+ const error = asText$4(seed.error);
13624
+ const quota = quotaOf(seed);
13625
+ return {
13626
+ quota: quota === null ? null : {
13627
+ ...quota,
13628
+ error
13629
+ },
13630
+ planLabel: asText$4(seed.planLabel),
13631
+ accountLabel: asText$4(seed.accountLabel),
13632
+ signedIn: asBoolean$2(seed.signedIn),
13633
+ error: quota === null ? error : null
13634
+ };
13635
+ }
13636
+ /**
13637
+ * The facts of a payload that could not be read at all.
13638
+ *
13639
+ * @param error - Why the payload was unusable.
13640
+ * @returns The facts, with nothing measured.
13641
+ */
13642
+ function unavailableFacts(error) {
13643
+ return {
13644
+ quota: null,
13645
+ planLabel: null,
13646
+ accountLabel: null,
13647
+ signedIn: null,
13648
+ error
13649
+ };
13650
+ }
13651
+ /**
13652
+ * The first non-null text of a candidate list.
13653
+ *
13654
+ * @param values - Candidates, most specific first.
13655
+ * @returns The first text that was stated, or null.
13656
+ */
13657
+ function firstText(values) {
13658
+ for (const value of values) {
13659
+ const text = asText$4(value);
13660
+ if (text !== null) return text;
13661
+ }
13662
+ return null;
13663
+ }
13664
+ /**
13665
+ * A balance as a string, keeping its unit.
13666
+ *
13667
+ * @param balance - The line's own figure.
13668
+ * @param unit - Unit to append, when the line states one.
13669
+ * @returns The balance, or null when there was none.
13670
+ */
13671
+ function balanceOf(balance, unit) {
13672
+ const number = asNumber$6(balance);
13673
+ const suffix = unit === null || unit === void 0 ? "" : " " + unit;
13674
+ if (number === null) {
13675
+ const text = asText$4(balance);
13676
+ return text === null ? null : text + suffix;
13677
+ }
13678
+ return String(number) + suffix;
13679
+ }
13680
+ /**
13681
+ * The display name of one Qoder allowance.
13682
+ *
13683
+ * @param kind - The line's bucket kind.
13684
+ * @returns The label, or null when the kind is not one this module knows.
13685
+ */
13686
+ function bucketLabel(kind) {
13687
+ switch (asText$4(kind)) {
13688
+ case "personal": return "个人额度";
13689
+ case "add-on": return "加购额度";
13690
+ case "org": return "组织额度";
13691
+ default: return null;
13692
+ }
13693
+ }
13694
+ /**
13695
+ * The ChatGPT line's quota, as `GET /status` answers it.
13696
+ *
13697
+ * Units: `QuotaWindowDto.resetsAt`, `resetCredits.expiresAt` and
13698
+ * `quota.fetchedAt` are Unix SECONDS here (that line's own quota service
13699
+ * multiplies them by 1000 further out), while everything else on the payload is
13700
+ * already milliseconds. The bucket list also carries an older
13701
+ * `primary`/`secondary` pair that some payload generations state instead of
13702
+ * `windows`; both are read, so the card shows the same bars the settings card
13703
+ * does.
13704
+ *
13705
+ * @param status - The route's `value`.
13706
+ * @returns The facts.
13707
+ */
13708
+ function normalizeCodexQuota(status) {
13709
+ const root = asRecord$17(status);
13710
+ if (root === null) return unavailableFacts(null);
13711
+ const quotaBody = asRecord$17(root.quota);
13712
+ const account = asRecord$17(root.account);
13713
+ const buckets = asRecords(quotaBody?.buckets);
13714
+ const seeds = [];
13715
+ for (const bucket of buckets) {
13716
+ const stated = asRecords(bucket.windows);
13717
+ const source = stated.length > 0 ? stated : [asRecord$17(bucket.primary), asRecord$17(bucket.secondary)];
13718
+ for (const window of source) {
13719
+ if (window === null) continue;
13720
+ seeds.push({
13721
+ label: asText$4(window.label),
13722
+ usedPercent: asPercent(window.usedPercent),
13723
+ windowMinutes: asNumber$6(window.windowDurationMins),
13724
+ resetsAt: asSecondsToMs(window.resetsAt)
13725
+ });
13726
+ }
13727
+ }
13728
+ const creditsBody = asRecord$17(quotaBody?.credits);
13729
+ const credits = creditsBody === null ? null : {
13730
+ unlimited: creditsBody.unlimited === true,
13731
+ balance: asText$4(creditsBody.balance)
13732
+ };
13733
+ const resetBody = asRecord$17(quotaBody?.resetCredits);
13734
+ const resetCredits = resetBody === null ? null : {
13735
+ available: Math.max(0, Math.trunc(asNumber$6(resetBody.availableCount) ?? 0)),
13736
+ expiresAt: asResetAt(asSecondsToMs(resetBody.expiresAt))
13737
+ };
13738
+ const planLabel = firstText([...buckets.map((bucket) => bucket.planType), account?.planType]);
13739
+ const state = asText$4(quotaBody?.state);
13740
+ return factsOf({
13741
+ windows: windowsFrom(seeds),
13742
+ credits,
13743
+ resetCredits,
13744
+ fetchedAt: asSecondsToMs(quotaBody?.fetchedAt),
13745
+ refreshing: root.quotaRefreshing === true,
13746
+ error: firstText([asRecord$17(quotaBody?.error)?.message]),
13747
+ planLabel,
13748
+ accountLabel: account?.email,
13749
+ signedIn: asBoolean$2(root.authenticated) ?? (state === null ? null : state !== "signed-out")
13750
+ });
13751
+ }
13752
+ /**
13753
+ * The Kimi Code line's quota.
13754
+ *
13755
+ * Units: `resetsAt` and `fetchedAt` are already milliseconds. The fallback
13756
+ * wallet (`extraUsage`) is stated in the currency's minor unit, so it is
13757
+ * divided once and kept with its currency: a bare "75.00" would be read as
13758
+ * provider credits by every other card on the board.
13759
+ *
13760
+ * @param status - The route's `value`.
13761
+ * @returns The facts.
13762
+ */
13763
+ function normalizeKimiCodeQuota(status) {
13764
+ const root = asRecord$17(status);
13765
+ if (root === null) return unavailableFacts(null);
13766
+ const quotaBody = asRecord$17(root.quota);
13767
+ const account = asRecord$17(root.account);
13768
+ const extra = asRecord$17(quotaBody?.extraUsage);
13769
+ const balanceCents = asNumber$6(extra?.balanceCents);
13770
+ const currency = asText$4(extra?.currency);
13771
+ const credits = balanceCents === null ? null : {
13772
+ unlimited: false,
13773
+ balance: (balanceCents / 100).toFixed(2) + (currency === null ? "" : " " + currency)
13774
+ };
13775
+ return factsOf({
13776
+ windows: windowsFrom(asRecords(quotaBody?.windows).map((window) => ({
13777
+ label: asText$4(window.label),
13778
+ usedPercent: asPercent(window.usedPercent),
13779
+ windowMinutes: asNumber$6(window.windowDurationMins),
13780
+ resetsAt: asMs(window.resetsAt)
13781
+ }))),
13782
+ credits,
13783
+ resetCredits: null,
13784
+ fetchedAt: asMs(quotaBody?.fetchedAt),
13785
+ refreshing: root.quotaRefreshing === true,
13786
+ error: firstText([root.quotaError]),
13787
+ planLabel: firstText([quotaBody?.planName, account?.planName]),
13788
+ accountLabel: firstText([account?.email, account?.nickname]),
13789
+ signedIn: root.authenticated
13790
+ });
13791
+ }
13792
+ /**
13793
+ * The MiniMax Code line's quota.
13794
+ *
13795
+ * Units: milliseconds throughout (`resetsAtMs`, `fetchedAtMs`). The windows are
13796
+ * keyed (`interval` / `weekly`) rather than labelled — the line leaves the
13797
+ * wording to the client — and carry a REMAINING percent, sometimes only a
13798
+ * remaining one; both are inverted into the used share here.
13799
+ *
13800
+ * @param status - The route's `value`.
13801
+ * @returns The facts.
13802
+ */
13803
+ function normalizeMinimaxCodeQuota(status) {
13804
+ const root = asRecord$17(status);
13805
+ if (root === null) return unavailableFacts(null);
13806
+ const quotaBody = asRecord$17(root.quota);
13807
+ const seeds = asRecords(quotaBody?.windows).map((window) => ({
13808
+ label: null,
13809
+ usedPercent: asPercent(window.usedPercent) ?? remainingPercentToUsed(window.remainingPercent),
13810
+ windowMinutes: namedWindowMinutes(window.key),
13811
+ resetsAt: asMs(window.resetsAtMs)
13812
+ }));
13813
+ if (seeds.length === 0) {
13814
+ const headline = asPercent(quotaBody?.usedPercent);
13815
+ if (headline !== null) seeds.push({
13816
+ label: null,
13817
+ usedPercent: headline,
13818
+ windowMinutes: 300,
13819
+ resetsAt: asMs(quotaBody?.resetsAtMs)
13820
+ });
13821
+ }
13822
+ const accounts = asRecords(root.accounts);
13823
+ const activeId = asText$4(root.activeAccountId);
13824
+ const active = accounts.find((account) => asText$4(account.id) === activeId) ?? accounts.find((account) => asText$4(account.email) !== null);
13825
+ return factsOf({
13826
+ windows: windowsFrom(seeds),
13827
+ credits: null,
13828
+ resetCredits: null,
13829
+ fetchedAt: asMs(quotaBody?.fetchedAtMs),
13830
+ refreshing: root.quotaRefreshing === true,
13831
+ error: firstText([root.quotaError]),
13832
+ planLabel: firstText([quotaBody?.label]),
13833
+ accountLabel: firstText([active?.email]),
13834
+ signedIn: root.authenticated
13835
+ });
13836
+ }
13837
+ /**
13838
+ * The Antigravity line's quota.
13839
+ *
13840
+ * Units: the harder one of the set. Each allowance is stated as
13841
+ * `remainingFraction` in [0, 1] (null when the service measured nothing, and
13842
+ * NOT zero), named windows arrive as `window: '5h' | 'weekly'`, and reset
13843
+ * instants as ISO 8601. Windows are grouped (Gemini models, third-party
13844
+ * models); each bucket's label is prefixed with its group once there is more
13845
+ * than one group, because "Weekly" twice on one card says nothing about which
13846
+ * allowance it is.
13847
+ *
13848
+ * @param status - The route's `value`.
13849
+ * @returns The facts.
13850
+ */
13851
+ function normalizeAntigravityQuota(status) {
13852
+ const root = asRecord$17(status);
13853
+ if (root === null) return unavailableFacts(null);
13854
+ const quotaBody = asRecord$17(root.quota);
13855
+ const groups = asRecords(quotaBody?.groups);
13856
+ const seeds = [];
13857
+ for (const group of groups) {
13858
+ const groupName = groups.length > 1 ? asText$4(group.displayName) : null;
13859
+ for (const bucket of asRecords(group.buckets)) {
13860
+ const minutes = namedWindowMinutes(bucket.window);
13861
+ const windowName = standardWindowLabel(minutes) ?? asText$4(bucket.displayName);
13862
+ seeds.push({
13863
+ label: groupName === null || windowName === null ? windowName : groupName + " · " + windowName,
13864
+ usedPercent: remainingFractionToUsed(bucket.remainingFraction),
13865
+ windowMinutes: minutes,
13866
+ resetsAt: asIsoToMs(bucket.resetTime)
13867
+ });
13868
+ }
13869
+ }
13870
+ const paidTier = asRecord$17(quotaBody?.paidTier);
13871
+ const productTier = asRecord$17(quotaBody?.productTier);
13872
+ return factsOf({
13873
+ windows: windowsFrom(seeds),
13874
+ credits: null,
13875
+ resetCredits: null,
13876
+ fetchedAt: asMs(quotaBody?.fetchedAt) ?? asMs(root.lastFetchedAt),
13877
+ refreshing: root.quotaRefreshing === true,
13878
+ error: firstText([root.quotaError]),
13879
+ planLabel: firstText([
13880
+ quotaBody?.planLabel,
13881
+ paidTier?.name,
13882
+ productTier?.name
13883
+ ]),
13884
+ accountLabel: firstText([root.email]),
13885
+ signedIn: root.authenticated
13886
+ });
13887
+ }
13888
+ /**
13889
+ * The Command Code line's quota.
13890
+ *
13891
+ * Units: two different meter shapes, and both are needed. `windows[]` is
13892
+ * already normalized (`usedPercent`, `windowDurationMins`, milliseconds);
13893
+ * `meters[]` states a used FRACTION that is null whenever the service reports a
13894
+ * spend meter instead of a bounded allowance, and only the ones that did state
13895
+ * a fraction become bars. The credit balance is that line's own string, kept
13896
+ * verbatim.
13897
+ *
13898
+ * @param status - The route's `value`.
13899
+ * @returns The facts.
13900
+ */
13901
+ function normalizeCommandCodeQuota(status) {
13902
+ const root = asRecord$17(status);
13903
+ if (root === null) return unavailableFacts(null);
13904
+ const quotaBody = asRecord$17(root.quota);
13905
+ const windows = windowsFrom(asRecords(quotaBody?.windows).map((window) => ({
13906
+ label: asText$4(window.label),
13907
+ usedPercent: asPercent(window.usedPercent),
13908
+ windowMinutes: asNumber$6(window.windowDurationMins),
13909
+ resetsAt: asMs(window.resetsAt)
13910
+ })));
13911
+ if (windows.length === 0) for (const meter of asRecords(quotaBody?.meters)) windows.push(...windowsFrom([{
13912
+ label: asText$4(meter.label),
13913
+ usedPercent: asFractionPercent(meter.usedFraction),
13914
+ windowMinutes: null,
13915
+ resetsAt: asMs(meter.resetsAt)
13916
+ }]));
13917
+ const account = asRecord$17(root.account) ?? asRecord$17(quotaBody?.account);
13918
+ return factsOf({
13919
+ windows,
13920
+ credits: quotaBody === null ? null : {
13921
+ unlimited: quotaBody.unlimited === true,
13922
+ balance: asText$4(quotaBody.creditBalance)
13923
+ },
13924
+ resetCredits: null,
13925
+ fetchedAt: asMs(quotaBody?.fetchedAt) ?? asMs(root.lastFetchedAt),
13926
+ refreshing: root.quotaRefreshing === true,
13927
+ error: firstText([root.quotaError]),
13928
+ planLabel: firstText([quotaBody?.planName, account?.planLabel]),
13929
+ accountLabel: firstText([account?.email]),
13930
+ signedIn: root.authenticated
13931
+ });
13932
+ }
13933
+ /**
13934
+ * The Claude line's quota.
13935
+ *
13936
+ * Units: millisecond `fetchedAt` / `observedAt`, ISO 8601 `resetsAt`, and a
13937
+ * used share that may be stated as percent or as a fraction in [0, 1] — the
13938
+ * three numeric fields are null together when the source said nothing, so the
13939
+ * fraction is only consulted when the percent is absent.
13940
+ *
13941
+ * The line answers `quota: null` on a first read and only fills it after its
13942
+ * own `POST /quota`; that is what the refresh capability exists for, so an
13943
+ * empty first read is reported as-is rather than as an error.
13944
+ *
13945
+ * @param status - The route's `value`.
13946
+ * @returns The facts.
13947
+ */
13948
+ function normalizeClaudeQuota(status) {
13949
+ const root = asRecord$17(status);
13950
+ if (root === null) return unavailableFacts(null);
13951
+ const quotaBody = asRecord$17(root.quota);
13952
+ const account = asRecord$17(root.account);
13953
+ return factsOf({
13954
+ windows: windowsFrom(asRecords(quotaBody?.windows).map((window) => {
13955
+ const minutes = asNumber$6(window.windowMinutes) ?? namedWindowMinutes(window.id);
13956
+ return {
13957
+ label: asText$4(window.label) ?? standardWindowLabel(minutes),
13958
+ usedPercent: asPercent(window.usedPercent) ?? asFractionPercent(window.usedFraction),
13959
+ windowMinutes: minutes,
13960
+ resetsAt: asIsoToMs(window.resetsAt)
13961
+ };
13962
+ })),
13963
+ credits: null,
13964
+ resetCredits: null,
13965
+ fetchedAt: asMs(quotaBody?.fetchedAt) ?? asMs(quotaBody?.observedAt),
13966
+ refreshing: root.quotaRefreshing === true,
13967
+ error: firstText([root.quotaError]),
13968
+ planLabel: firstText([account?.subscriptionType]),
13969
+ accountLabel: firstText([account?.email]),
13970
+ signedIn: root.authenticated
13971
+ });
13972
+ }
13973
+ /**
13974
+ * The GLM Coding Plan line's quota.
13975
+ *
13976
+ * Units: milliseconds (`resetsAt`, `fetchedAt`). Windows are keyed
13977
+ * (`five-hour` / `weekly` / `monthly`) and their used percent is null when the
13978
+ * upstream reported no bounded allowance — an unlimited plan states no
13979
+ * fraction, and drawing that as 0% used would invent a measurement.
13980
+ *
13981
+ * @param status - The route's `value`.
13982
+ * @returns The facts.
13983
+ */
13984
+ function normalizeGlmCodingPlanQuota(status) {
13985
+ const root = asRecord$17(status);
13986
+ if (root === null) return unavailableFacts(null);
13987
+ const quotaBody = asRecord$17(root.quota);
13988
+ return factsOf({
13989
+ windows: windowsFrom(asRecords(quotaBody?.windows).map((window) => {
13990
+ const minutes = namedWindowMinutes(window.key);
13991
+ return {
13992
+ label: standardWindowLabel(minutes),
13993
+ usedPercent: asPercent(window.usedPercent),
13994
+ windowMinutes: minutes,
13995
+ resetsAt: asMs(window.resetsAt)
13996
+ };
13997
+ })),
13998
+ credits: null,
13999
+ resetCredits: null,
14000
+ fetchedAt: asMs(quotaBody?.fetchedAt),
14001
+ refreshing: root.quotaRefreshing === true,
14002
+ error: firstText([root.quotaError]),
14003
+ planLabel: firstText([quotaBody?.planName, quotaBody?.planLevel]),
14004
+ signedIn: root.authenticated
14005
+ });
14006
+ }
14007
+ /**
14008
+ * The Step Code line's quota.
14009
+ *
14010
+ * Units: milliseconds. Two payload generations share one snapshot: an older
14011
+ * account has 5-hour and weekly windows, a newer one has `creditBuckets` — an
14012
+ * absolute amount per bucket, with no percentage at all. The buckets become the
14013
+ * card's credit figure (their remaining amounts summed) rather than being
14014
+ * squeezed into a progress bar nobody measured; the `credit` window key, which
14015
+ * names the same thing, is deliberately left without a bar for the same reason.
14016
+ *
14017
+ * @param status - The route's `value`.
14018
+ * @returns The facts.
14019
+ */
14020
+ function normalizeStepCodeQuota(status) {
14021
+ const root = asRecord$17(status);
14022
+ if (root === null) return unavailableFacts(null);
14023
+ const quotaBody = asRecord$17(root.quota);
14024
+ let remaining = 0;
14025
+ let hasRemaining = false;
14026
+ for (const bucket of asRecords(quotaBody?.creditBuckets)) {
14027
+ const amount = asNumber$6(bucket.remaining);
14028
+ if (amount === null) continue;
14029
+ remaining += amount;
14030
+ hasRemaining = true;
14031
+ }
14032
+ const credits = hasRemaining ? {
14033
+ unlimited: false,
14034
+ balance: String(Math.round(remaining * 100) / 100)
14035
+ } : null;
14036
+ const account = asRecord$17(root.account) ?? asRecord$17(asRecord$17(root.quotaCredential)?.account);
14037
+ return factsOf({
14038
+ windows: windowsFrom(asRecords(quotaBody?.windows).map((window) => {
14039
+ const minutes = window.key === "credit" ? null : namedWindowMinutes(window.key);
14040
+ return {
14041
+ label: standardWindowLabel(minutes) ?? (window.key === "credit" ? "Credits" : null),
14042
+ usedPercent: asPercent(window.usedPercent),
14043
+ windowMinutes: minutes,
14044
+ resetsAt: asMs(window.resetsAt)
14045
+ };
14046
+ })),
14047
+ credits,
14048
+ resetCredits: null,
14049
+ fetchedAt: asMs(quotaBody?.fetchedAt),
14050
+ refreshing: root.quotaRefreshing === true,
14051
+ error: firstText([root.quotaError]),
14052
+ planLabel: firstText([quotaBody?.planName]),
14053
+ accountLabel: firstText([account?.email]),
14054
+ signedIn: root.authenticated
14055
+ });
14056
+ }
14057
+ /**
14058
+ * The Qoder line's quota.
14059
+ *
14060
+ * Units: milliseconds, with one sentinel: the account period ends at
14061
+ * `253402214400000` (year 9999) for a subscription that never expires, and a
14062
+ * card would print that as a real date, so the shared ceiling drops it.
14063
+ *
14064
+ * Qoder states ABSOLUTE amounts (total / used / remaining + unit), not windows.
14065
+ * A ratio is derived only when both total and used are stated AND total is
14066
+ * positive; anything else is reported as a credit balance instead of a bar.
14067
+ *
14068
+ * @param status - The route's `value`.
14069
+ * @returns The facts.
14070
+ */
14071
+ function normalizeQoderQuota(status) {
14072
+ const root = asRecord$17(status);
14073
+ if (root === null) return unavailableFacts(null);
14074
+ const quotaBody = asRecord$17(root.quota);
14075
+ const expiresAt = asResetAt(asMs(quotaBody?.expiresAt));
14076
+ const seeds = [];
14077
+ let credits = null;
14078
+ for (const bucket of asRecords(quotaBody?.buckets)) {
14079
+ const total = asNumber$6(bucket.total);
14080
+ const remaining = asNumber$6(bucket.remaining);
14081
+ const used = asNumber$6(bucket.used) ?? (total !== null && remaining !== null ? total - remaining : null);
14082
+ if (total !== null && total > 0 && used !== null) seeds.push({
14083
+ label: bucketLabel(bucket.kind),
14084
+ usedPercent: used / total * 100,
14085
+ windowMinutes: null,
14086
+ resetsAt: expiresAt
14087
+ });
14088
+ if (credits === null && remaining !== null) credits = {
14089
+ unlimited: false,
14090
+ balance: balanceOf(remaining, asText$4(bucket.unit))
14091
+ };
14092
+ }
14093
+ const account = asRecord$17(root.account);
14094
+ return factsOf({
14095
+ windows: windowsFrom(seeds),
14096
+ credits,
14097
+ resetCredits: null,
14098
+ fetchedAt: asMs(quotaBody?.fetchedAt),
14099
+ refreshing: root.quotaRefreshing === true,
14100
+ error: firstText([root.quotaError]),
14101
+ planLabel: null,
14102
+ accountLabel: firstText([account?.email]),
14103
+ signedIn: root.authenticated
14104
+ });
14105
+ }
14106
+ /**
14107
+ * The Cline Pass line's quota.
14108
+ *
14109
+ * Units: `percentUsed` is a percent, `resetsAt` is an ISO 8601 string, and the
14110
+ * window's length is only stated as a gateway token (`five_hour` / `weekly` /
14111
+ * `monthly`). The payload carries one reading PER ACCOUNT, so the row shown is
14112
+ * the pool's active account when it has a reading, else the first account that
14113
+ * did read something. An account that failed states its own reason, which is
14114
+ * what the card shows instead of an empty frame.
14115
+ *
14116
+ * @param status - The route's `value`.
14117
+ * @returns The facts.
14118
+ */
14119
+ function normalizeClinePassQuota(status) {
14120
+ const root = asRecord$17(status);
14121
+ if (root === null) return unavailableFacts(null);
14122
+ const usage = asRecord$17(root.usage);
14123
+ const readings = asRecords(usage?.accounts);
14124
+ const activeId = asText$4(asRecord$17(root.pool)?.activeAccountId);
14125
+ const reading = readings.find((entry) => asText$4(entry.accountId) === activeId) ?? readings.find((entry) => asRecords(entry.windows).length > 0) ?? readings[0];
14126
+ return factsOf({
14127
+ windows: windowsFrom(asRecords(reading?.windows).map((window) => {
14128
+ const minutes = namedWindowMinutes(window.type);
14129
+ return {
14130
+ label: asText$4(window.label) ?? standardWindowLabel(minutes),
14131
+ usedPercent: asPercent(window.percentUsed),
14132
+ windowMinutes: minutes,
14133
+ resetsAt: asIsoToMs(window.resetsAt)
14134
+ };
14135
+ })),
14136
+ credits: null,
14137
+ resetCredits: null,
14138
+ fetchedAt: asMs(reading?.fetchedAt) ?? asMs(usage?.fetchedAt),
14139
+ refreshing: root.quotaRefreshing === true,
14140
+ error: firstText([root.quotaError, reading?.error]),
14141
+ planLabel: null,
14142
+ accountLabel: null,
14143
+ signedIn: root.authenticated
14144
+ });
14145
+ }
14146
+ /**
14147
+ * An integer credit amount the service states as a STRING.
14148
+ *
14149
+ * `/billing/coding-plan-usage` strings its amounts (`"1000"`), and a number-shaped
14150
+ * field of any other type is not one: reading a float as credits would let a
14151
+ * rounded value become a denominator.
14152
+ *
14153
+ * @param value - Candidate.
14154
+ * @returns The amount, or null when it is not an integer string.
14155
+ */
14156
+ function asCreditAmount(value) {
14157
+ const text = asText$4(value);
14158
+ if (text === null || !/^\d+$/.test(text)) return null;
14159
+ const amount = Number(text);
14160
+ return Number.isFinite(amount) ? amount : null;
14161
+ }
14162
+ /**
14163
+ * The used share of one window, from the two amounts that bound it.
14164
+ *
14165
+ * `(limit − remaining) / limit` in percent, rounded to the one decimal the bar is
14166
+ * drawn at and clamped into 0-100. A remainder larger than the limit (a top-up
14167
+ * rides outside the window upstream) clamps to 0 rather than reporting a bar
14168
+ * running backwards, and nothing left is exactly a full bar. The REMAINING
14169
+ * percentage the console shows is `100 − this`; no second derivation exists.
14170
+ *
14171
+ * @param limit - Credits the window grants; positive, which the caller checks.
14172
+ * @param remaining - Credits the window still has.
14173
+ * @returns The used share in 0-100 with one decimal.
14174
+ */
14175
+ function usedSharePercent(limit, remaining) {
14176
+ const share = (limit - remaining) / limit * 100;
14177
+ return Math.min(100, Math.max(0, Math.round(share * 10) / 10));
14178
+ }
14179
+ /**
14180
+ * Every Design Plan window one account stated, in the card's own vocabulary.
14181
+ *
14182
+ * Two amounts and one length make a bar; a window that states less is dropped
14183
+ * rather than patched, because a guessed denominator is a progress bar claiming
14184
+ * a limit nobody measured. `durationSeconds` is the service's own unit (18000 is
14185
+ * the 5-hour window, 604800 the 7-day one) and becomes minutes here because that
14186
+ * is what every other line's card already reads.
14187
+ *
14188
+ * The LABEL stays empty on purpose: the length is the fact, and the card words it
14189
+ * (`5 小时` / `7 天`). Writing that wording here would put a second, competing
14190
+ * vocabulary in the host for the same window.
14191
+ *
14192
+ * @param codingPlan - The account's `codingPlan` object, when it had one.
14193
+ * @returns The windows, shortest first; empty when the read stated none.
14194
+ */
14195
+ function codingPlanWindows(codingPlan) {
14196
+ const windows = [];
14197
+ for (const window of asRecords(codingPlan?.windows)) {
14198
+ const limit = asCreditAmount(window.limitCredits);
14199
+ const remaining = asCreditAmount(window.remainingCredits);
14200
+ if (limit === null || limit <= 0 || remaining === null) continue;
14201
+ const seconds = asNumber$6(window.durationSeconds);
14202
+ windows.push({
14203
+ label: "",
14204
+ usedPercent: usedSharePercent(limit, remaining),
14205
+ windowMinutes: seconds === null || seconds <= 0 ? null : Math.round(seconds / 60),
14206
+ resetsAt: asIsoToMs(window.resetsAt)
14207
+ });
14208
+ }
14209
+ return windows.sort(byWindowLength);
14210
+ }
14211
+ /**
14212
+ * The OpenDesign line's quota.
14213
+ *
14214
+ * Units: this line's REAL allowance is the "Design Plan" window pair the console
14215
+ * renders as a remaining percentage — a 5-hour window and a 7-day one, each
14216
+ * stating an integer credit LIMIT and what is LEFT of it, plus an ISO reset
14217
+ * instant. A bar is therefore the USED share, `(limit − remaining) / limit`;
14218
+ * the remaining percentage is `100 − used` and is derived by whoever prints it.
14219
+ *
14220
+ * `quota.credits` keeps the WALLET balance, and only that: the spendable USD
14221
+ * figure is a different quantity from a window's allowance, and folding one into
14222
+ * the other is how a card ends up stating a percentage of the wrong thing.
14223
+ *
14224
+ * Absence is the honest half of this reader. The endpoint is only known to accept
14225
+ * a browser session, so a control-key read may be refused; `codingPlan.error`
14226
+ * then carries the reason and `windows` is empty. An empty list is reported as
14227
+ * exactly that — never padded with a 0 % bar, which would claim a limit nobody
14228
+ * observed — and the reason rides along so the card can say why.
14229
+ *
14230
+ * @param status - The route's `value`.
14231
+ * @returns The facts.
14232
+ */
14233
+ function normalizeOpenDesignQuota(status) {
14234
+ const root = asRecord$17(status);
14235
+ if (root === null) return unavailableFacts(null);
14236
+ const usage = asRecord$17(root.usage);
14237
+ const readings = asRecords(usage?.accounts);
14238
+ const activeId = asText$4(asRecord$17(root.pool)?.activeAccountId);
14239
+ const reading = readings.find((entry) => asText$4(entry.accountId) === activeId) ?? readings.find((entry) => asText$4(entry.balanceUsd) !== null) ?? readings[0];
14240
+ const balance = asText$4(reading?.balanceUsd);
14241
+ const credits = balance === null ? null : {
14242
+ unlimited: false,
14243
+ balance: balance + " USD"
14244
+ };
14245
+ const codingPlan = asRecord$17(reading?.codingPlan);
14246
+ return factsOf({
14247
+ windows: codingPlanWindows(codingPlan),
14248
+ credits,
14249
+ resetCredits: null,
14250
+ fetchedAt: asMs(reading?.fetchedAt) ?? asMs(usage?.fetchedAt),
14251
+ refreshing: root.quotaRefreshing === true,
14252
+ error: firstText([
14253
+ root.quotaError,
14254
+ reading?.error,
14255
+ codingPlan?.error
14256
+ ]),
14257
+ planLabel: firstText([asRecord$17(reading?.billing)?.plan, codingPlan?.tier]),
14258
+ accountLabel: null,
14259
+ signedIn: root.authenticated
14260
+ });
14261
+ }
14262
+ /**
14263
+ * The Qoder account's own reading, from one `quota/usage` snapshot.
14264
+ *
14265
+ * Qoder states ABSOLUTE amounts per bucket (total / used / remaining), never a
14266
+ * percentage, so a share is derived here only under the same rule
14267
+ * {@link normalizeQoderQuota} already applies to the line-level card: BOTH a
14268
+ * total (positive) and a used figure must be stated. A bucket that states
14269
+ * amounts without a bounded allowance — a top-up balance, an org cap nobody
14270
+ * measured — is not turned into a bar, and a snapshot with no such bucket
14271
+ * answers null rather than a made-up zero.
14272
+ *
14273
+ * No window list accompanies it: this line has no percentage windows at all
14274
+ * (its buckets are absolute amounts, and its only instant is the snapshot's own
14275
+ * expiry), so `windows` is `[]` — the honest answer rather than a manufactured
14276
+ * one-window list nobody measured.
14277
+ *
14278
+ * @param snapshot - The account's quota snapshot (`{ buckets, expiresAt }`).
14279
+ * @returns The reading, or null when nothing bounded was measured.
14280
+ */
14281
+ function qoderAccountQuotaReading(snapshot) {
14282
+ const root = asRecord$17(snapshot);
14283
+ if (root === null) return null;
14284
+ const resetsAt = asResetAt(asMs(root.expiresAt));
14285
+ for (const bucket of asRecords(root.buckets)) {
14286
+ const total = asNumber$6(bucket.total);
14287
+ const remaining = asNumber$6(bucket.remaining);
14288
+ const used = asNumber$6(bucket.used) ?? (total !== null && remaining !== null ? total - remaining : null);
14289
+ if (total === null || total <= 0 || used === null) continue;
14290
+ return {
14291
+ usedPercent: asPercent(used / total * 100),
14292
+ resetsAt,
14293
+ windows: []
14294
+ };
14295
+ }
14296
+ return null;
14297
+ }
14298
+ /**
14299
+ * The MiniMax Code account's own reading, from one Token Plan snapshot.
14300
+ *
14301
+ * The window the row's BAR draws is the rolling 5-hour one — the line's own
14302
+ * headline, and the tightest limit the plan has: its machine key is `interval`,
14303
+ * and a payload that named no key is placed by its nominal length instead. The
14304
+ * weekly window is deliberately NOT substituted (a row saying 5% while the
14305
+ * 5-hour window is spent is worse than a row saying nothing), and an `unlimited`
14306
+ * window states no share at all, so nothing becomes a bar.
14307
+ *
14308
+ * `windows` carries the WHOLE list all the same: this line states it — `interval`
14309
+ * and `weekly`, each with a share and a reset instant — so an account can be
14310
+ * opened up and show both of its limits instead of only the bar.
14311
+ *
14312
+ * @param quota - The parsed `MinimaxCodeQuota`.
14313
+ * @returns The reading, or null when the 5-hour window measured nothing.
14314
+ */
14315
+ function minimaxAccountQuotaReading(quota) {
14316
+ const root = asRecord$17(quota);
14317
+ if (root === null) return null;
14318
+ const stated = asRecords(root.windows);
14319
+ const fiveHour = stated.find((window) => asText$4(window.key) === "interval") ?? stated.find((window) => namedWindowMinutes(window.key) === 300);
14320
+ const usedPercent = (fiveHour === void 0 ? null : asPercent(fiveHour.usedPercent) ?? remainingPercentToUsed(fiveHour.remainingPercent)) ?? asPercent(root.usedPercent);
14321
+ if (usedPercent === null) return null;
14322
+ const resetsAtMs = fiveHour === void 0 ? asMs(root.resetsAtMs) : asMs(fiveHour.resetsAtMs);
14323
+ return {
14324
+ usedPercent,
14325
+ resetsAt: asResetAt(resetsAtMs),
14326
+ windows: minimaxWindowList(stated, usedPercent, resetsAtMs)
14327
+ };
14328
+ }
14329
+ /**
14330
+ * Every window one Token Plan snapshot stated, in the card's own vocabulary.
14331
+ *
14332
+ * Mapped exactly the way `normalizeMinimaxCodeQuota` maps the line-level list, so
14333
+ * the per-account list and the line list can never disagree about what a window
14334
+ * is called: the key is a machine token that stands for a length, and the length
14335
+ * names the window (`interval` → 300 → 「5 小时」, `weekly` → 10080 → 「每周」).
14336
+ * One derived rule, not a second one beside it.
14337
+ *
14338
+ * @param stated - The payload's own `windows`.
14339
+ * @param headlineUsedPercent - The share the row's bar draws.
14340
+ * @param headlineResetsAtMs - The reset instant that goes with it.
14341
+ * @returns The windows, shortest first; one derived from the headline when the
14342
+ * payload stated no window at all, `[]` only when it said nothing usable.
14343
+ */
14344
+ function minimaxWindowList(stated, headlineUsedPercent, headlineResetsAtMs) {
14345
+ const windows = windowsFrom(stated.map((window) => ({
14346
+ label: null,
14347
+ usedPercent: asPercent(window.usedPercent) ?? remainingPercentToUsed(window.remainingPercent),
14348
+ windowMinutes: namedWindowMinutes(window.key),
14349
+ resetsAt: asMs(window.resetsAtMs)
14350
+ }))).sort(byWindowLength);
14351
+ if (windows.length > 0) return windows;
14352
+ return windowsFrom([{
14353
+ label: null,
14354
+ usedPercent: headlineUsedPercent,
14355
+ windowMinutes: 300,
14356
+ resetsAt: headlineResetsAtMs
14357
+ }]);
14358
+ }
14359
+ /**
14360
+ * Every normalizer, keyed by the line id a card names.
14361
+ *
14362
+ * The registry looks a line up here; a line that is registered but has no entry
14363
+ * falls back to nothing measured, so adding a pool without a normalizer
14364
+ * degrades to "no quota window" instead of to a thrown error.
14365
+ */
14366
+ const LINE_QUOTA_NORMALIZERS = {
14367
+ codex: normalizeCodexQuota,
14368
+ claude: normalizeClaudeQuota,
14369
+ "glm-coding-plan": normalizeGlmCodingPlanQuota,
14370
+ "step-code": normalizeStepCodeQuota,
14371
+ qoder: normalizeQoderQuota,
14372
+ "cline-pass": normalizeClinePassQuota,
14373
+ opendesign: normalizeOpenDesignQuota,
14374
+ "kimi-code": normalizeKimiCodeQuota,
14375
+ "minimax-code": normalizeMinimaxCodeQuota,
14376
+ antigravity: normalizeAntigravityQuota,
14377
+ "command-code": normalizeCommandCodeQuota
14378
+ };
14379
+ /** The three rotation strategies, in the pool's own vocabulary. */
14380
+ const ROTATION_STRATEGIES$1 = [
14381
+ "sequential",
14382
+ "round-robin",
14383
+ "sticky"
14384
+ ];
14385
+ /**
14386
+ * Every line this plugin can serve a card for.
14387
+ *
14388
+ * The routes are the lines' own, verbatim: this module is not allowed to invent
14389
+ * a prefix, because a prefix is what a line owns.
14390
+ */
14391
+ const LINE_DEFINITIONS = [
14392
+ {
14393
+ id: "codex",
14394
+ label: "ChatGPT",
14395
+ statusPath: "/api/dsh-chatgpt-subscription/status",
14396
+ refreshPath: "/api/dsh-chatgpt-subscription/quota/refresh",
14397
+ normalize: LINE_QUOTA_NORMALIZERS.codex
14398
+ },
14399
+ {
14400
+ id: "claude",
14401
+ label: "Claude",
14402
+ statusPath: "/claude/api/status",
14403
+ refreshPath: "/claude/api/quota",
14404
+ normalize: LINE_QUOTA_NORMALIZERS.claude
14405
+ },
14406
+ {
14407
+ id: "glm-coding-plan",
14408
+ label: "GLM Coding Plan",
14409
+ statusPath: "/glm-coding-plan/api/status",
14410
+ refreshPath: "/glm-coding-plan/api/quota",
14411
+ normalize: LINE_QUOTA_NORMALIZERS["glm-coding-plan"]
14412
+ },
14413
+ {
14414
+ id: "step-code",
14415
+ label: "Step Code",
14416
+ statusPath: "/step-code/api/status",
14417
+ refreshPath: "/step-code/api/quota",
14418
+ normalize: LINE_QUOTA_NORMALIZERS["step-code"]
14419
+ },
14420
+ {
14421
+ id: "qoder",
14422
+ label: "Qoder",
14423
+ statusPath: "/qoder/api/status",
14424
+ refreshPath: "/qoder/api/quota",
14425
+ normalize: LINE_QUOTA_NORMALIZERS.qoder
14426
+ },
14427
+ {
14428
+ id: "cline-pass",
14429
+ label: "Cline Pass",
14430
+ statusPath: "/cline-pass/api/status",
14431
+ refreshPath: "/cline-pass/api/usage",
14432
+ normalize: LINE_QUOTA_NORMALIZERS["cline-pass"]
14433
+ },
14434
+ {
14435
+ id: "opendesign",
14436
+ label: "OpenDesign",
14437
+ statusPath: "/opendesign/api/status",
14438
+ refreshPath: "/opendesign/api/usage",
14439
+ normalize: LINE_QUOTA_NORMALIZERS.opendesign
14440
+ },
14441
+ {
14442
+ id: "kimi-code",
14443
+ label: "Kimi Code",
14444
+ statusPath: "/kimi-code/api/status",
14445
+ refreshPath: "/kimi-code/api/quota",
14446
+ normalize: LINE_QUOTA_NORMALIZERS["kimi-code"]
14447
+ },
14448
+ {
14449
+ id: "minimax-code",
14450
+ label: "MiniMax Code",
14451
+ statusPath: "/minimax-code/api/status",
14452
+ refreshPath: "/minimax-code/api/quota",
14453
+ normalize: LINE_QUOTA_NORMALIZERS["minimax-code"]
14454
+ },
14455
+ {
14456
+ id: "antigravity",
14457
+ label: "Antigravity",
14458
+ statusPath: "/antigravity/api/status",
14459
+ refreshPath: "/antigravity/api/quota",
14460
+ normalize: LINE_QUOTA_NORMALIZERS.antigravity
14461
+ },
14462
+ {
14463
+ id: "command-code",
14464
+ label: "Command Code",
14465
+ statusPath: "/command-code/api/status",
14466
+ refreshPath: "/command-code/api/quota",
14467
+ normalize: LINE_QUOTA_NORMALIZERS["command-code"]
14468
+ }
14469
+ ];
14470
+ /**
14471
+ * THE per-account quota hook table: line id → how that line's readings are made.
14472
+ *
14473
+ * Keying it by line id is the whole point: a card names a line, a row names an
14474
+ * account, and a line with no entry here keeps EXACTLY the behaviour it had
14475
+ * before this table existed. That is not a fallback nobody exercised — it is the
14476
+ * default for nine of the eleven lines.
14477
+ *
14478
+ * Nothing in this table imports a line. Each entry needs that line's client and
14479
+ * receives it ({@link LineAccountQuotaClient}); a future line adds one entry
14480
+ * here plus one injected client, and no other module has to learn about it.
14481
+ *
14482
+ * What justified the two entries:
14483
+ *
14484
+ * - `qoder` — the line's `quota/usage` snapshot states absolute buckets, not a
14485
+ * percentage; {@link qoderAccountQuotaReading} derives a share only when a
14486
+ * bucket bounded it (total > 0) AND stated what was used. No bounded bucket,
14487
+ * no bar.
14488
+ * - `minimax-code` — the Token Plan states a 5-hour (`interval`) and a weekly
14489
+ * window; the row draws the 5-hour one, because that is the tightest limit the
14490
+ * plan has ({@link minimaxAccountQuotaReading}). The weekly window is never
14491
+ * substituted for it.
14492
+ */
14493
+ const LINE_ACCOUNT_QUOTA_READERS = {
14494
+ qoder: (client) => async (credentials, context) => qoderAccountQuotaReading(await client(credentials, context)),
14495
+ "minimax-code": (client) => async (credentials, context) => minimaxAccountQuotaReading(await client(credentials, context))
14496
+ };
14497
+ /**
14498
+ * The readers in force, built from the clients the composition injected.
14499
+ *
14500
+ * A line whose client was not injected gets NO reader, which is what keeps the
14501
+ * hook optional in practice and not merely on paper: a composition that hands
14502
+ * over none of these reads every card exactly as it did before.
14503
+ *
14504
+ * @param clients - Line id → that line's quota client. Omitted or partial is the
14505
+ * normal case, not an error.
14506
+ * @returns Line id → the reader for it; lines without both halves are absent.
14507
+ */
14508
+ function createAccountQuotaReaders(clients) {
14509
+ const readers = {};
14510
+ if (clients === void 0) return readers;
14511
+ for (const [line, factory] of Object.entries(LINE_ACCOUNT_QUOTA_READERS)) {
14512
+ const client = clients[line];
14513
+ if (client === void 0 || factory === void 0) continue;
14514
+ readers[line] = factory(client);
14515
+ }
14516
+ return readers;
14517
+ }
14518
+ /**
14519
+ * The payload as a JSON object, or null.
14520
+ *
14521
+ * @param value - Candidate.
14522
+ * @returns The object, or null.
14523
+ */
14524
+ function asRecord$16(value) {
14525
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
14526
+ }
14527
+ /**
14528
+ * A trimmed non-empty string, or null.
14529
+ *
14530
+ * @param value - Candidate.
14531
+ * @returns The trimmed text, or null.
14532
+ */
14533
+ function nonEmpty$2(value) {
14534
+ if (typeof value !== "string") return null;
14535
+ const trimmed = value.trim();
14536
+ return trimmed === "" ? null : trimmed;
14537
+ }
14538
+ /**
14539
+ * A rotation strategy, with the pool kernel's own fallback.
14540
+ *
14541
+ * @param value - Candidate.
14542
+ * @returns The strategy, defaulting to `sequential`.
14543
+ */
14544
+ function normalizeStrategy(value) {
14545
+ return value === "round-robin" || value === "sticky" ? value : "sequential";
14546
+ }
14547
+ /**
14548
+ * A consumed share, clamped into the bar it is drawn as.
14549
+ *
14550
+ * @param value - Share in percent.
14551
+ * @returns The share between 0 and 100.
14552
+ */
14553
+ function clampPercent$1(value) {
14554
+ if (!Number.isFinite(value)) return 0;
14555
+ return Math.min(100, Math.max(0, value));
14556
+ }
14557
+ /**
14558
+ * One error as the sentence a card can show.
14559
+ *
14560
+ * @param error - The caught value.
14561
+ * @param label - The line it happened on.
14562
+ * @param timeoutMs - The budget that may have expired.
14563
+ * @returns A short, user-facing reason.
14564
+ */
14565
+ function reasonOf(error, label, timeoutMs) {
14566
+ if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return label + " 的接口在 " + timeoutMs + " 毫秒内没有响应。 (Timed out.)";
14567
+ return error instanceof Error ? error.message : String(error);
14568
+ }
14569
+ /**
14570
+ * A deadline, combined with the caller's cancellation when one was passed.
14571
+ *
14572
+ * @param timeoutMs - The budget in milliseconds.
14573
+ * @param caller - The capability's own signal, when it has one.
14574
+ * @returns The signal to hand to fetch, and the disposer that clears the timer.
14575
+ */
14576
+ function budgetOf(timeoutMs, caller) {
14577
+ const controller = new AbortController();
14578
+ const timer = setTimeout(() => {
14579
+ controller.abort(/* @__PURE__ */ new Error("timeout"));
14580
+ }, timeoutMs);
14581
+ timer.unref?.();
14582
+ const forward = () => {
14583
+ controller.abort(caller?.reason);
14584
+ };
14585
+ if (caller !== void 0) if (caller.aborted) forward();
14586
+ else caller.addEventListener("abort", forward, { once: true });
14587
+ return {
14588
+ signal: controller.signal,
14589
+ dispose: () => {
14590
+ clearTimeout(timer);
14591
+ caller?.removeEventListener("abort", forward);
14592
+ }
14593
+ };
14594
+ }
14595
+ /**
14596
+ * A promise that rejects once the budget expires.
14597
+ *
14598
+ * Applied to the LOCAL pool read as well as to HTTP: a credential store that
14599
+ * hangs (a stuck keychain, a locked file) must not hold the card either.
14600
+ *
14601
+ * @param promise - The work.
14602
+ * @param timeoutMs - The budget.
14603
+ * @param label - The line it belongs to.
14604
+ * @returns The work's value.
14605
+ */
14606
+ function bounded(promise, timeoutMs, label) {
14607
+ if (timeoutMs <= 0) return promise;
14608
+ return new Promise((resolve, reject) => {
14609
+ const timer = setTimeout(() => {
14610
+ reject(/* @__PURE__ */ new Error(label + " 的本地读取超过 " + timeoutMs + " 毫秒。 (Local read timed out.)"));
14611
+ }, timeoutMs);
14612
+ timer.unref?.();
14613
+ promise.then((value) => {
14614
+ clearTimeout(timer);
14615
+ resolve(value);
14616
+ }, (error) => {
14617
+ clearTimeout(timer);
14618
+ reject(error instanceof Error ? error : new Error(String(error)));
14619
+ });
14620
+ });
14621
+ }
14622
+ /**
14623
+ * The reason a failed response states, when it states one.
14624
+ *
14625
+ * The routes answer their own failures in the same envelope
14626
+ * (`{ ok: false, error: '…' }`), and that sentence is what tells a user what to
14627
+ * do ("every account is disabled", "not signed in"). Reporting only the status
14628
+ * code would throw it away.
14629
+ *
14630
+ * @param response - The failed response.
14631
+ * @returns The stated reason, or null when the body states none.
14632
+ */
14633
+ async function failureDetail(response) {
14634
+ try {
14635
+ const body = asRecord$16(await response.json());
14636
+ const detail = body?.error;
14637
+ if (typeof detail === "string" && detail.trim() !== "") return detail.trim();
14638
+ const message = body?.message;
14639
+ return typeof message === "string" && message.trim() !== "" ? message.trim() : null;
14640
+ } catch {
14641
+ return null;
14642
+ }
14643
+ }
14644
+ /**
14645
+ * Read one line's status route over loopback.
14646
+ *
14647
+ * @param definition - The line.
14648
+ * @param options - Where to reach the host and with what.
14649
+ * @param timeoutMs - The budget.
14650
+ * @param signal - The caller's cancellation.
14651
+ * @returns The payload, or the reason it could not be read.
14652
+ */
14653
+ async function readStatus(definition, options, timeoutMs, signal) {
14654
+ const budget = budgetOf(timeoutMs, signal);
14655
+ const client = options.fetchFn ?? fetch;
14656
+ try {
14657
+ const response = await client(originOf(options) + definition.statusPath, {
14658
+ method: "GET",
14659
+ headers: { accept: "application/json" },
14660
+ signal: budget.signal
14661
+ });
14662
+ if (!response.ok) {
14663
+ const detail = await failureDetail(response);
14664
+ return { error: definition.label + " 的 status 路由返回 HTTP " + response.status + (detail === null ? "。" : ":" + detail + "。") + " (Status route failed.)" };
14665
+ }
14666
+ const body = asRecord$16(await response.json());
14667
+ if (body === null) return { error: definition.label + " 的 status 响应不是 JSON 对象。 (Status payload is not an object.)" };
14668
+ if (body.ok !== true) {
14669
+ const detail = nonEmpty$2(typeof body.error === "string" ? body.error : null);
14670
+ return { error: definition.label + " 的 status 路由报告失败:" + (detail ?? "ok 不是 true") + "。 (Status route reported a failure.)" };
14671
+ }
14672
+ return { value: body.value };
14673
+ } catch (error) {
14674
+ return { error: reasonOf(error, definition.label, timeoutMs) };
14675
+ } finally {
14676
+ budget.dispose();
14677
+ }
14678
+ }
14679
+ /**
14680
+ * The origin, resolved at call time.
14681
+ *
14682
+ * Lazy on purpose: the web server may not have bound a port when this provider
14683
+ * registers, and an origin resolved then would be a guess that outlives the
14684
+ * fact. A failure here is reported per card, not thrown at load.
14685
+ *
14686
+ * @param options - The registry options.
14687
+ * @returns The origin, without a trailing slash.
14688
+ */
14689
+ function originOf(options) {
14690
+ const origin = options.origin();
14691
+ return origin.endsWith("/") ? origin.slice(0, -1) : origin;
14692
+ }
14693
+ /**
14694
+ * POST one line's own refresh route.
14695
+ *
14696
+ * The body is an empty JSON object rather than nothing: several of these routes
14697
+ * parse a JSON body before dispatching, and a request stating `content-length:
14698
+ * 0` is one they would refuse. The `origin` header is what the routes'
14699
+ * same-origin check compares against the `Host` they were addressed to — this
14700
+ * call is the host asking itself, so the two agree.
14701
+ *
14702
+ * @param definition - The line.
14703
+ * @param options - Where to reach the host and with what.
14704
+ * @param timeoutMs - The budget.
14705
+ * @param signal - The caller's cancellation.
14706
+ * @returns The outcome when the refresh failed, or null when it landed.
14707
+ */
14708
+ async function postRefresh(definition, options, timeoutMs, signal) {
14709
+ const path = definition.refreshPath;
14710
+ if (path === null) return null;
14711
+ const budget = budgetOf(timeoutMs, signal);
14712
+ const client = options.fetchFn ?? fetch;
14713
+ let origin;
14714
+ try {
14715
+ origin = originOf(options);
14716
+ } catch (error) {
14717
+ budget.dispose();
14718
+ return {
14719
+ message: reasonOf(error, definition.label, timeoutMs),
14720
+ stillRunning: false
14721
+ };
14722
+ }
14723
+ try {
14724
+ const response = await client(origin + path, {
14725
+ method: "POST",
14726
+ headers: {
14727
+ "content-type": "application/json",
14728
+ origin
14729
+ },
14730
+ body: "{}",
14731
+ signal: budget.signal
14732
+ });
14733
+ if (!response.ok) {
14734
+ const detail = await failureDetail(response);
14735
+ return {
14736
+ message: definition.label + " 的刷新路由返回 HTTP " + response.status + (detail === null ? "。" : ":" + detail + "。") + " (Refresh route failed.)",
14737
+ stillRunning: false
14738
+ };
14739
+ }
14740
+ return null;
14741
+ } catch (error) {
14742
+ if (budget.signal.aborted && !(signal?.aborted ?? false)) return {
14743
+ message: definition.label + " 的刷新在 " + timeoutMs + " 毫秒内没有返回;线路仍在刷新,卡片读到的是它上一次的快照。 (The refresh did not answer within the budget; the line is still refreshing.)",
14744
+ stillRunning: true
14745
+ };
14746
+ return {
14747
+ message: reasonOf(error, definition.label, timeoutMs),
14748
+ stillRunning: false
14749
+ };
14750
+ } finally {
14751
+ budget.dispose();
14752
+ }
14753
+ }
14754
+ /**
14755
+ * One deadline applied to every request a fetch makes.
14756
+ *
14757
+ * The per-account readers are handed a client, not a signal, so the budget has
14758
+ * to travel inside the client: a reader that keeps the `fetch` it was given is
14759
+ * then cancelled at the deadline instead of merely being ignored while its
14760
+ * socket stays open. A caller's own signal is respected when it already carries
14761
+ * one — this ADDS a deadline, it never loosens one.
14762
+ *
14763
+ * @param fetchFn - The client to wrap.
14764
+ * @param signal - The deadline to attach.
14765
+ * @returns A client whose requests carry it.
14766
+ */
14767
+ function withDeadline(fetchFn, signal) {
14768
+ const boundedFetch = async (input, init) => await fetchFn(input, init === void 0 ? { signal } : {
14769
+ ...init,
14770
+ signal: init.signal ?? signal
14771
+ });
14772
+ return boundedFetch;
14773
+ }
14774
+ /**
14775
+ * A counting gate: at most `limit` callers run `work` at the same time.
14776
+ *
14777
+ * The slot is handed straight to the next waiter rather than released and
14778
+ * re-taken, so `active` can never exceed the limit by one in the window between
14779
+ * two arrivals — the reason a naive decrement-then-increment leaks.
14780
+ *
14781
+ * @param limit - How many may run at once.
14782
+ * @returns The gated runner.
14783
+ */
14784
+ function gateOf(limit) {
14785
+ let active = 0;
14786
+ const waiting = [];
14787
+ return async function run(work) {
14788
+ if (active < limit) active += 1;
14789
+ else await new Promise((resolve) => waiting.push(resolve));
14790
+ try {
14791
+ return await work();
14792
+ } finally {
14793
+ const next = waiting.shift();
14794
+ if (next === void 0) active -= 1;
14795
+ else next();
14796
+ }
14797
+ };
14798
+ }
14799
+ /**
14800
+ * How long one cached entry stays fresh.
14801
+ *
14802
+ * The two tiers are the point: a good reading waits out the poll interval, a
14803
+ * failed one is retried soon (see the two TTL constants).
14804
+ *
14805
+ * @param entry - The cached entry.
14806
+ * @param runtime - Where the two TTLs live.
14807
+ * @returns Its lifetime in milliseconds.
14808
+ */
14809
+ function lifetimeOf(entry, runtime) {
14810
+ return entry.reading === null ? runtime.failureCacheMs : runtime.cacheMs;
14811
+ }
14812
+ /**
14813
+ * Read ONE account's own quota, absorbing every failure.
14814
+ *
14815
+ * The two steps — the credential, then the upstream answer — share one budget,
14816
+ * because the row is one question: a credential that took two of the five
14817
+ * seconds must not buy the read a second full allowance.
14818
+ *
14819
+ * @param definition - The line, for the label a diagnostic would state.
14820
+ * @param accountId - The account being read.
14821
+ * @param credentialFor - The pool's per-account credential resolver.
14822
+ * @param reader - The line's reader.
14823
+ * @param fetchFn - The client to read through.
14824
+ * @param runtime - The budgets, and the caller's cancellation.
14825
+ * @param signal - The card's own cancellation, when it has one.
14826
+ * @returns The reading, or null for "nothing measured, one way or another".
14827
+ */
14828
+ async function readOneAccountQuota(definition, accountId, credentialFor, reader, fetchFn, runtime, signal) {
14829
+ const budget = budgetOf(runtime.timeoutMs, signal);
14830
+ const client = withDeadline(fetchFn, budget.signal);
14831
+ try {
14832
+ return await bounded((async () => {
14833
+ const reading = await reader(await credentialFor(accountId, client), {
14834
+ accountId,
14835
+ fetchFn: client
14836
+ });
14837
+ if (reading === null || reading.usedPercent === null || !Number.isFinite(reading.usedPercent)) return null;
14838
+ const resetsAt = reading.resetsAt !== null && Number.isFinite(reading.resetsAt) ? reading.resetsAt : null;
14839
+ return {
14840
+ usedPercent: clampPercent$1(reading.usedPercent),
14841
+ resetsAt,
14842
+ windows: Array.isArray(reading.windows) ? reading.windows : []
14843
+ };
14844
+ })(), runtime.timeoutMs, definition.label);
14845
+ } catch {
14846
+ return null;
14847
+ } finally {
14848
+ budget.dispose();
14849
+ }
14850
+ }
14851
+ /**
14852
+ * The accounts one read spends its requests on, in the order it spends them.
14853
+ *
14854
+ * Primary first, then most recently used, because a cap has to drop SOMETHING and
14855
+ * the rows it drops should be the ones a reader is least likely to be looking at.
14856
+ * The sort is stable on purpose: rows that tie on both keys keep the pool's own
14857
+ * order, so two reads of an unchanged pool pick the same eight and the window
14858
+ * does not flicker between polls.
14859
+ *
14860
+ * @param accounts - Every pooled account, as the pool stated them.
14861
+ * @param limit - How many may be read at most; zero or less means no cap.
14862
+ * @returns The accounts to read, in the order their requests should start.
14863
+ */
14864
+ function accountsForReading(accounts, limit) {
14865
+ const ordered = [...accounts].sort((left, right) => {
14866
+ const primary = (right.isPrimary ? 1 : 0) - (left.isPrimary ? 1 : 0);
14867
+ if (primary !== 0) return primary;
14868
+ return (right.lastUsedAt ?? 0) - (left.lastUsedAt ?? 0);
14869
+ });
14870
+ return limit > 0 ? ordered.slice(0, limit) : ordered;
14871
+ }
14872
+ /**
14873
+ * The value a per-account read settles to when the stage budget expired first.
14874
+ *
14875
+ * A sentinel rather than `null` on purpose: `null` means "this account WAS asked
14876
+ * and measured nothing", which the row draws as an empty window list and the cache
14877
+ * remembers as a failure. A row the stage budget never reached was never asked, so
14878
+ * remembering it would be wrong — and not remembering it is exactly what keeps the
14879
+ * promise that the next read tries it again.
14880
+ */
14881
+ const STAGE_BUDGET_SPENT = Symbol("per-account stage budget spent");
14882
+ /**
14883
+ * A promise that settles the moment `signal` aborts, and never rejects.
14884
+ *
14885
+ * Racing a read against this is how the stage budget cuts a row off: the row stops
14886
+ * being awaited, while the read itself is cancelled through the same signal it was
14887
+ * handed ({@link readOneAccountQuota}), so no gate slot is held afterwards.
14888
+ *
14889
+ * @param signal - The stage's own deadline.
14890
+ * @returns The sentinel, once the deadline passes.
14891
+ */
14892
+ function whenSpent(signal) {
14893
+ return new Promise((resolve) => {
14894
+ if (signal.aborted) {
14895
+ resolve(STAGE_BUDGET_SPENT);
14896
+ return;
14897
+ }
14898
+ signal.addEventListener("abort", () => resolve(STAGE_BUDGET_SPENT), { once: true });
14899
+ });
14900
+ }
14901
+ /**
14902
+ * Every account's own reading this card can afford.
14903
+ *
14904
+ * Two bounds decide "can afford", and they answer different questions: HOW MANY
14905
+ * rows are worth a request ({@link LINE_ACCOUNT_QUOTA_MAX_ACCOUNTS}, primary and
14906
+ * most-recently-used first) and HOW LONG the stage may hold the card
14907
+ * ({@link LINE_ACCOUNT_QUOTA_WHOLE_BUDGET_MS}). Neither replaces the per-account
14908
+ * budget, which still stops one hanging row from spending the stage's whole
14909
+ * allowance alone.
14910
+ *
14911
+ * @param definition - The line.
14912
+ * @param pool - The account half, already read.
14913
+ * @param source - The line's pool, for its per-account credential resolver.
14914
+ * @param runtime - Readers, cache, gate and budgets.
14915
+ * @param fetchFn - The client the pool and the readers use.
14916
+ * @param signal - The card's own cancellation.
14917
+ * @returns The readings per account id (null included, so a remembered failure
14918
+ * is distinguishable from an absent entry), and whether the line is hooked;
14919
+ * both empty/false for a line that did not opt in, in which case every row
14920
+ * keeps the pool's own snapshot and states no window list.
14921
+ */
14922
+ async function readAccountQuotas(definition, pool, source, runtime, fetchFn, signal) {
14923
+ const readings = /* @__PURE__ */ new Map();
14924
+ const reader = runtime.readers[definition.id];
14925
+ const credentialFor = source?.getFreshCredential;
14926
+ const hooked = reader !== void 0 && credentialFor !== void 0;
14927
+ if (reader === void 0 || credentialFor === void 0 || pool.accounts.length === 0) return {
14928
+ hooked,
14929
+ readings
14930
+ };
14931
+ const now = Date.now();
14932
+ for (const [key, entry] of runtime.cache) if (now - entry.at >= lifetimeOf(entry, runtime)) runtime.cache.delete(key);
14933
+ const selected = accountsForReading(pool.accounts, runtime.maxAccounts);
14934
+ const stage = runtime.wholeBudgetMs > 0 ? budgetOf(runtime.wholeBudgetMs, signal) : null;
14935
+ const spent = stage === null ? null : whenSpent(stage.signal);
14936
+ const credentialed = credentialFor.bind(source);
14937
+ try {
14938
+ await Promise.all(selected.map(async (account) => {
14939
+ const key = definition.id + "\0" + account.id;
14940
+ const cached = runtime.cache.get(key);
14941
+ if (cached !== void 0 && now - cached.at < lifetimeOf(cached, runtime)) {
14942
+ readings.set(account.id, cached.reading);
14943
+ return;
14944
+ }
14945
+ let flight = runtime.pending.get(key);
14946
+ if (flight === void 0) {
14947
+ flight = runtime.gate(async () => await readOneAccountQuota(definition, account.id, credentialed, reader, fetchFn, runtime, stage?.signal ?? signal));
14948
+ runtime.pending.set(key, flight);
14949
+ const settle = () => {
14950
+ if (runtime.pending.get(key) === flight) runtime.pending.delete(key);
14951
+ };
14952
+ flight.then(settle, settle);
14953
+ }
14954
+ const reading = spent === null ? await flight : await Promise.race([flight, spent]);
14955
+ if (reading === STAGE_BUDGET_SPENT) return;
14956
+ runtime.cache.set(key, {
14957
+ at: Date.now(),
14958
+ reading
14959
+ });
14960
+ readings.set(account.id, reading);
14961
+ }));
14962
+ } finally {
14963
+ stage?.dispose();
14964
+ }
14965
+ return {
14966
+ hooked,
14967
+ readings
14968
+ };
14969
+ }
14970
+ /**
14971
+ * The account half of a card, from the pool itself.
14972
+ *
14973
+ * Both calls are attempted even when one fails: listAccounts and read read the
14974
+ * same document through different paths, and a pool that answers rows but not
14975
+ * its document (or the reverse) still describes most of itself.
14976
+ *
14977
+ * @param pool - The line's pool, if it has one.
14978
+ * @param definition - The line.
14979
+ * @param timeoutMs - The budget.
14980
+ * @returns The facts.
14981
+ */
14982
+ async function readPool(pool, definition, timeoutMs) {
14983
+ if (pool === void 0) return {
14984
+ installed: false,
14985
+ accounts: [],
14986
+ activeAccountId: null,
14987
+ strategy: "sequential",
14988
+ error: null
14989
+ };
14990
+ const listTask = bounded(pool.listAccounts(), timeoutMs, definition.label);
14991
+ const documentTask = pool.read === void 0 ? Promise.resolve(void 0) : bounded(pool.read(), timeoutMs, definition.label);
14992
+ const [list, document] = await Promise.allSettled([listTask, documentTask]);
14993
+ const accounts = list.status === "fulfilled" ? list.value : [];
14994
+ const failure = list.status === "rejected" ? reasonOf(list.reason, definition.label, timeoutMs) : document.status === "rejected" ? reasonOf(document.reason, definition.label, timeoutMs) : null;
14995
+ const record = document.status === "fulfilled" ? asRecord$16(document.value) : null;
14996
+ const primary = accounts.find((account) => account.isPrimary === true);
14997
+ return {
14998
+ installed: true,
14999
+ accounts,
15000
+ activeAccountId: nonEmpty$2(typeof record?.activeAccountId === "string" ? record.activeAccountId : null) ?? primary?.id ?? null,
15001
+ strategy: normalizeStrategy(record?.rotationStrategy),
15002
+ error: failure
15003
+ };
15004
+ }
15005
+ /**
15006
+ * One pooled account as the card renders it.
15007
+ *
15008
+ * @param account - The pool's own summary.
15009
+ * @param reading - This account's own newest reading, when the line produced
15010
+ * one; null/absent means the line has no hook, the read failed, or it measured
15011
+ * nothing — and all three leave the pool's own snapshot standing.
15012
+ * @param hooked - Whether a per-account read is in force for this line at all,
15013
+ * which is what decides whether the row states a window list. Defaults to
15014
+ * false so a caller with no hook knowledge omits it rather than inventing an
15015
+ * empty list.
15016
+ * @returns The row.
15017
+ */
15018
+ function accountRow(account, reading, hooked = false) {
15019
+ const window = account.quota?.windows[0];
15020
+ const own = reading === null || reading === void 0 ? null : reading;
15021
+ const ownUsed = own === null || own.usedPercent === null ? null : clampPercent$1(own.usedPercent);
15022
+ return {
15023
+ id: account.id,
15024
+ label: nonEmpty$2(account.alias) ?? nonEmpty$2(account.email) ?? account.id,
15025
+ email: account.email ?? null,
15026
+ primary: account.isPrimary === true,
15027
+ plan: account.planLabel ?? null,
15028
+ cooldownUntil: account.cooldownUntil ?? null,
15029
+ authStatus: account.authStatus ?? null,
15030
+ usedPercent: ownUsed ?? (window === void 0 ? null : clampPercent$1(window.usedPercent)),
15031
+ resetsAt: ownUsed === null ? window?.resetsAt ?? null : own?.resetsAt ?? window?.resetsAt ?? null,
15032
+ ...hooked ? { windows: own?.windows ?? [] } : {}
15033
+ };
15034
+ }
15035
+ /**
15036
+ * One line's whole card: the halves read and merged.
15037
+ *
15038
+ * The quota half starts first because the per-account readings need the account
15039
+ * rows to know which accounts exist; both then run while it answers, so the card
15040
+ * still costs one budget rather than two.
15041
+ *
15042
+ * @param definition - The line.
15043
+ * @param options - The registry options.
15044
+ * @param quota - The per-account hook state (readers, cache, gate, budgets).
15045
+ * @param timeoutMs - The budget.
15046
+ * @param signal - The caller's cancellation.
15047
+ * @returns Shape C.
15048
+ */
15049
+ async function readCard(definition, options, quota, timeoutMs, signal) {
15050
+ const source = options.pools[definition.id];
15051
+ const statusTask = readStatus(definition, options, timeoutMs, signal);
15052
+ const pool = await readPool(source, definition, timeoutMs);
15053
+ const [status, accountQuotas] = await Promise.all([statusTask, readAccountQuotas(definition, pool, source, quota, options.fetchFn ?? fetch, signal)]);
15054
+ const facts = "error" in status ? null : definition.normalize(status.value);
15055
+ const transportError = "error" in status ? status.error : null;
15056
+ const primary = pool.accounts.find((account) => account.id === pool.activeAccountId) ?? pool.accounts.find((account) => account.isPrimary === true) ?? pool.accounts[0];
15057
+ return {
15058
+ line: definition.id,
15059
+ label: definition.label,
15060
+ signedIn: facts?.signedIn === true || pool.accounts.some((account) => account.authStatus === void 0 || account.authStatus === "ok"),
15061
+ installed: pool.installed,
15062
+ planLabel: facts?.planLabel ?? null,
15063
+ accountLabel: facts?.accountLabel ?? nonEmpty$2(primary?.email) ?? null,
15064
+ accounts: pool.accounts.map((account) => accountRow(account, accountQuotas.readings.get(account.id), accountQuotas.hooked)),
15065
+ activeAccountId: pool.activeAccountId ?? primary?.id ?? null,
15066
+ strategy: pool.strategy,
15067
+ quota: facts?.quota ?? null,
15068
+ error: transportError ?? facts?.error ?? pool.error
15069
+ };
15070
+ }
15071
+ /**
15072
+ * Build a registry over the injected line → pool mapping.
15073
+ *
15074
+ * @param options - The mapping, the origin and the seams.
15075
+ * @returns The registry.
15076
+ */
15077
+ function createLineRegistry(options) {
15078
+ const definitions = options.definitions ?? LINE_DEFINITIONS;
15079
+ const timeoutMs = options.timeoutMs ?? 5e3;
15080
+ const accountQuota = {
15081
+ readers: createAccountQuotaReaders(options.accountQuotaClients),
15082
+ cache: /* @__PURE__ */ new Map(),
15083
+ pending: /* @__PURE__ */ new Map(),
15084
+ gate: gateOf(2),
15085
+ cacheMs: options.accountQuotaCacheMs ?? 6e4,
15086
+ failureCacheMs: options.accountQuotaFailureCacheMs ?? 15e3,
15087
+ timeoutMs: options.accountQuotaTimeoutMs ?? 5e3,
15088
+ wholeBudgetMs: options.accountQuotaWholeBudgetMs ?? 6e3,
15089
+ maxAccounts: options.accountQuotaMaxAccounts ?? 8
15090
+ };
15091
+ const byId = new Map(definitions.map((definition) => [definition.id, definition]));
15092
+ const definitionOf = (line) => {
15093
+ const found = byId.get(line);
15094
+ if (found !== void 0) return found;
15095
+ throw new Error("未知线路「" + line + "」:这个注册表里没有它。 (Unknown line \"" + line + "\"; known lines: " + definitions.map((entry) => entry.id).join(", ") + ".)");
15096
+ };
15097
+ const poolOf = (definition) => options.pools[definition.id];
15098
+ return {
15099
+ definitions,
15100
+ ids: () => definitions.map((definition) => definition.id),
15101
+ read: async (line, signal) => await readCard(definitionOf(line), options, accountQuota, timeoutMs, signal),
15102
+ refresh: async (line, signal) => {
15103
+ const definition = definitionOf(line);
15104
+ const outcome = await postRefresh(definition, options, timeoutMs, signal);
15105
+ const card = await readCard(definition, options, accountQuota, timeoutMs, signal);
15106
+ if (outcome === null) return card;
15107
+ if (outcome.stillRunning && card.quota !== null) return {
15108
+ ...card,
15109
+ quota: {
15110
+ ...card.quota,
15111
+ refreshing: true
15112
+ }
15113
+ };
15114
+ return card.quota !== null || card.error !== null ? card : {
15115
+ ...card,
15116
+ error: outcome.message
15117
+ };
15118
+ },
15119
+ switchAccount: async (line, accountId, signal) => {
15120
+ const definition = definitionOf(line);
15121
+ const pool = poolOf(definition);
15122
+ if (pool === void 0) throw new Error("「" + definition.label + "」的账号池未安装,没有可切换的账号。 (This line has no account pool.)");
15123
+ if (!(await pool.listAccounts()).some((account) => account.id === accountId)) throw new Error("未知账号「" + accountId + "」:「" + definition.label + "」的号池里没有这个 id。 (Unknown account.)");
15124
+ await pool.setPrimary(accountId);
15125
+ return await readCard(definition, options, accountQuota, timeoutMs, signal);
15126
+ },
15127
+ changeStrategy: async (line, strategy, signal) => {
15128
+ const definition = definitionOf(line);
15129
+ const pool = poolOf(definition);
15130
+ if (pool === void 0) throw new Error("「" + definition.label + "」的账号池未安装,没有可调整的轮换策略。 (This line has no account pool.)");
15131
+ const match = ROTATION_STRATEGIES$1.find((candidate) => candidate === strategy);
15132
+ if (match === void 0) throw new Error("轮换策略只能是 " + ROTATION_STRATEGIES$1.join(" / ") + " 之一,收到的不是其中之一。 (The rotation strategy must be one of " + ROTATION_STRATEGIES$1.join(", ") + ".)");
15133
+ await pool.setStrategy(match);
15134
+ return await readCard(definition, options, accountQuota, timeoutMs, signal);
15135
+ }
15136
+ };
15137
+ }
15138
+ /**
15139
+ * The line a card names, from its props.
15140
+ *
15141
+ * @param props - The card's props.
15142
+ * @returns The line id.
15143
+ */
15144
+ function lineOfProps(props) {
15145
+ const record = asRecord$16(props);
15146
+ const line = nonEmpty$2(typeof record?.line === "string" ? record.line : null);
15147
+ if (line === null) throw new Error("这张卡片没有 line 属性:line-card 数据源需要 props.line 指明读哪条线路。 (This card states no \"line\" prop; the line-card data source needs one.)");
15148
+ return line;
15149
+ }
15150
+ //#endregion
15151
+ //#region src/host/widget-board/provider.ts
15152
+ /**
15153
+ * The `widgetBoard` provider: this plugin's board contribution.
15154
+ *
15155
+ * What it publishes (all under one handle, so unloading this plugin takes all
15156
+ * of it with it):
15157
+ *
15158
+ * - one widget-type directory — the `widgets` folder that ships in this package;
15159
+ * - one data source, `line-card`, which answers one card per subscription line;
15160
+ * - three capabilities: force one line's quota read, pin one of its pooled
15161
+ * accounts, and change that line's rotation strategy.
15162
+ *
15163
+ * The two ChatGPT-only cards this provider used to publish (one for quota, one
15164
+ * for the account pool) are gone: `widgets/line-flip` replaced both with a single
15165
+ * card that names its line in `props.line`, so the old per-subject data sources,
15166
+ * their shapes and their unit conversion were deleted rather than left unused.
15167
+ *
15168
+ * The service is optional. A host without a board never reaches the inject
15169
+ * callback at all, and everything below is sized to make that true even when
15170
+ * the board IS present: nothing here may fail the plugin's load, so the
15171
+ * registration runs off the load path and every failure is a warn.
15172
+ */
15173
+ /**
15174
+ * The package name the widget directory is looked up by.
15175
+ *
15176
+ * Deliberately the SCOPED id, unlike {@link WIDGET_BOARD_PROVIDER}: this string
15177
+ * never reaches a manifest, it only identifies which `package.json` found by
15178
+ * walking up from this file is this package's own.
15179
+ */
15180
+ const PACKAGE_NAME = "@eddyskywalker/dsh-chatgpt-subscription";
15181
+ /** How many directories to climb while looking for this package's root. */
15182
+ const MAX_ROOT_WALK = 8;
15183
+ /**
15184
+ * Subdirectory of this package that holds the widget types.
15185
+ *
15186
+ * The board reads every child directory of it as one widget, so it ships as
15187
+ * part of the package (`files` in package.json) rather than being generated.
15188
+ */
15189
+ const WIDGET_DIRECTORY_NAME = "widgets";
15190
+ /**
15191
+ * Register this plugin's board contribution, if the host has a board.
15192
+ *
15193
+ * @param ctx - The host context; only `inject` is used.
15194
+ * @param options - The line registry to publish through, and the widget directory override.
15195
+ */
15196
+ function registerWidgetBoard(ctx, options) {
15197
+ ctx.inject(["widgetBoard"], (scope) => {
15198
+ const service = scope.get("widgetBoard");
15199
+ if (service === void 0 || service === null) return;
15200
+ const apiVersion = service.apiVersion;
15201
+ if (typeof apiVersion !== "number" || !Number.isFinite(apiVersion) || apiVersion < 2) {
15202
+ scope.logger.warn(`[dsh-chatgpt-subscription] widget board provider skipped: the board reports apiVersion ${String(apiVersion)}, which this provider cannot serve (it needs a number >= 2).`);
15203
+ return;
15204
+ }
15205
+ if (typeof service.registerWidgetType !== "function") {
15206
+ scope.logger.warn("[dsh-chatgpt-subscription] widget board provider skipped: the board service has no registerWidgetType.");
15207
+ return;
15208
+ }
15209
+ scope.effect(() => {
15210
+ let closed = false;
15211
+ let handle;
15212
+ const lines = options.lines === void 0 ? void 0 : createLineRegistry(options.lines);
15213
+ const publish = async () => {
15214
+ const directory = options.widgetsDirectory ?? widgetsDirectory();
15215
+ if (directory === void 0) {
15216
+ scope.logger.warn("[dsh-chatgpt-subscription] widget board provider skipped: this package's own package.json could not be found from the loaded module, so the widget directory is unknown.");
15217
+ return;
15218
+ }
15219
+ const registered = await service.registerWidgetType({
15220
+ apiVersion,
15221
+ provider: WIDGET_BOARD_PROVIDER,
15222
+ directory
15223
+ });
15224
+ if (closed) {
15225
+ await registered.dispose().catch(() => void 0);
15226
+ return;
15227
+ }
15228
+ handle = registered;
15229
+ registered.register({
15230
+ dataSources: lineDataSources(apiVersion, lines),
15231
+ ...typeof service.registerCapability === "function" ? { capabilities: lineCapabilities(apiVersion, lines) } : {}
15232
+ });
15233
+ };
15234
+ publish().catch((error) => {
15235
+ scope.logger.warn(`[dsh-chatgpt-subscription] widget board registration failed: ${error instanceof Error ? error.message : String(error)}`);
15236
+ });
15237
+ return () => {
15238
+ closed = true;
15239
+ const current = handle;
15240
+ handle = void 0;
15241
+ if (current === void 0) return;
15242
+ current.dispose().catch(() => void 0);
15243
+ };
15244
+ }, "dsh-chatgpt-subscription: widget board provider");
15245
+ });
15246
+ }
15247
+ /**
15248
+ * This package's widget directory, resolved from the loaded module.
15249
+ *
15250
+ * `import.meta.url` points at `lib/index.js` in a built package and at a source
15251
+ * file under `src/` in a test run, so the root is found by walking up to the
15252
+ * `package.json` that names THIS package rather than by assuming a depth.
15253
+ *
15254
+ * @returns The absolute directory, or undefined when no such manifest was found.
15255
+ */
15256
+ function widgetsDirectory() {
15257
+ const root = findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
15258
+ return root === void 0 ? void 0 : path.join(root, WIDGET_DIRECTORY_NAME);
15259
+ }
15260
+ /**
15261
+ * Walk up from a directory to the package root of this plugin.
15262
+ *
15263
+ * @param start - Directory to start from.
15264
+ * @returns The directory holding this package's `package.json`, or undefined.
15265
+ */
15266
+ function findPackageRoot(start) {
15267
+ let directory = start;
15268
+ for (let depth = 0; depth < MAX_ROOT_WALK; depth += 1) {
15269
+ try {
15270
+ if (JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")).name === PACKAGE_NAME) return directory;
15271
+ } catch {}
15272
+ const parent = path.dirname(directory);
15273
+ if (parent === directory) break;
15274
+ directory = parent;
15275
+ }
15276
+ }
15277
+ /**
15278
+ * The three rotation strategies every line pool accepts, in the pool own
15279
+ * vocabulary.
15280
+ *
15281
+ * The registry re-checks the same list inside its own changeStrategy; this copy
15282
+ * is what lets the capability refuse a bad argument before any pool is touched,
15283
+ * naming the mistake the way the other capability arguments do.
15284
+ */
15285
+ const ROTATION_STRATEGIES = [
15286
+ "sequential",
15287
+ "round-robin",
15288
+ "sticky"
15289
+ ];
15290
+ /**
15291
+ * The `accountId` argument, or a refusal.
15292
+ *
15293
+ * @param args - Capability arguments.
15294
+ * @returns The account id.
15295
+ */
15296
+ function readAccountId(args) {
15297
+ const accountId = (typeof args === "object" && args !== null ? args : {}).accountId;
15298
+ if (typeof accountId !== "string" || accountId.trim() === "") throw new Error("切换账号需要 accountId 参数。 (Switching accounts needs a non-empty \"accountId\" argument.)");
15299
+ return accountId;
15300
+ }
15301
+ /**
15302
+ * The `strategy` argument, or a refusal.
15303
+ *
15304
+ * @param args - Capability arguments.
15305
+ * @returns The strategy.
15306
+ */
15307
+ function readStrategy(args) {
15308
+ const strategy = (typeof args === "object" && args !== null ? args : {}).strategy;
15309
+ const match = ROTATION_STRATEGIES.find((candidate) => candidate === strategy);
15310
+ if (match === void 0) throw new Error(`轮换策略只能是 ${ROTATION_STRATEGIES.join(" / ")} 之一,收到的不是其中之一。 (The rotation strategy must be one of ${ROTATION_STRATEGIES.join(", ")}.)`);
15311
+ return match;
15312
+ }
15313
+ /**
15314
+ * The `line-card` data source, when a line registry was handed over.
15315
+ *
15316
+ * This is the whole data side of the board contribution: one source, read per
15317
+ * card, with each card naming its own line. A composition with no registry
15318
+ * registers nothing here rather than falling back to a ChatGPT-only source.
15319
+ *
15320
+ * @param apiVersion - The board's own version, passed through verbatim.
15321
+ * @param lines - The registry, when the composition has one.
15322
+ * @returns The registrations; empty without a registry.
15323
+ */
15324
+ function lineDataSources(apiVersion, lines) {
15325
+ if (lines === void 0) return [];
15326
+ return [{
15327
+ apiVersion,
15328
+ provider: WIDGET_BOARD_PROVIDER,
15329
+ id: LINE_CARD_DATA_SOURCE_ID,
15330
+ read: (instance) => lines.read(lineOfProps(instance.props))
15331
+ }];
15332
+ }
15333
+ /**
15334
+ * The three line capabilities, when a line registry was handed over.
15335
+ *
15336
+ * Like the data source they declare no `permission`: they force one line's own
15337
+ * quota read or write one line's own pool document, both of which the user is
15338
+ * already looking at on the card they clicked.
15339
+ *
15340
+ * @param apiVersion - The board's own version, passed through verbatim.
15341
+ * @param lines - The registry, when the composition has one.
15342
+ * @returns The registrations; empty without a registry.
15343
+ */
15344
+ function lineCapabilities(apiVersion, lines) {
15345
+ if (lines === void 0) return [];
15346
+ return [
15347
+ {
15348
+ apiVersion,
15349
+ provider: WIDGET_BOARD_PROVIDER,
15350
+ id: LINE_REFRESH_CAPABILITY_ID,
15351
+ title: {
15352
+ zh: "刷新该线路额度",
15353
+ en: "Refresh this line quota"
15354
+ },
15355
+ description: {
15356
+ zh: "先让这条线路自己向上游读一次额度,再把读到的结果作为整张卡交回。",
15357
+ en: "Ask the line to read its quota upstream, then answer with the refreshed card."
15358
+ },
15359
+ input: LINE_REFRESH_INPUT_SCHEMA,
15360
+ output: LINE_CARD_SCHEMA,
15361
+ call: (_instance, args, signal) => withLine(lines, args, (line) => lines.refresh(line, signal))
15362
+ },
15363
+ {
15364
+ apiVersion,
15365
+ provider: WIDGET_BOARD_PROVIDER,
15366
+ id: LINE_ACCOUNT_SWITCH_CAPABILITY_ID,
15367
+ title: {
15368
+ zh: "切换线路账号",
15369
+ en: "Switch line account"
15370
+ },
15371
+ description: {
15372
+ zh: "把这条线路号池里的某个账号设为主账号,之后的下一次请求就由它来发。",
15373
+ en: "Pin one account of this line as the primary; the next request is spent on it."
15374
+ },
15375
+ input: LINE_ACCOUNT_SWITCH_INPUT_SCHEMA,
15376
+ output: LINE_CARD_SCHEMA,
15377
+ call: (_instance, args, signal) => withLine(lines, args, (line) => lines.switchAccount(line, readAccountId(args), signal))
15378
+ },
15379
+ {
15380
+ apiVersion,
15381
+ provider: WIDGET_BOARD_PROVIDER,
15382
+ id: LINE_ACCOUNT_STRATEGY_CAPABILITY_ID,
15383
+ title: {
15384
+ zh: "线路轮换策略",
15385
+ en: "Line rotation strategy"
15386
+ },
15387
+ description: {
15388
+ zh: "改这条线路号池的选择策略:顺序、轮询或粘住当前账号。",
15389
+ en: "Change how this line s pool picks its next account: sequential, round-robin or sticky."
15390
+ },
15391
+ input: LINE_ACCOUNT_STRATEGY_INPUT_SCHEMA,
15392
+ output: LINE_CARD_SCHEMA,
15393
+ call: (_instance, args, signal) => withLine(lines, args, (line) => lines.changeStrategy(line, readStrategy(args), signal))
15394
+ }
15395
+ ];
15396
+ }
15397
+ /**
15398
+ * The `line` argument of one line capability.
15399
+ *
15400
+ * @param args - Capability arguments, already schema-checked by the board.
15401
+ * @returns The line id.
15402
+ */
15403
+ function readLine(args) {
15404
+ const line = (typeof args === "object" && args !== null ? args : {}).line;
15405
+ if (typeof line !== "string" || line.trim() === "") throw new Error("这条能力需要 line 参数,指明作用在哪条线路上。 (This capability needs a non-empty \"line\" argument.)");
15406
+ return line.trim();
15407
+ }
15408
+ /**
15409
+ * Run one line action, resolving its `line` argument first.
15410
+ *
15411
+ * The check runs before the action so an unknown line fails on the argument
15412
+ * rather than halfway through a write, and so every capability reports the same
15413
+ * sentence for the same mistake.
15414
+ *
15415
+ * @param lines - The registry.
15416
+ * @param args - Capability arguments.
15417
+ * @param run - The action, given the resolved line id.
15418
+ * @returns The refreshed card.
15419
+ */
15420
+ async function withLine(lines, args, run) {
15421
+ const line = readLine(args);
15422
+ if (!lines.ids().includes(line)) throw new Error("未知线路「" + line + "」:已知的是 " + lines.ids().join(" / ") + "。 (Unknown line; known lines: " + lines.ids().join(", ") + ".)");
15423
+ return await run(line);
15424
+ }
15425
+ //#endregion
12965
15426
  //#region src/host/common/quota-refresh.ts
12966
15427
  /**
12967
15428
  * One quota refresh that runs behind an already-answered status call.
@@ -66530,6 +68991,37 @@ const WALLET_BALANCE_PATH = "/wallet/balance";
66530
68991
  */
66531
68992
  const BILLING_SUMMARY_PATH = "/billing/summary";
66532
68993
  /**
68994
+ * The workspace the signed-in account is CURRENTLY on.
68995
+ *
68996
+ * This is what the console itself reads to pick "the active workspace": its
68997
+ * response object carries `activeWorkspaceId` beside `workspaceType` and `role`.
68998
+ * It is the first place the coding-plan read looks for an id, because it answers
68999
+ * "where is this account right now" rather than "where could it go".
69000
+ */
69001
+ const CURRENT_WORKSPACE_PATH = "/workspaces/current";
69002
+ /**
69003
+ * The workspace list: the FALLBACK source of that id.
69004
+ *
69005
+ * `GET {amr}/workspaces` with the same control key answers every workspace the
69006
+ * account can see. It is only consulted when {@link CURRENT_WORKSPACE_PATH} does
69007
+ * not state an id — a listing describes what the account could switch to, while
69008
+ * the coding-plan read needs the one workspace it is on. The device flow returns
69009
+ * neither, so these two reads are the only way to learn an id at all.
69010
+ */
69011
+ const WORKSPACES_PATH = "/workspaces";
69012
+ /**
69013
+ * The Design Plan's real allowance, per workspace: the 5-hour and 7-day windows
69014
+ * the console draws.
69015
+ *
69016
+ * Appended to `{WORKSPACES_PATH}/{workspaceId}`. It is the only surface of this
69017
+ * line that states a bounded window — `/billing/summary` states credits and the
69018
+ * tier but never a rolling allowance — and it authenticates with the control key
69019
+ * as a Bearer token plus `x-vela-workspace-id`, exactly like the wallet read.
69020
+ * The console itself calls it with a browser session, so a control-key Bearer is
69021
+ * unverified: a refusal is reported as data, never as a reason to guess.
69022
+ */
69023
+ const CODING_PLAN_USAGE_SUFFIX = "/billing/coding-plan-usage";
69024
+ /**
66533
69025
  * What the create call says about this client. The service stores host /
66534
69026
  * profile / cliVersion per authorization (its console lists devices by them);
66535
69027
  * the profile vocabulary is 'prod' | 'test' | 'feature-test' | 'local' per the
@@ -68080,6 +70572,391 @@ async function fetchBillingSummary({ fetchFn, controlKey, apiBase = AMR_API_BASE
68080
70572
  };
68081
70573
  }
68082
70574
  }
70575
+ /**
70576
+ * The refusal one endpoint stated, as one line.
70577
+ *
70578
+ * The machine `code` comes first because that is the only part a caller can act
70579
+ * on — `coding_plan_configuration_unavailable` means "this workspace has no
70580
+ * coding plan", which is a fact about the account rather than a fault. Both
70581
+ * `{ code }` and `{ error: { code } }` are read (the control plane uses both
70582
+ * spellings across its surfaces), then the usual `error`/`message` prose, then
70583
+ * the raw body. Nothing is invented when the body says nothing: that case stays
70584
+ * empty and the caller prints the status alone.
70585
+ */
70586
+ function refusalDetail(json, text) {
70587
+ if (isRecord$2(json)) {
70588
+ const code = pickString(json, ["code"]);
70589
+ if (code !== void 0) return code;
70590
+ if (isRecord$2(json.error)) {
70591
+ const nested = pickString(json.error, ["code", "message"]);
70592
+ if (nested !== void 0) return nested;
70593
+ }
70594
+ const message = firstMessage(json);
70595
+ if (message !== "") return message;
70596
+ }
70597
+ return text.trim().slice(0, 120);
70598
+ }
70599
+ /** `HTTP <status>: <code or first message>`, or the bare status when nothing was said. */
70600
+ function httpRefusal(status, json, text) {
70601
+ const detail = refusalDetail(json, text);
70602
+ return detail === "" ? `HTTP ${status}` : `HTTP ${status}: ${detail}`;
70603
+ }
70604
+ /**
70605
+ * The keys a workspace id travels under, in the order the console's payloads
70606
+ * spell them.
70607
+ *
70608
+ * `activeWorkspaceId` is what the current-workspace read answers; `workspaceId`
70609
+ * and `id` are how a listing entry or a nested `workspace` object names the same
70610
+ * fact.
70611
+ */
70612
+ const WORKSPACE_ID_KEYS = [
70613
+ "activeWorkspaceId",
70614
+ "workspaceId",
70615
+ "id"
70616
+ ];
70617
+ /**
70618
+ * One id out of one object: the direct keys first, then a nested `workspace`.
70619
+ *
70620
+ * @param record - Candidate object.
70621
+ * @returns The id, or null when nothing in it states one.
70622
+ */
70623
+ function workspaceIdIn(record) {
70624
+ const direct = pickString(record, WORKSPACE_ID_KEYS);
70625
+ if (direct !== void 0) return direct;
70626
+ const nested = record.workspace;
70627
+ if (isRecord$2(nested)) return pickString(nested, WORKSPACE_ID_KEYS) ?? null;
70628
+ return null;
70629
+ }
70630
+ /**
70631
+ * The entries a listing endpoint answered, whatever wrapper it used.
70632
+ *
70633
+ * The wrapper is not worth trusting: the same control-plane family answers a bare
70634
+ * array, `{ workspaces }`, `{ items }`, `{ data }` and `{ data: { items | workspaces } }`
70635
+ * on its own different surfaces, and a lookup that guesses one of them reports
70636
+ * "no workspace" on a response that carried several.
70637
+ *
70638
+ * @param body - The decoded response.
70639
+ * @returns The candidate entries; empty when none of the shapes matched.
70640
+ */
70641
+ function listedWorkspaces(body) {
70642
+ if (Array.isArray(body)) return body;
70643
+ if (!isRecord$2(body)) return [];
70644
+ if (Array.isArray(body.workspaces)) return body.workspaces;
70645
+ if (Array.isArray(body.items)) return body.items;
70646
+ if (Array.isArray(body.data)) return body.data;
70647
+ if (isRecord$2(body.data)) {
70648
+ if (Array.isArray(body.data.items)) return body.data.items;
70649
+ if (Array.isArray(body.data.workspaces)) return body.data.workspaces;
70650
+ }
70651
+ return [];
70652
+ }
70653
+ /**
70654
+ * The first id a listing states.
70655
+ *
70656
+ * @param body - The decoded response.
70657
+ * @returns The id, or null when no entry states one.
70658
+ */
70659
+ function listedWorkspaceId(body) {
70660
+ for (const entry of listedWorkspaces(body)) {
70661
+ if (!isRecord$2(entry)) continue;
70662
+ const direct = pickString(entry, ["id", "workspaceId"]);
70663
+ if (direct !== void 0) return direct;
70664
+ const nested = isRecord$2(entry.workspace) ? pickString(entry.workspace, ["id", "workspaceId"]) : void 0;
70665
+ if (nested !== void 0) return nested;
70666
+ }
70667
+ return null;
70668
+ }
70669
+ /**
70670
+ * The SHAPE of a response body — never its content.
70671
+ *
70672
+ * Diagnostic only. A failed lookup has to say what the endpoint actually answered
70673
+ * without leaking a single value: the top-level keys, sorted and capped, are the
70674
+ * one thing that makes the next live attempt debuggable while an id, a workspace
70675
+ * name or a token stays out of the log and out of the card.
70676
+ *
70677
+ * @param json - The decoded response.
70678
+ * @returns A key summary such as `keys: activeWorkspaceId, role, workspaceType`.
70679
+ */
70680
+ function bodyShape(json) {
70681
+ if (Array.isArray(json)) return `a bare array of ${json.length}`;
70682
+ if (!isRecord$2(json)) return "no object body";
70683
+ const keys = Object.keys(json).sort().slice(0, 8);
70684
+ return keys.length === 0 ? "an empty object" : `keys: ${keys.join(", ")}`;
70685
+ }
70686
+ /**
70687
+ * Read the workspace id the coding-plan endpoint is addressed by.
70688
+ *
70689
+ * TWO READS, in this order, because the console itself does not use one:
70690
+ *
70691
+ * 1. `GET {amr}/workspaces/current` — the workspace this account is on right now,
70692
+ * the object the console's own switcher starts from. The id is read from
70693
+ * `activeWorkspaceId`, then `workspaceId`, then `id`, then the same keys under
70694
+ * a nested `workspace`, then the same order again under a `data` wrapper.
70695
+ * 2. Only when that states NO id, `GET {amr}/workspaces` — the listing, which
70696
+ * answers what the account could switch to rather than where it is. Its
70697
+ * first entry with an id wins, and every wrapper shape it has been seen in is
70698
+ * accepted (bare array, `{ workspaces }`, `{ items }`, `{ data }`,
70699
+ * `{ data: { items | workspaces } }`).
70700
+ *
70701
+ * The listing used to be the ONLY read here, and on a live account it answered
70702
+ * 200 with a body whose entries carried no id: the lookup must therefore say
70703
+ * WHICH endpoint failed and what it looked like, or the next attempt is blind.
70704
+ * A failure reports the endpoint, its HTTP status (or the transport error) and a
70705
+ * keys-only shape summary; no value and no id ever appears in it.
70706
+ *
70707
+ * Pure data in, data out: a refusal, a timeout or a malformed body is returned as
70708
+ * `error`, never thrown, and a transport failure on the first read does not stop
70709
+ * the second from answering. The caller composes the result into the account
70710
+ * reading, so one unreadable surface cannot take the whole quota read down.
70711
+ *
70712
+ * @param options.fetchFn - Injected fetch (tests never reach the network).
70713
+ * @param options.controlKey - The OAuth account's `vela_ctrl_…` key.
70714
+ * @param options.apiBase - Control-plane base; overridable for a deployment.
70715
+ * @param options.signal - Caller cancellation.
70716
+ * @param options.timeoutMs - Per-request budget.
70717
+ * @returns The id, or the reason neither endpoint stated one.
70718
+ */
70719
+ async function fetchWorkspaceId({ fetchFn, controlKey, apiBase = AMR_API_BASE, signal, timeoutMs = 15e3 }) {
70720
+ const base = trimBase$1(apiBase);
70721
+ const notes = [];
70722
+ /** One read, with its outcome kept as a NOTE: a throw here must not hide the next one. */
70723
+ const probe = async (path) => {
70724
+ try {
70725
+ return await sendJSON$1(fetchFn, `${base}${path}`, {
70726
+ apiKey: controlKey,
70727
+ signal,
70728
+ timeoutMs
70729
+ });
70730
+ } catch (error) {
70731
+ notes.push(`GET ${path} failed: ${error instanceof Error ? error.message : String(error)}`);
70732
+ return null;
70733
+ }
70734
+ };
70735
+ /** One endpoint's diagnostic line: what was called, what it answered, what shape. */
70736
+ const noteOf = (path, result) => result.ok ? `GET ${path} HTTP ${result.status}, ${bodyShape(result.json)}` : `GET ${path} ${httpRefusal(result.status, result.json, result.text)}, ${bodyShape(result.json)}`;
70737
+ try {
70738
+ const current = await probe(CURRENT_WORKSPACE_PATH);
70739
+ if (current !== null) {
70740
+ if (current.ok) {
70741
+ const body = current.json;
70742
+ const id = isRecord$2(body) ? workspaceIdIn(body) ?? (isRecord$2(body.data) ? workspaceIdIn(body.data) : null) : null;
70743
+ if (id !== null) return {
70744
+ ok: true,
70745
+ id,
70746
+ error: ""
70747
+ };
70748
+ }
70749
+ notes.push(noteOf(CURRENT_WORKSPACE_PATH, current));
70750
+ }
70751
+ const list = await probe(WORKSPACES_PATH);
70752
+ if (list !== null) {
70753
+ if (list.ok) {
70754
+ const id = listedWorkspaceId(list.json);
70755
+ if (id !== null) return {
70756
+ ok: true,
70757
+ id,
70758
+ error: ""
70759
+ };
70760
+ }
70761
+ notes.push(noteOf(WORKSPACES_PATH, list));
70762
+ }
70763
+ return {
70764
+ ok: false,
70765
+ id: null,
70766
+ error: `the workspaces endpoint listed no workspace id (${notes.join("; ")})`
70767
+ };
70768
+ } catch (error) {
70769
+ return {
70770
+ ok: false,
70771
+ id: null,
70772
+ error: error instanceof Error ? error.message : String(error)
70773
+ };
70774
+ }
70775
+ }
70776
+ /** The tiers this line knows how to name; anything else is "unknown", never a guess. */
70777
+ const CODING_PLAN_TIERS = [
70778
+ "go",
70779
+ "plus",
70780
+ "pro",
70781
+ "max"
70782
+ ];
70783
+ /**
70784
+ * One window read strictly: a field that does not state what the card needs
70785
+ * makes the whole window unreadable, and an unreadable window is dropped rather
70786
+ * than patched with a plausible default. A guessed `limitCredits` is a made-up
70787
+ * denominator on a progress bar, which is precisely the failure this contract
70788
+ * exists to prevent.
70789
+ *
70790
+ * @param raw - One entry of the response's `windows`.
70791
+ * @returns The window, or null when any field it needs was not stated.
70792
+ */
70793
+ function codingPlanWindowOf(raw) {
70794
+ if (!isRecord$2(raw)) return null;
70795
+ const policyId = pickString(raw, ["policyId"]);
70796
+ if (policyId === void 0) return null;
70797
+ const durationSeconds = raw.durationSeconds;
70798
+ if (typeof durationSeconds !== "number" || !Number.isSafeInteger(durationSeconds) || durationSeconds <= 0) return null;
70799
+ const remainingCredits = raw.remainingCredits;
70800
+ if (typeof remainingCredits !== "string" || !/^\d+$/.test(remainingCredits)) return null;
70801
+ const limitCredits = raw.limitCredits;
70802
+ if (typeof limitCredits !== "string" || !/^\d+$/.test(limitCredits)) return null;
70803
+ const limit = Number(limitCredits);
70804
+ if (!Number.isFinite(limit) || limit <= 0) return null;
70805
+ if (!Number.isFinite(Number(remainingCredits))) return null;
70806
+ const reset = codingPlanResetOf(raw.resetsAt);
70807
+ if (reset === void 0) return null;
70808
+ return {
70809
+ policyId,
70810
+ durationSeconds,
70811
+ remainingCredits,
70812
+ limitCredits,
70813
+ resetsAt: reset
70814
+ };
70815
+ }
70816
+ /**
70817
+ * A window's reset instant as the DTO states it.
70818
+ *
70819
+ * Absent and `null` both mean "the service stated no reset" and are kept as
70820
+ * null; a string has to parse as an instant, and anything else (a number, a
70821
+ * sentence) is `undefined` — the sentinel that makes the window unreadable.
70822
+ *
70823
+ * @param value - The raw field.
70824
+ * @returns The ISO string, null for "not stated", or undefined for "unreadable".
70825
+ */
70826
+ function codingPlanResetOf(value) {
70827
+ if (value === null || value === void 0) return null;
70828
+ if (typeof value !== "string") return void 0;
70829
+ const trimmed = value.trim();
70830
+ return Number.isFinite(Date.parse(trimmed)) ? trimmed : void 0;
70831
+ }
70832
+ /**
70833
+ * The credential shapes to try, in order, for one coding-plan read.
70834
+ *
70835
+ * The console signs this call with a browser SESSION (`credentials: "include"`),
70836
+ * so which of this line's two keys the endpoint accepts is not public: a control
70837
+ * key returned 401 live, which is exactly the ambiguity this list settles by
70838
+ * trying rather than by guessing. The order is "most likely intended first":
70839
+ * the control key already reads the rest of the control plane, the runtime key is
70840
+ * the gateway's own, and `x-api-key` is the spelling some routes use instead of
70841
+ * a Bearer.
70842
+ *
70843
+ * A runtime key of `''` (a pasted key account, or a credential that never got
70844
+ * one) leaves the first shape alone rather than sending an empty header.
70845
+ *
70846
+ * @param controlKey - The account's `vela_ctrl_…` key.
70847
+ * @param runtimeKey - The account's `sk-amr-…` key, or ''.
70848
+ * @returns The shapes to try, one to three.
70849
+ */
70850
+ function codingPlanAttempts(controlKey, runtimeKey) {
70851
+ const attempts = [{
70852
+ label: "bearer control key",
70853
+ apiKey: controlKey,
70854
+ headers: {}
70855
+ }];
70856
+ const runtime = runtimeKey.trim();
70857
+ if (runtime === "") return attempts;
70858
+ attempts.push({
70859
+ label: "bearer runtime key",
70860
+ apiKey: runtime,
70861
+ headers: {}
70862
+ });
70863
+ attempts.push({
70864
+ label: "x-api-key runtime key",
70865
+ apiKey: "",
70866
+ headers: { "x-api-key": runtime }
70867
+ });
70868
+ return attempts;
70869
+ }
70870
+ /**
70871
+ * A successful response body as the DTO.
70872
+ *
70873
+ * @param json - The decoded body.
70874
+ * @returns The plan, with `error` empty and only the windows that survived.
70875
+ */
70876
+ function codingPlanOf(json) {
70877
+ const body = isRecord$2(json) ? json : {};
70878
+ const windows = [];
70879
+ for (const row of Array.isArray(body.windows) ? body.windows : []) {
70880
+ const window = codingPlanWindowOf(row);
70881
+ if (window !== null) windows.push(window);
70882
+ }
70883
+ const tierText = typeof body.tier === "string" ? body.tier.trim().toLowerCase() : "";
70884
+ return {
70885
+ tier: CODING_PLAN_TIERS.includes(tierText) ? tierText : null,
70886
+ windows,
70887
+ error: ""
70888
+ };
70889
+ }
70890
+ /**
70891
+ * Read one account's Design Plan allowance.
70892
+ *
70893
+ * This is the line's REAL quota surface — the 5-hour and 7-day windows the
70894
+ * console draws as "剩余 90% / 81%" — and it is the only endpoint that states a
70895
+ * bounded window at all: `/billing/summary` states credits and a tier, never a
70896
+ * rolling allowance.
70897
+ *
70898
+ * `GET {amr}/workspaces/{id}/billing/coding-plan-usage`, always the same URL with
70899
+ * the same `x-vela-workspace-id`, tried once per credential SHAPE (see
70900
+ * {@link codingPlanAttempts}). The retry rule is the important half: only
70901
+ * `401` / `403` — "these credentials are not accepted" — moves on to the next
70902
+ * shape, because that is the only verdict a different key could change. A `404` or
70903
+ * a `500`, and in particular `coding_plan_configuration_unavailable`, is the
70904
+ * endpoint's own verdict about the WORKSPACE, so it stops the search and travels
70905
+ * out verbatim.
70906
+ *
70907
+ * Exhausting every shape yields one sentence a user can act on: the last refusal
70908
+ * plus the shapes that were tried. A success carries NO trace of which credential
70909
+ * worked — the shape of a credential is not the card's business.
70910
+ *
70911
+ * It never throws and never fills a window nobody measured.
70912
+ *
70913
+ * @param options.fetchFn - Injected fetch.
70914
+ * @param options.controlKey - The account's `vela_ctrl_…` key.
70915
+ * @param options.runtimeKey - The account's `sk-amr-…` key; `''` skips the two
70916
+ * shapes that need one.
70917
+ * @param options.workspaceId - From {@link fetchWorkspaceId}.
70918
+ * @param options.apiBase - Control-plane base.
70919
+ * @param options.signal - Caller cancellation.
70920
+ * @param options.timeoutMs - Per-request budget.
70921
+ * @returns The plan, or the reason every shape it knew failed.
70922
+ */
70923
+ async function fetchCodingPlanUsage({ fetchFn, controlKey, runtimeKey, workspaceId, apiBase = AMR_API_BASE, signal, timeoutMs = 15e3 }) {
70924
+ const url = `${trimBase$1(apiBase)}${WORKSPACES_PATH}/${encodeURIComponent(workspaceId)}${CODING_PLAN_USAGE_SUFFIX}`;
70925
+ const tried = [];
70926
+ let refusal = "";
70927
+ try {
70928
+ for (const attempt of codingPlanAttempts(controlKey, runtimeKey)) {
70929
+ const result = await sendJSON$1(fetchFn, url, {
70930
+ apiKey: attempt.apiKey,
70931
+ signal,
70932
+ timeoutMs,
70933
+ headers: {
70934
+ "x-vela-workspace-id": workspaceId,
70935
+ ...attempt.headers
70936
+ }
70937
+ });
70938
+ if (result.ok) return codingPlanOf(result.json);
70939
+ refusal = httpRefusal(result.status, result.json, result.text);
70940
+ if (result.status !== 401 && result.status !== 403) return {
70941
+ tier: null,
70942
+ windows: [],
70943
+ error: refusal
70944
+ };
70945
+ tried.push(attempt.label);
70946
+ }
70947
+ return {
70948
+ tier: null,
70949
+ windows: [],
70950
+ error: (refusal === "" ? "the coding plan could not be read" : refusal) + ` (tried: ${tried.join("; ")})`
70951
+ };
70952
+ } catch (error) {
70953
+ return {
70954
+ tier: null,
70955
+ windows: [],
70956
+ error: error instanceof Error ? error.message : String(error)
70957
+ };
70958
+ }
70959
+ }
68083
70960
  //#endregion
68084
70961
  //#region src/host/opendesign/mapper.ts
68085
70962
  /**
@@ -69800,10 +72677,45 @@ function registerOpenDesignRoutes(ctx, options) {
69800
72677
  ok: false,
69801
72678
  error: "No OAuth-signed-in account can be read. Plan, credits and wallet balances come from the control key a browser sign-in issues; a pasted API key has none."
69802
72679
  });
72680
+ /**
72681
+ * The workspace id, resolved ONCE for this request and reused.
72682
+ *
72683
+ * Every account here asks the same control-plane question — "which
72684
+ * workspace is this key signed into" — and the coding-plan read needs
72685
+ * the answer only as an address. The first account to need it settles
72686
+ * the lookup; the rest await that same promise, so a request with four
72687
+ * readable accounts makes one `/workspaces/current` call rather than four.
72688
+ *
72689
+ * The settled answer is reused even when it FAILED: every account then
72690
+ * carries the same `error` in its own `codingPlan`, which is what a
72691
+ * user needs to see ("the workspace cannot be resolved") instead of
72692
+ * four identical timeouts. A failure never reaches the reading itself —
72693
+ * the wallet and billing halves are unaffected.
72694
+ */
72695
+ let workspaceLookup = null;
72696
+ const readCodingPlan = async (controlKey, runtimeKey) => {
72697
+ if (workspaceLookup === null) workspaceLookup = fetchWorkspaceId({
72698
+ fetchFn,
72699
+ controlKey
72700
+ });
72701
+ const workspace = await workspaceLookup;
72702
+ if (!workspace.ok || workspace.id === null) return {
72703
+ tier: null,
72704
+ windows: [],
72705
+ error: workspace.error === "" ? "no workspace id for the coding-plan read" : workspace.error
72706
+ };
72707
+ return await fetchCodingPlanUsage({
72708
+ fetchFn,
72709
+ controlKey,
72710
+ runtimeKey,
72711
+ workspaceId: workspace.id
72712
+ });
72713
+ };
69803
72714
  const readings = await Promise.all(readable.map(async (account) => {
69804
72715
  const fetchedAt = Date.now();
69805
72716
  try {
69806
- const controlKey = controlKeyFor(await accountPool.getFreshCredential(account.id, fetchFn));
72717
+ const credentials = await accountPool.getFreshCredential(account.id, fetchFn);
72718
+ const controlKey = controlKeyFor(credentials);
69807
72719
  if (controlKey === void 0) return {
69808
72720
  accountId: account.id,
69809
72721
  alias: account.alias,
@@ -69811,15 +72723,24 @@ function registerOpenDesignRoutes(ctx, options) {
69811
72723
  balanceUsd: null,
69812
72724
  updatedAt: null,
69813
72725
  error: "no control key on this account",
69814
- fetchedAt
72726
+ fetchedAt,
72727
+ codingPlan: {
72728
+ tier: null,
72729
+ windows: [],
72730
+ error: "no control key on this account"
72731
+ }
69815
72732
  };
69816
- const [wallet, billing] = await Promise.all([fetchWalletBalance({
69817
- fetchFn,
69818
- controlKey
69819
- }), fetchBillingSummary({
69820
- fetchFn,
69821
- controlKey
69822
- })]);
72733
+ const [wallet, billing, codingPlan] = await Promise.all([
72734
+ fetchWalletBalance({
72735
+ fetchFn,
72736
+ controlKey
72737
+ }),
72738
+ fetchBillingSummary({
72739
+ fetchFn,
72740
+ controlKey
72741
+ }),
72742
+ readCodingPlan(controlKey, bearerFor(credentials))
72743
+ ]);
69823
72744
  const balanceUsd = wallet.balanceUsd ?? billing.billing?.totalAvailableCreditsUsd ?? null;
69824
72745
  return {
69825
72746
  accountId: account.id,
@@ -69829,7 +72750,8 @@ function registerOpenDesignRoutes(ctx, options) {
69829
72750
  updatedAt: wallet.updatedAt,
69830
72751
  error: wallet.error,
69831
72752
  fetchedAt,
69832
- ...billing.billing === null ? {} : { billing: billing.billing }
72753
+ ...billing.billing === null ? {} : { billing: billing.billing },
72754
+ codingPlan
69833
72755
  };
69834
72756
  } catch (error) {
69835
72757
  return {
@@ -69839,7 +72761,12 @@ function registerOpenDesignRoutes(ctx, options) {
69839
72761
  balanceUsd: null,
69840
72762
  updatedAt: null,
69841
72763
  error: firstLine(error instanceof Error ? error.message : error, 240),
69842
- fetchedAt
72764
+ fetchedAt,
72765
+ codingPlan: {
72766
+ tier: null,
72767
+ windows: [],
72768
+ error: "the account could not be read, so its coding plan was not read either"
72769
+ }
69843
72770
  };
69844
72771
  }
69845
72772
  }));
@@ -73741,7 +76668,8 @@ const DISABLED_OUTPUT_TEXT = "Progress updates are disabled; the update was not
73741
76668
  /** 工具描述的最终全文。 */
73742
76669
  const PROGRESS_UPDATE_DESCRIPTION = [
73743
76670
  "Report progress to the user without ending your turn.",
73744
- "Use it during multi-step work so the user can follow along and correct course early:",
76671
+ "Two updates are part of the job, not optional narration: send one BEFORE your first tool call of a multi-step task (the plan), and one BEFORE your final answer (what was done and what was verified).",
76672
+ "Use it during the rest of the work so the user can follow along and correct course early:",
73745
76673
  "state your plan near the beginning of the task (what you will do, and in what order);",
73746
76674
  "report a meaningful conclusion the moment you have it (a key fact, the root cause, a verified assumption) rather than narrating every step;",
73747
76675
  "give a stage judgement when one phase ends and the next begins (where things stand, what happens next);",
@@ -73894,6 +76822,13 @@ function stringOf$1(value) {
73894
76822
  * 文本用函数形式提供(而不是静态字符串):开关关闭时返回空串,装配期就地
73895
76823
  * 抹掉引导,不需要重启会话、也不需要重挂插件行。
73896
76824
  *
76825
+ * ## 为什么是英文
76826
+ *
76827
+ * 提示词是写给模型看的,不是写给用户看的:同一段要求在英文下最短、歧义最少,
76828
+ * 也和工具描述(`tool.ts` 的 `PROGRESS_UPDATE_DESCRIPTION`,本来就是英文)保持
76829
+ * 同一套措辞。用户看到的语言由 {@link progressLanguageDirective} 单独钉住——
76830
+ * 「指令用英文」与「输出用中文」是两件事,不要混在一起。
76831
+ *
73897
76832
  * @module dsh-chatgpt-subscription/host/progress-updates/prompt
73898
76833
  */
73899
76834
  /**
@@ -73903,38 +76838,70 @@ function stringOf$1(value) {
73903
76838
  * 而变成第二个分节。
73904
76839
  */
73905
76840
  const PROGRESS_SECTION_NAME = "plugin:progress-updates";
76841
+ /** 允许的取值,供设置解析做白名单校验(配置里写错就等于没写)。 */
76842
+ const PROGRESS_UPDATE_LANGUAGES = [
76843
+ "auto",
76844
+ "zh",
76845
+ "en"
76846
+ ];
73906
76847
  /**
73907
76848
  * 引导词正文。写法对齐 Kimi 的「进展更新」:先说清多步任务早期的思路,再报
73908
76849
  * 有意义的结论、阶段判断、长等待与阻塞;每条给一行标题,细节放正文;不重复
73909
- * 没有变化的状态。
76850
+ * 没有变化的状态。开头与结尾两条被提成硬要求——没有它们,面板在任务的两端
76851
+ * 都是空的。
73910
76852
  */
73911
76853
  const PROGRESS_GUIDANCE = [
73912
- "## 进展更新",
76854
+ "## Progress updates",
76855
+ "",
76856
+ "During multi-step work you can call `progress_update` to report progress without ending your turn and without spending a question: report, then keep working.",
76857
+ "",
76858
+ "**Two updates are mandatory — missing either one means the job is not finished:**",
73913
76859
  "",
73914
- "在多步任务里,你可以调用 `progress_update` 向用户主动汇报进展。它不会结束你的回合,也不消耗一次问答:报告完继续干活即可。",
76860
+ "- **Opening**: before the first tool call of the task, send one update stating the plan (which steps, what you touch first). The user must be able to redirect you before you disappear into execution.",
76861
+ "- **Closing**: after the work is done and before your final answer, send one update stating the result (what changed, what was verified, what is left). The panel and the conversation both close on this line.",
73915
76862
  "",
73916
- "什么时候用:",
73917
- "- **任务早期**:先说清你打算怎么做(拆成哪几步、先动哪里),让用户在你埋头执行之前就能纠正方向。",
73918
- "- **有意义的结论**:查到关键事实、定位到根因、验证了一个假设——报结论本身,不要报「我刚读了一个文件」。",
73919
- "- **阶段判断**:一步做完、进入下一步之前,用一句话说明现在的状态和接下来要做什么。",
73920
- "- **长等待之前**:跑测试、装依赖、等构建这类要花上一阵子的操作,先说清你在等什么、大概要等多久。",
73921
- "- **被阻塞时**:缺信息、缺权限、外部依赖失败——说清卡在哪、你打算怎么绕过去,或者需要用户做什么。",
76863
+ "Beyond those two, these moments are worth a report:",
76864
+ "- **Early planning**: the opening update covers it; report again only when the plan really changes (different approach, smaller scope, a broken premise).",
76865
+ "- **A meaningful conclusion**: a key fact, a root cause, a verified assumption — report the conclusion itself, not \"I just read a file\".",
76866
+ "- **A stage judgement**: one phase ends and the next begins — one line on where things stand and what happens next.",
76867
+ "- **Before a long wait**: a test run, an install, a build — say what you are waiting for and roughly how long.",
76868
+ "- **When blocked**: missing information, missing permission, an external failure — say where you are stuck, what you will try next, or what you need from the user.",
73922
76869
  "",
73923
- "怎么写:",
73924
- "- 每条给一行短标题(title,纯文本,不带 Markdown,不加结尾句号),细节放进正文(message)。",
73925
- "- 正文用轻 Markdown:一段话说清发生了什么,必要时跟少量 bullet 列出要点。**有几行数据要对照时,用 GFM 管道符表格**(表头行 + `| --- |` 分隔行),不要写成排成一列的纯文本。",
73926
- "- 不要重复没有变化的状态:上一条报过的还在进行中,就不用再报一遍。",
73927
- "- 进展更新是给用户看的,不是给日志看的:写人话,别复述工具调用参数。",
76870
+ "How to write one:",
76871
+ "- One short title line (plain text, no Markdown, no trailing period); the details go in the message body.",
76872
+ "- The body uses light Markdown: one paragraph of prose plus a few bullets. **When several rows share fields, use a GFM pipe table** (header row plus a `| --- |` separator row) instead of one item per line.",
76873
+ "- Do not repeat a status that has not changed: if the last update already said it is running, it needs no second report.",
76874
+ "- A progress update is for the user, not for the log: write it in human words, do not restate tool arguments.",
73928
76875
  "",
73929
- "作为 subagent 运行时:只描述你自己的子任务进展。你的子任务完成不等于整体任务完成,不要把整体进度写成已完成。"
76876
+ "When you run as a subagent: describe only your own subtask progress. Finishing your subtask is not the same as finishing the overall task — do not report the overall progress as done."
73930
76877
  ].join("\n");
73931
76878
  /**
76879
+ * 每个语言档位对应的一句指令。
76880
+ *
76881
+ * 只约束输出的标题与正文,不约束引导词本身;三条都必须自洽——`auto` 不许在
76882
+ * 用户说中文时滑回英文,`zh`/`en` 则明确压过对话语言。
76883
+ */
76884
+ const PROGRESS_LANGUAGE_DIRECTIVES = {
76885
+ auto: "Write the title and the message of every progress update in the language the user is writing in, whatever it is. Follow the conversation; do not fall back to English unless the user does.",
76886
+ zh: "Write the title and the message of every progress update in Simplified Chinese, even when the conversation is in another language.",
76887
+ en: "Write the title and the message of every progress update in English, even when the conversation is in another language."
76888
+ };
76889
+ /**
76890
+ * 取一个语言档位的指令文本;未知取值按 `auto` 处理。
76891
+ * @param language - 配置里读到的语言档位。
76892
+ * @returns 追加到引导词末尾的一句指令。
76893
+ */
76894
+ function progressLanguageDirective(language) {
76895
+ return PROGRESS_LANGUAGE_DIRECTIVES[language] ?? PROGRESS_LANGUAGE_DIRECTIVES.auto;
76896
+ }
76897
+ /**
73932
76898
  * 生成提示词分节的文本提供者。
73933
76899
  * @param isEnabled - 读取当前开关状态的函数(每次装配重新求值)。
73934
- * @returns 开关打开时返回引导词,关闭时返回空串。
76900
+ * @param language - 读取当前输出语言的函数;缺省按 `auto` 处理。
76901
+ * @returns 开关打开时返回引导词加语言指令,关闭时返回空串。
73935
76902
  */
73936
- function progressSectionText(isEnabled) {
73937
- return () => isEnabled() ? PROGRESS_GUIDANCE : "";
76903
+ function progressSectionText(isEnabled, language = () => "auto") {
76904
+ return () => isEnabled() ? PROGRESS_GUIDANCE + "\n\n" + progressLanguageDirective(language()) : "";
73938
76905
  }
73939
76906
  //#endregion
73940
76907
  //#region src/host/progress-updates/projection.ts
@@ -74261,9 +77228,11 @@ function readProgressConfigField(field) {
74261
77228
  function resolveProgressUpdatesSettings(config) {
74262
77229
  const enabled = readProgressConfigField(config?.progressUpdates);
74263
77230
  const threshold = readProgressConfigField(config?.progressUpdatesNudgeThreshold);
77231
+ const language = readProgressConfigField(config?.progressUpdatesLanguage);
74264
77232
  return {
74265
77233
  enabled: enabled !== false,
74266
- nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8
77234
+ nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8,
77235
+ language: language !== void 0 && PROGRESS_UPDATE_LANGUAGES.includes(language) ? language : "auto"
74267
77236
  };
74268
77237
  }
74269
77238
  /** 血缘回溯的深度上限:与 `nudge.ts` 的 `counterSessionIdOf` 同一个数。 */
@@ -74521,7 +77490,7 @@ function installProgressUpdates(ctx, options) {
74521
77490
  return systemPrompt.section({
74522
77491
  name: PROGRESS_SECTION_NAME,
74523
77492
  order: 220,
74524
- text: progressSectionText(() => options.settings().enabled)
77493
+ text: progressSectionText(() => options.settings().enabled, () => options.settings().language)
74525
77494
  });
74526
77495
  }, "dsh-chatgpt-subscription: progress update prompt section");
74527
77496
  track(() => {
@@ -74577,7 +77546,12 @@ const Config = z.object({
74577
77546
  noAnswerResume: z.union([z.object({}), z.const(false)]).default({}),
74578
77547
  codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS$2),
74579
77548
  progressUpdates: z.boolean().default(true).volatile().description("启用进展更新:允许 agent 用 progress_update 工具主动汇报多步任务的进展,并在侧栏展示。"),
74580
- progressUpdatesNudgeThreshold: z.number().step(1).min(1).max(50).default(8).volatile().description("催促阈值:连续这么多轮带工具调用却没有进展更新时,提醒 agent 发一条进展。")
77549
+ progressUpdatesNudgeThreshold: z.number().step(1).min(1).max(50).default(8).volatile().description("催促阈值:连续这么多轮带工具调用却没有进展更新时,提醒 agent 发一条进展。"),
77550
+ progressUpdatesLanguage: z.union([
77551
+ z.const("auto"),
77552
+ z.const("zh"),
77553
+ z.const("en")
77554
+ ]).default("auto").volatile().description("进展更新的输出语言:auto 跟随对话语言,zh 中文,en 英文。")
74581
77555
  });
74582
77556
  const inject = [
74583
77557
  "webServer",
@@ -74873,6 +77847,35 @@ function apply(ctx, pluginConfig = {}) {
74873
77847
  const snapshot = usage.snapshotFor(account.id, account.credentials);
74874
77848
  return snapshot === void 0 ? void 0 : codexAccountQuota(snapshot);
74875
77849
  });
77850
+ registerWidgetBoard(ctx, { lines: {
77851
+ origin: () => webServerOrigin(ctx.webServer),
77852
+ pools: {
77853
+ codex: codexAccountPool,
77854
+ claude: claudeAccountPool,
77855
+ "glm-coding-plan": glmCodingPlanAccountPool,
77856
+ "step-code": stepCodeAccountPool,
77857
+ qoder: qoderAccountPool,
77858
+ "cline-pass": clinePassLine.accountPool,
77859
+ opendesign: openDesignLine.accountPool,
77860
+ "kimi-code": kimiCodeAccountPool,
77861
+ "minimax-code": minimaxCodeAccountPool,
77862
+ antigravity: antigravityAccountPool,
77863
+ "command-code": commandCodeAccountPool
77864
+ },
77865
+ accountQuotaClients: {
77866
+ qoder: async (credentials, { fetchFn, accountId }) => {
77867
+ const read = await loadQoderQuota(credentials, {
77868
+ fetchFn,
77869
+ cacheKey: accountId
77870
+ });
77871
+ return read.ok ? read.snapshot : null;
77872
+ },
77873
+ "minimax-code": async (credentials, { fetchFn, accountId }) => await fetchTokenPlanQuota(credentials, {
77874
+ fetchFn,
77875
+ accountId
77876
+ })
77877
+ }
77878
+ } });
74876
77879
  const adapter = new CodexChatGptAdapter(new ResponsesClient(oauth, ctx.attachments, {
74877
77880
  fetchFn: modelFetch["codex-chatgpt"],
74878
77881
  accountPool: codexAccountPool,