@eddyskywalker/dsh-chatgpt-subscription 0.36.2 → 0.38.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 (71) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +32 -9
  3. package/lib/client.js +603 -134
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +3256 -55
  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/kimi-code/KimiCodeSection.d.ts.map +1 -1
  9. package/lib/types/client/kimi-code/KimiModelCapabilities.d.ts.map +1 -1
  10. package/lib/types/client/kimi-code/labels.d.ts +66 -0
  11. package/lib/types/client/kimi-code/labels.d.ts.map +1 -0
  12. package/lib/types/client/kimi-code/locales.d.ts +52 -11
  13. package/lib/types/client/kimi-code/locales.d.ts.map +1 -1
  14. package/lib/types/client/locales-ru/kimi-code.d.ts.map +1 -1
  15. package/lib/types/client/locales-ru/opendesign.d.ts.map +1 -1
  16. package/lib/types/client/locales-ru/progress-updates.d.ts.map +1 -1
  17. package/lib/types/client/locales.d.ts +4 -4
  18. package/lib/types/client/opendesign/OpenDesignComposerQuota.d.ts +6 -0
  19. package/lib/types/client/opendesign/OpenDesignComposerQuota.d.ts.map +1 -1
  20. package/lib/types/client/opendesign/OpenDesignSection.d.ts.map +1 -1
  21. package/lib/types/client/opendesign/locales.d.ts +54 -0
  22. package/lib/types/client/opendesign/locales.d.ts.map +1 -1
  23. package/lib/types/client/opendesign/styles.d.ts +5 -0
  24. package/lib/types/client/opendesign/styles.d.ts.map +1 -1
  25. package/lib/types/client/progress-updates/locales.d.ts +17 -2
  26. package/lib/types/client/progress-updates/locales.d.ts.map +1 -1
  27. package/lib/types/client/progress-updates/settings-binding.d.ts +13 -3
  28. package/lib/types/client/progress-updates/settings-binding.d.ts.map +1 -1
  29. package/lib/types/client/progress-updates/settings-section.d.ts +5 -5
  30. package/lib/types/client/progress-updates/settings-section.d.ts.map +1 -1
  31. package/lib/types/client/progress-updates/styles.d.ts.map +1 -1
  32. package/lib/types/host/kimi-code/adapter.d.ts.map +1 -1
  33. package/lib/types/host/kimi-code/client.d.ts.map +1 -1
  34. package/lib/types/host/kimi-code/mapper.d.ts +89 -1
  35. package/lib/types/host/kimi-code/mapper.d.ts.map +1 -1
  36. package/lib/types/host/kimi-code/model-catalog.d.ts +20 -4
  37. package/lib/types/host/kimi-code/model-catalog.d.ts.map +1 -1
  38. package/lib/types/host/kimi-code/routes.d.ts.map +1 -1
  39. package/lib/types/host/kimi-code/types.d.ts +50 -6
  40. package/lib/types/host/kimi-code/types.d.ts.map +1 -1
  41. package/lib/types/host/opendesign/client.d.ts +103 -0
  42. package/lib/types/host/opendesign/client.d.ts.map +1 -1
  43. package/lib/types/host/opendesign/routes.d.ts.map +1 -1
  44. package/lib/types/host/opendesign/types.d.ts +31 -0
  45. package/lib/types/host/opendesign/types.d.ts.map +1 -1
  46. package/lib/types/host/progress-updates/index.d.ts +5 -0
  47. package/lib/types/host/progress-updates/index.d.ts.map +1 -1
  48. package/lib/types/host/progress-updates/prompt.d.ts +37 -3
  49. package/lib/types/host/progress-updates/prompt.d.ts.map +1 -1
  50. package/lib/types/host/progress-updates/tool.d.ts.map +1 -1
  51. package/lib/types/host/proxy-manager.d.ts +63 -1
  52. package/lib/types/host/proxy-manager.d.ts.map +1 -1
  53. package/lib/types/host/widget-board/line-contracts.d.ts +192 -0
  54. package/lib/types/host/widget-board/line-contracts.d.ts.map +1 -0
  55. package/lib/types/host/widget-board/line-quota.d.ts +284 -0
  56. package/lib/types/host/widget-board/line-quota.d.ts.map +1 -0
  57. package/lib/types/host/widget-board/line-registry.d.ts +402 -0
  58. package/lib/types/host/widget-board/line-registry.d.ts.map +1 -0
  59. package/lib/types/host/widget-board/provider.d.ts +140 -0
  60. package/lib/types/host/widget-board/provider.d.ts.map +1 -0
  61. package/lib/types/index.d.ts +10 -0
  62. package/lib/types/index.d.ts.map +1 -1
  63. package/lib/types/shared/kimi-code-contracts.d.ts +30 -6
  64. package/lib/types/shared/kimi-code-contracts.d.ts.map +1 -1
  65. package/lib/types/shared/opendesign-contracts.d.ts +47 -0
  66. package/lib/types/shared/opendesign-contracts.d.ts.map +1 -1
  67. package/package.json +2 -1
  68. package/widgets/line-flip/index.html +58 -0
  69. package/widgets/line-flip/main.js +1102 -0
  70. package/widgets/line-flip/style.css +729 -0
  71. 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.
@@ -21162,7 +23623,7 @@ var OllamaAccountPool = class extends AccountPoolCore {
21162
23623
  //#endregion
21163
23624
  //#region src/host/kimi-code/model-catalog.ts
21164
23625
  /**
21165
- * The four ids the subscription serves today.
23626
+ * The ids the subscription serves today.
21166
23627
  *
21167
23628
  * `kimi-for-coding` is the alias that never changes: Moonshot upgrades the model
21168
23629
  * behind it in place (K2.7 Code became K2.8 Preview without a config change), so
@@ -21260,6 +23721,58 @@ const KIMI_CODE_MODELS = [
21260
23721
  supportsDynamicTools: false,
21261
23722
  quotaMultiplier: 3,
21262
23723
  speed: "highspeed"
23724
+ },
23725
+ {
23726
+ id: "k3.1-pro",
23727
+ name: "K3.1 Pro",
23728
+ version: "K3.1 Pro",
23729
+ contextWindow: 262144,
23730
+ maxContextWindow: 1048576,
23731
+ maxTokens: 32768,
23732
+ inputModalities: [
23733
+ "text",
23734
+ "image",
23735
+ "video"
23736
+ ],
23737
+ reasoningEfforts: [
23738
+ "low",
23739
+ "high",
23740
+ "max"
23741
+ ],
23742
+ defaultReasoningEffort: "max",
23743
+ thinkingType: "only",
23744
+ minimumPlan: "Moderato",
23745
+ contextPlan: "Allegretto",
23746
+ description: "The K3.1 Pro flagship: 3T parameters, a 256K context window on Plus/Moderato and 1M on Pro/Allegretto and above. Thinking runs at low/high/max, defaults to max, and cannot be disabled.",
23747
+ supportsDynamicTools: true,
23748
+ quotaMultiplier: null,
23749
+ speed: "regular"
23750
+ },
23751
+ {
23752
+ id: "k3.1-pro-highspeed",
23753
+ name: "K3.1 Pro Highspeed",
23754
+ version: "K3.1 Pro Highspeed",
23755
+ contextWindow: 1048576,
23756
+ maxContextWindow: null,
23757
+ maxTokens: 32768,
23758
+ inputModalities: [
23759
+ "text",
23760
+ "image",
23761
+ "video"
23762
+ ],
23763
+ reasoningEfforts: [
23764
+ "low",
23765
+ "high",
23766
+ "max"
23767
+ ],
23768
+ defaultReasoningEffort: "max",
23769
+ thinkingType: "only",
23770
+ minimumPlan: "Allegretto",
23771
+ contextPlan: null,
23772
+ description: "The high-speed K3.1 Pro with a 1M context window and faster output, at about twice the quota of the regular version. Requires the Pro/Allegretto plan or above.",
23773
+ supportsDynamicTools: true,
23774
+ quotaMultiplier: null,
23775
+ speed: "highspeed"
21263
23776
  }
21264
23777
  ];
21265
23778
  const BY_ID$1 = new Map(KIMI_CODE_MODELS.map((model) => [model.id, model]));
@@ -21896,17 +24409,79 @@ function maxRequestImageBytes$1(env = process.env) {
21896
24409
  /**
21897
24410
  * Which wire dialect a model id is served over.
21898
24411
  *
21899
- * The managed service speaks both protocols for every model, so the choice is a
21900
- * client preference rather than a per-model constraint. The OpenAI dialect is
21901
- * the default because that is what the official CLI configures for the managed
21902
- * provider.
24412
+ * Always the OpenAI-compatible chat completions dialect: this line uses that one
24413
+ * protocol for every model, K3.1 Pro included, which is what the official agent
24414
+ * configures for the managed provider (`type = "kimi"`). The Anthropic Messages
24415
+ * dialect remains reachable only when a live listing declares it for an id
24416
+ * (see `wireForCatalogEntry`), so the answer here is a constant rather than a
24417
+ * per-model table.
21903
24418
  */
21904
24419
  function wireForModel(_modelId) {
21905
24420
  return "openai";
21906
24421
  }
21907
- /** Thinking levels one model advertises, from the static registry. */
24422
+ /**
24423
+ * Thinking levels one model advertises, from the static registry.
24424
+ *
24425
+ * A registry row that declares `thinkingType: 'only'` cannot be asked to stop
24426
+ * reasoning — the service answers HTTP 400 for a disabled-thinking request — so
24427
+ * `none` is filtered out of the list it publishes.
24428
+ */
21908
24429
  function reasoningEffortsFor(modelId) {
21909
- return [...kimiCodeModelDef(modelId)?.reasoningEfforts ?? []];
24430
+ const declared = kimiCodeModelDef(modelId)?.reasoningEfforts ?? [];
24431
+ if (kimiCodeModelDef(modelId)?.thinkingType === "only") return declared.filter((effort) => effort !== "none");
24432
+ return [...declared];
24433
+ }
24434
+ /**
24435
+ * Thinking level one model uses when the conversation does not pick one.
24436
+ *
24437
+ * A declared default that the resolved effort list no longer contains is
24438
+ * dropped rather than carried through: with `only` in force the list can lose
24439
+ * `none`, and a default naming a level the model can no longer be asked for
24440
+ * would fail on the first turn that relied on it.
24441
+ */
24442
+ function defaultReasoningEffortFor(modelId) {
24443
+ const declared = kimiCodeModelDef(modelId)?.defaultReasoningEffort ?? void 0;
24444
+ if (declared === null || declared === void 0) return void 0;
24445
+ return reasoningEffortsFor(modelId).includes(declared) ? declared : void 0;
24446
+ }
24447
+ /**
24448
+ * How a model handles thinking, from the static registry.
24449
+ *
24450
+ * Exposed so the request builder and the settings card read the same row rather
24451
+ * than each re-deriving the rule; `undefined` means the registry says nothing
24452
+ * about this id, which is not the same as "thinking can be switched off".
24453
+ */
24454
+ function thinkingTypeFor(modelId) {
24455
+ return kimiCodeModelDef(modelId)?.thinkingType;
24456
+ }
24457
+ /**
24458
+ * Raise a disabled-thinking request to a level the model can actually run.
24459
+ *
24460
+ * Asking a thinking-only model for `none` is refused by the service with
24461
+ * HTTP 400 ("K3.1 Pro does not support disabled thinking mode"), and the user's
24462
+ * real intent there is "don't spend my quota on thinking", not "fail the turn".
24463
+ * So the effort is lifted to the model's own default — `max` for the K3.1 pair —
24464
+ * and the lift is REPORTED through `coercedFrom` so a caller can tell the user
24465
+ * what happened instead of silently spending more than they asked for.
24466
+ *
24467
+ * A model that does not declare `only`, and any effort other than `none`, is
24468
+ * returned untouched. When a thinking-only row declares no default the effort
24469
+ * becomes `undefined`, which sends no level at all and leaves the service on its
24470
+ * own default — still never a disabled-thinking request.
24471
+ */
24472
+ function coerceThinkingEffort(modelId, effort) {
24473
+ if (effort !== "none") return {
24474
+ effort,
24475
+ coercedFrom: null
24476
+ };
24477
+ if (thinkingTypeFor(modelId) !== "only") return {
24478
+ effort,
24479
+ coercedFrom: null
24480
+ };
24481
+ return {
24482
+ effort: defaultReasoningEffortFor(modelId),
24483
+ coercedFrom: "none"
24484
+ };
21910
24485
  }
21911
24486
  /**
21912
24487
  * Accepted request modalities for one model.
@@ -23296,7 +25871,7 @@ function wireForCatalogEntry(modelId, catalog) {
23296
25871
  function reasoningEffortsForEntry(modelId, catalog) {
23297
25872
  const entry = catalog.find((model) => model.id === modelId);
23298
25873
  const declared = entry?.reasoningEfforts ?? reasoningEffortsFor(modelId);
23299
- switch (entry?.thinkingType) {
25874
+ switch (entry?.thinkingType ?? thinkingTypeFor(modelId)) {
23300
25875
  case "no": return [];
23301
25876
  case "only": return declared.filter((effort) => effort !== "none");
23302
25877
  default: return [...declared];
@@ -23369,6 +25944,7 @@ function buildModelOptions(catalog, enabledModelIds, contextWindowOverrides) {
23369
25944
  const contextWindow = typeof override === "number" && Number.isFinite(override) && override > 0 ? override : model.contextWindow ?? 262144;
23370
25945
  const efforts = reasoningEffortsForEntry(model.id, catalog);
23371
25946
  const defaultEffort = defaultReasoningEffortForEntry(model.id, catalog);
25947
+ const thinkingType = model.thinkingType ?? thinkingTypeFor(model.id);
23372
25948
  const declaredInputCap = maxInputTokensForEntry(model.id, catalog);
23373
25949
  const promptBudget = declaredInputCap === void 0 ? contextWindow : Math.min(contextWindow, declaredInputCap);
23374
25950
  return {
@@ -23382,6 +25958,7 @@ function buildModelOptions(catalog, enabledModelIds, contextWindowOverrides) {
23382
25958
  defaultMaxTokens: maxOutputTokensFor$4(model.id, contextWindow),
23383
25959
  ...efforts.length === 0 ? {} : { reasoningEfforts: [...efforts] },
23384
25960
  ...defaultEffort === void 0 ? {} : { defaultReasoningEffort: defaultEffort },
25961
+ ...thinkingType === void 0 ? {} : { thinkingType },
23385
25962
  wire: model.protocol ?? wireForModel(model.id),
23386
25963
  description: model.description ?? kimiCodeModelDef(model.id)?.description ?? null,
23387
25964
  supportsVideo: model.supportsVideo ?? kimiCodeModelDef(model.id)?.inputModalities.includes("video") ?? false,
@@ -25296,6 +27873,8 @@ function createStreamState$4(wire, sessionId) {
25296
27873
  outputTokens: 0,
25297
27874
  cacheReadTokens: 0,
25298
27875
  cacheWriteTokens: 0,
27876
+ cacheWrite5mTokens: 0,
27877
+ cacheWrite1hTokens: 0,
25299
27878
  reasoningTokens: 0,
25300
27879
  sawUsage: false,
25301
27880
  toolCallIds: new ToolCallIdNormalizer()
@@ -25386,8 +27965,10 @@ function processOpenAIStreamLine(line, state) {
25386
27965
  const prompt = numberOr$7(usage.prompt_tokens, 0);
25387
27966
  const details = isRecord$34(usage.prompt_tokens_details) ? usage.prompt_tokens_details : void 0;
25388
27967
  const cached = details ? numberOr$7(details.cached_tokens, 0) : 0;
25389
- state.inputTokens = Math.max(0, prompt - cached);
27968
+ const written = details ? numberOr$7(details.cache_write_tokens, 0) : 0;
27969
+ state.inputTokens = Math.max(0, prompt - cached - written);
25390
27970
  state.cacheReadTokens = cached;
27971
+ state.cacheWriteTokens = written;
25391
27972
  state.outputTokens = numberOr$7(usage.completion_tokens, state.outputTokens);
25392
27973
  if (isRecord$34(usage.completion_tokens_details)) state.reasoningTokens = numberOr$7(usage.completion_tokens_details.reasoning_tokens, state.reasoningTokens);
25393
27974
  }
@@ -25767,6 +28348,63 @@ function recordDriftCause(cause, sessionId) {
25767
28348
  function getLastDriftCause(sessionId) {
25768
28349
  return driftCauses.get(cacheScopeKey(sessionId));
25769
28350
  }
28351
+ /** Last lift per session. */
28352
+ const thinkingCoercions = /* @__PURE__ */ new Map();
28353
+ /**
28354
+ * Record one lift, and make it visible.
28355
+ *
28356
+ * `process.emitWarning` rather than a thrown error or a silently swallowed
28357
+ * value: the request must still go out (that is the whole point of the lift),
28358
+ * but a diagnostic that reaches stderr with a stable code is what keeps it from
28359
+ * being invisible. It is emitted once per turn, and only for a model that
28360
+ * actually declares thinking-only, so a deployment that never asks for 'none'
28361
+ * never sees one.
28362
+ */
28363
+ function recordThinkingCoercion(coercion, sessionId) {
28364
+ rememberScoped(thinkingCoercions, cacheScopeKey(sessionId), coercion);
28365
+ const target = coercion.to === void 0 ? "the service default" : "\"" + coercion.to + "\"";
28366
+ process.emitWarning("[kimi-code] model \"" + coercion.model + "\" cannot disable thinking; sending " + target + " instead of \"none\" on the " + coercion.wire + " wire.", { code: "KIMI_CODE_THINKING_COERCED" });
28367
+ }
28368
+ /**
28369
+ * Response headers that split a cache write across its two TTL tiers.
28370
+ *
28371
+ * The service states the write TOTAL in 'usage' and the per-tier split only in
28372
+ * these headers, so the split has to be read off the HTTP response — there is no
28373
+ * body field for it. Both are decimal integers; a missing or non-numeric value
28374
+ * means the header said nothing, which is recorded as 0 rather than guessed at
28375
+ * (see KimiCodeCacheStatsDto.cacheWrite5mTokens: an absent header is silence,
28376
+ * not a claim that nothing was written to that tier).
28377
+ */
28378
+ const CACHE_WRITE_5M_HEADER = "Msh-Usage-Cache-Write-Tokens-5m";
28379
+ const CACHE_WRITE_1H_HEADER = "Msh-Usage-Cache-Write-Tokens-1h";
28380
+ /** Decimal whole-token count from one response header, or 0 when it is silent. */
28381
+ function headerCount(headers, name) {
28382
+ const raw = headers.get(name);
28383
+ if (raw === null) return 0;
28384
+ const text = raw.trim();
28385
+ if (!/^\d+$/.test(text)) return 0;
28386
+ const value = Number(text);
28387
+ return Number.isSafeInteger(value) ? value : 0;
28388
+ }
28389
+ /** The two tier counts one response's headers state. */
28390
+ function cacheWriteTiers(headers) {
28391
+ return {
28392
+ write5m: headerCount(headers, CACHE_WRITE_5M_HEADER),
28393
+ write1h: headerCount(headers, CACHE_WRITE_1H_HEADER)
28394
+ };
28395
+ }
28396
+ /**
28397
+ * File one response's tier counts on the stream state that will close it.
28398
+ *
28399
+ * Called by the adapter on the Response itself, which is the only place the
28400
+ * headers exist; the totals are then filed with the rest of the usage when the
28401
+ * stream closes, so one turn's numbers stay one turn's numbers.
28402
+ */
28403
+ function applyCacheWriteTiers(state, headers) {
28404
+ const tiers = cacheWriteTiers(headers);
28405
+ state.cacheWrite5mTokens = tiers.write5m;
28406
+ state.cacheWrite1hTokens = tiers.write1h;
28407
+ }
25770
28408
  /**
25771
28409
  * Rolling cache totals per session.
25772
28410
  *
@@ -25780,7 +28418,9 @@ const EMPTY_CACHE_STATS = {
25780
28418
  cachedTokens: 0,
25781
28419
  freshTokens: 0,
25782
28420
  outputTokens: 0,
25783
- cacheWriteTokens: 0
28421
+ cacheWriteTokens: 0,
28422
+ cacheWrite5mTokens: 0,
28423
+ cacheWrite1hTokens: 0
25784
28424
  };
25785
28425
  /** Record one request's usage into its own session's totals. */
25786
28426
  function recordCacheStats(state, sessionId, accountId) {
@@ -25792,7 +28432,9 @@ function recordCacheStats(state, sessionId, accountId) {
25792
28432
  cachedTokens: previous.cachedTokens + state.cacheReadTokens,
25793
28433
  freshTokens: previous.freshTokens + state.inputTokens,
25794
28434
  outputTokens: previous.outputTokens + state.outputTokens,
25795
- cacheWriteTokens: previous.cacheWriteTokens + state.cacheWriteTokens
28435
+ cacheWriteTokens: previous.cacheWriteTokens + state.cacheWriteTokens,
28436
+ cacheWrite5mTokens: previous.cacheWrite5mTokens + state.cacheWrite5mTokens,
28437
+ cacheWrite1hTokens: previous.cacheWrite1hTokens + state.cacheWrite1hTokens
25796
28438
  });
25797
28439
  }
