dsh-workbuddy-connect 0.4.0 → 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,15 +1,136 @@
1
- import { C as defaultDesktopAuthPath, S as defaultDesktopAuthCandidates, T as workbuddyOwnAuthPath, _ as FALLBACK_WORKBUDDY_MODELS, a as readHostHeartbeat, b as WORKBUDDY_AUTH_FILE_ENV, c as WORKBUDDY_CONNECT_VERSION, d as normalizeCredits, f as prepareChatBody, g as randomSentinel, h as probeModel, i as processStartTimeMs, l as WorkBuddyUpstreamClient, m as PROBE_EFFORT_CANDIDATES, 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 WorkBuddyCatalog, w as parseWorkBuddyAuth, x as WorkBuddyCredentialStore, y as WORKBUDDY_AUTH_FILENAME } from "./host-heartbeat-0UAWqWac.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
3
  import { dirname, join, resolve } from "node:path";
4
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";
5
7
  import { createProvider } from "@earendil-works/pi-ai";
6
8
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
7
9
  import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
8
10
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
9
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
10
11
  import { createServer } from "node:http";
11
12
  import { Readable } from "node:stream";
12
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
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
13
134
  //#region src/adapter.ts
14
135
  /**
15
136
  * The `workbuddy` pi-ai provider: one loopback-backed adapter registered
@@ -154,18 +275,19 @@ function reasoningFields(info, observed) {
154
275
  };
155
276
  }
156
277
  /** Build one pi-ai model descriptor pointing at the loopback shim. */
157
- function toPiModel(info, baseUrl, observed) {
278
+ function toPiModel(info, baseUrl, observed, providerId = WORKBUDDY_PROVIDER) {
158
279
  return {
159
280
  id: info.id,
160
281
  name: info.name,
161
282
  api: "openai-completions",
162
- provider: WORKBUDDY_PROVIDER,
283
+ provider: providerId,
163
284
  baseUrl,
164
285
  input: info.supportsImages === true ? ["text", "image"] : ["text"],
165
286
  ...reasoningFields(info, observed),
166
287
  cost: NO_COST,
167
288
  contextWindow: info.contextWindow,
168
- maxTokens: info.maxTokens
289
+ maxTokens: info.maxTokens,
290
+ compat: { maxTokensField: "max_tokens" }
169
291
  };
170
292
  }
171
293
  /**
@@ -182,14 +304,16 @@ function toPiModel(info, baseUrl, observed) {
182
304
  */
183
305
  function createWorkBuddyAdapter(options) {
184
306
  const { shim, store, catalog, resolveAttachments, observe } = options;
307
+ const providerId = options.providerId ?? "workbuddy";
308
+ const displayName = options.displayName ?? "WorkBuddy";
185
309
  const buildModels = () => {
186
310
  const baseUrl = `${shim.baseUrl()}/v1`;
187
- return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id)));
311
+ return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id), providerId));
188
312
  };
