dsh-workbuddy-connect 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,12 +1,136 @@
1
- import { S as workbuddyOwnAuthPath, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as defaultDesktopAuthPath, d as normalizeCredits, f as prepareChatBody, g as WORKBUDDY_AUTH_FILENAME, h as WorkBuddyCatalog, i as processStartTimeMs, l as WorkBuddyUpstreamClient, m as FALLBACK_WORKBUDDY_MODELS, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as regionOf, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as classifyUpstreamError, v as WorkBuddyCredentialStore, x as parseWorkBuddyAuth, y as defaultDesktopAuthCandidates } from "./host-heartbeat-BSsKUoB8.js";
1
+ import { A as parseModelCatalog, B as readBundleVersion, C as desktopAuthCandidatesFor, D as classifyUpstreamError, E as WorkBuddyUpstreamClient, F as probeModel, H as validAppVersion, I as randomSentinel, L as WORKBUDDY_APP_VERSION_FILENAME, M as prepareInternationalChatBody, N as regionOf, O as modelWithCurrentPromotion, P as PROBE_EFFORT_CANDIDATES, R as appUserAgent, S as defaultDesktopAuthPath, T as workbuddyOwnAuthPath, V as resolveAppVersion, _ as WorkBuddyCatalog, a as WORKBUDDY_HOST_HEARTBEAT_FILENAME, b as WorkBuddyCredentialStore, c as processStartTimeMs, d as writeHostHeartbeat, f as WORKBUDDY_CONNECT_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, j as prepareChatBody, k as normalizeCredits, l as readHostHeartbeat, n as CN_VARIANT, o as clearHostHeartbeat, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, t as AI_VARIANT, u as workbuddyHostHeartbeatPath, v as WORKBUDDY_AUTH_FILENAME, w as parseWorkBuddyAuth, x as defaultDesktopAuthCandidates, y as WORKBUDDY_AUTH_FILE_ENV, z as installedAppVersion } from "./variants-GgLTJgZs.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
5
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
7
  import { createProvider } from "@earendil-works/pi-ai";
4
8
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
5
9
  import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
6
10
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
7
- import { randomBytes, timingSafeEqual } from "node:crypto";
8
11
  import { createServer } from "node:http";
9
12
  import { Readable } from "node:stream";
13
+ //#region src/catalog-store.ts
14
+ /**
15
+ * The last catalog that actually loaded, kept per variant and per account.
16
+ *
17
+ * Both the plan (§4 "降级顺序为同版同来源的最近成功目录 → 本版内置保守目录")
18
+ * and the README promise this fallback, and without it a restart always drops
19
+ * the user to the built-in roster even when a good catalog was fetched minutes
20
+ * earlier. The built-in roster is a snapshot taken once; a fetched catalog is
21
+ * what the upstream actually serves to this account.
22
+ *
23
+ * What it deliberately is *not*:
24
+ *
25
+ * - not a cache with a freshness policy — it never prevents a fetch, it only
26
+ * answers when a fetch cannot;
27
+ * - not shared across accounts (a different account can see a different roster
28
+ * and different promotions), nor across variants (the CN and international
29
+ * endpoints disagree about rates and windows for the same model id);
30
+ * - not a place for secrets: model metadata only, never a token. The account
31
+ * key is a `uid:enterpriseId` identity already visible in the status document.
32
+ *
33
+ * @module dsh-workbuddy-connect/catalog-store
34
+ */
35
+ /** On-disk format this reader accepts; other versions are discarded. */
36
+ const CATALOG_FORMAT_VERSION = 1;
37
+ /** Basename of the CN variant's saved catalog inside the Harness home. */
38
+ const WORKBUDDY_CATALOG_FILENAME = ".workbuddy-catalog.json";
39
+ /** Plugin-owned saved-catalog path inside the Harness home. */
40
+ function workbuddyCatalogPath(filename = WORKBUDDY_CATALOG_FILENAME) {
41
+ return join(resolveDshHome(), filename);
42
+ }
43
+ /** Whether a parsed value is a model row worth keeping. */
44
+ function isModel(value) {
45
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
46
+ const row = value;
47
+ return typeof row["id"] === "string" && row["id"] !== "" && typeof row["name"] === "string" && typeof row["contextWindow"] === "number" && Number.isFinite(row["contextWindow"]) && typeof row["maxTokens"] === "number" && Number.isFinite(row["maxTokens"]) && typeof row["supportsImages"] === "boolean";
48
+ }
49
+ /** Whether a parsed value is a saved catalog this reader can trust. */
50
+ function isSaved(value) {
51
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
52
+ const entry = value;
53
+ if (typeof entry["account"] !== "string" || entry["account"] === "") return false;
54
+ if (typeof entry["source"] !== "string" || entry["source"] === "") return false;
55
+ if (typeof entry["fetchedAtMs"] !== "number" || !Number.isFinite(entry["fetchedAtMs"])) return false;
56
+ const models = entry["models"];
57
+ if (!Array.isArray(models) || models.length === 0) return false;
58
+ return models.every(isModel);
59
+ }
60
+ /**
61
+ * The last successful catalog per account, read once and written atomically.
62
+ *
63
+ * Malformed content reads as "nothing saved" rather than throwing: this file
64
+ * is an optimization for the offline and first-seconds cases, and a corrupt one
65
+ * must never be able to stop the plugin from serving models.
66
+ */
67
+ var WorkBuddyCatalogStore = class {
68
+ path;
69
+ entries;
70
+ constructor(options = {}) {
71
+ this.path = typeof options === "string" ? options : options.path ?? workbuddyCatalogPath();
72
+ }
73
+ /** Resolved state-file path, for the CLI and tests. */
74
+ filePath() {
75
+ return this.path;
76
+ }
77
+ load() {
78
+ if (this.entries !== void 0) return this.entries;
79
+ const entries = {};
80
+ if (existsSync(this.path)) try {
81
+ const parsed = JSON.parse(readFileSync(this.path, "utf8"));
82
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
83
+ const document = parsed;
84
+ const raw = document["version"] === CATALOG_FORMAT_VERSION ? document["entries"] : void 0;
85
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
86
+ for (const [key, value] of Object.entries(raw)) if (isSaved(value)) entries[key] = value;
87
+ }
88
+ }
89
+ } catch {}
90
+ this.entries = entries;
91
+ return entries;
92
+ }
93
+ /** The saved catalog for one account, or `undefined` when there is none. */
94
+ get(account) {
95
+ const entry = this.load()[account];
96
+ return entry === void 0 ? void 0 : entry;
97
+ }
98
+ /**
99
+ * Remember a catalog for an account, replacing whatever was saved before.
100
+ *
101
+ * A failed write is swallowed: the plugin has already served these models,
102
+ * and losing the *memory* of them is not worth surfacing.
103
+ */
104
+ set(account, catalog) {
105
+ const entries = this.load();
106
+ entries[account] = {
107
+ account,
108
+ ...catalog
109
+ };
110
+ this.persist();
111
+ }
112
+ /** Forget one account's catalog — used when that account signs out. */
113
+ delete(account) {
114
+ const entries = this.load();
115
+ if (!(account in entries)) return;
116
+ delete entries[account];
117
+ this.persist();
118
+ }
119
+ persist() {
120
+ const directory = dirname(this.path);
121
+ try {
122
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
123
+ const document = {
124
+ version: CATALOG_FORMAT_VERSION,
125
+ entries: this.load()
126
+ };
127
+ const temporary = resolve(`${this.path}.tmp`);
128
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
129
+ renameSync(temporary, this.path);
130
+ } catch {}
131
+ }
132
+ };
133
+ //#endregion
10
134
  //#region src/adapter.ts
