dsh-connect-workbuddy 4.13.2 → 4.14.1

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.
@@ -962,6 +962,71 @@ function clearAtRestKeyCache() {
962
962
  cachedAppExecutable = void 0;
963
963
  }
964
964
  //#endregion
965
+ //#region src/native-modality.ts
966
+ /**
967
+ * Reviewed vendor classifications, keyed by EXACT model id (never a prefix).
968
+ * Ids absent from this record are `unknown` — see {@link nativeModalityOf}.
969
+ */
970
+ const NATIVE_MODALITY_BY_MODEL_ID = {
971
+ "deepseek-v4-pro": "text",
972
+ "glm-5.1": "text",
973
+ "glm-5.2": "text",
974
+ "glm-5.3": "text",
975
+ "hy3": "text",
976
+ "hy4-preview": "text",
977
+ "deepseek-v4.1-flash": "multimodal",
978
+ "glm-5.3-flash": "multimodal",
979
+ "glm-5v-turbo": "multimodal",
980
+ "kimi-k2.6": "multimodal",
981
+ "kimi-k2.7": "multimodal",
982
+ "kimi-k3-1": "multimodal",
983
+ "minimax-m3": "multimodal"
984
+ };
985
+ /**
986
+ * Ids the upstream lists in the model roster that are ROUTERS, not models.
987
+ *
988
+ * `auto` (CN) is the one observed today: the gateway resolves it to whichever
989
+ * model it considers the best fit (measured: CN `auto` → `hy4-preview`), so its
990
+ * price, context window and behaviour are decided at request time by something
991
+ * else. Offering it as a row advertises a model whose terms the plugin cannot
992
+ * state, and it duplicates a real row already in the same roster.
993
+ *
994
+ * Kept as an explicit table rather than a heuristic: a router is an upstream
995
+ * concept with no declared flag, and guessing from the name would eventually
996
+ * hide a real model. Add an id here only after the same kind of evidence.
997
+ */
998
+ const ROUTER_MODEL_IDS = /* @__PURE__ */ new Set(["auto"]);
999
+ /** Whether this id is an upstream router rather than a model. */
1000
+ function isRouterModelId(modelId) {
1001
+ return ROUTER_MODEL_IDS.has(modelId);
1002
+ }
1003
+ /**
1004
+ * Native modality of one model id. Exact match only: a new model never
1005
+ * inherits a classification from its family, its display name, or the
1006
+ * platform's image flag.
1007
+ */
1008
+ function nativeModalityOf(modelId) {
1009
+ if (isRouterModelId(modelId)) return "router";
1010
+ return NATIVE_MODALITY_BY_MODEL_ID[modelId] ?? "unknown";
1011
+ }
1012
+ /**
1013
+ * Whether a model refresh should PRE-CHECK this model's image box.
1014
+ *
1015
+ * Only a documented `multimodal` is pre-checked. `text` is documented as
1016
+ * unable to read images, and `unknown` is deliberately left unchecked rather
1017
+ * than guessed — the whole reason this table exists is that the platform flag
1018
+ * guessed wrong. A platform `supportsImages: false` still vetoes, so a
1019
+ * documented-multimodal id can never be pre-checked against the platform's
1020
+ * explicit "no".
1021
+ *
1022
+ * The user can always tick a box by hand; this decides a DEFAULT only, and the
1023
+ * effective runtime flag remains the saved `imageModelIds`.
1024
+ */
1025
+ function imageDefaultFor(info) {
1026
+ if (info.supportsImages === false) return false;
1027
+ return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
1028
+ }
1029
+ //#endregion
965
1030
  //#region src/off-thinking.ts
