dsh-workbuddy-connect 0.7.1 → 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.
@@ -846,6 +846,95 @@ function envelopeError(status, envelope) {
846
846
  return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
847
847
  }
848
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
+ /**
849
938
  * Upstream HTTP client. One instance serves the whole plugin; requests take
850
939
  * the credential explicitly so token refreshes apply on the next call.
851
940
  *
@@ -887,7 +976,7 @@ var WorkBuddyUpstreamClient = class {
887
976
  ...chatHeaders(credential, userAgent, identity.clientVersion),
888
977
  "Authorization": `Bearer ${credential.accessToken}`
889
978
  },
890
- body: region === "global" ? prepareInternationalChatBody(bodyJson) : bodyJson,
979
+ body: region === "global" ? prepareInternationalChatBody(bodyJson) : prepareCnChatBody(bodyJson),
891
980
  ...signal === void 0 ? {} : { signal }
892
981
  });
893
982
  } catch (error) {
@@ -1090,15 +1179,18 @@ var WorkBuddyUpstreamClient = class {
1090
1179
  else remain = capacityRemain;
1091
1180
  if (remain < 0) remain = 0;
1092
1181
  total += remain;
1182
+ const endTimeMs = parseCycleEndTime(account["CycleEndTime"]);
1093
1183
  accounts.push({
1094
1184
  packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
1095
1185
  remain,
1096
- size: size > 0 ? size : numberField("CapacitySize")
1186
+ size: size > 0 ? size : numberField("CapacitySize"),
1187
+ ...endTimeMs === void 0 ? {} : { endTimeMs }
1097
1188
  });
1098
1189
  }
1099
1190
  return {
1100
1191
  total,
1101
- accounts
1192
+ accounts,
1193
+ ...summariseExpiry(accounts, now.getTime())
1102
1194
  };
1103
1195
  }
1104
1196
  /**
@@ -1435,6 +1527,32 @@ function modelWithCurrentPromotion(model, now = Date.now()) {
1435
1527
  * JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
1436
1528
  * so this is safe to run over an already-prepared-or-not body.
1437
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
+ }
1438
1556
  function prepareInternationalChatBody(source) {
1439
1557
  const prepared = prepareChatBody(source);
1440
1558
  let body;
@@ -1456,25 +1574,34 @@ function prepareInternationalChatBody(source) {
1456
1574
  return JSON.stringify(body);
1457
1575
  }
1458
1576
  /**
1459
- * 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.
1460
1578
  *
1461
1579
  * `thinkingLevelMap.off` is pinned to the literal `'off'` for models that
1462
1580
  * declare `canDisableThinking`, so that the level stays selectable. pi-ai
1463
- * sends that value for any request carrying no explicit level, and the
1464
- * international endpoint rejects it on the GPT family with HTTP 400 `11133` /
1465
- * `extError.param === 'reasoning.effort'` (issue #49). Omission is the only
1466
- * form measured good on every such model; a literal `'none'` is *not* a safe
1467
- * substitute — accepted by the GPT-5.6 family and GLM, rejected by
1468
- * `gpt-6-astra`.
1469
- *
1470
- * Consequences, stated honestly: the international picker still offers Off,
1471
- * but selecting it now means "the field is omitted" — the model's actual
1472
- * behaviour is decided upstream and is *not* guaranteed to disable thinking
1473
- * or to match the catalog's `defaultEffort`. Declared spellings
1474
- * (`low`/`medium`/`high`/`xhigh`/`max`) and an explicit `none` pass through
1475
- * untouched. The CN variant is deliberately unaffected: its endpoint has
1476
- * accepted this spelling in every measurement so far, and keeping its wire
1477
- * 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.
1478
1605
  */