25798
28440
  /** Add one entry into an accumulator. */
@@ -25802,6 +28444,8 @@ function accumulate(totals, stats) {
25802
28444
  totals.freshTokens += stats.freshTokens;
25803
28445
  totals.outputTokens += stats.outputTokens;
25804
28446
  totals.cacheWriteTokens += stats.cacheWriteTokens;
28447
+ totals.cacheWrite5mTokens += stats.cacheWrite5mTokens;
28448
+ totals.cacheWrite1hTokens += stats.cacheWrite1hTokens;
25805
28449
  }
25806
28450
  /**
25807
28451
  * Totals for one account's share of a session, one whole session, or every
@@ -26360,7 +29004,19 @@ var KimiCodeAdapter = class extends LlmAdapter {
26360
29004
  const entry = catalog.find((model) => model.id === options.model);
26361
29005
  const normalized = normalizeGenerateOptions(options);
26362
29006
  const images = await resolveRequestImages$1(normalized, this.options.attachments, signal);
26363
- const requestOptions = offloadOldestRequestVideos(offloadOldestRequestImages$1(normalized, maxRequestImageBytes$1(), images), MAX_REQUEST_VIDEO_BYTES$1);
29007
+ const imageTrimmed = offloadOldestRequestImages$1(normalized, maxRequestImageBytes$1(), images);
29008
+ const gatedEffort = coerceThinkingEffort(options.model, mapReasoningEffort(options.reasoningEffort === void 0 || options.reasoningEffort === null ? void 0 : String(options.reasoningEffort)));
29009
+ if (gatedEffort.coercedFrom !== null) recordThinkingCoercion({
29010
+ model: options.model,
29011
+ wire,
29012
+ from: gatedEffort.coercedFrom,
29013
+ to: gatedEffort.effort
29014
+ }, options.sessionId);
29015
+ const videoTrimmed = offloadOldestRequestVideos(imageTrimmed, MAX_REQUEST_VIDEO_BYTES$1);
29016
+ const requestOptions = gatedEffort.effort === void 0 ? videoTrimmed : {
29017
+ ...videoTrimmed,
29018
+ reasoningEffort: ReasoningEffortId(gatedEffort.effort)
29019
+ };
26364
29020
  const videos = await resolveRequestVideos(requestOptions, this.options.videos, signal);
26365
29021
  const settings = await this.settings();
26366
29022
  const media = {
@@ -26473,6 +29129,7 @@ var KimiCodeAdapter = class extends LlmAdapter {
26473
29129
  const reader = response.body.getReader();
26474
29130
  const decoder = new TextDecoder();
26475
29131
  const state = createStreamState$4(wire, requestOptions.sessionId);
29132
+ applyCacheWriteTiers(state, response.headers);
26476
29133
  markSessionActive(requestOptions.sessionId);
26477
29134
  let buffer = "";
26478
29135
  /**
@@ -26938,7 +29595,9 @@ function cacheStatsOrNull(sessionId) {
26938
29595
  return {
26939
29596
  ...stats,
26940
29597
  ...lastDrift === void 0 ? {} : { lastDrift },
26941
- cacheWriteTokensNote: "always-zero-on-this-route"
29598
+ cacheWriteTokens: stats.cacheWriteTokens,
29599
+ cacheWrite5mTokens: stats.cacheWrite5mTokens,
29600
+ cacheWrite1hTokens: stats.cacheWrite1hTokens
26942
29601
  };
26943
29602
  }
26944
29603
  /**
@@ -27531,7 +30190,7 @@ async function assertVideoCapableRoute(ctx, exec, source) {
27531
30190
  const llm = ctx.get("llm");
27532
30191
  if (llm === void 0) throw new HarnessError(`cannot attach "${source}": the LLM service is unavailable`, "KIMI_VIDEO_NO_LLM");
27533
30192
  const active = await llm.resolveModelInfo(provider, model, exec.signal);
27534
- if (active.inputModalities === void 0 || !active.inputModalities.includes("video")) throw new HarnessError(`cannot attach "${source}": model "${model}" does not accept video input; switch to k3 or kimi-for-coding`, "KIMI_VIDEO_MODEL_UNSUPPORTED");
30193
+ if (active.inputModalities === void 0 || !active.inputModalities.includes("video")) throw new HarnessError(`cannot attach "${source}": model "${model}" does not accept video input; switch to k3, k3.1-pro, kimi-for-coding or kimi-for-coding-highspeed`, "KIMI_VIDEO_MODEL_UNSUPPORTED");
27535
30194
  }
27536
30195
  function humanBytes$1(bytes) {
27537
30196
  return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -27594,7 +30253,7 @@ function createKimiVideoTool(ctx, options = {}) {
27594
30253
  const fetchFn = options.fetchFn ?? fetch;
27595
30254
  return defineTool({
27596
30255
  name: KIMI_VIDEO_TOOL_NAME,
27597
- description: "Attach a video to the conversation for models that accept video input (Kimi k3 and kimi-for-coding). Use this when the user asks about a video file or a video URL: pass an absolute local path or an http(s) URL. The video is sent to the model on the next request; it does not work on image-only models such as k3-256k.",
30256
+ description: "Attach a video to the conversation for models that accept video input (Kimi k3, k3.1-pro, kimi-for-coding and kimi-for-coding-highspeed). Use this when the user asks about a video file or a video URL: pass an absolute local path or an http(s) URL. The video is sent to the model on the next request; it does not work on image-only models such as k3-256k.",
27598
30257
  parameters: {
27599
30258
  source: {
27600
30259
  type: "string",
@@ -66530,6 +69189,37 @@ const WALLET_BALANCE_PATH = "/wallet/balance";
66530
69189
  */