966
1031
  /**
967
1032
  * Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
@@ -1096,6 +1161,198 @@ function withEffectiveOff(models, selection) {
1096
1161
  });
1097
1162
  }
1098
1163
  //#endregion
1164
+ //#region src/promo.ts
1165
+ /** A record-ish value, or undefined for anything else. */
1166
+ function record(value) {
1167
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
1168
+ }
1169
+ /** A trimmed non-empty string, or undefined. */
1170
+ function text(value) {
1171
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
1172
+ }
1173
+ /**
1174
+ * `'HH:mm'` → minutes since midnight, or undefined for anything else.
1175
+ *
1176
+ * `24:00` is accepted and means exactly 1440 — the END of a day. The upstream
1177
+ * writes "all day" as `0:00`–`23:59` today, which leaves a one-minute hole
1178
+ * between 23:59 and midnight where a standing campaign reads as not running.
1179
+ * Accepting `24:00` lets such a window be expressed (and lets a future upstream
1180
+ * fix it) without that hole; as a START it is rejected, because a day that
1181
+ * begins at 24:00 is the next day and that is a different statement.
1182
+ *
1183
+ * Any other malformed value is left to the caller to drop — a wrong window
1184
+ * would hide a live promotion, and guessing is not this parser's job.
1185
+ */
1186
+ function minutesOfClock(value) {
1187
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
1188
+ if (match === null) return void 0;
1189
+ const hours = Number(match[1]);
1190
+ const minutes = Number(match[2]);
1191
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return void 0;
1192
+ if (minutes > 59) return void 0;
1193
+ if (hours > 24) return void 0;
1194
+ if (hours === 24) return minutes === 0 ? 1440 : void 0;
1195
+ return hours * 60 + minutes;
1196
+ }
1197
+ /** An ISO-8601 instant → epoch milliseconds. Numbers are refused on purpose. */
1198
+ function parseInstant(value) {
1199
+ const raw = text(value);
1200
+ if (raw === void 0) return void 0;
1201
+ const ms = Date.parse(raw);
1202
+ return Number.isNaN(ms) ? void 0 : ms;
1203
+ }
1204
+ /** The upstream's `modelIds` → the model's ids, de-duplicated. */
1205
+ function parseModelIds(value) {
1206
+ if (!Array.isArray(value)) return void 0;
1207
+ const ids = [];
1208
+ for (const entry of value) {
1209
+ const id = text(entry);
1210
+ if (id !== void 0 && !ids.includes(id)) ids.push(id);
1211
+ }
1212
+ return ids.length === 0 ? void 0 : ids;
1213
+ }
1214
+ /** The upstream's `badge` → this plugin's badge shape. */
1215
+ function parseBadge(value) {
1216
+ const raw = record(value);
1217
+ if (raw === void 0) return void 0;
1218
+ const label = text(raw["label"]) ?? text(raw["labelEn"]) ?? text(raw["shortLabel"]) ?? text(raw["shortLabelEn"]);
1219
+ if (label === void 0) return void 0;
1220
+ const labelEn = text(raw["labelEn"]);
1221
+ const shortLabel = text(raw["shortLabel"]);
1222
+ const shortLabelEn = text(raw["shortLabelEn"]);
1223
+ const color = text(raw["color"]);
1224
+ const display = raw["display"] === "activeOnly" ? "activeOnly" : void 0;
1225
+ return {
1226
+ label,
1227
+ ...labelEn === void 0 ? {} : { labelEn },
1228
+ ...shortLabel === void 0 ? {} : { shortLabel },
1229
+ ...shortLabelEn === void 0 ? {} : { shortLabelEn },
1230
+ ...color === void 0 ? {} : { color },
1231
+ ...display === void 0 ? {} : { display }
1232
+ };
1233
+ }
1234
+ /** The upstream's `hover` → this plugin's copy shape. */
1235
+ function parseHover(value) {
1236
+ const raw = record(value);
1237
+ if (raw === void 0) return void 0;
1238
+ const textZh = text(raw["textZh"]);
1239
+ const textEn = text(raw["textEn"]);
1240
+ if (textZh === void 0 && textEn === void 0) return void 0;
1241
+ return {
1242
+ ...textZh === void 0 ? {} : { textZh },
1243
+ ...textEn === void 0 ? {} : { textEn }
1244
+ };
1245
+ }
1246
+ /** The upstream's `schedule.daily` → the windows this plugin can evaluate. */
1247
+ function parseDaily(value) {
1248
+ if (!Array.isArray(value)) return void 0;
1249
+ const windows = [];
1250
+ for (const entry of value) {
1251
+ const raw = record(entry);
1252
+ if (raw === void 0) continue;
1253
+ const start = text(raw["start"]);
1254
+ const end = text(raw["end"]);
1255
+ if (start === void 0 || end === void 0) continue;
1256
+ if (minutesOfClock(start) === void 0 || minutesOfClock(end) === void 0) continue;
1257
+ windows.push({
1258
+ start,
1259
+ end
1260
+ });
1261
+ }
1262
+ return windows.length === 0 ? void 0 : windows;
1263
+ }
1264
+ /** The upstream's `schedule` → this plugin's schedule shape. */
1265
+ function parseSchedule(value) {
1266
+ const raw = record(value);
1267
+ if (raw === void 0) return void 0;
1268
+ const timezone = text(raw["timezone"]);
1269
+ const validFrom = parseInstant(raw["validFrom"]);
1270
+ const validUntil = parseInstant(raw["validUntil"]);
1271
+ const daily = parseDaily(raw["daily"]);
1272
+ const dailyUnusable = daily === void 0 && Array.isArray(raw["daily"]) && raw["daily"].length > 0;
1273
+ if (timezone === void 0 && validFrom === void 0 && validUntil === void 0 && daily === void 0 && !dailyUnusable) return void 0;
1274
+ return {
1275
+ ...timezone === void 0 ? {} : { timezone },
1276
+ ...validFrom === void 0 ? {} : { validFrom },
1277
+ ...validUntil === void 0 ? {} : { validUntil },
1278
+ ...daily === void 0 ? {} : { daily },
1279
+ ...dailyUnusable ? { dailyUnusable: true } : {}
1280
+ };
1281
+ }
1282
+ /** One upstream entry → this plugin's promo shape, or undefined if it is not one. */
1283
+ function parsePromo(value, source) {
1284
+ const raw = record(value);
1285
+ if (raw === void 0) return void 0;
1286
+ if (raw["enabled"] === false) return void 0;
1287
+ const modelIds = parseModelIds(raw["modelIds"]);
1288
+ if (modelIds === void 0) return void 0;
1289
+ const badge = parseBadge(raw["badge"]);
1290
+ if (badge === void 0) return void 0;
1291
+ const hover = parseHover(raw["hover"]);
1292
+ const schedule = parseSchedule(raw["schedule"]);
1293
+ const priority = typeof raw["priority"] === "number" && Number.isFinite(raw["priority"]) ? raw["priority"] : 0;
1294
+ return {
1295
+ id: text(raw["id"]) ?? `${source}:${modelIds.join(",")}`,
1296
+ source,
1297
+ modelIds,
1298
+ badge,
1299
+ ...hover === void 0 ? {} : { hover },
1300
+ ...schedule === void 0 ? {} : { schedule },
1301
+ priority
1302
+ };
1303
+ }
1304
+ /**
1305
+ * Parse the upstream's promotion and tier lists.
1306
+ *
1307
+ * Both lists are read from the SAME document the roster comes from, so this
1308
+ * never costs a request. Entries that are not promotions — no `modelIds`, no
1309
+ * `badge`, or `enabled: false` — are dropped here rather than downstream, so
1310
+ * that everything downstream can assume a promo is drawable.
1311
+ *
1312
+ * The promotions come first, so that a TIE on `priority` is broken in the
1313
+ * promotions' favour: a price badge ("Free now") is more actionable to a user
1314
+ * than an access-tier badge ("Pro Only"), and the upstream numbers them
1315
+ * equally (both 200 on the observed feed).
1316
+ */
1317
+ function parseWorkBuddyPromos(rawPromotions, rawTiers) {
1318
+ const promos = [];
1319
+ for (const entry of Array.isArray(rawPromotions) ? rawPromotions : []) {
1320
+ const promo = parsePromo(entry, "promotion");
1321
+ if (promo !== void 0) promos.push(promo);
1322
+ }
1323
+ for (const entry of Array.isArray(rawTiers) ? rawTiers : []) {
1324
+ const tier = parsePromo(entry, "tier");
1325
+ if (tier !== void 0) promos.push(tier);
1326
+ }
1327
+ return promos;
1328
+ }
1329
+ /**
1330
+ * Attach the promotions that name each model.
1331
+ *
1332
+ * The result is UNRESOLVED on purpose: every candidate that names the model
1333
+ * rides along, so the badge can be chosen at render time against the clock
1334
+ * (see {@link selectPromoBadge}). Resolving here would freeze the answer at
1335
+ * fetch time — the defect that makes a "night discount" badge keep claiming
1336
+ * the night after the night is over.
1337
+ */
1338
+ function attachPromos(models, promos) {
1339
+ if (promos.length === 0) return [...models];
1340
+ const byModel = /* @__PURE__ */ new Map();
1341
+ for (const promo of promos) for (const modelId of promo.modelIds) {
1342
+ const list = byModel.get(modelId);
1343
+ if (list === void 0) byModel.set(modelId, [promo]);
1344
+ else list.push(promo);
1345
+ }
1346
+ return models.map((model) => {
1347
+ const list = byModel.get(model.id);
1348
+ if (list === void 0) return model;
1349
+ return {
1350
+ ...model,
1351
+ promos: [...list].sort((a, b) => b.priority - a.priority)
1352
+ };
1353
+ });
1354
+ }
1355
+ //#endregion
1099
1356
  //#region src/upstream.ts
1100
1357
  /**
1101
1358
  * WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
@@ -1145,55 +1402,80 @@ const GLOBAL_BASE = "https://www.workbuddy.ai";
1145
1402
  * Legacy model-catalog path, kept only as the CN region's FALLBACK.
1146
1403
  *
1147
1404
  * This is the document the WorkBuddy *plugin* used to read, and it is NOT the
1148
- * document the WorkBuddy *app* reads. The gateway answers it with the CLI
1149
- * channel's roster, whose second slot is the paid `hy4-preview`
1150
- * (`credits: 'x0.29 credits'`), while the app's own config lists the free
1151
- * `hy4-preview-f` (`credits: 'x0.00 credits'`) in that slot under the SAME
1152
- * display name "Hy4 preview". Reading this path is therefore what makes the
1153
- * plugin's card disagree with the app about a model's price — both are
1154
- * correctly displaying a real record, just different ones.
1405
+ * document the WorkBuddy *app* reads: the gateway answers it with the CLI
1406
+ * channel's roster, which carries neither the app's `auto` slot nor any
1407
+ * promotion feed. The free Hy4 tier that used to live here as a separate
1408
+ * `hy4-preview-f` record is gone as a record — measured 2026-10-21, both
1409
+ * channels list the paid `hy4-preview` (`x0.29`) — and the free tier is now
1410
+ * expressed as a promotion badge (`夜间免费`) in the app-channel document only.
1411
+ * So this path is strictly poorer than {@link GLOBAL_CONFIG_PATH}; it stays
1412
+ * only so that a gateway which stops serving the modern document degrades
1413
+ * instead of emptying the region.
1155
1414
  */