1479
1606
  function dropUnsupportedEffort(obj) {
1480
1607
  if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
@@ -1874,6 +2001,60 @@ const WINDOWS_REGISTRY_ROOTS = [
1874
2001
  ];
1875
2002
  const WINDOWS_REGISTRY_OUTPUT_MAX_BYTES = 1048576;
1876
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(" ");
1877
2058
  /** One discovery subprocess's own limits; see {@link WorkBuddyAtRestKeyProviderOptions}. */
1878
2059
  const WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS = 3e3;
1879
2060
  const MDFIND_MAX_OUTPUT_BYTES = 1048576;
@@ -1968,6 +2149,15 @@ function workBuddyDiscoveryTools(bundleId) {
1968
2149
  function workBuddyWindowsDiscoveryTools() {
1969
2150
  const systemRoot = process.env.SystemRoot?.trim();
1970
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);
1971
2161
  const runTool = (root, signal) => new Promise((resolve, reject) => {
1972
2162
  if (regPath === void 0) {
1973
2163
  reject(new DiscoveryIncompleteError("SystemRoot is not configured"));
@@ -1985,13 +2175,14 @@ function workBuddyWindowsDiscoveryTools() {
1985
2175
  ], {
1986
2176
  maxBuffer: WINDOWS_REGISTRY_OUTPUT_MAX_BYTES,
1987
2177
  timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS,
1988
- windowsHide: true
2178
+ windowsHide: true,
2179
+ encoding: "buffer"
1989
2180
  }, (error, stdout, stderr) => {
1990
2181
  if (settled) return;
1991
2182
  settled = true;
1992
2183
  if (error !== null && error !== void 0) {
1993
- if (error.killed !== true && (error.code === 1 || error.code === "1") && windowsRegistryKeyMissing(stderr)) {
1994
- resolve("");
2184
+ if (error.killed !== true && (error.code === 1 || error.code === "1") && windowsRegistryKeyMissing(stderr.toString("utf8"))) {
2185
+ resolve(Buffer.alloc(0));
1995
2186
  return;
1996
2187
  }
1997
2188
  reject(new DiscoveryIncompleteError(`reg.exe could not complete (${error.killed === true ? "timed out" : String(error.code ?? "unavailable")})`));
@@ -2010,7 +2201,119 @@ function workBuddyWindowsDiscoveryTools() {
2010
2201
  signal.removeEventListener("abort", abort);
2011
2202
  });
2012
2203
  });
2013
- 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");
2014
2317
  }
2015
2318
  /**
2016
2319
  * In-memory protector-key resolver: one spawn per key id, single-flight, never
@@ -3635,7 +3938,7 @@ var WorkBuddyCatalog = class {
3635
3938
  };
3636
3939
  //#endregion
3637
3940
  //#region src/version.ts
3638
- const WORKBUDDY_CONNECT_VERSION = "0.7.1";
3941
+ const WORKBUDDY_CONNECT_VERSION = "0.7.2";
3639
3942
  //#endregion
3640
3943
  //#region src/host-heartbeat.ts
3641
3944
  /**
@@ -3806,4 +4109,4 @@ function isHeartbeatProcessAlive(heartbeat) {
3806
4109
  return startAtMs <= heartbeat.registeredAt;
3807
4110
  }
3808
4111
  //#endregion
3809
- 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
@@ -330,6 +330,49 @@ interface WorkBuddyCreditAccount {
330
330
  remain: number;
331
331
  size: number;
332
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;
333
376
  }
334
377
  /** Aggregated credit answer for one credential. */
335
378
  interface WorkBuddyCredits {
@@ -347,6 +390,13 @@ interface WorkBuddyCredits {
347
390
  */
348
391
  unlimited?: true;
349
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;
350
400
  }
351
401
  /** Token refresh answer; fields the upstream omits stay absent. */
352
402
  interface WorkBuddyRefreshOutcome {
@@ -609,6 +659,22 @@ declare function modelWithCurrentPromotion(model: WorkBuddyUpstreamModel, now?:
609
659
  * JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
610
660
  * so this is safe to run over an already-prepared-or-not body.
611
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;
612
678
  declare function prepareInternationalChatBody(source: string): string;
613
679
  //#endregion
614
680
  //#region src/variants.d.ts
@@ -1276,6 +1342,12 @@ declare const WORKBUDDY_STREAM_IDLE_TIMEOUT_MS = 300000;
1276
1342
  interface WorkBuddyAdapterOptions {
1277
1343
  providerId?: string;
1278
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;
1279
1351
  shim: WorkBuddyShim;
1280
1352
  store: WorkBuddyCredentialStore;
1281
1353
  catalog: WorkBuddyCatalog;
@@ -1645,4 +1717,4 @@ declare function visibilityAccountOf(credential: Pick<WorkBuddyCredential, 'uid'
1645
1717
  */
1646
1718
  declare function apply(ctx: Context, config: Config): void;
1647
1719
  //#endregion
1648
- 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 };