dsh-workbuddy-connect 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -20,9 +20,9 @@ type WorkBuddySignedOutReasonCode =
20
20
  'credential-region-mismatch' |
21
21
  /** An encrypted credential exists but could not be opened (wrong key, GCM failure, helper crash). */
22
22
  'encrypted-credential-unreadable' |
23
- /** CN on a supported platform: discovery ran to completion and found no usable candidate. */
23
+ /** A product on a supported platform: discovery ran to completion and found no usable candidate. */
24
24
  'electron-binary-not-found' |
25
- /** CN on a supported platform: discovery found more than one distinct usable app. */
25
+ /** A product on a supported platform: discovery found more than one distinct usable app. */
26
26
  'electron-binary-ambiguous' |
27
27
  /** No auto-discovery for this product/platform and no explicit path configured. */
28
28
  'electron-binary-unavailable' |
@@ -619,6 +619,15 @@ interface WorkBuddyVariant {
619
619
  region: WorkBuddyRegion;
620
620
  /** Env var overriding the desktop auth-file location. */
621
621
  env: string;
622
+ /**
623
+ * How this product's Electron helper is identified and located.
624
+ *
625
+ * Optional for source compatibility: `WorkBuddyVariant` is a public type and
626
+ * existing callers construct their own descriptors without it. Resolution
627
+ * falls back to {@link electronProfileFor}, keyed by variant id — an
628
+ * unknown id stays on the CN profile, the store's other legacy default.
629
+ */
630
+ electron?: WorkBuddyElectronProduct;
622
631
  /** Basename of the desktop app's own auth file in the shared auth directory. */
623
632
  desktopFilename: string;
624
633
  /** Basename of the plugin-owned credential copy under `$DSH_HOME`. */
@@ -647,6 +656,39 @@ interface WorkBuddyVariant {
647
656
  /** Same-origin probe-control route consumed by this variant's card. */
648
657
  probePath: string;
649
658
  }
659
+ /**
660
+ * How one product's Electron key helper is identified on each platform, and
661
+ * which env var names an explicit binary for it (issues #59/#60).
662
+ *
663
+ * The profile is the *only* place product identity enters helper resolution —
664
+ * never the discovery setting, which only says whether a platform may be
665
+ * searched at all: two products on the same platform differ by bundle id /
666
+ * registry name / exe basename, so a discovery that matches one can never
667
+ * legitimately execute the other's binary.
668
+ */
669
+ interface WorkBuddyElectronProduct {
670
+ /** Product name for helper diagnostics and error copy, e.g. `WorkBuddy AI`. */
671
+ productName: string;
672
+ /** Env var naming an explicit Electron binary for this product alone. */
673
+ envVar: string;
674
+ /** macOS identity and default install layout, verified per product. */
675
+ macOS: {
676
+ bundleId: string;
677
+ defaultPath: string;
678
+ };
679
+ /**
680
+ * Windows identity from the uninstall registry and the exe it names.
681
+ * `defaultPathSegments` exists only where the default install location has
682
+ * been measured (CN); the international app has only been seen in
683
+ * user-chosen locations, so it stays registry-only — an unverified default
684
+ * is a guess, and guessing is how the wrong app gets executed.
685
+ */
686
+ windows: {
687
+ displayNamePattern: RegExp;
688
+ exeBasename: string;
689
+ defaultPathSegments?: readonly string[];
690
+ };
691
+ }
650
692
  /** CN WorkBuddy first: the existing provider keeps its id, paths, and copy. */
651
693
  declare const WORKBUDDY_VARIANTS: readonly WorkBuddyVariant[];
652
694
  /** The CN variant; the plugin's long-standing default and compatibility anchor. */
@@ -665,15 +707,16 @@ type WorkBuddyKeyPayloadSource = () => Promise<string>;
665
707
  * Which automatic discovery, if any, this provider may run when no explicit
666
708
  * binary is configured.
667
709
  *
668
- * `none` is the safe default: a provider that has not been told which product
669
- * it serves must not reach for another product's app. The CN line selects a
670
- * platform-specific discovery only where that platform's app layout has been
671
- * verified; Global remains `none`.
710
+ * The value says which *platform* may be searched, never which product: two
711
+ * products on the same platform are told apart by the product profile
712
+ * ({@link WorkBuddyElectronProduct} — bundle id, registry name, exe basename),
713
+ * so a search for one can never execute the other's binary. `none` remains the
714
+ * safe default for platforms without a verified layout (Linux today).
672
715
  */
673
716
  type WorkBuddyElectronDiscovery = 'none' | 'macos-workbuddy' | 'windows-workbuddy';
674
717
  /** Seams the discovery flow runs through, so tests never spawn a process. */
675
718
  interface WorkBuddyDiscoveryTools {
676
- /** Candidate `.app` bundles for the CN bundle id, or a throw for an unusable tool. */
719
+ /** Candidate `.app` bundles for the product's bundle id, or a throw for an unusable tool. */
677
720
  findApps: (signal: AbortSignal) => Promise<readonly string[]>;
678
721
  /**
679
722
  * `CFBundleIdentifier` of a bundle, or `undefined` when the tool could not
@@ -691,6 +734,15 @@ interface WorkBuddyWindowsDiscoveryTools {
691
734
  }
692
735
  /** Provider options. */
693
736
  interface WorkBuddyAtRestKeyProviderOptions {
737
+ /**
738
+ * Which product's Electron this provider resolves. Required and the only
739
+ * source of product identity: it names the explicit-path env var, the bundle
740
+ * id / registry name / exe basename discovery must match, and the platform
741
+ * default. Without it the provider could not even decide which env var to
742
+ * read — the discovery setting alone cannot carry this (both products are
743
+ * `none` on Linux, yet each must read its own variable).
744
+ */
745
+ product: WorkBuddyElectronProduct;
694
746
  /** Explicit Electron binary; overrides the platform default and env. */
695
747
  electronPath?: string;
696
748
  /** Helper timeout in milliseconds; default 10s. */
@@ -747,6 +799,7 @@ interface WorkBuddyAtRestKeyProviderOptions {
747
799
  * a different app.
748
800
  */
749
801
  private readonly explicitPath;
802
+ private readonly product;
750
803
  private readonly defaultPath;
751
804
  private readonly discovery;
752
805
  private readonly tools;
@@ -764,7 +817,7 @@ interface WorkBuddyAtRestKeyProviderOptions {
764
817
  private discoveredPath;
765
818
  private cache;
766
819
  private inflight;
767
- constructor(options?: WorkBuddyAtRestKeyProviderOptions);
820
+ constructor(options: WorkBuddyAtRestKeyProviderOptions);
768
821
  /**
769
822
  * The binary the default helper would use, for diagnostics.
770
823
  *
@@ -793,8 +846,8 @@ interface WorkBuddyAtRestKeyProviderOptions {
793
846
  */
794
847
  private resolveElectronPath;
795
848
  /**
796
- * Resolve the CN app through Spotlight, then prove each candidate's identity
797
- * before it can be executed.
849
+ * Resolve this product's app through Spotlight, then prove each candidate's
850
+ * identity before it can be executed.
798
851
  *
799
852
  * The whole flow shares one budget: a hang in one candidate must not extend
800
853
  * the wait for the others, and running out of budget is reported as an
@@ -802,9 +855,10 @@ interface WorkBuddyAtRestKeyProviderOptions {
802
855
  */
803
856
  private discoverMacosApp;
804
857
  /**
805
- * Resolve the CN app through Windows uninstall records. Registry entries
806
- * provide hints, not trust: every DisplayIcon candidate must still be a
807
- * WorkBuddy Electron binary with the known Electron layout before execution.
858
+ * Resolve this product's app through Windows uninstall records. Registry
859
+ * entries provide hints, not trust: every DisplayIcon candidate must still
860
+ * be the product's Electron binary with the known Electron layout before
861
+ * execution.
808
862
  */
809
863
  private discoverWindowsApp;
810
864
  private spawnPayload;
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as modelWithCurrentPromotion, B as FALLBACK_CN_APP_VERSION, C as desktopAuthCandidatesFor, D as electronDiscoveryFor, E as WorkBuddyAtRestKeyProvider, F as regionOf, G as validCliVersion, H as fallbackChatIdentity, I as PROBE_EFFORT_CANDIDATES, J as installedAppVersion, K as WORKBUDDY_APP_VERSION_FILENAME, L as probeModel, M as parseModelCatalog, N as prepareChatBody, O as WorkBuddyUpstreamClient, P as prepareInternationalChatBody, R as randomSentinel, S as defaultDesktopAuthPath, T as workbuddyOwnAuthPath, U as readCliVersion, V as chatUserAgent, W as resolveChatIdentity, X as resolveAppVersion, Y as readBundleVersion, Z as validAppVersion, _ as WorkBuddyCatalog, a as WORKBUDDY_HOST_HEARTBEAT_FILENAME, b as WorkBuddyCredentialStore, c as processStartTimeMs, d as writeHostHeartbeat, f as WORKBUDDY_CONNECT_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, j as normalizeCredits, k as classifyUpstreamError, l as readHostHeartbeat, n as CN_VARIANT, o as clearHostHeartbeat, q as appUserAgent, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, t as AI_VARIANT, u as workbuddyHostHeartbeatPath, v as WORKBUDDY_AUTH_FILENAME, w as parseWorkBuddyAuth, x as defaultDesktopAuthCandidates, y as WORKBUDDY_AUTH_FILE_ENV, z as CN_APP_VERSION_FILENAME } from "./variants-D_oxWP_K.js";
1
+ import { A as extractDisplayErrorMessage, B as CN_APP_VERSION_FILENAME, C as WORKBUDDY_VARIANTS, D as WORKBUDDY_UPDATE_PATH, F as prepareInternationalChatBody, G as resolveChatIdentity, H as chatUserAgent, I as regionOf, J as appUserAgent, K as validCliVersion, L as PROBE_EFFORT_CANDIDATES, M as normalizeCredits, N as parseModelCatalog, O as WorkBuddyUpstreamClient, P as prepareChatBody, Q as validAppVersion, R as probeModel, S as CN_VARIANT, U as fallbackChatIdentity, V as FALLBACK_CN_APP_VERSION, W as readCliVersion, X as readBundleVersion, Y as installedAppVersion, Z as resolveAppVersion, _ as desktopAuthCandidatesFor, a as readHostHeartbeat, b as atRestKeyProviderFor, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as WORKBUDDY_AUTH_FILENAME, g as defaultDesktopAuthPath, h as defaultDesktopAuthCandidates, i as processStartTimeMs, j as modelWithCurrentPromotion, k as classifyUpstreamError, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as WORKBUDDY_AUTH_FILE_ENV, q as WORKBUDDY_APP_VERSION_FILENAME, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as parseWorkBuddyAuth, w as variantFor, x as AI_VARIANT, y as workbuddyOwnAuthPath, z as randomSentinel } from "./host-heartbeat-D9DvyLWz.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -770,7 +770,9 @@ function createWorkBuddyShim(options) {
770
770
  req.on("close", () => controller.abort());
771
771
  const result = await client.chatStream(credential, prepared, controller.signal);
772
772
  if (!result.ok) {
773
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status}): ${result.message.slice(0, 400)}`);
773
+ const detail = extractDisplayErrorMessage(result.message) ?? result.message.slice(0, 400);
774
+ const statusNote = (result.status === 401 || result.status === 403) && result.kind !== "session_dead" ? "" : ` (http ${result.status})`;
775
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind}${statusNote}: ${detail}`);
774
776
  return;
775
777
  }
776
778
  res.writeHead(200, {
@@ -1146,7 +1148,7 @@ var WorkBuddyProbeService = class {
1146
1148
  function safeMessage(error) {
1147
1149
  return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
1148
1150
  }
1149
- function json$1(res, status, body) {
1151
+ function json$2(res, status, body) {
1150
1152
  const payload = JSON.stringify(body);
1151
1153
  res.writeHead(status, {
1152
1154
  "Content-Type": "application/json",
@@ -1245,17 +1247,17 @@ async function workBuddyWebStatus(deps) {
1245
1247
  function workBuddyStatusHandler(deps) {
1246
1248
  return async (req, res) => {
1247
1249
  if (req.method !== "GET") {
1248
- json$1(res, 405, { error: "method not allowed" });
1250
+ json$2(res, 405, { error: "method not allowed" });
1249
1251
  return;
1250
1252
  }
1251
1253
  if (!loopbackRequest(req)) {
1252
- json$1(res, 403, { error: "request-not-trusted" });
1254
+ json$2(res, 403, { error: "request-not-trusted" });
1253
1255
  return;
1254
1256
  }
1255
1257
  try {
1256
- json$1(res, 200, await workBuddyWebStatus(deps));
1258
+ json$2(res, 200, await workBuddyWebStatus(deps));
1257
1259
  } catch (error) {
1258
- json$1(res, 500, { error: safeMessage(error) });
1260
+ json$2(res, 500, { error: safeMessage(error) });
1259
1261
  }
1260
1262
  };
1261
1263
  }
@@ -1274,6 +1276,308 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
1274
1276
  }, "dsh-workbuddy-connect: Web status route");
1275
1277
  }
1276
1278
  //#endregion
1279
+ //#region src/update.ts
1280
+ /**
1281
+ * Public update metadata and bounded version checking for WorkBuddy Connect.
1282
+ *
1283
+ * Two upstreams, strictly layered so the judgement never depends on the
1284
+ * enrichment: npm's dist-tags decide whether a newer release exists, and only
1285
+ * then is GitHub's release list consulted — one request that feeds both the
1286
+ * "versions behind" count and the per-release notes. Every upstream answer is
1287
+ * treated as untrusted input: bodies are size-capped while streaming, releases
1288
+ * are re-validated on the browser side by {@link parseWorkBuddyUpdateResult}
1289
+ * before anything renders.
1290
+ *
1291
+ * This module is host/browser shared and dependency-free by design (the client
1292
+ * bundle's runtime imports stay React + local modules only).
1293
+ *
1294
+ * @module dsh-workbuddy-connect/update
1295
+ */
1296
+ const WORKBUDDY_REPOSITORY_URL = "https://github.com/corrinehu/dsh-workbuddy-connect";
1297
+ const WORKBUDDY_UPDATE_NPM_METADATA_URL = `https://registry.npmjs.org/-/package/dsh-workbuddy-connect/dist-tags`;
1298
+ const WORKBUDDY_UPDATE_RELEASES_API_URL = `${WORKBUDDY_REPOSITORY_URL.replace("https://github.com", "https://api.github.com/repos")}/releases?per_page=100`;
1299
+ const WORKBUDDY_RELEASE_PAGE_BASE = `${WORKBUDDY_REPOSITORY_URL}/releases/tag/`;
1300
+ const WORKBUDDY_UPDATE_MAX_METADATA_BYTES = 65536;
1301
+ const WORKBUDDY_UPDATE_MAX_RELEASES_BYTES = 1048576;
1302
+ /** GitHub caps one page at 100 releases; ours is far below that today. */
1303
+ const RELEASES_LIST_MAX = 100;
1304
+ const RELEASE_NAME_MAX_CHARS = 200;
1305
+ const RELEASE_NOTES_MAX_CHARS = 16e3;
1306
+ /** Parse one exact SemVer version, accepting the conventional leading `v`. */
1307
+ function parseWorkBuddyVersion(raw) {
1308
+ if (typeof raw !== "string") return void 0;
1309
+ const normalized = raw.startsWith("v") ? raw.slice(1) : raw;
1310
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.exec(normalized);
1311
+ if (match === null) return void 0;
1312
+ const rawPrerelease = match[4] === void 0 ? [] : match[4].split(".");
1313
+ if (rawPrerelease.some((identifier) => /^\d+$/u.test(identifier) && !/^(0|[1-9]\d*)$/u.test(identifier))) return void 0;
1314
+ const prerelease = rawPrerelease.map((identifier) => /^(0|[1-9]\d*)$/u.test(identifier) ? Number(identifier) : identifier);
1315
+ if (prerelease.some((identifier) => typeof identifier === "number" && !Number.isSafeInteger(identifier))) return void 0;
1316
+ const parsed = {
1317
+ major: Number(match[1]),
1318
+ minor: Number(match[2]),
1319
+ patch: Number(match[3]),
1320
+ prerelease
1321
+ };
1322
+ return [
1323
+ parsed.major,
1324
+ parsed.minor,
1325
+ parsed.patch
1326
+ ].every(Number.isSafeInteger) ? parsed : void 0;
1327
+ }
1328
+ /**
1329
+ * One canonical spelling per SemVer value: leading `v` and build metadata
1330
+ * fold away, so `v0.6.4`, `0.6.4`, and `0.6.4+build` dedupe as one release.
1331
+ * Returns `undefined` for unparseable input; callers drop those first.
1332
+ */
1333
+ function canonicalWorkBuddyVersion(version) {
1334
+ const parsed = parseWorkBuddyVersion(version);
1335
+ if (parsed === void 0) return void 0;
1336
+ return `${String(parsed.major)}.${String(parsed.minor)}.${String(parsed.patch)}` + (parsed.prerelease.length === 0 ? "" : `-${parsed.prerelease.join(".")}`);
1337
+ }
1338
+ function compareIdentifiers(left, right) {
1339
+ if (typeof left === "number" && typeof right === "number") return left < right ? -1 : left > right ? 1 : 0;
1340
+ if (typeof left === "number") return -1;
1341
+ if (typeof right === "number") return 1;
1342
+ return left < right ? -1 : left > right ? 1 : 0;
1343
+ }
1344
+ /** Compare two versions using SemVer precedence (build metadata ignored). */
1345
+ function compareWorkBuddyVersions(left, right) {
1346
+ const a = parseWorkBuddyVersion(left);
1347
+ const b = parseWorkBuddyVersion(right);
1348
+ if (a === void 0 || b === void 0) throw new TypeError("invalid WorkBuddy Connect version");
1349
+ for (const [aPart, bPart] of [
1350
+ [a.major, b.major],
1351
+ [a.minor, b.minor],
1352
+ [a.patch, b.patch]
1353
+ ]) if (aPart !== bPart) return aPart < bPart ? -1 : 1;
1354
+ if (a.prerelease.length === 0 && b.prerelease.length !== 0) return 1;
1355
+ if (a.prerelease.length !== 0 && b.prerelease.length === 0) return -1;
1356
+ for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) {
1357
+ const aPart = a.prerelease[index];
1358
+ const bPart = b.prerelease[index];
1359
+ if (aPart === void 0) return -1;
1360
+ if (bPart === void 0) return 1;
1361
+ const comparison = compareIdentifiers(aPart, bPart);
1362
+ if (comparison !== 0) return comparison;
1363
+ }
1364
+ return 0;
1365
+ }
1366
+ function boundedText(value, maxBytes) {
1367
+ if (new TextEncoder().encode(value).byteLength > maxBytes) throw new RangeError("update response is too large");
1368
+ return value;
1369
+ }
1370
+ async function readBoundedText(response, maxBytes) {
1371
+ if (response.body === null) return boundedText(await response.text(), maxBytes);
1372
+ const reader = response.body.getReader();
1373
+ const decoder = new TextDecoder();
1374
+ const chunks = [];
1375
+ let total = 0;
1376
+ try {
1377
+ while (true) {
1378
+ const next = await reader.read();
1379
+ if (next.done) break;
1380
+ total += next.value.byteLength;
1381
+ if (total > maxBytes) {
1382
+ await reader.cancel();
1383
+ throw new RangeError("update response is too large");
1384
+ }
1385
+ chunks.push(decoder.decode(next.value, { stream: true }));
1386
+ }
1387
+ chunks.push(decoder.decode());
1388
+ return chunks.join("");
1389
+ } finally {
1390
+ reader.releaseLock();
1391
+ }
1392
+ }
1393
+ async function fetchBounded(fetchImpl, url, maxBytes, timeoutMs, headers) {
1394
+ const controller = new AbortController();
1395
+ const timer = setTimeout(() => {
1396
+ controller.abort(/* @__PURE__ */ new Error("update request timed out"));
1397
+ }, timeoutMs);
1398
+ try {
1399
+ const response = await fetchImpl(url, {
1400
+ headers,
1401
+ signal: controller.signal
1402
+ });
1403
+ return {
1404
+ response,
1405
+ text: await readBoundedText(response, maxBytes)
1406
+ };
1407
+ } finally {
1408
+ clearTimeout(timer);
1409
+ }
1410
+ }
1411
+ /** Strip control characters, normalize newlines, and cap the length. */
1412
+ function cleanReleaseText(value, maxLength) {
1413
+ if (typeof value !== "string" || value.length === 0) return void 0;
1414
+ const clean = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/gu, "").replace(/\r\n?/gu, "\n").trim().slice(0, maxLength);
1415
+ return clean.length === 0 ? void 0 : clean;
1416
+ }
1417
+ function cleanPublishedAt(value) {
1418
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/iu.test(value) ? value.slice(0, 64) : void 0;
1419
+ }
1420
+ /**
1421
+ * The `latest` dist-tag only. Pre-release channels are not this plugin's
1422
+ * release line, so recommending one would be wrong even when newer.
1423
+ */
1424
+ function latestDistTag(value) {
1425
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1426
+ const tags = value["dist-tags"] ?? value;
1427
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) return void 0;
1428
+ const candidate = tags["latest"];
1429
+ return typeof candidate === "string" && parseWorkBuddyVersion(candidate) !== void 0 ? candidate : void 0;
1430
+ }
1431
+ function releasePageUrl(version) {
1432
+ return `${WORKBUDDY_RELEASE_PAGE_BASE}v${version}`;
1433
+ }
1434
+ /**
1435
+ * Parse GitHub's release-list page into the in-range releases, newest first.
1436
+ * Unknown entry shapes are skipped, never fatal: the count and the notes both
1437
+ * degrade per-entry rather than dropping the whole judgement.
1438
+ */
1439
+ function releasesInRange(currentVersion, latestVersion, value) {
1440
+ if (!Array.isArray(value) || value.length > RELEASES_LIST_MAX) return void 0;
1441
+ const releases = [];
1442
+ const seen = /* @__PURE__ */ new Set();
1443
+ for (const raw of value) {
1444
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
1445
+ const entry = raw;
1446
+ const tag = entry["tag_name"];
1447
+ if (typeof tag !== "string") continue;
1448
+ if (parseWorkBuddyVersion(tag) === void 0) continue;
1449
+ if (compareWorkBuddyVersions(tag, currentVersion) <= 0 || compareWorkBuddyVersions(tag, latestVersion) > 0) continue;
1450
+ const canonical = canonicalWorkBuddyVersion(tag);
1451
+ if (canonical === void 0 || seen.has(canonical)) continue;
1452
+ seen.add(canonical);
1453
+ const name = cleanReleaseText(entry["name"], RELEASE_NAME_MAX_CHARS);
1454
+ const notes = cleanReleaseText(entry["body"], RELEASE_NOTES_MAX_CHARS);
1455
+ const publishedAt = cleanPublishedAt(entry["published_at"]);
1456
+ releases.push({
1457
+ version: tag,
1458
+ ...name === void 0 ? {} : { name },
1459
+ ...notes === void 0 ? {} : { notes },
1460
+ ...publishedAt === void 0 ? {} : { publishedAt }
1461
+ });
1462
+ }
1463
+ releases.sort((left, right) => compareWorkBuddyVersions(right.version, left.version));
1464
+ return releases;
1465
+ }
1466
+ /** Enrich an available update from GitHub's release list; degrade on failure. */
1467
+ async function releaseList(currentVersion, latestVersion, fetchImpl, timeoutMs) {
1468
+ try {
1469
+ const { response, text } = await fetchBounded(fetchImpl, WORKBUDDY_UPDATE_RELEASES_API_URL, WORKBUDDY_UPDATE_MAX_RELEASES_BYTES, timeoutMs, { accept: "application/vnd.github+json" });
1470
+ if (!response.ok) return void 0;
1471
+ return releasesInRange(currentVersion, latestVersion, JSON.parse(text));
1472
+ } catch {
1473
+ return;
1474
+ }
1475
+ }
1476
+ /** Check npm's public dist-tags and enrich an available update with release notes. */
1477
+ async function checkWorkBuddyUpdate(options) {
1478
+ const { currentVersion } = options;
1479
+ if (parseWorkBuddyVersion(currentVersion) === void 0) return {
1480
+ status: "unavailable",
1481
+ currentVersion,
1482
+ reason: "invalid-current-version"
1483
+ };
1484
+ const fetchImpl = options.fetchImpl ?? fetch;
1485
+ const timeoutMs = options.timeoutMs ?? 8e3;
1486
+ let metadata;
1487
+ try {
1488
+ const { response, text } = await fetchBounded(fetchImpl, WORKBUDDY_UPDATE_NPM_METADATA_URL, WORKBUDDY_UPDATE_MAX_METADATA_BYTES, timeoutMs, { accept: "application/json" });
1489
+ if (!response.ok) return {
1490
+ status: "unavailable",
1491
+ currentVersion,
1492
+ reason: "registry-unavailable"
1493
+ };
1494
+ metadata = JSON.parse(text);
1495
+ } catch (error) {
1496
+ return {
1497
+ status: "unavailable",
1498
+ currentVersion,
1499
+ reason: error instanceof SyntaxError || error instanceof RangeError ? "invalid-registry-response" : "registry-unavailable"
1500
+ };
1501
+ }
1502
+ const latestVersion = latestDistTag(metadata);
1503
+ if (latestVersion === void 0) return {
1504
+ status: "unavailable",
1505
+ currentVersion,
1506
+ reason: "invalid-registry-response"
1507
+ };
1508
+ if (compareWorkBuddyVersions(latestVersion, currentVersion) <= 0) return {
1509
+ status: "up-to-date",
1510
+ currentVersion,
1511
+ latestVersion: currentVersion
1512
+ };
1513
+ const releases = await releaseList(currentVersion, latestVersion, fetchImpl, timeoutMs);
1514
+ return {
1515
+ status: "update-available",
1516
+ currentVersion,
1517
+ latestVersion,
1518
+ releaseUrl: releasePageUrl(latestVersion),
1519
+ releases: releases ?? [],
1520
+ ...releases === void 0 ? {} : { versionsBehind: releases.length }
1521
+ };
1522
+ }
1523
+ //#endregion
1524
+ //#region src/update-route.ts
1525
+ function json$1(res, status, value) {
1526
+ const payload = JSON.stringify(value);
1527
+ res.writeHead(status, {
1528
+ "Content-Type": "application/json; charset=utf-8",
1529
+ "Content-Length": Buffer.byteLength(payload),
1530
+ "Cache-Control": "no-store",
1531
+ "X-Content-Type-Options": "nosniff"
1532
+ });
1533
+ res.end(payload);
1534
+ }
1535
+ /** The same browser-origin gate the status route uses: DNS-rebinding pages die here. */
1536
+ function browserRequestAllowed(req) {
1537
+ return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
1538
+ }
1539
+ /** Build the route handler; the caller owns registration and disposal. */
1540
+ function workBuddyUpdateHandler(options) {
1541
+ return async (req, res) => {
1542
+ if (req.method !== "GET") {
1543
+ json$1(res, 405, { error: "method not allowed" });
1544
+ return;
1545
+ }
1546
+ if (!browserRequestAllowed(req)) {
1547
+ json$1(res, 403, { error: "forbidden" });
1548
+ return;
1549
+ }
1550
+ let result;
1551
+ try {
1552
+ result = await checkWorkBuddyUpdate({
1553
+ currentVersion: options.currentVersion,
1554
+ ...options.fetchImpl === void 0 ? {} : { fetchImpl: options.fetchImpl },
1555
+ timeoutMs: options.timeoutMs ?? 8e3
1556
+ });
1557
+ } catch {
1558
+ result = {
1559
+ status: "unavailable",
1560
+ currentVersion: options.currentVersion,
1561
+ reason: "registry-unavailable"
1562
+ };
1563
+ }
1564
+ json$1(res, 200, result);
1565
+ };
1566
+ }
1567
+ /** Register the update route on the optional host web server. */
1568
+ function registerWorkBuddyUpdateRoute(ctx, options) {
1569
+ ctx.effect(() => {
1570
+ const dispose = ctx.webServer.register({
1571
+ kind: "exact",
1572
+ path: WORKBUDDY_UPDATE_PATH,
1573
+ handler: workBuddyUpdateHandler(options)
1574
+ });
1575
+ return () => {
1576
+ dispose();
1577
+ };
1578
+ }, "dsh-workbuddy-connect: update route");
1579
+ }
1580
+ //#endregion
1277
1581
  //#region src/probe-route.ts
1278
1582
  /**
1279
1583
  * Probe control route: the only state-changing endpoint the plugin exposes.
@@ -1766,7 +2070,7 @@ function apply(ctx, config) {
1766
2070
  * case that must not fall back to a shared preference bucket.
1767
2071
  */
1768
2072
  const lastAccounts = /* @__PURE__ */ new Map();
1769
- const atRestKeysFor = (variant) => new WorkBuddyAtRestKeyProvider({ discovery: electronDiscoveryFor(variant) });
2073
+ const atRestKeysFor = (variant) => atRestKeyProviderFor(variant);
1770
2074
  const runtimes = WORKBUDDY_VARIANTS.map((variant) => createVariantRuntime(config, variant, () => current(), (id) => lastIdentities.get(id), (id) => lastAccounts.get(id), atRestKeysFor(variant)));
1771
2075
  const probeKey = createProbeKey();
1772
2076
  let setMaximumContextWindow;
@@ -1841,6 +2145,7 @@ function apply(ctx, config) {
1841
2145
  runtime.invalidate();
1842
2146
  };
1843
2147
  ctx.inject(["webServer"], (webCtx) => {
2148
+ registerWorkBuddyUpdateRoute(webCtx, { currentVersion: WORKBUDDY_CONNECT_VERSION });
1844
2149
  for (const runtime of runtimes) {
1845
2150
  registerWorkBuddyStatusRoute(webCtx, {
1846
2151
  path: runtime.variant.statusPath,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-connect",
3
3
  "displayName": "DSH WorkBuddy Connect",
4
4
  "description": "将 WorkBuddy 桌面 App 包含的模型自动接入 DeepSeek Harness — bring WorkBuddy desktop app models into DeepSeek Harness with zero configuration.",
5
- "version": "0.6.3",
5
+ "version": "0.6.4",
6
6
  "author": "corrinehu",
7
7
  "keywords": [
8
8
  "dsh-plugin",