1156
1415
  const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
1157
1416
  /**
1158
- * Remote product-config path; the CN region's PRIMARY catalog source and the
1159
- * global region's only one.
1417
+ * Remote product-config path — the document the WorkBuddy app itself reads for
1418
+ * its model list, its prices AND its promotion badges.
1160
1419
  *
1161
1420
  * It is field-compatible with {@link MODELS_CATALOG_PATH} for every key
1162
- * {@link parseUpstreamModel} reads, so one parser still serves both. On CN this
1163
- * path is what the app itself consumes, so the plugin's roster and the app's
1164
- * agree — that parity is the whole point of preferring it.
1165
- *
1166
- * The CLI user agent deliberately stays {@link CLIENT_UA} here. `/v3/config`
1167
- * serves a DIFFERENT roster per client channel: the desktop token yields a
1168
- * roster without the free `hy4-preview-f`, and the CLI token yields one with
1169
- * it. Measured 2026-10-04 — CN + `/v3/config`: CLI token = 17 models incl.
1170
- * `hy4-preview-f`(x0.00); desktop token = 29 models, no `hy4-preview-f` and no
1171
- * cheap free tier. That is why the global branch below pairs this path with
1172
- * {@link DESKTOP_UA} and the CN branch must NOT.
1421
+ * {@link parseUpstreamModel} reads, so one parser still serves both, and it
1422
+ * additionally carries `modelPromotions` / `modelTiers` — the promotion feed
1423
+ * no other path publishes (see {@link withPromos}).
1424
+ *
1425
+ * Which document this path answers depends on the CHANNEL the request claims
1426
+ * to be (see {@link PRODUCT_UA}); it does not depend on the host: `www.workbuddy.cn`,
1427
+ * `copilot.tencent.com`, `wb.tencentbuddy.com` and `www.codebuddy.cn` answer
1428
+ * byte-identically under the same header.
1173
1429
  */
1174
1430
  const GLOBAL_CONFIG_PATH = "/v3/config";
1431
+ /**
1432
+ * The chat channel's user agent — the identity the plugin's adapter speaks as.
1433
+ *
1434
+ * Chat is a different contract from configuration: the conversation-identity
1435
+ * triple (conversation id, session id, device id) is bound to this channel, and
1436
+ * the upstream bills and rate-limits it as the CLI agent. The plugin chats as
1437
+ * the CLI agent on purpose, so this stays {@link CLIENT_UA} everywhere the
1438
+ * request is a CHAT.
1439
+ */
1175
1440
  const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
