dsh-workbuddy-connect 0.7.0 → 0.7.2

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.
@@ -387,15 +387,36 @@ function randomSentinel() {
387
387
  return `probe_sentinel_${randomBytes(12).toString("hex")}`;
388
388
  }
389
389
  /**
390
- * The upstream's "this effort value is not supported" code, measured
391
- * 2026-09-11 (plan §4.2). It is *not* treated as a permanent protocol promise:
392
- * anything unrecognized degrades to `unknown` rather than to a capability
393
- * conclusion.
390
+ * The upstream's "this effort value is not supported" codes, per region, as
391
+ * measured on each live endpoint. The sets are kept separate so a code only
392
+ * ever widens detection for the endpoint it was measured on.
393
+ *
394
+ * - `invalid_reasoning_effort` — the China endpoint, measured 2026-09-11
395
+ * (plan §4.2). Also kept for `global` as a fallback spelling.
396
+ * - `model_param_invalid` — the global endpoint, measured 2026-10-01. A
397
+ * non-canonical value is answered `400` / `11133` with this code, which names
398
+ * no field (`extError.param` is empty), instead of one that names the effort.
399
+ * It is generic enough to be readable here only because the baseline step has
400
+ * already proved the *same* request without `reasoning_effort` succeeds,
401
+ * leaving the sentinel as the only difference between the two attempts. A
402
+ * sibling code in the same `11133` envelope that names another parameter
403
+ * (`integer_below_min_value`, the `max_tokens` floor) and a body carrying no
404
+ * `extError` at all (`11102`, unknown model) are therefore not mistaken for
405
+ * it, and neither is a level-sweep answer. It is *not* added to `cn`: that
406
+ * endpoint was measured answering the specific code, and reading a generic
407
+ * code there would widen attribution beyond what was observed.
408
+ *
409
+ * Neither is treated as a permanent protocol promise: anything unrecognized
410
+ * still degrades to `unknown` rather than to a capability conclusion.
394
411
  */
395
- const INVALID_EFFORT_CODE = "invalid_reasoning_effort";
412
+ const INVALID_EFFORT_CODES = {
413
+ cn: /* @__PURE__ */ new Set(["invalid_reasoning_effort"]),
414
+ global: /* @__PURE__ */ new Set(["invalid_reasoning_effort", "model_param_invalid"])
415
+ };
396
416
  /** Whether an attempt is an attributable rejection of the effort value. */
397
- function isEffortRejection(attempt) {
398
- return attempt.status === 400 && attempt.errorCode === INVALID_EFFORT_CODE;
417
+ function isEffortRejection(attempt, region) {
418
+ const codes = INVALID_EFFORT_CODES[region];
419
+ return attempt.status === 400 && attempt.errorCode !== void 0 && codes.has(attempt.errorCode);
399
420
  }
400
421
  /** Whether an attempt shows the upstream accepted the request and streamed. */
401
422
  function isAcceptance(attempt) {
@@ -411,12 +432,15 @@ function unknownReason(stage, attempt) {
411
432
  * Probe one model.
412
433
  *
413
434
  * `options.candidates` exists so tests can shorten the sweep; production always
414
- * uses {@link PROBE_EFFORT_CANDIDATES}.
435
+ * uses {@link PROBE_EFFORT_CANDIDATES}. `options.region` selects which
436
+ * endpoint's rejection vocabulary is read; it defaults to `cn`, which is also
437
+ * the production default for the China app.
415
438
  */
416
439
  async function probeModel(options) {
417
440
  const sentinel = options.sentinel ?? randomSentinel;
418
441
  const candidates = options.candidates ?? PROBE_EFFORT_CANDIDATES;
419
442
  const timeoutMs = options.timeoutMs ?? 3e4;
443
+ const region = options.region ?? "cn";
420
444
  let requests = 0;
421
445
  const attempt = async (effort) => {
422
446
  requests += 1;
@@ -447,7 +471,7 @@ async function probeModel(options) {
447
471
  efforts: [],
448
472
  requests
449
473
  };
450
- if (!isEffortRejection(sentinelAttempt)) return {
474
+ if (!isEffortRejection(sentinelAttempt, region)) return {
451
475
  validation: "unknown",
452
476
  efforts: [],
453
477
  requests,
@@ -460,7 +484,7 @@ async function probeModel(options) {
460
484
  accepted.push(effort);
461
485
  continue;
462
486
  }
463
- if (isEffortRejection(levelAttempt)) continue;
487
+ if (isEffortRejection(levelAttempt, region)) continue;
464
488
  return {
465
489
  validation: "unknown",
466
490
  efforts: [],
@@ -822,6 +846,95 @@ function envelopeError(status, envelope) {
822
846
  return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
823
847
  }
824
848
  /**
849
+ * Parse the billing response's `CycleEndTime` into epoch milliseconds.
850
+ *
851
+ * The upstream sends a zone-less timestamp (`YYYY-MM-DD HH:mm:ss`). This reads it
852
+ * as the running machine's local time, which is what the official desktop client
853
+ * does — it feeds the same field straight to `new Date(value).getTime()`. That
854
+ * makes the plugin agree with the app on the same machine; it is **not** evidence
855
+ * of which zone the server or the account uses, which remains unestablished.
856
+ *
857
+ * A naive read does mean a machine in a different zone than the account's gets a
858
+ * shifted instant. That is accepted here rather than guessed at: inventing an
859
+ * offset would be asserting a zone nobody has verified.
860
+ *
861
+ * @returns Epoch milliseconds, or undefined when absent, malformed, or not a real
862
+ * calendar moment (see the round-trip check below).
863
+ */
864
+ function parseCycleEndTime(value) {
865
+ if (typeof value !== "string" || value.trim() === "") return void 0;
866
+ const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/u.exec(value.trim());
867
+ if (match === null) return void 0;
868
+ const [, y, mo, d, h, mi, s] = match;
869
+ const [year, month, day, hour, minute, second] = [
870
+ y,
871
+ mo,
872
+ d,
873
+ h,
874
+ mi,
875
+ s
876
+ ].map(Number);
877
+ const date = new Date(year, month - 1, day, hour, minute, second);
878
+ const parsed = date.getTime();
879
+ if (!Number.isFinite(parsed)) return void 0;
880
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day || date.getHours() !== hour || date.getMinutes() !== minute || date.getSeconds() !== second) return;
881
+ return parsed;
882
+ }
883
+ /** Empty bucket, used whenever no account qualifies. */
884
+ const NO_EXPIRING_CREDIT = {
885
+ total: 0,
886
+ packages: 0
887
+ };
888
+ /**
889
+ * Sum the credit lapsing within `horizonMs` of `nowMs`.
890
+ *
891
+ * Only positive balances count: a package with nothing left is not a loss, and
892
+ * including it would make every long-lived account look permanently at risk.
893
+ * Only *future* expiries count too — a package already past its end time would
894
+ * otherwise satisfy every forward horizon at once (`end - now` is negative,
895
+ * hence "within 24 hours"), making a long-dead package look permanently
896
+ * imminent. See {@link WorkBuddyCreditExpiry} for why the past is not reported.
897
+ */
898
+ function expiringWithin(accounts, nowMs, horizonMs) {
899
+ return sumExpiring(accounts, (end) => end > nowMs && end - nowMs <= horizonMs);
900
+ }
901
+ /** Shared accumulator over accounts whose end time passes `matches`. */
902
+ function sumExpiring(accounts, matches) {
903
+ let total = 0;
904
+ let packages = 0;
905
+ let earliestEndTimeMs;
906
+ for (const account of accounts) {
907
+ const end = account.endTimeMs;
908
+ if (end === void 0 || account.remain <= 0) continue;
909
+ if (!matches(end)) continue;
910
+ total += account.remain;
911
+ packages += 1;
912
+ if (earliestEndTimeMs === void 0 || end < earliestEndTimeMs) earliestEndTimeMs = end;
913
+ }
914
+ if (packages === 0) return NO_EXPIRING_CREDIT;
915
+ return {
916
+ total,
917
+ packages,
918
+ ...earliestEndTimeMs === void 0 ? {} : { earliestEndTimeMs }
919
+ };
920
+ }
921
+ /**
922
+ * Bucket an account's credit by how soon it lapses.
923
+ *
924
+ * Returns undefined when no account carried a usable end time, so a caller can
925
+ * tell "nothing is expiring" apart from "we never learned when anything
926
+ * expires" — the second must not render as reassurance.
927
+ */
928
+ function summariseExpiry(accounts, nowMs) {
929
+ if (!accounts.some((account) => account.endTimeMs !== void 0)) return {};
930
+ const DAY = 864e5;
931
+ return { expiry: {
932
+ within24h: expiringWithin(accounts, nowMs, DAY),
933
+ within3d: expiringWithin(accounts, nowMs, 3 * DAY),
934
+ within7d: expiringWithin(accounts, nowMs, 7 * DAY)
935
+ } };
936
+ }
937
+ /**
825
938
  * Upstream HTTP client. One instance serves the whole plugin; requests take
826
939
  * the credential explicitly so token refreshes apply on the next call.
827
940
  *
@@ -863,7 +976,7 @@ var WorkBuddyUpstreamClient = class {
863
976
  ...chatHeaders(credential, userAgent, identity.clientVersion),
864
977
  "Authorization": `Bearer ${credential.accessToken}`
865
978
  },
866
- body: region === "global" ? prepareInternationalChatBody(bodyJson) : bodyJson,
979
+ body: region === "global" ? prepareInternationalChatBody(bodyJson) : prepareCnChatBody(bodyJson),
867
980
  ...signal === void 0 ? {} : { signal }
868
981
  });
869
982
  } catch (error) {
@@ -1066,15 +1179,18 @@ var WorkBuddyUpstreamClient = class {
1066
1179
  else remain = capacityRemain;
1067
1180
  if (remain < 0) remain = 0;
1068
1181
  total += remain;
1182
+ const endTimeMs = parseCycleEndTime(account["CycleEndTime"]);
1069
1183
  accounts.push({
1070
1184
  packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
1071
1185
  remain,
1072
- size: size > 0 ? size : numberField("CapacitySize")
1186
+ size: size > 0 ? size : numberField("CapacitySize"),
1187
+ ...endTimeMs === void 0 ? {} : { endTimeMs }
1073
1188
  });
1074
1189
  }
1075
1190
  return {
1076
1191
  total,
1077
- accounts
1192
+ accounts,
1193
+ ...summariseExpiry(accounts, now.getTime())
1078
1194
  };
1079
1195
  }
1080
1196
  /**
@@ -1411,6 +1527,32 @@ function modelWithCurrentPromotion(model, now = Date.now()) {
1411
1527
  * JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
1412
1528
  * so this is safe to run over an already-prepared-or-not body.
1413
1529
  */
1530
+ /**
1531
+ * Prepare a body for the **CN** endpoint.
1532
+ *
1533
+ * Until #87 the CN wire was the caller's body verbatim. The only difference
1534
+ * now is the shared {@link dropUnsupportedEffort} strip, whose region scope was
1535
+ * lifted once measurements showed this endpoint rejects `off` too.
1536
+ *
1537
+ * Deliberately **not** routed through {@link prepareChatBody}: the shim already
1538
+ * applies it before handing the body to {@link WorkBuddyUpstreamClient.chatStream},
1539
+ * and re-running it would be a second `stream`/`developer`/`tool_choice`
1540
+ * normalisation of an already-normalised body. Only the strip belongs here.
1541
+ *
1542
+ * A body that is not a JSON object is returned unchanged, so this is safe to
1543
+ * run over an already-prepared-or-not body.
1544
+ */
1545
+ function prepareCnChatBody(source) {
1546
+ let body;
1547
+ try {
1548
+ body = JSON.parse(source);
1549
+ } catch {
1550
+ return source;
1551
+ }
1552
+ if (!isObject(body)) return source;
1553
+ dropUnsupportedEffort(body);
1554
+ return JSON.stringify(body);
1555
+ }
1414
1556
  function prepareInternationalChatBody(source) {
1415
1557
  const prepared = prepareChatBody(source);
1416
1558
  let body;
@@ -1432,25 +1574,34 @@ function prepareInternationalChatBody(source) {
1432
1574
  return JSON.stringify(body);
1433
1575
  }
1434
1576
  /**
1435
- * Remove the adapter's own `off` effort spelling from the **international** wire.
1577
+ * Remove the adapter's own `off` effort spelling from the wire, on every region.
1436
1578
  *
1437
1579
  * `thinkingLevelMap.off` is pinned to the literal `'off'` for models that
1438
1580
  * declare `canDisableThinking`, so that the level stays selectable. pi-ai
1439
- * sends that value for any request carrying no explicit level, and the
1440
- * international endpoint rejects it on the GPT family with HTTP 400 `11133` /
1441
- * `extError.param === 'reasoning.effort'` (issue #49). Omission is the only
1442
- * form measured good on every such model; a literal `'none'` is *not* a safe
1443
- * substitute — accepted by the GPT-5.6 family and GLM, rejected by
1444
- * `gpt-6-astra`.
1445
- *
1446
- * Consequences, stated honestly: the international picker still offers Off,
1447
- * but selecting it now means "the field is omitted" — the model's actual
1448
- * behaviour is decided upstream and is *not* guaranteed to disable thinking
1449
- * or to match the catalog's `defaultEffort`. Declared spellings
1450
- * (`low`/`medium`/`high`/`xhigh`/`max`) and an explicit `none` pass through
1451
- * untouched. The CN variant is deliberately unaffected: its endpoint has
1452
- * accepted this spelling in every measurement so far, and keeping its wire
1453
- * unchanged is a scope decision, not a claim about that endpoint's future.
1581
+ * sends that value for any request carrying no explicit level — which is why
1582
+ * this covers **Default and Off alike**: the two produce the same body, so a
1583
+ * fix that only removed the menu entry would leave Default failing.
1584
+ *
1585
+ * Both endpoints reject it, which is why this is no longer region-scoped
1586
+ * (#87): the international GPT family answers HTTP 400 `11133` /
1587
+ * `extError.param === 'reasoning.effort'` (issue #49), and the CN endpoint
1588
+ * rejects it on models that declare `onlyReasoning` — measured on
1589
+ * `deepseek-v4.1-flash` with 400 / `11150`, while `low`/`high`/`max` pass
1590
+ * (issue #87). Omission is the only form measured good on every such model; a
1591
+ * literal `'none'` is *not* a safe substitute — accepted by the GPT-5.6 family
1592
+ * and GLM, rejected by `gpt-6-astra`.
1593
+ *
1594
+ * Consequences, stated honestly: the picker still offers Off for models where
1595
+ * the upstream declares `canDisableThinking`, but selecting it now means "the
1596
+ * field is omitted" — the model's actual behaviour is decided upstream and is
1597
+ * *not* guaranteed to disable thinking or to match the catalog's
1598
+ * `defaultEffort`. Declared spellings (`low`/`medium`/`high`/`xhigh`/`max`)
1599
+ * and an explicit `none` pass through untouched.
1600
+ *
1601
+ * The CN strip replaces a scope decision whose premise did not hold: the
1602
+ * earlier note here said that endpoint "has accepted this spelling in every
1603
+ * measurement so far". Measurements since (#87) show it rejects `off` on
1604
+ * validating models — the earlier runs had simply not hit one.
1454
1605
  */
1455
1606
  function dropUnsupportedEffort(obj) {
1456
1607
  if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
@@ -1850,6 +2001,60 @@ const WINDOWS_REGISTRY_ROOTS = [
1850
2001
  ];
1851
2002
  const WINDOWS_REGISTRY_OUTPUT_MAX_BYTES = 1048576;
1852
2003
  const WINDOWS_ELECTRON_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u;
2004
+ /**
2005
+ * The PowerShell command that re-reads one uninstall root as UTF-8.
2006
+ *
2007
+ * Used *only* as a fallback, and only to supply data: the caller feeds its
2008
+ * output through the same {@link parseWindowsRegistryOutput} and
2009
+ * {@link inspectWindowsElectronCandidate} as the `reg.exe` path, so candidate
2010
+ * validation, uniqueness, and the "we could not check" rules are unchanged.
2011
+ *
2012
+ * Why a different tool rather than a smarter decode: `reg.exe` writes in a
2013
+ * code page chosen by the console it inherits, and no inspection of the
2014
+ * resulting bytes can recover which one. .NET's registry API returns strings,
2015
+ * so the question never arises.
2016
+ *
2017
+ * Structure notes, each one a defect fixed after review:
2018
+ *
2019
+ * - **`param` is the first statement.** A `param` block must lead the script
2020
+ * block; putting the encoding assignment before it is a parse error in
2021
+ * Windows PowerShell 5.1.
2022
+ * - **`[Console]::OutputEncoding` is set explicitly.** Windows PowerShell 5.1
2023
+ * encodes redirected stdout with it, so UTF-8 is *defined* for this child
2024
+ * rather than inferred from what comes back.
2025
+ * - **Exit codes distinguish "root absent" from "root unreadable"** (`2` and
2026
+ * `3`). The caller branches on the code, never on message text, so the
2027
+ * localized diagnostics below cannot change an outcome.
2028
+ * - **An unopenable subkey is `3`, not `continue`.** Skipping it silently
2029
+ * could drop a real candidate and make the product look absent, which is
2030
+ * precisely the confusion the caller's `incomplete` state exists to prevent.
2031
+ * - **Output is JSON.** PowerShell does the escaping, so paths containing
2032
+ * spaces, commas, or quotes no longer depend on column positions, and only
2033
+ * the two values this plugin reads are collected.
2034
+ *
2035
+ * The root is passed as a `-Root` argument, never interpolated into the
2036
+ * command text.
2037
+ */
2038
+ const WINDOWS_REGISTRY_RELATIVE_POWERSHELL = "System32\\WindowsPowerShell\\v1.0\\powershell.exe";
2039
+ /** Exit code meaning the root key does not exist (a normal, expected outcome). */
2040
+ const WINDOWS_POWERSHELL_ROOT_ABSENT = 2;
2041
+ /** Exit code meaning the root exists but a part of it could not be read. */
2042
+ const WINDOWS_POWERSHELL_ROOT_UNREADABLE = 3;
2043
+ const WINDOWS_POWERSHELL_QUERY_SCRIPT = [
2044
+ "param([string]$Root)",
2045
+ "[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
2046
+ "$ErrorActionPreference=\"Stop\";",
2047
+ "$path=\"Registry::\"+$Root;",
2048
+ "if (-not (Test-Path -LiteralPath $path)) { [Console]::Error.Write(\"root absent\"); exit 2 };",
2049
+ "try { $k=Get-Item -LiteralPath $path -ErrorAction Stop } catch { [Console]::Error.Write(\"root unreadable\"); exit 3 };",
2050
+ "$out=@();",
2051
+ "foreach ($n in $k.GetSubKeyNames()) {",
2052
+ " try { $p=$k.OpenSubKey($n); if ($null -eq $p) { throw \"null\" } } catch { [Console]::Error.Write(\"subkey unreadable\"); exit 3 };",
2053
+ " $d=$p.GetValue(\"DisplayName\");$i=$p.GetValue(\"DisplayIcon\");",
2054
+ " if ($null -ne $d -or $null -ne $i) { $out += [pscustomobject]@{ displayName=[string]$d; displayIcon=[string]$i } }",
2055
+ "};",
2056
+ "ConvertTo-Json -InputObject @($out) -Compress -Depth 3"
2057
+ ].join(" ");
1853
2058
  /** One discovery subprocess's own limits; see {@link WorkBuddyAtRestKeyProviderOptions}. */
1854
2059
  const WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS = 3e3;
1855
2060
  const MDFIND_MAX_OUTPUT_BYTES = 1048576;
@@ -1944,6 +2149,15 @@ function workBuddyDiscoveryTools(bundleId) {
1944
2149
  function workBuddyWindowsDiscoveryTools() {
1945
2150
  const systemRoot = process.env.SystemRoot?.trim();
1946
2151
  const regPath = systemRoot === void 0 || systemRoot === "" ? void 0 : join(systemRoot, "System32", "reg.exe");
2152
+ /**
2153
+ * The fallback interpreter. Resolved by absolute path from SystemRoot, like
2154
+ * `reg.exe`: never through PATH, which a user can change.
2155
+ *
2156
+ * Windows PowerShell 5.1 ships with Windows, so unlike `pwsh` it can be
2157
+ * relied on; the script is written to its syntax (see
2158
+ * {@link WINDOWS_POWERSHELL_QUERY_SCRIPT}).
2159
+ */
2160
+ const powershellPath = systemRoot === void 0 || systemRoot === "" ? void 0 : join(systemRoot, WINDOWS_REGISTRY_RELATIVE_POWERSHELL);
1947
2161
  const runTool = (root, signal) => new Promise((resolve, reject) => {
1948
2162
  if (regPath === void 0) {
1949
2163
  reject(new DiscoveryIncompleteError("SystemRoot is not configured"));
@@ -1961,13 +2175,14 @@ function workBuddyWindowsDiscoveryTools() {
1961
2175
  ], {
1962
2176
  maxBuffer: WINDOWS_REGISTRY_OUTPUT_MAX_BYTES,
1963
2177
  timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS,
1964
- windowsHide: true
2178
+ windowsHide: true,
2179
+ encoding: "buffer"
1965
2180
  }, (error, stdout, stderr) => {
1966
2181
  if (settled) return;
1967
2182
  settled = true;
1968
2183
  if (error !== null && error !== void 0) {
1969
- if (error.killed !== true && (error.code === 1 || error.code === "1") && windowsRegistryKeyMissing(stderr)) {
1970
- resolve("");
2184
+ if (error.killed !== true && (error.code === 1 || error.code === "1") && windowsRegistryKeyMissing(stderr.toString("utf8"))) {
2185
+ resolve(Buffer.alloc(0));
1971
2186
  return;
1972
2187
  }
1973
2188
  reject(new DiscoveryIncompleteError(`reg.exe could not complete (${error.killed === true ? "timed out" : String(error.code ?? "unavailable")})`));
@@ -1986,7 +2201,119 @@ function workBuddyWindowsDiscoveryTools() {
1986
2201
  signal.removeEventListener("abort", abort);
1987
2202
  });
1988
2203
  });
1989
- return { queryUninstallRoot: runTool };
2204
+ /**
2205
+ * Re-read one root through PowerShell's Unicode registry API (#88).
2206
+ *
2207
+ * Returns the same column-shaped document the `reg.exe` path produces, so
2208
+ * the caller's parser and candidate checks apply unchanged. Every failure
2209
+ * that means "we could not tell" is a {@link DiscoveryIncompleteError}, and
2210
+ * a root that simply does not exist yields an empty document — one of the
2211
+ * three roots being absent is normal and must not fail the search.
2212
+ */
2213
+ const runFallback = (root, signal) => new Promise((resolve, reject) => {
2214
+ if (powershellPath === void 0) {
2215
+ reject(new DiscoveryIncompleteError("SystemRoot is not configured"));
2216
+ return;
2217
+ }
2218
+ if (signal.aborted) {
2219
+ reject(new DiscoveryIncompleteError("powershell.exe was not started: the discovery budget was already spent"));
2220
+ return;
2221
+ }
2222
+ let settled = false;
2223
+ const child = execFile(powershellPath, [
2224
+ "-NoProfile",
2225
+ "-NonInteractive",
2226
+ "-Command",
2227
+ WINDOWS_POWERSHELL_QUERY_SCRIPT,
2228
+ "-Root",
2229
+ root
2230
+ ], {
2231
+ maxBuffer: WINDOWS_REGISTRY_OUTPUT_MAX_BYTES,
2232
+ timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS,
2233
+ windowsHide: true,
2234
+ encoding: "utf8"
2235
+ }, (error, stdout) => {
2236
+ if (settled) return;
2237
+ settled = true;
2238
+ if (error !== null && error !== void 0) {
2239
+ const code = error.killed === true ? void 0 : Number(error.code);
2240
+ if (code === WINDOWS_POWERSHELL_ROOT_ABSENT) {
2241
+ resolve("");
2242
+ return;
2243
+ }
2244
+ if (code === WINDOWS_POWERSHELL_ROOT_UNREADABLE) {
2245
+ reject(new DiscoveryIncompleteError(`powershell.exe could not read the registry root ${root}`));
2246
+ return;
2247
+ }
2248
+ reject(new DiscoveryIncompleteError(`powershell.exe could not complete (${error.killed === true ? "timed out" : String(error.code ?? "unavailable")})`));
2249
+ return;
2250
+ }
2251
+ const records = parseWindowsUninstallJson(stdout);
2252
+ if (records === void 0) {
2253
+ reject(new DiscoveryIncompleteError("powershell.exe output was not the expected registry document"));
2254
+ return;
2255
+ }
2256
+ resolve(records);
2257
+ });
2258
+ const abort = () => {
2259
+ if (settled) return;
2260
+ settled = true;
2261
+ child.kill();
2262
+ reject(new DiscoveryIncompleteError("powershell.exe was abandoned: the discovery budget was spent"));
2263
+ };
2264
+ signal.addEventListener("abort", abort, { once: true });
2265
+ child.on("close", () => {
2266
+ signal.removeEventListener("abort", abort);
2267
+ });
2268
+ });
2269
+ return { queryUninstallRoot: async (root, signal) => {
2270
+ const raw = await runTool(root, signal);
2271
+ if (isAsciiOnly(raw)) return raw.toString("utf8");
2272
+ return await runFallback(root, signal);
2273
+ } };
2274
+ }
2275
+ /**
2276
+ * Whether every byte is ASCII, the one case where a decode cannot be wrong.
2277
+ *
2278
+ * @param bytes - the child's raw stdout.
2279
+ */
2280
+ function isAsciiOnly(bytes) {
2281
+ for (const byte of bytes) if (byte >= 128) return false;
2282
+ return true;
2283
+ }
2284
+ /**
2285
+ * Turn the fallback's JSON into the same column-shaped document
2286
+ * {@link parseWindowsRegistryOutput} already consumes.
2287
+ *
2288
+ * Re-encoding into the `reg query` text form on purpose: the parser and
2289
+ * {@link parseWindowsDisplayIcon} carry the candidate-quoting rules that are
2290
+ * shared with existing tests, and the seam between this tool and the provider
2291
+ * is a string. Records carrying neither value are skipped, and the key name is
2292
+ * synthetic because only the values are read.
2293
+ *
2294
+ * @param output - the child's UTF-8 stdout.
2295
+ * @returns the document, or `undefined` when it was not the expected JSON.
2296
+ */
2297
+ function parseWindowsUninstallJson(output) {
2298
+ let document;
2299
+ try {
2300
+ document = JSON.parse(output);
2301
+ } catch {
2302
+ return;
2303
+ }
2304
+ const rows = Array.isArray(document) ? document : [document];
2305
+ const lines = [];
2306
+ for (const [index, row] of rows.entries()) {
2307
+ if (typeof row !== "object" || row === null) continue;
2308
+ const record = row;
2309
+ const displayName = record["displayName"];
2310
+ const displayIcon = record["displayIcon"];
2311
+ if (typeof displayName !== "string" && typeof displayIcon !== "string") continue;
2312
+ lines.push(`HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{PS-${String(index)}}`);
2313
+ if (typeof displayName === "string") lines.push(` DisplayName REG_SZ ${displayName}`);
2314
+ if (typeof displayIcon === "string") lines.push(` DisplayIcon REG_SZ ${displayIcon}`);
2315
+ }
2316
+ return lines.join("\r\n");
1990
2317
  }
1991
2318
  /**
1992
2319
  * In-memory protector-key resolver: one spawn per key id, single-flight, never
@@ -3611,7 +3938,7 @@ var WorkBuddyCatalog = class {
3611
3938
  };
3612
3939
  //#endregion
3613
3940
  //#region src/version.ts
3614
- const WORKBUDDY_CONNECT_VERSION = "0.7.0";
3941
+ const WORKBUDDY_CONNECT_VERSION = "0.7.2";
3615
3942
  //#endregion
3616
3943
  //#region src/host-heartbeat.ts
3617
3944
  /**
@@ -3782,4 +4109,4 @@ function isHeartbeatProcessAlive(heartbeat) {
3782
4109
  return startAtMs <= heartbeat.registeredAt;
3783
4110
  }
3784
4111
  //#endregion
3785
- export { extractDisplayErrorMessage as A, CN_APP_VERSION_FILENAME as B, WORKBUDDY_VARIANTS as C, WORKBUDDY_UPDATE_PATH as D, WORKBUDDY_STATUS_PATH as E, prepareInternationalChatBody as F, resolveChatIdentity as G, chatUserAgent as H, regionOf as I, appUserAgent as J, validCliVersion as K, PROBE_EFFORT_CANDIDATES as L, normalizeCredits as M, parseModelCatalog as N, WorkBuddyUpstreamClient as O, prepareChatBody as P, validAppVersion as Q, probeModel as R, CN_VARIANT as S, WORKBUDDY_PROBE_PATH as T, fallbackChatIdentity as U, FALLBACK_CN_APP_VERSION as V, readCliVersion as W, readBundleVersion as X, installedAppVersion as Y, resolveAppVersion as Z, desktopAuthCandidatesFor as _, readHostHeartbeat as a, atRestKeyProviderFor as b, WORKBUDDY_CONNECT_VERSION as c, WorkBuddyCatalog as d, WORKBUDDY_AUTH_FILENAME as f, defaultDesktopAuthPath as g, defaultDesktopAuthCandidates as h, processStartTimeMs as i, modelWithCurrentPromotion as j, classifyUpstreamError as k, FALLBACK_WORKBUDDY_AI_MODELS as l, WorkBuddyCredentialStore as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, WORKBUDDY_AUTH_FILE_ENV as p, WORKBUDDY_APP_VERSION_FILENAME as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, parseWorkBuddyAuth as v, variantFor as w, AI_VARIANT as x, workbuddyOwnAuthPath as y, randomSentinel as z };
4112
+ export { validAppVersion as $, extractDisplayErrorMessage as A, randomSentinel as B, WORKBUDDY_VARIANTS as C, WORKBUDDY_UPDATE_PATH as D, WORKBUDDY_STATUS_PATH as E, prepareCnChatBody as F, readCliVersion as G, FALLBACK_CN_APP_VERSION as H, prepareInternationalChatBody as I, WORKBUDDY_APP_VERSION_FILENAME as J, resolveChatIdentity as K, regionOf as L, normalizeCredits as M, parseModelCatalog as N, WorkBuddyUpstreamClient as O, prepareChatBody as P, resolveAppVersion as Q, PROBE_EFFORT_CANDIDATES as R, CN_VARIANT as S, WORKBUDDY_PROBE_PATH as T, chatUserAgent as U, CN_APP_VERSION_FILENAME as V, fallbackChatIdentity as W, installedAppVersion as X, appUserAgent as Y, readBundleVersion as Z, desktopAuthCandidatesFor as _, readHostHeartbeat as a, atRestKeyProviderFor as b, WORKBUDDY_CONNECT_VERSION as c, WorkBuddyCatalog as d, WORKBUDDY_AUTH_FILENAME as f, defaultDesktopAuthPath as g, defaultDesktopAuthCandidates as h, processStartTimeMs as i, modelWithCurrentPromotion as j, classifyUpstreamError as k, FALLBACK_WORKBUDDY_AI_MODELS as l, WorkBuddyCredentialStore as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, WORKBUDDY_AUTH_FILE_ENV as p, validCliVersion as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, parseWorkBuddyAuth as v, variantFor as w, AI_VARIANT as x, workbuddyOwnAuthPath as y, probeModel as z };
package/lib/index.d.ts CHANGED
@@ -229,17 +229,22 @@ type ProbeOutcome = {
229
229
  requests: number;
230
230
  reason: string;
231
231
  };
232
+ /** Which endpoint's rejection vocabulary a probe interprets. */
233
+ type ProbeRegion = 'cn' | 'global';
232
234
  /**
233
235
  * Probe one model.
234
236
  *
235
237
  * `options.candidates` exists so tests can shorten the sweep; production always
236
- * uses {@link PROBE_EFFORT_CANDIDATES}.
238
+ * uses {@link PROBE_EFFORT_CANDIDATES}. `options.region` selects which
239
+ * endpoint's rejection vocabulary is read; it defaults to `cn`, which is also
240
+ * the production default for the China app.
237
241
  */
238
242
  declare function probeModel(options: {
239
243
  send: ProbeSender;
240
244
  sentinel?: SentinelFactory;
241
245
  candidates?: readonly WorkBuddyEffort[];
242
246
  timeoutMs?: number;
247
+ region?: ProbeRegion;
243
248
  }): Promise<ProbeOutcome>;
244
249
  //#endregion
245
250
  //#region src/upstream.d.ts
@@ -325,6 +330,49 @@ interface WorkBuddyCreditAccount {
325
330
  remain: number;
326
331
  size: number;
327
332
  unlimited?: true;
333
+ /**
334
+ * When the package's own cycle ends, in epoch milliseconds.
335
+ *
336
+ * The upstream has always sent this (`CycleEndTime`, a zone-less
337
+ * `YYYY-MM-DD HH:mm:ss` string) and the query itself already filters on
338
+ * `PackageEndTimeRange`, but the field used to be dropped here — so a card
339
+ * could show "500 credit" for a package expiring in an hour with nothing to
340
+ * distinguish it from one expiring next month. Absent when the upstream omits
341
+ * or malforms it, which must never read as "no expiry".
342
+ *
343
+ * The zone the upstream means is **not established**: reading the string as
344
+ * the running machine's local time follows the official desktop client (see
345
+ * {@link parseCycleEndTime}), and is not a claim about the account's zone.
346
+ */
347
+ endTimeMs?: number;
348
+ }
349
+ /** Credit whose package expires within a horizon the user should act on. */
350
+ interface WorkBuddyExpiringCredits {
351
+ /** Sum of {@link WorkBuddyCreditAccount.remain} over the window. */
352
+ total: number;
353
+ /** How many packages contribute to {@link total}. */
354
+ packages: number;
355
+ /** The soonest `endTimeMs` among them. */
356
+ earliestEndTimeMs?: number;
357
+ }
358
+ /**
359
+ * Credit at risk, bucketed by how soon it lapses.
360
+ *
361
+ * Thresholds are calendar-ish (24h / 3d / 7d) rather than "this month": a package
362
+ * expiring at 08:00 tomorrow is not helped by a monthly average, and the upstream
363
+ * genuinely staggers expiries per package.
364
+ *
365
+ * Only *future* horizons are reported. An already-lapsed package is deliberately
366
+ * **not** summarised here: the personal billing query this feeds filters
367
+ * `Status: [0, 3]` (`valid` + `usedUp`) from the current time forward, while the
368
+ * upstream marks a lapsed package `Status: 2` (`expired`) — a status this query
369
+ * never returns. Historical expiry would need its own query, which is out of
370
+ * scope.
371
+ */
372
+ interface WorkBuddyCreditExpiry {
373
+ within24h: WorkBuddyExpiringCredits;
374
+ within3d: WorkBuddyExpiringCredits;
375
+ within7d: WorkBuddyExpiringCredits;
328
376
  }
329
377
  /** Aggregated credit answer for one credential. */
330
378
  interface WorkBuddyCredits {
@@ -342,6 +390,13 @@ interface WorkBuddyCredits {
342
390
  */
343
391
  unlimited?: true;
344
392
  cycleResetTime?: string;
393
+ /**
394
+ * Credit lapsing soon, so a card can warn instead of only reporting a total.
395
+ *
396
+ * Absent when no account carried a usable end time — the plugin must not
397
+ * claim "nothing expires soon" on the strength of data it never received.
398
+ */
399
+ expiry?: WorkBuddyCreditExpiry;
345
400
  }
346
401
  /** Token refresh answer; fields the upstream omits stay absent. */
347
402
  interface WorkBuddyRefreshOutcome {
@@ -604,6 +659,22 @@ declare function modelWithCurrentPromotion(model: WorkBuddyUpstreamModel, now?:
604
659
  * JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
605
660
  * so this is safe to run over an already-prepared-or-not body.
606
661
  */
662
+ /**
663
+ * Prepare a body for the **CN** endpoint.
664
+ *
665
+ * Until #87 the CN wire was the caller's body verbatim. The only difference
666
+ * now is the shared {@link dropUnsupportedEffort} strip, whose region scope was
667
+ * lifted once measurements showed this endpoint rejects `off` too.
668
+ *
669
+ * Deliberately **not** routed through {@link prepareChatBody}: the shim already
670
+ * applies it before handing the body to {@link WorkBuddyUpstreamClient.chatStream},
671
+ * and re-running it would be a second `stream`/`developer`/`tool_choice`
672
+ * normalisation of an already-normalised body. Only the strip belongs here.
673
+ *
674
+ * A body that is not a JSON object is returned unchanged, so this is safe to
675
+ * run over an already-prepared-or-not body.
676
+ */
677
+ declare function prepareCnChatBody(source: string): string;
607
678
  declare function prepareInternationalChatBody(source: string): string;
608
679
  //#endregion
609
680
  //#region src/variants.d.ts
@@ -1271,6 +1342,12 @@ declare const WORKBUDDY_STREAM_IDLE_TIMEOUT_MS = 300000;
1271
1342
  interface WorkBuddyAdapterOptions {
1272
1343
  providerId?: string;
1273
1344
  displayName?: string;
1345
+ /**
1346
+ * Which endpoint this adapter serves, from the variant descriptor. Decides
1347
+ * only whether the CN `onlyReasoning` veto hides `off` (#87); defaults to
1348
+ * `'cn'`, the same legacy default `providerId` carries.
1349
+ */
1350
+ region?: WorkBuddyRegion;
1274
1351
  shim: WorkBuddyShim;
1275
1352
  store: WorkBuddyCredentialStore;
1276
1353
  catalog: WorkBuddyCatalog;
@@ -1463,6 +1540,12 @@ interface WorkBuddyProbeServiceOptions {
1463
1540
  */
1464
1541
  account: () => string | undefined;
1465
1542
  sentinel?: SentinelFactory;
1543
+ /**
1544
+ * Which endpoint's rejection vocabulary sweeps read. The two apps talk to
1545
+ * different upstreams that answer a bad effort with different codes, so each
1546
+ * runtime passes its own region instead of sharing one widening set.
1547
+ */
1548
+ region: ProbeRegion;
1466
1549
  /** Injectable for tests; defaults to the live upstream sender. */
1467
1550
  send?: (modelId: string) => ProbeSender;
1468
1551
  }
@@ -1634,4 +1717,4 @@ declare function visibilityAccountOf(credential: Pick<WorkBuddyCredential, 'uid'
1634
1717
  */
1635
1718
  declare function apply(ctx: Context, config: Config): void;
1636
1719
  //#endregion
1637
- export { AI_VARIANT, type AppVersionInfo, CN_APP_VERSION_FILENAME, CN_VARIANT, type ChatIdentity, Config, FALLBACK_CN_APP_VERSION, FALLBACK_WORKBUDDY_AI_MODELS, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, type ProbeAttempt, type ProbeOutcome, type ProbeSender, type ResolveChatIdentityOptions, type UpstreamErrorKind, WORKBUDDY_AI_SETTINGS_NS, WORKBUDDY_APP_VERSION_FILENAME, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CATALOG_FILENAME, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_VARIANTS, WORKBUDDY_VISIBILITY_FILENAME, type WorkBuddyAdapter, type WorkBuddyAppVersionSource, type WorkBuddyAuthStatus, WorkBuddyCatalog, type WorkBuddyCatalogFetch, WorkBuddyCatalogStore, type WorkBuddyChatResult, type WorkBuddyCredential, WorkBuddyCredentialStore, type WorkBuddyCredits, type WorkBuddyEffort, type WorkBuddyHostHeartbeat, type WorkBuddyModelBilling, type WorkBuddyModelInfo, type WorkBuddyModelReasoning, type WorkBuddyProbeRecord, WorkBuddyProbeService, type WorkBuddyProbeStatus, WorkBuddyProbeStore, type WorkBuddyProbeValidation, type WorkBuddyPromotion, type WorkBuddyRefreshOutcome, type WorkBuddyShim, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyVariant, WorkBuddyVisibilityStore, appUserAgent, apply, chatUserAgent, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, desktopAuthCandidatesFor, fallbackChatIdentity, fingerprintModel, inject, installedAppVersion, isHeartbeatProcessAlive, modelWithCurrentPromotion, name, normalizeCredits, parseModelCatalog, parseWorkBuddyAuth, prepareChatBody, prepareInternationalChatBody, probeModel, processStartTimeMs, randomSentinel, readBundleVersion, readCliVersion, readHostHeartbeat, regionOf, resolveAppVersion, resolveChatIdentity, validAppVersion, validCliVersion, variantFor, visibilityAccountOf, workbuddyCatalogPath, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath, workbuddyVisibilityPath };
1720
+ export { AI_VARIANT, type AppVersionInfo, CN_APP_VERSION_FILENAME, CN_VARIANT, type ChatIdentity, Config, FALLBACK_CN_APP_VERSION, FALLBACK_WORKBUDDY_AI_MODELS, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, type ProbeAttempt, type ProbeOutcome, type ProbeSender, type ResolveChatIdentityOptions, type UpstreamErrorKind, WORKBUDDY_AI_SETTINGS_NS, WORKBUDDY_APP_VERSION_FILENAME, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CATALOG_FILENAME, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_VARIANTS, WORKBUDDY_VISIBILITY_FILENAME, type WorkBuddyAdapter, type WorkBuddyAppVersionSource, type WorkBuddyAuthStatus, WorkBuddyCatalog, type WorkBuddyCatalogFetch, WorkBuddyCatalogStore, type WorkBuddyChatResult, type WorkBuddyCredential, WorkBuddyCredentialStore, type WorkBuddyCredits, type WorkBuddyEffort, type WorkBuddyHostHeartbeat, type WorkBuddyModelBilling, type WorkBuddyModelInfo, type WorkBuddyModelReasoning, type WorkBuddyProbeRecord, WorkBuddyProbeService, type WorkBuddyProbeStatus, WorkBuddyProbeStore, type WorkBuddyProbeValidation, type WorkBuddyPromotion, type WorkBuddyRefreshOutcome, type WorkBuddyShim, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyVariant, WorkBuddyVisibilityStore, appUserAgent, apply, chatUserAgent, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, desktopAuthCandidatesFor, fallbackChatIdentity, fingerprintModel, inject, installedAppVersion, isHeartbeatProcessAlive, modelWithCurrentPromotion, name, normalizeCredits, parseModelCatalog, parseWorkBuddyAuth, prepareChatBody, prepareCnChatBody, prepareInternationalChatBody, probeModel, processStartTimeMs, randomSentinel, readBundleVersion, readCliVersion, readHostHeartbeat, regionOf, resolveAppVersion, resolveChatIdentity, validAppVersion, validCliVersion, variantFor, visibilityAccountOf, workbuddyCatalogPath, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath, workbuddyVisibilityPath };