189
313
  const provider = {
190
314
  ...createProvider({
191
- id: WORKBUDDY_PROVIDER,
192
- name: "WorkBuddy",
315
+ id: providerId,
316
+ name: displayName,
193
317
  auth: { apiKey: {
194
318
  name: "WorkBuddy OAuth bearer token",
195
319
  async resolve({ credential }) {
@@ -206,8 +330,8 @@ function createWorkBuddyAdapter(options) {
206
330
  getModels: () => buildModels()
207
331
  };
208
332
  const profile = {
209
- provider: WORKBUDDY_PROVIDER,
210
- displayName: "WorkBuddy",
333
+ provider: providerId,
334
+ displayName,
211
335
  streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
212
336
  retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-connect retryPolicy"),
213
337
  configuredMaxTokens: /* @__PURE__ */ new Map(),
@@ -215,7 +339,7 @@ function createWorkBuddyAdapter(options) {
215
339
  ...REQUEST_IMAGE_BUDGETS,
216
340
  piProvider: provider
217
341
  };
218
- let profiles = /* @__PURE__ */ new Map([[WORKBUDDY_PROVIDER, profile]]);
342
+ let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
219
343
  return {
220
344
  adapter: new WorkBuddyPiAiAdapter(catalog, {
221
345
  profiles: () => profiles,
@@ -224,7 +348,7 @@ function createWorkBuddyAdapter(options) {
224
348
  ...resolveAttachments === void 0 ? {} : { resolveAttachments }
225
349
  }),
226
350
  invalidate: () => {
227
- profiles = /* @__PURE__ */ new Map([[WORKBUDDY_PROVIDER, profile]]);
351
+ profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
228
352
  }
229
353
  };
230
354
  }
@@ -541,9 +665,17 @@ const PROBE_FORMAT_VERSION = 1;
541
665
  * picker entry.
542
666
  */
543
667
  const DEFAULT_TTL_MS = 12096e5;
544
- /** Plugin-owned probe record path inside the Harness home. */
545
- function workbuddyProbePath() {
546
- return join(resolveDshHome(), WORKBUDDY_PROBE_FILENAME);
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);
547
679
  }
548
680
  /**
549
681
  * Fingerprint the catalog fields a probe depends on.
@@ -625,12 +757,17 @@ var WorkBuddyProbeStore = class {
625
757
  }
626
758
  /**
627
759
  * The usable record for a model, or `undefined` when there is none, it is
628
- * expired, or it was taken against a different catalog row.
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.
629
765
  */
630
- get(modelId, fingerprint) {
766
+ get(modelId, fingerprint, account) {
631
767
  const record = this.load()[modelId];
632
768
  if (record === void 0) return void 0;
633
769
  if (record.fingerprint !== fingerprint) return void 0;
770
+ if (record.account !== account) return void 0;
634
771
  if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
635
772
  return record;
636
773
  }
@@ -655,14 +792,15 @@ var WorkBuddyProbeStore = class {
655
792
  all() {
656
793
  return { ...this.load() };
657
794
  }
658
- /** Build a record stamped with this store's clock and version. */
659
- record(fingerprint, validation, efforts) {
795
+ /** Build a record stamped with this store's clock, version, and account. */
796
+ record(fingerprint, validation, efforts, account) {
660
797
  return {
661
798
  fingerprint,
662
799
  validation,
663
800
  efforts: validation === "validating" ? [...efforts] : [],
664
801
  probedAtMs: this.now(),
665
- pluginVersion: this.pluginVersion
802
+ pluginVersion: this.pluginVersion,
803
+ account
666
804
  };
667
805
  }
668
806
  /**
@@ -723,7 +861,9 @@ var WorkBuddyProbeService = class {
723
861
  const info = this.options.catalog.current().find((model) => model.id === modelId);
724
862
  if (info === void 0) return void 0;
725
863
  if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
726
- return this.options.store.get(modelId, fingerprintModel(info));
864
+ const account = this.options.account();
865
+ if (account === void 0) return void 0;
866
+ return this.options.store.get(modelId, fingerprintModel(info), account);
727
867
  }
728
868
  /**
729
869
  * Probe one model, serially.
@@ -742,7 +882,13 @@ var WorkBuddyProbeService = class {
742
882
  state: "unavailable",
743
883
  reason: `unknown model: ${modelId}`
744
884
  };
745
- const pending = this.pending.get(modelId);
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);
746
892
  if (pending !== void 0) return pending;
747
893
  const run = this.queue.then(async () => {
748
894
  const current = this.options.catalog.current().find((model) => model.id === modelId);
@@ -765,6 +911,10 @@ var WorkBuddyProbeService = class {
765
911
  efforts: cached.efforts,
766
912
  requests: 0
767
913
  };
914
+ if (this.options.account() !== account) return {
915
+ state: "unavailable",
916
+ reason: "account changed before detection"
917
+ };
768
918
  const credential = await this.options.credentials.current();
769
919
  if (credential === void 0) return {
770
920
  state: "unavailable",
@@ -777,7 +927,11 @@ var WorkBuddyProbeService = class {
777
927
  send,
778
928
  ...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
779
929
  });
780
- const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts);
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);
781
935
  this.options.store.set(modelId, record);
782
936
  if (outcome.validation === "unknown") return {
783
937
  state: "unavailable",
@@ -794,30 +948,15 @@ var WorkBuddyProbeService = class {
794
948
  }
795
949
  });
796
950
  this.queue = run.catch(() => void 0);
797
- this.pending.set(modelId, run);
951
+ this.pending.set(pendingKey, run);
798
952
  try {
799
953
  return await run;
800
954
  } finally {
801
- this.pending.delete(modelId);
955
+ this.pending.delete(pendingKey);
802
956
  }
803
957
  }
804
958
  };
805
959
  //#endregion
806
- //#region src/status-paths.ts
807
- /** Node-free constants and types shared by the Host and browser halves. */
808
- /** Plugin-owned status endpoint consumed by its browser half. */
809
- const WORKBUDDY_STATUS_PATH = "/plugins/dsh-workbuddy-connect/status";
810
- /**
811
- * Plugin-owned probe control endpoint.
812
- *
813
- * Separate from the status route because it accepts writes: the status route's
814
- * loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
815
- * not the same as authorizing a state-changing action. This route therefore
816
- * also requires the in-process key the browser half receives with the status
817
- * document.
818
- */
819
- const WORKBUDDY_PROBE_PATH = "/plugins/dsh-workbuddy-connect/probe";
820
- //#endregion
821
960
  //#region src/web-status.ts
822
961
  /** Redact token-like content before it crosses to the browser. */
823
962
  function safeMessage(error) {
@@ -847,7 +986,10 @@ function loopbackRequest(req) {
847
986
  */
848
987
  async function workBuddyWebStatus(deps) {
849
988
  const authStatus = await deps.store.status();
850
- 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
+ };
851
993
  const status = {
852
994
  status: "signed-in",
853
995
  ...authStatus.nickname === void 0 ? {} : { nickname: authStatus.nickname },
@@ -857,19 +999,29 @@ async function workBuddyWebStatus(deps) {
857
999
  };
858
1000
  const modelsField = deps.models().map((model) => {
859
1001
  const rate = normalizeCredits(model.billing?.credits);
1002
+ const supported = model.supportedContextWindows ?? [];
1003
+ const maxContextWindow = supported.length > 0 ? Math.max(...supported) : void 0;
860
1004
  return {
861
1005
  id: model.id,
862
1006
  name: model.name,
863
1007
  ...model.billing?.free === true ? { free: true } : {},
864
1008
  ...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
865
1009
  ...rate === void 0 ? {} : { credits: rate },
866
- ...typeof model.contextWindow === "number" && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}
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 } : {}
867
1014
  };
868
1015
  });
869
- const statusWithModels = modelsField.length > 0 ? {
1016
+ const catalog = deps.catalog?.();
1017
+ const withCatalog = catalog === void 0 ? status : {
870
1018
  ...status,
1019
+ catalog
1020
+ };
1021
+ const statusWithModels = modelsField.length > 0 ? {
1022
+ ...withCatalog,
871
1023
  models: modelsField
872
- } : status;
1024
+ } : withCatalog;
873
1025
  const probed = deps.probe === void 0 ? statusWithModels : {
874
1026
  ...statusWithModels,
875
1027
  probe: deps.probe(),
@@ -912,10 +1064,11 @@ function workBuddyStatusHandler(deps) {
912
1064
  }
913
1065
  /** Mount the GET status route on an optional webServer context. */
914
1066
  function registerWorkBuddyStatusRoute(ctx, deps) {
1067
+ const path = deps.path ?? "/plugins/dsh-workbuddy-connect/status";
915
1068
  ctx.effect(() => {
916
1069
  const dispose = ctx.webServer.register({
917
1070
  kind: "exact",
918
- path: WORKBUDDY_STATUS_PATH,
1071
+ path,
919
1072
  handler: workBuddyStatusHandler(deps)
920
1073
  });
921
1074
  return () => {
@@ -991,6 +1144,7 @@ function parseAction(text) {
991
1144
  const wrapped = parsed;
992
1145
  const action = wrapped["action"];
993
1146
  if (action === "clear") return { action: "clear" };
1147
+ if (action === "refresh") return { action: "refresh" };
994
1148
  if (action === "probe") {
995
1149
  const model = wrapped["model"];
996
1150
  if (typeof model !== "string" || model.trim() === "") return void 0;
@@ -1034,6 +1188,14 @@ function workBuddyProbeHandler(deps, key) {
1034
1188
  json(res, 200, { state: "cleared" });
1035
1189
  return;
1036
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
+ }
1037
1199
  json(res, 200, await deps.probe(action.model));
1038
1200
  } catch (error) {
1039
1201
  json(res, 500, { error: error instanceof Error ? error.message : String(error) });
@@ -1042,10 +1204,11 @@ function workBuddyProbeHandler(deps, key) {
1042
1204
  }
1043
1205
  /** Mount the POST probe-control route on an optional webServer context. */
1044
1206
  function registerWorkBuddyProbeRoute(ctx, deps, key) {
1207
+ const path = deps.path ?? "/plugins/dsh-workbuddy-connect/probe";
1045
1208
  ctx.effect(() => {
1046
1209
  const dispose = ctx.webServer.register({
1047
1210
  kind: "exact",
1048
- path: WORKBUDDY_PROBE_PATH,
1211
+ path,
1049
1212
  handler: workBuddyProbeHandler(deps, key)
1050
1213
  });
1051
1214
  return () => {
@@ -1060,7 +1223,7 @@ const name = "llm-workbuddy";
1060
1223
  /** The model registry required before the provider can register. */
1061
1224
  const inject = ["llm"];
1062
1225
  /**
1063
- * Settings namespace owning the configuration card.
1226
+ * Settings namespace owning the CN card's section.
1064
1227
  *
1065
1228
  * DSH 0.1.2 dropped the `settingsNamespace()` branding function: a namespace is
1066
1229
  * now a nominal string, validated by the type system where it is used rather
@@ -1072,176 +1235,563 @@ const inject = ["llm"];
1072
1235
  * their namespaces as plain string literals).
1073
1236
  */
1074
1237
  const WORKBUDDY_SETTINGS_NS = "workbuddy";
1238
+ /**
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.
1248
+ */
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)");
1075
1295
  const Config = z.object({
1076
- authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)"),
1077
- probeConsent: z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)")
1296
+ authFile: AUTH_FILE_FIELD,
1297
+ authFileAI: AUTH_FILE_AI_FIELD,
1298
+ probeConsent: PROBE_CONSENT_FIELD
1078
1299
  });
1079
1300
  /**
1080
- * Start the loopback endpoint, register the `workbuddy` provider, and
1081
- * refresh the model catalog from the upstream once credentials allow it.
1082
- * The static fallback catalog serves from the first moment, so an offline
1083
- * upstream never leaves the provider empty.
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.
1084
1308
  */
1085
- function apply(ctx, config) {
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) {
1086
1339
  const client = new WorkBuddyUpstreamClient();
1340
+ const configured = configuredAuthFile(config, variant);
1087
1341
  const store = new WorkBuddyCredentialStore({
1088
- ...config.authFile === void 0 ? {} : { desktopPath: config.authFile },
1342
+ variant,
1343
+ ...configured === void 0 ? {} : { desktopPath: configured },
1089
1344
  refresh: (credential) => client.refreshToken(credential)
1090
1345
  });
1091
- const catalog = new WorkBuddyCatalog();
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,
1356
+ store,
1357
+ client,
1358
+ catalog,
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
+ }];
1417
+ });
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;
1092
1437
  const shim = createWorkBuddyShim({
1093
1438
  store,
1094
1439
  client,
1095
1440
  catalog,
1096
1441
  logger: ctx.logger
1097
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) {
1098
1511
  let current = () => config;
1099
- const probeStore = new WorkBuddyProbeStore({ pluginVersion: WORKBUDDY_CONNECT_VERSION });
1100
- const probeService = new WorkBuddyProbeService({
1101
- store: probeStore,
1102
- catalog,
1103
- credentials: store,
1104
- client,
1105
- consent: () => current().probeConsent === true
1106
- });
1512
+ /** Timers and in-flight work belonging to this plugin instance. */
1513
+ let stopped = false;
1514
+ const timers = [];
1107
1515
  /**
1108
- * Whether a model can be probed by hand: it reasons and the upstream declares
1109
- * no effort set for it.
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.
1110
1532
  *
1111
- * Deliberately *not* filtered by whether a result already exists. Dropping a
1112
- * model once it has been detected made the list shrink with use, so
1113
- * re-detecting one model — after an upstream change, say — meant clearing
1114
- * every other result first. The list stays stable and the card marks which
1115
- * entries already have an answer.
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.
1116
1542
  */
1117
- const isProbeCandidate = (info) => {
1118
- if (info.reasoning?.supports !== true) return false;
1119
- return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
1120
- };
1121
- /** Compact probe state for the card: consent, candidates, observations. */
1122
- const probeSection = () => {
1123
- const config = current();
1124
- const models = catalog.current();
1125
- const results = models.flatMap((info) => {
1126
- const record = probeService.recordFor(info.id);
1127
- if (record === void 0) return [];
1128
- return [{
1129
- id: info.id,
1130
- name: info.name,
1131
- validation: record.validation,
1132
- efforts: record.efforts,
1133
- probedAt: record.probedAtMs
1134
- }];
1135
- });
1136
- return {
1137
- consent: config.probeConsent === true,
1138
- running: probeService.isRunning(),
1139
- candidates: models.filter(isProbeCandidate).map((info) => info.id),
1140
- results: newestFirst(results)
1141
- };
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();
1142
1579
  };
1143
- const probeKey = createProbeKey();
1144
- let refreshProbeModels = () => {};
1145
1580
  ctx.inject(["webServer"], (webCtx) => {
1146
- registerWorkBuddyStatusRoute(webCtx, {
1147
- store,
1148
- client,
1149
- models: () => catalog.current(),
1150
- probe: () => probeSection(),
1151
- probeKey
1152
- });
1153
- registerWorkBuddyProbeRoute(webCtx, {
1154
- probe: async (modelId) => {
1155
- const result = await probeService.probe(modelId, true);
1156
- if (result.state === "ok") refreshProbeModels();
1157
- return result;
1158
- },
1159
- clear: () => {
1160
- probeStore.clear();
1161
- refreshProbeModels();
1162
- }
1163
- }, probeKey);
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
+ }
1164
1633
  });
1165
1634
  ctx.inject(["settings"], (settingsCtx) => {
1166
- 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, {
1167
1651
  setSource(source) {
1168
- current = source;
1652
+ sources.cn = source;
1653
+ current = merged;
1169
1654
  },
1170
- onChange() {
1171
- const next = current().authFile;
1172
- store.setDesktopPath(next);
1173
- }
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
1174
1663
  });
1175
1664
  });
1176
- let stopped = false;
1177
1665
  ctx.effect(() => () => {
1178
1666
  stopped = true;
1179
- shim.close();
1667
+ for (const timer of timers) clearInterval(timer);
1668
+ timers.length = 0;
1180
1669
  clearHostHeartbeat();
1181
1670
  });
1182
- shim.ready.then(() => {
1183
- if (stopped) return;
1184
- let invalidate;
1185
- try {
1186
- const workbuddy = createWorkBuddyAdapter({
1187
- shim,
1188
- store,
1189
- catalog,
1190
- resolveAttachments: () => ctx.get("attachments"),
1191
- observe: (modelId) => probeService.recordFor(modelId)
1192
- });
1193
- invalidate = workbuddy.invalidate;
1194
- refreshProbeModels = () => {
1195
- if (stopped) return;
1196
- workbuddy.invalidate();
1197
- ctx.emit("llm/adapters-updated");
1198
- };
1199
- let releaseAdapter;
1200
- 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;
1201
1699
  try {
1202
- releaseAdapter = ctx.llm.registerAdapter([WORKBUDDY_PROVIDER], workbuddy.adapter);
1203
- releaseDirectory = ctx.llm.registerConfigurableProviders([{
1204
- provider: WORKBUDDY_PROVIDER,
1205
- displayName: "WorkBuddy",
1206
- settingsNs: WORKBUDDY_SETTINGS_NS,
1207
- settingsPath: [],
1208
- declared: false
1209
- }]);
1210
- } finally {
1211
- if (releaseAdapter === void 0 || releaseDirectory === void 0) {
1212
- releaseAdapter?.();
1213
- 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;
1214
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;
1215
1722
  }
1216
- try {
1217
- ctx.effect(() => () => {
1218
- releaseAdapter?.();
1219
- releaseDirectory?.();
1220
- });
1221
- } catch {
1222
- releaseAdapter?.();
1223
- releaseDirectory?.();
1224
- }
1225
- writeHostHeartbeat();
1226
- } catch (error) {
1227
- 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);
1228
1769
  return;
1229
1770
  }
1230
- (async () => {
1231
- try {
1232
- const credential = await store.current();
1233
- if (credential === void 0 || stopped) return;
1234
- const models = await client.fetchModels(credential);
1235
- if (stopped) return;
1236
- catalog.set([...models]);
1237
- invalidate?.();
1238
- } catch (error) {
1239
- ctx.logger.warn("dsh-workbuddy-connect: dynamic model catalog unavailable; serving the static fallback list", error);
1240
- }
1241
- })();
1242
- }).catch((error) => {
1243
- 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);
1244
1794
  });
1245
1795
  }
1246
1796
  //#endregion
1247
- export { Config, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddyUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, fingerprintModel, inject, isHeartbeatProcessAlive, name, normalizeCredits, parseWorkBuddyAuth, prepareChatBody, probeModel, processStartTimeMs, randomSentinel, readHostHeartbeat, regionOf, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath };
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 };