dsh-qoder-connect 0.1.5 → 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-BEiQve5M.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
  }
@@ -1049,7 +1063,8 @@ async function qoderWebStatus(deps) {
1049
1063
  ...statusWithModels,
1050
1064
  probe: deps.probe(),
1051
1065
  ...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey },
1052
- ...deps.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: deps.useMaximumContextWindow() }
1066
+ ...deps.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: deps.useMaximumContextWindow() },
1067
+ ...deps.disabledModels === void 0 ? {} : { disabledModels: deps.disabledModels() }
1053
1068
  };
1054
1069
  const refreshedAt = deps.jobTokenRefreshedAt?.();
1055
1070
  const withRefreshNotice = refreshedAt === void 0 ? probed : {
@@ -1181,6 +1196,20 @@ function parseAction(text) {
1181
1196
  action: "set-maximum-context-window",
1182
1197
  enabled: wrapped["enabled"]
1183
1198
  } : void 0;
1199
+ if (action === "set-models-enabled") {
1200
+ if (typeof wrapped["enabled"] !== "boolean") return void 0;
1201
+ const rawModels = wrapped["models"];
1202
+ const rawModel = wrapped["model"];
1203
+ let models = [];
1204
+ if (Array.isArray(rawModels)) models = rawModels.filter((m) => typeof m === "string" && m.trim() !== "").map((m) => m.trim());
1205
+ else if (typeof rawModel === "string" && rawModel.trim() !== "") models = [rawModel.trim()];
1206
+ if (models.length === 0) return void 0;
1207
+ return {
1208
+ action: "set-models-enabled",
1209
+ models,
1210
+ enabled: wrapped["enabled"]
1211
+ };
1212
+ }
1184
1213
  if (action === "probe") {
1185
1214
  const model = wrapped["model"];
1186
1215
  if (typeof model !== "string" || model.trim() === "") return void 0;
@@ -1253,6 +1282,17 @@ function qoderProbeHandler(deps, key) {
1253
1282
  json(res, 200, await deps.setMaximumContextWindow(action.enabled === true));
1254
1283
  return;
1255
1284
  }
1285
+ if (action.action === "set-models-enabled") {
1286
+ if (deps.setModelsEnabled === void 0) {
1287
+ json(res, 404, { error: "models-enabled-setting-not-supported" });
1288
+ return;
1289
+ }
1290
+ json(res, 200, await deps.setModelsEnabled({
1291
+ models: action.models ?? (action.model ? [action.model] : []),
1292
+ enabled: action.enabled === true
1293
+ }));
1294
+ return;
1295
+ }
1256
1296
  json(res, 200, await deps.probe(action.model));
1257
1297
  } catch (error) {
1258
1298
  json(res, 500, { error: error instanceof Error ? error.message : String(error) });
@@ -1336,14 +1376,25 @@ function getUtc8DateString(nowMs = Date.now()) {
1336
1376
  const utc8 = new Date(d.getTime() + (d.getTimezoneOffset() + 480) * 6e4);
1337
1377
  return `${utc8.getFullYear()}-${String(utc8.getMonth() + 1).padStart(2, "0")}-${String(utc8.getDate()).padStart(2, "0")}`;
1338
1378
  }
1379
+ /** Clamp any stored/typed value onto a real minute of the day. */
1380
+ function normalizeCheckInMinute(value) {
1381
+ if (typeof value !== "number" || !Number.isFinite(value)) return 600;
1382
+ const whole = Math.trunc(value);
1383
+ if (whole < 0 || whole > 1439) return 600;
1384
+ return whole;
1385
+ }
1339
1386
  /**
1340
- * Calculates milliseconds until the next 10:00:05 AM in UTC+8.
1387
+ * Calculates milliseconds until the next occurrence of `minuteOfDay` (UTC+8).
1388
+ *
1389
+ * Five seconds past the configured minute are used so the request lands after
1390
+ * the upstream has flipped the day over rather than on the boundary itself.
1341
1391
  */
1342
- function msUntilNext10amUtc8(nowMs = Date.now()) {
1392
+ function msUntilNextCheckIn(minuteOfDay, nowMs = Date.now()) {
1393
+ const minute = normalizeCheckInMinute(minuteOfDay);
1343
1394
  const d = new Date(nowMs);
1344
1395
  const utc8Time = new Date(d.getTime() + (d.getTimezoneOffset() + 480) * 6e4);
1345
1396
  const targetUtc8 = new Date(utc8Time.getTime());
1346
- targetUtc8.setHours(10, 0, 5, 0);
1397
+ targetUtc8.setHours(Math.floor(minute / 60), minute % 60, 5, 0);
1347
1398
  let diff = targetUtc8.getTime() - utc8Time.getTime();
1348
1399
  if (diff <= 0) {
1349
1400
  targetUtc8.setDate(targetUtc8.getDate() + 1);
@@ -1352,13 +1403,13 @@ function msUntilNext10amUtc8(nowMs = Date.now()) {
1352
1403
  return diff;
1353
1404
  }
1354
1405
  /**
1355
- * Checks whether catch-up is needed today:
1356
- * Current time is past today's 10:00:00 AM (UTC+8) and today has not yet settled a check-in.
1406
+ * Whether today's configured check-in moment (UTC+8) has already passed.
1357
1407
  */
1358
- function shouldCatchUp(today, lastDate, nowMs = Date.now()) {
1359
- if (lastDate === today) return false;
1408
+ function isPastCheckInTime(minuteOfDay, nowMs = Date.now()) {
1409
+ const minute = normalizeCheckInMinute(minuteOfDay);
1360
1410
  const d = new Date(nowMs);
1361
- return new Date(d.getTime() + (d.getTimezoneOffset() + 480) * 6e4).getHours() >= 10;
1411
+ const utc8 = new Date(d.getTime() + (d.getTimezoneOffset() + 480) * 6e4);
1412
+ return utc8.getHours() * 60 + utc8.getMinutes() >= minute;
1362
1413
  }
1363
1414
  var CheckInScheduler = class {
1364
1415
  targets;
@@ -1366,7 +1417,21 @@ var CheckInScheduler = class {
1366
1417
  store;
1367
1418
  onResult;
1368
1419
  now;
1369
- timer;
1420
+ /**
1421
+ * One timer per enabled variant, keyed by variant id.
1422
+ *
1423
+ * Per-variant rather than one shared timer because the two products may be
1424
+ * configured to different moments; a single timer would have to wake for the
1425
+ * earliest and then decide who was due, which is the same bookkeeping with a
1426
+ * worse failure mode.
1427
+ */
1428
+ timers = /* @__PURE__ */ new Map();
1429
+ /**
1430
+ * Variants with a sweep in progress, so concurrent callers cannot double-claim.
1431
+ */
1432
+ inFlight = /* @__PURE__ */ new Set();
1433
+ /** When each variant's timer is next due, epoch ms, for the card to show. */
1434
+ nextRuns = /* @__PURE__ */ new Map();
1370
1435
  disposed = false;
1371
1436
  constructor(options) {
1372
1437
  this.targets = options.targets;
@@ -1378,56 +1443,113 @@ var CheckInScheduler = class {
1378
1443
  start() {
1379
1444
  if (this.disposed) return;
1380
1445
  this.sweepAll(true);
1381
- this.armNextTimer();
1446
+ this.rearm();
1447
+ }
1448
+ /**
1449
+ * Re-run the startup catch-up once the toggles are actually readable.
1450
+ *
1451
+ * `start()` runs while the plugin is still assembling, before the settings
1452
+ * section that owns these toggles has handed over its stored values, so that
1453
+ * first sweep sees the raw plugin config and skips the day. The host calls
1454
+ * this again from the section's source callback; the sweep is idempotent, so
1455
+ * a day already handled costs nothing.
1456
+ */
1457
+ catchUp() {
1458
+ if (this.disposed) return;
1459
+ this.sweepAll(true);
1382
1460
  }
1383
1461
  dispose() {
1384
1462
  this.disposed = true;
1385
- if (this.timer) {
1386
- clearTimeout(this.timer);
1387
- this.timer = void 0;
1388
- }
1463
+ for (const timer of this.timers.values()) clearTimeout(timer);
1464
+ this.timers.clear();
1389
1465
  }
1390
- armNextTimer() {
1466
+ /**
1467
+ * Re-place every variant's timer.
1468
+ *
1469
+ * Called after each fire and whenever configuration lands or changes, so a
1470
+ * user editing the time on the card does not have to restart DSH for it to
1471
+ * take effect.
1472
+ *
1473
+ * A timer is placed for every target, including variants whose toggle is
1474
+ * currently off. `start()` runs while the plugin is still assembling, when
1475
+ * the stored toggles are not readable yet, so gating placement on
1476
+ * `isEnabled` left a variant with NO timer at all — and nothing re-armed it
1477
+ * afterwards, which is precisely how a configured 12:13 check-in never
1478
+ * fired. Whether the work is due is decided inside the sweep, where the
1479
+ * configuration is current; the timer only decides when to look.
1480
+ */
1481
+ rearm() {
1391
1482
  if (this.disposed) return;
1392
- const delay = msUntilNext10amUtc8(this.now());
1393
- this.timer = setTimeout(() => {
1394
- this.sweepAll(false);
1395
- this.armNextTimer();
1396
- }, delay);
1397
- this.timer.unref?.();
1483
+ for (const timer of this.timers.values()) clearTimeout(timer);
1484
+ this.timers.clear();
1485
+ this.nextRuns.clear();
1486
+ const nowMs = this.now();
1487
+ for (const target of this.targets) {
1488
+ const delay = msUntilNextCheckIn(target.minuteOfDay(), nowMs);
1489
+ this.nextRuns.set(target.variantId, nowMs + delay);
1490
+ const timer = setTimeout(() => {
1491
+ this.timers.delete(target.variantId);
1492
+ this.sweepAll(false, target.variantId).finally(() => {
1493
+ this.rearm();
1494
+ });
1495
+ }, delay);
1496
+ timer.unref?.();
1497
+ this.timers.set(target.variantId, timer);
1498
+ }
1398
1499
  }
1399
- async sweepAll(isCatchUp) {
1500
+ /** When this variant's timer is next due, epoch ms; absent before first arm. */
1501
+ nextRunAt(variantId) {
1502
+ return this.nextRuns.get(variantId);
1503
+ }
1504
+ async sweepAll(isCatchUp, only) {
1400
1505
  if (this.disposed) return;
1401
1506
  const nowMs = this.now();
1402
1507
  const today = getUtc8DateString(nowMs);
1403
1508
  for (const target of this.targets) {
1509
+ if (only !== void 0 && target.variantId !== only) continue;
1404
1510
  if (!this.isEnabled(target.variantId)) continue;
1405
- const record = this.store.read(target.variantId);
1406
- if (isCatchUp && !shouldCatchUp(today, record?.lastDate, nowMs)) continue;
1407
- if (!isCatchUp && record?.lastDate === today && (record.status === "claimed" || record.status === "already-claimed")) continue;
1408
- let pat;
1511
+ if (this.inFlight.has(target.variantId)) continue;
1512
+ this.inFlight.add(target.variantId);
1409
1513
  try {
1410
- pat = await target.getPat();
1411
- } catch {
1412
- continue;
1514
+ await this.sweepOne(target, isCatchUp, nowMs, today);
1515
+ } finally {
1516
+ this.inFlight.delete(target.variantId);
1413
1517
  }
1414
- if (!pat) continue;
1415
- try {
1416
- const result = await target.service.checkIn(pat);
1417
- if (result.status !== "error") {
1418
- this.store.write(target.variantId, {
1419
- lastDate: result.date,
1420
- lastAt: result.timestamp,
1421
- status: result.status,
1422
- amount: result.amount,
1423
- message: result.message
1424
- });
1425
- if (result.status === "claimed") target.onClaimed?.();
1426
- }
1427
- this.onResult?.(result);
1428
- } catch {}
1429
1518
  }
1430
1519
  }
1520
+ async sweepOne(target, isCatchUp, nowMs, today) {
1521
+ const record = this.store.read(target.variantId);
1522
+ if (record?.lastDate === today && (record.status === "claimed" || record.status === "already-claimed")) {
1523
+ if (!isCatchUp) this.store.write(target.variantId, {
1524
+ lastDate: today,
1525
+ lastAt: nowMs,
1526
+ status: "already-claimed",
1527
+ ...record.amount === void 0 ? {} : { amount: record.amount },
1528
+ message: "Scheduled check-in ran; today was already claimed"
1529
+ });
1530
+ return;
1531
+ }
1532
+ if (isCatchUp && !isPastCheckInTime(target.minuteOfDay(), nowMs)) return;
1533
+ let result;
1534
+ try {
1535
+ result = await target.checkIn();
1536
+ } catch {
1537
+ return;
1538
+ }
1539
+ try {
1540
+ if (result.status !== "error") {
1541
+ this.store.write(target.variantId, {
1542
+ lastDate: result.date,
1543
+ lastAt: result.timestamp,
1544
+ status: result.status,
1545
+ amount: result.amount,
1546
+ message: result.message
1547
+ });
1548
+ if (result.status === "claimed") target.onClaimed?.();
1549
+ }
1550
+ this.onResult?.(result);
1551
+ } catch {}
1552
+ }
1431
1553
  };
1432
1554
  //#endregion
1433
1555
  //#region src/job-token-hint.ts
@@ -1507,6 +1629,175 @@ function appendHintRow(agent, kind, text) {
1507
1629
  } catch {}
1508
1630
  }
1509
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
1510
1801
  //#region src/index.ts
1511
1802
  /** Stable Cordis plugin name. */
1512
1803
  const name = "llm-qoder";
@@ -1586,21 +1877,99 @@ function credentialPollMs() {
1586
1877
  * stay in proportion under the `DSH_QODER_POLL_MS` override.
1587
1878
  */
1588
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
+ }
1589
1947
  /** Probe authorization (shared by the plugin schema and the China section). */
1590
- const PROBE_CONSENT_FIELD = z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
1591
- 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)"));
1592
1950
  /** The China variant's own maximum-window preference. */
1593
- 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)"));
1594
1952
  /**
1595
1953
  * Per-model window overrides (model id → tokens). The card writes one entry
1596
1954
  * per selector change; the field is optional so an absent map means "no
1597
1955
  * overrides" rather than a default object the host must keep in sync.
1598
1956
  */
1599
- 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)"));
1958
+ /** Disabled models list (blacklist) */
1959
+ const DISABLED_MODELS_FIELD = volatileField(z.array(z.string()).default([]).description("Models hidden from the provider catalog and picker"));
1600
1960
  /** Sidebar quota toggle (one per variant; both live on the shared quota card). */
1601
- 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)"));
1602
1962
  /** Automatic check-in toggle. */
1603
- const AUTO_CHECK_IN_FIELD = z.boolean().default(false).description("每天 10:00 (UTC+8) 自动签到领取算力额度(默认关闭)");
1963
+ const AUTO_CHECK_IN_FIELD = volatileField(z.boolean().default(false).description("每天自动签到领取算力额度(默认关闭)"));
1964
+ /**
1965
+ * When a variant checks in, as minutes past midnight in UTC+8.
1966
+ *
1967
+ * Stored as a plain minute count rather than a "HH:mm" string so the schema
1968
+ * itself rejects an impossible time: a browser time input yields 0–1439, and
1969
+ * anything outside that range fails Host validation instead of silently
1970
+ * scheduling a request at a moment that never arrives.
1971
+ */
1972
+ const CHECK_IN_MINUTE_FIELD = volatileField(z.number().default(600).min(0).max(1439).description("每日自动签到的时刻(自 UTC+8 午夜起的分钟数,600 = 10:00)"));
1604
1973
  /**
1605
1974
  * Quota poll interval: default 5 minutes, floor 1 minute. The status route
1606
1975
  * performs a live upstream billing call per request with no cache, so an
@@ -1610,52 +1979,106 @@ const AUTO_CHECK_IN_FIELD = z.boolean().default(false).description("每天 10:00
1610
1979
  */
1611
1980
  const QUOTA_POLL_DEFAULT_MS = 3e5;
1612
1981
  const QUOTA_POLL_MIN_MS = 6e4;
1613
- 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)"));
1614
1983
  const Config = z.object({
1615
1984
  probeConsent: PROBE_CONSENT_FIELD,
1616
1985
  useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
1617
1986
  useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
1618
1987
  modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
1619
1988
  modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
1989
+ disabledModels: DISABLED_MODELS_FIELD,
1990
+ disabledModelsCN: DISABLED_MODELS_FIELD,
1620
1991
  sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
1621
1992
  sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
1622
1993
  autoCheckInCN: AUTO_CHECK_IN_FIELD,
1623
1994
  autoCheckInGlobal: AUTO_CHECK_IN_FIELD,
1995
+ checkInMinuteCN: CHECK_IN_MINUTE_FIELD,
1996
+ checkInMinuteGlobal: CHECK_IN_MINUTE_FIELD,
1624
1997
  quotaPollMs: QUOTA_POLL_FIELD
1625
1998
  });
1626
- /**
1627
- * The China card's settings section: only the fields that card edits.
1628
- *
1629
- * A section is what makes its namespace "served", which is what the Plugins
1630
- * tab dispatches a card by — so the schema and the card must stay split the
1631
- * same way. `probeConsent` lives here because it predates the second variant;
1632
- * it gates no current code path (only manual, per-click-confirmed probes run),
1633
- * so it is left where existing users set it rather than moved and re-asked.
1634
- */
1635
- const CHINA_SECTION = z.object({
1999
+ z.object({
1636
2000
  probeConsent: PROBE_CONSENT_FIELD,
1637
2001
  useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
1638
- modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD
2002
+ modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
2003
+ disabledModelsCN: DISABLED_MODELS_FIELD
1639
2004
  });
1640
- /** The global card's settings section and its context-window preferences. */
1641
- const GLOBAL_SECTION = z.object({
2005
+ z.object({
1642
2006
  useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
1643
- modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD
2007
+ modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
2008
+ disabledModels: DISABLED_MODELS_FIELD
1644
2009
  });
1645
- /**
1646
- * The shared quota card's section: both sidebar toggles and the poll interval.
1647
- *
1648
- * Only these fields — the card edits nothing else, and the Plugins tab pairs a
1649
- * card with the section whose namespace it names, so a stray field here would
1650
- * render as a control no other surface reads.
1651
- */
1652
- const QUOTA_SECTION = z.object({
2010
+ z.object({
1653
2011
  sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
1654
2012
  sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
1655
2013
  autoCheckInCN: AUTO_CHECK_IN_FIELD,
1656
2014
  autoCheckInGlobal: AUTO_CHECK_IN_FIELD,
2015
+ checkInMinuteCN: CHECK_IN_MINUTE_FIELD,
2016
+ checkInMinuteGlobal: CHECK_IN_MINUTE_FIELD,
1657
2017
  quotaPollMs: QUOTA_POLL_FIELD
1658
2018
  });
2019
+ /**
2020
+ * Every field each settings section owns, and therefore every field the live
2021
+ * configuration has to carry through.
2022
+ *
2023
+ * One list per section, shared by the merge and by the test that pins it to
2024
+ * the schema. Writing the merge out by hand is what broke auto check-in: the
2025
+ * `qoder-quota` section grew four fields (`autoCheckInCN`, `autoCheckInGlobal`,
2026
+ * `checkInMinuteCN`, `checkInMinuteGlobal`) while the merge kept copying only
2027
+ * the three that predated them, so `current().autoCheckInCN` read `undefined`
2028
+ * forever and the scheduler saw the toggle as permanently off. The card saved
2029
+ * it, the file held it, and nothing ever acted on it.
2030
+ */
2031
+ const CN_SECTION_KEYS = [
2032
+ "probeConsent",
2033
+ "useMaximumContextWindowCN",
2034
+ "modelContextWindowsCN",
2035
+ "disabledModelsCN"
2036
+ ];
2037
+ const GLOBAL_SECTION_KEYS = [
2038
+ "useMaximumContextWindow",
2039
+ "modelContextWindows",
2040
+ "disabledModels"
2041
+ ];
2042
+ const QUOTA_SECTION_KEYS = [
2043
+ "sidebarQuotaCN",
2044
+ "sidebarQuotaGlobal",
2045
+ "autoCheckInCN",
2046
+ "autoCheckInGlobal",
2047
+ "checkInMinuteCN",
2048
+ "checkInMinuteGlobal",
2049
+ "quotaPollMs"
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
+ ];
2057
+ /** Copy the declared fields off one section's source, skipping absent ones. */
2058
+ function pickFields(source, keys) {
2059
+ const value = source();
2060
+ const picked = {};
2061
+ for (const key of keys) {
2062
+ const field = readField(value, key);
2063
+ if (field !== void 0) picked[key] = field;
2064
+ }
2065
+ return picked;
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
+ }
1659
2082
  /** Stable identity key used by credentials, probe records, and catalog entries. */
1660
2083
  const credentialIdentity = qoderCredentialIdentity;
1661
2084
  /** The settings namespace a variant's card and provider directory entry use. */
@@ -1701,10 +2124,23 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
1701
2124
  logger: ctx.logger
1702
2125
  });
1703
2126
  let jobTokenRefreshedAt;
2127
+ const resolveAttachmentService = () => {
2128
+ const service = ctx.get("attachments");
2129
+ if (service === void 0) throw new Error("dsh-qoder-connect: no attachment service is available");
2130
+ return service;
2131
+ };
2132
+ const attachments = {
2133
+ get imageLimits() {
2134
+ return resolveAttachmentService().imageLimits;
2135
+ },
2136
+ readImageRequest: async (attachment, policy, signal) => await resolveAttachmentService().readImageRequest(attachment, policy, signal),
2137
+ saveImage: async (request) => await resolveAttachmentService().saveImage(request)
2138
+ };
1704
2139
  const transport = createQoderTransport({
1705
2140
  region: variant.region,
1706
2141
  resolvePat: () => store.patPromise(),
1707
2142
  resolveMachineId: () => getMachineId([qoderMachineIdPath()]),
2143
+ attachments,
1708
2144
  onJobTokenRefreshed: (info) => {
1709
2145
  jobTokenRefreshedAt = info.at;
1710
2146
  ctx.logger.warn(`dsh-qoder-connect: ${variant.displayName} job token was auto-refreshed after an upstream rejection`);
@@ -1720,18 +2156,18 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
1720
2156
  providerId: variant.id,
1721
2157
  getPat: () => store.patPromise(),
1722
2158
  transport,
1723
- attachments: { saveImage: async (request) => {
1724
- const service = ctx.get("attachments");
1725
- if (service === void 0) throw new Error("dsh-qoder-connect: no attachment service is available to store images");
1726
- return await service.saveImage(request);
1727
- } }
2159
+ attachments
1728
2160
  });
1729
2161
  const fallback = fallbackFor(variant);
1730
2162
  const catalog = new QoderCatalog(fallback);
1731
- if (variant.id !== CHINA_VARIANT.id) catalog.setUseMaximumContextWindow(config.useMaximumContextWindow === true);
1732
- else {
1733
- catalog.setUseMaximumContextWindow(config.useMaximumContextWindowCN === true);
1734
- if (config.modelContextWindowsCN !== void 0) catalog.setModelContextWindows(config.modelContextWindowsCN);
2163
+ const initial = current();
2164
+ if (variant.id !== CHINA_VARIANT.id) {
2165
+ catalog.setUseMaximumContextWindow(initial.useMaximumContextWindow === true);
2166
+ if (initial.disabledModels !== void 0) catalog.setDisabledModels(initial.disabledModels);
2167
+ } else {
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);
1735
2171
  }
1736
2172
  catalog.setVisible(false);
1737
2173
  const probeStore = new QoderProbeStore({
@@ -1764,7 +2200,8 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
1764
2200
  inflightFetch: void 0,
1765
2201
  invalidate: () => {},
1766
2202
  registered: false,
1767
- jobTokenRefreshedAt: () => jobTokenRefreshedAt
2203
+ jobTokenRefreshedAt: () => jobTokenRefreshedAt,
2204
+ checkIn: (signal) => transport.checkIn(signal)
1768
2205
  };
1769
2206
  }
1770
2207
  /** The catalog provenance the card displays. */
@@ -1833,9 +2270,11 @@ function detach(ctx, work, what) {
1833
2270
  * models read the shim origin at construction time. A failure here is
1834
2271
  * contained to this variant: the caller logs it and the other keeps working.
1835
2272
  *
2273
+ * @param seedCatalog - adopt the current credential into this runtime's
2274
+ * catalog immediately; supplied by `apply` (it owns `adoptIdentity`).
1836
2275
  * @returns whether the provider registered.
1837
2276
  */
1838
- async function startVariant(ctx, runtime) {
2277
+ async function startVariant(ctx, runtime, seedCatalog) {
1839
2278
  const { variant, store, client, catalog, probeService } = runtime;
1840
2279
  const shim = createQoderShim({
1841
2280
  store,
@@ -1868,10 +2307,19 @@ async function startVariant(ctx, runtime) {
1868
2307
  let releaseDirectory;
1869
2308
  try {
1870
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);
1871
2319
  releaseDirectory = ctx.llm.registerConfigurableProviders([{
1872
2320
  provider: variant.id,
1873
2321
  displayName: variant.displayName,
1874
- settingsNs: settingsNamespaceFor(variant),
2322
+ settingsNs,
1875
2323
  settingsPath: [],
1876
2324
  declared: false
1877
2325
  }]);
@@ -1893,6 +2341,7 @@ async function startVariant(ctx, runtime) {
1893
2341
  detach(ctx, shim.close(), "loopback endpoint close");
1894
2342
  }
1895
2343
  runtime.registered = true;
2344
+ detach(ctx, seedCatalog(), `${variant.id} catalog seed`);
1896
2345
  return true;
1897
2346
  } catch (error) {
1898
2347
  ctx.logger.error(`dsh-qoder-connect: ${variant.displayName} provider registration failed`, error);
@@ -1901,6 +2350,305 @@ async function startVariant(ctx, runtime) {
1901
2350
  }
1902
2351
  }
1903
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
+ /**
1904
2652
  * Start both variants: their loopback endpoints, the `qoder` and
1905
2653
  * `qoder-global` providers, their configuration cards, and their
1906
2654
  * credential-driven catalog lifecycles.
@@ -1911,23 +2659,82 @@ async function startVariant(ctx, runtime) {
1911
2659
  * without re-registering the provider.
1912
2660
  */
1913
2661
  function apply(ctx, config) {
1914
- 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
+ });
1915
2725
  /** Timers and in-flight work belonging to this plugin instance. */
1916
2726
  let stopped = false;
1917
2727
  const timers = [];
1918
- /**
1919
- * The account identity each variant last published a catalog for. Keeps a
1920
- * same-token sweep from re-fetching, and lets a late response from a
1921
- * previous identity be discarded instead of overwriting a newer one.
1922
- */
1923
- const lastIdentities = /* @__PURE__ */ new Map();
1924
2728
  const runtimes = QODER_VARIANTS.map((variant) => createVariantRuntime(ctx, config, variant, () => current(), (id) => lastIdentities.get(id)));
1925
2729
  const checkInStore = new JsonFileCheckInStore();
1926
2730
  const checkInScheduler = new CheckInScheduler({
1927
2731
  targets: runtimes.map((runtime) => ({
1928
2732
  variantId: runtime.variant.id,
1929
- service: runtime.transport.checkInService,
1930
- getPat: async () => runtime.store.patPromise(),
2733
+ checkIn: (signal) => runtime.checkIn(signal),
2734
+ minuteOfDay: () => {
2735
+ const cfg = current();
2736
+ return normalizeCheckInMinute((runtime.variant.id === CHINA_VARIANT.id ? cfg.checkInMinuteCN : cfg.checkInMinuteGlobal) ?? 600);
2737
+ },
1931
2738
  onClaimed: () => {
1932
2739
  runtime.client.fetchCredits().catch(() => void 0);
1933
2740
  }
@@ -1940,20 +2747,26 @@ function apply(ctx, config) {
1940
2747
  store: checkInStore
1941
2748
  });
1942
2749
  checkInScheduler.start();
1943
- installJobTokenHint(ctx);
1944
- const probeKey = createProbeKey();
1945
2750
  /**
1946
- * The in-process key authorizing PAT writes, minted separately from the
1947
- * probe key.
2751
+ * The startup catch-up, run once more from the settings source callback.
1948
2752
  *
1949
- * Separate keys rather than one shared secret because the two authorize
1950
- * different powers: one spends credit on a probe, the other stores a
1951
- * credential. A single key handed to both would let a defect in either card
1952
- * reach the other's authority.
2753
+ * `start()` above runs while the plugin is still assembling: the section
2754
+ * that stores these toggles has not handed over its values yet, so that
2755
+ * sweep reads the raw plugin config, sees no toggle, and skips the day. The
2756
+ * retry is what makes "boot after 10:00" actually claim.
1953
2757
  */
1954
- const authKey = createAuthKey();
2758
+ let startupCatchUpDone = false;
2759
+ const runStartupCatchUpOnce = () => {
2760
+ if (startupCatchUpDone) return;
2761
+ startupCatchUpDone = true;
2762
+ checkInScheduler.catchUp();
2763
+ };
2764
+ installJobTokenHint(ctx);
2765
+ const { probe: probeKey, auth: authKey } = controlKeys();
1955
2766
  let setMaximumContextWindow;
1956
2767
  let setMaximumContextWindowCN;
2768
+ let setModelsEnabled;
2769
+ let setModelsEnabledCN;
1957
2770
  /**
1958
2771
  * Point a variant at an account identity, invalidating whatever the previous
1959
2772
  * one left behind.
@@ -1977,7 +2790,7 @@ function apply(ctx, config) {
1977
2790
  const adoptIdentity = (runtime, identity) => {
1978
2791
  const id = runtime.variant.id;
1979
2792
  const known = lastIdentities.get(id);
1980
- if (known === identity) return;
2793
+ if (known === identity && runtime.catalog.isVisible()) return;
1981
2794
  const hadCredential = known !== void 0;
1982
2795
  if (identity === void 0) lastIdentities.delete(id);
1983
2796
  else lastIdentities.set(id, identity);
@@ -2011,20 +2824,54 @@ function apply(ctx, config) {
2011
2824
  runtime.catalog.setVisible(true);
2012
2825
  runtime.invalidate();
2013
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
+ };
2014
2847
  ctx.inject(["webServer"], (webCtx) => {
2015
2848
  for (const runtime of runtimes) {
2016
2849
  registerQoderStatusRoute(webCtx, {
2017
2850
  path: runtime.variant.statusPath,
2018
2851
  store: runtime.store,
2019
2852
  client: runtime.client,
2020
- models: () => runtime.catalog.current(),
2853
+ models: () => runtime.catalog.all(),
2021
2854
  catalog: () => catalogSection(runtime),
2022
2855
  probe: () => probeSection(runtime, current().probeConsent === true),
2023
2856
  probeKey,
2024
2857
  authKey,
2025
- ...runtime.variant.id === CHINA_VARIANT.id ? { useMaximumContextWindow: () => current().useMaximumContextWindowCN === true } : { useMaximumContextWindow: () => current().useMaximumContextWindow === true },
2858
+ ...runtime.variant.id === CHINA_VARIANT.id ? {
2859
+ useMaximumContextWindow: () => current().useMaximumContextWindowCN === true,
2860
+ disabledModels: () => current().disabledModelsCN ?? []
2861
+ } : {
2862
+ useMaximumContextWindow: () => current().useMaximumContextWindow === true,
2863
+ disabledModels: () => current().disabledModels ?? []
2864
+ },
2026
2865
  jobTokenRefreshedAt: runtime.jobTokenRefreshedAt,
2027
- checkIn: () => checkInStore.read(runtime.variant.id)
2866
+ checkIn: () => {
2867
+ const record = checkInStore.read(runtime.variant.id);
2868
+ if (record === void 0) return void 0;
2869
+ const nextRunAt = checkInScheduler.nextRunAt(runtime.variant.id);
2870
+ return {
2871
+ ...record,
2872
+ ...nextRunAt === void 0 ? {} : { nextRunAt }
2873
+ };
2874
+ }
2028
2875
  });
2029
2876
  registerQoderAuthRoute(webCtx, {
2030
2877
  path: runtime.variant.authPath,
@@ -2091,16 +2938,7 @@ function apply(ctx, config) {
2091
2938
  checkInStore.clearLogs(runtime.variant.id);
2092
2939
  },
2093
2940
  checkIn: async () => {
2094
- let pat;
2095
- try {
2096
- pat = await runtime.store.patPromise();
2097
- } catch {
2098
- return {
2099
- state: "failed",
2100
- reason: "No PAT available"
2101
- };
2102
- }
2103
- const result = await runtime.transport.checkInService.checkIn(pat);
2941
+ const result = await runtime.checkIn();
2104
2942
  if (result.status !== "error") {
2105
2943
  checkInStore.write(runtime.variant.id, {
2106
2944
  lastDate: result.date,
@@ -2117,81 +2955,98 @@ function apply(ctx, config) {
2117
2955
  ...result.message === void 0 ? {} : { reason: result.message }
2118
2956
  };
2119
2957
  },
2120
- ...runtime.variant.id === CHINA_VARIANT.id ? { setMaximumContextWindow: async (enabled) => {
2121
- if (setMaximumContextWindowCN === void 0) return {
2122
- state: "failed",
2123
- reason: "settings are unavailable"
2124
- };
2125
- return setMaximumContextWindowCN(enabled);
2126
- } } : { setMaximumContextWindow: async (enabled) => {
2127
- if (setMaximumContextWindow === void 0) return {
2128
- state: "failed",
2129
- reason: "settings are unavailable"
2130
- };
2131
- return setMaximumContextWindow(enabled);
2132
- } }
2958
+ ...runtime.variant.id === CHINA_VARIANT.id ? {
2959
+ setMaximumContextWindow: async (enabled) => {
2960
+ if (setMaximumContextWindowCN === void 0) return {
2961
+ state: "failed",
2962
+ reason: "settings are unavailable"
2963
+ };
2964
+ return setMaximumContextWindowCN(enabled);
2965
+ },
2966
+ setModelsEnabled: async (opts) => {
2967
+ if (setModelsEnabledCN === void 0) return {
2968
+ state: "failed",
2969
+ reason: "settings are unavailable"
2970
+ };
2971
+ return setModelsEnabledCN(opts);
2972
+ }
2973
+ } : {
2974
+ setMaximumContextWindow: async (enabled) => {
2975
+ if (setMaximumContextWindow === void 0) return {
2976
+ state: "failed",
2977
+ reason: "settings are unavailable"
2978
+ };
2979
+ return setMaximumContextWindow(enabled);
2980
+ },
2981
+ setModelsEnabled: async (opts) => {
2982
+ if (setModelsEnabled === void 0) return {
2983
+ state: "failed",
2984
+ reason: "settings are unavailable"
2985
+ };
2986
+ return setModelsEnabled(opts);
2987
+ }
2988
+ }
2133
2989
  }, probeKey);
2134
2990
  }
2991
+ registerQoderSettingsFace(webCtx, {
2992
+ store,
2993
+ config,
2994
+ current,
2995
+ apply: applyCatalogSettings,
2996
+ rearm: () => {
2997
+ runStartupCatchUpOnce();
2998
+ checkInScheduler.rearm();
2999
+ }
3000
+ });
2135
3001
  });
2136
3002
  ctx.inject(["settings"], (settingsCtx) => {
2137
- /** Section sources; each falls back to its own slice when its side unloads. */
2138
- const sources = {
2139
- cn: () => config,
2140
- global: () => config,
2141
- quota: () => config
2142
- };
2143
- /** Merge all sections into the whole config the rest of the plugin reads. */
2144
- const merged = () => ({
2145
- ...sources.cn().probeConsent === void 0 ? {} : { probeConsent: sources.cn().probeConsent },
2146
- ...sources.cn().useMaximumContextWindowCN === void 0 ? {} : { useMaximumContextWindowCN: sources.cn().useMaximumContextWindowCN },
2147
- ...sources.cn().modelContextWindowsCN === void 0 ? {} : { modelContextWindowsCN: sources.cn().modelContextWindowsCN },
2148
- ...sources.global().useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: sources.global().useMaximumContextWindow },
2149
- ...sources.global().modelContextWindows === void 0 ? {} : { modelContextWindows: sources.global().modelContextWindows },
2150
- ...sources.quota().sidebarQuotaCN === void 0 ? {} : { sidebarQuotaCN: sources.quota().sidebarQuotaCN },
2151
- ...sources.quota().sidebarQuotaGlobal === void 0 ? {} : { sidebarQuotaGlobal: sources.quota().sidebarQuotaGlobal },
2152
- ...sources.quota().quotaPollMs === void 0 ? {} : { quotaPollMs: sources.quota().quotaPollMs }
2153
- });
2154
- const applyMaximumContextWindow = (next) => {
2155
- let changed = false;
2156
- for (const runtime of runtimes) {
2157
- const isChina = runtime.variant.id === CHINA_VARIANT.id;
2158
- const preference = isChina ? next.useMaximumContextWindowCN : next.useMaximumContextWindow;
2159
- if (runtime.catalog.setUseMaximumContextWindow(preference === true)) changed = true;
2160
- const overrides = isChina ? next.modelContextWindowsCN : next.modelContextWindows;
2161
- if (overrides !== void 0 && runtime.catalog.setModelContextWindows(overrides)) changed = true;
2162
- }
2163
- if (changed) for (const runtime of runtimes) runtime.invalidate();
2164
- };
2165
- const repointStores = () => {
2166
- applyMaximumContextWindow(merged());
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();
2167
3029
  };
2168
- settingsCtx.settings.installSection(ctx, QODER_SETTINGS_NS, CHINA_SECTION, config, {
2169
- setSource(source) {
2170
- sources.cn = source;
2171
- current = merged;
2172
- },
2173
- onChange: repointStores
2174
- });
2175
- settingsCtx.settings.installSection(ctx, QODER_GLOBAL_SETTINGS_NS, GLOBAL_SECTION, config, {
2176
- setSource(source) {
2177
- sources.global = source;
2178
- current = merged;
2179
- },
2180
- onChange: repointStores
2181
- });
2182
- settingsCtx.settings.installSection(ctx, QODER_QUOTA_SETTINGS_NS, QUOTA_SECTION, config, {
2183
- setSource(source) {
2184
- sources.quota = source;
2185
- current = merged;
2186
- },
2187
- onChange: () => {}
2188
- });
2189
3030
  setMaximumContextWindow = async (enabled) => {
2190
- await settingsCtx.settings.update(QODER_GLOBAL_SETTINGS_NS, { useMaximumContextWindow: enabled });
3031
+ write({ useMaximumContextWindow: enabled });
2191
3032
  return { state: "updated" };
2192
3033
  };
2193
3034
  setMaximumContextWindowCN = async (enabled) => {
2194
- await settingsCtx.settings.update(QODER_SETTINGS_NS, { useMaximumContextWindowCN: enabled });
3035
+ write({ useMaximumContextWindowCN: enabled });
3036
+ return { state: "updated" };
3037
+ };
3038
+ setModelsEnabled = async ({ models, enabled }) => {
3039
+ const currentDisabled = new Set(readConfigField(current(), "disabledModels") ?? []);
3040
+ for (const m of models) if (enabled) currentDisabled.delete(m);
3041
+ else currentDisabled.add(m);
3042
+ write({ disabledModels: Array.from(currentDisabled) });
3043
+ return { state: "updated" };
3044
+ };
3045
+ setModelsEnabledCN = async ({ models, enabled }) => {
3046
+ const currentDisabled = new Set(readConfigField(current(), "disabledModelsCN") ?? []);
3047
+ for (const m of models) if (enabled) currentDisabled.delete(m);
3048
+ else currentDisabled.add(m);
3049
+ write({ disabledModelsCN: Array.from(currentDisabled) });
2195
3050
  return { state: "updated" };
2196
3051
  };
2197
3052
  });
@@ -2257,6 +3112,7 @@ function apply(ctx, config) {
2257
3112
  }
2258
3113
  if (stopped || runtime.catalogGeneration !== generation) return;
2259
3114
  runtime.lastFetchAtMs = Date.now();
3115
+ lastLiveFetchAt.set(identity, runtime.lastFetchAtMs);
2260
3116
  runtime.catalog.set([...models]);
2261
3117
  runtime.catalogSource = "live";
2262
3118
  runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
@@ -2309,7 +3165,8 @@ function apply(ctx, config) {
2309
3165
  const identity = credentialIdentity(credential);
2310
3166
  if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
2311
3167
  const stale = runtime.catalogSource !== "live";
2312
- 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;
2313
3170
  if (stale && due) await fetchCatalog(runtime, identity);
2314
3171
  return;
2315
3172
  }
@@ -2327,7 +3184,18 @@ function apply(ctx, config) {
2327
3184
  * would be an unhandled rejection — which Node turns into process
2328
3185
  * termination, taking the whole Harness down over one plugin's startup.
2329
3186
  */
2330
- 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(() => {
2331
3199
  if (stopped) return;
2332
3200
  if (runtimes.some((runtime) => runtime.registered)) detach(ctx, writeHostHeartbeat(), "host heartbeat write");
2333
3201
  detach(ctx, syncAll(), "credential sweep");
@@ -2339,4 +3207,4 @@ function apply(ctx, config) {
2339
3207
  }), "variant startup");
2340
3208
  }
2341
3209
  //#endregion
2342
- export { CHINA_VARIANT, Config, FALLBACK_QODER_MODELS, GLOBAL_VARIANT, KIND_STATUS, PROBE_EFFORT_CANDIDATES, QODER_AUTH_FILENAME, QODER_AUTH_PATH, QODER_DATA_DIR_ENV, QODER_DATA_DIR_NAME, QODER_GLOBAL_AUTH_PATH, QODER_GLOBAL_PROBE_PATH, QODER_GLOBAL_SETTINGS_NS, QODER_GLOBAL_STATUS_PATH, QODER_HOST_HEARTBEAT_FILENAME, QODER_PAT_ENV_CN, QODER_PAT_ENV_GLOBAL, QODER_PROBE_FILENAME, QODER_PROBE_PATH, QODER_PROVIDER, QODER_QUOTA_SETTINGS_NS, QODER_SETTINGS_NS, QODER_STATUS_PATH, QODER_STREAM_IDLE_TIMEOUT_MS, QODER_VARIANTS, QUOTA_POLL_DEFAULT_MS, QUOTA_POLL_MIN_MS, QoderCatalog, QoderCatalogStore, QoderCredentialStore, QoderProbeService, QoderProbeStore, QoderUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createAuthKey, createQoderAdapter, createQoderShim, fingerprintModel, inject, isHeartbeatProcessAlive, kindFromQoderFailure, modelInfoOf, name, normalizeCredits, patTail, probeModel, processStartTimeMs, qoderAuthHandler, qoderCatalogPath, qoderCredentialIdentity, qoderHostHeartbeatPath, qoderOwnAuthPath, qoderPluginDataDir, qoderProbePath, randomSentinel, readHostHeartbeat, registerQoderAuthRoute, variantFor };
3210
+ export { CHINA_VARIANT, CN_SECTION_KEYS, Config, FALLBACK_QODER_MODELS, GLOBAL_SECTION_KEYS, GLOBAL_VARIANT, KIND_STATUS, PROBE_EFFORT_CANDIDATES, QODER_AUTH_FILENAME, QODER_AUTH_PATH, QODER_DATA_DIR_ENV, QODER_DATA_DIR_NAME, QODER_GLOBAL_AUTH_PATH, QODER_GLOBAL_PROBE_PATH, QODER_GLOBAL_SETTINGS_NS, QODER_GLOBAL_STATUS_PATH, QODER_HOST_HEARTBEAT_FILENAME, QODER_PAT_ENV_CN, QODER_PAT_ENV_GLOBAL, QODER_PROBE_FILENAME, QODER_PROBE_PATH, QODER_PROVIDER, QODER_QUOTA_SETTINGS_NS, QODER_SETTINGS_NS, QODER_STATUS_PATH, QODER_STREAM_IDLE_TIMEOUT_MS, QODER_VARIANTS, QUOTA_POLL_DEFAULT_MS, QUOTA_POLL_MIN_MS, QUOTA_SECTION_KEYS, QoderCatalog, QoderCatalogStore, QoderCredentialStore, QoderProbeService, QoderProbeStore, QoderUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createAuthKey, createQoderAdapter, createQoderShim, fingerprintModel, inject, isHeartbeatProcessAlive, kindFromQoderFailure, modelInfoOf, name, normalizeCredits, patTail, probeModel, processStartTimeMs, qoderAuthHandler, qoderCatalogPath, qoderCredentialIdentity, qoderHostHeartbeatPath, qoderOwnAuthPath, qoderPluginDataDir, qoderProbePath, randomSentinel, readHostHeartbeat, registerQoderAuthRoute, variantFor };