1176
1441
  /**
1177
- * User agent of the WorkBuddy desktop app.
1442
+ * The product-config channel's user agent: the WorkBuddy APP's own product
1443
+ * identity, byte-for-byte the header the app sends.
1444
+ *
1445
+ * The app builds it in `buildVlmGatewayHeaders` (WorkBuddy 5.8.3) as
1446
+ *
1447
+ * `User-Agent: WorkBuddy/<app> WorkBuddy/<app> CLI/<cli>`
1178
1448
  *
1179
- * The config service serves a DIFFERENT product configuration per client
1180
- * channel, selected by this product token — the version suffix is ignored
1181
- * (`WorkBuddy/5.5.2`, `WorkBuddy/1.0.0` and a bare `WorkBuddy` answer
1182
- * identically). On the INTERNATIONAL gateway the split decides which models
1183
- * exist at all:
1449
+ * — the app version twice, the CLI version once — and sends it to
1450
+ * `/v3/config`; the app's own log reads
1451
+ * `fetch cloud product from url:https://www.workbuddy.cn/v3/config success`
1452
+ * with `{"user-agent":"WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0", …}`.
1184
1453
  *
1185
- * - CLI channel (`CLI/… CodeBuddy/…`) → 35 models that OMIT
1186
- * `deepseek-v4.1-flash` and `gpt-6-astra`, even though both are perfectly
1187
- * chat-usable (verified: `deepseek-v4.1-flash` streams HTTP 200 and is
1188
- * billed `x0.00`);
1189
- * - desktop channel → the account's real 20-model chat roster including both.
1454
+ * It matters, because `/v3/config` answers a DIFFERENT product configuration
1455
+ * per client channel. Measured 2026-10-21, same token, same URL, only this
1456
+ * header differing (CN gateway):
1190
1457
  *
1191
- * The plugin emulates the CLI channel for CHAT but reads the desktop channel's
1192
- * configuration to learn the account's actual model list. The CN gateway needs
1193
- * no such switch: its desktop config carries no `cli` agent roster at all, so
1194
- * CN keeps reading the shared `/v2/enterprises/personal/models` path.
1458
+ * | User-Agent | `/v3/config` answer |
1459
+ * | --- | --- |
1460
+ * | `CLI/2.63.2 CodeBuddy/2.63.2` (CLI channel) | 5 keys, 35 models, NO promotion feed |
1461
+ * | `WorkBuddy/5.8.3` (app channel, no CLI part) | 18 keys, 37 models, no promotion feed |
1462
+ * | `WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0` | 22 keys, 62 models, 8 promos + 1 tier |
1463
+ *
1464
+ * The CLI channel's answer is a strict SUBSET of the app channel's: it
1465
+ * publishes neither `modelPromotions` nor `modelTiers`. That is precisely why
1466
+ * the CN card showed no promotion badge while the WorkBuddy app showed one on
1467
+ * the very same account — the plugin was reading a document the upstream
1468
+ * publishes WITHOUT the feed, not a feed that does not exist.
1469
+ *
1470
+ * The version suffixes are inert — `WorkBuddy/1.0.0 CLI/2.63.2
1471
+ * CodeBuddy/2.63.2` answers identically — so what selects the document is the
1472
+ * CHANNEL the header claims, not the version in it.
1473
+ *
1474
+ * Global answers the same document under either app-shaped header (17 keys,
1475
+ * 28 models, 5 promotions + 1 tier, identical roster), so both regions read
1476
+ * `/v3/config` as {@link PRODUCT_UA}.
1195
1477
  */
1196
- const DESKTOP_UA = "WorkBuddy/5.5.2";
1478
+ const PRODUCT_UA = "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0";
1197
1479
  const JSON_TIMEOUT_MS = 3e4;
1198
1480
  const ERROR_BODY_LIMIT = 4096;
1199
1481
  /**
@@ -2110,8 +2392,28 @@ function parseUpstreamModel(value) {
2110
2392
  * selector serves them. Without a usable `cli` roster the whole parsed catalog
2111
2393
  * is exposed rather than nothing: the roster is an upstream detail that may
2112
2394
  * change, and an empty answer would silently disarm the provider.
2395
+ *
2396
+ * ROUTERS ARE NOT MODELS. The upstream lists `auto` (CN) and its global
2397
+ * equivalents alongside real models, but those entries are ROUTING decisions,
2398
+ * not models: the gateway resolves them to whichever model it thinks fits, so
2399
+ * a row for one would offer a name whose price, context window and behaviour
2400
+ * are all decided at request time by something else. Selected this way it also
2401
+ * duplicates a real row — upstream resolves CN `auto` to `hy4-preview`, which
2402
+ * is already in the same roster. They are dropped here, at the single place
2403
+ * the roster is built, so the card, the picker, the probe and the discovery
2404
+ * route all agree. A user who already had one enabled keeps it through
2405
+ * `keepIds` below: this removes the OFFER, never a running model (issue #32's
2406
+ * failure mode).
2407
+ *
2408
+ * `keepIds` is the user's own selection riding along with the refresh. The
2409
+ * upstream roster may drop a model the user is actually running — the app
2410
+ * channel's `cli` roster dropped `minimax-m2.7` on 2026-10-21 while the SAME
2411
+ * document still publishes it (`x0.26`) and the chat endpoint still answers for
2412
+ * it (measured HTTP 200). A roster read is therefore the upstream's roster PLUS
2413
+ * the kept ids this document still lists: a refresh may add or withdraw
2414
+ * upstream models, but it never silently withdraws a model the user is on.
2113
2415
  */