11
135
  /**
12
136
  * The `workbuddy` pi-ai provider: one loopback-backed adapter registered
@@ -107,29 +231,40 @@ function withCatalogDisplay(name, info) {
107
231
  * `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
108
232
  * unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
109
233
  *
110
- * Declared sets only: a thinking control is offered exactly when the upstream
111
- * catalog declares a `supportedEfforts` list, and it offers exactly the
112
- * declared values. Rows without a list (the older `{effort, summary}` shape)
113
- * get no control at all — their selectable set is client-side knowledge the
114
- * catalog does not carry (the desktop app differs per model there: GLM-5.2
115
- * gets a thinking control while MiniMax-M3 and Kimi-K2.6 do not, though their
116
- * catalog rows are identical), and another implementation against the same
117
- * upstream (workbuddy2api) gates on the declared set and downgrades
118
- * out-of-set values rather than passing them through, so sending an
119
- * undeclared value risks a 400. Such models never carry `reasoning_effort`
120
- * on the wire; the upstream applies its own default.
121
- * `off` is offered only when the model explicitly reports thinking can be
122
- * disabled (`canDisableThinking === true`).
123
- */
124
- function reasoningFields(info) {
234
+ * Two sources, strictly ordered (`docs/reasoning-effort-probe-plan.md` §5):
235
+ *
236
+ * 1. **The declared set.** When the upstream declares a non-empty
237
+ * `supportedEfforts`, exactly those values are offered and nothing else.
238
+ * This always wins: an observation never widens or narrows a declared set.
239
+ * 2. **A local observation.** Rows without a declared set (the older
240
+ * `{effort, summary}` shape) normally get no control at all — their
241
+ * selectable set is client-side knowledge the catalog does not carry, and
242
+ * the desktop app differs per model there. If the user authorized a probe
243
+ * and it established that the upstream *validates* the parameter, the
244
+ * verified spellings are offered.
245
+ *
246
+ * A `non-validating` observation deliberately yields no control: the upstream
247
+ * accepts values that cannot exist (measured on `glm-5.2`), so every per-level
248
+ * acceptance it produced would be a false positive.
249
+ *
250
+ * `off` is offered only when the upstream declares `canDisableThinking: true`.
251
+ * It is never probed — disabling thinking is a separate capability, and the
252
+ * per-model acceptance of `off` cannot be inferred from the row's shape.
253
+ *
254
+ * The offered set is described internally as "verified accepted", never as
255
+ * "verified effective": acceptance proves the upstream did not reject the
256
+ * spelling, not that it changes what the model does.
257
+ */
258
+ function reasoningFields(info, observed) {
125
259
  const reasoning = info.reasoning;
126
260
  if (reasoning === void 0 || reasoning.supports !== true) return { reasoning: false };
127
- const efforts = reasoning.supportedEfforts;
128
- if (efforts === void 0 || efforts.length === 0) return { reasoning: false };
261
+ const declared = reasoning.supportedEfforts;
262
+ const efforts = declared !== void 0 && declared.length > 0 ? declared : observed?.validation === "validating" && observed.efforts.length > 0 ? observed.efforts : void 0;
263
+ if (efforts === void 0) return { reasoning: false };
129
264
  return {
130
265
  reasoning: true,
131
266
  thinkingLevelMap: {
132
- off: reasoning.canDisableThinking === true ? "off" : null,
267
+ off: reasoning.canDisableThinking === true && declared !== void 0 && declared.length > 0 ? "off" : null,
133
268
  minimal: null,
134
269
  low: efforts.includes("low") ? "low" : null,
135
270
  medium: efforts.includes("medium") ? "medium" : null,
@@ -140,18 +275,19 @@ function reasoningFields(info) {
140
275
  };
141
276
  }
142
277
  /** Build one pi-ai model descriptor pointing at the loopback shim. */
143
- function toPiModel(info, baseUrl) {
278
+ function toPiModel(info, baseUrl, observed, providerId = WORKBUDDY_PROVIDER) {
144
279
  return {
145
280
  id: info.id,
146
281
  name: info.name,
147
282
  api: "openai-completions",
148
- provider: WORKBUDDY_PROVIDER,
283
+ provider: providerId,
149
284
  baseUrl,
150
285
  input: info.supportsImages === true ? ["text", "image"] : ["text"],
151
- ...reasoningFields(info),
286
+ ...reasoningFields(info, observed),
152
287
  cost: NO_COST,
153
288
  contextWindow: info.contextWindow,
154
- maxTokens: info.maxTokens
289
+ maxTokens: info.maxTokens,
290
+ compat: { maxTokensField: "max_tokens" }
155
291
  };
156
292
  }
157
293
  /**
@@ -167,15 +303,17 @@ function toPiModel(info, baseUrl) {
167
303
  * `modelErrors` since 0.1.5-alpha.2 (#12).
168
304
  */
169
305
  function createWorkBuddyAdapter(options) {
170
- const { shim, store, catalog, resolveAttachments } = options;
306
+ const { shim, store, catalog, resolveAttachments, observe } = options;
307
+ const providerId = options.providerId ?? "workbuddy";
308
+ const displayName = options.displayName ?? "WorkBuddy";
171
309
  const buildModels = () => {
172
310
  const baseUrl = `${shim.baseUrl()}/v1`;
173
- return catalog.current().map((info) => toPiModel(info, baseUrl));
311
+ return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id), providerId));
174
312
  };
175
313
  const provider = {
176
314
  ...createProvider({
177
- id: WORKBUDDY_PROVIDER,
178
- name: "WorkBuddy",
315
+ id: providerId,
316
+ name: displayName,
179
317
  auth: { apiKey: {
180
318
  name: "WorkBuddy OAuth bearer token",
181
319
  async resolve({ credential }) {
@@ -192,8 +330,8 @@ function createWorkBuddyAdapter(options) {
192
330
  getModels: () => buildModels()
193
331
  };
194
332
  const profile = {
195
- provider: WORKBUDDY_PROVIDER,
196
- displayName: "WorkBuddy",
333
+ provider: providerId,
334
+ displayName,
197
335
  streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
198
336
  retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-connect retryPolicy"),
199
337
  configuredMaxTokens: /* @__PURE__ */ new Map(),
@@ -201,7 +339,7 @@ function createWorkBuddyAdapter(options) {
201
339
  ...REQUEST_IMAGE_BUDGETS,
202
340
  piProvider: provider
203
341
  };
204
- let profiles = /* @__PURE__ */ new Map([[WORKBUDDY_PROVIDER, profile]]);
342
+ let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
205
343
  return {
206
344
  adapter: new WorkBuddyPiAiAdapter(catalog, {
207
345
  profiles: () => profiles,
@@ -210,7 +348,7 @@ function createWorkBuddyAdapter(options) {
210
348
  ...resolveAttachments === void 0 ? {} : { resolveAttachments }
211
349
  }),
212
350
  invalidate: () => {
213
- profiles = /* @__PURE__ */ new Map([[WORKBUDDY_PROVIDER, profile]]);
351
+ profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
214
352
  }
215
353
  };
216
354
  }
@@ -357,7 +495,7 @@ function writeOpenAIError(res, status, kind, message) {
357
495
  } });
358
496
  }
359
497
  /** Read a request body with a size cap; over-limit bodies fail the request. */
360
- function readBody(req) {
498
+ function readBody$1(req) {
361
499
  return new Promise((resolve, reject) => {
362
500
  const chunks = [];
363
501
  let size = 0;
@@ -461,7 +599,7 @@ function createWorkBuddyShim(options) {
461
599
  writeOpenAIError(res, 401, "not_signed_in", String(error));
462
600
  return;
463
601
  }
464
- const raw = (await readBody(req)).toString("utf8");
602
+ const raw = (await readBody$1(req)).toString("utf8");
465
603
  const prepared = prepareChatBody(raw);
466
604
  const controller = new AbortController();
467
605
  req.on("close", () => controller.abort());
@@ -499,17 +637,332 @@ function createWorkBuddyShim(options) {
499
637
  };
500
638
  }
501
639
  //#endregion
502
- //#region src/status-paths.ts
503
- /** Node-free constants and types shared by the Host and browser halves. */
504
- /** Plugin-owned status endpoint consumed by its browser half. */
505
- const WORKBUDDY_STATUS_PATH = "/plugins/dsh-workbuddy-connect/status";
640
+ //#region src/probe-store.ts
641
+ /**
642
+ * Local record of reasoning-effort probes.
643
+ *
644
+ * What this stores is an *observation*, never a claim about the upstream: a
645
+ * model's row is only consulted when the catalog carries no explicit
646
+ * `supportedEfforts` set, and it always loses to a declared set. The plan this
647
+ * implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
648
+ * is invalidated whenever the model's catalog row changes, so every record
649
+ * carries a fingerprint of the fields the probe depended on.
650
+ *
651
+ * The file lives beside the plugin's own credential copy under `$DSH_HOME`,
652
+ * never in the desktop app's files, and carries no token, prompt, or response
653
+ * body — only model ids, effort spellings, and timestamps.
654
+ *
655
+ * @module dsh-workbuddy-connect/probe-store
656
+ */
657
+ /** Basename of the probe record inside the Harness home. */
658
+ const WORKBUDDY_PROBE_FILENAME = ".workbuddy-probe.json";
659
+ /** On-disk format this reader accepts; other versions are discarded. */
660
+ const PROBE_FORMAT_VERSION = 1;
661
+ /**
662
+ * How long an observation stays usable. Conservative on purpose: the plan's
663
+ * whole argument is that upstream metadata moves fast, so a result that has
664
+ * outlived its fingerprint's usefulness should not quietly keep granting a
665
+ * picker entry.
666
+ */
667
+ const DEFAULT_TTL_MS = 12096e5;
668
+ /**
669
+ * Plugin-owned probe record path inside the Harness home.
670
+ *
671
+ * One file per variant. Same-named models exist on both endpoints (the
672
+ * international catalog repeats `glm-5.3`, `glm-5.2`, `hy3`, `kimi-k2.6`), and
673
+ * {@link fingerprintModel} covers only `id`/`reasoning`/`supportsImages` —
674
+ * never the provider — so a single shared file would let one variant's
675
+ * observation answer for the other. The paths differ; the format does not.
676
+ */
677
+ function workbuddyProbePath(filename = WORKBUDDY_PROBE_FILENAME) {
678
+ return join(resolveDshHome(), filename);
679
+ }
680
+ /**
681
+ * Fingerprint the catalog fields a probe depends on.
682
+ *
683
+ * Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
684
+ * so a rename or a promo badge does not throw away a valid observation, and
685
+ * deliberately includes the whole reasoning object so any change to the
686
+ * declared shape re-probes.
687
+ */
688
+ function fingerprintModel(info) {
689
+ const basis = JSON.stringify({
690
+ id: info.id,
691
+ reasoning: info.reasoning ?? null,
692
+ supportsImages: info.supportsImages ?? null
693
+ });
694
+ return createHash("sha256").update(basis).digest("hex").slice(0, 16);
695
+ }
696
+ /** Read-and-validate the documents on disk; anything malformed reads as empty. */
697
+ function readDocument(path) {
698
+ if (!existsSync(path)) return void 0;
699
+ let parsed;
700
+ try {
701
+ parsed = JSON.parse(readFileSync(path, "utf8"));
702
+ } catch {
703
+ return;
704
+ }
705
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
706
+ const wrapped = parsed;
707
+ if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
708
+ const records = wrapped["records"];
709
+ if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
710
+ return parsed;
711
+ }
712
+ /** One record's shape check; a bad row is dropped rather than trusted. */
713
+ function isRecord(value) {
714
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
715
+ const wrapped = value;
716
+ const validation = wrapped["validation"];
717
+ if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
718
+ if (typeof wrapped["fingerprint"] !== "string") return false;
719
+ if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
720
+ if (typeof wrapped["pluginVersion"] !== "string") return false;
721
+ const efforts = wrapped["efforts"];
722
+ if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
723
+ return true;
724
+ }
725
+ /**
726
+ * The plugin's probe records: read once, written atomically, never trusted
727
+ * across a fingerprint change or past the TTL.
728
+ */
729
+ var WorkBuddyProbeStore = class {
730
+ path;
731
+ ttlMs;
732
+ pluginVersion;
733
+ now;
734
+ records;
735
+ constructor(options) {
736
+ const opts = typeof options === "string" ? {
737
+ path: options,
738
+ pluginVersion: "0.0.0"
739
+ } : options;
740
+ this.path = opts.path ?? workbuddyProbePath();
741
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
742
+ this.pluginVersion = opts.pluginVersion;
743
+ this.now = opts.now ?? (() => Date.now());
744
+ }
745
+ /** Resolved state-file path, for the CLI and tests. */
746
+ filePath() {
747
+ return this.path;
748
+ }
749
+ load() {
750
+ if (this.records === void 0) {
751
+ const document = readDocument(this.path);
752
+ const records = {};
753
+ for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
754
+ this.records = records;
755
+ }
756
+ return this.records;
757
+ }
758
+ /**
759
+ * The usable record for a model, or `undefined` when there is none, it is
760
+ * expired, it was taken against a different catalog row, or it belongs to a
761
+ * different account.
762
+ *
763
+ * @param account - the account in effect, as `uid:enterpriseId`. Records are
764
+ * only returned for the account that produced them.
765
+ */
766
+ get(modelId, fingerprint, account) {
767
+ const record = this.load()[modelId];
768
+ if (record === void 0) return void 0;
769
+ if (record.fingerprint !== fingerprint) return void 0;
770
+ if (record.account !== account) return void 0;
771
+ if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
772
+ return record;
773
+ }
774
+ /**
775
+ * Store one observation. Only a decisive answer (`validating` /
776
+ * `non-validating`) replaces an existing decisive record: a transient
777
+ * `unknown` must not erase knowledge the user already paid for.
778
+ */
779
+ set(modelId, record) {
780
+ const records = this.load();
781
+ const existing = records[modelId];
782
+ if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
783
+ records[modelId] = record;
784
+ this.persist();
785
+ }
786
+ /** Drop every record; used by the card's explicit "clear" action. */
787
+ clear() {
788
+ this.records = {};
789
+ this.persist();
790
+ }
791
+ /** Every record currently held, for status display. */
792
+ all() {
793
+ return { ...this.load() };
794
+ }
795
+ /** Build a record stamped with this store's clock, version, and account. */
796
+ record(fingerprint, validation, efforts, account) {
797
+ return {
798
+ fingerprint,
799
+ validation,
800
+ efforts: validation === "validating" ? [...efforts] : [],
801
+ probedAtMs: this.now(),
802
+ pluginVersion: this.pluginVersion,
803
+ account
804
+ };
805
+ }
806
+ /**
807
+ * Write through a temporary file and rename, so a crash mid-write cannot
808
+ * leave a half-parsed document that reads as "no records" and silently drops
809
+ * every observation.
810
+ */
811
+ persist() {
812
+ const directory = dirname(this.path);
813
+ try {
814
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
815
+ const document = {
816
+ version: PROBE_FORMAT_VERSION,
817
+ records: this.load()
818
+ };
819
+ const temporary = resolve(`${this.path}.tmp`);
820
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
821
+ renameSync(temporary, this.path);
822
+ } catch {}
823
+ }
824
+ };
825
+ /**
826
+ * Order observations newest-first for display.
827
+ *
828
+ * The store keeps insertion order so the file reads chronologically, but the
829
+ * card wants the most recent detection at the top: a sweep the user just ran
830
+ * should not appear below every earlier one, which is what appending to an
831
+ * insertion-ordered list does.
832
+ */
833
+ function newestFirst(records) {
834
+ return [...records].sort((a, b) => b.probedAt - a.probedAt);
835
+ }
836
+ //#endregion
837
+ //#region src/probe-service.ts
838
+ /**
839
+ * Serial probe runner. One instance is shared by the manual API and any
840
+ * future automatic trigger, so the two can never overlap.
841
+ */
842
+ var WorkBuddyProbeService = class {
843
+ options;
844
+ queue = Promise.resolve();
845
+ pending = /* @__PURE__ */ new Map();
846
+ running = false;
847
+ constructor(options) {
848
+ this.options = options;
849
+ }
850
+ /** Whether a sweep is in flight right now. */
851
+ isRunning() {
852
+ return this.running;
853
+ }
854
+ /**
855
+ * The record the adapter may use for this model, or `undefined`.
856
+ *
857
+ * Applies the plan's precedence (§5): a declared set always wins, so a model
858
+ * that declares `supportedEfforts` is never answered from an observation.
859
+ */
860
+ recordFor(modelId) {
861
+ const info = this.options.catalog.current().find((model) => model.id === modelId);
862
+ if (info === void 0) return void 0;
863
+ if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
864
+ const account = this.options.account();
865
+ if (account === void 0) return void 0;
866
+ return this.options.store.get(modelId, fingerprintModel(info), account);
867
+ }
868
+ /**
869
+ * Probe one model, serially.
870
+ *
871
+ * The authenticated manual route supplies one-request consent after UI
872
+ * confirmation. Other callers must pass the configured consent gate.
873
+ * Manual consent never changes the automatic-probing configuration.
874
+ * Explicit requests bypass historical results, but share an ongoing run.
875
+ */
876
+ async probe(modelId, manualConsent = false) {
877
+ if (!manualConsent && !this.options.consent()) return {
878
+ state: "unavailable",
879
+ reason: "probing is not authorized"
880
+ };
881
+ if (this.options.catalog.current().find((model) => model.id === modelId) === void 0) return {
882
+ state: "unavailable",
883
+ reason: `unknown model: ${modelId}`
884
+ };
885
+ const account = this.options.account();
886
+ if (account === void 0) return {
887
+ state: "unavailable",
888
+ reason: "no WorkBuddy credential"
889
+ };
890
+ const pendingKey = JSON.stringify([account, modelId]);
891
+ const pending = this.pending.get(pendingKey);
892
+ if (pending !== void 0) return pending;
893
+ const run = this.queue.then(async () => {
894
+ const current = this.options.catalog.current().find((model) => model.id === modelId);
895
+ if (current === void 0) return {
896
+ state: "unavailable",
897
+ reason: `unknown model: ${modelId}`
898
+ };
899
+ if (!manualConsent && !this.options.consent()) return {
900
+ state: "unavailable",
901
+ reason: "probing is not authorized"
902
+ };
903
+ if (current.reasoning?.supports !== true || (current.reasoning.supportedEfforts?.length ?? 0) > 0) return {
904
+ state: "unavailable",
905
+ reason: "model does not need detection"
906
+ };
907
+ const cached = this.recordFor(modelId);
908
+ if (!manualConsent && cached !== void 0 && cached.validation !== "unknown") return {
909
+ state: "ok",
910
+ validation: cached.validation,
911
+ efforts: cached.efforts,
912
+ requests: 0
913
+ };
914
+ if (this.options.account() !== account) return {
915
+ state: "unavailable",
916
+ reason: "account changed before detection"
917
+ };
918
+ const credential = await this.options.credentials.current();
919
+ if (credential === void 0) return {
920
+ state: "unavailable",
921
+ reason: "no WorkBuddy credential"
922
+ };
923
+ const send = this.options.send === void 0 ? (effort, signal) => this.options.client.probeEffort(credential, modelId, effort, signal) : this.options.send(modelId);
924
+ this.running = true;
925
+ try {
926
+ const outcome = await probeModel({
927
+ send,
928
+ ...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
929
+ });
930
+ if (this.options.account() !== account) return {
931
+ state: "unavailable",
932
+ reason: "account changed during detection"
933
+ };
934
+ const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts, account);
935
+ this.options.store.set(modelId, record);
936
+ if (outcome.validation === "unknown") return {
937
+ state: "unavailable",
938
+ reason: outcome.reason
939
+ };
940
+ return {
941
+ state: "ok",
942
+ validation: outcome.validation,
943
+ efforts: record.efforts,
944
+ requests: outcome.requests
945
+ };
946
+ } finally {
947
+ this.running = false;
948
+ }
949
+ });
950
+ this.queue = run.catch(() => void 0);
951
+ this.pending.set(pendingKey, run);
952
+ try {
953
+ return await run;
954
+ } finally {
955
+ this.pending.delete(pendingKey);
956
+ }
957
+ }
958
+ };
506
959
  //#endregion
507
960
  //#region src/web-status.ts
508
961
  /** Redact token-like content before it crosses to the browser. */
509
962
  function safeMessage(error) {
510
963
  return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
511
964
  }
512
- function json(res, status, body) {
965
+ function json$1(res, status, body) {
513
966
  const payload = JSON.stringify(body);
514
967
  res.writeHead(status, {
515
968
  "Content-Type": "application/json",
@@ -533,7 +986,10 @@ function loopbackRequest(req) {
533
986
  */
534
987
  async function workBuddyWebStatus(deps) {
535
988
  const authStatus = await deps.store.status();
536
- if (authStatus.state !== "signed-in") return { status: "signed-out" };
989
+ if (authStatus.state !== "signed-in") return {
990
+ status: "signed-out",
991
+ ...authStatus.reason === void 0 ? {} : { reason: authStatus.reason }
992
+ };
537
993
  const status = {
538
994
  status: "signed-in",
539
995
  ...authStatus.nickname === void 0 ? {} : { nickname: authStatus.nickname },
@@ -541,61 +997,78 @@ async function workBuddyWebStatus(deps) {
541
997
  ...authStatus.source === void 0 ? {} : { source: authStatus.source },
542
998
  ...authStatus.expiresAtMs === void 0 ? {} : { expiresAt: authStatus.expiresAtMs }
543
999
  };
544
- const modelsField = deps.models().filter((model) => model.billing?.free === true || (model.billing?.badges?.length ?? 0) > 0).map((model) => {
1000
+ const modelsField = deps.models().map((model) => {
545
1001
  const rate = normalizeCredits(model.billing?.credits);
1002
+ const supported = model.supportedContextWindows ?? [];
1003
+ const maxContextWindow = supported.length > 0 ? Math.max(...supported) : void 0;
546
1004
  return {
547
1005
  id: model.id,
548
1006
  name: model.name,
549
1007
  ...model.billing?.free === true ? { free: true } : {},
550
1008
  ...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
551
- ...rate === void 0 ? {} : { credits: rate }
1009
+ ...rate === void 0 ? {} : { credits: rate },
1010
+ ...model.billing?.rateUnknown === true ? { rateUnknown: true } : {},
1011
+ ...typeof model.contextWindow === "number" && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {},
1012
+ ...maxContextWindow === void 0 || maxContextWindow === model.contextWindow ? {} : { maxContextWindow },
1013
+ ...typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 ? { maxInputTokens: model.maxInputTokens } : {}
552
1014
  };
553
1015
  });
554
- const statusWithModels = modelsField.length > 0 ? {
1016
+ const catalog = deps.catalog?.();
1017
+ const withCatalog = catalog === void 0 ? status : {
555
1018
  ...status,
1019
+ catalog
1020
+ };
1021
+ const statusWithModels = modelsField.length > 0 ? {
1022
+ ...withCatalog,
556
1023
  models: modelsField
557
- } : status;
1024
+ } : withCatalog;
1025
+ const probed = deps.probe === void 0 ? statusWithModels : {
1026
+ ...statusWithModels,
1027
+ probe: deps.probe(),
1028
+ ...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey }
1029
+ };
558
1030
  try {
559
1031
  const credential = await deps.store.current();
560
1032
  if (credential !== void 0) {
561
1033
  const credits = await deps.client.fetchCredits(credential);
562
1034
  return {
563
- ...statusWithModels,
1035
+ ...probed,
564
1036
  credits
565
1037
  };
566
1038
  }
567
1039
  } catch (error) {
568
1040
  return {
569
- ...statusWithModels,
1041
+ ...probed,
570
1042
  creditsError: safeMessage(error)
571
1043
  };
572
1044
  }
573
- return statusWithModels;
1045
+ return probed;
574
1046
  }
575
1047
  /** The status route's request handler, extracted so tests can mount it on a bare server. */
576
1048
  function workBuddyStatusHandler(deps) {
577
1049
  return async (req, res) => {
578
1050
  if (req.method !== "GET") {
579
- json(res, 405, { error: "method not allowed" });
1051
+ json$1(res, 405, { error: "method not allowed" });
580
1052
  return;
581
1053
  }
582
1054
  if (!loopbackRequest(req)) {
583
- json(res, 403, { error: "request-not-trusted" });
1055
+ json$1(res, 403, { error: "request-not-trusted" });
584
1056
  return;
585
1057
  }
586
1058
  try {
587
- json(res, 200, await workBuddyWebStatus(deps));
1059
+ json$1(res, 200, await workBuddyWebStatus(deps));
588
1060
  } catch (error) {
589
- json(res, 500, { error: safeMessage(error) });
1061
+ json$1(res, 500, { error: safeMessage(error) });
590
1062
  }
591
1063
  };
592
1064
  }
593
1065
  /** Mount the GET status route on an optional webServer context. */
594
1066
  function registerWorkBuddyStatusRoute(ctx, deps) {
1067
+ const path = deps.path ?? "/plugins/dsh-workbuddy-connect/status";
595
1068
  ctx.effect(() => {
596
1069
  const dispose = ctx.webServer.register({
597
1070
  kind: "exact",
598
- path: WORKBUDDY_STATUS_PATH,
1071
+ path,
599
1072
  handler: workBuddyStatusHandler(deps)
600
1073
  });
601
1074
  return () => {
@@ -604,13 +1077,153 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
604
1077
  }, "dsh-workbuddy-connect: Web status route");
605
1078
  }
606
1079
  //#endregion
1080
+ //#region src/probe-route.ts
1081
+ /**
1082
+ * Probe control route: the only state-changing endpoint the plugin exposes.
1083
+ *
1084
+ * Two guards, because they stop different things (see `docs/reasoning-effort-probe-plan.md`
1085
+ * §6.4 and the v0.3.1 note in AGENTS.md about their exact scope):
1086
+ *
1087
+ * 1. **Loopback Host + Origin**, shared with the status route. This drops
1088
+ * DNS-rebinding pages, whose requests arrive addressed to the attacker's
1089
+ * domain.
1090
+ * 2. **An in-process random key**, minted per process and handed only to the
1091
+ * same-origin card. Loopback alone is *not* authentication — any local
1092
+ * process can write `Host: 127.0.0.1` — so a route that spends the user's
1093
+ * credit must prove the caller was told the key.
1094
+ *
1095
+ * The route never accepts a prompt, a model id outside the live catalog, or a
1096
+ * sentinel from the browser: a probe request is assembled entirely host-side.
1097
+ *
1098
+ * @module dsh-workbuddy-connect/probe-route
1099
+ */
1100
+ /** Largest control body accepted; these payloads are a few dozen bytes. */
1101
+ const MAX_BODY_BYTES = 4096;
1102
+ /** Mint the per-process control key. */
1103
+ function createProbeKey() {
1104
+ return randomBytes(24).toString("hex");
1105
+ }
1106
+ /**
1107
+ * Constant-time key comparison; a length mismatch is a failure, not a crash.
1108
+ */
1109
+ function keyMatches(expected, presented) {
1110
+ if (presented === void 0 || presented.length !== expected.length) return false;
1111
+ const a = Buffer.from(expected);
1112
+ const b = Buffer.from(presented);
1113
+ return a.length === b.length && timingSafeEqual(a, b);
1114
+ }
1115
+ function json(res, status, body) {
1116
+ const payload = JSON.stringify(body);
1117
+ res.writeHead(status, {
1118
+ "Content-Type": "application/json",
1119
+ "Content-Length": Buffer.byteLength(payload)
1120
+ });
1121
+ res.end(payload);
1122
+ }
1123
+ /** Read the request body with a hard ceiling. */
1124
+ async function readBody(req) {
1125
+ const chunks = [];
1126
+ let total = 0;
1127
+ for await (const chunk of req) {
1128
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1129
+ total += buffer.length;
1130
+ if (total > MAX_BODY_BYTES) return void 0;
1131
+ chunks.push(buffer);
1132
+ }
1133
+ return Buffer.concat(chunks).toString("utf8");
1134
+ }
1135
+ /** Parse and shape-check an action; unknown fields are ignored, not trusted. */
1136
+ function parseAction(text) {
1137
+ let parsed;
1138
+ try {
1139
+ parsed = JSON.parse(text);
1140
+ } catch {
1141
+ return;
1142
+ }
1143
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1144
+ const wrapped = parsed;
1145
+ const action = wrapped["action"];
1146
+ if (action === "clear") return { action: "clear" };
1147
+ if (action === "refresh") return { action: "refresh" };
1148
+ if (action === "probe") {
1149
+ const model = wrapped["model"];
1150
+ if (typeof model !== "string" || model.trim() === "") return void 0;
1151
+ return {
1152
+ action: "probe",
1153
+ model: model.trim()
1154
+ };
1155
+ }
1156
+ }
1157
+ /**
1158
+ * The control route's handler, extracted so tests can mount it on a bare
1159
+ * server with a known key.
1160
+ */
1161
+ function workBuddyProbeHandler(deps, key) {
1162
+ return async (req, res) => {
1163
+ if (req.method !== "POST") {
1164
+ json(res, 405, { error: "method not allowed" });
1165
+ return;
1166
+ }
1167
+ if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
1168
+ json(res, 403, { error: "request-not-trusted" });
1169
+ return;
1170
+ }
1171
+ if (!keyMatches(key, req.headers["x-workbuddy-probe-key"])) {
1172
+ json(res, 403, { error: "invalid-probe-key" });
1173
+ return;
1174
+ }
1175
+ const body = await readBody(req);
1176
+ if (body === void 0) {
1177
+ json(res, 413, { error: "body too large" });
1178
+ return;
1179
+ }
1180
+ const action = parseAction(body);
1181
+ if (action === void 0) {
1182
+ json(res, 400, { error: "invalid action" });
1183
+ return;
1184
+ }
1185
+ try {
1186
+ if (action.action === "clear") {
1187
+ deps.clear();
1188
+ json(res, 200, { state: "cleared" });
1189
+ return;
1190
+ }
1191
+ if (action.action === "refresh") {
1192
+ if (deps.refresh === void 0) {
1193
+ json(res, 404, { error: "refresh-not-supported" });
1194
+ return;
1195
+ }
1196
+ json(res, 200, await deps.refresh());
1197
+ return;
1198
+ }
1199
+ json(res, 200, await deps.probe(action.model));
1200
+ } catch (error) {
1201
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
1202
+ }
1203
+ };
1204
+ }
1205
+ /** Mount the POST probe-control route on an optional webServer context. */
1206
+ function registerWorkBuddyProbeRoute(ctx, deps, key) {
1207
+ const path = deps.path ?? "/plugins/dsh-workbuddy-connect/probe";
1208
+ ctx.effect(() => {
1209
+ const dispose = ctx.webServer.register({
1210
+ kind: "exact",
1211
+ path,
1212
+ handler: workBuddyProbeHandler(deps, key)
1213
+ });
1214
+ return () => {
1215
+ dispose();
1216
+ };
1217
+ }, "dsh-workbuddy-connect: probe control route");
1218
+ }
1219
+ //#endregion
607
1220
  //#region src/index.ts
608
1221
  /** Stable Cordis plugin name. */
609
1222
  const name = "llm-workbuddy";
610
1223
  /** The model registry required before the provider can register. */
611
1224
  const inject = ["llm"];
612
1225
  /**
613
- * Settings namespace owning the configuration card.
1226
+ * Settings namespace owning the CN card's section.
614
1227
  *
615
1228
  * DSH 0.1.2 dropped the `settingsNamespace()` branding function: a namespace is
616
1229
  * now a nominal string, validated by the type system where it is used rather
@@ -622,106 +1235,563 @@ const inject = ["llm"];
622
1235
  * their namespaces as plain string literals).
623
1236
  */
624
1237
  const WORKBUDDY_SETTINGS_NS = "workbuddy";
625
- const Config = z.object({ authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)") });
626
1238
  /**
627
- * Start the loopback endpoint, register the `workbuddy` provider, and
628
- * refresh the model catalog from the upstream once credentials allow it.
629
- * The static fallback catalog serves from the first moment, so an offline
630
- * upstream never leaves the provider empty.
1239
+ * Settings namespace owning the international card's section.
1240
+ *
1241
+ * One namespace per card, not one shared: the settings Plugins tab dispatches a
1242
+ * card by rendering `settings.plugin.item` with `entryKey = ns` for each
1243
+ * namespace the Host serves, and skips an entry whose key names no served
1244
+ * namespace. With a single installed section, the international card registers
1245
+ * into the slot but is never rendered — the card list is built from the Host's
1246
+ * sections, not from the slot's entries. Each card therefore needs its own
1247
+ * installed section whose namespace equals the card's slot key.
631
1248
  */
632
- function apply(ctx, config) {
1249
+ const WORKBUDDY_AI_SETTINGS_NS = "workbuddy-ai";
1250
+ /**
1251
+ * How often the credential files are re-checked, in milliseconds.
1252
+ *
1253
+ * A startup-only catalog fetch cannot notice a sign-in that happens while DSH
1254
+ * is already running, so the model group would not appear until a restart. This
1255
+ * poll is a cheap existence/parse read of at most a few local files: it never
1256
+ * contacts the network and never runs a reasoning probe.
1257
+ *
1258
+ * `DSH_WORKBUDDY_POLL_MS` overrides it. That exists so the sweep can be
1259
+ * exercised end to end in tests and shortened while diagnosing a slow sign-in
1260
+ * on a real machine; it is not a product setting and no UI exposes it. The
1261
+ * value is clamped to a sane range so a mistaken override cannot turn the poll
1262
+ * into a busy loop.
1263
+ */
1264
+ const CREDENTIAL_POLL_MS = 3e4;
1265
+ /** Floor and ceiling for the overridable poll interval. */
1266
+ const MIN_POLL_MS = 100;
1267
+ const MAX_POLL_MS = 864e5;
1268
+ /** Resolve the sweep interval, honoring the override when it is usable. */
1269
+ function credentialPollMs() {
1270
+ const override = Number(process.env["DSH_WORKBUDDY_POLL_MS"]);
1271
+ if (!Number.isFinite(override) || override < MIN_POLL_MS) return CREDENTIAL_POLL_MS;
1272
+ return Math.min(override, MAX_POLL_MS);
1273
+ }
1274
+ /**
1275
+ * How long to wait before retrying a catalog fetch that failed.
1276
+ *
1277
+ * The credential sweep deliberately does not re-fetch a catalog it already has
1278
+ * (a same-identity token rotation carries no new model information). But a
1279
+ * *failed* fetch must not be treated the same way: without a retry, one
1280
+ * transient network blip at startup would leave the group on the built-in
1281
+ * fallback roster until the user noticed and pressed refresh. This bound keeps
1282
+ * that recovery automatic while still honoring the "not every round" rule — at
1283
+ * most one attempt per interval, and none at all once a live catalog lands.
1284
+ *
1285
+ * Expressed as a multiple of the sweep rather than a fixed duration so the two
1286
+ * stay in proportion under the `DSH_WORKBUDDY_POLL_MS` override.
1287
+ */
1288
+ const CATALOG_RETRY_SWEEPS = 10;
1289
+ /** Explicit CN desktop auth-file path (shared by the plugin schema and its section). */
1290
+ const AUTH_FILE_FIELD = z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)");
1291
+ /** Explicit international desktop auth-file path (shared by the plugin schema and its section). */
1292
+ const AUTH_FILE_AI_FIELD = z.string().description("WorkBuddy AI desktop auth file (defaults to the app's own location)");
1293
+ /** Probe authorization (shared by the plugin schema and the CN section). */
1294
+ const PROBE_CONSENT_FIELD = z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
1295
+ const Config = z.object({
1296
+ authFile: AUTH_FILE_FIELD,
1297
+ authFileAI: AUTH_FILE_AI_FIELD,
1298
+ probeConsent: PROBE_CONSENT_FIELD
1299
+ });
1300
+ /**
1301
+ * The CN card's settings section: only the fields that card edits.
1302
+ *
1303
+ * A section is what makes its namespace "served", which is what the Plugins
1304
+ * tab dispatches a card by — so the schema and the card must stay split the
1305
+ * same way. `probeConsent` lives here because it predates the second variant;
1306
+ * it gates no current code path (only manual, per-click-confirmed probes run),
1307
+ * so it is left where existing users set it rather than moved and re-asked.
1308
+ */
1309
+ const CN_SECTION = z.object({
1310
+ authFile: AUTH_FILE_FIELD,
1311
+ probeConsent: PROBE_CONSENT_FIELD
1312
+ });
1313
+ /** The international card's settings section: only its own auth-file path. */
1314
+ const AI_SECTION = z.object({ authFileAI: AUTH_FILE_AI_FIELD });
1315
+ /** Stable identity key used by credentials, probe records, and catalog entries. */
1316
+ function credentialIdentity(credential) {
1317
+ return `${credential.uid}:${credential.enterpriseId ?? ""}`;
1318
+ }
1319
+ /** Read the configured explicit auth-file path for one variant. */
1320
+ function configuredAuthFile(config, variant) {
1321
+ return variant.id === CN_VARIANT.id ? config.authFile : config.authFileAI;
1322
+ }
1323
+ /** The settings namespace a variant's card and provider directory entry use. */
1324
+ function settingsNamespaceFor(variant) {
1325
+ return variant.id === CN_VARIANT.id ? WORKBUDDY_SETTINGS_NS : WORKBUDDY_AI_SETTINGS_NS;
1326
+ }
1327
+ /**
1328
+ * The static catalog a variant serves before its first successful fetch.
1329
+ *
1330
+ * Each variant has its own roster: the two endpoints share several model ids
1331
+ * but not their billing, context windows, or reasoning sets, so one shared
1332
+ * fallback would misdescribe whichever variant it was not captured from.
1333
+ */
1334
+ function fallbackFor(variant) {
1335
+ return variant.id === CN_VARIANT.id ? FALLBACK_WORKBUDDY_MODELS : FALLBACK_WORKBUDDY_AI_MODELS;
1336
+ }
1337
+ /** Build one variant's stores and probe state. */
1338
+ function createVariantRuntime(config, variant, current, identityOf) {
633
1339
  const client = new WorkBuddyUpstreamClient();
1340
+ const configured = configuredAuthFile(config, variant);
634
1341
  const store = new WorkBuddyCredentialStore({
635
- ...config.authFile === void 0 ? {} : { desktopPath: config.authFile },
1342
+ variant,
1343
+ ...configured === void 0 ? {} : { desktopPath: configured },
636
1344
  refresh: (credential) => client.refreshToken(credential)
637
1345
  });
638
- const catalog = new WorkBuddyCatalog();
639
- const shim = createWorkBuddyShim({
1346
+ const fallback = fallbackFor(variant);
1347
+ const catalog = new WorkBuddyCatalog(fallback);
1348
+ catalog.setVisible(false);
1349
+ const probeStore = new WorkBuddyProbeStore({
1350
+ pluginVersion: WORKBUDDY_CONNECT_VERSION,
1351
+ path: workbuddyProbePath(variant.probeFilename)
1352
+ });
1353
+ const savedCatalogs = new WorkBuddyCatalogStore(workbuddyCatalogPath(variant.catalogFilename));
1354
+ return {
1355
+ variant,
640
1356
  store,
641
1357
  client,
642
1358
  catalog,
643
- logger: ctx.logger
1359
+ probeStore,
1360
+ probeService: new WorkBuddyProbeService({
1361
+ store: probeStore,
1362
+ catalog,
1363
+ credentials: store,
1364
+ client,
1365
+ consent: () => current().probeConsent === true,
1366
+ account: () => identityOf(variant.id)
1367
+ }),
1368
+ savedCatalogs,
1369
+ fallback,
1370
+ catalogSource: "fallback",
1371
+ catalogFetchedAtMs: void 0,
1372
+ catalogError: void 0,
1373
+ lastFetchAtMs: 0,
1374
+ catalogGeneration: 0,
1375
+ inflightFetch: void 0,
1376
+ invalidate: () => {},
1377
+ registered: false
1378
+ };
1379
+ }
1380
+ /** The catalog provenance the card displays. */
1381
+ function catalogSection(runtime) {
1382
+ const fetch = runtime.client.lastCatalog;
1383
+ return {
1384
+ source: runtime.catalogSource,
1385
+ ...runtime.catalogFetchedAtMs === void 0 ? {} : { fetchedAt: runtime.catalogFetchedAtMs },
1386
+ ...fetch?.appVersion === void 0 ? {} : { appVersion: fetch.appVersion.version },
1387
+ ...runtime.catalogError === void 0 ? {} : { error: runtime.catalogError }
1388
+ };
1389
+ }
1390
+ /**
1391
+ * Whether a model can be probed by hand: it reasons and the upstream declares
1392
+ * no effort set for it.
1393
+ *
1394
+ * Deliberately *not* filtered by whether a result already exists. Dropping a
1395
+ * model once it has been detected made the list shrink with use, so
1396
+ * re-detecting one model — after an upstream change, say — meant clearing every
1397
+ * other result first. The list stays stable and the card marks which entries
1398
+ * already have an answer.
1399
+ */
1400
+ function isProbeCandidate(info) {
1401
+ if (info.reasoning?.supports !== true) return false;
1402
+ return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
1403
+ }
1404
+ /** Compact probe state for one card: consent, candidates, observations. */
1405
+ function probeSection(runtime, consent) {
1406
+ const models = runtime.catalog.current();
1407
+ const results = models.flatMap((info) => {
1408
+ const record = runtime.probeService.recordFor(info.id);
1409
+ if (record === void 0) return [];
1410
+ return [{
1411
+ id: info.id,
1412
+ name: info.name,
1413
+ validation: record.validation,
1414
+ efforts: record.efforts,
1415
+ probedAt: record.probedAtMs
1416
+ }];
644
1417
  });
645
- ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
1418
+ return {
1419
+ consent,
1420
+ running: runtime.probeService.isRunning(),
1421
+ candidates: models.filter(isProbeCandidate).map((info) => info.id),
1422
+ results: newestFirst(results)
1423
+ };
1424
+ }
1425
+ /**
1426
+ * Start one variant: its loopback endpoint, provider registration, and
1427
+ * configuration-card wiring.
1428
+ *
1429
+ * Registration waits for the shim to hold a port, because the provider's
1430
+ * models read the shim origin at construction time. A failure here is
1431
+ * contained to this variant: the caller logs it and the other keeps working.
1432
+ *
1433
+ * @returns whether the provider registered.
1434
+ */
1435
+ async function startVariant(ctx, runtime) {
1436
+ const { variant, store, client, catalog, probeService } = runtime;
1437
+ const shim = createWorkBuddyShim({
646
1438
  store,
647
1439
  client,
648
- models: () => catalog.current()
649
- }));
1440
+ catalog,
1441
+ logger: ctx.logger
1442
+ });
1443
+ try {
1444
+ await shim.ready;
1445
+ } catch (error) {
1446
+ ctx.logger.error(`dsh-workbuddy-connect: ${variant.displayName} loopback endpoint failed to start`, error);
1447
+ return false;
1448
+ }
1449
+ try {
1450
+ const workbuddy = createWorkBuddyAdapter({
1451
+ providerId: variant.id,
1452
+ displayName: variant.displayName,
1453
+ shim,
1454
+ store,
1455
+ catalog,
1456
+ resolveAttachments: () => ctx.get("attachments"),
1457
+ observe: (modelId) => probeService.recordFor(modelId)
1458
+ });
1459
+ workbuddy.invalidate;
1460
+ runtime.invalidate = () => {
1461
+ workbuddy.invalidate();
1462
+ ctx.emit("llm/adapters-updated");
1463
+ };
1464
+ let releaseAdapter;
1465
+ let releaseDirectory;
1466
+ try {
1467
+ releaseAdapter = ctx.llm.registerAdapter([variant.id], workbuddy.adapter);
1468
+ releaseDirectory = ctx.llm.registerConfigurableProviders([{
1469
+ provider: variant.id,
1470
+ displayName: variant.displayName,
1471
+ settingsNs: settingsNamespaceFor(variant),
1472
+ settingsPath: [],
1473
+ declared: false
1474
+ }]);
1475
+ } finally {
1476
+ if (releaseAdapter === void 0 || releaseDirectory === void 0) {
1477
+ releaseAdapter?.();
1478
+ releaseDirectory?.();
1479
+ }
1480
+ }
1481
+ try {
1482
+ ctx.effect(() => () => {
1483
+ releaseAdapter?.();
1484
+ releaseDirectory?.();
1485
+ shim.close();
1486
+ });
1487
+ } catch {
1488
+ releaseAdapter?.();
1489
+ releaseDirectory?.();
1490
+ shim.close();
1491
+ }
1492
+ runtime.registered = true;
1493
+ return true;
1494
+ } catch (error) {
1495
+ ctx.logger.error(`dsh-workbuddy-connect: ${variant.displayName} provider registration failed`, error);
1496
+ shim.close();
1497
+ return false;
1498
+ }
1499
+ }
1500
+ /**
1501
+ * Start both variants: their loopback endpoints, the `workbuddy` and
1502
+ * `workbuddy-ai` providers, their configuration cards, and their
1503
+ * credential-driven catalog lifecycles.
1504
+ *
1505
+ * Each variant registers unconditionally; what varies is whether its catalog is
1506
+ * *visible*. An empty catalog is how DSH hides a model group (the host filters
1507
+ * out groups with no models), which keeps a sign-in that happens after startup
1508
+ * working without re-registering the provider.
1509
+ */
1510
+ function apply(ctx, config) {
650
1511
  let current = () => config;
1512
+ /** Timers and in-flight work belonging to this plugin instance. */
1513
+ let stopped = false;
1514
+ const timers = [];
1515
+ /**
1516
+ * The account identity each variant last published a catalog for. Keeps a
1517
+ * same-identity token rotation from re-fetching, and lets a late response
1518
+ * from a previous identity be discarded instead of overwriting a newer one.
1519
+ */
1520
+ const lastIdentities = /* @__PURE__ */ new Map();
1521
+ const runtimes = WORKBUDDY_VARIANTS.map((variant) => createVariantRuntime(config, variant, () => current(), (id) => lastIdentities.get(id)));
1522
+ const probeKey = createProbeKey();
1523
+ /**
1524
+ * Point a variant at an account identity, invalidating whatever the previous
1525
+ * one left behind.
1526
+ *
1527
+ * One helper for all four transitions (sweep sign-in, sweep sign-out, manual
1528
+ * refresh, manual refresh sign-out) because each of them used to do its own
1529
+ * partial version, and the manual path forgot pieces the sweep did. Every
1530
+ * transition bumps {@link VariantRuntime.catalogGeneration}, which is what
1531
+ * makes an in-flight request from before the change refuse to write back.
1532
+ *
1533
+ * Probe observations are dropped whenever the account actually changes —
1534
+ * including sign-out, and including the "signed out, then in as someone else"
1535
+ * sequence that used to look like a first sighting and let the new account
1536
+ * inherit the old one's detected levels. They are deliberately NOT cleared on
1537
+ * a first sign-in: no previous account's data could leak there, and clearing
1538
+ * would delete records this very account owns (written before a restart, or
1539
+ * seeded while all of this is running).
1540
+ *
1541
+ * @param identity - the account now in effect, or `undefined` when signed out.
1542
+ */
1543
+ const adoptIdentity = (runtime, identity) => {
1544
+ const id = runtime.variant.id;
1545
+ const known = lastIdentities.get(id);
1546
+ if (known === identity) return;
1547
+ const hadCredential = known !== void 0;
1548
+ if (identity === void 0) lastIdentities.delete(id);
1549
+ else lastIdentities.set(id, identity);
1550
+ runtime.catalogGeneration += 1;
1551
+ runtime.inflightFetch?.controller.abort();
1552
+ runtime.inflightFetch = void 0;
1553
+ if (hadCredential && known !== identity) {
1554
+ runtime.probeStore.clear();
1555
+ runtime.invalidate();
1556
+ }
1557
+ if (identity === void 0) {
1558
+ if (known !== void 0) runtime.savedCatalogs.delete(known);
1559
+ runtime.catalog.set(runtime.fallback);
1560
+ runtime.catalogSource = "fallback";
1561
+ runtime.catalogFetchedAtMs = void 0;
1562
+ runtime.catalogError = void 0;
1563
+ if (runtime.catalog.setVisible(false)) runtime.invalidate();
1564
+ return;
1565
+ }
1566
+ const saved = runtime.savedCatalogs.get(identity);
1567
+ if (saved !== void 0) {
1568
+ runtime.catalog.set([...saved.models]);
1569
+ runtime.catalogSource = "saved";
1570
+ runtime.catalogFetchedAtMs = saved.fetchedAtMs;
1571
+ } else {
1572
+ runtime.catalog.set(runtime.fallback);
1573
+ runtime.catalogSource = "fallback";
1574
+ runtime.catalogFetchedAtMs = void 0;
1575
+ }
1576
+ runtime.catalogError = void 0;
1577
+ runtime.catalog.setVisible(true);
1578
+ runtime.invalidate();
1579
+ };
1580
+ ctx.inject(["webServer"], (webCtx) => {
1581
+ for (const runtime of runtimes) {
1582
+ registerWorkBuddyStatusRoute(webCtx, {
1583
+ path: runtime.variant.statusPath,
1584
+ store: runtime.store,
1585
+ client: runtime.client,
1586
+ models: () => runtime.catalog.current(),
1587
+ catalog: () => catalogSection(runtime),
1588
+ probe: () => probeSection(runtime, current().probeConsent === true),
1589
+ probeKey
1590
+ });
1591
+ registerWorkBuddyProbeRoute(webCtx, {
1592
+ path: runtime.variant.probePath,
1593
+ probe: async (modelId) => {
1594
+ const result = await runtime.probeService.probe(modelId, true);
1595
+ if (result.state === "ok") runtime.invalidate();
1596
+ return result;
1597
+ },
1598
+ clear: () => {
1599
+ runtime.probeStore.clear();
1600
+ runtime.invalidate();
1601
+ },
1602
+ refresh: async () => {
1603
+ if (stopped) return {
1604
+ state: "failed",
1605
+ reason: "plugin is stopping"
1606
+ };
1607
+ let credential;
1608
+ try {
1609
+ credential = await runtime.store.current();
1610
+ } catch (error) {
1611
+ return {
1612
+ state: "failed",
1613
+ reason: error instanceof Error ? error.message.slice(0, 300) : String(error)
1614
+ };
1615
+ }
1616
+ if (credential === void 0) {
1617
+ adoptIdentity(runtime, void 0);
1618
+ return { state: "signed-out" };
1619
+ }
1620
+ const identity = credentialIdentity(credential);
1621
+ adoptIdentity(runtime, identity);
1622
+ await fetchCatalog(runtime, identity);
1623
+ return runtime.catalogError === void 0 ? {
1624
+ state: "refreshed",
1625
+ reason: `${runtime.catalog.current().length} models`
1626
+ } : {
1627
+ state: "failed",
1628
+ reason: runtime.catalogError
1629
+ };
1630
+ }
1631
+ }, probeKey);
1632
+ }
1633
+ });
651
1634
  ctx.inject(["settings"], (settingsCtx) => {
652
- settingsCtx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, config, {
1635
+ /** Section sources; each falls back to its own slice when its side unloads. */
1636
+ const sources = {
1637
+ cn: () => config,
1638
+ ai: () => config
1639
+ };
1640
+ /** Merge both sections into the whole config the rest of the plugin reads. */
1641
+ const merged = () => ({
1642
+ ...sources.cn().authFile === void 0 ? {} : { authFile: sources.cn().authFile },
1643
+ ...sources.cn().probeConsent === void 0 ? {} : { probeConsent: sources.cn().probeConsent },
1644
+ ...sources.ai().authFileAI === void 0 ? {} : { authFileAI: sources.ai().authFileAI }
1645
+ });
1646
+ const repointStores = () => {
1647
+ const next = merged();
1648
+ for (const runtime of runtimes) runtime.store.setDesktopPath(configuredAuthFile(next, runtime.variant));
1649
+ };
1650
+ settingsCtx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, CN_SECTION, config, {
653
1651
  setSource(source) {
654
- current = source;
1652
+ sources.cn = source;
1653
+ current = merged;
655
1654
  },
656
- onChange() {
657
- const next = current().authFile;
658
- store.setDesktopPath(next);
659
- }
1655
+ onChange: repointStores
1656
+ });
1657
+ settingsCtx.settings.installSection(ctx, WORKBUDDY_AI_SETTINGS_NS, AI_SECTION, config, {
1658
+ setSource(source) {
1659
+ sources.ai = source;
1660
+ current = merged;
1661
+ },
1662
+ onChange: repointStores
660
1663
  });
661
1664
  });
662
- let stopped = false;
663
1665
  ctx.effect(() => () => {
664
1666
  stopped = true;
665
- shim.close();
1667
+ for (const timer of timers) clearInterval(timer);
1668
+ timers.length = 0;
666
1669
  clearHostHeartbeat();
667
1670
  });
668
- shim.ready.then(() => {
669
- if (stopped) return;
670
- let invalidate;
671
- try {
672
- const workbuddy = createWorkBuddyAdapter({
673
- shim,
674
- store,
675
- catalog,
676
- resolveAttachments: () => ctx.get("attachments")
677
- });
678
- invalidate = workbuddy.invalidate;
679
- let releaseAdapter;
680
- let releaseDirectory;
1671
+ /**
1672
+ * Fetch one variant's catalog for the current credential.
1673
+ *
1674
+ * Shared by the credential sweep and the card's manual refresh, and written
1675
+ * so that concurrent callers cost one request and cannot interleave badly:
1676
+ *
1677
+ * - **One request at a time.** A second caller joins the in-flight fetch
1678
+ * instead of starting its own (spec §5: one catalog request per variant at
1679
+ * a time).
1680
+ * - **Generation-checked write-back.** The request records the generation it
1681
+ * started under and writes nothing if the generation moved on — which is
1682
+ * what a slow answer from a superseded account must not do. Checking only
1683
+ * the *identity* was not enough: two refreshes for the same account can
1684
+ * still finish out of order, and the older one would win.
1685
+ * - **`resolve()`, not `current()`.** Only `resolve()` performs the locked,
1686
+ * single-flight token renewal. Reading `current()` meant an expired token
1687
+ * made every catalog request fail until something else happened to refresh
1688
+ * it, leaving the group on the fallback roster.
1689
+ */
1690
+ const fetchCatalog = async (runtime, identity) => {
1691
+ const inflight = runtime.inflightFetch;
1692
+ const generation = runtime.catalogGeneration;
1693
+ if (inflight !== void 0 && inflight.identity === identity && inflight.generation === generation) return inflight.promise;
1694
+ inflight?.controller.abort();
1695
+ const controller = new AbortController();
1696
+ let run;
1697
+ run = (async () => {
1698
+ let models;
681
1699
  try {
682
- releaseAdapter = ctx.llm.registerAdapter([WORKBUDDY_PROVIDER], workbuddy.adapter);
683
- releaseDirectory = ctx.llm.registerConfigurableProviders([{
684
- provider: WORKBUDDY_PROVIDER,
685
- displayName: "WorkBuddy",
686
- settingsNs: WORKBUDDY_SETTINGS_NS,
687
- settingsPath: [],
688
- declared: false
689
- }]);
690
- } finally {
691
- if (releaseAdapter === void 0 || releaseDirectory === void 0) {
692
- releaseAdapter?.();
693
- releaseDirectory?.();
1700
+ const credential = await runtime.store.resolve();
1701
+ const resolvedIdentity = credentialIdentity(credential);
1702
+ if (resolvedIdentity !== identity) {
1703
+ adoptIdentity(runtime, resolvedIdentity);
1704
+ await fetchCatalog(runtime, resolvedIdentity);
1705
+ return;
694
1706
  }
1707
+ models = await runtime.client.fetchModels(credential, controller.signal);
1708
+ const latest = await runtime.store.current();
1709
+ const latestIdentity = latest === void 0 ? void 0 : credentialIdentity(latest);
1710
+ if (latestIdentity !== identity) {
1711
+ adoptIdentity(runtime, latestIdentity);
1712
+ if (latestIdentity !== void 0) await fetchCatalog(runtime, latestIdentity);
1713
+ return;
1714
+ }
1715
+ } catch (error) {
1716
+ if (stopped || runtime.catalogGeneration !== generation) return;
1717
+ runtime.lastFetchAtMs = Date.now();
1718
+ runtime.catalogError = error instanceof Error ? error.message.slice(0, 300) : String(error);
1719
+ ctx.logger.warn(`dsh-workbuddy-connect: ${runtime.variant.displayName} catalog unavailable; serving the fallback list`, error);
1720
+ runtime.invalidate();
1721
+ return;
695
1722
  }
696
- try {
697
- ctx.effect(() => () => {
698
- releaseAdapter?.();
699
- releaseDirectory?.();
700
- });
701
- } catch {
702
- releaseAdapter?.();
703
- releaseDirectory?.();
704
- }
705
- writeHostHeartbeat();
706
- } catch (error) {
707
- ctx.logger.error("dsh-workbuddy-connect: provider registration failed", error);
1723
+ if (stopped || runtime.catalogGeneration !== generation) return;
1724
+ runtime.lastFetchAtMs = Date.now();
1725
+ runtime.catalog.set([...models]);
1726
+ runtime.catalogSource = "live";
1727
+ runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
1728
+ runtime.catalogError = void 0;
1729
+ if (lastIdentities.get(runtime.variant.id) === identity) runtime.savedCatalogs.set(identity, {
1730
+ source: runtime.client.lastCatalog?.source ?? "unknown",
1731
+ fetchedAtMs: runtime.client.lastCatalog?.fetchedAtMs ?? Date.now(),
1732
+ models: [...models],
1733
+ ...runtime.client.lastCatalog?.appVersion === void 0 ? {} : { appVersion: runtime.client.lastCatalog.appVersion.version }
1734
+ });
1735
+ runtime.invalidate();
1736
+ })().finally(() => {
1737
+ if (runtime.inflightFetch?.promise === run) runtime.inflightFetch = void 0;
1738
+ });
1739
+ runtime.inflightFetch = {
1740
+ identity,
1741
+ generation,
1742
+ controller,
1743
+ promise: run
1744
+ };
1745
+ return run;
1746
+ };
1747
+ /**
1748
+ * Reconcile one variant with its credentials.
1749
+ *
1750
+ * Four transitions matter, and each is a different action:
1751
+ *
1752
+ * - **none → some** (first sighting): reveal the group and fetch a catalog.
1753
+ * - **none → some, identity changed**: additionally drop the previous
1754
+ * account's observations, so another user's probe answers cannot be read as
1755
+ * the new account's.
1756
+ * - **some → none**: hide the group and stop serving its models.
1757
+ * - **same identity**: nothing to do — the store refreshes tokens on demand,
1758
+ * and re-fetching on every rotation would hit the catalog endpoint for no
1759
+ * new information.
1760
+ */
1761
+ const syncVariant = async (runtime) => {
1762
+ if (stopped || !runtime.registered) return;
1763
+ const credential = await runtime.store.current().catch((error) => {
1764
+ ctx.logger.warn(`dsh-workbuddy-connect: ${runtime.variant.displayName} credential read failed`, error);
1765
+ });
1766
+ if (stopped) return;
1767
+ if (credential === void 0) {
1768
+ adoptIdentity(runtime, void 0);
708
1769
  return;
709
1770
  }
710
- (async () => {
711
- try {
712
- const credential = await store.current();
713
- if (credential === void 0 || stopped) return;
714
- const models = await client.fetchModels(credential);
715
- if (stopped) return;
716
- catalog.set([...models]);
717
- invalidate?.();
718
- } catch (error) {
719
- ctx.logger.warn("dsh-workbuddy-connect: dynamic model catalog unavailable; serving the static fallback list", error);
720
- }
721
- })();
722
- }).catch((error) => {
723
- ctx.logger.error("dsh-workbuddy-connect: loopback endpoint failed to start; provider not registered", error);
1771
+ const identity = credentialIdentity(credential);
1772
+ if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
1773
+ const stale = runtime.catalogSource !== "live";
1774
+ const due = Date.now() - runtime.lastFetchAtMs >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
1775
+ if (stale && due) await fetchCatalog(runtime, identity);
1776
+ return;
1777
+ }
1778
+ adoptIdentity(runtime, identity);
1779
+ await fetchCatalog(runtime, identity);
1780
+ };
1781
+ /** Run one reconcile sweep across both variants. */
1782
+ const syncAll = async () => {
1783
+ for (const runtime of runtimes) await syncVariant(runtime);
1784
+ };
1785
+ Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime))).then(() => {
1786
+ if (stopped) return;
1787
+ if (runtimes.some((runtime) => runtime.registered)) writeHostHeartbeat();
1788
+ syncAll();
1789
+ const timer = setInterval(() => {
1790
+ syncAll();
1791
+ }, credentialPollMs());
1792
+ timer.unref?.();
1793
+ timers.push(timer);
724
1794
  });
725
1795
  }
726
1796
  //#endregion
727
- export { Config, FALLBACK_WORKBUDDY_MODELS, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, inject, isHeartbeatProcessAlive, name, normalizeCredits, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionOf, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath };
1797
+ export { AI_VARIANT, CN_VARIANT, Config, FALLBACK_WORKBUDDY_AI_MODELS, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, WORKBUDDY_AI_SETTINGS_NS, WORKBUDDY_APP_VERSION_FILENAME, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CATALOG_FILENAME, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_VARIANTS, WorkBuddyCatalog, WorkBuddyCatalogStore, WorkBuddyCredentialStore, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddyUpstreamClient, appUserAgent, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, desktopAuthCandidatesFor, fingerprintModel, inject, installedAppVersion, isHeartbeatProcessAlive, modelWithCurrentPromotion, name, normalizeCredits, parseModelCatalog, parseWorkBuddyAuth, prepareChatBody, prepareInternationalChatBody, probeModel, processStartTimeMs, randomSentinel, readBundleVersion, readHostHeartbeat, regionOf, resolveAppVersion, validAppVersion, variantFor, workbuddyCatalogPath, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath };