dsh-qoder-connect 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as QODER_GLOBAL_AUTH_PATH, B as qoderCredentialIdentity, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_AUTH_FILENAME, G as qoderPluginDataDir, H as QODER_DATA_DIR_ENV, I as QODER_PAT_ENV_CN, K as qoderStateDir, L as QODER_PAT_ENV_GLOBAL, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_STATUS_PATH, R as QoderCredentialStore, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_NAME, V as qoderOwnAuthPath, W as qoderMachineIdPath, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as patTail } from "./variants-DE76gF0Y.js";
1
+ import { A as QODER_GLOBAL_AUTH_PATH, B as patTail, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_STATUS_PATH, G as qoderMachineIdPath, H as qoderOwnAuthPath, I as QODER_AUTH_FILENAME, K as qoderPluginDataDir, L as QODER_PAT_ENV_CN, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_SETTINGS_FACE_PATH, R as QODER_PAT_ENV_GLOBAL, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_ENV, V as qoderCredentialIdentity, W as QODER_DATA_DIR_NAME, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, q as qoderStateDir, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as QoderCredentialStore } from "./variants-C8ezztjV.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -7,8 +7,10 @@ import { createProvider } from "@earendil-works/pi-ai";
7
7
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
8
  import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
9
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
10
+ import { homedir } from "node:os";
10
11
  import { createServer } from "node:http";
11
12
  import { Readable } from "node:stream";
13
+ import yamlModule from "js-yaml";
12
14
  //#region src/loopback.ts
13
15
  /**
14
16
  * Shared loopback gates for the plugin's local HTTP surfaces: the loopback
@@ -265,6 +267,17 @@ const NO_COST = {
265
267
  */
266
268
  const RATE_SEPARATOR = " · ";
267
269
  /**
270
+ * What the model seat shows in place of a rate the plugin cannot stand behind.
271
+ *
272
+ * The seat has no locale service (the adapter is a host seam), so this is a
273
+ * literal; it matches the settings card's own wording for the same state.
274
+ * Rendering it as WORDS rather than an empty slot: every other model in the
275
+ * seat shows `· x0.3`, so a bare name where the rate should be reads as a
276
+ * rendering bug. This plugin's fallback roster carries no rate at all, so the
277
+ * case is not hypothetical.
278
+ */
279
+ const RATE_UNAVAILABLE = "价格暂不可用";
280
+ /**
268
281
  * Append the billing rate to one model's display name.
269
282
  *
270
283
  * The rate rides the *name* alone: since DSH 0.1.2 the composer's model seat
@@ -280,6 +293,7 @@ const RATE_SEPARATOR = " · ";
280
293
  */
281
294
  /** The catalog display suffix: the normalized billing rate, when the row has one. */
282
295
  function displaySuffix(info) {
296
+ if (info.billing?.rateUnknown === true) return RATE_UNAVAILABLE;
283
297
  const rate = normalizeCredits(info.billing?.credits);
284
298
  return rate === void 0 || rate === "" ? void 0 : rate;
285
299
  }
@@ -1615,6 +1629,175 @@ function appendHintRow(agent, kind, text) {
1615
1629
  } catch {}
1616
1630
  }
1617
1631
  //#endregion