2114
- function selectCliModels(rawModels, agents) {
2416
+ function selectCliModels(rawModels, agents, keepIds = []) {
2115
2417
  const byId = /* @__PURE__ */ new Map();
2116
2418
  for (const model of Array.isArray(rawModels) ? rawModels : []) {
2117
2419
  const parsed = parseUpstreamModel(model);
@@ -2125,9 +2427,33 @@ function selectCliModels(rawModels, agents) {
2125
2427
  break;
2126
2428
  }
2127
2429
  }
2128
- const models = (cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()]).map((id) => byId.get(id)).filter((model) => model !== void 0);
2129
- if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
2130
- return models;
2430
+ const ids = cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()];
2431
+ const models = ids.filter((id) => !isRouterModelId(id)).map((id) => byId.get(id)).filter((model) => model !== void 0);
2432
+ const kept = keepIds.filter((id) => !ids.includes(id)).map((id) => byId.get(id)).filter((model) => model !== void 0);
2433
+ const resolved = [...models, ...kept];
2434
+ if (resolved.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
2435
+ return resolved;
2436
+ }
2437
+ /**
2438
+ * The roster plus the promotion badges the SAME document carries.
2439
+ *
2440
+ * `data.modelPromotions` and `data.modelTiers` sit beside `data.models` in
2441
+ * the upstream's `/v3/config` document, so the badges cost no extra request:
2442
+ * they are read out of the response the roster was read from. Which models
2443
+ * they name is resolved here; WHICH badge holds at a given moment is not
2444
+ * (see `src/promo.ts` — a badge is a function of the clock).
2445
+ *
2446
+ * BOTH regions publish the feed, and they publish it in the app channel's
2447
+ * document: measured 2026-10-21 on the same accounts the app reads, CN
2448
+ * `/v3/config` carries 8 promotions + 1 tier badge (Chinese labels such as
2449
+ * `夜间折扣` / `限时免费` / `订阅优先`, with `schedule.daily` night windows),
2450
+ * and the global gateway carries 5 promotions + 1 tier badge. The feed is
2451
+ * absent only from the CLI channel's answer, which the plugin never reads a
2452
+ * product configuration as (see {@link PRODUCT_UA}).
2453
+ */
2454
+ function withPromos(models, data) {
2455
+ const promos = parseWorkBuddyPromos(data["modelPromotions"], data["modelTiers"]);
2456
+ return promos.length === 0 ? models : attachPromos(models, promos);
2131
2457
  }
2132
2458
  /**
2133
2459
  * Upstream HTTP client. One instance serves the whole plugin; requests take
@@ -2269,13 +2595,14 @@ var WorkBuddyUpstreamClient = class {
2269
2595
  /**
2270
2596
  * Read the model directory for the credential's region.
2271
2597
  *
2272
- * Both regions prefer `/v3/config`, the document the WorkBuddy app itself
2273
- * consumes, so the plugin's roster and the app's agree. They differ in the
2274
- * user agent that requests it, and that difference is load-bearing: `/v3`
2275
- * serves a different roster per client channel. Global asks as the desktop
2276
- * channel (see {@link DESKTOP_UA}); CN asks as the CLI channel
2277
- * ({@link CLIENT_UA}), which is the only CN channel whose `/v3` answer
2278
- * contains the free `hy4-preview-f` (see {@link GLOBAL_CONFIG_PATH}).
2598
+ * Both regions read `/v3/config`, the document the WorkBuddy app itself
2599
+ * consumes, and both read it as the APP's product identity
2600
+ * ({@link PRODUCT_UA}) — the header the app sends, not the CLI channel's.
2601
+ * That is load-bearing, not cosmetic: the CLI channel's answer is a strict
2602
+ * subset of the app channel's and publishes neither `modelPromotions` nor
2603
+ * `modelTiers`, so reading `/v3/config` as the CLI channel is what left the
2604
+ * CN card without a single promotion badge while the app showed one on the
2605
+ * same account (see {@link PRODUCT_UA}).
2279
2606
  *
2280
2607
  * CN falls back to the legacy {@link MODELS_CATALOG_PATH} when `/v3` fails,
2281
2608
  * returns a non-zero envelope, or resolves to no usable model, so a gateway
@@ -2286,8 +2613,14 @@ var WorkBuddyUpstreamClient = class {
2286
2613
  *
2287
2614
  * No user-side toggle is involved: the region comes from the credential's
2288
2615
  * `domain`.
2616
+ *
2617
+ * `keepModelIds` is the user's own selection, passed through to
2618
+ * {@link selectCliModels}: a model the upstream roster dropped but the SAME
2619
+ * document still publishes stays in the roster, so reading the directory
2620
+ * never withdraws a model the user is running (issue #32's failure mode,
2621
+ * one step upstream of it).
2289
2622
  */
