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.
@@ -0,0 +1,3807 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { readFile, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, release } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
6
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
7
+ import { createDecipheriv, createHash, randomBytes } from "node:crypto";
8
+ import { execFile, execFileSync } from "node:child_process";
9
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
10
+ //#region src/protocol/app-version.ts
11
+ /**
12
+ * The international desktop app's version, used as the `/v3/config` UA.
13
+ *
14
+ * The App-shaped catalog is served only to a User-Agent carrying the product
15
+ * name (see `docs/workbuddy-ai-international-research-2026-09-11.md` §2.7).
16
+ * That document's conclusion recommended the space form `WorkBuddy AI/<v>`;
17
+ * re-measured on 2026-09-11 the *space* form is rejected (HTTP 400, code
18
+ * 12403) while the terse `WorkBuddyAI/<v>` form — with or without the space
19
+ * removed — returns the 21-model App document. The UA is therefore built from
20
+ * the form verified in code, not from the earlier prose.
21
+ *
22
+ * The version is only ever a UA component: a missing App, an unreadable
23
+ * plist, or a bad cached value degrades to the last saved value and finally to
24
+ * a compiled-in constant, and never blocks credential use or the provider.
25
+ *
26
+ * @module dsh-workbuddy-bridge/app-version
27
+ */
28
+ /**
29
+ * Last-resort UA version.
30
+ *
31
+ * The gateway ignores the version number when splitting the UA (research §2.7.2
32
+ * item 2: `CLI/1.0.0`, `CLI/99.0.0` and the real version all return the same
33
+ * document), so this constant is a shape requirement rather than a currency
34
+ * claim. It is *not* used to infer anything about model capabilities.
35
+ */
36
+ const FALLBACK_APP_VERSION = "5.5.2";
37
+ /** Basename of the saved version under `$DSH_HOME`. */
38
+ const WORKBUDDY_APP_VERSION_FILENAME = ".workbuddy-ai-version.json";
39
+ /**
40
+ * Whether a string is safe to interpolate into an HTTP header.
41
+ *
42
+ * Strict on purpose: the value reaches a header, so anything that could split
43
+ * the request (CR, LF, spaces beyond the separator) or inject a second UA
44
+ * token must never pass. The App's own version is always `N.N.N` or `N.N.N.N`.
45
+ */
46
+ function validAppVersion(value) {
47
+ return typeof value === "string" && /^\d{1,6}(?:\.\d{1,6}){1,3}$/u.test(value);
48
+ }
49
+ /** macOS App-bundle roots: system-wide first, then the user's own install. */
50
+ function macAppRoots$1() {
51
+ return ["/Applications", join(homedir(), "Applications")];
52
+ }
53
+ /**
54
+ * Read `CFBundleShortVersionString` out of an `Info.plist`.
55
+ *
56
+ * Parsed as XML rather than grepped, because the plist contains several
57
+ * `<string>` values and a regex would be one unrelated key away from
58
+ * returning the wrong one. A binary plist has no `<dict>` in its bytes and is
59
+ * reported as unreadable (the saved value then applies) rather than guessed at.
60
+ */
61
+ async function readBundleVersion(plistPath) {
62
+ let text;
63
+ try {
64
+ text = await readFile(plistPath, "utf8");
65
+ } catch {
66
+ return;
67
+ }
68
+ const version = /<key>\s*CFBundleShortVersionString\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(text)?.[1]?.trim();
69
+ return validAppVersion(version) ? version : void 0;
70
+ }
71
+ /**
72
+ * The installed international App's version, or `undefined` when it is not
73
+ * installed (or not readable).
74
+ *
75
+ * Windows and Linux have no verified bundle-metadata location yet, so this
76
+ * returns `undefined` there and the saved/fallback value is used instead of
77
+ * guessing a path — the same discipline the credential discovery follows.
78
+ */
79
+ async function installedAppVersion() {
80
+ if (process.platform !== "darwin") return void 0;
81
+ for (const root of macAppRoots$1()) {
82
+ const bundle = join(root, "WorkBuddy AI.app");
83
+ const version = await readBundleVersion(join(bundle, "Contents", "Info.plist"));
84
+ if (version !== void 0) return {
85
+ version,
86
+ bundle
87
+ };
88
+ }
89
+ }
90
+ /** Saved-version file path under the Harness home. */
91
+ function appVersionPath() {
92
+ return join(resolveDshHome(), WORKBUDDY_APP_VERSION_FILENAME);
93
+ }
94
+ /**
95
+ * Resolve the UA version: installed App first, then the last saved value, then
96
+ * the compiled-in fallback.
97
+ *
98
+ * A value read from the App is written back immediately, so an uninstalled App
99
+ * or an unreadable plist later still has the last real version to fall back
100
+ * on. The write is best-effort: failing to cache a version must never fail the
101
+ * catalog request that asked for it.
102
+ */
103
+ async function resolveAppVersion(options = {}) {
104
+ const path = options.path ?? appVersionPath();
105
+ const installed = await (options.installed ?? installedAppVersion)();
106
+ if (installed !== void 0 && validAppVersion(installed.version)) {
107
+ const write = options.write ?? ((target, content) => writeFileAtomic(target, content, {
108
+ mode: 384,
109
+ dirMode: 448
110
+ }));
111
+ try {
112
+ await write(path, `${JSON.stringify({
113
+ version: installed.version,
114
+ bundle: installed.bundle,
115
+ observedAt: Date.now()
116
+ }, null, 2)}\n`);
117
+ } catch {}
118
+ return {
119
+ version: installed.version,
120
+ source: "installed",
121
+ bundle: installed.bundle
122
+ };
123
+ }
124
+ try {
125
+ const saved = JSON.parse(await readFile(path, "utf8"));
126
+ if (typeof saved === "object" && saved !== null) {
127
+ const version = saved["version"];
128
+ if (validAppVersion(version)) return {
129
+ version,
130
+ source: "saved"
131
+ };
132
+ }
133
+ } catch {}
134
+ return {
135
+ version: FALLBACK_APP_VERSION,
136
+ source: "fallback"
137
+ };
138
+ }
139
+ /**
140
+ * Build the App-shaped User-Agent for catalog requests.
141
+ *
142
+ * `WorkBuddyAI/<version>` with no space is the form measured to reach the App
143
+ * document; the space form is rejected with 400/12403. Throws on an invalid
144
+ * version rather than sending a malformed header.
145
+ */
146
+ function appUserAgent(version) {
147
+ if (!validAppVersion(version)) throw new Error(`invalid WorkBuddy AI version for User-Agent: ${JSON.stringify(version)}`);
148
+ return `WorkBuddyAI/${version}`;
149
+ }
150
+ //#endregion
151
+ //#region src/protocol/client-identity.ts
152
+ /**
153
+ * The desktop-client identity chat requests present as (phase 1 of
154
+ * `docs/upstream-identity-alignment-plan.md`).
155
+ *
156
+ * Chat and its probe sibling carry the User-Agent shape the official desktop
157
+ * client composes — `WorkBuddy/<v> <product>/<v> CLI/<cli>` — where the
158
+ * product token names the app that owns the region's requests: `WorkBuddy`
159
+ * for CN, `WorkBuddy AI` for international. Versions come from the installed
160
+ * App when it can be read, degrade to a per-region saved value, and finally
161
+ * to a compiled-in constant. A CLI version that does not resolve drops the
162
+ * `CLI/…` token instead of inventing one (the official client's own rule for
163
+ * a missing extension).
164
+ *
165
+ * Scope: chat and probe requests ONLY. Refresh, catalog, and billing keep
166
+ * the headers they have always sent; the plan holds the blast radius to this
167
+ * one variable so the live verification matrix stays readable.
168
+ *
169
+ * @module dsh-workbuddy-bridge/client-identity
170
+ */
171
+ /**
172
+ * Compiled-in CN fallback for the `WorkBuddy/<v>` tokens.
173
+ *
174
+ * Observed on the CN desktop app installed here (research §3.1, verified
175
+ * 2026-09-11); like the international fallback it is a shape requirement,
176
+ * not a currency claim — the gateway has not been observed to branch on it.
177
+ */
178
+ const FALLBACK_CN_APP_VERSION = "5.5.6";
179
+ /**
180
+ * Basename of the CN saved-version cache under `$DSH_HOME`.
181
+ *
182
+ * Deliberately not the international `.workbuddy-ai-version.json`: that file
183
+ * feeds the international catalog's User-Agent, and a CN App writing its
184
+ * version into it would relabel that request. The two caches stay isolated
185
+ * the way the per-variant catalog files are.
186
+ */
187
+ const CN_APP_VERSION_FILENAME = ".workbuddy-app-version.json";
188
+ /**
189
+ * Whether a value is a CLI version that may reach a header.
190
+ *
191
+ * Tolerates a prerelease suffix (`2.137.1-rc.1`) because the bundled CLI's
192
+ * own metadata uses that spelling; anything with whitespace, CR or LF never
193
+ * passes — the value is interpolated into an HTTP header.
194
+ */
195
+ function validCliVersion(value) {
196
+ return typeof value === "string" && /^\d{1,6}(?:\.\d{1,6}){1,3}(?:-[0-9A-Za-z.]+)?$/u.test(value);
197
+ }
198
+ /** macOS App-bundle roots, searched in the order `app-version.ts` uses. */
199
+ function macAppRoots() {
200
+ return ["/Applications", join(homedir(), "Applications")];
201
+ }
202
+ /** Path of the bundled agent CLI's package.json inside an App bundle. */
203
+ function cliPackagePath(bundle) {
204
+ return join(bundle, "Contents", "Resources", "app.asar.unpacked", "cli", "package.json");
205
+ }
206
+ /**
207
+ * The bundled agent CLI's real version, or `undefined` when it does not resolve.
208
+ *
209
+ * `cli/package.json` ships a `0.0.0` placeholder in `version` with the real
210
+ * version in `publishConfig.customPackage.version`; a valid non-placeholder
211
+ * `version` wins, otherwise the custom-package value applies, and unreadable
212
+ * or invalid metadata yields `undefined` (the caller drops the `CLI/…` UA
213
+ * token rather than guessing).
214
+ */
215
+ async function readCliVersion(bundle) {
216
+ let document;
217
+ try {
218
+ document = JSON.parse(await readFile(cliPackagePath(bundle), "utf8"));
219
+ } catch {
220
+ return;
221
+ }
222
+ if (typeof document !== "object" || document === null || Array.isArray(document)) return void 0;
223
+ const pkg = document;
224
+ const declared = pkg["version"];
225
+ if (validCliVersion(declared) && declared !== "0.0.0") return declared;
226
+ const publishConfig = pkg["publishConfig"];
227
+ const customPackage = typeof publishConfig === "object" && publishConfig !== null && !Array.isArray(publishConfig) ? publishConfig : void 0;
228
+ const customVersion = (typeof customPackage?.["customPackage"] === "object" && customPackage["customPackage"] !== null && !Array.isArray(customPackage["customPackage"]) ? customPackage["customPackage"] : void 0)?.["version"];
229
+ return validCliVersion(customVersion) ? customVersion : void 0;
230
+ }
231
+ /**
232
+ * Build the chat User-Agent for one region.
233
+ *
234
+ * Throws on an invalid version rather than interpolating one into a header;
235
+ * `resolveChatIdentity` never produces such an identity, so the throw is a
236
+ * last gate against future call-site mistakes, not an expected path.
237
+ */
238
+ function chatUserAgent(identity, region) {
239
+ if (!validAppVersion(identity.clientVersion)) throw new Error(`invalid client version for chat User-Agent: ${JSON.stringify(identity.clientVersion)}`);
240
+ if (identity.cliVersion !== void 0 && !validCliVersion(identity.cliVersion)) throw new Error(`invalid CLI version for chat User-Agent: ${JSON.stringify(identity.cliVersion)}`);
241
+ const product = region === "global" ? "WorkBuddy AI" : "WorkBuddy";
242
+ const parts = [`WorkBuddy/${identity.clientVersion}`, `${product}/${identity.clientVersion}`];
243
+ if (identity.cliVersion !== void 0) parts.push(`CLI/${identity.cliVersion}`);
244
+ return parts.join(" ");
245
+ }
246
+ /** The installed CN desktop bundle, or `undefined` when it is not installed (or not readable). */
247
+ async function installedCnApp() {
248
+ if (process.platform !== "darwin") return void 0;
249
+ for (const root of macAppRoots()) {
250
+ const bundle = join(root, "WorkBuddy.app");
251
+ const version = await readBundleVersion(join(bundle, "Contents", "Info.plist"));
252
+ if (version !== void 0) return {
253
+ version,
254
+ bundle
255
+ };
256
+ }
257
+ }
258
+ /** Default CN saved-cache path. */
259
+ function cnSavedVersionPath() {
260
+ return join(resolveDshHome(), CN_APP_VERSION_FILENAME);
261
+ }
262
+ /**
263
+ * Resolve the chat identity for one region: installed App → region's saved
264
+ * value → compiled-in fallback. Never throws — a missing App, an unreadable
265
+ * plist, a failed cache write, or a reader that throws outright all degrade
266
+ * to {@link fallbackChatIdentity}; resolution never blocks a message.
267
+ *
268
+ * The production path caches per region (a message must not re-read the
269
+ * install tree); any injected option bypasses the cache entirely so tests
270
+ * with different readers cannot observe each other's resolutions.
271
+ */
272
+ async function resolveChatIdentity(region, options = {}) {
273
+ const injectable = options.installedCn !== void 0 || options.resolveIntl !== void 0 || options.cliVersion !== void 0 || options.cnSavedPath !== void 0;
274
+ if (!injectable) {
275
+ const cached = cache.get(region);
276
+ if (cached !== void 0) return cached;
277
+ }
278
+ let identity;
279
+ try {
280
+ identity = region === "global" ? await resolveGlobalIdentity(options) : await resolveCnIdentity(options);
281
+ } catch {
282
+ return fallbackChatIdentity(region);
283
+ }
284
+ if (!injectable) cache.set(region, identity);
285
+ return identity;
286
+ }
287
+ const cache = /* @__PURE__ */ new Map();
288
+ /**
289
+ * The region's compiled-in fallback identity: the desktop form with the
290
+ * built-in version and no `CLI/…` segment. This is the single degraded
291
+ * shape every failure path converges on — a thrown reader, an unreadable
292
+ * bundle, or a missing cache all present this, never the legacy CLI UA.
293
+ */
294
+ function fallbackChatIdentity(region) {
295
+ return { clientVersion: region === "global" ? FALLBACK_APP_VERSION : FALLBACK_CN_APP_VERSION };
296
+ }
297
+ /** CN: installed `WorkBuddy.app` → CN saved cache → CN fallback. */
298
+ async function resolveCnIdentity(options) {
299
+ const savedPath = options.cnSavedPath ?? cnSavedVersionPath();
300
+ const installed = await (options.installedCn ?? installedCnApp)();
301
+ if (installed !== void 0 && validAppVersion(installed.version)) {
302
+ const cliVersion = await (options.cliVersion ?? readCliVersion)(installed.bundle);
303
+ const identity = {
304
+ clientVersion: installed.version,
305
+ ...cliVersion !== void 0 && validCliVersion(cliVersion) ? { cliVersion } : {}
306
+ };
307
+ try {
308
+ await writeFileAtomic(savedPath, `${JSON.stringify({
309
+ version: identity.clientVersion,
310
+ observedAt: Date.now()
311
+ }, null, 2)}\n`, {
312
+ mode: 384,
313
+ dirMode: 448
314
+ });
315
+ } catch {}
316
+ return identity;
317
+ }
318
+ try {
319
+ const saved = JSON.parse(await readFile(savedPath, "utf8"));
320
+ if (typeof saved === "object" && saved !== null && !Array.isArray(saved)) {
321
+ const document = saved;
322
+ if (validAppVersion(document["version"])) return { clientVersion: document["version"] };
323
+ }
324
+ } catch {}
325
+ return fallbackChatIdentity("cn");
326
+ }
327
+ /**
328
+ * International: reuse `app-version.ts`'s installed → saved → fallback chain
329
+ * (its cache format and the catalog's version source stay untouched). The
330
+ * CLI version is read only when that chain reports the installed bundle; a
331
+ * saved or fallback resolution has no bundle path and drops the `CLI/…` token.
332
+ * The CN cache is never read or written on this path.
333
+ */
334
+ async function resolveGlobalIdentity(options) {
335
+ const info = await (options.resolveIntl ?? resolveAppVersion)();
336
+ const clientVersion = validAppVersion(info.version) ? info.version : FALLBACK_APP_VERSION;
337
+ let cliVersion;
338
+ if (info.bundle !== void 0) {
339
+ const read = await (options.cliVersion ?? readCliVersion)(info.bundle);
340
+ if (read !== void 0 && validCliVersion(read)) cliVersion = read;
341
+ }
342
+ return {
343
+ clientVersion,
344
+ ...cliVersion === void 0 ? {} : { cliVersion }
345
+ };
346
+ }
347
+ //#endregion
348
+ //#region src/protocol/errors.ts
349
+ /**
350
+ * 上游失败的结构化分类。
351
+ *
352
+ * 判定顺序是刻意的:**结构化信号优先,HTTP 状态次之,文案匹配只做兜底**。
353
+ * 上游一旦改动提示文案,靠子串匹配的错误分类就会静默失效;而 `extError.code`
354
+ * 这类机器码与 HTTP 状态是协议的一部分,稳定得多。文案匹配保留下来只是因为
355
+ * 部分失败既不带码也不带明确状态,此时它是唯一线索 —— 命中兜底时调用方应当
356
+ * 记日志,让新出现的文案能被发现并补进码表。
357
+ *
358
+ * @module dsh-workbuddy-bridge/protocol/errors
359
+ */
360
+ /**
361
+ * 观测到的上游机器码 → 失败类别。
362
+ *
363
+ * 这张表的每一条都来自真实响应(见 docs/),不是推测。遇到表里没有的码时
364
+ * 按 HTTP 状态兜底,并把码打进日志,方便补表 —— 宁可分类粗一点,也不要猜。
365
+ */
366
+ const UPSTREAM_CODE_KINDS = {
367
+ "12153": "session_dead",
368
+ "11128": "client",
369
+ "11133": "client"
370
+ };
371
+ /** 业务码 → 失败类别:信封 code 非 0 时的第一落点。 */
372
+ const ENVELOPE_CODE_KINDS = {
373
+ 12153: "session_dead",
374
+ 11128: "client",
375
+ 11133: "client"
376
+ };
377
+ /**
378
+ * 文案兜底标记。**只在既无机器码也无业务码可依时使用**,且命中后必须记日志:
379
+ * 它是唯一的线索,同时也是最容易失效的一环。中英两种拼写都在,因为上游两种都返回过。
380
+ */
381
+ const TEXT_MARKERS = [
382
+ ["insufficient credit", "hard_credit"],
383
+ ["no credit", "hard_credit"],
384
+ ["credit exhausted", "hard_credit"],
385
+ ["credits exhausted", "hard_credit"],
386
+ ["out of credit", "hard_credit"],
387
+ ["quota exceeded", "hard_credit"],
388
+ ["payment required", "hard_credit"],
389
+ ["credit not enough", "hard_credit"],
390
+ ["积分不足", "hard_credit"],
391
+ ["额度不足", "hard_credit"],
392
+ ["余额不足", "hard_credit"],
393
+ ["积分用完", "hard_credit"],
394
+ ["额度用尽", "hard_credit"],
395
+ ["没有积分", "hard_credit"],
396
+ ["offline user session not found", "session_dead"],
397
+ ["12153", "session_dead"],
398
+ ["11128", "client"],
399
+ ["11133", "client"]
400
+ ];
401
+ /** 上游错误体:只取结构化字段,其余一概不看。 */
402
+ const ErrorBody = z.object({
403
+ extError: z.object({
404
+ code: z.string(),
405
+ message: z.string()
406
+ }),
407
+ code: z.number(),
408
+ msg: z.string()
409
+ });
410
+ /** 信封里的业务码与文案。 */
411
+ const EnvelopeBody = z.object({
412
+ code: z.number(),
413
+ msg: z.string()
414
+ });
415
+ /** 从响应体里读出结构化错误码;读不出来返回 undefined。 */
416
+ function upstreamErrorCode(body) {
417
+ const parsed = parseJson(body);
418
+ if (parsed === void 0) return void 0;
419
+ try {
420
+ return ErrorBody(parsed).extError?.code;
421
+ } catch {
422
+ return;
423
+ }
424
+ }
425
+ /** 读出信封业务码;非 JSON 或无 code 字段时返回 undefined。 */
426
+ function envelopeCodeOf(body) {
427
+ const parsed = parseJson(body);
428
+ if (parsed === void 0) return void 0;
429
+ try {
430
+ return EnvelopeBody(parsed).code;
431
+ } catch {
432
+ return;
433
+ }
434
+ }
435
+ /**
436
+ * 分类一次上游失败。
437
+ *
438
+ * @param status - HTTP 状态;传输层失败传 0。
439
+ * @param body - 响应体原文(已截断亦可)。
440
+ */
441
+ function classifyUpstreamFailure(status, body) {
442
+ const code = upstreamErrorCode(body);
443
+ if (code !== void 0) return {
444
+ status,
445
+ kind: UPSTREAM_CODE_KINDS[code] ?? kindOfStatus(status),
446
+ code,
447
+ message: body.slice(0, 160),
448
+ fromTextFallback: false
449
+ };
450
+ const envelopeCode = envelopeCodeOf(body);
451
+ if (envelopeCode !== void 0 && envelopeCode !== 0) return {
452
+ status,
453
+ kind: ENVELOPE_CODE_KINDS[envelopeCode] ?? kindOfStatus(status),
454
+ envelopeCode,
455
+ message: body.slice(0, 160),
456
+ fromTextFallback: false
457
+ };
458
+ const text = textKindOf(body);
459
+ if (text !== void 0) return {
460
+ status,
461
+ kind: text,
462
+ message: body.slice(0, 160),
463
+ fromTextFallback: true
464
+ };
465
+ return {
466
+ status,
467
+ kind: kindOfStatus(status),
468
+ message: body.slice(0, 160),
469
+ fromTextFallback: false
470
+ };
471
+ }
472
+ /** 兼容旧调用点:只要类别,不要证据。 */
473
+ function classifyUpstreamError(status, body) {
474
+ return classifyUpstreamFailure(status, body).kind;
475
+ }
476
+ /** HTTP 状态到类别的兜底映射;传输层失败(0)算服务端。 */
477
+ function kindOfStatus(status) {
478
+ if (status === 402) return "hard_credit";
479
+ if (status === 429) return "soft_rate";
480
+ if (status === 404) return "not_found";
481
+ if (status >= 500 || status === 0) return "server";
482
+ return "client";
483
+ }
484
+ /** 文案兜底:命中返回类别,否则 undefined。 */
485
+ function textKindOf(body) {
486
+ const lower = body.toLowerCase();
487
+ for (const [marker, kind] of TEXT_MARKERS) if (lower.includes(marker.toLowerCase())) return kind;
488
+ }
489
+ /** 解析 JSON;非 JSON 返回 undefined(上游时不时返回 HTML 或空体)。 */
490
+ function parseJson(body) {
491
+ try {
492
+ return JSON.parse(body);
493
+ } catch {
494
+ return;
495
+ }
496
+ }
497
+ //#endregion
498
+ //#region src/probe/probe.ts
499
+ /**
500
+ * The reasoning-effort probe: decide whether a model's `reasoning_effort`
501
+ * parameter is actually validated, and if so which canonical values it accepts.
502
+ *
503
+ * Implements `docs/reasoning-effort-probe-plan.md` §4. The order matters and is
504
+ * not an optimization:
505
+ *
506
+ * 1. **Baseline** (no `reasoning_effort`) proves the model, credential, and
507
+ * request shape work at all, so a later rejection can be attributed.
508
+ * 2. **Sentinel** (a fresh random, impossible-to-collide value) answers the one
509
+ * question a per-level sweep cannot: does the upstream validate the field?
510
+ * A model that accepts the sentinel answers 200 to *everything*, so its
511
+ * per-level results would be uniformly false positives.
512
+ * 3. **Levels**, only after the sentinel was refused.
513
+ *
514
+ * The result is an observation, never a capability claim. Even a fully
515
+ * successful sweep means "the upstream accepted these spellings", not "these
516
+ * spellings change how the model thinks".
517
+ *
518
+ * @module dsh-workbuddy-bridge/probe
519
+ */
520
+ /**
521
+ * The canonical values a probe tests, in a fixed order.
522
+ *
523
+ * `minimal` is absent: it appears in no upstream vocabulary. `off` is absent
524
+ * by policy — disabling thinking is a separate capability the upstream must
525
+ * declare through `canDisableThinking`, never something probing may infer.
526
+ */
527
+ const PROBE_EFFORT_CANDIDATES = [
528
+ "low",
529
+ "medium",
530
+ "high",
531
+ "xhigh",
532
+ "max"
533
+ ];
534
+ /** Prompt body used by every probe request; carries nothing user-specific. */
535
+ const PROBE_PROMPT = "ping";
536
+ /** Default sentinel: unmistakably non-canonical, different on every call. */
537
+ function randomSentinel() {
538
+ return `probe_sentinel_${randomBytes(12).toString("hex")}`;
539
+ }
540
+ /**
541
+ * The upstream's "this effort value is not supported" code, measured
542
+ * 2026-09-11 (plan §4.2). It is *not* treated as a permanent protocol promise:
543
+ * anything unrecognized degrades to `unknown` rather than to a capability
544
+ * conclusion.
545
+ */
546
+ const INVALID_EFFORT_CODE = "invalid_reasoning_effort";
547
+ /** Whether an attempt is an attributable rejection of the effort value. */
548
+ function isEffortRejection(attempt) {
549
+ return attempt.status === 400 && attempt.errorCode === INVALID_EFFORT_CODE;
550
+ }
551
+ /** Whether an attempt shows the upstream accepted the request and streamed. */
552
+ function isAcceptance(attempt) {
553
+ return attempt.status === 200 && attempt.streamed;
554
+ }
555
+ /** Why an attempt ended in `unknown`, phrased for a log line. */
556
+ function unknownReason(stage, attempt) {
557
+ const code = attempt.errorCode === void 0 ? "" : ` (${attempt.errorCode})`;
558
+ const detail = attempt.detail === void 0 ? "" : `: ${attempt.detail}`;
559
+ return `${stage} status ${attempt.status}${code}${detail}`;
560
+ }
561
+ /**
562
+ * Probe one model.
563
+ *
564
+ * `options.candidates` exists so tests can shorten the sweep; production always
565
+ * uses {@link PROBE_EFFORT_CANDIDATES}.
566
+ */
567
+ async function probeModel(options) {
568
+ const sentinel = options.sentinel ?? randomSentinel;
569
+ const candidates = options.candidates ?? PROBE_EFFORT_CANDIDATES;
570
+ const timeoutMs = options.timeoutMs ?? 3e4;
571
+ let requests = 0;
572
+ const attempt = async (effort) => {
573
+ requests += 1;
574
+ const controller = new AbortController();
575
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
576
+ try {
577
+ return await options.send(effort, controller.signal);
578
+ } catch (error) {
579
+ return {
580
+ status: 0,
581
+ streamed: false,
582
+ detail: `transport error: ${String(error)}`
583
+ };
584
+ } finally {
585
+ clearTimeout(timer);
586
+ }
587
+ };
588
+ const baseline = await attempt(void 0);
589
+ if (!isAcceptance(baseline)) return {
590
+ validation: "unknown",
591
+ efforts: [],
592
+ requests,
593
+ reason: unknownReason("baseline", baseline)
594
+ };
595
+ const sentinelAttempt = await attempt(sentinel());
596
+ if (isAcceptance(sentinelAttempt)) return {
597
+ validation: "non-validating",
598
+ efforts: [],
599
+ requests
600
+ };
601
+ if (!isEffortRejection(sentinelAttempt)) return {
602
+ validation: "unknown",
603
+ efforts: [],
604
+ requests,
605
+ reason: unknownReason("sentinel", sentinelAttempt)
606
+ };
607
+ const accepted = [];
608
+ for (const effort of candidates) {
609
+ const levelAttempt = await attempt(effort);
610
+ if (isAcceptance(levelAttempt)) {
611
+ accepted.push(effort);
612
+ continue;
613
+ }
614
+ if (isEffortRejection(levelAttempt)) continue;
615
+ return {
616
+ validation: "unknown",
617
+ efforts: [],
618
+ requests,
619
+ reason: unknownReason(`level ${effort}`, levelAttempt)
620
+ };
621
+ }
622
+ return {
623
+ validation: "validating",
624
+ efforts: accepted,
625
+ requests
626
+ };
627
+ }
628
+ //#endregion
629
+ //#region src/protocol/client.ts
630
+ /**
631
+ * WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
632
+ * token refresh, model catalog, and credit balance. The wire behavior is
633
+ * ported from Sliverkiss/workbuddy2api (MIT), whose Go implementation is
634
+ * battle-tested against the real endpoint.
635
+ *
636
+ * @module dsh-workbuddy-bridge/upstream
637
+ */
638
+ const CN_CHAT_BASE = "https://copilot.tencent.com";
639
+ const CN_BILLING_BASE = "https://www.codebuddy.cn";
640
+ const GLOBAL_BASE = "https://www.workbuddy.ai";
641
+ /**
642
+ * Display name for the single synthetic row the enterprise endpoint produces.
643
+ *
644
+ * The endpoint reports one cycle quota, not the personal endpoint's list of
645
+ * named packages, so the card's "by package" table has exactly one row.
646
+ */
647
+ const enterprisePackageName = "enterprise";
648
+ /**
649
+ * Field names and value types of a response document, for diagnostics.
650
+ *
651
+ * Names and `typeof` only. This string ends up in the status route and then in
652
+ * the browser, and the response describes the account's own usage; the values
653
+ * themselves must never travel. Only the document and its `data` member are
654
+ * described, so the output stays small.
655
+ */
656
+ function describeShape(document) {
657
+ if (typeof document !== "object" || document === null || Array.isArray(document)) return typeof document;
658
+ const record = document;
659
+ const at = (source) => {
660
+ const keys = Object.keys(source).slice(0, 24);
661
+ return keys.length === 0 ? "(empty)" : keys.map((key) => `${key}:${typeof source[key]}`).join(", ");
662
+ };
663
+ const top = `top-level { ${at(record)} }`;
664
+ const data = record["data"];
665
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return top;
666
+ return `${top}; data { ${at(data)} }`;
667
+ }
668
+ /** Shared CLI-form User-Agent for refresh and the CN catalog; chat and probe present the desktop identity (client-identity.ts). */
669
+ const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
670
+ const JSON_TIMEOUT_MS = 3e4;
671
+ const ERROR_BODY_LIMIT = 4096;
672
+ /** The concrete effort spellings WorkBuddy exposes on the wire. */
673
+ const EFFORT_VALUES = [
674
+ "low",
675
+ "medium",
676
+ "high",
677
+ "xhigh",
678
+ "max"
679
+ ];
680
+ /** Promotional badge keys the upstream tags carry, minus their color suffix. */
681
+ const BADGE_PREFIX = "badge:";
682
+ /** Parse the upstream `reasoning` object into {@link WorkBuddyModelReasoning}. */
683
+ function resolveUpstreamReasoning(wrapped) {
684
+ const supports = wrapped["supportsReasoning"] === true;
685
+ const onlyReasoning = wrapped["onlyReasoning"] === true;
686
+ const rawReasoning = wrapped["reasoning"];
687
+ let supportedEfforts;
688
+ let defaultEffort;
689
+ let canDisableThinking = true;
690
+ if (typeof rawReasoning === "object" && rawReasoning !== null && !Array.isArray(rawReasoning)) {
691
+ const reasoning = rawReasoning;
692
+ const rawEfforts = reasoning["supportedEfforts"];
693
+ if (Array.isArray(rawEfforts)) {
694
+ const efforts = rawEfforts.filter((value) => typeof value === "string" && EFFORT_VALUES.includes(value));
695
+ if (efforts.length > 0) supportedEfforts = efforts;
696
+ }
697
+ if (typeof reasoning["defaultEffort"] === "string" && EFFORT_VALUES.includes(reasoning["defaultEffort"])) defaultEffort = reasoning["defaultEffort"];
698
+ else if (typeof reasoning["effort"] === "string" && EFFORT_VALUES.includes(reasoning["effort"])) defaultEffort = reasoning["effort"];
699
+ canDisableThinking = reasoning["canDisableThinking"] === true;
700
+ }
701
+ return { reasoning: {
702
+ supports,
703
+ onlyReasoning,
704
+ ...supportedEfforts === void 0 ? {} : { supportedEfforts },
705
+ ...defaultEffort === void 0 ? {} : { defaultEffort },
706
+ canDisableThinking
707
+ } };
708
+ }
709
+ /**
710
+ * Reduce an upstream credits string to its language-neutral display form.
711
+ *
712
+ * The host LLM seam carries this text to the browser, and the host has no
713
+ * locale service — whatever string is produced here is shown verbatim in every
714
+ * UI language. The upstream is inconsistent in a way that matters: some catalog
715
+ * rows report a bare multiplier (`x0.79`) and others append a unit word
716
+ * (`x0.79 credits`), and the unit word would pin the display to English.
717
+ * Dropping a trailing `credits` (case-insensitive, singular or plural) yields
718
+ * the one spelling that reads identically in every language.
719
+ *
720
+ * @param credits - raw upstream credits string, e.g. `"x0.79 credits"`.
721
+ * @returns the bare multiplier, or undefined when nothing displayable remains.
722
+ */
723
+ function normalizeCredits(credits) {
724
+ if (credits === void 0) return void 0;
725
+ const trimmed = credits.trim();
726
+ if (trimmed === "") return void 0;
727
+ if (/^credits?$/iu.test(trimmed)) return void 0;
728
+ const bare = trimmed.replace(/\s+credits?$/iu, "").trim();
729
+ return bare === "" ? void 0 : bare;
730
+ }
731
+ /**
732
+ * Parse the upstream `tags` / `credits` fields into billing metadata.
733
+ *
734
+ * @param wrapped - one catalog row.
735
+ * @param extraTags - badge tags recovered from another document, merged in
736
+ * after the row's own. The `/v3/config` product document carries the roster
737
+ * but no `badge:*` tags; those live only in the console catalog, so the CN
738
+ * refresh reads both and joins them here (see `fetchPromoBadges`).
739
+ */
740
+ function resolveUpstreamBilling(wrapped, extraTags) {
741
+ const rawCredits = wrapped["credits"];
742
+ const credits = typeof rawCredits === "string" && rawCredits.trim() !== "" ? rawCredits.trim() : void 0;
743
+ const badges = [];
744
+ const rawTags = [...Array.isArray(wrapped["tags"]) ? wrapped["tags"] : [], ...extraTags ?? []];
745
+ for (const tag of rawTags) {
746
+ if (typeof tag !== "string") continue;
747
+ if (!tag.toLowerCase().startsWith(BADGE_PREFIX)) continue;
748
+ const label = tag.slice(6).split(":")[0] ?? tag.slice(6);
749
+ if (label !== "" && !badges.includes(label)) badges.push(label);
750
+ }
751
+ const multiplier = normalizeCredits(credits);
752
+ const free = multiplier !== void 0 && /^x?0\.0+$/u.test(multiplier);
753
+ return { billing: {
754
+ ...credits === void 0 ? {} : { credits },
755
+ ...badges.length === 0 ? {} : { badges },
756
+ free
757
+ } };
758
+ }
759
+ /** Region for a login domain; an empty domain means CN (matching upstream tooling). */
760
+ function regionOf(domain) {
761
+ const lowered = domain.trim().toLowerCase();
762
+ if (lowered === "workbuddy.ai" || lowered.endsWith(".workbuddy.ai")) return "global";
763
+ return "cn";
764
+ }
765
+ function chatBase(credential) {
766
+ return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
767
+ }
768
+ function billingBase(credential) {
769
+ return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
770
+ }
771
+ function originReferer(credential) {
772
+ return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
773
+ }
774
+ /** Headers every upstream request shares. */
775
+ function commonHeaders(credential) {
776
+ return {
777
+ "Accept": "application/json, text/plain, */*",
778
+ "X-Requested-With": "XMLHttpRequest",
779
+ "Origin": originReferer(credential),
780
+ "Referer": `${originReferer(credential)}/`,
781
+ "User-Agent": CLIENT_UA
782
+ };
783
+ }
784
+ /**
785
+ * Chat request headers, including the X-No-* conventions the official CLI uses.
786
+ *
787
+ * `userAgent` carries the desktop identity for chat and probe requests; when
788
+ * it is absent the shared CLI-form UA applies. Refresh shares `commonHeaders`
789
+ * but never this override, so the two paths cannot drift into each other.
790
+ */
791
+ function chatHeaders(credential, userAgent) {
792
+ return {
793
+ ...commonHeaders(credential),
794
+ ...userAgent === void 0 ? {} : { "User-Agent": userAgent },
795
+ "Content-Type": "application/json",
796
+ ...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
797
+ ...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
798
+ ...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
799
+ "X-Product": "SaaS"
800
+ };
801
+ }
802
+ /** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
803
+ function refreshHeaders(credential) {
804
+ const headers = {
805
+ ...commonHeaders(credential),
806
+ "X-Refresh-Token": credential.refreshToken,
807
+ "X-Auth-Refresh-Source": "workbuddy"
808
+ };
809
+ if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
810
+ return headers;
811
+ }
812
+ /** Billing request headers. */
813
+ function billingHeaders(credential) {
814
+ const headers = {
815
+ "Authorization": `Bearer ${credential.accessToken}`,
816
+ "Accept": "application/json",
817
+ "Content-Type": "application/json"
818
+ };
819
+ if (credential.uid !== "") headers["X-User-Id"] = credential.uid;
820
+ if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
821
+ headers["X-Enterprise-Id"] = credential.enterpriseId;
822
+ headers["X-Tenant-Id"] = credential.enterpriseId;
823
+ }
824
+ if (credential.domain !== "") headers["X-Domain"] = credential.domain;
825
+ return headers;
826
+ }
827
+ /**
828
+ * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
829
+ * force `stream: true` (the upstream rejects non-streaming), flatten
830
+ * `tool_choice` (the upstream's field is a string; object forms return 400),
831
+ * and rewrite `developer` messages as `system`.
832
+ *
833
+ * The `developer` rewrite is load-bearing: pi-ai emits the system prompt as
834
+ * `role: "developer"` (the OpenAI convention it adopted), but the WorkBuddy
835
+ * upstream rejects that role with HTTP 400 code 11128 ("Illegal API
836
+ * invocation from an unapproved channel"). Rewriting to `system` is the
837
+ * compatible spelling the upstream accepts.
838
+ */
839
+ function prepareChatBody(source) {
840
+ let body;
841
+ try {
842
+ body = JSON.parse(source);
843
+ } catch {
844
+ return source;
845
+ }
846
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return source;
847
+ const obj = body;
848
+ obj["stream"] = true;
849
+ normalizeDeveloperRole(obj);
850
+ normalizeToolChoice(obj);
851
+ return JSON.stringify(obj);
852
+ }
853
+ /** Rewrite `role: "developer"` messages to `role: "system"` (upstream rejects developer). */
854
+ function normalizeDeveloperRole(obj) {
855
+ const messages = obj["messages"];
856
+ if (!Array.isArray(messages)) return;
857
+ for (const message of messages) {
858
+ if (typeof message !== "object" || message === null || Array.isArray(message)) continue;
859
+ const wrapped = message;
860
+ if (wrapped["role"] === "developer") wrapped["role"] = "system";
861
+ }
862
+ }
863
+ /** Rewrite OpenAI `tool_choice` spellings into the upstream's string form. */
864
+ function normalizeToolChoice(obj) {
865
+ const suppress = () => {
866
+ delete obj["tools"];
867
+ delete obj["functions"];
868
+ };
869
+ if (!("tool_choice" in obj)) return;
870
+ const choice = obj["tool_choice"];
871
+ if (typeof choice === "string") {
872
+ if (choice.trim().toLowerCase() === "none") {
873
+ delete obj["tool_choice"];
874
+ suppress();
875
+ }
876
+ return;
877
+ }
878
+ if (typeof choice === "object" && choice !== null && !Array.isArray(choice)) {
879
+ const wrapped = choice;
880
+ const type = typeof wrapped["type"] === "string" ? wrapped["type"].trim().toLowerCase() : "";
881
+ if (type === "none") {
882
+ delete obj["tool_choice"];
883
+ suppress();
884
+ } else if (type === "auto" || type === "required") obj["tool_choice"] = type;
885
+ else if (type === "function") {
886
+ const fn = typeof wrapped["function"] === "object" && wrapped["function"] !== null ? wrapped["function"] : void 0;
887
+ let name = typeof fn?.["name"] === "string" ? fn["name"] : "";
888
+ if (name === "" && typeof wrapped["name"] === "string") name = wrapped["name"];
889
+ name = name.trim();
890
+ obj["tool_choice"] = name !== "" ? name : "auto";
891
+ } else delete obj["tool_choice"];
892
+ return;
893
+ }
894
+ delete obj["tool_choice"];
895
+ }
896
+ async function readEnvelope(response) {
897
+ const text = await response.text();
898
+ let parsed;
899
+ try {
900
+ parsed = JSON.parse(text);
901
+ } catch {
902
+ throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
903
+ }
904
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
905
+ const document = parsed;
906
+ return {
907
+ code: typeof document["code"] === "number" ? document["code"] : 0,
908
+ msg: typeof document["msg"] === "string" ? document["msg"] : "",
909
+ data: "data" in document ? document["data"] : void 0,
910
+ document
911
+ };
912
+ }
913
+ /** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
914
+ function envelopeError(status, envelope) {
915
+ const kind = classifyUpstreamError(status, envelope.msg);
916
+ return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
917
+ }
918
+ /**
919
+ * Upstream HTTP client. One instance serves the whole plugin; requests take
920
+ * the credential explicitly so token refreshes apply on the next call.
921
+ *
922
+ * One instance is *per variant*: the international provider needs its own
923
+ * catalog source, UA version, and probe differences, and keeping them on the
924
+ * instance avoids passing a variant through every call signature.
925
+ */
926
+ var WorkBuddyUpstreamClient = class {
927
+ /**
928
+ * Resolves the App-shaped UA version for international catalog requests.
929
+ * Injectable so tests never read the real filesystem.
930
+ */
931
+ resolveAppVersion;
932
+ /** Chat-identity resolver; see {@link WorkBuddyUpstreamClientOptions.resolveChatIdentity}. */
933
+ resolveChatIdentity;
934
+ /** Provenance of the most recent successful catalog fetch, for the card. */
935
+ lastCatalog;
936
+ constructor(options = {}) {
937
+ this.resolveAppVersion = options.resolveAppVersion ?? (() => resolveAppVersion());
938
+ this.resolveChatIdentity = options.resolveChatIdentity ?? ((region) => resolveChatIdentity(region));
939
+ }
940
+ /** POST the chat endpoint; a successful answer is the raw SSE response. */
941
+ async chatStream(credential, bodyJson, signal) {
942
+ const region = regionOf(credential.domain);
943
+ let userAgent;
944
+ try {
945
+ userAgent = chatUserAgent(await this.resolveChatIdentity(region), region);
946
+ } catch {
947
+ userAgent = chatUserAgent(fallbackChatIdentity(region), region);
948
+ }
949
+ let response;
950
+ try {
951
+ response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
952
+ method: "POST",
953
+ headers: {
954
+ ...chatHeaders(credential, userAgent),
955
+ "Authorization": `Bearer ${credential.accessToken}`
956
+ },
957
+ body: region === "global" ? prepareInternationalChatBody(bodyJson) : bodyJson,
958
+ ...signal === void 0 ? {} : { signal }
959
+ });
960
+ } catch (error) {
961
+ return {
962
+ ok: false,
963
+ status: 0,
964
+ kind: "server",
965
+ message: `transport error: ${String(error)}`
966
+ };
967
+ }
968
+ if (response.ok) return {
969
+ ok: true,
970
+ response
971
+ };
972
+ const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
973
+ return {
974
+ ok: false,
975
+ status: response.status,
976
+ kind: classifyUpstreamError(response.status, text),
977
+ message: text
978
+ };
979
+ }
980
+ /** POST the token-refresh endpoint; the caller merges the outcome. */
981
+ async refreshToken(credential) {
982
+ const response = await fetch(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
983
+ method: "POST",
984
+ headers: refreshHeaders(credential),
985
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
986
+ });
987
+ const envelope = await readEnvelope(response);
988
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
989
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
990
+ const accessToken = typeof data["accessToken"] === "string" ? data["accessToken"] : "";
991
+ if (accessToken === "") throw new Error("workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app");
992
+ const outcome = { accessToken };
993
+ if (typeof data["refreshToken"] === "string" && data["refreshToken"] !== "") outcome.refreshToken = data["refreshToken"];
994
+ if (typeof data["expiresIn"] === "number" && data["expiresIn"] > 0) outcome.expiresInSec = data["expiresIn"];
995
+ if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
996
+ return outcome;
997
+ }
998
+ /**
999
+ * GET the personal model catalog.
1000
+ *
1001
+ * Both variants read `/v3/config`, the product document the desktop product
1002
+ * itself fetches. CN used to read `/console/enterprises/personal/models`
1003
+ * (the console catalog) instead, and that was why its model list drifted
1004
+ * from the desktop App's selector: the console document lags the product
1005
+ * one, and the product roster itself churns day to day (`auto`,
1006
+ * `kimi-k3-1`, `minimax-m3` have each appeared and disappeared within a
1007
+ * week).
1008
+ *
1009
+ * What distinguishes the two variants here is the User-Agent, not the path:
1010
+ * the gateway splits `/v3/config` by client identity, and the split is
1011
+ * load-bearing. A CLI-shaped UA yields the CLI's roster — the chat models
1012
+ * this plugin serves — while an App-shaped UA yields the App's internal
1013
+ * roster. CN keeps the CLI UA it sends for chat, so the catalog it
1014
+ * advertises is exactly the one its own requests can use. The international
1015
+ * variant has no CLI identity, so it keeps the App-shaped UA.
1016
+ *
1017
+ * Membership is the `cli` roster intersected with the usable rows (see
1018
+ * {@link parseModelCatalog}); the promo badges the product document does
1019
+ * not carry are merged in from a best-effort console read — see
1020
+ * {@link fetchPromoBadges}.
1021
+ *
1022
+ * Responses are unwrapped and classified the same way — `readEnvelope` plus
1023
+ * `envelopeError` — so an expired session or exhausted credit is reported as
1024
+ * such rather than as a generic catalog failure.
1025
+ */
1026
+ async fetchModels(credential, signal) {
1027
+ const international = regionOf(credential.domain) === "global";
1028
+ const appVersion = international ? await this.resolveAppVersion() : void 0;
1029
+ const response = await fetch(`${chatBase(credential)}/v3/config`, {
1030
+ headers: {
1031
+ Authorization: `Bearer ${credential.accessToken}`,
1032
+ Accept: "application/json",
1033
+ Origin: originReferer(credential),
1034
+ Referer: `${originReferer(credential)}/`,
1035
+ ...international ? {
1036
+ "X-Requested-With": "XMLHttpRequest",
1037
+ "X-Product": "SaaS"
1038
+ } : {},
1039
+ "User-Agent": appVersion === void 0 ? CLIENT_UA : appUserAgent(appVersion.version)
1040
+ },
1041
+ signal: signal === void 0 ? AbortSignal.timeout(JSON_TIMEOUT_MS) : AbortSignal.any([signal, AbortSignal.timeout(JSON_TIMEOUT_MS)])
1042
+ });
1043
+ const envelope = await readEnvelope(response);
1044
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1045
+ const models = parseModelCatalog(isObject(envelope.data) ? envelope.data : "models" in envelope.document || "agents" in envelope.document ? envelope.document : {}, international, international ? void 0 : await this.fetchPromoBadges(credential, signal));
1046
+ this.lastCatalog = {
1047
+ fetchedAtMs: Date.now(),
1048
+ source: international ? "workbuddy-ai:app" : "workbuddy:cli",
1049
+ ...appVersion === void 0 ? {} : { appVersion }
1050
+ };
1051
+ return models;
1052
+ }
1053
+ /**
1054
+ * Read the console catalog's promotional tags, by model id.
1055
+ *
1056
+ * `/v3/config` carries no `badge:<label>:<color>` tags — the discount labels
1057
+ * the cards render (`限时免费`, `夜间折扣`, …) live only in
1058
+ * `/console/enterprises/personal/models`. Since the roster now comes from
1059
+ * the product document, those tags are read from the console one in a
1060
+ * second request and merged by id.
1061
+ *
1062
+ * Best-effort by construction: a badge is a label on a price, so failing to
1063
+ * read this document must not fail a catalog refresh. Every failure —
1064
+ * network, envelope, an unreadable body — returns undefined, and the models
1065
+ * simply ship without badges.
1066
+ */
1067
+ async fetchPromoBadges(credential, signal) {
1068
+ try {
1069
+ const response = await fetch(`${chatBase(credential)}/console/enterprises/personal/models`, {
1070
+ headers: {
1071
+ Authorization: `Bearer ${credential.accessToken}`,
1072
+ Accept: "application/json",
1073
+ Origin: originReferer(credential),
1074
+ Referer: `${originReferer(credential)}/`,
1075
+ "User-Agent": CLIENT_UA
1076
+ },
1077
+ signal: signal === void 0 ? AbortSignal.timeout(JSON_TIMEOUT_MS) : AbortSignal.any([signal, AbortSignal.timeout(JSON_TIMEOUT_MS)])
1078
+ });
1079
+ if (!response.ok) return void 0;
1080
+ const envelope = await readEnvelope(response);
1081
+ const data = isObject(envelope.data) ? envelope.data : envelope.document;
1082
+ const rawModels = Array.isArray(data["models"]) ? data["models"] : [];
1083
+ const badges = /* @__PURE__ */ new Map();
1084
+ for (const model of rawModels) {
1085
+ if (!isObject(model)) continue;
1086
+ const id = typeof model["id"] === "string" ? model["id"] : "";
1087
+ if (id === "") continue;
1088
+ const tags = Array.isArray(model["tags"]) ? model["tags"].filter((tag) => typeof tag === "string" && tag.toLowerCase().startsWith(BADGE_PREFIX)) : [];
1089
+ if (tags.length > 0) badges.set(id, tags);
1090
+ }
1091
+ return badges.size === 0 ? void 0 : badges;
1092
+ } catch {
1093
+ return;
1094
+ }
1095
+ }
1096
+ /**
1097
+ * POST the billing endpoint for the aggregated remaining credit.
1098
+ *
1099
+ * Two upstream shapes, chosen by account type:
1100
+ *
1101
+ * - **CN enterprise** (`regionOf === 'cn'` and `enterpriseId` non-empty) asks
1102
+ * `/v2/billing/meter/get-enterprise-user-usage`, which answers with a single
1103
+ * cycle quota. The personal endpoint serves these accounts an empty
1104
+ * `Accounts` list, which the card then renders as "0 credit" — a wrong
1105
+ * number rather than a visible failure (issue #31).
1106
+ * - **Everyone else** keeps the personal endpoint unchanged.
1107
+ *
1108
+ * The region gate is load-bearing: the enterprise endpoint is unverified for
1109
+ * the global region, so an international credential that happens to carry an
1110
+ * `enterpriseId` must stay on the measured personal path instead of being
1111
+ * moved onto an unmeasured one.
1112
+ */
1113
+ async fetchCredits(credential) {
1114
+ if (regionOf(credential.domain) === "cn" && credential.enterpriseId !== void 0 && credential.enterpriseId !== "") return await this.fetchEnterpriseCredits(credential);
1115
+ const now = /* @__PURE__ */ new Date();
1116
+ const format = (date) => [
1117
+ date.getFullYear().toString().padStart(4, "0"),
1118
+ (date.getMonth() + 1).toString().padStart(2, "0"),
1119
+ date.getDate().toString().padStart(2, "0")
1120
+ ].join("-") + " " + [
1121
+ date.getHours().toString().padStart(2, "0"),
1122
+ date.getMinutes().toString().padStart(2, "0"),
1123
+ date.getSeconds().toString().padStart(2, "0")
1124
+ ].join(":");
1125
+ const response = await fetch(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
1126
+ method: "POST",
1127
+ headers: billingHeaders(credential),
1128
+ body: JSON.stringify({
1129
+ PageNumber: 1,
1130
+ PageSize: 100,
1131
+ ProductCode: "p_tcaca",
1132
+ Status: [0, 3],
1133
+ PackageEndTimeRangeBegin: format(now),
1134
+ PackageEndTimeRangeEnd: format(new Date(now.getTime() + 3185136e6))
1135
+ }),
1136
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1137
+ });
1138
+ const envelope = await readEnvelope(response);
1139
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1140
+ const responseWrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
1141
+ const data = typeof responseWrapper["Response"] === "object" && responseWrapper["Response"] !== null ? responseWrapper["Response"] : {};
1142
+ const inner = typeof data["Data"] === "object" && data["Data"] !== null ? data["Data"] : {};
1143
+ const rawAccounts = Array.isArray(inner["Accounts"]) ? inner["Accounts"] : [];
1144
+ const accounts = [];
1145
+ let total = 0;
1146
+ for (const raw of rawAccounts) {
1147
+ if (typeof raw !== "object" || raw === null) continue;
1148
+ const account = raw;
1149
+ const numberField = (key) => typeof account[key] === "number" ? account[key] : 0;
1150
+ const size = numberField("CycleCapacitySize");
1151
+ const cycleRemain = numberField("CycleCapacityRemain");
1152
+ const cycleUsed = numberField("CycleCapacityUsed");
1153
+ const capacityRemain = numberField("CapacityRemain");
1154
+ let remain;
1155
+ if (size > 0) remain = cycleRemain;
1156
+ else if (cycleRemain > 0 || cycleUsed > 0) remain = cycleRemain;
1157
+ else remain = capacityRemain;
1158
+ if (remain < 0) remain = 0;
1159
+ total += remain;
1160
+ accounts.push({
1161
+ packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
1162
+ remain,
1163
+ size: size > 0 ? size : numberField("CapacitySize")
1164
+ });
1165
+ }
1166
+ return {
1167
+ total,
1168
+ accounts
1169
+ };
1170
+ }
1171
+ /**
1172
+ * CN enterprise credit read: a single cycle quota instead of a package list.
1173
+ *
1174
+ * Verified against the WorkBuddy desktop app (`app.asar`,
1175
+ * `BackendProvider.getEnterpriseUsage` and `CloudAccountRepo.billing`): the
1176
+ * body is an empty object and the account identity travels only in the
1177
+ * headers. The two official call sites disagree on the field spelling
1178
+ * (`limitNum`/`credit` vs `limit_num`/`used_num`), so both are accepted.
1179
+ *
1180
+ * A body carrying no recognisable quota field is a hard error rather than a
1181
+ * zero. Rendering `0` for "we did not understand the answer" is exactly how
1182
+ * issue #31 stayed invisible while users saw a plausible wrong number.
1183
+ *
1184
+ * The error names fields and types only: it reaches the browser, and the
1185
+ * response body may describe the account's usage.
1186
+ */
1187
+ async fetchEnterpriseCredits(credential) {
1188
+ const response = await fetch(`${CN_BILLING_BASE}/v2/billing/meter/get-enterprise-user-usage`, {
1189
+ method: "POST",
1190
+ headers: billingHeaders(credential),
1191
+ body: JSON.stringify({}),
1192
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1193
+ });
1194
+ const envelope = await readEnvelope(response);
1195
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1196
+ const sources = [];
1197
+ for (const candidate of [envelope.data, envelope.document]) {
1198
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) continue;
1199
+ const record = candidate;
1200
+ if (typeof record["data"] === "object" && record["data"] !== null && !Array.isArray(record["data"])) sources.push(record["data"]);
1201
+ sources.push(record);
1202
+ }
1203
+ const numberAt = (source, key) => typeof source[key] === "number" ? source[key] : void 0;
1204
+ let limit;
1205
+ let used;
1206
+ let resetTime;
1207
+ for (const source of sources) {
1208
+ const candidate = numberAt(source, "limitNum") ?? numberAt(source, "limit_num");
1209
+ if (candidate === void 0) continue;
1210
+ limit = candidate;
1211
+ used = numberAt(source, "credit") ?? numberAt(source, "used_num");
1212
+ if (typeof source["cycleResetTime"] === "string" && source["cycleResetTime"] !== "") resetTime = source["cycleResetTime"];
1213
+ break;
1214
+ }
1215
+ if (limit === void 0) throw new Error(`workbuddy enterprise billing response carried no recognised quota field (expected limitNum/limit_num + credit/used_num; received ${describeShape(envelope.document)})`);
1216
+ if (limit === -1) return {
1217
+ total: 0,
1218
+ accounts: [{
1219
+ packageName: enterprisePackageName,
1220
+ remain: 0,
1221
+ size: 0,
1222
+ unlimited: true
1223
+ }],
1224
+ unlimited: true,
1225
+ ...resetTime === void 0 ? {} : { cycleResetTime: resetTime }
1226
+ };
1227
+ if (used === void 0) throw new Error(`workbuddy enterprise billing response carried a quota limit but no recognised usage field (expected credit/used_num alongside limitNum/limit_num; received ${describeShape(envelope.document)})`);
1228
+ let remain = limit - used;
1229
+ if (remain < 0) remain = 0;
1230
+ return {
1231
+ total: remain,
1232
+ accounts: [{
1233
+ packageName: enterprisePackageName,
1234
+ remain,
1235
+ size: limit
1236
+ }],
1237
+ ...resetTime === void 0 ? {} : { cycleResetTime: resetTime }
1238
+ };
1239
+ }
1240
+ /**
1241
+ * One probe request: a real streaming chat call carrying the effort under
1242
+ * test.
1243
+ *
1244
+ * Shares `chatHeaders` with the normal chat path on purpose — the plan
1245
+ * forbids probing through anything but the plugin's own credential handling,
1246
+ * so a result describes what a real message would experience.
1247
+ *
1248
+ * The caller aborts as soon as a parseable event arrives; the body is never
1249
+ * assembled into an answer. `reasoning_effort` is omitted entirely (rather
1250
+ * than sent empty) when `effort` is undefined, so the baseline case is a
1251
+ * genuinely bare request.
1252
+ *
1253
+ * Two international differences, both measured on 2026-09-11:
1254
+ *
1255
+ * - The gateway requires a leading `system` message (400/11128 otherwise), so
1256
+ * one is prepended for the global region only.
1257
+ * - `max_tokens: 1` is below some models' floor (the GPT-5.6 family rejects it
1258
+ * with 400/11133 `integer_below_min_value`), so the international probe asks
1259
+ * for a slightly larger minimum. This is a floor the plugin must clear, not
1260
+ * evidence about any model's effort support: a model still refusing that
1261
+ * minimum is reported as an incompatible request, never as "effort
1262
+ * unsupported", and the ceiling is never raised further to force an answer.
1263
+ */
1264
+ async probeEffort(credential, model, effort, signal) {
1265
+ const international = regionOf(credential.domain) === "global";
1266
+ let userAgent;
1267
+ try {
1268
+ userAgent = chatUserAgent(await this.resolveChatIdentity(international ? "global" : "cn"), international ? "global" : "cn");
1269
+ } catch {
1270
+ userAgent = chatUserAgent(fallbackChatIdentity(international ? "global" : "cn"), international ? "global" : "cn");
1271
+ }
1272
+ const payload = {
1273
+ model,
1274
+ stream: true,
1275
+ messages: [...international ? [{
1276
+ role: "system",
1277
+ content: INTERNATIONAL_SYSTEM_PROMPT
1278
+ }] : [], {
1279
+ role: "user",
1280
+ content: PROBE_PROMPT
1281
+ }],
1282
+ max_tokens: international ? INTERNATIONAL_PROBE_MAX_TOKENS : 1
1283
+ };
1284
+ if (effort !== void 0) payload["reasoning_effort"] = effort;
1285
+ let response;
1286
+ try {
1287
+ response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
1288
+ method: "POST",
1289
+ headers: {
1290
+ ...chatHeaders(credential, userAgent),
1291
+ "Authorization": `Bearer ${credential.accessToken}`
1292
+ },
1293
+ body: JSON.stringify(payload),
1294
+ signal
1295
+ });
1296
+ } catch (error) {
1297
+ return {
1298
+ status: 0,
1299
+ streamed: false,
1300
+ detail: `transport error: ${String(error)}`
1301
+ };
1302
+ }
1303
+ if (!response.ok) {
1304
+ const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
1305
+ return {
1306
+ status: response.status,
1307
+ streamed: false,
1308
+ ...errorCodeOf(text)
1309
+ };
1310
+ }
1311
+ const streamed = await readFirstEvent(response);
1312
+ return {
1313
+ status: response.status,
1314
+ streamed
1315
+ };
1316
+ }
1317
+ };
1318
+ /** Pull `extError.code` out of an upstream error body, if it is shaped that way. */
1319
+ function errorCodeOf(text) {
1320
+ try {
1321
+ const parsed = JSON.parse(text);
1322
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1323
+ const extError = parsed["extError"];
1324
+ if (typeof extError === "object" && extError !== null && !Array.isArray(extError)) {
1325
+ const code = extError["code"];
1326
+ if (typeof code === "string") return {
1327
+ errorCode: code,
1328
+ detail: code
1329
+ };
1330
+ }
1331
+ }
1332
+ } catch {}
1333
+ return { detail: text.slice(0, 200) };
1334
+ }
1335
+ /**
1336
+ * Consume just enough of a streaming response to know it really streams.
1337
+ *
1338
+ * Returns true on the first chunk containing a data line. Cancels the body
1339
+ * afterwards; a stream that ends or errors before that counts as not streamed,
1340
+ * because an empty 200 is not evidence the effort was accepted.
1341
+ */
1342
+ async function readFirstEvent(response) {
1343
+ const body = response.body;
1344
+ if (body === null) return false;
1345
+ const reader = body.getReader();
1346
+ const decoder = new TextDecoder();
1347
+ try {
1348
+ for (;;) {
1349
+ const { done, value } = await reader.read();
1350
+ if (done) return false;
1351
+ if (decoder.decode(value, { stream: true }).includes("data:")) return true;
1352
+ }
1353
+ } catch {
1354
+ return false;
1355
+ } finally {
1356
+ await reader.cancel().catch(() => {});
1357
+ }
1358
+ }
1359
+ function isObject(value) {
1360
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1361
+ }
1362
+ function positive(value) {
1363
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
1364
+ }
1365
+ /**
1366
+ * Parse either response shape after its envelope has been checked.
1367
+ *
1368
+ * Membership is the `cli` agent's roster, intersected with the rows that are
1369
+ * usable: the roster is what the client identity this plugin presents is
1370
+ * allowed to chat with, and joining rather than trusting it outright drops
1371
+ * both ids the roster has retired and rows the document lists but cannot
1372
+ * serve (no row, `disabled: true`, or non-positive caps all drop out here —
1373
+ * a published-but-unservable id must never reach the picker).
1374
+ *
1375
+ * @param data - the unwrapped catalog/product document.
1376
+ * @param international - whether it is the international product document, whose
1377
+ * rows carry window objects and promotions.
1378
+ * @param promoBadges - badge tags by model id, read from the console document,
1379
+ * which is the only one that carries them.
1380
+ */
1381
+ function parseModelCatalog(data, international = false, promoBadges) {
1382
+ const rawModels = Array.isArray(data["models"]) ? data["models"] : [];
1383
+ const agents = Array.isArray(data["agents"]) ? data["agents"] : [];
1384
+ let cliIds;
1385
+ for (const agent of agents) if (typeof agent === "object" && agent !== null) {
1386
+ const wrapped = agent;
1387
+ if (wrapped["name"] === "cli" && Array.isArray(wrapped["models"])) {
1388
+ cliIds = wrapped["models"].filter((id) => typeof id === "string");
1389
+ break;
1390
+ }
1391
+ }
1392
+ if (cliIds === void 0 || cliIds.length === 0) throw new Error("workbuddy model catalog lists no cli agent models");
1393
+ const byId = /* @__PURE__ */ new Map();
1394
+ for (const model of rawModels) {
1395
+ if (typeof model !== "object" || model === null) continue;
1396
+ const wrapped = model;
1397
+ const id = typeof wrapped["id"] === "string" ? wrapped["id"] : "";
1398
+ if (id === "" || wrapped["disabled"] === true) continue;
1399
+ const input = typeof wrapped["maxInputTokens"] === "number" ? wrapped["maxInputTokens"] : 0;
1400
+ const output = typeof wrapped["maxOutputTokens"] === "number" ? wrapped["maxOutputTokens"] : 0;
1401
+ if (input <= 0 || output <= 0) continue;
1402
+ byId.set(id, {
1403
+ id,
1404
+ name: typeof wrapped["name"] === "string" && wrapped["name"] !== "" ? wrapped["name"] : id,
1405
+ contextWindow: international && isObject(wrapped["contextWindow"]) && positive(wrapped["contextWindow"]["defaultLength"]) ? wrapped["contextWindow"]["defaultLength"] : input,
1406
+ ...international ? {
1407
+ ...isObject(wrapped["contextWindow"]) && positive(wrapped["contextWindow"]["defaultLength"]) ? { defaultContextWindow: wrapped["contextWindow"]["defaultLength"] } : {},
1408
+ maxInputTokens: input,
1409
+ supportedContextWindows: isObject(wrapped["contextWindow"]) && Array.isArray(wrapped["contextWindow"]["supportedLengths"]) ? wrapped["contextWindow"]["supportedLengths"].filter(positive) : [],
1410
+ promotions: parsePromotions(data["modelPromotions"], id)
1411
+ } : {},
1412
+ maxTokens: output,
1413
+ supportsImages: wrapped["supportsImages"] === true && wrapped["disabledMultimodal"] !== true,
1414
+ ...resolveUpstreamReasoning(wrapped),
1415
+ ...resolveUpstreamBilling(wrapped, promoBadges?.get(id))
1416
+ });
1417
+ }
1418
+ const models = cliIds.map((id) => byId.get(id)).filter((model) => model !== void 0);
1419
+ if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
1420
+ return models;
1421
+ }
1422
+ /** Extract the promotions covering `model` from the `modelPromotions` array. */
1423
+ function parsePromotions(value, model) {
1424
+ if (!Array.isArray(value)) return [];
1425
+ return value.flatMap((item) => {
1426
+ if (!isObject(item) || item["enabled"] !== true) return [];
1427
+ const modelIds = item["modelIds"];
1428
+ if (!Array.isArray(modelIds) || !modelIds.includes(model)) return [];
1429
+ const schedule = item["schedule"];
1430
+ const discount = item["discount"];
1431
+ const badge = item["badge"];
1432
+ if (!isObject(schedule) || !isObject(discount) || !isObject(badge)) return [];
1433
+ if (discount["displayMode"] !== "replace") return [];
1434
+ const start = typeof schedule["validFrom"] === "string" ? Date.parse(schedule["validFrom"]) : NaN;
1435
+ const end = typeof schedule["validUntil"] === "string" ? Date.parse(schedule["validUntil"]) : NaN;
1436
+ const factor = discount["factor"];
1437
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return [];
1438
+ if (typeof factor !== "number" || !Number.isFinite(factor) || factor < 0) return [];
1439
+ return [{
1440
+ start,
1441
+ end,
1442
+ factor,
1443
+ label: typeof badge["label"] === "string" ? badge["label"] : "",
1444
+ priority: typeof item["priority"] === "number" && Number.isFinite(item["priority"]) ? item["priority"] : 0
1445
+ }];
1446
+ });
1447
+ }
1448
+ /**
1449
+ * Re-evaluate a model's promotion against the current time.
1450
+ *
1451
+ * Promotions are time-boxed, and the catalog they arrive in is cached for the
1452
+ * life of the process. Frozen at parse time, a cached "Free now" would keep
1453
+ * claiming a discount after `validUntil` had passed, and would keep showing the
1454
+ * pre-discount rate as the discounted one. Re-deriving on every read means the
1455
+ * badge disappears on its own and the rate reverts, with no refresh needed.
1456
+ *
1457
+ * Non-destructive: the model's own `credits` and `badges` are the base, and the
1458
+ * promotion is layered onto a copy. A model with no live promotion is returned
1459
+ * as-is, so the common case allocates nothing.
1460
+ */
1461
+ function modelWithCurrentPromotion(model, now = Date.now()) {
1462
+ if (model.promotions === void 0 || model.promotions.length === 0) return model;
1463
+ const promotion = [...model.promotions].sort((a, b) => b.priority - a.priority).find((candidate) => now >= candidate.start && now < candidate.end);
1464
+ if (promotion === void 0) {
1465
+ if (!(model.billing?.free === true || (model.billing?.badges?.length ?? 0) > 0 || model.promotions.some((candidate) => candidate.factor !== 1))) return model;
1466
+ return {
1467
+ ...model,
1468
+ billing: {
1469
+ free: false,
1470
+ rateUnknown: true
1471
+ }
1472
+ };
1473
+ }
1474
+ const rate = normalizeCredits(model.billing?.credits);
1475
+ const original = rate !== void 0 && rate.startsWith("x") ? Number(rate.slice(1)) : NaN;
1476
+ if (promotion.factor !== 0 && !Number.isFinite(original)) return model;
1477
+ const value = promotion.factor === 0 ? 0 : original * promotion.factor;
1478
+ return {
1479
+ ...model,
1480
+ billing: {
1481
+ ...model.billing,
1482
+ credits: `x${value.toFixed(2)}`,
1483
+ free: value === 0,
1484
+ badges: [...model.billing?.badges ?? [], ...promotion.label === "" ? [] : [promotion.label]]
1485
+ }
1486
+ };
1487
+ }
1488
+ /**
1489
+ * Apply the international endpoint's extra chat requirement: the first message
1490
+ * must be a system prompt.
1491
+ *
1492
+ * The international gateway rejects a body whose first message is not `system`
1493
+ * with HTTP 400 code 11128 ("first message is not system prompt"). Note that
1494
+ * the *same* code means something else on the CN endpoint — there it reports a
1495
+ * rejected `developer` role — so the two are never branched on by code alone.
1496
+ *
1497
+ * The added prompt is deliberately empty of user content and prepended, never
1498
+ * merged: existing messages keep their order and wording. A body that is not a
1499
+ * JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
1500
+ * so this is safe to run over an already-prepared-or-not body.
1501
+ */
1502
+ function prepareInternationalChatBody(source) {
1503
+ const prepared = prepareChatBody(source);
1504
+ let body;
1505
+ try {
1506
+ body = JSON.parse(prepared);
1507
+ } catch {
1508
+ return prepared;
1509
+ }
1510
+ if (!isObject(body)) return prepared;
1511
+ dropUnsupportedEffort(body);
1512
+ const messages = body["messages"];
1513
+ if (!Array.isArray(messages)) return JSON.stringify(body);
1514
+ const first = messages[0];
1515
+ if (isObject(first) && first["role"] === "system") return JSON.stringify(body);
1516
+ messages.unshift({
1517
+ role: "system",
1518
+ content: INTERNATIONAL_SYSTEM_PROMPT
1519
+ });
1520
+ return JSON.stringify(body);
1521
+ }
1522
+ /**
1523
+ * Remove the adapter's own `off` effort spelling from the **international** wire.
1524
+ *
1525
+ * `thinkingLevelMap.off` is pinned to the literal `'off'` for models that
1526
+ * declare `canDisableThinking`, so that the level stays selectable. pi-ai
1527
+ * sends that value for any request carrying no explicit level, and the
1528
+ * international endpoint rejects it on the GPT family with HTTP 400 `11133` /
1529
+ * `extError.param === 'reasoning.effort'` (issue #49). Omission is the only
1530
+ * form measured good on every such model; a literal `'none'` is *not* a safe
1531
+ * substitute — accepted by the GPT-5.6 family and GLM, rejected by
1532
+ * `gpt-6-astra`.
1533
+ *
1534
+ * Consequences, stated honestly: the international picker still offers Off,
1535
+ * but selecting it now means "the field is omitted" — the model's actual
1536
+ * behaviour is decided upstream and is *not* guaranteed to disable thinking
1537
+ * or to match the catalog's `defaultEffort`. Declared spellings
1538
+ * (`low`/`medium`/`high`/`xhigh`/`max`) and an explicit `none` pass through
1539
+ * untouched. The CN variant is deliberately unaffected: its endpoint has
1540
+ * accepted this spelling in every measurement so far, and keeping its wire
1541
+ * unchanged is a scope decision, not a claim about that endpoint's future.
1542
+ */
1543
+ function dropUnsupportedEffort(obj) {
1544
+ if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
1545
+ }
1546
+ /**
1547
+ * The system prompt injected when the international endpoint receives a body
1548
+ * with none.
1549
+ *
1550
+ * Minimal on purpose: it exists to satisfy a gateway precondition, not to
1551
+ * steer the model. The plugin is not the place to invent a persona, and the
1552
+ * normal path never reaches this — pi-ai already sends the harness's system
1553
+ * prompt, so this only covers a caller that omitted one.
1554
+ */
1555
+ const INTERNATIONAL_SYSTEM_PROMPT = "You are a helpful assistant.";
1556
+ /**
1557
+ * Output ceiling for an international probe request.
1558
+ *
1559
+ * Above the smallest value that the strictest observed model accepts (the
1560
+ * GPT-5.6 family rejects `1` with 11133), while still being far too small to
1561
+ * produce a real answer. See {@link WorkBuddyUpstreamClient.probeEffort}.
1562
+ */
1563
+ const INTERNATIONAL_PROBE_MAX_TOKENS = 16;
1564
+ //#endregion
1565
+ //#region src/credential/at-rest.ts
1566
+ /**
1567
+ * WorkBuddy 5.6.x at-rest credential protection: classification, key
1568
+ * resolution, and field decryption for the desktop app's encrypted auth file.
1569
+ *
1570
+ * Since WorkBuddy 5.6 the desktop app encrypts `auth.accessToken` and
1571
+ * `auth.refreshToken` at rest (`buildPolicy: "fields"`, on by default), so the
1572
+ * plugin reads `{$wbEncrypted:1, envelope}` wrappers instead of token strings
1573
+ * (issues #39/#40). Everything needed to open them lives on the same machine:
1574
+ *
1575
+ * - the sealed payload (`{version:1, atRestSecretKey}`) comes from the
1576
+ * WorkBuddy-modified Electron's private `workbuddyStorage` binding, reached
1577
+ * by running *its own* binary once with `ELECTRON_RUN_AS_NODE=1`;
1578
+ * - `protectorKey = sha256(atRestSecretKey, utf8)` opens the envelopes with
1579
+ * AES-256-GCM; the AAD builder below is transcribed from the app's own
1580
+ * `buildAuthenticatedContextAad` (verified live against 5.6.2, see
1581
+ * `docs/r3-final.js` in the working copy — not committed).
1582
+ *
1583
+ * The plugin process itself can never call `_linkedBinding` (it runs in DSH's
1584
+ * Node, not the forked Electron), so the helper is spawned. The key is cached
1585
+ * in memory only, single-flight, and re-resolved when an envelope names a
1586
+ * different key id. Neither the payload, the key, nor any token is ever
1587
+ * logged; error messages carry sizes, ids, and exit codes only.
1588
+ *
1589
+ * @module dsh-workbuddy-bridge/desktop-credential-protection
1590
+ */
1591
+ /** Env variable that overrides the WorkBuddy Electron binary used as the key helper. */
1592
+ const WORKBUDDY_ELECTRON_BIN_ENV = "WORKBUDDY_ELECTRON_BIN";
1593
+ /** Platform-default Electron binary, confirmed only on macOS (5.6.2). */
1594
+ const MACOS_ELECTRON_PATH = "/Applications/WorkBuddy.app/Contents/MacOS/Electron";
1595
+ /**
1596
+ * The Windows install locations probed in order, confirmed on WorkBuddy 5.6.2.
1597
+ *
1598
+ * The app ships the Electron runtime as `WorkBuddy.exe` beside its `resources/`
1599
+ * and `locales/` directories — there is no `Electron.exe`, and the launcher is
1600
+ * the runtime itself (it honours `ELECTRON_RUN_AS_NODE`, which is what the key
1601
+ * helper relies on).
1602
+ *
1603
+ * The default location is `%LOCALAPPDATA%\Programs\WorkBuddy`, but the 5.6.2
1604
+ * installer here put it at the drive root (`E:\WorkBuddy`), so the registry's
1605
+ * `DisplayIcon` is consulted as well — see {@link windowsElectronCandidates}.
1606
+ */
1607
+ /** The Electron runtime as the Windows installer ships it — there is no `Electron.exe`. */
1608
+ const WINDOWS_ELECTRON_FILENAME = "WorkBuddy.exe";
1609
+ /** Where the 5.6.2 installer puts the app unless the user picks another drive. */
1610
+ const WINDOWS_LOCAL_PROGRAM_SUFFIX = [
1611
+ "Programs",
1612
+ "WorkBuddy",
1613
+ WINDOWS_ELECTRON_FILENAME
1614
+ ];
1615
+ /**
1616
+ * The Electron binary one discovery strategy expects at its default location, or
1617
+ * `undefined` where that strategy has no verified layout on this platform.
1618
+ *
1619
+ * Keyed by **strategy** rather than by platform: a strategy names both the
1620
+ * product and the layout it expects, so a macOS-strategy provider on Windows
1621
+ * must report no default rather than this platform's — `discoverMacosApp` would
1622
+ * contradict it a moment later, and a caller reading `helperPath()` for
1623
+ * diagnostics would be pointed at a binary that can never run.
1624
+ *
1625
+ * A platform without a verified layout must set
1626
+ * {@link WORKBUDDY_ELECTRON_BIN_ENV} explicitly — guessing would spawn the wrong
1627
+ * app's binary.
1628
+ */
1629
+ function defaultWorkBuddyElectronPath(discovery = "none") {
1630
+ if (discovery === "macos-workbuddy" && process.platform === "darwin") return MACOS_ELECTRON_PATH;
1631
+ if (discovery === "windows-workbuddy" && process.platform === "win32") {
1632
+ const localAppData = process.env["LOCALAPPDATA"]?.trim();
1633
+ if (localAppData !== void 0 && localAppData !== "") return join(localAppData, ...WINDOWS_LOCAL_PROGRAM_SUFFIX);
1634
+ }
1635
+ }
1636
+ /** Distinct key ids across the wrapped fields, in field order. */
1637
+ function keyIdsOf(fields) {
1638
+ return [...new Set(fields.map((wrapped) => wrapped.envelope.keyId))];
1639
+ }
1640
+ /**
1641
+ * Whether a raw value is the 5.6 field wrapper, with its inner envelope
1642
+ * decodable. The wrapper is `{$wbEncrypted:1, envelope:<base64 of a JSON
1643
+ * {suite,keyId,nonce,authTag,ciphertext>}}`; anything claiming the flag whose
1644
+ * envelope cannot be decoded makes the whole document unrecognized rather
1645
+ * than encrypted, because no key could ever open it.
1646
+ */
1647
+ function parseWrappedField(field, value) {
1648
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1649
+ const wrapped = value;
1650
+ if (wrapped["$wbEncrypted"] !== 1 || typeof wrapped["envelope"] !== "string") return void 0;
1651
+ let inner;
1652
+ try {
1653
+ inner = JSON.parse(Buffer.from(wrapped["envelope"], "base64").toString("utf8"));
1654
+ } catch {
1655
+ return;
1656
+ }
1657
+ if (typeof inner !== "object" || inner === null || Array.isArray(inner)) return void 0;
1658
+ const parts = inner;
1659
+ const nonce = parseBase64(parts["nonce"], 12);
1660
+ const authTag = parseBase64(parts["authTag"], 16);
1661
+ const ciphertext = parseBase64(parts["ciphertext"]);
1662
+ if (nonce === void 0 || authTag === void 0 || ciphertext === void 0) return void 0;
1663
+ if (typeof parts["suite"] !== "number" || !Number.isInteger(parts["suite"])) return void 0;
1664
+ if (parts["suite"] !== 1) return void 0;
1665
+ if (typeof parts["keyId"] !== "string" || !/^[0-9a-f]{16}$/u.test(parts["keyId"])) return void 0;
1666
+ return {
1667
+ field,
1668
+ envelope: {
1669
+ suite: parts["suite"],
1670
+ keyId: parts["keyId"],
1671
+ nonce,
1672
+ authTag,
1673
+ ciphertext
1674
+ }
1675
+ };
1676
+ }
1677
+ /** Decode a base64 value and check its exact byte length when given. */
1678
+ function parseBase64(value, length) {
1679
+ if (typeof value !== "string" || value === "") return void 0;
1680
+ let decoded;
1681
+ try {
1682
+ decoded = Buffer.from(value, "base64");
1683
+ } catch {
1684
+ return;
1685
+ }
1686
+ if (decoded.length === 0 || decoded.toString("base64").replace(/=+$/u, "") !== value.replace(/=+$/u, "")) return void 0;
1687
+ return length === void 0 || decoded.length === length ? decoded : void 0;
1688
+ }
1689
+ const AUTH_FIELDS = ["accessToken", "refreshToken"];
1690
+ /**
1691
+ * Read a desktop auth document's format. `absent` is an empty file; `plaintext`
1692
+ * is any document the regular parser could read (even one without a token);
1693
+ * `encrypted` has at least one field in a decodable wrapper; everything else —
1694
+ * unparsable JSON, non-objects, wrappers whose envelope will not decode — is
1695
+ * `unrecognized`.
1696
+ */
1697
+ function classifyDesktopAuthDocument(text) {
1698
+ if (text.trim() === "") return { format: "absent" };
1699
+ let parsed;
1700
+ try {
1701
+ parsed = JSON.parse(text);
1702
+ } catch {
1703
+ return { format: "unrecognized" };
1704
+ }
1705
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { format: "unrecognized" };
1706
+ const document = parsed;
1707
+ const auth = typeof document["auth"] === "object" && document["auth"] !== null ? document["auth"] : document;
1708
+ const fields = [];
1709
+ for (const field of AUTH_FIELDS) {
1710
+ const value = auth[field];
1711
+ if (typeof value === "string") continue;
1712
+ const wrapped = parseWrappedField(field, value);
1713
+ if (wrapped === void 0 && value !== void 0) return { format: "unrecognized" };
1714
+ if (wrapped !== void 0) fields.push(wrapped);
1715
+ }
1716
+ if (fields.length === 0) return { format: "plaintext" };
1717
+ return {
1718
+ format: "encrypted",
1719
+ wrapped: {
1720
+ document,
1721
+ fields
1722
+ }
1723
+ };
1724
+ }
1725
+ /**
1726
+ * Decrypt a wrapped document into the plaintext text the regular parser reads.
1727
+ * Throws a diagnosable error naming the field and key ids — never envelope or
1728
+ * token content — when any wrapped field cannot be opened.
1729
+ */
1730
+ function unwrapDesktopAuthDocument(classification, openField) {
1731
+ const wrapped = classification.wrapped;
1732
+ const rebuilt = structuredClone(wrapped.document);
1733
+ const auth = typeof rebuilt["auth"] === "object" && rebuilt["auth"] !== null ? rebuilt["auth"] : rebuilt;
1734
+ for (const field of wrapped.fields) auth[field.field] = openField(field);
1735
+ return JSON.stringify(rebuilt);
1736
+ }
1737
+ /**
1738
+ * The authenticated-context AAD for one field envelope, transcribed from the
1739
+ * app bundle's `buildAuthenticatedContextAad` and verified live against 5.6.2
1740
+ * (`docs/r3-final.js` in the working copy holds the original reference).
1741
+ * Credential fields are always suite 1 under the `field` framing (WBEV1);
1742
+ * the framing family's other members (WBEF1/WBER1/WBES1) belong to other
1743
+ * document kinds and are deliberately not implemented — opening a field is
1744
+ * not a place to guess at future formats.
1745
+ */
1746
+ function buildAuthenticatedContextAad(keyId, suite) {
1747
+ const prefix = Buffer.from("WB-AAD\0", "ascii");
1748
+ const lengthPrefixed = (value) => {
1749
+ const bytes = Buffer.from(value, "utf8");
1750
+ const header = Buffer.allocUnsafe(4);
1751
+ header.writeUInt32BE(bytes.length);
1752
+ return Buffer.concat([header, bytes]);
1753
+ };
1754
+ const suiteBytes = Buffer.allocUnsafe(4);
1755
+ suiteBytes.writeUInt32BE(suite);
1756
+ return Buffer.concat([
1757
+ prefix,
1758
+ Buffer.from([1]),
1759
+ lengthPrefixed("WBEV1"),
1760
+ lengthPrefixed("sym-v1"),
1761
+ suiteBytes,
1762
+ lengthPrefixed(keyId),
1763
+ Buffer.from([2]),
1764
+ Buffer.from([0]),
1765
+ Buffer.from([0])
1766
+ ]);
1767
+ }
1768
+ /**
1769
+ * Open one envelope with a protector key; `undefined` when it will not open.
1770
+ * The accepted format is exactly what WorkBuddy 5.6.2 writes — suite 1 under
1771
+ * the `field` framing — so a failure means "not this format / wrong key",
1772
+ * and is reported as such rather than retried against other framings.
1773
+ */
1774
+ function openAuthField(key, envelope) {
1775
+ try {
1776
+ const decipher = createDecipheriv("aes-256-gcm", key, envelope.nonce, { authTagLength: 16 });
1777
+ decipher.setAAD(buildAuthenticatedContextAad(envelope.keyId, envelope.suite));
1778
+ decipher.setAuthTag(envelope.authTag);
1779
+ return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]).toString("utf8");
1780
+ } catch {
1781
+ return;
1782
+ }
1783
+ }
1784
+ /**
1785
+ * Validate the helper's payload against the app's own rules: `version:1` and
1786
+ * a canonical-base64 32-byte, non-all-zero secret. `undefined` otherwise.
1787
+ */
1788
+ function parseAtRestPayload(text) {
1789
+ let parsed;
1790
+ try {
1791
+ parsed = JSON.parse(text);
1792
+ } catch {
1793
+ return;
1794
+ }
1795
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1796
+ const payload = parsed;
1797
+ if (payload["version"] !== 1) return void 0;
1798
+ const secret = payload["atRestSecretKey"];
1799
+ if (typeof secret !== "string" || secret === "") return void 0;
1800
+ let decoded;
1801
+ try {
1802
+ decoded = Buffer.from(secret, "base64");
1803
+ } catch {
1804
+ return;
1805
+ }
1806
+ if (decoded.length !== 32) return void 0;
1807
+ if (decoded.toString("base64") !== secret) return void 0;
1808
+ if (decoded.every((byte) => byte === 0)) return void 0;
1809
+ return { atRestSecretKey: secret };
1810
+ }
1811
+ /** Derive the protector key from the payload's secret (sha256 over its UTF-8 string). */
1812
+ function deriveProtectorKey(secret) {
1813
+ return createHash("sha256").update(secret, "utf8").digest();
1814
+ }
1815
+ /**
1816
+ * The strategy the CN app may use on this platform.
1817
+ *
1818
+ * Both macOS and Windows have a verified WorkBuddy 5.6.2 layout; anything else
1819
+ * gets `none`, which reads as "not configured" rather than "we searched and
1820
+ * failed". Lives here — beside the strategies it chooses between — so the plugin
1821
+ * host and the CLI cannot drift apart on it, which they did once already.
1822
+ */
1823
+ function cnAppDiscovery() {
1824
+ if (process.platform === "darwin") return "macos-workbuddy";
1825
+ if (process.platform === "win32") return "windows-workbuddy";
1826
+ return "none";
1827
+ }
1828
+ /** The CN app's bundle id; the only one this round resolves by discovery. */
1829
+ const WORKBUDDY_CN_BUNDLE_ID = "com.tencent.workbuddy.mac";
1830
+ /** Absolute tool paths: never resolved through PATH, which a user can change. */
1831
+ const MDFIND_BIN = "/usr/bin/mdfind";
1832
+ const PLUTIL_BIN = "/usr/bin/plutil";
1833
+ /** One discovery subprocess's own limits; see {@link WorkBuddyAtRestKeyProviderOptions}. */
1834
+ const WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS = 3e3;
1835
+ const MDFIND_MAX_OUTPUT_BYTES = 1048576;
1836
+ const PLUTIL_MAX_OUTPUT_BYTES = 65536;
1837
+ /**
1838
+ * Why a discovery step could not produce an answer. Every one of these means
1839
+ * "we do not know", explicitly *not* "the candidate does not exist" — the
1840
+ * distinction is what keeps a half-finished check from being mistaken for a
1841
+ * unique candidate.
1842
+ */
1843
+ var DiscoveryIncompleteError = class extends Error {};
1844
+ /** `reg.exe` by absolute path: never resolved through `PATH`, which a user can change. */
1845
+ const REG_BIN = `${process.env["SystemRoot"] ?? "C:\\Windows"}\\System32\\reg.exe`;
1846
+ /** Registry paths searched for a registered WorkBuddy, in order. */
1847
+ const WINDOWS_UNINSTALL_HIVES = [
1848
+ "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
1849
+ "HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
1850
+ "HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
1851
+ ];
1852
+ /** Three hives' worth of matching entries is far smaller than this. */
1853
+ const WINDOWS_REGISTRY_MAX_OUTPUT_BYTES = 262144;
1854
+ /**
1855
+ * Install directories named by one `reg query` dump, in listing order.
1856
+ *
1857
+ * `reg query <hive> /s /f WorkBuddy` prints one block per matching key: a
1858
+ * `HKEY_…` header, then indented `Name REG_SZ value` lines. The identity
1859
+ * proof is `DisplayName` starting with `WorkBuddy` — the search also matches the
1860
+ * product name inside *other* values, so a block that merely mentions it must not
1861
+ * count. The location comes from `DisplayIcon`, because that is the field that
1862
+ * carries the path: the 5.6.2 installer here wrote the app to a drive root and
1863
+ * left `InstallLocation` empty.
1864
+ *
1865
+ * Only the fields read here are mentioned: the same `/f` filter that finds these
1866
+ * blocks also drops every value that does not contain "WorkBuddy", so the dump
1867
+ * is a partial view of the key by construction and must not be read as one.
1868
+ *
1869
+ * Exported for tests: the flow's whole decision surface is here, so it can be
1870
+ * exercised without running `reg.exe`.
1871
+ */
1872
+ function windowsInstallsFromRegistry(output) {
1873
+ const roots = [];
1874
+ let name;
1875
+ let icon;
1876
+ let location;
1877
+ const flush = () => {
1878
+ if (name !== void 0 && /^WorkBuddy\b/u.test(name)) {
1879
+ const root = iconRootOf(icon) ?? locationRootOf(location);
1880
+ if (root !== void 0 && !roots.includes(root)) roots.push(root);
1881
+ }
1882
+ name = void 0;
1883
+ icon = void 0;
1884
+ location = void 0;
1885
+ };
1886
+ for (const line of output.split(/\r?\n/u)) {
1887
+ if (line.startsWith("HKEY_")) {
1888
+ flush();
1889
+ continue;
1890
+ }
1891
+ const field = /^\s+(\S+)\s+REG_[A-Z_]+\s+(.*)$/u.exec(line);
1892
+ if (field === null) continue;
1893
+ const key = field[1] ?? "";
1894
+ const value = (field[2] ?? "").trim();
1895
+ if (key === "DisplayName") name = value;
1896
+ else if (key === "DisplayIcon") icon = value;
1897
+ else if (key === "InstallLocation") location = value;
1898
+ }
1899
+ flush();
1900
+ return roots;
1901
+ }
1902
+ /**
1903
+ * Strip what the registry wraps a path value in: a surrounding pair of quotes and
1904
+ * a trailing `,<icon index>`. Neither is part of the path.
1905
+ */
1906
+ function unwrapRegistryPath(value) {
1907
+ if (value === void 0) return void 0;
1908
+ const path = value.trim().replace(/^"(.*)"(?:,-?\d+)?$/u, "$1").replace(/,-?\d+$/u, "");
1909
+ return path === "" ? void 0 : path;
1910
+ }
1911
+ /**
1912
+ * The install directory `DisplayIcon` points at, or `undefined` when it names
1913
+ * something other than the app's executable.
1914
+ *
1915
+ * It is rejected unless it names a `.exe`: installers often point this value at an
1916
+ * icon file, and a `.ico` is not a directory that could hold the runtime. Rejecting
1917
+ * it is what lets {@link locationRootOf} answer instead.
1918
+ *
1919
+ * The split is done on Windows separators explicitly: the value comes out of the
1920
+ * Windows registry, so its shape must not depend on the host that happens to parse
1921
+ * it — a Linux CI runner reads the same dump and must reach the same directory.
1922
+ */
1923
+ function iconRootOf(value) {
1924
+ const path = unwrapRegistryPath(value);
1925
+ if (path === void 0 || !/\.exe$/iu.test(path)) return void 0;
1926
+ return path.replace(/[\\/][^\\/]*$/u, "");
1927
+ }
1928
+ /**
1929
+ * The directory `InstallLocation` names, or `undefined` when it names nothing.
1930
+ *
1931
+ * It is a directory by definition, so it is taken as-is — this installer leaves it
1932
+ * empty, which is exactly the "no answer here" case.
1933
+ */
1934
+ function locationRootOf(value) {
1935
+ const path = unwrapRegistryPath(value);
1936
+ if (path === void 0) return void 0;
1937
+ return /\.exe$/iu.test(path) ? path.replace(/[\\/][^\\/]*$/u, "") : path;
1938
+ }
1939
+ /**
1940
+ * The default Windows discovery tools: `reg.exe` from its absolute path, so a
1941
+ * user's `PATH` cannot redirect what the plugin executes.
1942
+ *
1943
+ * A hive that cannot be read at all — a missing `reg.exe`, a denied key — raises
1944
+ * {@link DiscoveryIncompleteError}, because "we could not check" must never be
1945
+ * read as "not installed". A hive that simply holds no WorkBuddy entry is a
1946
+ * normal empty result: `reg` reports that with exit code 1 and empty stdout.
1947
+ */
1948
+ function workBuddyWindowsDiscoveryTools() {
1949
+ return { findInstallRoots: async (signal) => {
1950
+ const found = [];
1951
+ for (const hive of WINDOWS_UNINSTALL_HIVES) {
1952
+ if (signal.aborted) throw new DiscoveryIncompleteError("the Windows registry search was not started: the discovery budget was already spent");
1953
+ const output = await new Promise((resolve, reject) => {
1954
+ execFile(REG_BIN, [
1955
+ "query",
1956
+ hive,
1957
+ "/s",
1958
+ "/f",
1959
+ "WorkBuddy"
1960
+ ], {
1961
+ maxBuffer: WINDOWS_REGISTRY_MAX_OUTPUT_BYTES,
1962
+ timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS,
1963
+ windowsHide: true
1964
+ }, (error, stdout) => {
1965
+ if (error === null || error === void 0) {
1966
+ resolve(stdout);
1967
+ return;
1968
+ }
1969
+ if (error.killed !== true && typeof error.code !== "string") {
1970
+ resolve(stdout);
1971
+ return;
1972
+ }
1973
+ reject(new DiscoveryIncompleteError(`the Windows registry query for ${hive} could not complete (${error.killed === true ? "timed out" : String(error.code)})`));
1974
+ });
1975
+ });
1976
+ found.push(...windowsInstallsFromRegistry(output));
1977
+ }
1978
+ return found;
1979
+ } };
1980
+ }
1981
+ /**
1982
+ * The default discovery tools: Spotlight for the bundle, `/usr/bin/plutil` for
1983
+ * identity. Every failure that means "we could not tell" — a missing tool, a
1984
+ * timeout, an oversized answer — is raised as {@link DiscoveryIncompleteError}
1985
+ * so it can never be silently read as "no such app".
1986
+ */
1987
+ function workBuddyDiscoveryTools() {
1988
+ const runTool = (bin, args, maxBytes, signal) => new Promise((resolve, reject) => {
1989
+ if (signal.aborted) {
1990
+ reject(new DiscoveryIncompleteError(`${bin} was not started: the discovery budget was already spent`));
1991
+ return;
1992
+ }
1993
+ let settled = false;
1994
+ const child = execFile(bin, [...args], {
1995
+ maxBuffer: maxBytes,
1996
+ timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS
1997
+ }, (error, stdout) => {
1998
+ if (settled) return;
1999
+ settled = true;
2000
+ if (error !== null && error !== void 0) {
2001
+ reject(new DiscoveryIncompleteError(`${bin} could not complete (${error.killed === true ? "timed out" : String(error.code ?? "unavailable")})`));
2002
+ return;
2003
+ }
2004
+ resolve(stdout);
2005
+ });
2006
+ const abort = () => {
2007
+ if (settled) return;
2008
+ settled = true;
2009
+ child.kill();
2010
+ reject(new DiscoveryIncompleteError(`${bin} was abandoned: the discovery budget was spent`));
2011
+ };
2012
+ signal.addEventListener("abort", abort, { once: true });
2013
+ child.on("close", () => {
2014
+ signal.removeEventListener("abort", abort);
2015
+ });
2016
+ });
2017
+ return {
2018
+ findApps: async (signal) => {
2019
+ return (await runTool(MDFIND_BIN, [`kMDItemCFBundleIdentifier == '${WORKBUDDY_CN_BUNDLE_ID}'`], MDFIND_MAX_OUTPUT_BYTES, signal)).split("\n").map((line) => line.trim()).filter((line) => line.endsWith(".app"));
2020
+ },
2021
+ bundleIdentifier: async (bundlePath, signal) => {
2022
+ try {
2023
+ return (await runTool(PLUTIL_BIN, [
2024
+ "-extract",
2025
+ "CFBundleIdentifier",
2026
+ "raw",
2027
+ "-o",
2028
+ "-",
2029
+ join(bundlePath, "Contents", "Info.plist")
2030
+ ], PLUTIL_MAX_OUTPUT_BYTES, signal)).trim();
2031
+ } catch {
2032
+ return;
2033
+ }
2034
+ },
2035
+ bundleVersion: async (bundlePath, signal) => {
2036
+ try {
2037
+ const version = (await runTool(PLUTIL_BIN, [
2038
+ "-extract",
2039
+ "CFBundleShortVersionString",
2040
+ "raw",
2041
+ "-o",
2042
+ "-",
2043
+ join(bundlePath, "Contents", "Info.plist")
2044
+ ], PLUTIL_MAX_OUTPUT_BYTES, signal)).trim();
2045
+ return version === "" ? void 0 : version;
2046
+ } catch {
2047
+ return;
2048
+ }
2049
+ }
2050
+ };
2051
+ }
2052
+ /**
2053
+ * In-memory protector-key resolver: one spawn per key id, single-flight, never
2054
+ * persisted. The cache is keyed by the id envelopes ask for, so an envelope
2055
+ * sealed under a rotated key triggers exactly one fresh resolution.
2056
+ */ var WorkBuddyAtRestKeyProvider = class {
2057
+ /**
2058
+ * The explicit binary, when one was configured. `undefined` here means "the
2059
+ * caller did not name one", which is what lets discovery run — an explicit
2060
+ * path that turns out to be unusable is an error, never a reason to look for
2061
+ * a different app.
2062
+ */
2063
+ explicitPath;
2064
+ defaultPath;
2065
+ discovery;
2066
+ tools;
2067
+ /** Explicit `tools` means the caller owns the platform question (tests, custom hosts). */
2068
+ toolsAreInjected;
2069
+ windowsTools;
2070
+ /** Explicit `windowsTools` means the caller owns the platform question. */
2071
+ windowsToolsAreInjected;
2072
+ discoveryBudgetMs;
2073
+ timeoutMs;
2074
+ source;
2075
+ spawnHelper;
2076
+ /**
2077
+ * The path discovery settled on, cached only on success. A failure leaves
2078
+ * this unset so the next attempt tries again — the user may install or move
2079
+ * the app without restarting DSH.
2080
+ */
2081
+ discoveredPath;
2082
+ cache;
2083
+ inflight;
2084
+ constructor(options = {}) {
2085
+ const fromEnv = process.env[WORKBUDDY_ELECTRON_BIN_ENV]?.trim();
2086
+ const envPath = fromEnv === void 0 || fromEnv === "" ? void 0 : fromEnv;
2087
+ this.explicitPath = options.electronPath ?? envPath;
2088
+ this.discovery = options.discovery ?? "none";
2089
+ this.defaultPath = options.defaultElectronPath === void 0 ? defaultWorkBuddyElectronPath(this.discovery) : options.defaultElectronPath ?? void 0;
2090
+ this.tools = options.tools ?? workBuddyDiscoveryTools();
2091
+ this.toolsAreInjected = options.tools !== void 0;
2092
+ this.windowsTools = options.windowsTools ?? workBuddyWindowsDiscoveryTools();
2093
+ this.windowsToolsAreInjected = options.windowsTools !== void 0;
2094
+ this.discoveryBudgetMs = options.discoveryBudgetMs ?? 1e4;
2095
+ this.timeoutMs = options.timeoutMs ?? 1e4;
2096
+ this.spawnHelper = options.spawnHelper ?? ((path) => this.spawnAt(path));
2097
+ this.source = options.source ?? (() => this.spawnPayload());
2098
+ }
2099
+ /**
2100
+ * The binary the default helper would use, for diagnostics.
2101
+ *
2102
+ * Reports a *discovery result* once one exists, so diagnostics describe what
2103
+ * would actually run rather than the default that was bypassed. Discovery
2104
+ * itself stays in {@link resolveElectronPath}: this accessor never triggers a
2105
+ * search (the constructor must remain I/O-free, and callers may ask before
2106
+ * any resolution has happened).
2107
+ */
2108
+ helperPath() {
2109
+ if (this.explicitPath !== void 0) return this.explicitPath;
2110
+ if (this.discovery === "none") return void 0;
2111
+ return this.discoveredPath ?? this.defaultPath;
2112
+ }
2113
+ /**
2114
+ * A protector key matching one of the requested envelope key ids. The first
2115
+ * id the cache answers wins; otherwise one spawn resolves the current key,
2116
+ * which must match a request — a mismatch means the envelopes were sealed by
2117
+ * a different install than the one this machine now runs, and no key we can
2118
+ * reach will open them.
2119
+ */
2120
+ async protectorKeyFor(requested) {
2121
+ if (requested.length === 0) throw new WorkBuddyElectronPathError("encrypted-credential-unreadable", "encrypted desktop credential carries no key ids");
2122
+ const cached = this.cache;
2123
+ if (cached !== void 0 && requested.includes(cached.keyId)) return cached.key;
2124
+ this.inflight ??= this.source().then((text) => this.ingest(text)).finally(() => {
2125
+ this.inflight = void 0;
2126
+ });
2127
+ const resolved = await this.inflight;
2128
+ if (!requested.includes(resolved.keyId)) throw new WorkBuddyElectronPathError("encrypted-credential-unreadable", `WorkBuddy's current at-rest key (id ${resolved.keyId}) does not match the credential's envelope (id ${requested.join(" or ")}); the desktop credential was sealed by a different WorkBuddy installation`);
2129
+ return resolved.key;
2130
+ }
2131
+ ingest(text) {
2132
+ const payload = parseAtRestPayload(text);
2133
+ if (payload === void 0) throw new WorkBuddyElectronPathError("encrypted-credential-unreadable", "WorkBuddy key helper returned an unusable at-rest payload (expected {version:1, atRestSecretKey})");
2134
+ const key = deriveProtectorKey(payload.atRestSecretKey);
2135
+ const resolved = {
2136
+ key,
2137
+ keyId: createHash("sha256").update(key).digest("hex").slice(0, 16)
2138
+ };
2139
+ this.cache = resolved;
2140
+ return resolved;
2141
+ }
2142
+ /**
2143
+ * The binary to spawn, or a diagnosable error saying why there is none.
2144
+ *
2145
+ * Order is the contract: an explicit path is used as-is and never falls back;
2146
+ * discovery runs only for a provider that was configured for it, and only
2147
+ * after the platform default has been tried and found unusable.
2148
+ */
2149
+ async resolveElectronPath() {
2150
+ if (this.explicitPath !== void 0) {
2151
+ if (!isExecutable(this.explicitPath)) throw new WorkBuddyElectronPathError("electron-path-invalid", `the configured WorkBuddy Electron binary is not available at ${this.explicitPath}; check ${WORKBUDDY_ELECTRON_BIN_ENV} or unset it to let the plugin look for the app itself`);
2152
+ return this.explicitPath;
2153
+ }
2154
+ if (this.discovery === "none") throw new WorkBuddyElectronPathError("electron-binary-unavailable", `no WorkBuddy Electron binary is configured for this platform; set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`);
2155
+ if (this.defaultPath !== void 0 && isExecutable(this.defaultPath)) return this.defaultPath;
2156
+ if (this.discoveredPath !== void 0) {
2157
+ if (isExecutable(this.discoveredPath)) return this.discoveredPath;
2158
+ this.discoveredPath = void 0;
2159
+ }
2160
+ const found = this.discovery === "windows-workbuddy" ? await this.discoverWindowsApp() : await this.discoverMacosApp();
2161
+ this.discoveredPath = found;
2162
+ return found;
2163
+ }
2164
+ /**
2165
+ * Resolve the CN app through Spotlight, then prove each candidate's identity
2166
+ * before it can be executed.
2167
+ *
2168
+ * The whole flow shares one budget: a hang in one candidate must not extend
2169
+ * the wait for the others, and running out of budget is reported as an
2170
+ * unfinished check rather than an absent app.
2171
+ */
2172
+ async discoverMacosApp() {
2173
+ if (process.platform !== "darwin" && !this.toolsAreInjected) throw new WorkBuddyElectronPathError("electron-binary-unavailable", `no WorkBuddy Electron binary is configured for this platform; set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`);
2174
+ const controller = new AbortController();
2175
+ const timer = setTimeout(() => controller.abort(), this.discoveryBudgetMs);
2176
+ try {
2177
+ let candidates;
2178
+ try {
2179
+ candidates = await this.tools.findApps(controller.signal);
2180
+ } catch {
2181
+ throw discoveryIncomplete("the app search did not complete");
2182
+ }
2183
+ const seen = /* @__PURE__ */ new Map();
2184
+ let unresolved = false;
2185
+ for (const candidate of candidates) {
2186
+ try {
2187
+ statSync(candidate);
2188
+ } catch (error) {
2189
+ if (isENOENT$1(error)) continue;
2190
+ unresolved = true;
2191
+ continue;
2192
+ }
2193
+ let bundleIdentifier;
2194
+ try {
2195
+ bundleIdentifier = await this.tools.bundleIdentifier(candidate, controller.signal);
2196
+ } catch {
2197
+ unresolved = true;
2198
+ continue;
2199
+ }
2200
+ if (bundleIdentifier === void 0) {
2201
+ unresolved = true;
2202
+ continue;
2203
+ }
2204
+ if (bundleIdentifier !== "com.tencent.workbuddy.mac") continue;
2205
+ const electronPath = join(candidate, "Contents", "MacOS", "Electron");
2206
+ if (!isExecutable(electronPath)) continue;
2207
+ let identity;
2208
+ try {
2209
+ identity = realpathSync(candidate);
2210
+ } catch {
2211
+ identity = candidate;
2212
+ }
2213
+ if (seen.has(identity)) continue;
2214
+ let version;
2215
+ try {
2216
+ version = await this.tools.bundleVersion(candidate, controller.signal);
2217
+ } catch {
2218
+ version = void 0;
2219
+ }
2220
+ seen.set(identity, {
2221
+ bundlePath: candidate,
2222
+ electronPath,
2223
+ ...version === void 0 ? {} : { version }
2224
+ });
2225
+ }
2226
+ if (seen.size > 1) throw new WorkBuddyElectronPathError("electron-binary-ambiguous", `more than one WorkBuddy application was found, so none was chosen:\n${[...seen.values()].map((app) => ` - ${app.bundlePath}${app.version === void 0 ? "" : ` (${app.version})`}`).join("\n")}\n set ${WORKBUDDY_ELECTRON_BIN_ENV} to the one to use`);
2227
+ if (unresolved) throw discoveryIncomplete("some candidates could not be checked");
2228
+ if (seen.size === 0) throw new WorkBuddyElectronPathError("electron-binary-not-found", "no WorkBuddy application was found in the default location or the system index; if WorkBuddy is installed elsewhere, it may not be indexed yet");
2229
+ return [...seen.values()][0].electronPath;
2230
+ } finally {
2231
+ clearTimeout(timer);
2232
+ controller.abort();
2233
+ }
2234
+ }
2235
+ /**
2236
+ * Resolve the CN app from its Windows registration.
2237
+ *
2238
+ * There is no second identity check as on macOS: the registration *is* the
2239
+ * identity — {@link windowsInstallsFromRegistry} only accepts blocks whose
2240
+ * `DisplayName` starts with `WorkBuddy` — and the executable is a fixed name
2241
+ * inside the registered directory. A registered directory that no longer holds
2242
+ * an executable is a decidable exclusion, the same way a Spotlight row for a
2243
+ * deleted app is.
2244
+ */
2245
+ async discoverWindowsApp() {
2246
+ if (process.platform !== "win32" && !this.windowsToolsAreInjected) throw new WorkBuddyElectronPathError("electron-binary-unavailable", `no WorkBuddy Electron binary is configured for this platform; set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`);
2247
+ const controller = new AbortController();
2248
+ const timer = setTimeout(() => controller.abort(), this.discoveryBudgetMs);
2249
+ try {
2250
+ let roots;
2251
+ try {
2252
+ roots = await this.windowsTools.findInstallRoots(controller.signal);
2253
+ } catch {
2254
+ throw discoveryIncomplete("the Windows registry search did not complete");
2255
+ }
2256
+ const seen = /* @__PURE__ */ new Map();
2257
+ for (const root of roots) {
2258
+ const electronPath = join(root, WINDOWS_ELECTRON_FILENAME);
2259
+ if (!isExecutable(electronPath)) continue;
2260
+ let identity;
2261
+ try {
2262
+ identity = realpathSync(electronPath);
2263
+ } catch {
2264
+ identity = electronPath;
2265
+ }
2266
+ seen.set(identity, electronPath);
2267
+ }
2268
+ if (seen.size > 1) throw new WorkBuddyElectronPathError("electron-binary-ambiguous", `more than one WorkBuddy application was found, so none was chosen:\n${[...seen.values()].map((path) => ` - ${path}`).join("\n")}\n set ${WORKBUDDY_ELECTRON_BIN_ENV} to the one to use`);
2269
+ if (seen.size === 0) throw new WorkBuddyElectronPathError("electron-binary-not-found", `no WorkBuddy application was found in the default location or its Windows registration; if WorkBuddy is installed elsewhere, set ${WORKBUDDY_ELECTRON_BIN_ENV} to the app's Electron binary`);
2270
+ return [...seen.values()][0];
2271
+ } finally {
2272
+ clearTimeout(timer);
2273
+ controller.abort();
2274
+ }
2275
+ }
2276
+ async spawnPayload() {
2277
+ return await this.spawnHelper(await this.resolveElectronPath());
2278
+ }
2279
+ async spawnAt(electronPath) {
2280
+ return await new Promise((resolve, reject) => {
2281
+ execFile(electronPath, [HELPER_SCRIPT_ARGUMENT_FLAG, HELPER_SCRIPT], {
2282
+ timeout: this.timeoutMs,
2283
+ maxBuffer: 1048576,
2284
+ windowsHide: true,
2285
+ env: {
2286
+ ...process.env,
2287
+ ELECTRON_RUN_AS_NODE: "1"
2288
+ }
2289
+ }, (error, stdout) => {
2290
+ if (error !== null && error !== void 0) {
2291
+ reject(new WorkBuddyElectronPathError("encrypted-credential-unreadable", `the WorkBuddy key helper (${electronPath}) ${error.killed === true ? `timed out or was killed after ${String(this.timeoutMs)}ms` : error.code !== void 0 ? `exited with code ${String(error.code)}` : "could not be started"}`));
2292
+ return;
2293
+ }
2294
+ const output = stdout.trim();
2295
+ if (output === "") {
2296
+ reject(new WorkBuddyElectronPathError("encrypted-credential-unreadable", `the WorkBuddy key helper (${electronPath}) produced no payload`));
2297
+ return;
2298
+ }
2299
+ resolve(output);
2300
+ });
2301
+ });
2302
+ }
2303
+ };
2304
+ /** Whether a path exists and is executable; never throws. */
2305
+ function isExecutable(path) {
2306
+ try {
2307
+ accessSync(path, constants.X_OK);
2308
+ return true;
2309
+ } catch {
2310
+ return false;
2311
+ }
2312
+ }
2313
+ /**
2314
+ * A failure the card must be able to classify. The code travels with the error
2315
+ * so the store can promote it to `reasonCode` without re-deriving the cause
2316
+ * from prose.
2317
+ */
2318
+ var WorkBuddyElectronPathError = class extends Error {
2319
+ reasonCode;
2320
+ constructor(reasonCode, message) {
2321
+ super(message);
2322
+ this.name = "WorkBuddyElectronPathError";
2323
+ this.reasonCode = reasonCode;
2324
+ }
2325
+ };
2326
+ /** Read the reason code off an arbitrary thrown value, when it carries one. */
2327
+ function reasonCodeOf(error) {
2328
+ return error instanceof WorkBuddyElectronPathError ? error.reasonCode : void 0;
2329
+ }
2330
+ /** Whether a filesystem error reports an absent path (`existsSync` cannot tell). */
2331
+ function isENOENT$1(error) {
2332
+ return error?.code === "ENOENT";
2333
+ }
2334
+ function discoveryIncomplete(detail) {
2335
+ return new WorkBuddyElectronPathError("electron-discovery-incomplete", `the WorkBuddy application search did not finish (${detail}); this is not proof that the app is missing`);
2336
+ }
2337
+ /**
2338
+ * The helper: run inside WorkBuddy's Electron as plain Node, where the
2339
+ * private `workbuddyStorage` binding exists, and print only the payload. It
2340
+ * writes nothing else, so whatever reaches stdout is the payload.
2341
+ */
2342
+ const HELPER_SCRIPT = "process.stdout.write(String(process._linkedBinding(\"electron_browser_workbuddy_storage\").loggerGet()))";
2343
+ const HELPER_SCRIPT_ARGUMENT_FLAG = "-e";
2344
+ //#endregion
2345
+ //#region src/credential/store.ts
2346
+ /**
2347
+ * WorkBuddy credential resolution. The primary source is the WorkBuddy
2348
+ * desktop app's own auth file, read-only; a plugin-owned copy under
2349
+ * `$DSH_HOME` holds token refreshes so the desktop file is never written.
2350
+ * The effective credential is whichever of the two expires later, so a
2351
+ * refresh by either side wins.
2352
+ *
2353
+ * @module dsh-workbuddy-bridge/auth
2354
+ */
2355
+ /** Basename of the plugin-owned credential copy inside the Harness home. */
2356
+ const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
2357
+ /** Env variable that overrides the desktop auth-file location. */
2358
+ const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
2359
+ /** Current on-disk format of the plugin-owned copy; readers reject others. */
2360
+ const OWN_FORMAT_VERSION = 1;
2361
+ /** Plugin-owned copy path inside the Harness home. */
2362
+ function workbuddyOwnAuthPath() {
2363
+ return join(resolveDshHome(), WORKBUDDY_AUTH_FILENAME);
2364
+ }
2365
+ const DESKTOP_AUTH_RELATIVE_PATH = [
2366
+ "CodeBuddyExtension",
2367
+ "Data",
2368
+ "Public",
2369
+ "auth",
2370
+ "workbuddy-desktop.info"
2371
+ ];
2372
+ /** Whether this Linux process is running inside Windows Subsystem for Linux. */
2373
+ function isWsl() {
2374
+ if (process.platform !== "linux") return false;
2375
+ if (process.env["WSL_DISTRO_NAME"] !== void 0 || process.env["WSL_INTEROP"] !== void 0) return true;
2376
+ return release().toLowerCase().includes("microsoft");
2377
+ }
2378
+ /** Convert a Windows drive path to WSL's conventional `/mnt/<drive>` form. */
2379
+ function windowsPathForWsl(value) {
2380
+ const path = value?.trim();
2381
+ if (!path) return void 0;
2382
+ if (path.startsWith("/")) return path;
2383
+ const drivePath = /^([a-z]):[\\/](.*)$/iu.exec(path);
2384
+ if (drivePath === null) return void 0;
2385
+ return join("/mnt", drivePath[1].toLowerCase(), ...drivePath[2].split(/[\\/]+/u));
2386
+ }
2387
+ /** Windows desktop credential candidates visible from a WSL process. */
2388
+ function wslDesktopAuthCandidates(home) {
2389
+ const profile = windowsPathForWsl(process.env["USERPROFILE"]) ?? join("/mnt/c/Users", basename(home));
2390
+ const localAppData = windowsPathForWsl(process.env["LOCALAPPDATA"]) ?? join(profile, "AppData", "Local");
2391
+ const roamingAppData = windowsPathForWsl(process.env["APPDATA"]) ?? join(profile, "AppData", "Roaming");
2392
+ return [join(localAppData, ...DESKTOP_AUTH_RELATIVE_PATH), join(roamingAppData, ...DESKTOP_AUTH_RELATIVE_PATH)];
2393
+ }
2394
+ /**
2395
+ * Platform-default candidates for the WorkBuddy desktop app's auth file, in
2396
+ * probe order. Windows probes both AppData roots: current builds write under
2397
+ * `%LOCALAPPDATA%` (Local), older ones under `%APPDATA%` (Roaming). Linux
2398
+ * probes both XDG bases — most distributions write under the config home,
2399
+ * but UOS/deepin builds write under the data home (issue #43), and probing
2400
+ * only one silently reads a signed-in app as signed out. WSL probes those
2401
+ * same Windows locations through its mounted Windows profile before the
2402
+ * native Linux locations.
2403
+ */
2404
+ function defaultDesktopAuthCandidates() {
2405
+ const home = homedir();
2406
+ if (process.platform === "darwin") return [join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info")];
2407
+ if (process.platform === "win32") return [join(home, "AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info"), join(home, "AppData", "Roaming", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info")];
2408
+ if (process.platform === "linux") {
2409
+ const configHome = xdgBase("XDG_CONFIG_HOME", join(home, ".config"));
2410
+ const dataHome = xdgBase("XDG_DATA_HOME", join(home, ".local", "share"));
2411
+ const linux = dedupeCandidates([join(configHome, ...DESKTOP_AUTH_RELATIVE_PATH), join(dataHome, ...DESKTOP_AUTH_RELATIVE_PATH)]);
2412
+ return isWsl() ? dedupeCandidates([...wslDesktopAuthCandidates(home), ...linux]) : linux;
2413
+ }
2414
+ return [];
2415
+ }
2416
+ /** The XDG base directory for one env variable, or its platform default. */
2417
+ function xdgBase(envName, fallback) {
2418
+ const value = process.env[envName]?.trim();
2419
+ if (value !== void 0 && value !== "" && value.startsWith("/")) return value;
2420
+ return fallback;
2421
+ }
2422
+ /** Drop duplicate candidates while keeping probe order. */
2423
+ function dedupeCandidates(candidates) {
2424
+ return [...new Set(candidates)];
2425
+ }
2426
+ /**
2427
+ * The platform-default candidates for one variant, in probe order.
2428
+ *
2429
+ * Both apps write into the *same* shared `CodeBuddyExtension` auth directory
2430
+ * and differ only in the file's basename, so the per-platform ordering above
2431
+ * is reused verbatim and just the filename is swapped.
2432
+ */
2433
+ function desktopAuthCandidatesFor(variant) {
2434
+ return dedupeCandidates(defaultDesktopAuthCandidates().map((path) => join(dirname(path), variant.desktopFilename)));
2435
+ }
2436
+ /** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
2437
+ function defaultDesktopAuthPath(variant) {
2438
+ return (variant === void 0 ? defaultDesktopAuthCandidates() : desktopAuthCandidatesFor(variant))[0];
2439
+ }
2440
+ /** Normalize an expiry that may arrive in seconds or milliseconds. */
2441
+ function expiryToMs(value) {
2442
+ if (value <= 0) return 0;
2443
+ return value > 0xe8d4a51000 ? value : value * 1e3;
2444
+ }
2445
+ function optionalString(value) {
2446
+ return typeof value === "string" && value !== "" ? value : void 0;
2447
+ }
2448
+ /**
2449
+ * Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
2450
+ * nested form `{"auth":{...},"account":{...}}` and the flat panel form.
2451
+ * Returns undefined when the document carries no access token.
2452
+ */
2453
+ function parseWorkBuddyAuth(text) {
2454
+ let parsed;
2455
+ try {
2456
+ parsed = JSON.parse(text);
2457
+ } catch {
2458
+ return;
2459
+ }
2460
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
2461
+ const document = parsed;
2462
+ let auth;
2463
+ let identity;
2464
+ if (typeof document["auth"] === "object" && document["auth"] !== null) {
2465
+ auth = document["auth"];
2466
+ identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : {};
2467
+ } else {
2468
+ auth = document;
2469
+ identity = document;
2470
+ }
2471
+ const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
2472
+ if (accessToken === "") return void 0;
2473
+ const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
2474
+ const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
2475
+ const enterpriseId = optionalString(identity["enterpriseId"]);
2476
+ const nickname = optionalString(identity["nickname"]);
2477
+ return {
2478
+ accessToken,
2479
+ refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
2480
+ expiresAtMs,
2481
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
2482
+ domain: optionalString(auth["domain"]) ?? "",
2483
+ uid: optionalString(identity["uid"]) ?? "",
2484
+ ...enterpriseId === void 0 ? {} : { enterpriseId },
2485
+ ...nickname === void 0 ? {} : { nickname },
2486
+ source: "desktop"
2487
+ };
2488
+ }
2489
+ /** Serialize the plugin-owned copy. */
2490
+ function ownDocument(credential) {
2491
+ return {
2492
+ version: OWN_FORMAT_VERSION,
2493
+ credential
2494
+ };
2495
+ }
2496
+ /** Parse the plugin-owned copy; other versions and shapes are rejected. */
2497
+ function parseOwnDocument(text) {
2498
+ let parsed;
2499
+ try {
2500
+ parsed = JSON.parse(text);
2501
+ } catch {
2502
+ return;
2503
+ }
2504
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
2505
+ const document = parsed;
2506
+ if (document["version"] !== OWN_FORMAT_VERSION) return void 0;
2507
+ if (typeof document["credential"] !== "object" || document["credential"] === null) return void 0;
2508
+ const stored = document["credential"];
2509
+ const accessToken = typeof stored["accessToken"] === "string" ? stored["accessToken"] : "";
2510
+ if (accessToken === "") return void 0;
2511
+ const refreshExpiresAtMs = typeof stored["refreshExpiresAtMs"] === "number" ? stored["refreshExpiresAtMs"] : void 0;
2512
+ const enterpriseId = optionalString(stored["enterpriseId"]);
2513
+ const nickname = optionalString(stored["nickname"]);
2514
+ return {
2515
+ accessToken,
2516
+ refreshToken: typeof stored["refreshToken"] === "string" ? stored["refreshToken"] : "",
2517
+ expiresAtMs: typeof stored["expiresAtMs"] === "number" ? stored["expiresAtMs"] : 0,
2518
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
2519
+ domain: optionalString(stored["domain"]) ?? "",
2520
+ uid: optionalString(stored["uid"]) ?? "",
2521
+ ...enterpriseId === void 0 ? {} : { enterpriseId },
2522
+ ...nickname === void 0 ? {} : { nickname },
2523
+ source: "dsh"
2524
+ };
2525
+ }
2526
+ /** Whether a filesystem error reports an absent path. */
2527
+ function isENOENT(error) {
2528
+ return error?.code === "ENOENT";
2529
+ }
2530
+ /**
2531
+ * Read-only credential store with demand-driven refresh.
2532
+ *
2533
+ * Refresh policy: refresh only when the access token is inside the margin
2534
+ * (or already expired), keep the refreshed credential in the plugin-owned
2535
+ * copy, and never write the desktop app's file. A failed refresh still
2536
+ * returns a not-yet-expired token so an unreachable refresh endpoint does
2537
+ * not take down a working session.
2538
+ */
2539
+ var WorkBuddyCredentialStore = class {
2540
+ variant;
2541
+ refresh;
2542
+ refreshMarginMs;
2543
+ ownPath;
2544
+ keyProvider;
2545
+ desktopPathOverride;
2546
+ inflight;
2547
+ constructor(options) {
2548
+ this.variant = options.variant;
2549
+ this.refresh = options.refresh;
2550
+ this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
2551
+ this.ownPath = options.ownPath ?? (options.variant ? join(resolveDshHome(), options.variant.ownFilename) : workbuddyOwnAuthPath());
2552
+ this.keyProvider = options.keyProvider ?? new WorkBuddyAtRestKeyProvider();
2553
+ this.desktopPathOverride = options.desktopPath;
2554
+ }
2555
+ /**
2556
+ * Configuration precedence for the desktop file: the plugin's configured
2557
+ * path, then the environment variable, then the platform defaults. An
2558
+ * explicit path is used verbatim; the defaults are a probe order.
2559
+ */
2560
+ resolveDesktopCandidates() {
2561
+ const fromEnv = process.env[this.variant?.env ?? "WORKBUDDY_AUTH_FILE"];
2562
+ const explicit = this.desktopPathOverride ?? (fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0);
2563
+ if (explicit !== void 0) return [explicit];
2564
+ return this.variant === void 0 ? defaultDesktopAuthCandidates() : desktopAuthCandidatesFor(this.variant);
2565
+ }
2566
+ resolveDesktopPath() {
2567
+ return this.resolveDesktopCandidates()[0];
2568
+ }
2569
+ /**
2570
+ * Repoint the desktop file; a settings change applies on the next read.
2571
+ */
2572
+ setDesktopPath(path) {
2573
+ this.desktopPathOverride = path;
2574
+ }
2575
+ /** The resolved desktop auth-file path, for diagnostics. */
2576
+ desktopAuthPath() {
2577
+ return this.resolveDesktopPath();
2578
+ }
2579
+ /** The plugin-owned copy path, for diagnostics. */
2580
+ ownAuthPath() {
2581
+ return this.ownPath;
2582
+ }
2583
+ /** Read the freshest stored credential without refreshing anything. */
2584
+ async current() {
2585
+ const [desktop, own] = await Promise.all([this.readDesktop(), this.readOwn()]);
2586
+ if (this.variant !== void 0) for (const [label, credential] of [["desktop file", desktop], ["plugin copy", own]]) {
2587
+ if (credential === void 0) continue;
2588
+ const region = regionOf(credential.domain);
2589
+ if (region !== this.variant.region) throw new WorkBuddyElectronPathError("credential-region-mismatch", `${this.variant.displayName} received a ${region === "cn" ? "WorkBuddy (CN)" : "WorkBuddy AI"} credential in its ${label} (domain ${JSON.stringify(credential.domain)}); point ${this.variant.env} at the ${this.variant.appName} sign-in, or remove the mismatched file`);
2590
+ }
2591
+ if (desktop === void 0) return own;
2592
+ if (own === void 0) return desktop;
2593
+ if (desktop.uid !== own.uid || desktop.enterpriseId !== own.enterpriseId) return desktop;
2594
+ return own.expiresAtMs > desktop.expiresAtMs ? own : desktop;
2595
+ }
2596
+ /**
2597
+ * The credential to send upstream: {@link current}, refreshed on demand.
2598
+ * Single-flight, so parallel requests share one refresh.
2599
+ */
2600
+ async resolve() {
2601
+ const credential = await this.current();
2602
+ if (credential === void 0) {
2603
+ const candidates = this.resolveDesktopCandidates();
2604
+ const desktop = candidates.length > 0 ? candidates.join(" or ") : "(no desktop path on this platform)";
2605
+ const app = this.variant?.appName ?? "WorkBuddy";
2606
+ throw new Error(`workbuddy: no signed-in ${app} account found; sign in once in the ${app} desktop app (expected ${desktop} or ${this.variant?.env ?? "WORKBUDDY_AUTH_FILE"}), or refresh an existing session`);
2607
+ }
2608
+ if (!this.needsRefresh(credential)) return credential;
2609
+ this.inflight ??= this.refreshNow(credential).finally(() => {
2610
+ this.inflight = void 0;
2611
+ });
2612
+ return this.inflight;
2613
+ }
2614
+ /** Read-only sign-in summary; never refreshes and never throws. */
2615
+ async status() {
2616
+ try {
2617
+ const credential = await this.current();
2618
+ if (credential === void 0) return {
2619
+ state: "signed-out",
2620
+ reasonCode: "no-credential"
2621
+ };
2622
+ return {
2623
+ state: "signed-in",
2624
+ expiresAtMs: credential.expiresAtMs,
2625
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
2626
+ ...credential.nickname === void 0 ? {} : { nickname: credential.nickname },
2627
+ ...credential.domain === "" ? {} : { domain: credential.domain },
2628
+ source: credential.source
2629
+ };
2630
+ } catch (error) {
2631
+ return {
2632
+ state: "signed-out",
2633
+ reason: error instanceof Error ? error.message : String(error),
2634
+ ...reasonCodeOf(error) === void 0 ? {} : { reasonCode: reasonCodeOf(error) }
2635
+ };
2636
+ }
2637
+ }
2638
+ /** Remove the plugin-owned copy; the desktop file is untouched. */
2639
+ async logout() {
2640
+ await rm(this.ownPath, { force: true });
2641
+ await rm(`${this.ownPath}.lock`, { force: true });
2642
+ }
2643
+ needsRefresh(credential) {
2644
+ if (credential.expiresAtMs <= 0) return true;
2645
+ return Date.now() + this.refreshMarginMs >= credential.expiresAtMs;
2646
+ }
2647
+ async refreshNow(credential) {
2648
+ if (credential.refreshToken === "") {
2649
+ if (credential.expiresAtMs > Date.now() + 3e4) return credential;
2650
+ throw new Error("workbuddy: access token expired and no refresh token is stored; sign in again in the WorkBuddy desktop app");
2651
+ }
2652
+ try {
2653
+ const outcome = await this.refresh(credential);
2654
+ const refreshed = {
2655
+ ...credential,
2656
+ accessToken: outcome.accessToken,
2657
+ ...outcome.refreshToken === void 0 ? {} : { refreshToken: outcome.refreshToken },
2658
+ expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
2659
+ ...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
2660
+ source: "dsh"
2661
+ };
2662
+ await this.saveOwn(refreshed);
2663
+ return refreshed;
2664
+ } catch (error) {
2665
+ if (credential.expiresAtMs > Date.now() + 3e4) return credential;
2666
+ throw new Error(`workbuddy: token refresh failed and the access token is expired (${String(error)}); open the WorkBuddy desktop app once to sign in again`);
2667
+ }
2668
+ }
2669
+ async saveOwn(credential) {
2670
+ await withFileLock(this.ownPath, async () => {
2671
+ await writeFileAtomic(this.ownPath, `${JSON.stringify(ownDocument(credential), null, 2)}\n`, {
2672
+ mode: 384,
2673
+ dirMode: 448
2674
+ });
2675
+ });
2676
+ }
2677
+ /**
2678
+ * Read the first desktop candidate that exists. Only an absent file
2679
+ * (ENOENT) falls through to the next candidate; a file that is present
2680
+ * but unparsable is authoritative for its slot, so a stale older-version
2681
+ * file never silently wins over a broken newer one.
2682
+ *
2683
+ * Since WorkBuddy 5.6 the token fields may arrive in at-rest envelopes, so
2684
+ * the text is classified before the regular parser sees it. An encrypted
2685
+ * document must be *opened*, never skipped; an unrecognized one must fail
2686
+ * loudly. The desktop file, as long as it exists, is the identity
2687
+ * authority — a document this plugin cannot read must surface as a
2688
+ * diagnosis rather than be papered over by the plugin-owned copy, which
2689
+ * belongs to whatever account was signed in when it was last refreshed.
2690
+ * Only an absent (or empty) file lets the probe continue.
2691
+ */
2692
+ async readDesktop() {
2693
+ for (const desktopPath of this.resolveDesktopCandidates()) {
2694
+ let text;
2695
+ try {
2696
+ text = await readFile(desktopPath, "utf8");
2697
+ } catch (error) {
2698
+ if (!isENOENT(error)) throw error;
2699
+ continue;
2700
+ }
2701
+ const classification = classifyDesktopAuthDocument(text);
2702
+ if (classification.format === "plaintext") return parseWorkBuddyAuth(text);
2703
+ if (classification.format === "absent") continue;
2704
+ if (classification.format === "unrecognized") throw new Error(`the desktop auth file at ${desktopPath} exists but is unreadable (neither a plaintext credential nor a decodable WorkBuddy 5.6 envelope); fix or remove the file — it outranks the plugin-owned credential copy`);
2705
+ return await this.openEncryptedDesktop(classification);
2706
+ }
2707
+ }
2708
+ /** Open a 5.6 encrypted desktop document into the regular credential shape. */
2709
+ async openEncryptedDesktop(classification) {
2710
+ const wrapped = classification.wrapped;
2711
+ const key = await this.keyProvider.protectorKeyFor(keyIdsOf(wrapped.fields));
2712
+ return parseWorkBuddyAuth(unwrapDesktopAuthDocument(classification, (field) => {
2713
+ const plaintext = openAuthField(key, field.envelope);
2714
+ if (plaintext === void 0) throw new WorkBuddyElectronPathError("encrypted-credential-unreadable", `the encrypted desktop credential's ${field.field} could not be decrypted (envelope key id ${field.envelope.keyId}); the WorkBuddy app may hold a different at-rest key — open it once to reseal the sign-in`);
2715
+ return plaintext;
2716
+ }));
2717
+ }
2718
+ /**
2719
+ * Classify the first desktop candidate that exists and carries content;
2720
+ * `absent` when none does. An empty first file is skipped so it cannot mask
2721
+ * a real document on the next candidate. Diagnostics only — it never spawns
2722
+ * the key helper and never decrypts, so doctor can describe the file
2723
+ * without attempting the unlock.
2724
+ */
2725
+ async desktopAuthFormat() {
2726
+ for (const desktopPath of this.resolveDesktopCandidates()) {
2727
+ let text;
2728
+ try {
2729
+ text = await readFile(desktopPath, "utf8");
2730
+ } catch (error) {
2731
+ if (!isENOENT(error)) throw error;
2732
+ continue;
2733
+ }
2734
+ const format = classifyDesktopAuthDocument(text).format;
2735
+ if (format !== "absent") return format;
2736
+ }
2737
+ return "absent";
2738
+ }
2739
+ async readOwn() {
2740
+ try {
2741
+ return parseOwnDocument(await readFile(this.ownPath, "utf8"));
2742
+ } catch (error) {
2743
+ if (isENOENT(error)) return void 0;
2744
+ return;
2745
+ }
2746
+ }
2747
+ /**
2748
+ * The first desktop candidate the probe would actually read from; `undefined`
2749
+ * when none qualifies. Semantics deliberately match the probe: empty files
2750
+ * are skipped (the probe classifies them as absent and moves on), so on an
2751
+ * XDG layout where the config-home file is empty but the data-home file
2752
+ * holds the credential, diagnostics name the *data-home* file — the one
2753
+ * authentication really uses. Like the probe it never parses or decrypts.
2754
+ */
2755
+ async resolvedDesktopAuthPath() {
2756
+ for (const desktopPath of this.resolveDesktopCandidates()) {
2757
+ let text;
2758
+ try {
2759
+ text = await readFile(desktopPath, "utf8");
2760
+ } catch (error) {
2761
+ if (!isENOENT(error)) throw error;
2762
+ continue;
2763
+ }
2764
+ if (text.trim() === "") continue;
2765
+ return desktopPath;
2766
+ }
2767
+ }
2768
+ /** Whether any desktop-file candidate exists as a regular file; diagnostics only. */
2769
+ async desktopFilePresent() {
2770
+ return await this.resolvedDesktopAuthPath() !== void 0;
2771
+ }
2772
+ };
2773
+ //#endregion
2774
+ //#region src/catalog/index.ts
2775
+ /**
2776
+ * WorkBuddy model catalog: a static fallback list captured from the live
2777
+ * endpoint, replaced by the upstream's dynamic answer once it loads.
2778
+ *
2779
+ * @module dsh-workbuddy-bridge/catalog
2780
+ */
2781
+ /**
2782
+ * Static CLI models observed on the CN endpoint (re-verified against the live
2783
+ * `/v3/config` document 2026-09-23, including the thinking-effort and billing
2784
+ * metadata). The upstream refresh replaces this list at startup; it exists so
2785
+ * the provider registers with a usable catalog even while the first fetch is
2786
+ * in flight or offline.
2787
+ *
2788
+ * The list tracks the `cli` agent's model roster exactly — the 16 models it
2789
+ * offered that day. The roster churns quickly (`auto`, `kimi-k3-1`,
2790
+ * `minimax-m3` each appeared or vanished within days, and a competing patch's
2791
+ * 2026-09-22 snapshot named three ids that were gone a day later), so this
2792
+ * table is a boot-time placeholder, never a promise: the live fetch
2793
+ * intersects the day's roster with the document's usable rows, and
2794
+ * `tests/upstream.spec.ts` pins this table to the same parse so the two
2795
+ * cannot drift apart silently. Reasoning metadata is verbatim from the live
2796
+ * document, and the `free` flag follows the normalized `x0.00` credits
2797
+ * marker.
2798
+ *
2799
+ * Deliberately NOT baked in: promotional badges. `限时免费` and friends are
2800
+ * dynamic console-side promotions with no reliable validity window, so a
2801
+ * static table would keep them alive long after the offers end. The live
2802
+ * refresh merges the day's badges best-effort from the console document (see
2803
+ * `fetchPromoBadges` in upstream.ts); until then the rows simply ship
2804
+ * without them.
2805
+ */
2806
+ const FALLBACK_WORKBUDDY_MODELS = [
2807
+ {
2808
+ id: "hy4-preview",
2809
+ name: "Hy4 preview",
2810
+ contextWindow: 1e6,
2811
+ maxTokens: 64e3,
2812
+ supportsImages: true,
2813
+ reasoning: {
2814
+ supports: true,
2815
+ onlyReasoning: true,
2816
+ supportedEfforts: ["high"],
2817
+ defaultEffort: "high",
2818
+ canDisableThinking: false
2819
+ },
2820
+ billing: {
2821
+ credits: "x0.29 credits",
2822
+ free: false
2823
+ }
2824
+ },
2825
+ {
2826
+ id: "hy3",
2827
+ name: "Hy3",
2828
+ contextWindow: 192e3,
2829
+ maxTokens: 64e3,
2830
+ supportsImages: true,
2831
+ reasoning: {
2832
+ supports: true,
2833
+ onlyReasoning: true,
2834
+ defaultEffort: "high",
2835
+ canDisableThinking: false
2836
+ },
2837
+ billing: {
2838
+ credits: "x0.00 credits",
2839
+ free: true
2840
+ }
2841
+ },
2842
+ {
2843
+ id: "hy3-x",
2844
+ name: "Hy3-X",
2845
+ contextWindow: 192e3,
2846
+ maxTokens: 64e3,
2847
+ supportsImages: true,
2848
+ reasoning: {
2849
+ supports: true,
2850
+ onlyReasoning: true,
2851
+ defaultEffort: "high",
2852
+ canDisableThinking: false
2853
+ },
2854
+ billing: {
2855
+ credits: "x0.05 credits",
2856
+ free: false
2857
+ }
2858
+ },
2859
+ {
2860
+ id: "deepseek-v4.1-flash",
2861
+ name: "Deepseek-V4.1-Flash",
2862
+ contextWindow: 1e6,
2863
+ maxTokens: 128e3,
2864
+ supportsImages: true,
2865
+ reasoning: {
2866
+ supports: true,
2867
+ onlyReasoning: true,
2868
+ defaultEffort: "high",
2869
+ canDisableThinking: false
2870
+ },
2871
+ billing: {
2872
+ credits: "x0.03 credits",
2873
+ free: false
2874
+ }
2875
+ },
2876
+ {
2877
+ id: "glm-5.3",
2878
+ name: "GLM-5.3",
2879
+ contextWindow: 1e6,
2880
+ maxTokens: 64e3,
2881
+ supportsImages: true,
2882
+ reasoning: {
2883
+ supports: true,
2884
+ onlyReasoning: true,
2885
+ defaultEffort: "medium",
2886
+ canDisableThinking: false
2887
+ },
2888
+ billing: {
2889
+ credits: "x0.79 credits",
2890
+ free: false
2891
+ }
2892
+ },
2893
+ {
2894
+ id: "glm-5.3-flash",
2895
+ name: "GLM-5.3-Flash",
2896
+ contextWindow: 1e6,
2897
+ maxTokens: 131072,
2898
+ supportsImages: true,
2899
+ reasoning: {
2900
+ supports: true,
2901
+ onlyReasoning: true,
2902
+ supportedEfforts: [
2903
+ "low",
2904
+ "high",
2905
+ "max"
2906
+ ],
2907
+ defaultEffort: "high",
2908
+ canDisableThinking: true
2909
+ },
2910
+ billing: {
2911
+ credits: "x0.06 credits",
2912
+ free: false
2913
+ }
2914
+ },
2915
+ {
2916
+ id: "glm-5.2",
2917
+ name: "GLM-5.2",
2918
+ contextWindow: 1e6,
2919
+ maxTokens: 64e3,
2920
+ supportsImages: true,
2921
+ reasoning: {
2922
+ supports: true,
2923
+ onlyReasoning: true,
2924
+ defaultEffort: "medium",
2925
+ canDisableThinking: false
2926
+ },
2927
+ billing: {
2928
+ credits: "x0.79 credits",
2929
+ free: false
2930
+ }
2931
+ },
2932
+ {
2933
+ id: "glm-5.1",
2934
+ name: "GLM-5.1",
2935
+ contextWindow: 2e5,
2936
+ maxTokens: 48e3,
2937
+ supportsImages: true,
2938
+ reasoning: {
2939
+ supports: true,
2940
+ onlyReasoning: true,
2941
+ defaultEffort: "medium",
2942
+ canDisableThinking: false
2943
+ },
2944
+ billing: {
2945
+ credits: "x0.79 credits",
2946
+ free: false
2947
+ }
2948
+ },
2949
+ {
2950
+ id: "glm-5v-turbo",
2951
+ name: "GLM-5v-Turbo",
2952
+ contextWindow: 2e5,
2953
+ maxTokens: 64e3,
2954
+ supportsImages: true,
2955
+ reasoning: {
2956
+ supports: true,
2957
+ onlyReasoning: true,
2958
+ defaultEffort: "medium",
2959
+ canDisableThinking: false
2960
+ },
2961
+ billing: {
2962
+ credits: "x0.71 credits",
2963
+ free: false
2964
+ }
2965
+ },
2966
+ {
2967
+ id: "minimax-m3",
2968
+ name: "MiniMax-M3",
2969
+ contextWindow: 512e3,
2970
+ maxTokens: 64e3,
2971
+ supportsImages: true,
2972
+ reasoning: {
2973
+ supports: true,
2974
+ onlyReasoning: true,
2975
+ defaultEffort: "medium",
2976
+ canDisableThinking: false
2977
+ },
2978
+ billing: {
2979
+ credits: "x0.25 credits",
2980
+ free: false
2981
+ }
2982
+ },
2983
+ {
2984
+ id: "minimax-m2.7",
2985
+ name: "MiniMax-M2.7",
2986
+ contextWindow: 2e5,
2987
+ maxTokens: 48e3,
2988
+ supportsImages: true,
2989
+ reasoning: {
2990
+ supports: true,
2991
+ onlyReasoning: true,
2992
+ defaultEffort: "medium",
2993
+ canDisableThinking: false
2994
+ },
2995
+ billing: {
2996
+ credits: "x0.19 credits",
2997
+ free: false
2998
+ }
2999
+ },
3000
+ {
3001
+ id: "kimi-k3-1",
3002
+ name: "Kimi-K3",
3003
+ contextWindow: 1e6,
3004
+ maxTokens: 32e3,
3005
+ supportsImages: true,
3006
+ reasoning: {
3007
+ supports: true,
3008
+ onlyReasoning: true,
3009
+ defaultEffort: "medium",
3010
+ canDisableThinking: false
3011
+ },
3012
+ billing: {
3013
+ credits: "x1.62 credits",
3014
+ free: false
3015
+ }
3016
+ },
3017
+ {
3018
+ id: "kimi-k2.8-preview",
3019
+ name: "Kimi-K2.8-Preview",
3020
+ contextWindow: 1e6,
3021
+ maxTokens: 64e3,
3022
+ supportsImages: true,
3023
+ reasoning: {
3024
+ supports: true,
3025
+ onlyReasoning: true,
3026
+ supportedEfforts: [
3027
+ "low",
3028
+ "high",
3029
+ "max"
3030
+ ],
3031
+ defaultEffort: "high",
3032
+ canDisableThinking: true
3033
+ },
3034
+ billing: {
3035
+ credits: "x0.77 credits",
3036
+ free: false
3037
+ }
3038
+ },
3039
+ {
3040
+ id: "kimi-k2.7",
3041
+ name: "Kimi-K2.7-Code",
3042
+ contextWindow: 256e3,
3043
+ maxTokens: 32e3,
3044
+ supportsImages: true,
3045
+ reasoning: {
3046
+ supports: true,
3047
+ onlyReasoning: true,
3048
+ defaultEffort: "medium",
3049
+ canDisableThinking: false
3050
+ },
3051
+ billing: {
3052
+ credits: "x0.57 credits",
3053
+ free: false
3054
+ }
3055
+ },
3056
+ {
3057
+ id: "kimi-k2.6",
3058
+ name: "Kimi-K2.6",
3059
+ contextWindow: 256e3,
3060
+ maxTokens: 32e3,
3061
+ supportsImages: true,
3062
+ reasoning: {
3063
+ supports: true,
3064
+ onlyReasoning: true,
3065
+ defaultEffort: "medium",
3066
+ canDisableThinking: false
3067
+ },
3068
+ billing: {
3069
+ credits: "x0.52 credits",
3070
+ free: false
3071
+ }
3072
+ },
3073
+ {
3074
+ id: "deepseek-v4-pro",
3075
+ name: "Deepseek-V4-Pro",
3076
+ contextWindow: 1e6,
3077
+ maxTokens: 128e3,
3078
+ supportsImages: true,
3079
+ reasoning: {
3080
+ supports: true,
3081
+ onlyReasoning: true,
3082
+ defaultEffort: "high",
3083
+ canDisableThinking: false
3084
+ },
3085
+ billing: {
3086
+ credits: "x0.51 credits",
3087
+ free: false
3088
+ }
3089
+ }
3090
+ ];
3091
+ /**
3092
+ * Static CLI models for the international endpoint, captured 2026-09-11 from
3093
+ * the App-form `/v3/config` document (the 20 ids of its `cli` agent, in order).
3094
+ *
3095
+ * Same purpose and same discipline as {@link FALLBACK_WORKBUDDY_MODELS}: it
3096
+ * covers the window before the first successful fetch and an offline start,
3097
+ * and it is deliberately *not* a promise about the upstream's current state.
3098
+ * Reasoning metadata is verbatim from that snapshot. No promo badge is baked
3099
+ * in: promotions are time-boxed (`modelPromotions` carries `validFrom`/
3100
+ * `validUntil`), so hard-coding a "Free now" label would keep claiming a
3101
+ * discount the upstream may have already ended.
3102
+ */
3103
+ const FALLBACK_WORKBUDDY_AI_MODELS = [
3104
+ {
3105
+ id: "default-model",
3106
+ name: "Auto",
3107
+ contextWindow: 176e3,
3108
+ maxTokens: 24e3,
3109
+ supportsImages: true,
3110
+ reasoning: {
3111
+ supports: false,
3112
+ onlyReasoning: false,
3113
+ canDisableThinking: true
3114
+ },
3115
+ billing: { free: false }
3116
+ },
3117
+ {
3118
+ id: "fast-model",
3119
+ name: "Fast",
3120
+ contextWindow: 2e5,
3121
+ maxTokens: 32e3,
3122
+ supportsImages: true,
3123
+ reasoning: {
3124
+ supports: true,
3125
+ onlyReasoning: true,
3126
+ defaultEffort: "medium",
3127
+ canDisableThinking: false
3128
+ },
3129
+ billing: {
3130
+ credits: "x0.34",
3131
+ free: false
3132
+ }
3133
+ },
3134
+ {
3135
+ id: "balanced-model",
3136
+ name: "Balanced",
3137
+ contextWindow: 256e3,
3138
+ maxTokens: 32e3,
3139
+ supportsImages: true,
3140
+ reasoning: {
3141
+ supports: true,
3142
+ onlyReasoning: true,
3143
+ defaultEffort: "medium",
3144
+ canDisableThinking: false
3145
+ },
3146
+ billing: {
3147
+ credits: "x0.59",
3148
+ free: false
3149
+ }
3150
+ },
3151
+ {
3152
+ id: "primary-model",
3153
+ name: "Primary",
3154
+ contextWindow: 272e3,
3155
+ maxTokens: 72e3,
3156
+ supportsImages: true,
3157
+ reasoning: {
3158
+ supports: true,
3159
+ onlyReasoning: true,
3160
+ defaultEffort: "high",
3161
+ canDisableThinking: false
3162
+ },
3163
+ billing: {
3164
+ credits: "x3.31",
3165
+ free: false
3166
+ }
3167
+ },
3168
+ {
3169
+ id: "deep-model",
3170
+ name: "Deep",
3171
+ contextWindow: 176e3,
3172
+ maxTokens: 24e3,
3173
+ supportsImages: true,
3174
+ reasoning: {
3175
+ supports: false,
3176
+ onlyReasoning: false,
3177
+ canDisableThinking: true
3178
+ },
3179
+ billing: {
3180
+ credits: "x3.33",
3181
+ free: false
3182
+ }
3183
+ },
3184
+ {
3185
+ id: "hy4-preview-f",
3186
+ name: "Hy4 preview",
3187
+ contextWindow: 3e5,
3188
+ defaultContextWindow: 3e5,
3189
+ supportedContextWindows: [3e5, 1e6],
3190
+ maxTokens: 64e3,
3191
+ supportsImages: true,
3192
+ reasoning: {
3193
+ supports: true,
3194
+ onlyReasoning: true,
3195
+ supportedEfforts: ["high"],
3196
+ defaultEffort: "high",
3197
+ canDisableThinking: false
3198
+ },
3199
+ billing: {
3200
+ free: false,
3201
+ rateUnknown: true
3202
+ }
3203
+ },
3204
+ {
3205
+ id: "hy3",
3206
+ name: "Hy3",
3207
+ contextWindow: 192e3,
3208
+ maxTokens: 64e3,
3209
+ supportsImages: true,
3210
+ reasoning: {
3211
+ supports: true,
3212
+ onlyReasoning: true,
3213
+ supportedEfforts: ["low", "high"],
3214
+ defaultEffort: "high",
3215
+ canDisableThinking: false
3216
+ },
3217
+ billing: {
3218
+ free: false,
3219
+ rateUnknown: true
3220
+ }
3221
+ },
3222
+ {
3223
+ id: "deepseek-v4.1-flash",
3224
+ name: "Deepseek-V4.1-Flash",
3225
+ contextWindow: 3e5,
3226
+ defaultContextWindow: 3e5,
3227
+ supportedContextWindows: [3e5, 1e6],
3228
+ maxTokens: 128e3,
3229
+ supportsImages: true,
3230
+ reasoning: {
3231
+ supports: true,
3232
+ onlyReasoning: true,
3233
+ defaultEffort: "high",
3234
+ canDisableThinking: false
3235
+ },
3236
+ billing: {
3237
+ free: false,
3238
+ rateUnknown: true
3239
+ }
3240
+ },
3241
+ {
3242
+ id: "gpt-6-astra",
3243
+ name: "GPT-6-Astra",
3244
+ contextWindow: 4e5,
3245
+ defaultContextWindow: 4e5,
3246
+ supportedContextWindows: [4e5, 1e6],
3247
+ maxTokens: 128e3,
3248
+ supportsImages: true,
3249
+ reasoning: {
3250
+ supports: true,
3251
+ onlyReasoning: true,
3252
+ supportedEfforts: [
3253
+ "low",
3254
+ "medium",
3255
+ "high",
3256
+ "xhigh",
3257
+ "max"
3258
+ ],
3259
+ defaultEffort: "medium",
3260
+ canDisableThinking: true
3261
+ },
3262
+ billing: {
3263
+ credits: "x6.67",
3264
+ free: false
3265
+ }
3266
+ },
3267
+ {
3268
+ id: "gpt-5.6-sol",
3269
+ name: "GPT-5.6-Sol",
3270
+ contextWindow: 1e6,
3271
+ maxTokens: 128e3,
3272
+ supportsImages: true,
3273
+ reasoning: {
3274
+ supports: true,
3275
+ onlyReasoning: true,
3276
+ supportedEfforts: [
3277
+ "low",
3278
+ "medium",
3279
+ "high",
3280
+ "xhigh",
3281
+ "max"
3282
+ ],
3283
+ defaultEffort: "medium",
3284
+ canDisableThinking: true
3285
+ },
3286
+ billing: {
3287
+ credits: "x3.47",
3288
+ free: false
3289
+ }
3290
+ },
3291
+ {
3292
+ id: "gpt-5.6-terra",
3293
+ name: "GPT-5.6-Terra",
3294
+ contextWindow: 1e6,
3295
+ maxTokens: 128e3,
3296
+ supportsImages: true,
3297
+ reasoning: {
3298
+ supports: true,
3299
+ onlyReasoning: true,
3300
+ supportedEfforts: [
3301
+ "low",
3302
+ "medium",
3303
+ "high",
3304
+ "xhigh",
3305
+ "max"
3306
+ ],
3307
+ defaultEffort: "medium",
3308
+ canDisableThinking: true
3309
+ },
3310
+ billing: {
3311
+ credits: "x1.39",
3312
+ free: false
3313
+ }
3314
+ },
3315
+ {
3316
+ id: "gpt-5.6-luna",
3317
+ name: "GPT-5.6-Luna",
3318
+ contextWindow: 1e6,
3319
+ maxTokens: 128e3,
3320
+ supportsImages: true,
3321
+ reasoning: {
3322
+ supports: true,
3323
+ onlyReasoning: true,
3324
+ supportedEfforts: [
3325
+ "low",
3326
+ "medium",
3327
+ "high",
3328
+ "xhigh",
3329
+ "max"
3330
+ ],
3331
+ defaultEffort: "medium",
3332
+ canDisableThinking: true
3333
+ },
3334
+ billing: {
3335
+ credits: "x0.14",
3336
+ free: false
3337
+ }
3338
+ },
3339
+ {
3340
+ id: "gpt-5.5",
3341
+ name: "GPT-5.5",
3342
+ contextWindow: 1e6,
3343
+ maxTokens: 128e3,
3344
+ supportsImages: true,
3345
+ reasoning: {
3346
+ supports: true,
3347
+ onlyReasoning: true,
3348
+ supportedEfforts: [
3349
+ "low",
3350
+ "medium",
3351
+ "high",
3352
+ "xhigh"
3353
+ ],
3354
+ defaultEffort: "medium",
3355
+ canDisableThinking: false
3356
+ },
3357
+ billing: {
3358
+ credits: "x3.31",
3359
+ free: false
3360
+ }
3361
+ },
3362
+ {
3363
+ id: "gpt-5.4",
3364
+ name: "GPT-5.4",
3365
+ contextWindow: 272e3,
3366
+ maxTokens: 72e3,
3367
+ supportsImages: true,
3368
+ reasoning: {
3369
+ supports: true,
3370
+ onlyReasoning: true,
3371
+ supportedEfforts: [
3372
+ "low",
3373
+ "medium",
3374
+ "high",
3375
+ "xhigh"
3376
+ ],
3377
+ defaultEffort: "medium",
3378
+ canDisableThinking: false
3379
+ },
3380
+ billing: {
3381
+ credits: "x1.65",
3382
+ free: false
3383
+ }
3384
+ },
3385
+ {
3386
+ id: "gpt-5.3-codex",
3387
+ name: "GPT-5.3-Codex",
3388
+ contextWindow: 272e3,
3389
+ maxTokens: 72e3,
3390
+ supportsImages: true,
3391
+ reasoning: {
3392
+ supports: true,
3393
+ onlyReasoning: true,
3394
+ defaultEffort: "medium",
3395
+ canDisableThinking: false
3396
+ },
3397
+ billing: {
3398
+ credits: "x1.25",
3399
+ free: false
3400
+ }
3401
+ },
3402
+ {
3403
+ id: "gemini-3.5-flash",
3404
+ name: "Gemini-3.5-Flash",
3405
+ contextWindow: 1e6,
3406
+ maxTokens: 65536,
3407
+ supportsImages: true,
3408
+ reasoning: {
3409
+ supports: true,
3410
+ onlyReasoning: true,
3411
+ defaultEffort: "medium",
3412
+ canDisableThinking: false
3413
+ },
3414
+ billing: {
3415
+ credits: "x0.99",
3416
+ free: false
3417
+ }
3418
+ },
3419
+ {
3420
+ id: "glm-5.3",
3421
+ name: "GLM-5.3",
3422
+ contextWindow: 1e6,
3423
+ maxTokens: 48e3,
3424
+ supportsImages: true,
3425
+ reasoning: {
3426
+ supports: true,
3427
+ onlyReasoning: true,
3428
+ supportedEfforts: [
3429
+ "low",
3430
+ "high",
3431
+ "max"
3432
+ ],
3433
+ defaultEffort: "high",
3434
+ canDisableThinking: true
3435
+ },
3436
+ billing: {
3437
+ credits: "x0.79",
3438
+ free: false
3439
+ }
3440
+ },
3441
+ {
3442
+ id: "glm-5.2",
3443
+ name: "GLM-5.2",
3444
+ contextWindow: 1e6,
3445
+ maxTokens: 48e3,
3446
+ supportsImages: true,
3447
+ reasoning: {
3448
+ supports: true,
3449
+ onlyReasoning: true,
3450
+ supportedEfforts: ["high", "xhigh"],
3451
+ defaultEffort: "high",
3452
+ canDisableThinking: true
3453
+ },
3454
+ billing: {
3455
+ credits: "x0.79",
3456
+ free: false
3457
+ }
3458
+ },
3459
+ {
3460
+ id: "kimi-k3",
3461
+ name: "Kimi-K3",
3462
+ contextWindow: 1e6,
3463
+ maxTokens: 32e3,
3464
+ supportsImages: true,
3465
+ reasoning: {
3466
+ supports: true,
3467
+ onlyReasoning: true,
3468
+ defaultEffort: "medium",
3469
+ canDisableThinking: false
3470
+ },
3471
+ billing: {
3472
+ credits: "x1.62",
3473
+ free: false
3474
+ }
3475
+ },
3476
+ {
3477
+ id: "kimi-k2.6",
3478
+ name: "Kimi-K2.6",
3479
+ contextWindow: 256e3,
3480
+ maxTokens: 32e3,
3481
+ supportsImages: true,
3482
+ reasoning: {
3483
+ supports: true,
3484
+ onlyReasoning: true,
3485
+ defaultEffort: "medium",
3486
+ canDisableThinking: false
3487
+ },
3488
+ billing: {
3489
+ credits: "x0.52",
3490
+ free: false
3491
+ }
3492
+ }
3493
+ ];
3494
+ /**
3495
+ * Mutable catalog shared by the shim's `/v1/models` and the adapter.
3496
+ *
3497
+ * Visibility is separate from content. A variant whose app has no credentials
3498
+ * must expose *no* models rather than a fallback roster: the DSH model picker
3499
+ * drops an empty group, so an empty catalog is exactly how a provider hides
3500
+ * without touching registration. Serving the fallback to a signed-out user
3501
+ * instead offers models that can only fail (`store.resolve()` throws on the
3502
+ * first message), which is worse than showing nothing.
3503
+ *
3504
+ * The flag defaults to visible so a directly-constructed catalog behaves as it
3505
+ * always has; the plugin runtime applies the credential gate.
3506
+ */
3507
+ var WorkBuddyCatalog = class {
3508
+ models;
3509
+ visible = true;
3510
+ useMaximumContextWindow = false;
3511
+ constructor(initial = FALLBACK_WORKBUDDY_MODELS) {
3512
+ this.models = initial;
3513
+ }
3514
+ /** Current entries; empty while the variant has no usable credential. */
3515
+ current() {
3516
+ if (!this.visible) return [];
3517
+ return this.models.map((model) => {
3518
+ const current = modelWithCurrentPromotion(model);
3519
+ const maximum = current.supportedContextWindows === void 0 ? void 0 : Math.max(...current.supportedContextWindows);
3520
+ return this.useMaximumContextWindow && maximum !== void 0 && maximum > current.contextWindow ? {
3521
+ ...current,
3522
+ defaultContextWindow: current.defaultContextWindow ?? current.contextWindow,
3523
+ contextWindow: maximum
3524
+ } : current;
3525
+ });
3526
+ }
3527
+ /** Replace the list; callers invalidate their adapter snapshot after this. */
3528
+ set(models) {
3529
+ this.models = [...models];
3530
+ }
3531
+ /** Whether this variant's models are exposed at all. */
3532
+ isVisible() {
3533
+ return this.visible;
3534
+ }
3535
+ /**
3536
+ * Show or hide the whole catalog. Returns whether the value changed, so the
3537
+ * caller can skip an invalidation that would re-render an identical list.
3538
+ */
3539
+ setVisible(visible) {
3540
+ if (this.visible === visible) return false;
3541
+ this.visible = visible;
3542
+ return true;
3543
+ }
3544
+ /** Select the largest declared international window where the upstream offers one. */
3545
+ setUseMaximumContextWindow(useMaximum) {
3546
+ if (this.useMaximumContextWindow === useMaximum) return false;
3547
+ this.useMaximumContextWindow = useMaximum;
3548
+ return true;
3549
+ }
3550
+ /** Models to fall back to when the upstream fetch fails; ignores visibility. */
3551
+ fallback() {
3552
+ return this.models;
3553
+ }
3554
+ };
3555
+ //#endregion
3556
+ //#region src/shared/paths.ts
3557
+ /** Node-free constants and types shared by the Host and browser halves. */
3558
+ /** Plugin-owned status endpoint consumed by its browser half. */
3559
+ const WORKBUDDY_STATUS_PATH = "/plugins/dsh-workbuddy-bridge/status";
3560
+ /**
3561
+ * Plugin-owned probe control endpoint.
3562
+ *
3563
+ * Separate from the status route because it accepts writes: the status route's
3564
+ * loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
3565
+ * not the same as authorizing a state-changing action. This route therefore
3566
+ * also requires the in-process key the browser half receives with the status
3567
+ * document.
3568
+ */
3569
+ const WORKBUDDY_PROBE_PATH = "/plugins/dsh-workbuddy-bridge/probe";
3570
+ /**
3571
+ * The international (WorkBuddy AI) variant's own pair of routes.
3572
+ *
3573
+ * Kept as separate constants rather than a computed suffix so both halves
3574
+ * reference literal strings: the browser bundle and the host bundle are built
3575
+ * independently, and a shared expression is one build-config drift away from
3576
+ * the desk asking a route the host never mounted.
3577
+ */
3578
+ const WORKBUDDY_AI_STATUS_PATH = "/plugins/dsh-workbuddy-bridge/ai/status";
3579
+ const WORKBUDDY_AI_PROBE_PATH = "/plugins/dsh-workbuddy-bridge/ai/probe";
3580
+ //#endregion
3581
+ //#region src/version.ts
3582
+ const WORKBUDDY_BRIDGE_VERSION = "0.1.0";
3583
+ //#endregion
3584
+ //#region src/web/heartbeat.ts
3585
+ /**
3586
+ * Host-side heartbeat: a small JSON file written under `$DSH_HOME` once the
3587
+ * `workbuddy` provider is registered. The status CLI reads it to report
3588
+ * whether the host bundle is alive, independent of the browser card.
3589
+ *
3590
+ * The browser (client) bundle cannot write files; its health is reported
3591
+ * only through `console.error` on failure (see `src/client/index.tsx`).
3592
+ * This asymmetry is intentional: the host is the load-bearing half, and
3593
+ * a missing heartbeat unambiguously means the host never started.
3594
+ *
3595
+ * @module dsh-workbuddy-bridge/host-heartbeat
3596
+ */
3597
+ /** Basename of the host heartbeat file inside the Harness home. */
3598
+ const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
3599
+ /** Current on-disk heartbeat format; readers reject others. */
3600
+ const HEARTBEAT_FORMAT_VERSION = 1;
3601
+ /** Absolute path of the host heartbeat file. */
3602
+ function workbuddyHostHeartbeatPath() {
3603
+ return join(resolveDshHome(), WORKBUDDY_HOST_HEARTBEAT_FILENAME);
3604
+ }
3605
+ /**
3606
+ * Write (or overwrite) the heartbeat after the host bundle registered the
3607
+ * provider. A failed write is non-fatal: the host is already running, and
3608
+ * the status CLI will simply report "heartbeat missing" rather than failing.
3609
+ */
3610
+ async function writeHostHeartbeat() {
3611
+ const document = {
3612
+ version: HEARTBEAT_FORMAT_VERSION,
3613
+ package: "dsh-workbuddy-bridge",
3614
+ pluginVersion: WORKBUDDY_BRIDGE_VERSION,
3615
+ registeredAt: Date.now(),
3616
+ pid: process.pid
3617
+ };
3618
+ try {
3619
+ await writeFile(workbuddyHostHeartbeatPath(), JSON.stringify(document), "utf8");
3620
+ } catch {}
3621
+ }
3622
+ /** Remove the heartbeat on plugin disposal so a stale file does not linger. */
3623
+ async function clearHostHeartbeat() {
3624
+ try {
3625
+ await rm(workbuddyHostHeartbeatPath(), { force: true });
3626
+ } catch {}
3627
+ }
3628
+ /** Read and validate the heartbeat; returns `undefined` when absent or malformed. */
3629
+ async function readHostHeartbeat() {
3630
+ let raw;
3631
+ try {
3632
+ raw = await readFile(workbuddyHostHeartbeatPath(), "utf8");
3633
+ } catch {
3634
+ return;
3635
+ }
3636
+ try {
3637
+ const parsed = JSON.parse(raw);
3638
+ if (parsed.version === HEARTBEAT_FORMAT_VERSION && parsed.package === "dsh-workbuddy-bridge" && typeof parsed.registeredAt === "number" && typeof parsed.pid === "number") return {
3639
+ version: HEARTBEAT_FORMAT_VERSION,
3640
+ package: "dsh-workbuddy-bridge",
3641
+ pluginVersion: typeof parsed.pluginVersion === "string" ? parsed.pluginVersion : "unknown",
3642
+ registeredAt: parsed.registeredAt,
3643
+ pid: parsed.pid
3644
+ };
3645
+ } catch {}
3646
+ }
3647
+ /**
3648
+ * Parse a WMI `CreationDate` (CIM_DATETIME) into epoch milliseconds; returns
3649
+ * `undefined` for anything that does not match the format, never throws.
3650
+ *
3651
+ * The CIM_DATETIME layout is `yyyymmddHHMMSS.mmmmmmsUUU`: local wall-clock
3652
+ * fields, a 6-digit microsecond fraction, and a **3-digit signed UTC offset
3653
+ * in minutes** (`+000`, `+480` for UTC+8, `-300` for UTC−5). The fields are
3654
+ * therefore *not* UTC — the offset must be subtracted to obtain the epoch:
3655
+ * `+480` means local time runs 480 minutes ahead of UTC, so
3656
+ * `20260923104314.239907+480` is `2026-09-23T02:43:14.000Z`. A 4-digit offset
3657
+ * is not part of the format and is rejected rather than partially matched.
3658
+ */
3659
+ function parseWmiCreationDate(value) {
3660
+ const m = value.trim().match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d+)([+-]\d{3})$/);
3661
+ if (m === null) return void 0;
3662
+ const [, y, mo, d, h, mi, s, , offset] = m;
3663
+ const [year, month, day, hour, minute, second] = [
3664
+ y,
3665
+ mo,
3666
+ d,
3667
+ h,
3668
+ mi,
3669
+ s
3670
+ ].map(Number);
3671
+ if (month < 1 || month > 12 || day < 1 || day > 31) return void 0;
3672
+ if (hour > 23 || minute > 59 || second > 59) return void 0;
3673
+ const epoch = Date.UTC(year, month - 1, day, hour, minute, second) - Number(offset) * 6e4;
3674
+ return Number.isFinite(epoch) ? epoch : void 0;
3675
+ }
3676
+ /**
3677
+ * Absolute start time (epoch ms) of the process holding `pid`, or `undefined`
3678
+ * when it cannot be determined (no such PID, platform lacks a readable source).
3679
+ *
3680
+ * - macOS / Linux: `ps -o lstart=` prints a local-time "EEE MMM DD HH:MM:SS YYYY";
3681
+ * `Date.parse` resolves it against the local clock, which matches how
3682
+ * `registeredAt` (a `Date.now()` absolute value) is expressed.
3683
+ * - Windows: `wmic` prints a CIM_DATETIME `CreationDate` — local fields plus a
3684
+ * signed minute offset (see {@link parseWmiCreationDate}); the epoch it
3685
+ * yields is comparable to `registeredAt`.
3686
+ *
3687
+ * Failures return `undefined` so callers can fall back to plain PID liveness
3688
+ * rather than mis-report a running host as dead.
3689
+ */
3690
+ function processStartTimeMs(pid) {
3691
+ try {
3692
+ if (process.platform === "win32") {
3693
+ const token = execFileSync("wmic", [
3694
+ "process",
3695
+ "where",
3696
+ `processid=${pid}`,
3697
+ "get",
3698
+ "CreationDate"
3699
+ ], {
3700
+ encoding: "utf8",
3701
+ windowsHide: true
3702
+ }).match(/(\d{14})\.(\d+)([+-]\d{3})(?=\s|$)/)?.[0];
3703
+ if (token === void 0) return void 0;
3704
+ return parseWmiCreationDate(token);
3705
+ }
3706
+ const out = execFileSync("ps", [
3707
+ "-o",
3708
+ "lstart=",
3709
+ "-p",
3710
+ String(pid)
3711
+ ], {
3712
+ encoding: "utf8",
3713
+ env: {
3714
+ ...process.env,
3715
+ LC_ALL: "C",
3716
+ LANG: "C"
3717
+ }
3718
+ }).trim();
3719
+ if (out === "") return void 0;
3720
+ const ms = Date.parse(out);
3721
+ return Number.isFinite(ms) ? ms : void 0;
3722
+ } catch {
3723
+ return;
3724
+ }
3725
+ }
3726
+ /**
3727
+ * Whether the heartbeat's PID is still alive *and* still the same process that
3728
+ * registered it. A stale heartbeat (host crashed without clearing the file)
3729
+ * is distinguished from a live host by two checks:
3730
+ *
3731
+ * 1. `process.kill(pid, 0)` — the PID exists (signal 0 tests existence).
3732
+ * 2. The process holding that PID started at or before `registeredAt`. A host
3733
+ * that registered the heartbeat must have been started before writing it,
3734
+ * so `start <= registeredAt`; a recycled PID belongs to an unrelated process
3735
+ * started after the host died, so `start > registeredAt` correctly reads dead.
3736
+ *
3737
+ * PID-only detection is not enough: after a crash the OS may hand the same PID
3738
+ * to an unrelated process, and the un-cleared stale heartbeat would otherwise
3739
+ * produce a false "Host running". When the process start time cannot be read
3740
+ * (e.g. unsupported platform) the check degrades to plain PID liveness.
3741
+ */
3742
+ function isHeartbeatProcessAlive(heartbeat) {
3743
+ try {
3744
+ process.kill(heartbeat.pid, 0);
3745
+ } catch {
3746
+ return false;
3747
+ }
3748
+ const startAtMs = processStartTimeMs(heartbeat.pid);
3749
+ if (startAtMs === void 0) return true;
3750
+ return startAtMs <= heartbeat.registeredAt;
3751
+ }
3752
+ //#endregion
3753
+ //#region src/variants.ts
3754
+ /**
3755
+ * The two WorkBuddy desktop apps this one plugin serves.
3756
+ *
3757
+ * Both products are the same client framework in different regions, and both
3758
+ * write their sign-in into the *same* shared `CodeBuddyExtension` auth
3759
+ * directory — they differ by file basename, base URL, catalog endpoint, and
3760
+ * display identity. Everything that varies between them is collected here as
3761
+ * one descriptor, so no module has to carry its own `if (international)`
3762
+ * branch and a third variant would be a data change rather than a refactor.
3763
+ *
3764
+ * This module is host-side (it names files and env vars). The browser half
3765
+ * takes the same ids and routes from the Node-free `status-paths.ts`, which
3766
+ * stays the single source shared by both halves.
3767
+ *
3768
+ * @module dsh-workbuddy-bridge/variants
3769
+ */
3770
+ /** CN WorkBuddy first: the existing provider keeps its id, paths, and copy. */
3771
+ const WORKBUDDY_VARIANTS = [{
3772
+ id: "workbuddy",
3773
+ displayName: "WorkBuddy",
3774
+ appName: "WorkBuddy",
3775
+ region: "cn",
3776
+ env: "WORKBUDDY_AUTH_FILE",
3777
+ desktopFilename: "workbuddy-desktop.info",
3778
+ ownFilename: ".workbuddy-auth.json",
3779
+ probeFilename: ".workbuddy-probe.json",
3780
+ catalogFilename: ".workbuddy-catalog.json",
3781
+ visibilityFilename: ".workbuddy-model-visibility.json",
3782
+ statusPath: WORKBUDDY_STATUS_PATH,
3783
+ probePath: WORKBUDDY_PROBE_PATH
3784
+ }, {
3785
+ id: "workbuddy-ai",
3786
+ displayName: "WorkBuddy AI",
3787
+ appName: "WorkBuddy AI",
3788
+ region: "global",
3789
+ env: "WORKBUDDY_AI_AUTH_FILE",
3790
+ desktopFilename: "workbuddy-desktop-ai.info",
3791
+ ownFilename: ".workbuddy-ai-auth.json",
3792
+ probeFilename: ".workbuddy-ai-probe.json",
3793
+ catalogFilename: ".workbuddy-ai-catalog.json",
3794
+ visibilityFilename: ".workbuddy-ai-model-visibility.json",
3795
+ statusPath: WORKBUDDY_AI_STATUS_PATH,
3796
+ probePath: WORKBUDDY_AI_PROBE_PATH
3797
+ }];
3798
+ /** The CN variant; the plugin's long-standing default and compatibility anchor. */
3799
+ const CN_VARIANT = WORKBUDDY_VARIANTS[0];
3800
+ /** The international variant. */
3801
+ const AI_VARIANT = WORKBUDDY_VARIANTS[1];
3802
+ /** Look up a variant by provider id. */
3803
+ function variantFor(id) {
3804
+ return WORKBUDDY_VARIANTS.find((variant) => variant.id === id);
3805
+ }
3806
+ //#endregion
3807
+ export { normalizeCredits as A, FALLBACK_CN_APP_VERSION as B, desktopAuthCandidatesFor as C, cnAppDiscovery as D, WorkBuddyAtRestKeyProvider as E, PROBE_EFFORT_CANDIDATES as F, validCliVersion as G, fallbackChatIdentity as H, probeModel as I, installedAppVersion as J, WORKBUDDY_APP_VERSION_FILENAME as K, randomSentinel as L, prepareChatBody as M, prepareInternationalChatBody as N, WorkBuddyUpstreamClient as O, regionOf as P, classifyUpstreamError as R, defaultDesktopAuthPath as S, workbuddyOwnAuthPath as T, readCliVersion as U, chatUserAgent as V, resolveChatIdentity as W, resolveAppVersion as X, readBundleVersion as Y, validAppVersion as Z, WorkBuddyCatalog as _, WORKBUDDY_HOST_HEARTBEAT_FILENAME as a, WorkBuddyCredentialStore as b, processStartTimeMs as c, writeHostHeartbeat as d, WORKBUDDY_BRIDGE_VERSION as f, FALLBACK_WORKBUDDY_MODELS as g, FALLBACK_WORKBUDDY_AI_MODELS as h, variantFor as i, parseModelCatalog as j, modelWithCurrentPromotion as k, readHostHeartbeat as l, WORKBUDDY_STATUS_PATH as m, CN_VARIANT as n, clearHostHeartbeat as o, WORKBUDDY_PROBE_PATH as p, appUserAgent as q, WORKBUDDY_VARIANTS as r, isHeartbeatProcessAlive as s, AI_VARIANT as t, workbuddyHostHeartbeatPath as u, WORKBUDDY_AUTH_FILENAME as v, parseWorkBuddyAuth as w, defaultDesktopAuthCandidates as x, WORKBUDDY_AUTH_FILE_ENV as y, CN_APP_VERSION_FILENAME as z };