1632
+ //#region src/settings-store.ts
1633
+ /**
1634
+ * Plugin-owned settings store — the single source of truth for this plugin's
1635
+ * user configuration on every host line.
1636
+ *
1637
+ * WHY THIS EXISTS
1638
+ *
1639
+ * A settings write on DSH 0.1.7 goes through the profile patch
1640
+ * (`configEditor.edit`), which reconciles the whole loader tree and hot-reloads
1641
+ * the plugin's fiber (~1–1.5 s, plus a storm of client mirror refreshes) on
1642
+ * EVERY write — one per toggled switch. The plugin's own files (the credential,
1643
+ * the catalog cache) have always lived in `<profile>/.dsh-qoder-connect/`, so
1644
+ * the settings move there too: a write becomes a small atomic local file write
1645
+ * with an in-memory apply, no tree reconcile, no reload.
1646
+ *
1647
+ * FILE
1648
+ * <plugin data dir>/settings.json (same directory as the credential)
1649
+ *
1650
+ * The data directory is resolved by `qoderPluginDataDir()` — the ONE discovery
1651
+ * implementation this plugin already has (env override, then the declaring
1652
+ * profile, then the installing link). It is deliberately not re-implemented
1653
+ * here: a second copy of that logic is how a nested `.dsh-qoder-connect/
1654
+ * .dsh-qoder-connect/` path gets shipped.
1655
+ *
1656
+ * MIGRATION (one time)
1657
+ * the file is absent → seed it from the entry config's own fields → write the
1658
+ * file → delete ONLY this plugin's fields from the entry config (see
1659
+ * ./index.ts), so the profile row returns to its shipped state.
1660
+ *
1661
+ * @module dsh-qoder-connect/settings-store
1662
+ */
1663
+ const yaml = yamlModule;
1664
+ const loadYaml = typeof yaml.load === "function" ? yaml.load : yaml.default?.load;
1665
+ /** Settings file name inside the plugin data directory. */
1666
+ const SETTINGS_FILE_NAME = "settings.json";
1667
+ /** This plugin's data directory (shared with the credential and caches). */
1668
+ function dataDir() {
1669
+ return qoderPluginDataDir();
1670
+ }
1671
+ /** Absolute path of the settings file. */
1672
+ function settingsFilePath() {
1673
+ return join(dataDir(), SETTINGS_FILE_NAME);
1674
+ }
1675
+ /** Read + parse the settings file; `undefined` when absent or unreadable. */
1676
+ function readFile() {
1677
+ const path = settingsFilePath();
1678
+ if (!existsSync(path)) return void 0;
1679
+ try {
1680
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1681
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
1682
+ } catch {
1683
+ return;
1684
+ }
1685
+ }
1686
+ /** Write the settings file atomically (tmp + rename), creating the directory. */
1687
+ function writeSettings(values) {
1688
+ const path = settingsFilePath();
1689
+ mkdirSync(dirname(path), { recursive: true });
1690
+ const tmp = `${path}.tmp`;
1691
+ writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, "utf8");
1692
+ renameSync(tmp, path);
1693
+ }
1694
+ /** Resolve `$DSH_HOME` the way the Host does, without importing a Host package. */
1695
+ function dshHome() {
1696
+ const fromEnv = process.env.DSH_HOME?.trim() || void 0;
1697
+ if (fromEnv !== void 0) return fromEnv;
1698
+ return join(homedir(), ".dsh");
1699
+ }
1700
+ /**
1701
+ * Read this plugin's sections out of the legacy settings document.
1702
+ *
1703
+ * The 0.1.5 host kept every plugin's settings in `$DSH_HOME/settings.yaml`;
1704
+ * 0.1.7 renames it to `settings.yaml.imported` once, on the first boot, and
1705
+ * imports only the sections whose names match a profile entry id. This
1706
+ * plugin's 0.1.5 sections were keyed by its SERVED NAMESPACES (`qoder`,
1707
+ * `qoder-global`, `qoder-quota`), which name no entry in the profile — the row
1708
+ * is `llm-qoder` — so the platform refuses them and their data survives only
1709
+ * in the renamed document. Reading it here recovers that data independently of
1710
+ * the platform's import, its section-name mapping, and its timing.
1711
+ *
1712
+ * @param namespaces - the section names the 0.1.5 host keyed this plugin's
1713
+ * settings under (one per served section).
1714
+ * @returns the union of those sections' fields, or undefined when the document
1715
+ * is absent, unreadable, or holds none of them.
1716
+ */
1717
+ function readLegacySections(namespaces) {
1718
+ let path;
1719
+ for (const name of ["settings.yaml", "settings.yaml.imported"]) {
1720
+ const candidate = join(dshHome(), name);
1721
+ if (existsSync(candidate)) {
1722
+ path = candidate;
1723
+ break;
1724
+ }
1725
+ }
1726
+ if (path === void 0 || loadYaml === void 0) return void 0;
1727
+ let document;
1728
+ try {
1729
+ document = loadYaml(readFileSync(path, "utf8"));
1730
+ } catch {
1731
+ return;
1732
+ }
1733
+ if (document === null || typeof document !== "object") return void 0;
1734
+ const merged = {};
1735
+ let found = false;
1736
+ for (const ns of namespaces) {
1737
+ const section = document[ns];
1738
+ if (section === null || typeof section !== "object" || Array.isArray(section)) continue;
1739
+ Object.assign(merged, section);
1740
+ found = true;
1741
+ }
1742
+ return found ? merged : void 0;
1743
+ }
1744
+ /**
1745
+ * Reserved bookkeeping key: how many writes the settings card has made through
1746
+ * this store. Its presence separates "the file holds migration seed" from "the
1747
+ * file holds the user's live edits" — see the seed rule in ./index.ts. Field
1748
+ * readers address their fields by name and never collide with it.
1749
+ */
1750
+ const WRITE_MARK = "__writes";
1751
+ /**
1752
+ * One store instance: the plugin's own settings file, with the entry config as
1753
+ * the fallback layer the caller overlays it on.
1754
+ */
1755
+ var SettingsStore = class {
1756
+ /** The user layer exactly as stored (presence marks an override). */
1757
+ user;
1758
+ constructor() {
1759
+ this.user = readFile() ?? {};
1760
+ }
1761
+ /** Whether the store file exists. */
1762
+ exists() {
1763
+ return existsSync(settingsFilePath());
1764
+ }
1765
+ /**
1766
+ * Whether the settings card has ever written through this store.
1767
+ *
1768
+ * False during the migration window — the window where a 0.1.5 settings.yaml
1769
+ * import (which lands only after the Loader settles, i.e. AFTER this plugin's
1770
+ * first apply) can still deliver the user's real values, and where a first
1771
+ * seed may have been taken before that import. While false the entry config
1772
+ * stays authoritative; once the card writes, the file is — a runtime edit
1773
+ * must never be regressed by a stale profile row.
1774
+ */
1775
+ get edited() {
1776
+ return typeof this.user[WRITE_MARK] === "number" && this.user[WRITE_MARK] > 0;
1777
+ }
1778
+ /**
1779
+ * Apply one patch in memory and persist it.
1780
+ * @param patch - field → value; a `null` value clears the field.
1781
+ * @param fromCard - true when the settings card made this write; marks the
1782
+ * file as holding live user edits from then on.
1783
+ * @returns the new user layer.
1784
+ */
1785
+ patch(patch, fromCard = false) {
1786
+ const next = { ...this.user };
1787
+ for (const [field, value] of Object.entries(patch)) if (value === null) delete next[field];
1788
+ else next[field] = value;
1789
+ if (fromCard) next[WRITE_MARK] = (typeof next[WRITE_MARK] === "number" ? next[WRITE_MARK] : 0) + 1;
1790
+ writeSettings(next);
1791
+ for (const key of Object.keys(this.user)) delete this.user[key];
1792
+ Object.assign(this.user, next);
1793
+ return this.user;
1794
+ }
1795
+ /** The current user layer. */
1796
+ values() {
1797
+ return this.user;
1798
+ }
1799
+ };
1800
+ //#endregion
1618
1801
  //#region src/index.ts
1619
1802
  /** Stable Cordis plugin name. */
1620
1803
  const name = "llm-qoder";
