opencode-anthropic-fix 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -3,7 +3,12 @@ import { stdin, stdout } from "node:process";
3
3
  import { randomBytes, randomUUID, createHash as createHashCrypto } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve, basename } from "node:path";
6
- import { AccountManager, RATE_LIMIT_KEY_FAST, warnOnEphemeralTokenRotation } from "./lib/accounts.mjs";
6
+ import {
7
+ AccountManager,
8
+ RATE_LIMIT_KEY_FAST,
9
+ isBetaRetryAccountUsable,
10
+ warnOnEphemeralTokenRotation,
11
+ } from "./lib/accounts.mjs";
7
12
  import {
8
13
  authorize as oauthAuthorize,
9
14
  exchange as oauthExchange,
@@ -37,6 +42,7 @@ import { redactSecrets, redactString } from "./lib/redact.mjs";
37
42
  import {
38
43
  createTransformedSSEStream,
39
44
  resolveStreamIdleTimeoutMs,
45
+ shouldApplyHostCompatShim,
40
46
  stripMcpPrefixFromParsedEvent,
41
47
  } from "./lib/mimicry/response-stream.mjs";
42
48
  import { transformRequestBody, CORE_TOOL_NAMES } from "./lib/mimicry/request-body.mjs";
@@ -78,6 +84,7 @@ import {
78
84
  assertAdapterBodyUsable,
79
85
  buildAdapterTransport,
80
86
  resolveAdapterEnv,
87
+ selectLatchableRejectedBetas,
81
88
  } from "./lib/mimicry/adapter-input.mjs";
82
89
  import { buildPassthroughHeaders, stripNonApiBodyFields } from "./lib/passthrough-headers.mjs";
83
90
  import {
@@ -311,6 +318,21 @@ export async function AnthropicAuthPlugin({ client }) {
311
318
  const SESSION_REJECTED_BETA_TTL_MS = 5 * 60 * 1000; // 5 minutes
312
319
  /** @type {Map<string, number>} canonical-beta to rejected-at epoch ms */
313
320
  const sessionRejectedBetas = new Map();
321
+ // Sent-beta suppression latch, keyed by ACCOUNT. Holds only betas that were on
322
+ // the failing request's header AND named by the API's invalid-beta message
323
+ // (selectLatchableRejectedBetas); they reach the adapter as `rejectedBetas` ->
324
+ // `suppressBetas` for SESSION_REJECTED_BETA_TTL_MS. Unlike
325
+ // `sessionRejectedBetas` (a custom_betas filter only), this one removes
326
+ // package-composed betas, so it must not leak to accounts that never
327
+ // received the rejection. Memory only.
328
+ /** @type {Map<string, Map<string, number>>} account key to (beta to rejected-at epoch ms) */
329
+ const sentBetaSuppressionsByAccount = new Map();
330
+ /**
331
+ * @param {{id?: unknown, index?: unknown} | null | undefined} acct
332
+ * @returns {string}
333
+ */
334
+ const sentBetaLatchKey = (acct) =>
335
+ typeof acct?.id === "string" && acct.id ? acct.id : `index:${String(acct?.index ?? "")}`;
314
336
 
315
337
  // Token economy — session state for layered compaction strategies.
316
338
  const tokenEconomySession = {
@@ -2592,6 +2614,18 @@ export async function AnthropicAuthPlugin({ client }) {
2592
2614
  const requestMethod = String(
2593
2615
  requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST"),
2594
2616
  ).toUpperCase();
2617
+ // The HOST's own User-Agent (not the one sent to Anthropic): it
2618
+ // names the host's @ai-sdk/anthropic version, which decides
2619
+ // whether the L5 host-compat stream shim must run.
2620
+ const hostCompatShim = shouldApplyHostCompatShim(getIncomingHeader(input, requestInit, "user-agent"));
2621
+ /** @param {{blockTypeFallbacks: number, deltasDropped: number, eventsDropped: number, dryRun?: boolean}} shimStats */
2622
+ const onHostCompatShim = (shimStats) =>
2623
+ shimStats.dryRun
2624
+ ? debugLog(
2625
+ "host-compat shim disabled for this host SDK version, but would have rewritten the response stream (dry run) — allowlists may need re-extracting",
2626
+ shimStats,
2627
+ )
2628
+ : debugLog("host-compat shim rewrote the response stream", shimStats);
2595
2629
  let showUsageToast;
2596
2630
  try {
2597
2631
  showUsageToast = new URL(requestUrl).pathname === "/v1/messages" && requestMethod === "POST";
@@ -2699,10 +2733,25 @@ export async function AnthropicAuthPlugin({ client }) {
2699
2733
  let _overloadRecoveryAttempted = false; // Guard: only one quota-aware switch per request
2700
2734
  let _connectionResetRetries = 0; // Cap ECONNRESET/EPIPE retries to prevent infinite loop
2701
2735
  let customBetasStripped = false; // One-shot latch: strip config.custom_betas once per logical request
2736
+ let sentBetaRejectionRetried = false; // One-shot latch: retry once after latching a rejected SENT beta
2737
+ /** @type {{key: string, betas: string[], recordedAt: number} | null} */
2738
+ let pendingSentBetaLatch = null; // Entries latched for the retry in flight; dropped if it fails the same way
2739
+ // One-shot: the account that just received a beta rejection. The
2740
+ // beta-rejection retry (`attempt--; continue`) must land on it: the
2741
+ // sent-beta latch is keyed per account, so a rotating strategy
2742
+ // (round-robin, hybrid) moving the retry to another account would
2743
+ // resend the rejected beta and burn the single retry.
2744
+ let betaRetryAccount = null;
2702
2745
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2703
- // Select account — use pinned account on first attempt if available
2704
- const account =
2705
- attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
2746
+ // Select account — a beta-rejection retry stays on the rejecting
2747
+ // account (QA fix L5: only if it's still enabled and still
2748
+ // tracked by the account manager — see isBetaRetryAccountUsable);
2749
+ // otherwise use the pinned account on the first attempt.
2750
+ const _betaRetryAccount = betaRetryAccount;
2751
+ betaRetryAccount = null;
2752
+ const account = isBetaRetryAccountUsable(_betaRetryAccount, accountManager, transientRefreshSkips)
2753
+ ? _betaRetryAccount
2754
+ : attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
2706
2755
  ? pinnedAccount
2707
2756
  : accountManager.getCurrentAccount(transientRefreshSkips);
2708
2757
 
@@ -2937,6 +2986,29 @@ export async function AnthropicAuthPlugin({ client }) {
2937
2986
  }
2938
2987
  return false;
2939
2988
  });
2989
+ // Evicting a rejected beta from `customBetas` is not enough on the
2990
+ // adapter path: the package composes some betas on its own (e.g.
2991
+ // `cache-diagnosis-2026-04-07` under 2.1.280), and only
2992
+ // `suppressBetas` (its terminal filter) can take those off the
2993
+ // wire. But suppression also removes the package's OWN copy of a
2994
+ // beta (e.g. context-1m under adaptive 1M), so it is reserved for
2995
+ // betas the API explicitly named as invalid for THIS account: the
2996
+ // unexpired entries of `sentBetaSuppressionsByAccount`. The
2997
+ // `customBetasStripped` one-shot and `sessionRejectedBetas` only
2998
+ // filter `customBetas` above, exactly as before 2.1.280. The
2999
+ // adapter never suppresses UNSUPPRESSIBLE_BETAS or
3000
+ // BODY_COUPLED_BETAS from this list.
3001
+ const _sessionRejectedBetaNames = new Set();
3002
+ const _accountSentBetaLatch = sentBetaSuppressionsByAccount.get(sentBetaLatchKey(account));
3003
+ if (_accountSentBetaLatch) {
3004
+ for (const [beta, rejectedAt] of _accountSentBetaLatch) {
3005
+ if (Date.now() - rejectedAt > SESSION_REJECTED_BETA_TTL_MS) {
3006
+ _accountSentBetaLatch.delete(beta);
3007
+ } else {
3008
+ _sessionRejectedBetaNames.add(beta);
3009
+ }
3010
+ }
3011
+ }
2940
3012
  // PHASE 2.2.3 — THE BETA LATCH IS GONE. It used to merge every
2941
3013
  // beta ever sent this session back into this value, to avoid
2942
3014
  // server-side cache-key churn from a beta flipping mid-session.
@@ -3155,6 +3227,7 @@ export async function AnthropicAuthPlugin({ client }) {
3155
3227
  enabled: getSignatureEmulationEnabled(),
3156
3228
  claudeCliVersion,
3157
3229
  customBetas: _sessionFilteredCustomBetas,
3230
+ rejectedBetas: [..._sessionRejectedBetaNames],
3158
3231
  strategy: getEffectiveStrategy(),
3159
3232
  sessionId: signatureSessionId,
3160
3233
  };
@@ -3705,43 +3778,121 @@ export async function AnthropicAuthPlugin({ client }) {
3705
3778
  // context_window, long context, 1m/million context) to avoid false positives
3706
3779
  // on generic 413s (e.g. plain upload-size limits with an empty body).
3707
3780
  // One retry per logical request (latch prevents loop).
3708
- if (
3709
- !customBetasStripped &&
3710
- (config.custom_betas?.length ?? 0) > 0 &&
3711
- ((response.status === 400 &&
3781
+ const _betaRejectionSignal =
3782
+ (response.status === 400 &&
3712
3783
  errorBody &&
3713
3784
  errorBody.includes("anthropic-beta") &&
3714
3785
  !errorBody.includes("context-hint")) ||
3715
- (response.status === 413 &&
3716
- errorBody &&
3717
- /anthropic-beta|beta header|unsupported beta|unknown beta|invalid beta|context_window|long context|1m context|million context/i.test(
3718
- errorBody,
3719
- )))
3720
- ) {
3786
+ (response.status === 413 &&
3787
+ errorBody &&
3788
+ /anthropic-beta|beta header|unsupported beta|unknown beta|invalid beta|context_window|long context|1m context|million context/i.test(
3789
+ errorBody,
3790
+ ));
3791
+ // The retry that followed a sent-beta latch failed the same way on
3792
+ // the SAME account (so the suppression was actually applied): it
3793
+ // did not help, so drop the entries just latched instead of
3794
+ // carrying a useless 5-minute suppression.
3795
+ const _latchUnderTest = pendingSentBetaLatch;
3796
+ pendingSentBetaLatch = null;
3797
+ if (_betaRejectionSignal && _latchUnderTest && _latchUnderTest.key === sentBetaLatchKey(account)) {
3798
+ const _latched = sentBetaSuppressionsByAccount.get(_latchUnderTest.key);
3799
+ for (const _beta of _latchUnderTest.betas) {
3800
+ if (_latched?.get(_beta) === _latchUnderTest.recordedAt) _latched.delete(_beta);
3801
+ }
3802
+ debugLog("sent beta latch did not clear the rejection - dropped", _latchUnderTest.betas);
3803
+ }
3804
+ // Betas the API's invalid-beta message ("Unexpected value(s) `X`
3805
+ // for the `anthropic-beta` header") names that were actually on
3806
+ // the failing request's header. Under 2.1.280 the package
3807
+ // composes some betas by default (e.g. cache-diagnosis), so a
3808
+ // rejection can name a beta the user never configured; only the
3809
+ // adapter path can suppress those (via `rejectedBetas` ->
3810
+ // `suppressBetas`). `_sentLatchableBetas` excludes OAuth / Claude
3811
+ // Code (UNSUPPRESSIBLE_BETAS) and BODY_COUPLED_BETAS, and both
3812
+ // lists are empty when the error echoes the header (too many
3813
+ // matches). count_tokens is excluded: its input has no
3814
+ // suppressBetas, so a latch would not change its retry and would
3815
+ // only degrade the following /v1/messages requests.
3816
+ let _namedSentBetas = [];
3817
+ let _sentLatchableBetas = [];
3818
+ if (_betaRejectionSignal && _useAdapter && !_isCountTokens) {
3819
+ const _sentHeader =
3820
+ typeof requestHeaders?.get === "function"
3821
+ ? requestHeaders.get("anthropic-beta")
3822
+ : requestHeaders?.["anthropic-beta"];
3823
+ ({ namedSent: _namedSentBetas, latchable: _sentLatchableBetas } = selectLatchableRejectedBetas(
3824
+ _sentHeader,
3825
+ errorBody,
3826
+ ));
3827
+ }
3828
+ /**
3829
+ * Latch `_sentLatchableBetas` for the account that received the
3830
+ * rejection, and remember them so a failed retry can undo it.
3831
+ * @param {number} recordedAt
3832
+ */
3833
+ const _latchSentBetas = (recordedAt) => {
3834
+ const _key = sentBetaLatchKey(account);
3835
+ let _latched = sentBetaSuppressionsByAccount.get(_key);
3836
+ if (!_latched) {
3837
+ _latched = new Map();
3838
+ sentBetaSuppressionsByAccount.set(_key, _latched);
3839
+ }
3840
+ for (const _beta of _sentLatchableBetas) _latched.set(_beta, recordedAt);
3841
+ pendingSentBetaLatch = { key: _key, betas: [..._sentLatchableBetas], recordedAt };
3842
+ sentBetaRejectionRetried = true;
3843
+ };
3844
+ if (!customBetasStripped && (config.custom_betas?.length ?? 0) > 0 && _betaRejectionSignal) {
3721
3845
  customBetasStripped = true;
3722
3846
  // F4: record rejection in session latch so next logical request already
3723
- // omits the rejected beta without needing a first-fail.
3724
- // Only record betas explicitly mentioned in the error body (raw or canonical);
3725
- // fall back to recording all custom betas if the body names none specifically.
3847
+ // omits the rejected beta from `customBetas` without needing a first-fail.
3848
+ // When the invalid-beta message names betas that were sent, record only
3849
+ // the custom betas among them. Otherwise (no parsable names) fall back to
3850
+ // custom betas mentioned in the body, then to all custom betas. This latch
3851
+ // only filters `customBetas`; it never suppresses a package-composed beta.
3726
3852
  {
3727
3853
  const _allCustom = config.custom_betas ?? [];
3728
- const _mentioned = _allCustom.filter((_sb) => {
3729
- const _rawLc = _sb.toLowerCase();
3730
- const _canLc = resolveBetaShortcut(_sb).toLowerCase();
3854
+ let _toRecord;
3855
+ if (_namedSentBetas.length > 0) {
3856
+ const _namedLc = new Set(_namedSentBetas.map((_b) => _b.toLowerCase()));
3857
+ _toRecord = _allCustom.filter((_sb) => _namedLc.has(resolveBetaShortcut(_sb).toLowerCase()));
3858
+ } else {
3731
3859
  const _bodyLc = (errorBody || "").toLowerCase();
3732
- return _bodyLc.includes(_rawLc) || _bodyLc.includes(_canLc);
3733
- });
3734
- const _toRecord = _mentioned.length > 0 ? _mentioned : _allCustom;
3860
+ const _mentioned = _allCustom.filter((_sb) => {
3861
+ const _rawLc = _sb.toLowerCase();
3862
+ const _canLc = resolveBetaShortcut(_sb).toLowerCase();
3863
+ return _bodyLc.includes(_rawLc) || _bodyLc.includes(_canLc);
3864
+ });
3865
+ _toRecord = _mentioned.length > 0 ? _mentioned : _allCustom;
3866
+ }
3735
3867
  const _recordedAt = Date.now();
3736
3868
  for (const _sb of _toRecord) {
3737
3869
  sessionRejectedBetas.set(resolveBetaShortcut(_sb), _recordedAt);
3738
3870
  }
3871
+ // Same single retry also suppresses any named SENT beta, so it does
3872
+ // not take a second retry to clear a package-composed one.
3873
+ if (_sentLatchableBetas.length > 0) _latchSentBetas(_recordedAt);
3739
3874
  }
3740
3875
  attempt--;
3876
+ betaRetryAccount = account;
3741
3877
  debugLog("custom beta/context rejection - retrying without custom betas");
3742
3878
  continue;
3743
3879
  }
3744
3880
 
3881
+ // Sent-beta rejection retry: the error names a beta that was on the
3882
+ // wire but is not (only) a custom beta, e.g. the package-default
3883
+ // cache-diagnosis with empty custom_betas. Latch it for this account
3884
+ // (TTL'd, as above) and retry once; `sentBetaRejectionRetried`
3885
+ // prevents a loop. Like the custom-beta path, this runs before any
3886
+ // account-failure bookkeeping and on a non-ok response, so no stream
3887
+ // was delivered.
3888
+ if (!sentBetaRejectionRetried && _sentLatchableBetas.length > 0) {
3889
+ _latchSentBetas(Date.now());
3890
+ attempt--;
3891
+ betaRetryAccount = account;
3892
+ debugLog("sent beta rejection - retrying without", _sentLatchableBetas);
3893
+ continue;
3894
+ }
3895
+
3745
3896
  // Reactive compaction: on "prompt too long" error, trim oldest messages and retry once
3746
3897
  if (
3747
3898
  response.status === 400 &&
@@ -3909,7 +4060,10 @@ export async function AnthropicAuthPlugin({ client }) {
3909
4060
  status: response.status,
3910
4061
  errorBody: typeof errorBody === "string" ? errorBody.slice(0, 600) : errorBody,
3911
4062
  });
3912
- return transformResponse(response, undefined, undefined, correlationId);
4063
+ return transformResponse(response, undefined, undefined, correlationId, {
4064
+ hostCompatShim,
4065
+ onHostCompatShim,
4066
+ });
3913
4067
  }
3914
4068
 
3915
4069
  const accountSpecific = isAccountSpecificError(response.status, errorBody);
@@ -4135,7 +4289,10 @@ export async function AnthropicAuthPlugin({ client }) {
4135
4289
  status: response.status,
4136
4290
  });
4137
4291
  }
4138
- return transformResponse(response, undefined, undefined, correlationId);
4292
+ return transformResponse(response, undefined, undefined, correlationId, {
4293
+ hostCompatShim,
4294
+ onHostCompatShim,
4295
+ });
4139
4296
  }
4140
4297
 
4141
4298
  // Success
@@ -4331,6 +4488,7 @@ export async function AnthropicAuthPlugin({ client }) {
4331
4488
  : null,
4332
4489
  accountErrorCallback,
4333
4490
  correlationId,
4491
+ { hostCompatShim, onHostCompatShim },
4334
4492
  );
4335
4493
  }
4336
4494
 
@@ -6086,15 +6244,27 @@ function transformRequestUrl(input, emulateSignature = true) {
6086
6244
  * @param {((details: {reason: import('./lib/backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [onAccountError]
6087
6245
  * Called if a mid-stream error looks account-specific
6088
6246
  * @param {string} [correlationId]
6247
+ * @param {object} [hostCompat] - L5 host-compat shim wiring.
6248
+ * @param {boolean} [hostCompat.hostCompatShim] - Run the shim (see
6249
+ * `shouldApplyHostCompatShim`); defaults to on.
6250
+ * @param {((stats: {blockTypeFallbacks: number, deltasDropped: number, eventsDropped: number, dryRun?: boolean}) => void) | null} [hostCompat.onHostCompatShim]
6089
6251
  * @returns {Response}
6090
6252
  */
6091
- function transformResponse(response, onUsage, onAccountError, correlationId) {
6253
+ function transformResponse(
6254
+ response,
6255
+ onUsage,
6256
+ onAccountError,
6257
+ correlationId,
6258
+ { hostCompatShim = true, onHostCompatShim = null } = {},
6259
+ ) {
6092
6260
  if (!response.body) return response;
6093
6261
  const idleTimeoutMs = resolveStreamIdleTimeoutMs(_pluginConfig);
6094
6262
  const captureEnabled = _pluginConfig?.token_economy?.debug_dump_bodies === true && Boolean(correlationId);
6095
6263
  const stream = createTransformedSSEStream(response, {
6096
6264
  onUsage,
6097
6265
  onAccountError,
6266
+ hostCompatShim,
6267
+ onHostCompatShim,
6098
6268
  correlationId,
6099
6269
  idleTimeoutMs,
6100
6270
  captureEnabled,
@@ -6103,6 +6273,13 @@ function transformResponse(response, onUsage, onAccountError, correlationId) {
6103
6273
 
6104
6274
  // Inject cache transparency headers (session-level, available before stream completes).
6105
6275
  const responseHeaders = new Headers(response.headers);
6276
+ // QA fix L3: undici already decoded `response.body` (e.g. gzip was
6277
+ // transparently inflated) before it ever reached createTransformedSSEStream,
6278
+ // so a Content-Length/Content-Encoding copied from the upstream response
6279
+ // describes the ORIGINAL (possibly compressed) wire bytes, not the stream
6280
+ // this Response actually serves — forwarding either would lie to the host.
6281
+ responseHeaders.delete("content-length");
6282
+ responseHeaders.delete("content-encoding");
6106
6283
  responseHeaders.set("x-opencode-cache-hit-rate", String(Math.round(getAverageCacheHitRate() * 1000) / 1000));
6107
6284
  responseHeaders.set("x-opencode-cache-read-total", String(sessionMetrics.totalCacheRead));
6108
6285
  responseHeaders.set("x-opencode-session-cost", String(Math.round(sessionMetrics.sessionCostUsd * 10000) / 10000));
package/lib/accounts.mjs CHANGED
@@ -1193,3 +1193,33 @@ export class AccountManager {
1193
1193
  };
1194
1194
  }
1195
1195
  }
1196
+
1197
+ /**
1198
+ * Whether a one-shot beta-rejection retry pin (`betaRetryAccount` in the
1199
+ * fetch interceptor's retry loop) should still be honored for the retry
1200
+ * attempt it was set for (QA fix L5). Extracted from the loop closure so it
1201
+ * can be unit-tested without bootstrapping the full plugin.
1202
+ *
1203
+ * The pin is a live object reference captured when the *previous* attempt's
1204
+ * response named a rejected beta; by the time the retry lands, that same
1205
+ * account could have been disabled (CLI toggle, a terminal token-refresh
1206
+ * failure) or removed outright (account manager reload/edit) — either way it
1207
+ * must not be force-selected, or the retry could land on an account the rest
1208
+ * of the plugin no longer considers usable. `enabled` is read straight off
1209
+ * `betaRetryAccount` (accounts are mutated in place, so a live reference
1210
+ * reflects the current state), and "present in the account manager" is
1211
+ * checked by identity — not just index — since `removeAccount` re-indexes
1212
+ * the accounts that remain, so a stale index could otherwise coincidentally
1213
+ * match a different, unrelated account.
1214
+ *
1215
+ * @param {{index: number, enabled: boolean} | null} betaRetryAccount
1216
+ * @param {AccountManager} accountManager
1217
+ * @param {Set<number>} transientRefreshSkips - Indices excluded for the rest of this request.
1218
+ * @returns {boolean}
1219
+ */
1220
+ export function isBetaRetryAccountUsable(betaRetryAccount, accountManager, transientRefreshSkips) {
1221
+ if (!betaRetryAccount) return false;
1222
+ if (transientRefreshSkips.has(betaRetryAccount.index)) return false;
1223
+ if (!betaRetryAccount.enabled) return false;
1224
+ return accountManager.getEnabledAccounts().includes(betaRetryAccount);
1225
+ }
package/lib/betas.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * ADDS or SUPPRESSES on top of them.
14
14
  *
15
15
  * RECONCILIATION IS ENFORCED BY TEST, not by convention. Every literal header
16
- * below is checked against the package's `BETA_REGISTRY_2_1_233` — the genuine
16
+ * below is checked against the package's `BETA_REGISTRY_2_1_280` — the genuine
17
17
  * client's own registry — by `lib/betas.test.mjs`, which fails on a stale date
18
18
  * stamp and requires the host-only entries (the ones upstream has no registry
19
19
  * slot for) to be enumerated and justified rather than merely accumulated. Add
package/lib/diagnose.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { getConfigDir, loadConfig } from "./config.mjs";
7
7
  import { redactSecrets } from "./redact.mjs";
8
- import { WIRE_PROFILE } from "./mimicry/wire-compat.mjs";
8
+ import { getWireCompatPackageVersion, WIRE_PROFILE } from "./mimicry/wire-compat.mjs";
9
9
  import { loadAccounts } from "./storage.mjs";
10
10
 
11
11
  const ENV_KEYS = [
@@ -175,6 +175,13 @@ export async function buildDiagnosticBundle() {
175
175
  // Static baseline on purpose: the CLI version the wire package's profile
176
176
  // declares, NOT the (possibly npm-updated) version of the running session.
177
177
  mimicryBaseline: WIRE_PROFILE.cliVersion,
178
+ // The full profile id (e.g. "claude-code-2.1.280-sdk-0.112.1"), same
179
+ // baseline caveat as mimicryBaseline above.
180
+ mimicryProfileId: WIRE_PROFILE.id,
181
+ // What's ACTUALLY loaded in node_modules right now -- see
182
+ // getWireCompatPackageVersion's own doc comment for why this can differ
183
+ // from what a future `npm ci` would install.
184
+ wireCompatPackageVersion: getWireCompatPackageVersion(),
178
185
  },
179
186
  config: redactSecrets(loadConfig()),
180
187
  env: getRuntimeEnv(),