2290
- async fetchModels(credential, signal) {
2623
+ async fetchModels(credential, signal, keepModelIds = []) {
2291
2624
  const timeout = signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS);
2292
2625
  if (regionOf(credential.domain) === "global") {
2293
2626
  const response = await fetch(`${globalBase(credential.domain)}${GLOBAL_CONFIG_PATH}`, {
@@ -2299,14 +2632,14 @@ var WorkBuddyUpstreamClient = class {
2299
2632
  "X-Product": "SaaS",
2300
2633
  "X-Requested-With": "XMLHttpRequest",
2301
2634
  "Connection": "close",
2302
- "User-Agent": DESKTOP_UA
2635
+ "User-Agent": PRODUCT_UA
2303
2636
  },
2304
2637
  signal: timeout
2305
2638
  });
2306
2639
  const envelope = await readEnvelope(response);
2307
2640
  if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
2308
2641
  const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
2309
- return selectCliModels(data["models"], data["agents"]);
2642
+ return withPromos(selectCliModels(data["models"], data["agents"], keepModelIds), data);
2310
2643
  }
2311
2644
  const readCatalog = async (path) => {
2312
2645
  const response = await fetch(`${chatBase(credential)}${path}`, {
@@ -2315,14 +2648,14 @@ var WorkBuddyUpstreamClient = class {
2315
2648
  "Accept": "application/json",
2316
2649
  "Origin": originReferer(credential),
2317
2650
  "Referer": `${originReferer(credential)}/`,
2318
- "User-Agent": CLIENT_UA
2651
+ "User-Agent": PRODUCT_UA
2319
2652
  },
2320
2653
  signal: timeout
2321
2654
  });
2322
2655
  const envelope = await readEnvelope(response);
2323
2656
  if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
2324
2657
  const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
2325
- return selectCliModels(data["models"], data["agents"]);
2658
+ return withPromos(selectCliModels(data["models"], data["agents"], keepModelIds), data);
2326
2659
  };
2327
2660
  try {
2328
2661
  return await readCatalog(GLOBAL_CONFIG_PATH);
@@ -4000,14 +4333,14 @@ var WorkBuddyCredentialStore = class {
4000
4333
  * Static CLI models captured from the CN endpoint (2026-08-30). The upstream
4001
4334
  * refresh replaces this list at startup; it exists so the provider registers
4002
4335
  * with a usable catalog even while the first fetch is in flight or offline.
4336
+ *
4337
+ * The upstream's `auto` ROUTER is deliberately absent, matching what
4338
+ * `selectCliModels` now does to the live roster: it resolves to a different
4339
+ * model per request, so it is not a row this plugin can describe. Keeping it
4340
+ * here would have reintroduced it on the offline/第一帧 path only, which is the
4341
+ * hardest kind of inconsistency to notice.
4003
4342
  */
4004
4343
  const FALLBACK_WORKBUDDY_MODELS = [
4005
- {
4006
- id: "auto",
4007
- name: "Auto",
4008
- contextWindow: 168e3,
4009
- maxTokens: 32e3
4010
- },
4011
4344
  {
4012
4345
  id: "hy3",
4013
4346
  name: "Hy3",
@@ -4255,8 +4588,23 @@ function applyContextBudgets(catalog, budgets = {}) {
4255
4588
  contextWindow: budgets[model.id] === void 0 ? model.contextWindow : Math.min(model.contextWindow, budgets[model.id])
4256
4589
  }));
4257
4590
  }
4591
+ /**
4592
+ * Derive the runtime catalog from the last-refreshed directory plus the user's
4593
+ * selection.
4594
+ *
4595
+ * An empty selection means "never configured" and serves the whole directory.
4596
+ * But a NON-empty selection that intersects the directory in nothing must NOT
4597
+ * serve nothing: that is the issue #32 failure mode (the picker empties out),
4598
+ * and it now has a realistic trigger — a user whose only ticked model was the
4599
+ * upstream's `auto` router, which this version stops offering. Such a selection
4600
+ * degrades to the whole directory rather than to silence: the user asked for
4601
+ * "some models to be on", and the alternative is a region with no models at
4602
+ * all. An empty directory still yields an empty result; the guard is only about
4603
+ * a selection that no longer matches anything.
4604
+ */
4258
4605
  function deriveCatalog(catalog, enabled, budgets = {}) {
4259
- return applyContextBudgets(enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id)), budgets);
4606
+ const selected = enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id));
4607
+ return applyContextBudgets(selected.length === 0 && enabled.size > 0 ? catalog : selected, budgets);
4260
4608
  }