@@ -1694,23 +1877,90 @@ function credentialPollMs() {
1694
1877
  * stay in proportion under the `DSH_QODER_POLL_MS` override.
1695
1878
  */
1696
1879
  const CATALOG_RETRY_SWEEPS = 10;
1880
+ /**
1881
+ * Mark one configuration field as a live projected setting, when the host has
1882
+ * that concept at all.
1883
+ *
1884
+ * DSH 0.1.7 projects a `.volatile()` field into the profile's settings form and
1885
+ * hands the plugin a STABLE REFERENCE instead of the value; a field without the
1886
+ * mark never reaches the form — `dsh-settings`' `describe()` skips an entry
1887
+ * whose schema has no volatile field (`lib/index.js:411-421`), which is exactly
1888
+ * what makes the browser-side `configForms` namespace exist. DSH 0.1.5's
1889
+ * schemastery has no `volatile` at all, so calling it there is a `TypeError`
1890
+ * that would take the whole plugin down with it. The method is therefore
1891
+ * probed, never assumed.
1892
+ *
1893
+ * Only TOP-LEVEL fields are marked: a volatile field may not sit inside another
1894
+ * volatile field, and schemastery rejects a volatile node reached through a
1895
+ * collection (`validateVolatileSchema`), while the 0.1.7 client writes a single
1896
+ * path segment (`ConfigFormController.set` → `path: [field]`). Every field this
1897
+ * plugin exposes is already a top-level member of {@link Config}, so marking
1898
+ * the field itself is both legal and the only granularity the write path can
1899
+ * address.
1900
+ *
1901
+ * @param field - the field's schema.
1902
+ * @returns the projected schema on a host that supports projection, else the field itself.
1903
+ */
1904
+ function volatileField(field) {
1905
+ const probe = field;
1906
+ if (typeof probe.volatile === "function") return probe.volatile();
1907
+ if (typeof probe.extra === "function") return probe.extra("volatile", true);
1908
+ if (probe && typeof probe === "object") {
1909
+ probe.meta = {
1910
+ ...probe.meta,
1911
+ volatile: true
1912
+ };
1913
+ return probe;
1914
+ }
1915
+ return field;
1916
+ }
1917
+ /**
1918
+ * Read one configuration field through the volatile indirection.
1919
+ *
1920
+ * On DSH 0.1.7 a `.volatile()` field arrives as a reference object whose value
1921
+ * is read with `.get()` (`@deepseek-ai/dsh-agent-default-model`,
1922
+ * `lib/index.js:38-41`); on DSH 0.1.5 the plain value arrives directly. Every
1923
+ * read of a projected field goes through here, so both hosts yield the value
1924
+ * itself.
1925
+ *
1926
+ * @param config - the raw plugin configuration, in either host's shape.
1927
+ * @param field - field name inside the configuration object.
1928
+ * @returns the field's value, or undefined when the field is absent.
1929
+ */
1930
+ function readField(config, field) {
1931
+ const value = config?.[field];
1932
+ return value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
1933
+ }
1934
+ /**
1935
+ * Read one declared field with its declared type.
1936
+ *
1937
+ * The typed face of {@link readField}, for the few call sites that want a
1938
+ * single field rather than a whole {@link readConfig}.
1939
+ *
1940
+ * @param config - the raw plugin configuration, in either host's shape.
1941
+ * @param field - field name inside the configuration object.
1942
+ * @returns the field's plain value, or undefined when it is absent.
1943
+ */
1944
+ function readConfigField(config, field) {
1945
+ return readField(config, field);
1946
+ }
1697
1947
  /** Probe authorization (shared by the plugin schema and the China section). */
1698
- const PROBE_CONSENT_FIELD = z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
1699
- const MAXIMUM_CONTEXT_WINDOW_FIELD = z.boolean().default(true).description("Use the largest context window declared by Qoder Global when alternatives are available (on by default)");
1948
+ const PROBE_CONSENT_FIELD = volatileField(z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)"));
1949
+ const MAXIMUM_CONTEXT_WINDOW_FIELD = volatileField(z.boolean().default(true).description("Use the largest context window declared by Qoder Global when alternatives are available (on by default)"));
1700
1950
  /** The China variant's own maximum-window preference. */
1701
- const MAXIMUM_CONTEXT_WINDOW_CN_FIELD = z.boolean().default(true).description("Use the largest context window declared by Qoder (China) when alternatives are available (on by default)");
1951
+ const MAXIMUM_CONTEXT_WINDOW_CN_FIELD = volatileField(z.boolean().default(true).description("Use the largest context window declared by Qoder (China) when alternatives are available (on by default)"));
1702
1952
  /**
1703
1953
  * Per-model window overrides (model id → tokens). The card writes one entry
1704
1954
  * per selector change; the field is optional so an absent map means "no
1705
1955
  * overrides" rather than a default object the host must keep in sync.
1706
1956
  */
1707
- const MODEL_CONTEXT_WINDOWS_FIELD = z.dict(z.number().min(1), z.string()).description("Per-model context-window overrides for Qoder Global (model id → tokens)");
1957
+ const MODEL_CONTEXT_WINDOWS_FIELD = volatileField(z.dict(z.number().min(1), z.string()).description("Per-model context-window overrides for Qoder Global (model id → tokens)"));
1708
1958
  /** Disabled models list (blacklist) */
1709
- const DISABLED_MODELS_FIELD = z.array(z.string()).default([]).description("Models hidden from the provider catalog and picker");
1959
+ const DISABLED_MODELS_FIELD = volatileField(z.array(z.string()).default([]).description("Models hidden from the provider catalog and picker"));
1710
1960
  /** Sidebar quota toggle (one per variant; both live on the shared quota card). */
1711
- const QUOTA_TOGGLE_FIELD = z.boolean().default(false).description("Show this variant’s remaining-credit card in the sidebar footer (off by default)");
1961
+ const QUOTA_TOGGLE_FIELD = volatileField(z.boolean().default(false).description("Show this variant’s remaining-credit card in the sidebar footer (off by default)"));
1712
1962
  /** Automatic check-in toggle. */
1713
- const AUTO_CHECK_IN_FIELD = z.boolean().default(false).description("每天自动签到领取算力额度(默认关闭)");
1963
+ const AUTO_CHECK_IN_FIELD = volatileField(z.boolean().default(false).description("每天自动签到领取算力额度(默认关闭)"));
1714
1964
  /**
1715
1965
  * When a variant checks in, as minutes past midnight in UTC+8.
1716
1966
  *
@@ -1719,7 +1969,7 @@ const AUTO_CHECK_IN_FIELD = z.boolean().default(false).description("每天自动
1719
1969
  * anything outside that range fails Host validation instead of silently
1720
1970
  * scheduling a request at a moment that never arrives.
1721
1971
  */
1722
- const CHECK_IN_MINUTE_FIELD = z.number().default(600).min(0).max(1439).description("每日自动签到的时刻(自 UTC+8 午夜起的分钟数,600 = 10:00)");
1972
+ const CHECK_IN_MINUTE_FIELD = volatileField(z.number().default(600).min(0).max(1439).description("每日自动签到的时刻(自 UTC+8 午夜起的分钟数,600 = 10:00)"));
1723
1973
  /**
1724
1974
  * Quota poll interval: default 5 minutes, floor 1 minute. The status route
1725
1975
  * performs a live upstream billing call per request with no cache, so an
@@ -1729,13 +1979,15 @@ const CHECK_IN_MINUTE_FIELD = z.number().default(600).min(0).max(1439).descripti
1729
1979
  */
1730
1980
  const QUOTA_POLL_DEFAULT_MS = 3e5;
1731
1981
  const QUOTA_POLL_MIN_MS = 6e4;
1732
- const QUOTA_POLL_FIELD = z.number().default(QUOTA_POLL_DEFAULT_MS).min(QUOTA_POLL_MIN_MS).description("Sidebar quota card refresh interval in milliseconds (default 300000, minimum 60000)");
1982
+ const QUOTA_POLL_FIELD = volatileField(z.number().default(QUOTA_POLL_DEFAULT_MS).min(QUOTA_POLL_MIN_MS).description("Sidebar quota card refresh interval in milliseconds (default 300000, minimum 60000)"));
1733
1983
  const Config = z.object({
1734
1984
  probeConsent: PROBE_CONSENT_FIELD,
1735
1985
  useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
1736
1986
  useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
1737
1987
  modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
1738
1988
  modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
1989
+ disabledModels: DISABLED_MODELS_FIELD,
1990
+ disabledModelsCN: DISABLED_MODELS_FIELD,
1739
1991
  sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
1740
1992
  sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
1741
1993
  autoCheckInCN: AUTO_CHECK_IN_FIELD,
@@ -1744,35 +1996,18 @@ const Config = z.object({
1744
1996
  checkInMinuteGlobal: CHECK_IN_MINUTE_FIELD,
1745
1997
  quotaPollMs: QUOTA_POLL_FIELD
1746
1998
  });
1747
- /**
1748
- * The China card's settings section: only the fields that card edits.
1749
- *
1750
- * A section is what makes its namespace "served", which is what the Plugins
1751
- * tab dispatches a card by — so the schema and the card must stay split the
1752
- * same way. `probeConsent` lives here because it predates the second variant;
1753
- * it gates no current code path (only manual, per-click-confirmed probes run),
1754
- * so it is left where existing users set it rather than moved and re-asked.
1755
- */
1756
- const CHINA_SECTION = z.object({
1999
+ z.object({
1757
2000
  probeConsent: PROBE_CONSENT_FIELD,
1758
2001
  useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
1759
2002
  modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
1760
2003
  disabledModelsCN: DISABLED_MODELS_FIELD
1761
2004
  });
1762
- /** The global card's settings section and its context-window preferences. */
1763
- const GLOBAL_SECTION = z.object({
2005
+ z.object({
1764
2006
  useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
1765
2007
  modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
1766
2008
  disabledModels: DISABLED_MODELS_FIELD
1767
2009
  });
1768
- /**
1769
- * The shared quota card's section: both sidebar toggles and the poll interval.
1770
- *
1771
- * Only these fields — the card edits nothing else, and the Plugins tab pairs a
1772
- * card with the section whose namespace it names, so a stray field here would
1773
- * render as a control no other surface reads.
1774
- */
1775
- const QUOTA_SECTION = z.object({
2010
+ z.object({
1776
2011
  sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
1777
2012
  sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
1778
2013
  autoCheckInCN: AUTO_CHECK_IN_FIELD,
@@ -1813,13 +2048,37 @@ const QUOTA_SECTION_KEYS = [
1813
2048
  "checkInMinuteGlobal",
1814
2049
  "quotaPollMs"
1815
2050
  ];
2051
+ /** Every declared configuration field, in the order the sections are merged. */
2052
+ const CONFIG_KEYS = [
2053
+ ...CN_SECTION_KEYS,
2054
+ ...GLOBAL_SECTION_KEYS,
2055
+ ...QUOTA_SECTION_KEYS
2056
+ ];
1816
2057
  /** Copy the declared fields off one section's source, skipping absent ones. */
1817
2058
  function pickFields(source, keys) {
1818
2059
  const value = source();
1819
2060
  const picked = {};
1820
- for (const key of keys) if (value[key] !== void 0) picked[key] = value[key];
2061
+ for (const key of keys) {
2062
+ const field = readField(value, key);
2063
+ if (field !== void 0) picked[key] = field;
2064
+ }
1821
2065
  return picked;
1822
2066
  }
2067
+ /**
2068
+ * Unwrap a whole configuration object into plain values.
2069
+ *
2070
+ * The merged configuration is read through {@link pickFields}/{@link readField}
2071
+ * everywhere else, but the plugin also reads its initial config directly while
2072
+ * it assembles its runtimes — before any settings section has handed over a
2073
+ * source. On 0.1.7 those direct reads would otherwise compare a reference
2074
+ * object against `true`/`undefined` and silently see "not set" for every field.
2075
+ *
2076
+ * @param config - the raw plugin configuration, in either host's shape.
2077
+ * @returns the same configuration with every declared field read out.
2078
+ */
2079
+ function readConfig(config) {
2080
+ return pickFields(() => config, CONFIG_KEYS);
2081
+ }
1823
2082
  /** Stable identity key used by credentials, probe records, and catalog entries. */
1824
2083
  const credentialIdentity = qoderCredentialIdentity;
1825
2084
  /** The settings namespace a variant's card and provider directory entry use. */
@@ -1901,13 +2160,14 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
1901
2160
  });
1902
2161
  const fallback = fallbackFor(variant);
1903
2162
  const catalog = new QoderCatalog(fallback);
2163
+ const initial = current();
1904
2164
  if (variant.id !== CHINA_VARIANT.id) {
1905
- catalog.setUseMaximumContextWindow(config.useMaximumContextWindow === true);
1906
- if (config.disabledModels !== void 0) catalog.setDisabledModels(config.disabledModels);
2165
+ catalog.setUseMaximumContextWindow(initial.useMaximumContextWindow === true);
2166
+ if (initial.disabledModels !== void 0) catalog.setDisabledModels(initial.disabledModels);
1907
2167
  } else {
1908
- catalog.setUseMaximumContextWindow(config.useMaximumContextWindowCN === true);
1909
- if (config.modelContextWindowsCN !== void 0) catalog.setModelContextWindows(config.modelContextWindowsCN);
1910
- if (config.disabledModelsCN !== void 0) catalog.setDisabledModels(config.disabledModelsCN);
2168
+ catalog.setUseMaximumContextWindow(initial.useMaximumContextWindowCN === true);
2169
+ if (initial.modelContextWindowsCN !== void 0) catalog.setModelContextWindows(initial.modelContextWindowsCN);
2170
+ if (initial.disabledModelsCN !== void 0) catalog.setDisabledModels(initial.disabledModelsCN);
1911
2171
  }
1912
2172
  catalog.setVisible(false);
1913
2173
  const probeStore = new QoderProbeStore({
@@ -2010,9 +2270,11 @@ function detach(ctx, work, what) {
2010
2270
  * models read the shim origin at construction time. A failure here is
2011
2271
  * contained to this variant: the caller logs it and the other keeps working.
2012
2272
  *
2273
+ * @param seedCatalog - adopt the current credential into this runtime's
2274
+ * catalog immediately; supplied by `apply` (it owns `adoptIdentity`).
2013
2275
  * @returns whether the provider registered.
2014
2276
  */
2015
- async function startVariant(ctx, runtime) {
2277
+ async function startVariant(ctx, runtime, seedCatalog) {
2016
2278
  const { variant, store, client, catalog, probeService } = runtime;
2017
2279
  const shim = createQoderShim({
2018
2280
  store,
@@ -2045,10 +2307,19 @@ async function startVariant(ctx, runtime) {
2045
2307
  let releaseDirectory;
2046
2308
  try {
2047
2309
  releaseAdapter = ctx.llm.registerAdapter([variant.id], qoder.adapter);
2310
+ const host017 = (() => {
2311
+ try {
2312
+ return ctx.get?.("configEditor") !== void 0;
2313
+ } catch {
2314
+ return false;
2315
+ }
2316
+ })();
2317
+ const entryId = ctx.fiber?.entry?.options?.id;
2318
+ const settingsNs = host017 && entryId !== void 0 ? entryId : settingsNamespaceFor(variant);
2048
2319
  releaseDirectory = ctx.llm.registerConfigurableProviders([{
2049
2320
  provider: variant.id,
2050
2321
  displayName: variant.displayName,
2051
- settingsNs: settingsNamespaceFor(variant),
2322
+ settingsNs,
2052
2323
  settingsPath: [],
2053
2324
  declared: false
2054
2325
  }]);
@@ -2070,6 +2341,7 @@ async function startVariant(ctx, runtime) {
2070
2341
  detach(ctx, shim.close(), "loopback endpoint close");
2071
2342
  }
2072
2343
  runtime.registered = true;
2344
+ detach(ctx, seedCatalog(), `${variant.id} catalog seed`);
2073
2345
  return true;
2074
2346
  } catch (error) {
2075
2347
  ctx.logger.error(`dsh-qoder-connect: ${variant.displayName} provider registration failed`, error);
@@ -2078,6 +2350,305 @@ async function startVariant(ctx, runtime) {
2078
2350
  }
2079
2351
  }
2080
2352
  /**
2353
+ * State that MUST survive a fiber reload, module-level on purpose.
2354
+ *
2355
+ * DSH 0.1.7's configuration write (`configEditor.edit`) reconciles the profile
2356
+ * tree, which hot-reloads the entry's fiber — `apply()` runs again with a fresh
2357
+ * closure. Anything re-minted per apply is therefore invalidated by every
2358
+ * settings write: the browser cards hold the keys the status document handed
2359
+ * them, so a per-apply key turns each write into a wave of 403s ("刷新失败"),
2360
+ * and a per-apply identity map makes the sweep re-fetch the catalog from
2361
+ * upstream on every write (the burst of requests and most of the latency).
2362
+ *
2363
+ * Keys and identity/fetch bookkeeping are per-PROCESS secrets and caches, not
2364
+ * per-instance state, so they live here once. A reload keeps the same keys (no
2365
+ * 403 storm) and a same-account, freshly-fetched sweep (no refetch burst).
2366
+ */
2367
+ /** The in-process control keys, minted once per process. */
2368
+ let processKeys;
2369
+ function controlKeys() {
2370
+ processKeys ??= {
2371
+ probe: createProbeKey(),
2372
+ auth: createAuthKey()
2373
+ };
2374
+ return processKeys;
2375
+ }
2376
+ /** The loopback guard the probe and status routes use, restated for settings. */
2377
+ function trustedSettingsRequest(req) {
2378
+ const host = req.headers.host ?? "";
2379
+ const origin = req.headers.origin;
2380
+ if (!/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(String(host))) return false;
2381
+ return origin === void 0 || /^https?:\/\/(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(String(origin));
2382
+ }
2383
+ /**
2384
+ * The schema defaults, READ off each field's own `meta.default`.
2385
+ *
2386
+ * Two ways this was got wrong before, both silent and both destructive: a
2387
+ * hand-written table drifted from the schema (so the schema's own defaults
2388
+ * were classified as real overrides and the legacy settings document could
2389
+ * never win), and `validate({})` was then tried instead — which on this
2390
+ * schemastery answers with hollow `{}` per field instead of applying the
2391
+ * defaults, which is worse than nothing because it looks like it worked.
2392
+ * Walking `Config.dict` for `meta.default` is the only honest source: it is
2393
+ * the value schemastery itself substituted during validation.
2394
+ */
2395
+ const DEFAULT_FOR_FIELD = (() => {
2396
+ const out = {};
2397
+ try {
2398
+ for (const [key, field] of Object.entries(Config.dict ?? {})) {
2399
+ const fallback = field?.meta?.default;
2400
+ if (fallback !== void 0) out[key] = fallback;
2401
+ }
2402
+ } catch {}
2403
+ if (Object.keys(out).length === 0) return {
2404
+ probeConsent: false,
2405
+ useMaximumContextWindow: true,
2406
+ useMaximumContextWindowCN: true,
2407
+ modelContextWindows: {},
2408
+ modelContextWindowsCN: {},
2409
+ disabledModels: [],
2410
+ disabledModelsCN: [],
2411
+ sidebarQuotaCN: false,
2412
+ sidebarQuotaGlobal: false,
2413
+ autoCheckInCN: false,
2414
+ autoCheckInGlobal: false,
2415
+ checkInMinuteCN: 600,
2416
+ checkInMinuteGlobal: 600,
2417
+ quotaPollMs: 3e5
2418
+ };
2419
+ return out;
2420
+ })();
2421
+ /**
2422
+ * Register the settings face (GET/POST) the browser cards read and write
2423
+ * through, answering the whole entry configuration as three layers.
2424
+ *
2425
+ * `value` carries every declared field, so a card rendering the quota section
2426
+ * sees the same merged view the host itself reads; `user` is the settings file
2427
+ * exactly as stored (presence marks an override), which is what the staged-edit
2428
+ * form's "written/reset" display needs. A POST validates the fields it is
2429
+ * allowed to touch, writes the file, applies the new view in memory, and
2430
+ * re-arms the check-in scheduler (the old 0.1.5 section `onChange` behaviour).
2431
+ */
2432
+ function registerQoderSettingsFace(ctx, deps) {
2433
+ const { store, config, current, apply, rearm } = deps;
2434
+ const key = controlKeys().probe;
2435
+ /** The document both routes answer with. */
2436
+ const view = () => {
2437
+ const merged = current();
2438
+ const value = {};
2439
+ const base = {};
2440
+ for (const field of CONFIG_KEYS) {
2441
+ if (merged[field] !== void 0) value[field] = merged[field];
2442
+ base[field] = readConfig(config)[field] ?? DEFAULT_FOR_FIELD[field];
2443
+ }
2444
+ return {
2445
+ key,
2446
+ value,
2447
+ base,
2448
+ user: store.values()
2449
+ };
2450
+ };
2451
+ ctx.effect(() => {
2452
+ const dispose = ctx.webServer.register({
2453
+ kind: "exact",
2454
+ path: QODER_SETTINGS_FACE_PATH,
2455
+ handler: async (req, res) => {
2456
+ if (!trustedSettingsRequest(req)) {
2457
+ jsonFace(res, 403, { error: "request-not-trusted" });
2458
+ return;
2459
+ }
2460
+ if (req.method === "GET") {
2461
+ jsonFace(res, 200, view());
2462
+ return;
2463
+ }
2464
+ if (req.method !== "POST") {
2465
+ jsonFace(res, 405, { error: "method not allowed" });
2466
+ return;
2467
+ }
2468
+ if (req.headers["x-qoder-settings-key"] !== key) {
2469
+ jsonFace(res, 403, { error: "invalid-key" });
2470
+ return;
2471
+ }
2472
+ const body = await readFaceBody(req);
2473
+ if (body === void 0) {
2474
+ jsonFace(res, 413, { error: "body too large" });
2475
+ return;
2476
+ }
2477
+ let patch;
2478
+ try {
2479
+ patch = JSON.parse(body || "{}");
2480
+ } catch {
2481
+ jsonFace(res, 400, { error: "invalid json" });
2482
+ return;
2483
+ }
2484
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) {
2485
+ jsonFace(res, 400, { error: "invalid patch" });
2486
+ return;
2487
+ }
2488
+ const invalid = validateSettingsPatch(patch);
2489
+ if (invalid !== void 0) {
2490
+ jsonFace(res, 400, { error: invalid });
2491
+ return;
2492
+ }
2493
+ store.patch(patch, true);
2494
+ apply(current());
2495
+ rearm();
2496
+ jsonFace(res, 200, view());
2497
+ }
2498
+ });
2499
+ return () => {
2500
+ dispose();
2501
+ };
2502
+ }, "dsh-qoder-connect: settings face");
2503
+ }
2504
+ /** JSON response helper for the settings face. */
2505
+ function jsonFace(res, status, body) {
2506
+ const payload = JSON.stringify(body);
2507
+ res.writeHead(status, { "Content-Type": "application/json" });
2508
+ res.end(payload);
2509
+ }
2510
+ /** Read the request body, or undefined when absent or oversized. */
2511
+ function readFaceBody(req) {
2512
+ return new Promise((resolve) => {
2513
+ let body = "";
2514
+ req.on("data", (chunk) => {
2515
+ body += String(chunk);
2516
+ if (body.length > 1e6) resolve(void 0);
2517
+ });
2518
+ req.on("end", () => resolve(body));
2519
+ req.on("error", () => resolve(void 0));
2520
+ });
2521
+ }
2522
+ /** Schema-level validation of one settings patch; the reason, or undefined. */
2523
+ function validateSettingsPatch(patch) {
2524
+ for (const [field, value] of Object.entries(patch)) {
2525
+ if (!CONFIG_KEYS.includes(field)) return `unknown field ${field}`;
2526
+ if (value === null) continue;
2527
+ switch (field) {
2528
+ case "probeConsent":
2529
+ case "useMaximumContextWindow":
2530
+ case "useMaximumContextWindowCN":
2531
+ case "sidebarQuotaCN":
2532
+ case "sidebarQuotaGlobal":
2533
+ case "autoCheckInCN":
2534
+ case "autoCheckInGlobal":
2535
+ if (typeof value !== "boolean") return `${field} must be a boolean`;
2536
+ break;
2537
+ case "disabledModels":
2538
+ case "disabledModelsCN":
2539
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return `${field} must be an array of strings`;
2540
+ break;
2541
+ case "checkInMinuteCN":
2542
+ case "checkInMinuteGlobal":
2543
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 1439) return `${field} must be an integer minute 0..1439`;
2544
+ break;
2545
+ case "quotaPollMs":
2546
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 6e4) return `${field} must be an integer of at least 60000 ms`;
2547
+ break;
2548
+ case "modelContextWindows":
2549
+ case "modelContextWindowsCN": if (typeof value !== "object" || value === null || Array.isArray(value)) return `${field} must be an object`;
2550
+ }
2551
+ }
2552
+ }
2553
+ /**
2554
+ * When the profile tree last recomposed, module-level so every apply sees it.
2555
+ *
2556
+ * The legacy settings.yaml import writes the profile patch one section at a
2557
+ * time for seconds after the Loader settles, emitting this event on every
2558
+ * write. Cleaning up our row in that window is futile — the import's next
2559
+ * section simply writes it back — so the cleanup waits for the tree to fall
2560
+ * quiet first.
2561
+ */
2562
+ let lastConfigReloadAt = Date.now();
2563
+ /** Wait until the profile tree has been quiet (the legacy import finished). */
2564
+ async function waitForProfileQuiet() {
2565
+ const deadline = Date.now() + 12e4;
2566
+ for (;;) {
2567
+ if (Date.now() - lastConfigReloadAt >= 5e3) return;
2568
+ if (Date.now() >= deadline) return;
2569
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
2570
+ }
2571
+ }
2572
+ /**
2573
+ * Delete this plugin's own fields from the profile entry config, leaving every
2574
+ * other key of the row untouched.
2575
+ *
2576
+ * One-time, right after the settings file has been seeded: the entry returns to
2577
+ * its shipped state, so no second source of truth remains. Both write APIs are
2578
+ * probed — `configEditor` (0.1.7) first, then the settings service's namespace
2579
+ * `replace` (0.1.5), which rebuilds each of this plugin's three sections from
2580
+ * the foreign keys alone.
2581
+ *
2582
+ * @param ctx - plugin context.
2583
+ * @param ownKeys - this plugin's declared config fields.
2584
+ * @param namespaces - the 0.1.5 section namespaces this plugin owns.
2585
+ */
2586
+ async function cleanupEntryConfig(ctx, ownKeys, namespaces) {
2587
+ const attempts = 10;
2588
+ for (let attempt = 0; attempt < attempts; attempt++) try {
2589
+ if (attempt === 0) await waitForProfileQuiet();
2590
+ else await new Promise((resolve) => setTimeout(resolve, 2e3 + Math.random() * 1e3));
2591
+ const probe = ctx;
2592
+ const editor = (typeof probe.get === "function" ? (() => {
2593
+ try {
2594
+ return probe.get.call(ctx, "configEditor");
2595
+ } catch {
2596
+ return;
2597
+ }
2598
+ })() : void 0) ?? await new Promise((resolve) => {
2599
+ ctx.inject(["settings"], (settingsCtx) => {
2600
+ const owner = settingsCtx.settings?.ownerContext;
2601
+ resolve(typeof owner?.get === "function" ? (() => {
2602
+ try {
2603
+ return owner.get.call(owner, "configEditor");
2604
+ } catch {
2605
+ return;
2606
+ }
2607
+ })() : void 0);
2608
+ });
2609
+ });
2610
+ const entry = probe.fiber?.entry;
2611
+ if (editor !== void 0 && entry !== void 0) {
2612
+ await editor.edit(entry, (raw, inherited) => {
2613
+ const next = { ...inherited ?? {} };
2614
+ for (const [key, value] of Object.entries(raw ?? {})) {
2615
+ if (ownKeys.includes(key)) continue;
2616
+ if (!Object.hasOwn(next, key)) next[key] = value;
2617
+ }
2618
+ return next;
2619
+ });
2620
+ return;
2621
+ }
2622
+ await new Promise((resolve) => {
2623
+ ctx.inject(["settings"], (settingsCtx) => {
2624
+ const settings = settingsCtx.settings;
2625
+ if (typeof settings?.replace !== "function" || typeof settings.installSection !== "function") {
2626
+ resolve(void 0);
2627
+ return;
2628
+ }
2629
+ Promise.all(namespaces.map((ns) => Promise.resolve(settings.replace(ns, {})).catch(() => void 0))).then(() => resolve(void 0));
2630
+ });
2631
+ });
2632
+ return;
2633
+ } catch (error) {
2634
+ if (attempt === 9) {
2635
+ ctx.logger?.warn?.("dsh-qoder-connect: entry config cleanup failed", error);
2636
+ return;
2637
+ }
2638
+ await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1) + Math.random() * 500));
2639
+ }
2640
+ }
2641
+ /** The account identity each variant last published a catalog for, across reloads. */
2642
+ const lastIdentities = /* @__PURE__ */ new Map();
2643
+ /**
2644
+ * When a variant's catalog was last fetched LIVE from upstream, per identity.
2645
+ *
2646
+ * A reload reseeds the catalog from the saved file; without this record the
2647
+ * sweep's retry rule (`stale && due`) sees `lastFetchAtMs = 0` and re-fetches
2648
+ * immediately — one upstream round trip per variant per settings write.
2649
+ */
2650
+ const lastLiveFetchAt = /* @__PURE__ */ new Map();
2651
+ /**
2081
2652
  * Start both variants: their loopback endpoints, the `qoder` and
2082
2653
  * `qoder-global` providers, their configuration cards, and their
2083
2654
  * credential-driven catalog lifecycles.
@@ -2088,16 +2659,72 @@ async function startVariant(ctx, runtime) {
2088
2659
  * without re-registering the provider.
2089
2660
  */
2090
2661
  function apply(ctx, config) {
2091
- let current = () => config;
2662
+ const store = new SettingsStore();
2663
+ let current = () => ({
2664
+ ...readConfig(config),
2665
+ ...store.values()
2666
+ });
2667
+ /**
2668
+ * One-time migration: per field — the file never held it → take the entry;
2669
+ * the card HAS written this file → keep the file; the entry carries a
2670
+ * NON-DEFAULT value → take the entry (the legacy settings.yaml import lands
2671
+ * only after the Loader settles, i.e. after this plugin's first apply, and a
2672
+ * seed taken inside that window can hold a stale or mis-encoded value); the
2673
+ * entry only carries the schema default → keep the file.
2674
+ *
2675
+ * That last clause is load-bearing: a bundle layer's insert config does NOT
2676
+ * reach the composition (verified with `dsh --dump-config`), so once the
2677
+ * one-time cleanup has emptied the entry row the entry answers pure defaults —
2678
+ * treating those as authoritative would erase the user's values on the next
2679
+ * boot. Re-checked on every apply, which is also what closes the legacy
2680
+ * import's timing window.
2681
+ *
2682
+ * When the entry is authoritative, its own fields are then deleted from the
2683
+ * profile row, so no second source of truth remains.
2684
+ */
2685
+ const migrateOwnSettings = () => {
2686
+ const legacy = readLegacySections([
2687
+ QODER_SETTINGS_NS,
2688
+ QODER_GLOBAL_SETTINGS_NS,
2689
+ QODER_QUOTA_SETTINGS_NS
2690
+ ]);
2691
+ const seeded = {};
2692
+ for (const key of CONFIG_KEYS) {
2693
+ const entryValue = readField(config, key);
2694
+ const legacyValue = legacy === void 0 ? void 0 : legacy[key];
2695
+ if (!Object.hasOwn(store.user, key)) {
2696
+ if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = entryValue;
2697
+ else if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
2698
+ else if (entryValue !== void 0) seeded[key] = entryValue;
2699
+ else if (legacyValue !== void 0) seeded[key] = legacyValue;
2700
+ continue;
2701
+ }
2702
+ if (store.edited) continue;
2703
+ if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) {
2704
+ seeded[key] = entryValue;
2705
+ continue;
2706
+ }
2707
+ if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
2708
+ }
2709
+ if (Object.keys(seeded).length === 0) return;
2710
+ store.patch(seeded);
2711
+ cleanupEntryConfig(ctx, CONFIG_KEYS, [
2712
+ QODER_SETTINGS_NS,
2713
+ QODER_GLOBAL_SETTINGS_NS,
2714
+ QODER_QUOTA_SETTINGS_NS
2715
+ ]);
2716
+ };
2717
+ migrateOwnSettings();
2718
+ const eventSink = ctx;
2719
+ eventSink.on("loader/volatile-update", () => {
2720
+ migrateOwnSettings();
2721
+ });
2722
+ eventSink.on("app-boot/config-reload", () => {
2723
+ lastConfigReloadAt = Date.now();
2724
+ });
2092
2725
  /** Timers and in-flight work belonging to this plugin instance. */
2093
2726
  let stopped = false;
2094
2727
  const timers = [];
2095
- /**
2096
- * The account identity each variant last published a catalog for. Keeps a
2097
- * same-token sweep from re-fetching, and lets a late response from a
2098
- * previous identity be discarded instead of overwriting a newer one.
2099
- */
2100
- const lastIdentities = /* @__PURE__ */ new Map();
2101
2728
  const runtimes = QODER_VARIANTS.map((variant) => createVariantRuntime(ctx, config, variant, () => current(), (id) => lastIdentities.get(id)));
2102
2729
  const checkInStore = new JsonFileCheckInStore();
2103
2730
  const checkInScheduler = new CheckInScheduler({
@@ -2135,17 +2762,7 @@ function apply(ctx, config) {
2135
2762
  checkInScheduler.catchUp();
2136
2763
  };
2137
2764
  installJobTokenHint(ctx);
2138
- const probeKey = createProbeKey();
2139
- /**
2140
- * The in-process key authorizing PAT writes, minted separately from the
2141
- * probe key.
2142
- *
2143
- * Separate keys rather than one shared secret because the two authorize
2144
- * different powers: one spends credit on a probe, the other stores a
2145
- * credential. A single key handed to both would let a defect in either card
2146
- * reach the other's authority.
2147
- */
2148
- const authKey = createAuthKey();
2765
+ const { probe: probeKey, auth: authKey } = controlKeys();
2149
2766
  let setMaximumContextWindow;
2150
2767
  let setMaximumContextWindowCN;
2151
2768
  let setModelsEnabled;
@@ -2173,7 +2790,7 @@ function apply(ctx, config) {
2173
2790
  const adoptIdentity = (runtime, identity) => {
2174
2791
  const id = runtime.variant.id;
2175
2792
  const known = lastIdentities.get(id);
2176
- if (known === identity) return;
2793
+ if (known === identity && runtime.catalog.isVisible()) return;
2177
2794
  const hadCredential = known !== void 0;
2178
2795
  if (identity === void 0) lastIdentities.delete(id);
2179
2796
  else lastIdentities.set(id, identity);
@@ -2207,6 +2824,26 @@ function apply(ctx, config) {
2207
2824
  runtime.catalog.setVisible(true);
2208
2825
  runtime.invalidate();
2209
2826
  };
2827
+ /**
2828
+ * Apply catalog preferences from a configuration view, in memory.
2829
+ *
2830
+ * Shared by the probe-route setters and the settings face's POST: both write
2831
+ * the plugin-owned settings file and then land the new view here, which is
2832
+ * what the cards see on their next status read.
2833
+ */
2834
+ const applyCatalogSettings = (next) => {
2835
+ let changed = false;
2836
+ for (const runtime of runtimes) {
2837
+ const isChina = runtime.variant.id === CHINA_VARIANT.id;
2838
+ const preference = isChina ? next.useMaximumContextWindowCN : next.useMaximumContextWindow;
2839
+ if (runtime.catalog.setUseMaximumContextWindow(preference === true)) changed = true;
2840
+ const overrides = isChina ? next.modelContextWindowsCN : next.modelContextWindows;
2841
+ if (overrides !== void 0 && runtime.catalog.setModelContextWindows(overrides)) changed = true;
2842
+ const disabled = isChina ? next.disabledModelsCN : next.disabledModels;
2843
+ if (runtime.catalog.setDisabledModels(disabled ?? [])) changed = true;
2844
+ }
2845
+ if (changed) for (const runtime of runtimes) runtime.invalidate();
2846
+ };
2210
2847
  ctx.inject(["webServer"], (webCtx) => {
2211
2848
  for (const runtime of runtimes) {
2212
2849
  registerQoderStatusRoute(webCtx, {
@@ -2351,82 +2988,65 @@ function apply(ctx, config) {
2351
2988
  }
2352
2989
  }, probeKey);
2353
2990
  }
2354
- });
2355
- ctx.inject(["settings"], (settingsCtx) => {
2356
- /** Section sources; each falls back to its own slice when its side unloads. */
2357
- const sources = {
2358
- cn: () => config,
2359
- global: () => config,
2360
- quota: () => config
2361
- };
2362
- /** Merge all sections into the whole config the rest of the plugin reads. */
2363
- const merged = () => ({
2364
- ...pickFields(sources.cn, CN_SECTION_KEYS),
2365
- ...pickFields(sources.global, GLOBAL_SECTION_KEYS),
2366
- ...pickFields(sources.quota, QUOTA_SECTION_KEYS)
2367
- });
2368
- const applyCatalogSettings = (next) => {
2369
- let changed = false;
2370
- for (const runtime of runtimes) {
2371
- const isChina = runtime.variant.id === CHINA_VARIANT.id;
2372
- const preference = isChina ? next.useMaximumContextWindowCN : next.useMaximumContextWindow;
2373
- if (runtime.catalog.setUseMaximumContextWindow(preference === true)) changed = true;
2374
- const overrides = isChina ? next.modelContextWindowsCN : next.modelContextWindows;
2375
- if (overrides !== void 0 && runtime.catalog.setModelContextWindows(overrides)) changed = true;
2376
- const disabled = isChina ? next.disabledModelsCN : next.disabledModels;
2377
- if (runtime.catalog.setDisabledModels(disabled ?? [])) changed = true;
2378
- }
2379
- if (changed) for (const runtime of runtimes) runtime.invalidate();
2380
- };
2381
- const repointStores = () => {
2382
- applyCatalogSettings(merged());
2383
- };
2384
- settingsCtx.settings.installSection(ctx, QODER_SETTINGS_NS, CHINA_SECTION, config, {
2385
- setSource(source) {
2386
- sources.cn = source;
2387
- current = merged;
2388
- },
2389
- onChange: repointStores
2390
- });
2391
- settingsCtx.settings.installSection(ctx, QODER_GLOBAL_SETTINGS_NS, GLOBAL_SECTION, config, {
2392
- setSource(source) {
2393
- sources.global = source;
2394
- current = merged;
2395
- },
2396
- onChange: repointStores
2397
- });
2398
- settingsCtx.settings.installSection(ctx, QODER_QUOTA_SETTINGS_NS, QUOTA_SECTION, config, {
2399
- setSource(source) {
2400
- sources.quota = source;
2401
- current = merged;
2402
- checkInScheduler.rearm();
2991
+ registerQoderSettingsFace(webCtx, {
2992
+ store,
2993
+ config,
2994
+ current,
2995
+ apply: applyCatalogSettings,
2996
+ rearm: () => {
2403
2997
  runStartupCatchUpOnce();
2404
- },
2405
- onChange: () => {
2406
2998
  checkInScheduler.rearm();
2407
- checkInScheduler.sweepAll(true);
2408
2999
  }
2409
3000
  });
3001
+ });
3002
+ ctx.inject(["settings"], (settingsCtx) => {
3003
+ /**
3004
+ * `auto: false` turns OFF the host's generated form page for this entry —
3005
+ * the plugin ships its own card, which is now the only editor. It must be
3006
+ * called as a METHOD: on 0.1.7 `configure` is a SettingsForms class method
3007
+ * (it reads `this.presentations`), and extracting it as a free function
3008
+ * throws a TypeError that used to kill the whole settings callback.
3009
+ */
3010
+ const settingsService = settingsCtx.settings;
3011
+ if (typeof settingsService.configure === "function") try {
3012
+ settingsCtx.effect(() => {
3013
+ const dispose = settingsService.configure({ auto: false }, ctx.fiber);
3014
+ return typeof dispose === "function" ? dispose : () => {};
3015
+ });
3016
+ } catch (error) {
3017
+ console.error("[dsh-qoder-connect] settings.configure failed (own settings file still serves):", error);
3018
+ }
3019
+ /**
3020
+ * The four setters, now one implementation on both hosts: write the
3021
+ * plugin-owned settings file, then apply the new view in memory. No
3022
+ * profile-patch write means no tree reconcile, no fiber reload, and no
3023
+ * client-mirror storm per toggle.
3024
+ */
3025
+ const write = (patch) => {
3026
+ store.patch(patch, true);
3027
+ applyCatalogSettings(current());
3028
+ checkInScheduler.rearm();
3029
+ };
2410
3030
  setMaximumContextWindow = async (enabled) => {
2411
- await settingsCtx.settings.update(QODER_GLOBAL_SETTINGS_NS, { useMaximumContextWindow: enabled });
3031
+ write({ useMaximumContextWindow: enabled });
2412
3032
  return { state: "updated" };
2413
3033
  };
2414
3034
  setMaximumContextWindowCN = async (enabled) => {
2415
- await settingsCtx.settings.update(QODER_SETTINGS_NS, { useMaximumContextWindowCN: enabled });
3035
+ write({ useMaximumContextWindowCN: enabled });
2416
3036
  return { state: "updated" };
2417
3037
  };
2418
3038
  setModelsEnabled = async ({ models, enabled }) => {
2419
- const currentDisabled = new Set(sources.global().disabledModels ?? []);
3039
+ const currentDisabled = new Set(readConfigField(current(), "disabledModels") ?? []);
2420
3040
  for (const m of models) if (enabled) currentDisabled.delete(m);
2421
3041
  else currentDisabled.add(m);
2422
- await settingsCtx.settings.update(QODER_GLOBAL_SETTINGS_NS, { disabledModels: Array.from(currentDisabled) });
3042
+ write({ disabledModels: Array.from(currentDisabled) });
2423
3043
  return { state: "updated" };
2424
3044
  };
2425
3045
  setModelsEnabledCN = async ({ models, enabled }) => {
2426
- const currentDisabled = new Set(sources.cn().disabledModelsCN ?? []);
3046
+ const currentDisabled = new Set(readConfigField(current(), "disabledModelsCN") ?? []);
2427
3047
  for (const m of models) if (enabled) currentDisabled.delete(m);
2428
3048
  else currentDisabled.add(m);
2429
- await settingsCtx.settings.update(QODER_SETTINGS_NS, { disabledModelsCN: Array.from(currentDisabled) });
3049
+ write({ disabledModelsCN: Array.from(currentDisabled) });
2430
3050
  return { state: "updated" };
2431
3051
  };
2432
3052
  });
@@ -2492,6 +3112,7 @@ function apply(ctx, config) {
2492
3112
  }
2493
3113
  if (stopped || runtime.catalogGeneration !== generation) return;
2494
3114
  runtime.lastFetchAtMs = Date.now();
3115
+ lastLiveFetchAt.set(identity, runtime.lastFetchAtMs);
2495
3116
  runtime.catalog.set([...models]);
2496
3117
  runtime.catalogSource = "live";
2497
3118
  runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
@@ -2544,7 +3165,8 @@ function apply(ctx, config) {
2544
3165
  const identity = credentialIdentity(credential);
2545
3166
  if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
2546
3167
  const stale = runtime.catalogSource !== "live";
2547
- const due = Date.now() - runtime.lastFetchAtMs >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
3168
+ const fetchedAt = lastLiveFetchAt.get(identity) ?? 0;
3169
+ const due = Date.now() - fetchedAt >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
2548
3170
  if (stale && due) await fetchCatalog(runtime, identity);
2549
3171
  return;
2550
3172
  }
@@ -2562,7 +3184,18 @@ function apply(ctx, config) {
2562
3184
  * would be an unhandled rejection — which Node turns into process
2563
3185
  * termination, taking the whole Harness down over one plugin's startup.
2564
3186
  */
2565
- detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime))).then(() => {
3187
+ detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime, async () => {
3188
+ if (stopped) return;
3189
+ let credential;
3190
+ try {
3191
+ credential = await runtime.store.current();
3192
+ } catch {
3193
+ return;
3194
+ }
3195
+ if (credential === void 0) return;
3196
+ const identity = credentialIdentity(credential);
3197
+ if (lastIdentities.get(runtime.variant.id) !== identity || !runtime.catalog.isVisible()) adoptIdentity(runtime, identity);
3198
+ }))).then(() => {
2566
3199
  if (stopped) return;
2567
3200
  if (runtimes.some((runtime) => runtime.registered)) detach(ctx, writeHostHeartbeat(), "host heartbeat write");
2568
3201
  detach(ctx, syncAll(), "credential sweep");