66531
69190
  const BILLING_SUMMARY_PATH = "/billing/summary";
66532
69191
  /**
69192
+ * The workspace the signed-in account is CURRENTLY on.
69193
+ *
69194
+ * This is what the console itself reads to pick "the active workspace": its
69195
+ * response object carries `activeWorkspaceId` beside `workspaceType` and `role`.
69196
+ * It is the first place the coding-plan read looks for an id, because it answers
69197
+ * "where is this account right now" rather than "where could it go".
69198
+ */
69199
+ const CURRENT_WORKSPACE_PATH = "/workspaces/current";
69200
+ /**
69201
+ * The workspace list: the FALLBACK source of that id.
69202
+ *
69203
+ * `GET {amr}/workspaces` with the same control key answers every workspace the
69204
+ * account can see. It is only consulted when {@link CURRENT_WORKSPACE_PATH} does
69205
+ * not state an id — a listing describes what the account could switch to, while
69206
+ * the coding-plan read needs the one workspace it is on. The device flow returns
69207
+ * neither, so these two reads are the only way to learn an id at all.
69208
+ */
69209
+ const WORKSPACES_PATH = "/workspaces";
69210
+ /**
69211
+ * The Design Plan's real allowance, per workspace: the 5-hour and 7-day windows
69212
+ * the console draws.
69213
+ *
69214
+ * Appended to `{WORKSPACES_PATH}/{workspaceId}`. It is the only surface of this
69215
+ * line that states a bounded window — `/billing/summary` states credits and the
69216
+ * tier but never a rolling allowance — and it authenticates with the control key
69217
+ * as a Bearer token plus `x-vela-workspace-id`, exactly like the wallet read.
69218
+ * The console itself calls it with a browser session, so a control-key Bearer is
69219
+ * unverified: a refusal is reported as data, never as a reason to guess.
69220
+ */
69221
+ const CODING_PLAN_USAGE_SUFFIX = "/billing/coding-plan-usage";
69222
+ /**
66533
69223
  * What the create call says about this client. The service stores host /
66534
69224
  * profile / cliVersion per authorization (its console lists devices by them);
66535
69225
  * the profile vocabulary is 'prod' | 'test' | 'feature-test' | 'local' per the
@@ -68080,6 +70770,391 @@ async function fetchBillingSummary({ fetchFn, controlKey, apiBase = AMR_API_BASE
68080
70770
  };
68081
70771
  }
68082
70772
  }
70773
+ /**
70774
+ * The refusal one endpoint stated, as one line.
70775
+ *
70776
+ * The machine `code` comes first because that is the only part a caller can act
70777
+ * on — `coding_plan_configuration_unavailable` means "this workspace has no
70778
+ * coding plan", which is a fact about the account rather than a fault. Both
70779
+ * `{ code }` and `{ error: { code } }` are read (the control plane uses both
70780
+ * spellings across its surfaces), then the usual `error`/`message` prose, then
70781
+ * the raw body. Nothing is invented when the body says nothing: that case stays
70782
+ * empty and the caller prints the status alone.
70783
+ */
70784
+ function refusalDetail(json, text) {
70785
+ if (isRecord$2(json)) {
70786
+ const code = pickString(json, ["code"]);
70787
+ if (code !== void 0) return code;
70788
+ if (isRecord$2(json.error)) {
70789
+ const nested = pickString(json.error, ["code", "message"]);
70790
+ if (nested !== void 0) return nested;
70791
+ }
70792
+ const message = firstMessage(json);
70793
+ if (message !== "") return message;
70794
+ }
70795
+ return text.trim().slice(0, 120);
70796
+ }
70797
+ /** `HTTP <status>: <code or first message>`, or the bare status when nothing was said. */
70798
+ function httpRefusal(status, json, text) {
70799
+ const detail = refusalDetail(json, text);
70800
+ return detail === "" ? `HTTP ${status}` : `HTTP ${status}: ${detail}`;
70801
+ }
70802
+ /**
70803
+ * The keys a workspace id travels under, in the order the console's payloads
70804
+ * spell them.
70805
+ *
70806
+ * `activeWorkspaceId` is what the current-workspace read answers; `workspaceId`
70807
+ * and `id` are how a listing entry or a nested `workspace` object names the same
70808
+ * fact.
70809
+ */
70810
+ const WORKSPACE_ID_KEYS = [
70811
+ "activeWorkspaceId",
70812
+ "workspaceId",
70813
+ "id"
70814
+ ];
70815
+ /**
70816
+ * One id out of one object: the direct keys first, then a nested `workspace`.
70817
+ *
70818
+ * @param record - Candidate object.
70819
+ * @returns The id, or null when nothing in it states one.
70820
+ */
70821
+ function workspaceIdIn(record) {
70822
+ const direct = pickString(record, WORKSPACE_ID_KEYS);
70823
+ if (direct !== void 0) return direct;
70824
+ const nested = record.workspace;
70825
+ if (isRecord$2(nested)) return pickString(nested, WORKSPACE_ID_KEYS) ?? null;
70826
+ return null;
70827
+ }
70828
+ /**
70829
+ * The entries a listing endpoint answered, whatever wrapper it used.
70830
+ *
70831
+ * The wrapper is not worth trusting: the same control-plane family answers a bare
70832
+ * array, `{ workspaces }`, `{ items }`, `{ data }` and `{ data: { items | workspaces } }`
70833
+ * on its own different surfaces, and a lookup that guesses one of them reports
70834
+ * "no workspace" on a response that carried several.
70835
+ *
70836
+ * @param body - The decoded response.
70837
+ * @returns The candidate entries; empty when none of the shapes matched.
70838
+ */
70839
+ function listedWorkspaces(body) {
70840
+ if (Array.isArray(body)) return body;
70841
+ if (!isRecord$2(body)) return [];
70842
+ if (Array.isArray(body.workspaces)) return body.workspaces;
70843
+ if (Array.isArray(body.items)) return body.items;
70844
+ if (Array.isArray(body.data)) return body.data;
70845
+ if (isRecord$2(body.data)) {
70846
+ if (Array.isArray(body.data.items)) return body.data.items;
70847
+ if (Array.isArray(body.data.workspaces)) return body.data.workspaces;
70848
+ }
70849
+ return [];
70850
+ }
70851
+ /**
70852
+ * The first id a listing states.
70853
+ *
70854
+ * @param body - The decoded response.
70855
+ * @returns The id, or null when no entry states one.
70856
+ */
70857
+ function listedWorkspaceId(body) {
70858
+ for (const entry of listedWorkspaces(body)) {
70859
+ if (!isRecord$2(entry)) continue;
70860
+ const direct = pickString(entry, ["id", "workspaceId"]);
70861
+ if (direct !== void 0) return direct;
70862
+ const nested = isRecord$2(entry.workspace) ? pickString(entry.workspace, ["id", "workspaceId"]) : void 0;
70863
+ if (nested !== void 0) return nested;
70864
+ }
70865
+ return null;
70866
+ }
70867
+ /**
70868
+ * The SHAPE of a response body — never its content.
70869
+ *
70870
+ * Diagnostic only. A failed lookup has to say what the endpoint actually answered
70871
+ * without leaking a single value: the top-level keys, sorted and capped, are the
70872
+ * one thing that makes the next live attempt debuggable while an id, a workspace
70873
+ * name or a token stays out of the log and out of the card.
70874
+ *
70875
+ * @param json - The decoded response.
70876
+ * @returns A key summary such as `keys: activeWorkspaceId, role, workspaceType`.
70877
+ */
70878
+ function bodyShape(json) {
70879
+ if (Array.isArray(json)) return `a bare array of ${json.length}`;
70880
+ if (!isRecord$2(json)) return "no object body";
70881
+ const keys = Object.keys(json).sort().slice(0, 8);
70882
+ return keys.length === 0 ? "an empty object" : `keys: ${keys.join(", ")}`;
70883
+ }
70884
+ /**
70885
+ * Read the workspace id the coding-plan endpoint is addressed by.
70886
+ *
70887
+ * TWO READS, in this order, because the console itself does not use one:
70888
+ *
70889
+ * 1. `GET {amr}/workspaces/current` — the workspace this account is on right now,
70890
+ * the object the console's own switcher starts from. The id is read from
70891
+ * `activeWorkspaceId`, then `workspaceId`, then `id`, then the same keys under
70892
+ * a nested `workspace`, then the same order again under a `data` wrapper.
70893
+ * 2. Only when that states NO id, `GET {amr}/workspaces` — the listing, which
70894
+ * answers what the account could switch to rather than where it is. Its
70895
+ * first entry with an id wins, and every wrapper shape it has been seen in is
70896
+ * accepted (bare array, `{ workspaces }`, `{ items }`, `{ data }`,
70897
+ * `{ data: { items | workspaces } }`).
70898
+ *
70899
+ * The listing used to be the ONLY read here, and on a live account it answered
70900
+ * 200 with a body whose entries carried no id: the lookup must therefore say
70901
+ * WHICH endpoint failed and what it looked like, or the next attempt is blind.
70902
+ * A failure reports the endpoint, its HTTP status (or the transport error) and a
70903
+ * keys-only shape summary; no value and no id ever appears in it.
70904
+ *
70905
+ * Pure data in, data out: a refusal, a timeout or a malformed body is returned as
70906
+ * `error`, never thrown, and a transport failure on the first read does not stop
70907
+ * the second from answering. The caller composes the result into the account
70908
+ * reading, so one unreadable surface cannot take the whole quota read down.
70909
+ *
70910
+ * @param options.fetchFn - Injected fetch (tests never reach the network).
70911
+ * @param options.controlKey - The OAuth account's `vela_ctrl_…` key.
70912
+ * @param options.apiBase - Control-plane base; overridable for a deployment.
70913
+ * @param options.signal - Caller cancellation.
70914
+ * @param options.timeoutMs - Per-request budget.
70915
+ * @returns The id, or the reason neither endpoint stated one.
70916
+ */
70917
+ async function fetchWorkspaceId({ fetchFn, controlKey, apiBase = AMR_API_BASE, signal, timeoutMs = 15e3 }) {
70918
+ const base = trimBase$1(apiBase);
70919
+ const notes = [];
70920
+ /** One read, with its outcome kept as a NOTE: a throw here must not hide the next one. */
70921
+ const probe = async (path) => {
70922
+ try {
70923
+ return await sendJSON$1(fetchFn, `${base}${path}`, {
70924
+ apiKey: controlKey,
70925
+ signal,
70926
+ timeoutMs
70927
+ });
70928
+ } catch (error) {
70929
+ notes.push(`GET ${path} failed: ${error instanceof Error ? error.message : String(error)}`);
70930
+ return null;
70931
+ }
70932
+ };
70933
+ /** One endpoint's diagnostic line: what was called, what it answered, what shape. */
70934
+ 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)}`;
70935
+ try {
70936
+ const current = await probe(CURRENT_WORKSPACE_PATH);
70937
+ if (current !== null) {
70938
+ if (current.ok) {
70939
+ const body = current.json;
70940
+ const id = isRecord$2(body) ? workspaceIdIn(body) ?? (isRecord$2(body.data) ? workspaceIdIn(body.data) : null) : null;
70941
+ if (id !== null) return {
70942
+ ok: true,
70943
+ id,
70944
+ error: ""
70945
+ };
70946
+ }
70947
+ notes.push(noteOf(CURRENT_WORKSPACE_PATH, current));
70948
+ }
70949
+ const list = await probe(WORKSPACES_PATH);
70950
+ if (list !== null) {
70951
+ if (list.ok) {
70952
+ const id = listedWorkspaceId(list.json);
70953
+ if (id !== null) return {
70954
+ ok: true,
70955
+ id,
70956
+ error: ""
70957
+ };
70958
+ }
70959
+ notes.push(noteOf(WORKSPACES_PATH, list));
70960
+ }
70961
+ return {
70962
+ ok: false,
70963
+ id: null,
70964
+ error: `the workspaces endpoint listed no workspace id (${notes.join("; ")})`
70965
+ };
70966
+ } catch (error) {
70967
+ return {
70968
+ ok: false,
70969
+ id: null,
70970
+ error: error instanceof Error ? error.message : String(error)
70971
+ };
70972
+ }
70973
+ }
70974
+ /** The tiers this line knows how to name; anything else is "unknown", never a guess. */
70975
+ const CODING_PLAN_TIERS = [
70976
+ "go",
70977
+ "plus",
70978
+ "pro",
70979
+ "max"
70980
+ ];
70981
+ /**
70982
+ * One window read strictly: a field that does not state what the card needs
70983
+ * makes the whole window unreadable, and an unreadable window is dropped rather
70984
+ * than patched with a plausible default. A guessed `limitCredits` is a made-up
70985
+ * denominator on a progress bar, which is precisely the failure this contract
70986
+ * exists to prevent.
70987
+ *
70988
+ * @param raw - One entry of the response's `windows`.
70989
+ * @returns The window, or null when any field it needs was not stated.
70990
+ */
70991
+ function codingPlanWindowOf(raw) {
70992
+ if (!isRecord$2(raw)) return null;
70993
+ const policyId = pickString(raw, ["policyId"]);
70994
+ if (policyId === void 0) return null;
70995
+ const durationSeconds = raw.durationSeconds;
70996
+ if (typeof durationSeconds !== "number" || !Number.isSafeInteger(durationSeconds) || durationSeconds <= 0) return null;
70997
+ const remainingCredits = raw.remainingCredits;
70998
+ if (typeof remainingCredits !== "string" || !/^\d+$/.test(remainingCredits)) return null;
70999
+ const limitCredits = raw.limitCredits;
71000
+ if (typeof limitCredits !== "string" || !/^\d+$/.test(limitCredits)) return null;
71001
+ const limit = Number(limitCredits);
71002
+ if (!Number.isFinite(limit) || limit <= 0) return null;
71003
+ if (!Number.isFinite(Number(remainingCredits))) return null;
71004
+ const reset = codingPlanResetOf(raw.resetsAt);
71005
+ if (reset === void 0) return null;
71006
+ return {
71007
+ policyId,
71008
+ durationSeconds,
71009
+ remainingCredits,
71010
+ limitCredits,
71011
+ resetsAt: reset
71012
+ };
71013
+ }
71014
+ /**
71015
+ * A window's reset instant as the DTO states it.
71016
+ *
71017
+ * Absent and `null` both mean "the service stated no reset" and are kept as
71018
+ * null; a string has to parse as an instant, and anything else (a number, a
71019
+ * sentence) is `undefined` — the sentinel that makes the window unreadable.
71020
+ *
71021
+ * @param value - The raw field.
71022
+ * @returns The ISO string, null for "not stated", or undefined for "unreadable".
71023
+ */
71024
+ function codingPlanResetOf(value) {
71025
+ if (value === null || value === void 0) return null;
71026
+ if (typeof value !== "string") return void 0;
71027
+ const trimmed = value.trim();
71028
+ return Number.isFinite(Date.parse(trimmed)) ? trimmed : void 0;
71029
+ }
71030
+ /**
71031
+ * The credential shapes to try, in order, for one coding-plan read.
71032
+ *
71033
+ * The console signs this call with a browser SESSION (`credentials: "include"`),
71034
+ * so which of this line's two keys the endpoint accepts is not public: a control
71035
+ * key returned 401 live, which is exactly the ambiguity this list settles by
71036
+ * trying rather than by guessing. The order is "most likely intended first":
71037
+ * the control key already reads the rest of the control plane, the runtime key is
71038
+ * the gateway's own, and `x-api-key` is the spelling some routes use instead of
71039
+ * a Bearer.
71040
+ *
71041
+ * A runtime key of `''` (a pasted key account, or a credential that never got
71042
+ * one) leaves the first shape alone rather than sending an empty header.
71043
+ *
71044
+ * @param controlKey - The account's `vela_ctrl_…` key.
71045
+ * @param runtimeKey - The account's `sk-amr-…` key, or ''.
71046
+ * @returns The shapes to try, one to three.
71047
+ */
71048
+ function codingPlanAttempts(controlKey, runtimeKey) {
71049
+ const attempts = [{
71050
+ label: "bearer control key",
71051
+ apiKey: controlKey,
71052
+ headers: {}
71053
+ }];
71054
+ const runtime = runtimeKey.trim();
71055
+ if (runtime === "") return attempts;
71056
+ attempts.push({
71057
+ label: "bearer runtime key",
71058
+ apiKey: runtime,
71059
+ headers: {}
71060
+ });
71061
+ attempts.push({
71062
+ label: "x-api-key runtime key",
71063
+ apiKey: "",
71064
+ headers: { "x-api-key": runtime }
71065
+ });
71066
+ return attempts;
71067
+ }
71068
+ /**
71069
+ * A successful response body as the DTO.
71070
+ *
71071
+ * @param json - The decoded body.
71072
+ * @returns The plan, with `error` empty and only the windows that survived.
71073
+ */
71074
+ function codingPlanOf(json) {
71075
+ const body = isRecord$2(json) ? json : {};
71076
+ const windows = [];
71077
+ for (const row of Array.isArray(body.windows) ? body.windows : []) {
71078
+ const window = codingPlanWindowOf(row);
71079
+ if (window !== null) windows.push(window);
71080
+ }
71081
+ const tierText = typeof body.tier === "string" ? body.tier.trim().toLowerCase() : "";
71082
+ return {
71083
+ tier: CODING_PLAN_TIERS.includes(tierText) ? tierText : null,
71084
+ windows,
71085
+ error: ""
71086
+ };
71087
+ }
71088
+ /**
71089
+ * Read one account's Design Plan allowance.
71090
+ *
71091
+ * This is the line's REAL quota surface — the 5-hour and 7-day windows the
71092
+ * console draws as "剩余 90% / 81%" — and it is the only endpoint that states a
71093
+ * bounded window at all: `/billing/summary` states credits and a tier, never a
71094
+ * rolling allowance.
71095
+ *
71096
+ * `GET {amr}/workspaces/{id}/billing/coding-plan-usage`, always the same URL with
71097
+ * the same `x-vela-workspace-id`, tried once per credential SHAPE (see
71098
+ * {@link codingPlanAttempts}). The retry rule is the important half: only
71099
+ * `401` / `403` — "these credentials are not accepted" — moves on to the next
71100
+ * shape, because that is the only verdict a different key could change. A `404` or
71101
+ * a `500`, and in particular `coding_plan_configuration_unavailable`, is the
71102
+ * endpoint's own verdict about the WORKSPACE, so it stops the search and travels
71103
+ * out verbatim.
71104
+ *
71105
+ * Exhausting every shape yields one sentence a user can act on: the last refusal
71106
+ * plus the shapes that were tried. A success carries NO trace of which credential
71107
+ * worked — the shape of a credential is not the card's business.
71108
+ *
71109
+ * It never throws and never fills a window nobody measured.
71110
+ *
71111
+ * @param options.fetchFn - Injected fetch.
71112
+ * @param options.controlKey - The account's `vela_ctrl_…` key.
71113
+ * @param options.runtimeKey - The account's `sk-amr-…` key; `''` skips the two
71114
+ * shapes that need one.
71115
+ * @param options.workspaceId - From {@link fetchWorkspaceId}.
71116
+ * @param options.apiBase - Control-plane base.
71117
+ * @param options.signal - Caller cancellation.
71118
+ * @param options.timeoutMs - Per-request budget.
71119
+ * @returns The plan, or the reason every shape it knew failed.
71120
+ */
71121
+ async function fetchCodingPlanUsage({ fetchFn, controlKey, runtimeKey, workspaceId, apiBase = AMR_API_BASE, signal, timeoutMs = 15e3 }) {
71122
+ const url = `${trimBase$1(apiBase)}${WORKSPACES_PATH}/${encodeURIComponent(workspaceId)}${CODING_PLAN_USAGE_SUFFIX}`;
71123
+ const tried = [];
71124
+ let refusal = "";
71125
+ try {
71126
+ for (const attempt of codingPlanAttempts(controlKey, runtimeKey)) {
71127
+ const result = await sendJSON$1(fetchFn, url, {
71128
+ apiKey: attempt.apiKey,
71129
+ signal,
71130
+ timeoutMs,
71131
+ headers: {
71132
+ "x-vela-workspace-id": workspaceId,
71133
+ ...attempt.headers
71134
+ }
71135
+ });
71136
+ if (result.ok) return codingPlanOf(result.json);
71137
+ refusal = httpRefusal(result.status, result.json, result.text);
71138
+ if (result.status !== 401 && result.status !== 403) return {
71139
+ tier: null,
71140
+ windows: [],
71141
+ error: refusal
71142
+ };
71143
+ tried.push(attempt.label);
71144
+ }
71145
+ return {
71146
+ tier: null,
71147
+ windows: [],
71148
+ error: (refusal === "" ? "the coding plan could not be read" : refusal) + ` (tried: ${tried.join("; ")})`
71149
+ };
71150
+ } catch (error) {
71151
+ return {
71152
+ tier: null,
71153
+ windows: [],
71154
+ error: error instanceof Error ? error.message : String(error)
71155
+ };
71156
+ }
71157
+ }
68083
71158
  //#endregion
68084
71159
  //#region src/host/opendesign/mapper.ts
68085
71160
  /**
@@ -69800,10 +72875,45 @@ function registerOpenDesignRoutes(ctx, options) {
69800
72875
  ok: false,
69801
72876
  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
72877
  });
72878
+ /**
72879
+ * The workspace id, resolved ONCE for this request and reused.
72880
+ *
72881
+ * Every account here asks the same control-plane question — "which
72882
+ * workspace is this key signed into" — and the coding-plan read needs
72883
+ * the answer only as an address. The first account to need it settles
72884
+ * the lookup; the rest await that same promise, so a request with four
72885
+ * readable accounts makes one `/workspaces/current` call rather than four.
72886
+ *
72887
+ * The settled answer is reused even when it FAILED: every account then
72888
+ * carries the same `error` in its own `codingPlan`, which is what a
72889
+ * user needs to see ("the workspace cannot be resolved") instead of
72890
+ * four identical timeouts. A failure never reaches the reading itself —
72891
+ * the wallet and billing halves are unaffected.
72892
+ */
72893
+ let workspaceLookup = null;
72894
+ const readCodingPlan = async (controlKey, runtimeKey) => {
72895
+ if (workspaceLookup === null) workspaceLookup = fetchWorkspaceId({
72896
+ fetchFn,
72897
+ controlKey
72898
+ });
72899
+ const workspace = await workspaceLookup;
72900
+ if (!workspace.ok || workspace.id === null) return {
72901
+ tier: null,
72902
+ windows: [],
72903
+ error: workspace.error === "" ? "no workspace id for the coding-plan read" : workspace.error
72904
+ };
72905
+ return await fetchCodingPlanUsage({
72906
+ fetchFn,
72907
+ controlKey,
72908
+ runtimeKey,
72909
+ workspaceId: workspace.id
72910
+ });
72911
+ };
69803
72912
  const readings = await Promise.all(readable.map(async (account) => {
69804
72913
  const fetchedAt = Date.now();
69805
72914
  try {
69806
- const controlKey = controlKeyFor(await accountPool.getFreshCredential(account.id, fetchFn));
72915
+ const credentials = await accountPool.getFreshCredential(account.id, fetchFn);
72916
+ const controlKey = controlKeyFor(credentials);
69807
72917
  if (controlKey === void 0) return {
69808
72918
  accountId: account.id,
69809
72919
  alias: account.alias,
@@ -69811,15 +72921,24 @@ function registerOpenDesignRoutes(ctx, options) {
69811
72921
  balanceUsd: null,
69812
72922
  updatedAt: null,
69813
72923
  error: "no control key on this account",
69814
- fetchedAt
72924
+ fetchedAt,
72925
+ codingPlan: {
72926
+ tier: null,
72927
+ windows: [],
72928
+ error: "no control key on this account"
72929
+ }
69815
72930
  };
69816
- const [wallet, billing] = await Promise.all([fetchWalletBalance({
69817
- fetchFn,
69818
- controlKey
69819
- }), fetchBillingSummary({
69820
- fetchFn,
69821
- controlKey
69822
- })]);
72931
+ const [wallet, billing, codingPlan] = await Promise.all([
72932
+ fetchWalletBalance({
72933
+ fetchFn,
72934
+ controlKey
72935
+ }),
72936
+ fetchBillingSummary({
72937
+ fetchFn,
72938
+ controlKey
72939
+ }),
72940
+ readCodingPlan(controlKey, bearerFor(credentials))
72941
+ ]);
69823
72942
  const balanceUsd = wallet.balanceUsd ?? billing.billing?.totalAvailableCreditsUsd ?? null;
69824
72943
  return {
69825
72944
  accountId: account.id,
@@ -69829,7 +72948,8 @@ function registerOpenDesignRoutes(ctx, options) {
69829
72948
  updatedAt: wallet.updatedAt,
69830
72949
  error: wallet.error,
69831
72950
  fetchedAt,
69832
- ...billing.billing === null ? {} : { billing: billing.billing }
72951
+ ...billing.billing === null ? {} : { billing: billing.billing },
72952
+ codingPlan
69833
72953
  };
69834
72954
  } catch (error) {
69835
72955
  return {
@@ -69839,7 +72959,12 @@ function registerOpenDesignRoutes(ctx, options) {
69839
72959
  balanceUsd: null,
69840
72960
  updatedAt: null,
69841
72961
  error: firstLine(error instanceof Error ? error.message : error, 240),
69842
- fetchedAt
72962
+ fetchedAt,
72963
+ codingPlan: {
72964
+ tier: null,
72965
+ windows: [],
72966
+ error: "the account could not be read, so its coding plan was not read either"
72967
+ }
69843
72968
  };
69844
72969
  }
69845
72970
  }));
@@ -73741,7 +76866,8 @@ const DISABLED_OUTPUT_TEXT = "Progress updates are disabled; the update was not
73741
76866
  /** 工具描述的最终全文。 */
73742
76867
  const PROGRESS_UPDATE_DESCRIPTION = [
73743
76868
  "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:",
76869
+ "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).",
76870
+ "Use it during the rest of the work so the user can follow along and correct course early:",
73745
76871
  "state your plan near the beginning of the task (what you will do, and in what order);",
73746
76872
  "report a meaningful conclusion the moment you have it (a key fact, the root cause, a verified assumption) rather than narrating every step;",
73747
76873
  "give a stage judgement when one phase ends and the next begins (where things stand, what happens next);",
@@ -73894,6 +77020,13 @@ function stringOf$1(value) {
73894
77020
  * 文本用函数形式提供(而不是静态字符串):开关关闭时返回空串,装配期就地
73895
77021
  * 抹掉引导,不需要重启会话、也不需要重挂插件行。
73896
77022
  *
77023
+ * ## 为什么是英文
77024
+ *
77025
+ * 提示词是写给模型看的,不是写给用户看的:同一段要求在英文下最短、歧义最少,
77026
+ * 也和工具描述(`tool.ts` 的 `PROGRESS_UPDATE_DESCRIPTION`,本来就是英文)保持
77027
+ * 同一套措辞。用户看到的语言由 {@link progressLanguageDirective} 单独钉住——
77028
+ * 「指令用英文」与「输出用中文」是两件事,不要混在一起。
77029
+ *
73897
77030
  * @module dsh-chatgpt-subscription/host/progress-updates/prompt
73898
77031
  */
73899
77032
  /**
@@ -73903,38 +77036,70 @@ function stringOf$1(value) {
73903
77036
  * 而变成第二个分节。
73904
77037
  */
73905
77038
  const PROGRESS_SECTION_NAME = "plugin:progress-updates";
77039
+ /** 允许的取值,供设置解析做白名单校验(配置里写错就等于没写)。 */
77040
+ const PROGRESS_UPDATE_LANGUAGES = [
77041
+ "auto",
77042
+ "zh",
77043
+ "en"
77044
+ ];
73906
77045
  /**
73907
77046
  * 引导词正文。写法对齐 Kimi 的「进展更新」:先说清多步任务早期的思路,再报
73908
77047
  * 有意义的结论、阶段判断、长等待与阻塞;每条给一行标题,细节放正文;不重复
73909
- * 没有变化的状态。
77048
+ * 没有变化的状态。开头与结尾两条被提成硬要求——没有它们,面板在任务的两端
77049
+ * 都是空的。
73910
77050
  */
73911
77051
  const PROGRESS_GUIDANCE = [
73912
- "## 进展更新",
77052
+ "## Progress updates",
77053
+ "",
77054
+ "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.",
73913
77055
  "",
73914
- "在多步任务里,你可以调用 `progress_update` 向用户主动汇报进展。它不会结束你的回合,也不消耗一次问答:报告完继续干活即可。",
77056
+ "**Two updates are mandatory — missing either one means the job is not finished:**",
73915
77057
  "",
73916
- "什么时候用:",
73917
- "- **任务早期**:先说清你打算怎么做(拆成哪几步、先动哪里),让用户在你埋头执行之前就能纠正方向。",
73918
- "- **有意义的结论**:查到关键事实、定位到根因、验证了一个假设——报结论本身,不要报「我刚读了一个文件」。",
73919
- "- **阶段判断**:一步做完、进入下一步之前,用一句话说明现在的状态和接下来要做什么。",
73920
- "- **长等待之前**:跑测试、装依赖、等构建这类要花上一阵子的操作,先说清你在等什么、大概要等多久。",
73921
- "- **被阻塞时**:缺信息、缺权限、外部依赖失败——说清卡在哪、你打算怎么绕过去,或者需要用户做什么。",
77058
+ "- **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.",
77059
+ "- **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.",
73922
77060
  "",
73923
- "怎么写:",
73924
- "- 每条给一行短标题(title,纯文本,不带 Markdown,不加结尾句号),细节放进正文(message)。",
73925
- "- 正文用轻 Markdown:一段话说清发生了什么,必要时跟少量 bullet 列出要点。**有几行数据要对照时,用 GFM 管道符表格**(表头行 + `| --- |` 分隔行),不要写成排成一列的纯文本。",
73926
- "- 不要重复没有变化的状态:上一条报过的还在进行中,就不用再报一遍。",
73927
- "- 进展更新是给用户看的,不是给日志看的:写人话,别复述工具调用参数。",
77061
+ "Beyond those two, these moments are worth a report:",
77062
+ "- **Early planning**: the opening update covers it; report again only when the plan really changes (different approach, smaller scope, a broken premise).",
77063
+ "- **A meaningful conclusion**: a key fact, a root cause, a verified assumption — report the conclusion itself, not \"I just read a file\".",
77064
+ "- **A stage judgement**: one phase ends and the next begins — one line on where things stand and what happens next.",
77065
+ "- **Before a long wait**: a test run, an install, a build — say what you are waiting for and roughly how long.",
77066
+ "- **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.",
73928
77067
  "",
73929
- "作为 subagent 运行时:只描述你自己的子任务进展。你的子任务完成不等于整体任务完成,不要把整体进度写成已完成。"
77068
+ "How to write one:",
77069
+ "- One short title line (plain text, no Markdown, no trailing period); the details go in the message body.",
77070
+ "- 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.",
77071
+ "- Do not repeat a status that has not changed: if the last update already said it is running, it needs no second report.",
77072
+ "- A progress update is for the user, not for the log: write it in human words, do not restate tool arguments.",
77073
+ "",
77074
+ "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
77075
  ].join("\n");
73931
77076
  /**
77077
+ * 每个语言档位对应的一句指令。
77078
+ *
77079
+ * 只约束输出的标题与正文,不约束引导词本身;三条都必须自洽——`auto` 不许在
77080
+ * 用户说中文时滑回英文,`zh`/`en` 则明确压过对话语言。
77081
+ */
77082
+ const PROGRESS_LANGUAGE_DIRECTIVES = {
77083
+ 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.",
77084
+ zh: "Write the title and the message of every progress update in Simplified Chinese, even when the conversation is in another language.",
77085
+ en: "Write the title and the message of every progress update in English, even when the conversation is in another language."
77086
+ };
77087
+ /**
77088
+ * 取一个语言档位的指令文本;未知取值按 `auto` 处理。
77089
+ * @param language - 配置里读到的语言档位。
77090
+ * @returns 追加到引导词末尾的一句指令。
77091
+ */
77092
+ function progressLanguageDirective(language) {
77093
+ return PROGRESS_LANGUAGE_DIRECTIVES[language] ?? PROGRESS_LANGUAGE_DIRECTIVES.auto;
77094
+ }
77095
+ /**
73932
77096
  * 生成提示词分节的文本提供者。
73933
77097
  * @param isEnabled - 读取当前开关状态的函数(每次装配重新求值)。
73934
- * @returns 开关打开时返回引导词,关闭时返回空串。
77098
+ * @param language - 读取当前输出语言的函数;缺省按 `auto` 处理。
77099
+ * @returns 开关打开时返回引导词加语言指令,关闭时返回空串。
73935
77100
  */
73936
- function progressSectionText(isEnabled) {
73937
- return () => isEnabled() ? PROGRESS_GUIDANCE : "";
77101
+ function progressSectionText(isEnabled, language = () => "auto") {
77102
+ return () => isEnabled() ? PROGRESS_GUIDANCE + "\n\n" + progressLanguageDirective(language()) : "";
73938
77103
  }
73939
77104
  //#endregion
73940
77105
  //#region src/host/progress-updates/projection.ts
@@ -74261,9 +77426,11 @@ function readProgressConfigField(field) {
74261
77426
  function resolveProgressUpdatesSettings(config) {
74262
77427
  const enabled = readProgressConfigField(config?.progressUpdates);
74263
77428
  const threshold = readProgressConfigField(config?.progressUpdatesNudgeThreshold);
77429
+ const language = readProgressConfigField(config?.progressUpdatesLanguage);
74264
77430
  return {
74265
77431
  enabled: enabled !== false,
74266
- nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8
77432
+ nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8,
77433
+ language: language !== void 0 && PROGRESS_UPDATE_LANGUAGES.includes(language) ? language : "auto"
74267
77434
  };
74268
77435
  }
74269
77436
  /** 血缘回溯的深度上限:与 `nudge.ts` 的 `counterSessionIdOf` 同一个数。 */
@@ -74521,7 +77688,7 @@ function installProgressUpdates(ctx, options) {
74521
77688
  return systemPrompt.section({
74522
77689
  name: PROGRESS_SECTION_NAME,
74523
77690
  order: 220,
74524
- text: progressSectionText(() => options.settings().enabled)
77691
+ text: progressSectionText(() => options.settings().enabled, () => options.settings().language)
74525
77692
  });
74526
77693
  }, "dsh-chatgpt-subscription: progress update prompt section");
74527
77694
  track(() => {
@@ -74577,7 +77744,12 @@ const Config = z.object({
74577
77744
  noAnswerResume: z.union([z.object({}), z.const(false)]).default({}),
74578
77745
  codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS$2),
74579
77746
  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 发一条进展。")
77747
+ progressUpdatesNudgeThreshold: z.number().step(1).min(1).max(50).default(8).volatile().description("催促阈值:连续这么多轮带工具调用却没有进展更新时,提醒 agent 发一条进展。"),
77748
+ progressUpdatesLanguage: z.union([
77749
+ z.const("auto"),
77750
+ z.const("zh"),
77751
+ z.const("en")
77752
+ ]).default("auto").volatile().description("进展更新的输出语言:auto 跟随对话语言,zh 中文,en 英文。")
74581
77753
  });
74582
77754
  const inject = [
74583
77755
  "webServer",
@@ -74873,6 +78045,35 @@ function apply(ctx, pluginConfig = {}) {
74873
78045
  const snapshot = usage.snapshotFor(account.id, account.credentials);
74874
78046
  return snapshot === void 0 ? void 0 : codexAccountQuota(snapshot);
74875
78047
  });
78048
+ registerWidgetBoard(ctx, { lines: {
78049
+ origin: () => webServerOrigin(ctx.webServer),
78050
+ pools: {
78051
+ codex: codexAccountPool,
78052
+ claude: claudeAccountPool,
78053
+ "glm-coding-plan": glmCodingPlanAccountPool,
78054
+ "step-code": stepCodeAccountPool,
78055
+ qoder: qoderAccountPool,
78056
+ "cline-pass": clinePassLine.accountPool,
78057
+ opendesign: openDesignLine.accountPool,
78058
+ "kimi-code": kimiCodeAccountPool,
78059
+ "minimax-code": minimaxCodeAccountPool,
78060
+ antigravity: antigravityAccountPool,
78061
+ "command-code": commandCodeAccountPool
78062
+ },
78063
+ accountQuotaClients: {
78064
+ qoder: async (credentials, { fetchFn, accountId }) => {
78065
+ const read = await loadQoderQuota(credentials, {
78066
+ fetchFn,
78067
+ cacheKey: accountId
78068
+ });
78069
+ return read.ok ? read.snapshot : null;
78070
+ },
78071
+ "minimax-code": async (credentials, { fetchFn, accountId }) => await fetchTokenPlanQuota(credentials, {
78072
+ fetchFn,
78073
+ accountId
78074
+ })
78075
+ }
78076
+ } });
74876
78077
  const adapter = new CodexChatGptAdapter(new ResponsesClient(oauth, ctx.attachments, {
74877
78078
  fetchFn: modelFetch["codex-chatgpt"],
74878
78079
  accountPool: codexAccountPool,