4261
4609
  /**
4262
4610
  * Mutable catalog shared by the shim's `/v1/models` and the adapter.
@@ -4515,7 +4863,7 @@ function generationOf(version) {
4515
4863
  //#endregion
4516
4864
  //#region src/version.ts
4517
4865
  /** The npm package version this build was produced from. */
4518
- const WORKBUDDY_CONNECT_VERSION = "4.13.2";
4866
+ const WORKBUDDY_CONNECT_VERSION = "4.14.1";
4519
4867
  //#endregion
4520
4868
  //#region src/host-heartbeat.ts
4521
4869
  /**
@@ -4673,4 +5021,4 @@ async function clearHostHeartbeat() {
4673
5021
  await rm(workbuddyHostHeartbeatPath(), { force: true });
4674
5022
  }
4675
5023
  //#endregion
4676
- export { withEffectiveOff as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, defaultOffModelIds as J, systemOpeningsOfBody as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, recordRefusal as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, offDefaultFor as X, lastRefusalFor as Y, offRefused as Z, WORKBUDDY_AUTH_FILENAME as _, writeFileAtomic as _t, readHostHeartbeat as a, findWorkbuddyAppExecutable as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, isWorkbuddyBundle as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, openEncryptedField as dt, WORKBUDDY_APP_EXECUTABLE_ENV as et, WorkBuddyCatalog as f, readAtRestKey as ft, ENCRYPTED_CREDENTIAL_CODE as g, withFileLock as gt, fallbackModelsFor as h, resolveDshHome as ht, processStartTimeMs as i, fetchAtRestKeyPayload as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, macosBundleExecutable as lt, deriveCatalog as m, hostPeersInfo as mt, clearHostHeartbeat as n, deriveAtRestKey as nt, workbuddyHostHeartbeatPath as o, findWorkbuddyAppExecutableWithSource as ot, applyContextBudgets as p, workbuddyAppExecutableCandidates as pt, REFUSES_OFF_MODEL_IDS as q, isHeartbeatProcessAlive as r, deriveAtRestKeyId as rt, writeHostHeartbeat as s, isEncryptedFieldWrapper as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, clearAtRestKeyCache as tt, FALLBACK_WORKBUDDY_MODELS as u, macosNestedAppBundles as ut, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
5024
+ export { withEffectiveOff as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, defaultOffModelIds as J, systemOpeningsOfBody as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, recordRefusal as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, offDefaultFor as X, lastRefusalFor as Y, offRefused as Z, WORKBUDDY_AUTH_FILENAME as _, hostPeersInfo as _t, readHostHeartbeat as a, deriveAtRestKey as at, WorkBuddyEncryptedCredentialError as b, writeFileAtomic as bt, WORKBUDDY_CONNECT_VERSION as c, findWorkbuddyAppExecutable as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, isWorkbuddyBundle as dt, NATIVE_MODALITY_BY_MODEL_ID as et, WorkBuddyCatalog as f, macosBundleExecutable as ft, ENCRYPTED_CREDENTIAL_CODE as g, workbuddyAppExecutableCandidates as gt, fallbackModelsFor as h, readAtRestKey as ht, processStartTimeMs as i, clearAtRestKeyCache as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, findWorkbuddyAppExecutableWithSource as lt, deriveCatalog as m, openEncryptedField as mt, clearHostHeartbeat as n, nativeModalityOf as nt, workbuddyHostHeartbeatPath as o, deriveAtRestKeyId as ot, applyContextBudgets as p, macosNestedAppBundles as pt, REFUSES_OFF_MODEL_IDS as q, isHeartbeatProcessAlive as r, WORKBUDDY_APP_EXECUTABLE_ENV as rt, writeHostHeartbeat as s, fetchAtRestKeyPayload as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, imageDefaultFor as tt, FALLBACK_WORKBUDDY_MODELS as u, isEncryptedFieldWrapper as ut, WORKBUDDY_AUTH_FILE_ENV as v, resolveDshHome as vt, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, withFileLock as yt, knownRegionOf as z };