dsh-workbuddy-bridge 0.1.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 ADDED
@@ -0,0 +1,2000 @@
1
+ import { A as normalizeCredits, B as FALLBACK_CN_APP_VERSION, C as desktopAuthCandidatesFor, D as cnAppDiscovery, E as WorkBuddyAtRestKeyProvider, F as PROBE_EFFORT_CANDIDATES, G as validCliVersion, H as fallbackChatIdentity, I as probeModel, J as installedAppVersion, K as WORKBUDDY_APP_VERSION_FILENAME, L as randomSentinel, M as prepareChatBody, N as prepareInternationalChatBody, O as WorkBuddyUpstreamClient, P as regionOf, R as classifyUpstreamError, S as defaultDesktopAuthPath, T as workbuddyOwnAuthPath, U as readCliVersion, V as chatUserAgent, W as resolveChatIdentity, X as resolveAppVersion, Y as readBundleVersion, Z as validAppVersion, _ as WorkBuddyCatalog, a as WORKBUDDY_HOST_HEARTBEAT_FILENAME, b as WorkBuddyCredentialStore, c as processStartTimeMs, d as writeHostHeartbeat, f as WORKBUDDY_BRIDGE_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, j as parseModelCatalog, k as modelWithCurrentPromotion, l as readHostHeartbeat, n as CN_VARIANT, o as clearHostHeartbeat, q as appUserAgent, 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 CN_APP_VERSION_FILENAME } from "./variants-CFO_LIBm.js";
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";
7
+ import { createProvider } from "@earendil-works/pi-ai";
8
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
9
+ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
10
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
11
+ import { createServer } from "node:http";
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-bridge/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$1(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$1(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
134
+ //#region src/catalog/visibility.ts
135
+ /**
136
+ * Per-account model-visibility preferences: which models the signed-in account
137
+ * has hidden from the DSH model picker (issue #36).
138
+ *
139
+ * A hidden-*id* list, deliberately not an enabled whitelist: a new account and a
140
+ * model the upstream adds both start visible, and an id that temporarily
141
+ * disappears from the catalog is kept — when the model returns it stays hidden
142
+ * until this account says otherwise. Entries are also kept across sign-outs, so
143
+ * returning to an account restores exactly what it left.
144
+ *
145
+ * One file per variant (the two endpoints share model ids but never
146
+ * preferences), keyed by the same `uid:enterpriseId` identity the saved
147
+ * catalogs and probe records use. Not a place for secrets: model-id strings
148
+ * only, never a token, and never written into the desktop auth file or the
149
+ * plugin-owned credential copy — hiding a model is a picker preference, not
150
+ * credential state.
151
+ *
152
+ * Why a plugin-owned file rather than a settings section: the settings sections
153
+ * are statically-typed schemastery objects, and `settings.yaml` is account-global
154
+ * — a per-uid dynamic map fits neither without weakening the schema or mixing
155
+ * one account's preferences into another's config. The saved-catalog and probe
156
+ * stores already persist per-account data this way, so this store follows them:
157
+ * version-tagged document, atomic write with `0o600`, and a malformed file that
158
+ * reads as "nothing saved" rather than throwing.
159
+ *
160
+ * @module dsh-workbuddy-bridge/visibility-store
161
+ */
162
+ /** On-disk format this reader accepts; other versions are discarded. */
163
+ const VISIBILITY_FORMAT_VERSION = 1;
164
+ /** Basename of the CN variant's visibility file inside the Harness home. */
165
+ const WORKBUDDY_VISIBILITY_FILENAME = ".workbuddy-model-visibility.json";
166
+ /** Plugin-owned visibility-file path inside the Harness home. */
167
+ function workbuddyVisibilityPath(filename = WORKBUDDY_VISIBILITY_FILENAME) {
168
+ return join(resolveDshHome(), filename);
169
+ }
170
+ /**
171
+ * Whether a parsed value is a saved preference entry this reader can trust.
172
+ *
173
+ * The list is read from `hidden`, falling back to `disabled`: that was the
174
+ * field's first name and the file format never bumped a version for the rename,
175
+ * so a file written by an older build still parses instead of silently reading
176
+ * as "nothing hidden".
177
+ */
178
+ function isSaved(value) {
179
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
180
+ const entry = value;
181
+ if (typeof entry["account"] !== "string" || entry["account"] === "") return false;
182
+ if (typeof entry["updatedAtMs"] !== "number" || !Number.isFinite(entry["updatedAtMs"])) return false;
183
+ const list = entry["hidden"] ?? entry["disabled"];
184
+ if (!Array.isArray(list)) return false;
185
+ return list.every((id) => typeof id === "string" && id !== "");
186
+ }
187
+ /** Normalize one parsed entry: pick the list up from either field name. */
188
+ function normalize(value) {
189
+ return {
190
+ account: value.account,
191
+ hidden: value.hidden ?? value.disabled ?? [],
192
+ updatedAtMs: value.updatedAtMs
193
+ };
194
+ }
195
+ /**
196
+ * The per-account hidden-model lists, read once and written atomically.
197
+ *
198
+ * Unlike the saved-catalog store, a failed *write* propagates: the caller
199
+ * reports it to the user rather than answering "hidden" for a preference that
200
+ * did not persist. Reads stay forgiving — a corrupt or unreadable file is
201
+ * "nothing hidden", which only ever shows models the account can still pick.
202
+ */
203
+ var WorkBuddyVisibilityStore = class {
204
+ path;
205
+ accounts;
206
+ constructor(options = {}) {
207
+ this.path = typeof options === "string" ? options : options.path ?? workbuddyVisibilityPath();
208
+ }
209
+ /** Resolved state-file path, for the CLI and tests. */
210
+ filePath() {
211
+ return this.path;
212
+ }
213
+ load() {
214
+ if (this.accounts !== void 0) return this.accounts;
215
+ const accounts = {};
216
+ if (existsSync(this.path)) try {
217
+ const parsed = JSON.parse(readFileSync(this.path, "utf8"));
218
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
219
+ const document = parsed;
220
+ const raw = document["version"] === VISIBILITY_FORMAT_VERSION ? document["accounts"] : void 0;
221
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
222
+ for (const [key, value] of Object.entries(raw)) if (isSaved(value)) accounts[key] = normalize(value);
223
+ }
224
+ }
225
+ } catch {}
226
+ this.accounts = accounts;
227
+ return accounts;
228
+ }
229
+ /** The model ids one account has hidden; empty when it never hid any. */
230
+ hidden(account) {
231
+ return this.load()[account]?.hidden ?? [];
232
+ }
233
+ /**
234
+ * Show or hide one model for one account, persisting before committing.
235
+ *
236
+ * Re-enabling (showing) the last hidden model removes the account's entry
237
+ * entirely — an absent entry and an empty list mean the same thing
238
+ * (everything visible), and the file should not accumulate empty buckets.
239
+ * Throws when the write fails, leaving the in-memory state untouched so a
240
+ * re-read cannot lie about what was persisted.
241
+ */
242
+ setVisible(account, model, visible) {
243
+ const current = this.load()[account]?.hidden ?? [];
244
+ const next = visible ? current.filter((id) => id !== model) : [.../* @__PURE__ */ new Set([...current, model])];
245
+ const accounts = { ...this.load() };
246
+ if (next.length === 0) delete accounts[account];
247
+ else accounts[account] = {
248
+ account,
249
+ hidden: next,
250
+ updatedAtMs: Date.now()
251
+ };
252
+ this.persist(accounts);
253
+ this.accounts = accounts;
254
+ }
255
+ persist(accounts) {
256
+ const directory = dirname(this.path);
257
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
258
+ const document = {
259
+ version: VISIBILITY_FORMAT_VERSION,
260
+ accounts
261
+ };
262
+ const temporary = resolve(`${this.path}.tmp`);
263
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
264
+ renameSync(temporary, this.path);
265
+ }
266
+ };
267
+ //#endregion
268
+ //#region src/llm/adapter.ts
269
+ /**
270
+ * The `workbuddy` pi-ai provider: one loopback-backed adapter registered
271
+ * into the Harness LLM seam, assembled from public `dsh-llm-pi-ai`
272
+ * extension points the way `dsh-codex-connect` assembles its Codex route.
273
+ *
274
+ * @module dsh-workbuddy-bridge/adapter
275
+ */
276
+ /** Provider route this bundle owns. */
277
+ const WORKBUDDY_PROVIDER = "workbuddy";
278
+ /** Provider idle ceiling while one stream read is outstanding. */
279
+ const WORKBUDDY_STREAM_IDLE_TIMEOUT_MS = 3e5;
280
+ /**
281
+ * Image-request budgets at the dsh-llm-pi-ai defaults; the profile type made
282
+ * them required in 0.1.1-rc.2. They bound requests to models whose catalog
283
+ * entry declares `supportsImages`; text-only models never receive images.
284
+ */
285
+ const REQUEST_IMAGE_BUDGETS = {
286
+ maxRequestImageBytes: 20971520,
287
+ requestImagePixelBudget: 4194304,
288
+ requestImageMaxBytes: 1048576
289
+ };
290
+ /**
291
+ * Inert pi-ai auth plane. The workbuddy route authenticates only through the
292
+ * shim shared secret resolved per request by `resolveApiKey`, so pi-ai's own
293
+ * credential lifecycle and ambient discovery must never manufacture a
294
+ * credential for it. `PiAiAdapterOptions.auth` is required since 0.1.1-rc.2;
295
+ * every ambient question here answers "nothing stored, nothing set".
296
+ */
297
+ const INERT_AUTH = {
298
+ credentials: {
299
+ async read() {},
300
+ async list() {
301
+ return [];
302
+ },
303
+ async modify() {
304
+ throw new Error("dsh-workbuddy-bridge: the workbuddy route has no pi-ai credential lifecycle");
305
+ },
306
+ async delete() {}
307
+ },
308
+ authContext: {
309
+ async env() {},
310
+ async fileExists() {
311
+ return false;
312
+ }
313
+ }
314
+ };
315
+ /** No per-token pricing is knowable for a subscription quota; report zero. */
316
+ const NO_COST = {
317
+ input: 0,
318
+ output: 0,
319
+ cacheRead: 0,
320
+ cacheWrite: 0
321
+ };
322
+ /**
323
+ * The suffix appended to a model's display name so its billing rate is visible
324
+ * wherever the name is shown.
325
+ *
326
+ * The separator is a middle dot rather than a hyphen or colon: model names
327
+ * already contain hyphens (`GLM-5.3-Flash`, `Deepseek-V4-Flash`), so a hyphen
328
+ * separator would be ambiguous about where the name ends and the rate begins.
329
+ */
330
+ const RATE_SEPARATOR = " · ";
331
+ /**
332
+ * Append the billing rate to one model's display name.
333
+ *
334
+ * The rate AND the declared promo badges ride the *name* alone: since DSH
335
+ * 0.1.2 the composer's model seat (`ModelSelect`) renders `model.name` only —
336
+ * `description` is no longer read there at all (the 0.1.1-era client rendered
337
+ * it, which is why the badges used to be visible in the seat). The `/model`
338
+ * popup renders the name too, so a separate `description` copy would either
339
+ * duplicate (rate) or vanish (badges) depending on client generation.
340
+ *
341
+ * This is display-only and cannot affect routing: the wire request is built
342
+ * from `model.id` (pi-ai's completions API sets `model: model.id`), the
343
+ * selection a picker submits is `{provider, model: id, reasoningEffort}`, and
344
+ * `dsh-llm` validates `name` as a non-empty string without comparing its
345
+ * contents. Nothing in the host resolves a model *by* name.
346
+ */
347
+ /**
348
+ * The catalog display suffix: the billing rate followed by the declared promo
349
+ * badges (`限时免费`, `夜间折扣`), or undefined when the row carries neither.
350
+ * The badge labels are the upstream's own spellings and the host seam has no
351
+ * locale service, so non-Chinese UIs see them verbatim — accepted until the
352
+ * picker grows a localized badge slot.
353
+ */
354
+ function displaySuffix(info) {
355
+ const parts = [normalizeCredits(info.billing?.credits), ...info.billing?.badges ?? []].filter((part) => part !== void 0 && part !== "");
356
+ return parts.length === 0 ? void 0 : parts.join(" · ");
357
+ }
358
+ /** Append the catalog display suffix to one model's display name. */
359
+ function withCatalogDisplay(name, info) {
360
+ const suffix = displaySuffix(info);
361
+ return suffix === void 0 ? name : `${name}${RATE_SEPARATOR}${suffix}`;
362
+ }
363
+ /**
364
+ * Resolve a WorkBuddy model's reasoning capability into pi-ai's
365
+ * `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
366
+ * unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
367
+ *
368
+ * Two sources, strictly ordered (`docs/reasoning-effort-probe-plan.md` §5):
369
+ *
370
+ * 1. **The declared set.** When the upstream declares a non-empty
371
+ * `supportedEfforts`, exactly those values are offered and nothing else.
372
+ * This always wins: an observation never widens or narrows a declared set.
373
+ * 2. **A local observation.** Rows without a declared set (the older
374
+ * `{effort, summary}` shape) normally get no control at all — their
375
+ * selectable set is client-side knowledge the catalog does not carry, and
376
+ * the desktop app differs per model there. If the user authorized a probe
377
+ * and it established that the upstream *validates* the parameter, the
378
+ * verified spellings are offered.
379
+ *
380
+ * A `non-validating` observation deliberately yields no control: the upstream
381
+ * accepts values that cannot exist (measured on `glm-5.2`), so every per-level
382
+ * acceptance it produced would be a false positive.
383
+ *
384
+ * `off` is offered only when the upstream declares `canDisableThinking: true`.
385
+ * It is never probed — disabling thinking is a separate capability, and the
386
+ * per-model acceptance of `off` cannot be inferred from the row's shape.
387
+ *
388
+ * The offered set is described internally as "verified accepted", never as
389
+ * "verified effective": acceptance proves the upstream did not reject the
390
+ * spelling, not that it changes what the model does.
391
+ */
392
+ function reasoningFields(info, observed) {
393
+ const reasoning = info.reasoning;
394
+ if (reasoning === void 0 || reasoning.supports !== true) return { reasoning: false };
395
+ const declared = reasoning.supportedEfforts;
396
+ const efforts = declared !== void 0 && declared.length > 0 ? declared : observed?.validation === "validating" && observed.efforts.length > 0 ? observed.efforts : void 0;
397
+ if (efforts === void 0) return { reasoning: false };
398
+ return {
399
+ reasoning: true,
400
+ thinkingLevelMap: {
401
+ off: reasoning.canDisableThinking === true && declared !== void 0 && declared.length > 0 ? "off" : null,
402
+ minimal: null,
403
+ low: efforts.includes("low") ? "low" : null,
404
+ medium: efforts.includes("medium") ? "medium" : null,
405
+ high: efforts.includes("high") ? "high" : null,
406
+ xhigh: efforts.includes("xhigh") ? "xhigh" : null,
407
+ max: efforts.includes("max") ? "max" : null
408
+ }
409
+ };
410
+ }
411
+ /** Build one pi-ai model descriptor pointing at the loopback shim. */
412
+ function toPiModel(info, baseUrl, observed, providerId = WORKBUDDY_PROVIDER) {
413
+ return {
414
+ id: info.id,
415
+ name: info.name,
416
+ api: "openai-completions",
417
+ provider: providerId,
418
+ baseUrl,
419
+ input: info.supportsImages === true ? ["text", "image"] : ["text"],
420
+ ...reasoningFields(info, observed),
421
+ cost: NO_COST,
422
+ contextWindow: info.contextWindow,
423
+ maxTokens: info.maxTokens,
424
+ compat: { maxTokensField: "max_tokens" }
425
+ };
426
+ }
427
+ /**
428
+ * Assemble the adapter. The provider's `getModels` reads the live catalog,
429
+ * and every model's `baseUrl` is re-resolved per read so the shim's
430
+ * ephemeral port applies from the first snapshot after startup.
431
+ *
432
+ * The profile is constructed by hand rather than through dsh-llm-pi-ai's
433
+ * internal `resolveProfiles()`: that helper is not part of the package's
434
+ * public export surface (root entry, `lib/` deep imports blocked by the
435
+ * exports map, `src/` not shipped), so hand-assembly is the only supported
436
+ * path and every newly required field must be adopted here explicitly —
437
+ * `modelErrors` is one such field.
438
+ */
439
+ function createWorkBuddyAdapter(options) {
440
+ const { shim, catalog, resolveAttachments, observe, hidden } = options;
441
+ const providerId = options.providerId ?? "workbuddy";
442
+ const displayName = options.displayName ?? "WorkBuddy";
443
+ const buildModels = () => {
444
+ const baseUrl = `${shim.baseUrl()}/v1`;
445
+ return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id), providerId));
446
+ };
447
+ const provider = {
448
+ ...createProvider({
449
+ id: providerId,
450
+ name: displayName,
451
+ auth: { apiKey: {
452
+ name: "WorkBuddy OAuth bearer token",
453
+ async resolve({ credential }) {
454
+ const apiKey = credential?.key;
455
+ return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
456
+ auth: { apiKey },
457
+ source: "WorkBuddy"
458
+ };
459
+ }
460
+ } },
461
+ models: buildModels(),
462
+ api: openAICompletionsApi()
463
+ }),
464
+ getModels: () => buildModels()
465
+ };
466
+ const profile = {
467
+ provider: providerId,
468
+ displayName,
469
+ streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
470
+ retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-bridge retryPolicy"),
471
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
472
+ modelErrors: /* @__PURE__ */ new Map(),
473
+ ...REQUEST_IMAGE_BUDGETS,
474
+ piProvider: provider
475
+ };
476
+ let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
477
+ return {
478
+ adapter: new WorkBuddyPiAiAdapter(catalog, hidden ?? (() => []), {
479
+ profiles: () => profiles,
480
+ auth: INERT_AUTH,
481
+ resolveApiKey: async () => shim.token(),
482
+ ...resolveAttachments === void 0 ? {} : { resolveAttachments }
483
+ }),
484
+ invalidate: () => {
485
+ profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
486
+ }
487
+ };
488
+ }
489
+ /**
490
+ * The WorkBuddy route's adapter: `PiAiAdapter` with the billing rate folded
491
+ * into the catalog answers it returns to the DSH model pickers.
492
+ *
493
+ * `PiAiAdapter.listModels()` and `.resolveModel()` build their answers straight
494
+ * from the pi-ai descriptors, which carry no billing fact, so the rate is
495
+ * layered on here by looking the model up in the live catalog. Both overrides
496
+ * delegate to `super` and then rewrite only the display fields, so streaming,
497
+ * capability resolution, and effort mapping stay exactly as `dsh-llm-pi-ai`
498
+ * implements them.
499
+ *
500
+ * A model missing from the catalog (an id the shim would serve but the last
501
+ * upstream refresh did not list) falls through with its name untouched rather
502
+ * than being dropped: catalog membership is advisory, and the seam tolerates
503
+ * serving an unlisted id.
504
+ */
505
+ var WorkBuddyPiAiAdapter = class extends PiAiAdapter {
506
+ catalog;
507
+ hidden;
508
+ constructor(catalog, hidden, options) {
509
+ super(options);
510
+ this.catalog = catalog;
511
+ this.hidden = hidden;
512
+ }
513
+ /** Catalog entry for one model id, or undefined when the catalog omits it. */
514
+ infoFor(model) {
515
+ return this.catalog.current().find((entry) => entry.id === model);
516
+ }
517
+ async listModels(provider) {
518
+ const models = await super.listModels(provider);
519
+ const hidden = new Set(this.hidden());
520
+ return models.flatMap((model) => {
521
+ if (hidden.has(model.id)) return [];
522
+ const info = this.infoFor(model.id);
523
+ if (info === void 0) return [model];
524
+ return [{
525
+ ...model,
526
+ name: withCatalogDisplay(model.name, info)
527
+ }];
528
+ });
529
+ }
530
+ async resolveModel(provider, model, signal) {
531
+ const resolved = await super.resolveModel(provider, model, signal);
532
+ const info = this.infoFor(model);
533
+ if (info === void 0) return resolved;
534
+ return {
535
+ ...resolved,
536
+ name: withCatalogDisplay(resolved.name, info)
537
+ };
538
+ }
539
+ };
540
+ //#endregion
541
+ //#region src/llm/loopback.ts
542
+ /**
543
+ * Shared loopback gates for the plugin's local HTTP surfaces: the loopback
544
+ * shim and the same-origin web-status route. Both are only ever meant to be
545
+ * addressed through the machine's loopback interface.
546
+ *
547
+ * @module dsh-workbuddy-bridge/loopback
548
+ */
549
+ /** Loopback hostnames a local plugin surface may be addressed by. */
550
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
551
+ "127.0.0.1",
552
+ "localhost",
553
+ "[::1]"
554
+ ]);
555
+ /** Strip the optional :port from a Host header value, IPv6-bracket aware. */
556
+ function hostnameOfHost(host) {
557
+ let hostname = host.trim().toLowerCase();
558
+ if (hostname.startsWith("[")) {
559
+ const end = hostname.indexOf("]");
560
+ return end === -1 ? hostname : hostname.slice(0, end + 1);
561
+ }
562
+ const colon = hostname.lastIndexOf(":");
563
+ if (colon !== -1 && !hostname.slice(0, colon).includes(":") && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
564
+ return hostname;
565
+ }
566
+ /**
567
+ * The request's Host header must name the loopback interface. A DNS-rebinding
568
+ * page (attacker domain re-resolved to 127.0.0.1) sends its own domain in
569
+ * Host, so this check drops those before any routing happens.
570
+ */
571
+ function hostIsLoopback(host) {
572
+ if (host === void 0 || host.trim() === "") return false;
573
+ return LOOPBACK_HOSTS.has(hostnameOfHost(host));
574
+ }
575
+ /**
576
+ * A browser-sent Origin (present header) must be loopback. Non-browser
577
+ * clients (the plugin's own fetch calls) send no Origin at all and pass.
578
+ */
579
+ function originIsLoopback(origin) {
580
+ if (origin === void 0 || origin.trim() === "") return true;
581
+ try {
582
+ const { hostname } = new URL(origin);
583
+ return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+ //#endregion
589
+ //#region src/llm/shim.ts
590
+ /**
591
+ * Loopback OpenAI-compatible endpoint. The pi-ai provider points here; the
592
+ * shim applies the WorkBuddy wire quirks (forced streaming, string
593
+ * `tool_choice`, CLI-shaped headers) and forwards to the real upstream.
594
+ * It binds 127.0.0.1 only and never serves another interface.
595
+ *
596
+ * Inbound hardening: the loopback bind alone is not a trust boundary (any
597
+ * local process or a DNS-rebinding page can reach 127.0.0.1), so every
598
+ * request must carry a loopback Host header, browser-sent Origins must be
599
+ * loopback, chat POSTs must be application/json, and the Authorization
600
+ * header must carry the shim's per-process shared secret. The plugin's
601
+ * own client satisfies all four by construction; local attackers cannot
602
+ * read the secret out of the plugin process's memory.
603
+ *
604
+ * @module dsh-workbuddy-bridge/shim
605
+ */
606
+ const REQUEST_BODY_LIMIT = 67108864;
607
+ /** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
608
+ function isJsonContentType(req) {
609
+ const type = req.headers["content-type"];
610
+ return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
611
+ }
612
+ /** HTTP status each upstream failure class surfaces as. */
613
+ const KIND_STATUS = {
614
+ hard_credit: 402,
615
+ soft_rate: 429,
616
+ session_dead: 401,
617
+ not_found: 502,
618
+ server: 502,
619
+ client: 400
620
+ };
621
+ function writeJson(res, status, body) {
622
+ const payload = JSON.stringify(body);
623
+ res.writeHead(status, {
624
+ "Content-Type": "application/json",
625
+ "Content-Length": Buffer.byteLength(payload)
626
+ });
627
+ res.end(payload);
628
+ }
629
+ function writeOpenAIError(res, status, kind, message) {
630
+ writeJson(res, status, { error: {
631
+ message,
632
+ type: kind,
633
+ code: kind
634
+ } });
635
+ }
636
+ /** Read a request body with a size cap; over-limit bodies fail the request. */
637
+ function readBody$1(req) {
638
+ return new Promise((resolve, reject) => {
639
+ const chunks = [];
640
+ let size = 0;
641
+ req.on("data", (chunk) => {
642
+ size += chunk.length;
643
+ if (size > REQUEST_BODY_LIMIT) {
644
+ reject(/* @__PURE__ */ new Error("request body too large"));
645
+ req.destroy();
646
+ return;
647
+ }
648
+ chunks.push(chunk);
649
+ });
650
+ req.on("end", () => resolve(Buffer.concat(chunks)));
651
+ req.on("error", reject);
652
+ });
653
+ }
654
+ /**
655
+ * Start the loopback endpoint. Requests carry any bearer; the loopback bind
656
+ * is the boundary, and the upstream credential comes from the store alone.
657
+ */
658
+ function createWorkBuddyShim(options) {
659
+ const { store, client, catalog } = options;
660
+ const logger = options.logger;
661
+ const SHARED_SECRET = randomBytes(32).toString("base64url");
662
+ /** Constant-time bearer check; absent or mismatched bearers are rejected. */
663
+ function bearerOk(req) {
664
+ const header = req.headers.authorization;
665
+ if (typeof header !== "string") return false;
666
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
667
+ if (match === null) return false;
668
+ const presented = match[1];
669
+ const expected = SHARED_SECRET;
670
+ const a = Buffer.from(presented);
671
+ const b = Buffer.from(expected);
672
+ if (a.length !== b.length) return false;
673
+ return timingSafeEqual(a, b);
674
+ }
675
+ const server = createServer((req, res) => {
676
+ handle(req, res);
677
+ });
678
+ const ready = new Promise((resolve, reject) => {
679
+ server.once("listening", () => resolve());
680
+ server.once("error", reject);
681
+ });
682
+ server.listen(0, "127.0.0.1");
683
+ const baseUrl = () => {
684
+ const address = server.address();
685
+ if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
686
+ return `http://127.0.0.1:${address.port}`;
687
+ };
688
+ async function handle(req, res) {
689
+ try {
690
+ if (!hostIsLoopback(req.headers.host)) {
691
+ writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
692
+ return;
693
+ }
694
+ if (!originIsLoopback(req.headers.origin)) {
695
+ writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
696
+ return;
697
+ }
698
+ if (!bearerOk(req)) {
699
+ writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
700
+ return;
701
+ }
702
+ const url = req.url ?? "/";
703
+ if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
704
+ writeJson(res, 200, { ok: true });
705
+ return;
706
+ }
707
+ if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
708
+ writeJson(res, 200, {
709
+ object: "list",
710
+ data: catalog.current().map((model) => ({
711
+ id: model.id,
712
+ object: "model",
713
+ created: 0,
714
+ owned_by: "workbuddy"
715
+ }))
716
+ });
717
+ return;
718
+ }
719
+ if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
720
+ await chatCompletions(req, res);
721
+ return;
722
+ }
723
+ writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
724
+ } catch (error) {
725
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
726
+ else res.end();
727
+ }
728
+ }
729
+ async function chatCompletions(req, res) {
730
+ if (!isJsonContentType(req)) {
731
+ writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
732
+ return;
733
+ }
734
+ let credential;
735
+ try {
736
+ credential = await store.resolve();
737
+ } catch (error) {
738
+ writeOpenAIError(res, 401, "not_signed_in", String(error));
739
+ return;
740
+ }
741
+ const raw = (await readBody$1(req)).toString("utf8");
742
+ const prepared = prepareChatBody(raw);
743
+ const controller = new AbortController();
744
+ req.on("close", () => controller.abort());
745
+ const result = await client.chatStream(credential, prepared, controller.signal);
746
+ if (!result.ok) {
747
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status}): ${result.message.slice(0, 400)}`);
748
+ return;
749
+ }
750
+ res.writeHead(200, {
751
+ "Content-Type": "text/event-stream",
752
+ "Cache-Control": "no-cache",
753
+ "Connection": "keep-alive",
754
+ "X-Accel-Buffering": "no"
755
+ });
756
+ let sawDone = false;
757
+ const body = Readable.fromWeb(result.response.body);
758
+ body.on("data", (chunk) => {
759
+ if (chunk.includes("[DONE]")) sawDone = true;
760
+ });
761
+ body.on("error", (error) => {
762
+ logger?.warn("dsh-workbuddy-bridge: upstream stream failed mid-flight", error);
763
+ if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
764
+ });
765
+ body.pipe(res);
766
+ }
767
+ return {
768
+ ready,
769
+ baseUrl,
770
+ token: () => SHARED_SECRET,
771
+ close: () => new Promise((resolve, reject) => {
772
+ server.close(() => resolve());
773
+ server.closeAllConnections();
774
+ server.once("error", reject);
775
+ })
776
+ };
777
+ }
778
+ //#endregion
779
+ //#region src/probe/store.ts
780
+ /**
781
+ * Local record of reasoning-effort probes.
782
+ *
783
+ * What this stores is an *observation*, never a claim about the upstream: a
784
+ * model's row is only consulted when the catalog carries no explicit
785
+ * `supportedEfforts` set, and it always loses to a declared set. The plan this
786
+ * implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
787
+ * is invalidated whenever the model's catalog row changes, so every record
788
+ * carries a fingerprint of the fields the probe depended on.
789
+ *
790
+ * The file lives beside the plugin's own credential copy under `$DSH_HOME`,
791
+ * never in the desktop app's files, and carries no token, prompt, or response
792
+ * body — only model ids, effort spellings, and timestamps.
793
+ *
794
+ * @module dsh-workbuddy-bridge/probe-store
795
+ */
796
+ /** Basename of the probe record inside the Harness home. */
797
+ const WORKBUDDY_PROBE_FILENAME = ".workbuddy-probe.json";
798
+ /**
799
+ * On-disk format this reader accepts; other versions are discarded.
800
+ *
801
+ * Version 2 nested the records under the account that produced them
802
+ * (`records[account][modelId]`), so two accounts no longer overwrite each
803
+ * other's observations for the same model. Version 1 files (flat, one record
804
+ * per model) are deliberately not migrated: they read as empty and the
805
+ * affected models are re-probed on demand, which keeps the reader free of
806
+ * half-understood compatibility paths.
807
+ */
808
+ const PROBE_FORMAT_VERSION = 2;
809
+ /**
810
+ * How long an observation stays usable. Conservative on purpose: the plan's
811
+ * whole argument is that upstream metadata moves fast, so a result that has
812
+ * outlived its fingerprint's usefulness should not quietly keep granting a
813
+ * picker entry.
814
+ */
815
+ const DEFAULT_TTL_MS = 12096e5;
816
+ /**
817
+ * Plugin-owned probe record path inside the Harness home.
818
+ *
819
+ * One file per variant. Same-named models exist on both endpoints (the
820
+ * international catalog repeats `glm-5.3`, `glm-5.2`, `hy3`, `kimi-k2.6`), and
821
+ * {@link fingerprintModel} covers only `id`/`reasoning`/`supportsImages` —
822
+ * never the provider — so a single shared file would let one variant's
823
+ * observation answer for the other. The paths differ; the format does not.
824
+ */
825
+ function workbuddyProbePath(filename = WORKBUDDY_PROBE_FILENAME) {
826
+ return join(resolveDshHome(), filename);
827
+ }
828
+ /**
829
+ * Fingerprint the catalog fields a probe depends on.
830
+ *
831
+ * Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
832
+ * so a rename or a promo badge does not throw away a valid observation, and
833
+ * deliberately includes the whole reasoning object so any change to the
834
+ * declared shape re-probes.
835
+ */
836
+ function fingerprintModel(info) {
837
+ const basis = JSON.stringify({
838
+ id: info.id,
839
+ reasoning: info.reasoning ?? null,
840
+ supportsImages: info.supportsImages ?? null
841
+ });
842
+ return createHash("sha256").update(basis).digest("hex").slice(0, 16);
843
+ }
844
+ /** Read-and-validate the documents on disk; anything malformed reads as empty. */
845
+ function readDocument(path) {
846
+ if (!existsSync(path)) return void 0;
847
+ let parsed;
848
+ try {
849
+ parsed = JSON.parse(readFileSync(path, "utf8"));
850
+ } catch {
851
+ return;
852
+ }
853
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
854
+ const wrapped = parsed;
855
+ if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
856
+ const records = wrapped["records"];
857
+ if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
858
+ return parsed;
859
+ }
860
+ /** One record's shape check; a bad row is dropped rather than trusted. */
861
+ function isRecord(value) {
862
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
863
+ const wrapped = value;
864
+ const validation = wrapped["validation"];
865
+ if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
866
+ if (typeof wrapped["fingerprint"] !== "string") return false;
867
+ if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
868
+ if (typeof wrapped["pluginVersion"] !== "string") return false;
869
+ if (typeof wrapped["account"] !== "string" || wrapped["account"] === "") return false;
870
+ const efforts = wrapped["efforts"];
871
+ if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
872
+ return true;
873
+ }
874
+ /**
875
+ * The plugin's probe records: read once, written atomically, keyed by the
876
+ * account that produced each observation, and never trusted across a
877
+ * fingerprint change or past the TTL.
878
+ */
879
+ var WorkBuddyProbeStore = class {
880
+ path;
881
+ ttlMs;
882
+ pluginVersion;
883
+ now;
884
+ records;
885
+ constructor(options) {
886
+ const opts = typeof options === "string" ? {
887
+ path: options,
888
+ pluginVersion: "0.0.0"
889
+ } : options;
890
+ this.path = opts.path ?? workbuddyProbePath();
891
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
892
+ this.pluginVersion = opts.pluginVersion;
893
+ this.now = opts.now ?? (() => Date.now());
894
+ }
895
+ /** Resolved state-file path, for the CLI and tests. */
896
+ filePath() {
897
+ return this.path;
898
+ }
899
+ load() {
900
+ if (this.records === void 0) {
901
+ const document = readDocument(this.path);
902
+ const records = {};
903
+ for (const [account, bucket] of Object.entries(document?.records ?? {})) {
904
+ if (typeof bucket !== "object" || bucket === null || Array.isArray(bucket)) continue;
905
+ const parsed = {};
906
+ for (const [modelId, record] of Object.entries(bucket)) if (isRecord(record)) parsed[modelId] = record;
907
+ records[account] = parsed;
908
+ }
909
+ this.records = records;
910
+ }
911
+ return this.records;
912
+ }
913
+ /**
914
+ * The usable record for one account and model, or `undefined` when there is
915
+ * none, it is expired, it was taken against a different catalog row, or it
916
+ * belongs to a different account.
917
+ *
918
+ * @param account - the account in effect, as `uid:enterpriseId`. Records are
919
+ * only returned for the account that produced them.
920
+ */
921
+ get(modelId, fingerprint, account) {
922
+ const record = this.load()[account]?.[modelId];
923
+ if (record === void 0) return void 0;
924
+ if (record.fingerprint !== fingerprint) return void 0;
925
+ if (record.account !== account) return void 0;
926
+ if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
927
+ return record;
928
+ }
929
+ /**
930
+ * Store one observation under the account stamped on it. Only a decisive
931
+ * answer (`validating` / `non-validating`) replaces an existing decisive
932
+ * record *of the same account*: a transient `unknown` must not erase
933
+ * knowledge the user already paid for.
934
+ */
935
+ set(modelId, record) {
936
+ const records = this.load();
937
+ const bucket = records[record.account] ?? (records[record.account] = {});
938
+ const existing = bucket[modelId];
939
+ if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
940
+ bucket[modelId] = record;
941
+ this.persist();
942
+ }
943
+ /** Drop every record of every account; used by the card's explicit "clear" action. */
944
+ clear() {
945
+ this.records = {};
946
+ this.persist();
947
+ }
948
+ /** Every record currently held, grouped by account, for status display. */
949
+ all() {
950
+ const records = this.load();
951
+ return Object.fromEntries(Object.entries(records).map(([account, bucket]) => [account, { ...bucket }]));
952
+ }
953
+ /** Build a record stamped with this store's clock, version, and account. */
954
+ record(fingerprint, validation, efforts, account) {
955
+ return {
956
+ fingerprint,
957
+ validation,
958
+ efforts: validation === "validating" ? [...efforts] : [],
959
+ probedAtMs: this.now(),
960
+ pluginVersion: this.pluginVersion,
961
+ account
962
+ };
963
+ }
964
+ /**
965
+ * Write through a temporary file and rename, so a crash mid-write cannot
966
+ * leave a half-parsed document that reads as "no records" and silently drops
967
+ * every observation.
968
+ */
969
+ persist() {
970
+ const directory = dirname(this.path);
971
+ try {
972
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
973
+ const document = {
974
+ version: PROBE_FORMAT_VERSION,
975
+ records: this.load()
976
+ };
977
+ const temporary = resolve(`${this.path}.tmp`);
978
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
979
+ renameSync(temporary, this.path);
980
+ } catch {}
981
+ }
982
+ };
983
+ /**
984
+ * Order observations newest-first for display.
985
+ *
986
+ * The store keeps insertion order so the file reads chronologically, but the
987
+ * card wants the most recent detection at the top: a sweep the user just ran
988
+ * should not appear below every earlier one, which is what appending to an
989
+ * insertion-ordered list does.
990
+ */
991
+ function newestFirst(records) {
992
+ return [...records].sort((a, b) => b.probedAt - a.probedAt);
993
+ }
994
+ //#endregion
995
+ //#region src/probe/service.ts
996
+ /**
997
+ * Serial probe runner. One instance is shared by the manual API and any
998
+ * future automatic trigger, so the two can never overlap.
999
+ */
1000
+ var WorkBuddyProbeService = class {
1001
+ options;
1002
+ queue = Promise.resolve();
1003
+ pending = /* @__PURE__ */ new Map();
1004
+ running = false;
1005
+ constructor(options) {
1006
+ this.options = options;
1007
+ }
1008
+ /** Whether a sweep is in flight right now. */
1009
+ isRunning() {
1010
+ return this.running;
1011
+ }
1012
+ /**
1013
+ * The record the adapter may use for this model, or `undefined`.
1014
+ *
1015
+ * Applies the plan's precedence (§5): a declared set always wins, so a model
1016
+ * that declares `supportedEfforts` is never answered from an observation.
1017
+ */
1018
+ recordFor(modelId) {
1019
+ const info = this.options.catalog.current().find((model) => model.id === modelId);
1020
+ if (info === void 0) return void 0;
1021
+ if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
1022
+ const account = this.options.account();
1023
+ if (account === void 0) return void 0;
1024
+ return this.options.store.get(modelId, fingerprintModel(info), account);
1025
+ }
1026
+ /**
1027
+ * Probe one model, serially.
1028
+ *
1029
+ * The authenticated manual route supplies one-request consent after UI
1030
+ * confirmation. Other callers must pass the configured consent gate.
1031
+ * Manual consent never changes the automatic-probing configuration.
1032
+ * Explicit requests bypass historical results, but share an ongoing run.
1033
+ */
1034
+ async probe(modelId, manualConsent = false) {
1035
+ if (!manualConsent && !this.options.consent()) return {
1036
+ state: "unavailable",
1037
+ reason: "probing is not authorized"
1038
+ };
1039
+ if (this.options.catalog.current().find((model) => model.id === modelId) === void 0) return {
1040
+ state: "unavailable",
1041
+ reason: `unknown model: ${modelId}`
1042
+ };
1043
+ const account = this.options.account();
1044
+ if (account === void 0) return {
1045
+ state: "unavailable",
1046
+ reason: "no WorkBuddy credential"
1047
+ };
1048
+ const pendingKey = JSON.stringify([account, modelId]);
1049
+ const pending = this.pending.get(pendingKey);
1050
+ if (pending !== void 0) return pending;
1051
+ const run = this.queue.then(async () => {
1052
+ const current = this.options.catalog.current().find((model) => model.id === modelId);
1053
+ if (current === void 0) return {
1054
+ state: "unavailable",
1055
+ reason: `unknown model: ${modelId}`
1056
+ };
1057
+ if (!manualConsent && !this.options.consent()) return {
1058
+ state: "unavailable",
1059
+ reason: "probing is not authorized"
1060
+ };
1061
+ if (current.reasoning?.supports !== true || (current.reasoning.supportedEfforts?.length ?? 0) > 0) return {
1062
+ state: "unavailable",
1063
+ reason: "model does not need detection"
1064
+ };
1065
+ const cached = this.recordFor(modelId);
1066
+ if (!manualConsent && cached !== void 0 && cached.validation !== "unknown") return {
1067
+ state: "ok",
1068
+ validation: cached.validation,
1069
+ efforts: cached.efforts,
1070
+ requests: 0
1071
+ };
1072
+ if (this.options.account() !== account) return {
1073
+ state: "unavailable",
1074
+ reason: "account changed before detection"
1075
+ };
1076
+ const credential = await this.options.credentials.current();
1077
+ if (credential === void 0) return {
1078
+ state: "unavailable",
1079
+ reason: "no WorkBuddy credential"
1080
+ };
1081
+ const send = this.options.send === void 0 ? (effort, signal) => this.options.client.probeEffort(credential, modelId, effort, signal) : this.options.send(modelId);
1082
+ this.running = true;
1083
+ try {
1084
+ const outcome = await probeModel({
1085
+ send,
1086
+ ...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
1087
+ });
1088
+ if (this.options.account() !== account) return {
1089
+ state: "unavailable",
1090
+ reason: "account changed during detection"
1091
+ };
1092
+ const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts, account);
1093
+ this.options.store.set(modelId, record);
1094
+ if (outcome.validation === "unknown") return {
1095
+ state: "unavailable",
1096
+ reason: outcome.reason
1097
+ };
1098
+ return {
1099
+ state: "ok",
1100
+ validation: outcome.validation,
1101
+ efforts: record.efforts,
1102
+ requests: outcome.requests
1103
+ };
1104
+ } finally {
1105
+ this.running = false;
1106
+ }
1107
+ });
1108
+ this.queue = run.catch(() => void 0);
1109
+ this.pending.set(pendingKey, run);
1110
+ try {
1111
+ return await run;
1112
+ } finally {
1113
+ this.pending.delete(pendingKey);
1114
+ }
1115
+ }
1116
+ };
1117
+ //#endregion
1118
+ //#region src/web/status.ts
1119
+ /** Redact token-like content before it crosses to the browser. */
1120
+ function safeMessage(error) {
1121
+ 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);
1122
+ }
1123
+ function json$1(res, status, body) {
1124
+ const payload = JSON.stringify(body);
1125
+ res.writeHead(status, {
1126
+ "Content-Type": "application/json",
1127
+ "Content-Length": Buffer.byteLength(payload)
1128
+ });
1129
+ res.end(payload);
1130
+ }
1131
+ /**
1132
+ * The request must be addressed to the loopback interface, and a
1133
+ * browser-attached Origin must be loopback too. The Host check drops
1134
+ * DNS-rebinding pages (their Host is the attacker's domain, not loopback);
1135
+ * the card's same-origin fetches carry no Origin and pass on Host alone.
1136
+ */
1137
+ function loopbackRequest(req) {
1138
+ return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
1139
+ }
1140
+ /**
1141
+ * Assemble the card's status document. Sign-in state is read-only; credit is
1142
+ * a live billing answer whose failure degrades to `creditsError` rather than
1143
+ * failing the whole document.
1144
+ */
1145
+ async function workBuddyWebStatus(deps) {
1146
+ const authStatus = await deps.store.status();
1147
+ if (authStatus.state !== "signed-in") return {
1148
+ status: "signed-out",
1149
+ ...authStatus.reason === void 0 ? {} : { reason: authStatus.reason },
1150
+ ...authStatus.reasonCode === void 0 ? {} : { reasonCode: authStatus.reasonCode }
1151
+ };
1152
+ const status = {
1153
+ status: "signed-in",
1154
+ ...authStatus.nickname === void 0 ? {} : { nickname: authStatus.nickname },
1155
+ ...authStatus.domain === void 0 || authStatus.domain === "" ? {} : { domain: authStatus.domain },
1156
+ ...authStatus.source === void 0 ? {} : { source: authStatus.source },
1157
+ ...authStatus.expiresAtMs === void 0 ? {} : { expiresAt: authStatus.expiresAtMs }
1158
+ };
1159
+ const modelsField = deps.models().map((model) => {
1160
+ const rate = normalizeCredits(model.billing?.credits);
1161
+ const supported = model.supportedContextWindows ?? [];
1162
+ const maxContextWindow = supported.length > 0 ? Math.max(...supported) : void 0;
1163
+ const defaultContextWindow = model.defaultContextWindow ?? model.contextWindow;
1164
+ return {
1165
+ id: model.id,
1166
+ name: model.name,
1167
+ ...model.billing?.free === true ? { free: true } : {},
1168
+ ...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
1169
+ ...rate === void 0 ? {} : { credits: rate },
1170
+ ...model.billing?.rateUnknown === true ? { rateUnknown: true } : {},
1171
+ ...typeof model.contextWindow === "number" && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {},
1172
+ ...typeof defaultContextWindow === "number" && defaultContextWindow > 0 && defaultContextWindow < model.contextWindow ? { defaultContextWindow } : {},
1173
+ ...maxContextWindow === void 0 || maxContextWindow <= defaultContextWindow ? {} : { maxContextWindow },
1174
+ ...typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 ? { maxInputTokens: model.maxInputTokens } : {}
1175
+ };
1176
+ });
1177
+ const catalog = deps.catalog?.();
1178
+ const withCatalog = catalog === void 0 ? status : {
1179
+ ...status,
1180
+ catalog
1181
+ };
1182
+ const visibility = deps.visibility?.();
1183
+ const withVisibility = visibility === void 0 ? withCatalog : {
1184
+ ...withCatalog,
1185
+ visibility
1186
+ };
1187
+ const statusWithModels = modelsField.length > 0 ? {
1188
+ ...withVisibility,
1189
+ models: modelsField
1190
+ } : withVisibility;
1191
+ let probed = statusWithModels;
1192
+ if (deps.probe !== void 0) {
1193
+ const maximumContextWindow = deps.useMaximumContextWindow?.();
1194
+ probed = {
1195
+ ...statusWithModels,
1196
+ probe: deps.probe(),
1197
+ ...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey },
1198
+ ...maximumContextWindow === void 0 ? {} : { useMaximumContextWindow: maximumContextWindow }
1199
+ };
1200
+ }
1201
+ try {
1202
+ const credential = await deps.store.current();
1203
+ if (credential !== void 0) {
1204
+ const credits = await deps.client.fetchCredits(credential);
1205
+ return {
1206
+ ...probed,
1207
+ credits
1208
+ };
1209
+ }
1210
+ } catch (error) {
1211
+ return {
1212
+ ...probed,
1213
+ creditsError: safeMessage(error)
1214
+ };
1215
+ }
1216
+ return probed;
1217
+ }
1218
+ /** The status route's request handler, extracted so tests can mount it on a bare server. */
1219
+ function workBuddyStatusHandler(deps) {
1220
+ return async (req, res) => {
1221
+ if (req.method !== "GET") {
1222
+ json$1(res, 405, { error: "method not allowed" });
1223
+ return;
1224
+ }
1225
+ if (!loopbackRequest(req)) {
1226
+ json$1(res, 403, { error: "request-not-trusted" });
1227
+ return;
1228
+ }
1229
+ try {
1230
+ json$1(res, 200, await workBuddyWebStatus(deps));
1231
+ } catch (error) {
1232
+ json$1(res, 500, { error: safeMessage(error) });
1233
+ }
1234
+ };
1235
+ }
1236
+ /** Mount the GET status route on an optional webServer context. */
1237
+ function registerWorkBuddyStatusRoute(ctx, deps) {
1238
+ const path = deps.path ?? "/plugins/dsh-workbuddy-bridge/status";
1239
+ ctx.effect(() => {
1240
+ const dispose = ctx.webServer.register({
1241
+ kind: "exact",
1242
+ path,
1243
+ handler: workBuddyStatusHandler(deps)
1244
+ });
1245
+ return () => {
1246
+ dispose();
1247
+ };
1248
+ }, "dsh-workbuddy-bridge: Web status route");
1249
+ }
1250
+ //#endregion
1251
+ //#region src/web/probe-route.ts
1252
+ /**
1253
+ * Probe control route: the only state-changing endpoint the plugin exposes.
1254
+ *
1255
+ * Two guards, because they stop different things (see `docs/reasoning-effort-probe-plan.md`
1256
+ * §6.4 and the v0.3.1 note in AGENTS.md about their exact scope):
1257
+ *
1258
+ * 1. **Loopback Host + Origin**, shared with the status route. This drops
1259
+ * DNS-rebinding pages, whose requests arrive addressed to the attacker's
1260
+ * domain.
1261
+ * 2. **An in-process random key**, minted per process and handed only to the
1262
+ * same-origin card. Loopback alone is *not* authentication — any local
1263
+ * process can write `Host: 127.0.0.1` — so a route that spends the user's
1264
+ * credit must prove the caller was told the key.
1265
+ *
1266
+ * A probe request is never accepted with a prompt, a model id outside the
1267
+ * live catalog, or a sentinel from the browser: it is assembled entirely
1268
+ * host-side. (Scope: the `probe` action only — `set-model-visibility`
1269
+ * deliberately accepts a model id the current catalog no longer lists, since
1270
+ * a hidden id is kept for when the model returns.)
1271
+ *
1272
+ * @module dsh-workbuddy-bridge/probe-route
1273
+ */
1274
+ /** Largest control body accepted; these payloads are a few dozen bytes. */
1275
+ const MAX_BODY_BYTES = 4096;
1276
+ /** Mint the per-process control key. */
1277
+ function createProbeKey() {
1278
+ return randomBytes(24).toString("hex");
1279
+ }
1280
+ /**
1281
+ * Constant-time key comparison; a length mismatch is a failure, not a crash.
1282
+ */
1283
+ function keyMatches(expected, presented) {
1284
+ if (presented === void 0 || presented.length !== expected.length) return false;
1285
+ const a = Buffer.from(expected);
1286
+ const b = Buffer.from(presented);
1287
+ return a.length === b.length && timingSafeEqual(a, b);
1288
+ }
1289
+ function json(res, status, body) {
1290
+ const payload = JSON.stringify(body);
1291
+ res.writeHead(status, {
1292
+ "Content-Type": "application/json",
1293
+ "Content-Length": Buffer.byteLength(payload)
1294
+ });
1295
+ res.end(payload);
1296
+ }
1297
+ /** Read the request body with a hard ceiling. */
1298
+ async function readBody(req) {
1299
+ const chunks = [];
1300
+ let total = 0;
1301
+ for await (const chunk of req) {
1302
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1303
+ total += buffer.length;
1304
+ if (total > MAX_BODY_BYTES) return void 0;
1305
+ chunks.push(buffer);
1306
+ }
1307
+ return Buffer.concat(chunks).toString("utf8");
1308
+ }
1309
+ /** Parse and shape-check an action; unknown fields are ignored, not trusted. */
1310
+ function parseAction(text) {
1311
+ let parsed;
1312
+ try {
1313
+ parsed = JSON.parse(text);
1314
+ } catch {
1315
+ return;
1316
+ }
1317
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1318
+ const wrapped = parsed;
1319
+ const action = wrapped["action"];
1320
+ if (action === "clear") return { action: "clear" };
1321
+ if (action === "refresh") return { action: "refresh" };
1322
+ if (action === "set-model-visibility") {
1323
+ const model = wrapped["model"];
1324
+ const account = wrapped["account"];
1325
+ if (typeof model !== "string" || model.trim() === "") return void 0;
1326
+ if (typeof wrapped["visible"] !== "boolean") return void 0;
1327
+ if (typeof account !== "string" || account === "") return void 0;
1328
+ return {
1329
+ action: "set-model-visibility",
1330
+ model: model.trim(),
1331
+ visible: wrapped["visible"],
1332
+ account
1333
+ };
1334
+ }
1335
+ if (action === "probe") {
1336
+ const model = wrapped["model"];
1337
+ if (typeof model !== "string" || model.trim() === "") return void 0;
1338
+ return {
1339
+ action: "probe",
1340
+ model: model.trim()
1341
+ };
1342
+ }
1343
+ }
1344
+ /**
1345
+ * The control route's handler, extracted so tests can mount it on a bare
1346
+ * server with a known key.
1347
+ */
1348
+ function workBuddyProbeHandler(deps, key) {
1349
+ return async (req, res) => {
1350
+ if (req.method !== "POST") {
1351
+ json(res, 405, { error: "method not allowed" });
1352
+ return;
1353
+ }
1354
+ if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
1355
+ json(res, 403, { error: "request-not-trusted" });
1356
+ return;
1357
+ }
1358
+ if (!keyMatches(key, req.headers["x-workbuddy-probe-key"])) {
1359
+ json(res, 403, { error: "invalid-probe-key" });
1360
+ return;
1361
+ }
1362
+ const body = await readBody(req);
1363
+ if (body === void 0) {
1364
+ json(res, 413, { error: "body too large" });
1365
+ return;
1366
+ }
1367
+ const action = parseAction(body);
1368
+ if (action === void 0) {
1369
+ json(res, 400, { error: "invalid action" });
1370
+ return;
1371
+ }
1372
+ try {
1373
+ if (action.action === "clear") {
1374
+ deps.clear();
1375
+ json(res, 200, { state: "cleared" });
1376
+ return;
1377
+ }
1378
+ if (action.action === "refresh") {
1379
+ if (deps.refresh === void 0) {
1380
+ json(res, 404, { error: "refresh-not-supported" });
1381
+ return;
1382
+ }
1383
+ json(res, 200, await deps.refresh());
1384
+ return;
1385
+ }
1386
+ if (action.action === "set-model-visibility") {
1387
+ if (deps.setModelVisibility === void 0) {
1388
+ json(res, 404, { error: "visibility-setting-not-supported" });
1389
+ return;
1390
+ }
1391
+ json(res, 200, await deps.setModelVisibility(action.model, action.visible === true, action.account));
1392
+ return;
1393
+ }
1394
+ json(res, 200, await deps.probe(action.model));
1395
+ } catch (error) {
1396
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
1397
+ }
1398
+ };
1399
+ }
1400
+ /** Mount the POST probe-control route on an optional webServer context. */
1401
+ function registerWorkBuddyProbeRoute(ctx, deps, key) {
1402
+ const path = deps.path ?? "/plugins/dsh-workbuddy-bridge/probe";
1403
+ ctx.effect(() => {
1404
+ const dispose = ctx.webServer.register({
1405
+ kind: "exact",
1406
+ path,
1407
+ handler: workBuddyProbeHandler(deps, key)
1408
+ });
1409
+ return () => {
1410
+ dispose();
1411
+ };
1412
+ }, "dsh-workbuddy-bridge: probe control route");
1413
+ }
1414
+ //#endregion
1415
+ //#region src/index.ts
1416
+ /** Stable Cordis plugin name. */
1417
+ const name = "llm-workbuddy";
1418
+ /** The model registry required before the provider can register. */
1419
+ const inject = ["llm"];
1420
+ /**
1421
+ * How often the credential files are re-checked, in milliseconds.
1422
+ *
1423
+ * A startup-only catalog fetch cannot notice a sign-in that happens while DSH
1424
+ * is already running, so the model group would not appear until a restart. This
1425
+ * poll is a cheap existence/parse read of at most a few local files: it never
1426
+ * contacts the network and never runs a reasoning probe.
1427
+ *
1428
+ * `DSH_WORKBUDDY_POLL_MS` overrides it. That exists so the sweep can be
1429
+ * exercised end to end in tests and shortened while diagnosing a slow sign-in
1430
+ * on a real machine; it is not a product setting and no UI exposes it. The
1431
+ * value is clamped to a sane range so a mistaken override cannot turn the poll
1432
+ * into a busy loop.
1433
+ */
1434
+ const CREDENTIAL_POLL_MS = 3e4;
1435
+ /** Floor and ceiling for the overridable poll interval. */
1436
+ const MIN_POLL_MS = 100;
1437
+ const MAX_POLL_MS = 864e5;
1438
+ /** Resolve the sweep interval, honoring the override when it is usable. */
1439
+ function credentialPollMs() {
1440
+ const override = Number(process.env["DSH_WORKBUDDY_POLL_MS"]);
1441
+ if (!Number.isFinite(override) || override < MIN_POLL_MS) return CREDENTIAL_POLL_MS;
1442
+ return Math.min(override, MAX_POLL_MS);
1443
+ }
1444
+ /**
1445
+ * How long to wait before retrying a catalog fetch that failed.
1446
+ *
1447
+ * The credential sweep deliberately does not re-fetch a catalog it already has
1448
+ * (a same-identity token rotation carries no new model information). But a
1449
+ * *failed* fetch must not be treated the same way: without a retry, one
1450
+ * transient network blip at startup would leave the group on the built-in
1451
+ * fallback roster until the user noticed and pressed refresh. This bound keeps
1452
+ * that recovery automatic while still honoring the "not every round" rule — at
1453
+ * most one attempt per interval, and none at all once a live catalog lands.
1454
+ *
1455
+ * Expressed as a multiple of the sweep rather than a fixed duration so the two
1456
+ * stay in proportion under the `DSH_WORKBUDDY_POLL_MS` override.
1457
+ */
1458
+ const CATALOG_RETRY_SWEEPS = 10;
1459
+ /** Explicit CN desktop auth-file path (shared by the plugin schema and its section). */
1460
+ const AUTH_FILE_FIELD = z.string().default("").volatile().description("WorkBuddy desktop auth file (defaults to the app's own location)");
1461
+ /** Explicit international desktop auth-file path (shared by the plugin schema and its section). */
1462
+ const AUTH_FILE_AI_FIELD = z.string().default("").volatile().description("WorkBuddy AI desktop auth file (defaults to the app's own location)");
1463
+ /** Probe authorization (shared by the plugin schema and the CN section). */
1464
+ const PROBE_CONSENT_FIELD = z.boolean().default(false).volatile().description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
1465
+ const MAXIMUM_CONTEXT_WINDOW_FIELD = z.boolean().default(true).volatile().description("Use the largest context window declared by WorkBuddy AI when alternatives are available (on by default)");
1466
+ const Config = z.object({
1467
+ authFile: AUTH_FILE_FIELD,
1468
+ authFileAI: AUTH_FILE_AI_FIELD,
1469
+ probeConsent: PROBE_CONSENT_FIELD,
1470
+ useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD
1471
+ });
1472
+ /**
1473
+ * Read the current plain configuration.
1474
+ *
1475
+ * Called at the point of use rather than cached: see {@link ConfigRefs}. Several
1476
+ * callers are lazy accessors evaluated long after `apply` returned, and those
1477
+ * must observe an edit made in between.
1478
+ * @param refs - the reference face handed to `apply`.
1479
+ * @returns the values in effect right now.
1480
+ */
1481
+ function configOf(refs) {
1482
+ return {
1483
+ authFile: refs.authFile.get() ?? "",
1484
+ authFileAI: refs.authFileAI.get() ?? "",
1485
+ probeConsent: refs.probeConsent?.get() ?? false,
1486
+ useMaximumContextWindow: refs.useMaximumContextWindow?.get() ?? true
1487
+ };
1488
+ }
1489
+ /** Stable identity key used by credentials, probe records, and catalog entries. */
1490
+ function credentialIdentity(credential) {
1491
+ return `${credential.uid}:${credential.enterpriseId ?? ""}`;
1492
+ }
1493
+ /**
1494
+ * The account key model-visibility preferences are stored under: the stable
1495
+ * identity, but only when it carries a uid.
1496
+ *
1497
+ * A credential whose desktop document carried no `account.uid` normalizes to
1498
+ * an empty string; keying preferences on the resulting `":enterpriseId"` would
1499
+ * silently share one bucket between every such account. Those accounts get no
1500
+ * per-account preferences at all — everything stays visible and the control
1501
+ * route explains the refusal — which is the only honest degradation: it never
1502
+ * applies one account's hidden list to another.
1503
+ */
1504
+ function visibilityAccountOf(credential) {
1505
+ return credential.uid === "" ? void 0 : credentialIdentity(credential);
1506
+ }
1507
+ /**
1508
+ * Read the configured explicit auth-file path for one variant.
1509
+ *
1510
+ * An empty string means "not set" — that is what the schema's default and the
1511
+ * form's empty field both produce — and it must be reported as `undefined`
1512
+ * rather than passed along. The consumers below treat a *present* path as
1513
+ * authoritative and stop falling back to the env var and the platform default,
1514
+ * so forwarding `''` would silently disable both.
1515
+ * @param config - the plain configuration in effect.
1516
+ * @param variant - which of the two desktop apps this is for.
1517
+ * @returns the configured path, or `undefined` when the field is empty.
1518
+ */
1519
+ function configuredAuthFile(config, variant) {
1520
+ const path = variant.id === CN_VARIANT.id ? config.authFile : config.authFileAI;
1521
+ return path === "" ? void 0 : path;
1522
+ }
1523
+ /**
1524
+ * The static catalog a variant serves before its first successful fetch.
1525
+ *
1526
+ * Each variant has its own roster: the two endpoints share several model ids
1527
+ * but not their billing, context windows, or reasoning sets, so one shared
1528
+ * fallback would misdescribe whichever variant it was not captured from.
1529
+ */
1530
+ function fallbackFor(variant) {
1531
+ return variant.id === CN_VARIANT.id ? FALLBACK_WORKBUDDY_MODELS : FALLBACK_WORKBUDDY_AI_MODELS;
1532
+ }
1533
+ /** Build one variant's stores and probe state. */
1534
+ function createVariantRuntime(config, variant, identityOf, accountOf, keyProvider) {
1535
+ const client = new WorkBuddyUpstreamClient();
1536
+ const configured = configuredAuthFile(config, variant);
1537
+ const store = new WorkBuddyCredentialStore({
1538
+ variant,
1539
+ ...configured === void 0 ? {} : { desktopPath: configured },
1540
+ ...keyProvider === void 0 ? {} : { keyProvider },
1541
+ refresh: (credential) => client.refreshToken(credential)
1542
+ });
1543
+ const fallback = fallbackFor(variant);
1544
+ const catalog = new WorkBuddyCatalog(fallback);
1545
+ if (variant.id !== CN_VARIANT.id) catalog.setUseMaximumContextWindow(config.useMaximumContextWindow === true);
1546
+ catalog.setVisible(false);
1547
+ const probeStore = new WorkBuddyProbeStore({
1548
+ pluginVersion: WORKBUDDY_BRIDGE_VERSION,
1549
+ path: workbuddyProbePath(variant.probeFilename)
1550
+ });
1551
+ const savedCatalogs = new WorkBuddyCatalogStore(workbuddyCatalogPath(variant.catalogFilename));
1552
+ const visibilityStore = new WorkBuddyVisibilityStore(workbuddyVisibilityPath(variant.visibilityFilename));
1553
+ return {
1554
+ variant,
1555
+ store,
1556
+ client,
1557
+ catalog,
1558
+ probeStore,
1559
+ probeService: new WorkBuddyProbeService({
1560
+ store: probeStore,
1561
+ catalog,
1562
+ credentials: store,
1563
+ client,
1564
+ consent: () => config.probeConsent,
1565
+ account: () => identityOf(variant.id)
1566
+ }),
1567
+ savedCatalogs,
1568
+ visibilityStore,
1569
+ account: () => accountOf(variant.id),
1570
+ fallback,
1571
+ catalogSource: "fallback",
1572
+ catalogFetchedAtMs: void 0,
1573
+ catalogError: void 0,
1574
+ lastFetchAtMs: 0,
1575
+ catalogGeneration: 0,
1576
+ inflightFetch: void 0,
1577
+ invalidate: () => {},
1578
+ registered: false
1579
+ };
1580
+ }
1581
+ /** The catalog provenance the card displays. */
1582
+ function catalogSection(runtime) {
1583
+ const fetch = runtime.client.lastCatalog;
1584
+ return {
1585
+ source: runtime.catalogSource,
1586
+ ...runtime.catalogFetchedAtMs === void 0 ? {} : { fetchedAt: runtime.catalogFetchedAtMs },
1587
+ ...fetch?.appVersion === void 0 ? {} : { appVersion: fetch.appVersion.version },
1588
+ ...runtime.catalogError === void 0 ? {} : { error: runtime.catalogError }
1589
+ };
1590
+ }
1591
+ /**
1592
+ * Whether a model can be probed by hand: it reasons and the upstream declares
1593
+ * no effort set for it.
1594
+ *
1595
+ * Deliberately *not* filtered by whether a result already exists. Dropping a
1596
+ * model once it has been detected made the list shrink with use, so
1597
+ * re-detecting one model — after an upstream change, say — meant clearing every
1598
+ * other result first. The list stays stable and the card marks which entries
1599
+ * already have an answer.
1600
+ */
1601
+ function isProbeCandidate(info) {
1602
+ if (info.reasoning?.supports !== true) return false;
1603
+ return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
1604
+ }
1605
+ /** Compact probe state for one card: consent, candidates, observations. */
1606
+ function probeSection(runtime, consent) {
1607
+ const models = runtime.catalog.current();
1608
+ const results = models.flatMap((info) => {
1609
+ const record = runtime.probeService.recordFor(info.id);
1610
+ if (record === void 0) return [];
1611
+ return [{
1612
+ id: info.id,
1613
+ name: info.name,
1614
+ validation: record.validation,
1615
+ efforts: record.efforts,
1616
+ probedAt: record.probedAtMs
1617
+ }];
1618
+ });
1619
+ return {
1620
+ consent,
1621
+ running: runtime.probeService.isRunning(),
1622
+ candidates: models.filter(isProbeCandidate).map((info) => info.id),
1623
+ results: newestFirst(results)
1624
+ };
1625
+ }
1626
+ /**
1627
+ * Start one variant: its loopback endpoint, provider registration, and
1628
+ * configuration-card wiring.
1629
+ *
1630
+ * Registration waits for the shim to hold a port, because the provider's
1631
+ * models read the shim origin at construction time. A failure here is
1632
+ * contained to this variant: the caller logs it and the other keeps working.
1633
+ *
1634
+ * @returns whether the provider registered.
1635
+ */
1636
+ async function startVariant(ctx, runtime) {
1637
+ const { variant, store, client, catalog, probeService } = runtime;
1638
+ const shim = createWorkBuddyShim({
1639
+ store,
1640
+ client,
1641
+ catalog,
1642
+ logger: ctx.logger
1643
+ });
1644
+ try {
1645
+ await shim.ready;
1646
+ } catch (error) {
1647
+ ctx.logger.error(`dsh-workbuddy-bridge: ${variant.displayName} loopback endpoint failed to start`, error);
1648
+ return false;
1649
+ }
1650
+ try {
1651
+ const workbuddy = createWorkBuddyAdapter({
1652
+ providerId: variant.id,
1653
+ displayName: variant.displayName,
1654
+ shim,
1655
+ store,
1656
+ catalog,
1657
+ resolveAttachments: () => ctx.get("attachments"),
1658
+ observe: (modelId) => probeService.recordFor(modelId),
1659
+ hidden: () => {
1660
+ const account = runtime.account();
1661
+ return account === void 0 ? [] : runtime.visibilityStore.hidden(account);
1662
+ }
1663
+ });
1664
+ runtime.invalidate = () => {
1665
+ workbuddy.invalidate();
1666
+ ctx.emit("llm/adapters-updated");
1667
+ };
1668
+ const releaseAdapter = ctx.llm.registerAdapter([variant.id], workbuddy.adapter);
1669
+ try {
1670
+ ctx.effect(() => () => {
1671
+ releaseAdapter();
1672
+ shim.close();
1673
+ });
1674
+ } catch {
1675
+ releaseAdapter();
1676
+ shim.close();
1677
+ }
1678
+ runtime.registered = true;
1679
+ return true;
1680
+ } catch (error) {
1681
+ ctx.logger.error(`dsh-workbuddy-bridge: ${variant.displayName} provider registration failed`, error);
1682
+ shim.close();
1683
+ return false;
1684
+ }
1685
+ }
1686
+ /**
1687
+ * Start both variants: their loopback endpoints, the `workbuddy` and
1688
+ * `workbuddy-ai` providers, their configuration cards, and their
1689
+ * credential-driven catalog lifecycles.
1690
+ *
1691
+ * Each variant registers unconditionally; what varies is whether its catalog is
1692
+ * *visible*. An empty catalog is how DSH hides a model group (the host filters
1693
+ * out groups with no models), which keeps a sign-in that happens after startup
1694
+ * working without re-registering the provider.
1695
+ */
1696
+ function apply(ctx, refs) {
1697
+ /**
1698
+ * The configuration in effect *now*.
1699
+ *
1700
+ * A function rather than a captured object because the references are live:
1701
+ * the lazy accessors registered below outlive `apply`, and an edit made in
1702
+ * between must be visible to them.
1703
+ */
1704
+ const config = () => configOf(refs);
1705
+ /** Timers and in-flight work belonging to this plugin instance. */
1706
+ let stopped = false;
1707
+ const timers = [];
1708
+ /**
1709
+ * The account identity each variant last published a catalog for. Keeps a
1710
+ * same-identity token rotation from re-fetching, and lets a late response
1711
+ * from a previous identity be discarded instead of overwriting a newer one.
1712
+ */
1713
+ const lastIdentities = /* @__PURE__ */ new Map();
1714
+ /**
1715
+ * The visibility account key each variant last adopted, parallel to
1716
+ * {@link lastIdentities}: same credential, second key — undefined both when
1717
+ * signed out and when the credential carried no uid, which is exactly the
1718
+ * case that must not fall back to a shared preference bucket.
1719
+ */
1720
+ const lastAccounts = /* @__PURE__ */ new Map();
1721
+ const atRestKeysFor = (variant) => new WorkBuddyAtRestKeyProvider({ discovery: variant.id === CN_VARIANT.id ? cnAppDiscovery() : "none" });
1722
+ const runtimes = WORKBUDDY_VARIANTS.map((variant) => createVariantRuntime(config(), variant, (id) => lastIdentities.get(id), (id) => lastAccounts.get(id), atRestKeysFor(variant)));
1723
+ const probeKey = createProbeKey();
1724
+ /**
1725
+ * Point a variant at an account identity, invalidating whatever the previous
1726
+ * one left behind.
1727
+ *
1728
+ * One helper for all four transitions (sweep sign-in, sweep sign-out, manual
1729
+ * refresh, manual refresh sign-out) because each of them used to do its own
1730
+ * partial version, and the manual path forgot pieces the sweep did. Every
1731
+ * transition bumps {@link VariantRuntime.catalogGeneration}, which is what
1732
+ * makes an in-flight request from before the change refuse to write back.
1733
+ *
1734
+ * Probe observations are kept across an account change: the store nests them
1735
+ * per account, so the departing account's records simply stop being served
1736
+ * (every read is account-scoped) and are found intact if that account
1737
+ * returns. The "signed out, then in as someone else" sequence that used to
1738
+ * look like a first sighting is still safe — a record only ever answers for
1739
+ * the account stamped on it, so the new account inherits nothing. Visibility
1740
+ * preferences are kept for the same reason, and read through the new
1741
+ * account's key immediately: the picker re-lists after the invalidate below
1742
+ * and a returning account finds its own hidden list back in force.
1743
+ *
1744
+ * @param identity - the account now in effect, or `undefined` when signed out.
1745
+ * @param account - the visibility key for that same credential (`undefined`
1746
+ * also when the credential carries no uid); stored alongside the identity so
1747
+ * preference reads never guess it from the identity string.
1748
+ */
1749
+ const adoptIdentity = (runtime, identity, account) => {
1750
+ const id = runtime.variant.id;
1751
+ const known = lastIdentities.get(id);
1752
+ if (known === identity) return;
1753
+ const hadCredential = known !== void 0;
1754
+ if (identity === void 0) lastIdentities.delete(id);
1755
+ else lastIdentities.set(id, identity);
1756
+ if (account === void 0) lastAccounts.delete(id);
1757
+ else lastAccounts.set(id, account);
1758
+ runtime.catalogGeneration += 1;
1759
+ runtime.inflightFetch?.controller.abort();
1760
+ runtime.inflightFetch = void 0;
1761
+ if (hadCredential && known !== identity) runtime.invalidate();
1762
+ if (identity === void 0) {
1763
+ if (known !== void 0) runtime.savedCatalogs.delete(known);
1764
+ runtime.catalog.set(runtime.fallback);
1765
+ runtime.catalogSource = "fallback";
1766
+ runtime.catalogFetchedAtMs = void 0;
1767
+ runtime.catalogError = void 0;
1768
+ if (runtime.catalog.setVisible(false)) runtime.invalidate();
1769
+ return;
1770
+ }
1771
+ const saved = runtime.savedCatalogs.get(identity);
1772
+ if (saved !== void 0) {
1773
+ runtime.catalog.set([...saved.models]);
1774
+ runtime.catalogSource = "saved";
1775
+ runtime.catalogFetchedAtMs = saved.fetchedAtMs;
1776
+ } else {
1777
+ runtime.catalog.set(runtime.fallback);
1778
+ runtime.catalogSource = "fallback";
1779
+ runtime.catalogFetchedAtMs = void 0;
1780
+ }
1781
+ runtime.catalogError = void 0;
1782
+ runtime.catalog.setVisible(true);
1783
+ runtime.invalidate();
1784
+ };
1785
+ ctx.inject(["webServer"], (webCtx) => {
1786
+ for (const runtime of runtimes) {
1787
+ registerWorkBuddyStatusRoute(webCtx, {
1788
+ path: runtime.variant.statusPath,
1789
+ store: runtime.store,
1790
+ client: runtime.client,
1791
+ models: () => runtime.catalog.current(),
1792
+ catalog: () => catalogSection(runtime),
1793
+ probe: () => probeSection(runtime, config().probeConsent),
1794
+ probeKey,
1795
+ visibility: () => {
1796
+ const account = runtime.account();
1797
+ return account === void 0 ? void 0 : {
1798
+ account,
1799
+ hidden: runtime.visibilityStore.hidden(account)
1800
+ };
1801
+ },
1802
+ ...runtime.variant.id === CN_VARIANT.id ? {} : { useMaximumContextWindow: () => config().useMaximumContextWindow }
1803
+ });
1804
+ registerWorkBuddyProbeRoute(webCtx, {
1805
+ path: runtime.variant.probePath,
1806
+ probe: async (modelId) => {
1807
+ const result = await runtime.probeService.probe(modelId, true);
1808
+ if (result.state === "ok") runtime.invalidate();
1809
+ return result;
1810
+ },
1811
+ clear: () => {
1812
+ runtime.probeStore.clear();
1813
+ runtime.invalidate();
1814
+ },
1815
+ refresh: async () => {
1816
+ if (stopped) return {
1817
+ state: "failed",
1818
+ reason: "plugin is stopping"
1819
+ };
1820
+ let credential;
1821
+ try {
1822
+ credential = await runtime.store.current();
1823
+ } catch (error) {
1824
+ return {
1825
+ state: "failed",
1826
+ reason: error instanceof Error ? error.message.slice(0, 300) : String(error)
1827
+ };
1828
+ }
1829
+ if (credential === void 0) {
1830
+ adoptIdentity(runtime, void 0, void 0);
1831
+ return { state: "signed-out" };
1832
+ }
1833
+ const identity = credentialIdentity(credential);
1834
+ adoptIdentity(runtime, identity, visibilityAccountOf(credential));
1835
+ await fetchCatalog(runtime, identity);
1836
+ return runtime.catalogError === void 0 ? {
1837
+ state: "refreshed",
1838
+ reason: `${runtime.catalog.current().length} models`
1839
+ } : {
1840
+ state: "failed",
1841
+ reason: runtime.catalogError
1842
+ };
1843
+ },
1844
+ setModelVisibility: async (modelId, visible, expectedAccount) => {
1845
+ const account = runtime.account();
1846
+ if (account === void 0) return {
1847
+ state: "failed",
1848
+ reason: "model visibility needs a signed-in account with a stable user id"
1849
+ };
1850
+ if (expectedAccount !== account) return {
1851
+ state: "stale-account",
1852
+ reason: "the signed-in account changed"
1853
+ };
1854
+ try {
1855
+ runtime.visibilityStore.setVisible(account, modelId, visible);
1856
+ } catch (error) {
1857
+ return {
1858
+ state: "failed",
1859
+ reason: error instanceof Error ? error.message.slice(0, 300) : String(error)
1860
+ };
1861
+ }
1862
+ runtime.invalidate();
1863
+ return { state: "updated" };
1864
+ }
1865
+ }, probeKey);
1866
+ }
1867
+ });
1868
+ ctx.effect(() => () => {
1869
+ stopped = true;
1870
+ for (const timer of timers) clearInterval(timer);
1871
+ timers.length = 0;
1872
+ clearHostHeartbeat();
1873
+ });
1874
+ /**
1875
+ * Fetch one variant's catalog for the current credential.
1876
+ *
1877
+ * Shared by the credential sweep and the card's manual refresh, and written
1878
+ * so that concurrent callers cost one request and cannot interleave badly:
1879
+ *
1880
+ * - **One request at a time.** A second caller joins the in-flight fetch
1881
+ * instead of starting its own (spec §5: one catalog request per variant at
1882
+ * a time).
1883
+ * - **Generation-checked write-back.** The request records the generation it
1884
+ * started under and writes nothing if the generation moved on — which is
1885
+ * what a slow answer from a superseded account must not do. Checking only
1886
+ * the *identity* was not enough: two refreshes for the same account can
1887
+ * still finish out of order, and the older one would win.
1888
+ * - **`resolve()`, not `config`.** Only `resolve()` performs the locked,
1889
+ * single-flight token renewal. Reading `config` meant an expired token
1890
+ * made every catalog request fail until something else happened to refresh
1891
+ * it, leaving the group on the fallback roster.
1892
+ */
1893
+ const fetchCatalog = async (runtime, identity) => {
1894
+ const inflight = runtime.inflightFetch;
1895
+ const generation = runtime.catalogGeneration;
1896
+ if (inflight !== void 0 && inflight.identity === identity && inflight.generation === generation) return inflight.promise;
1897
+ inflight?.controller.abort();
1898
+ const controller = new AbortController();
1899
+ let run;
1900
+ run = (async () => {
1901
+ let models;
1902
+ try {
1903
+ const credential = await runtime.store.resolve();
1904
+ const resolvedIdentity = credentialIdentity(credential);
1905
+ if (resolvedIdentity !== identity) {
1906
+ adoptIdentity(runtime, resolvedIdentity, visibilityAccountOf(credential));
1907
+ await fetchCatalog(runtime, resolvedIdentity);
1908
+ return;
1909
+ }
1910
+ models = await runtime.client.fetchModels(credential, controller.signal);
1911
+ const latest = await runtime.store.current();
1912
+ const latestIdentity = latest === void 0 ? void 0 : credentialIdentity(latest);
1913
+ if (latestIdentity !== identity) {
1914
+ adoptIdentity(runtime, latestIdentity, latest === void 0 ? void 0 : visibilityAccountOf(latest));
1915
+ if (latestIdentity !== void 0) await fetchCatalog(runtime, latestIdentity);
1916
+ return;
1917
+ }
1918
+ } catch (error) {
1919
+ if (stopped || runtime.catalogGeneration !== generation) return;
1920
+ runtime.lastFetchAtMs = Date.now();
1921
+ runtime.catalogError = error instanceof Error ? error.message.slice(0, 300) : String(error);
1922
+ ctx.logger.warn(`dsh-workbuddy-bridge: ${runtime.variant.displayName} catalog unavailable; serving the fallback list`, error);
1923
+ runtime.invalidate();
1924
+ return;
1925
+ }
1926
+ if (stopped || runtime.catalogGeneration !== generation) return;
1927
+ runtime.lastFetchAtMs = Date.now();
1928
+ runtime.catalog.set([...models]);
1929
+ runtime.catalogSource = "live";
1930
+ runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
1931
+ runtime.catalogError = void 0;
1932
+ if (lastIdentities.get(runtime.variant.id) === identity) runtime.savedCatalogs.set(identity, {
1933
+ source: runtime.client.lastCatalog?.source ?? "unknown",
1934
+ fetchedAtMs: runtime.client.lastCatalog?.fetchedAtMs ?? Date.now(),
1935
+ models: [...models],
1936
+ ...runtime.client.lastCatalog?.appVersion === void 0 ? {} : { appVersion: runtime.client.lastCatalog.appVersion.version }
1937
+ });
1938
+ runtime.invalidate();
1939
+ })().finally(() => {
1940
+ if (runtime.inflightFetch?.promise === run) runtime.inflightFetch = void 0;
1941
+ });
1942
+ runtime.inflightFetch = {
1943
+ identity,
1944
+ generation,
1945
+ controller,
1946
+ promise: run
1947
+ };
1948
+ return run;
1949
+ };
1950
+ /**
1951
+ * Reconcile one variant with its credentials.
1952
+ *
1953
+ * Four transitions matter, and each is a different action:
1954
+ *
1955
+ * - **none → some** (first sighting): reveal the group and fetch a catalog.
1956
+ * - **none → some, identity changed**: additionally drop the previous
1957
+ * account's observations, so another user's probe answers cannot be read as
1958
+ * the new account's.
1959
+ * - **some → none**: hide the group and stop serving its models.
1960
+ * - **same identity**: nothing to do — the store refreshes tokens on demand,
1961
+ * and re-fetching on every rotation would hit the catalog endpoint for no
1962
+ * new information.
1963
+ */
1964
+ const syncVariant = async (runtime) => {
1965
+ if (stopped || !runtime.registered) return;
1966
+ const credential = await runtime.store.current().catch((error) => {
1967
+ ctx.logger.warn(`dsh-workbuddy-bridge: ${runtime.variant.displayName} credential read failed`, error);
1968
+ });
1969
+ if (stopped) return;
1970
+ if (credential === void 0) {
1971
+ adoptIdentity(runtime, void 0, void 0);
1972
+ return;
1973
+ }
1974
+ const identity = credentialIdentity(credential);
1975
+ if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
1976
+ const stale = runtime.catalogSource !== "live";
1977
+ const due = Date.now() - runtime.lastFetchAtMs >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
1978
+ if (stale && due) await fetchCatalog(runtime, identity);
1979
+ return;
1980
+ }
1981
+ adoptIdentity(runtime, identity, visibilityAccountOf(credential));
1982
+ await fetchCatalog(runtime, identity);
1983
+ };
1984
+ /** Run one reconcile sweep across both variants. */
1985
+ const syncAll = async () => {
1986
+ for (const runtime of runtimes) await syncVariant(runtime);
1987
+ };
1988
+ Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime))).then(() => {
1989
+ if (stopped) return;
1990
+ if (runtimes.some((runtime) => runtime.registered)) writeHostHeartbeat();
1991
+ syncAll();
1992
+ const timer = setInterval(() => {
1993
+ syncAll();
1994
+ }, credentialPollMs());
1995
+ timer.unref?.();
1996
+ timers.push(timer);
1997
+ });
1998
+ }
1999
+ //#endregion
2000
+ export { AI_VARIANT, CN_APP_VERSION_FILENAME, CN_VARIANT, Config, FALLBACK_CN_APP_VERSION, FALLBACK_WORKBUDDY_AI_MODELS, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, WORKBUDDY_APP_VERSION_FILENAME, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CATALOG_FILENAME, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_VARIANTS, WORKBUDDY_VISIBILITY_FILENAME, WorkBuddyCatalog, WorkBuddyCatalogStore, WorkBuddyCredentialStore, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddyUpstreamClient, WorkBuddyVisibilityStore, appUserAgent, apply, chatUserAgent, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, desktopAuthCandidatesFor, fallbackChatIdentity, fingerprintModel, inject, installedAppVersion, isHeartbeatProcessAlive, modelWithCurrentPromotion, name, normalizeCredits, parseModelCatalog, parseWorkBuddyAuth, prepareChatBody, prepareInternationalChatBody, probeModel, processStartTimeMs, randomSentinel, readBundleVersion, readCliVersion, readHostHeartbeat, regionOf, resolveAppVersion, resolveChatIdentity, validAppVersion, validCliVersion, variantFor, visibilityAccountOf, workbuddyCatalogPath, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath, workbuddyVisibilityPath };