@eddyskywalker/dsh-chatgpt-subscription 0.13.2 → 0.15.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +76 -8
  3. package/lib/client.js +183 -115
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +1110 -76
  6. package/lib/types/client/CodexSubscriptionSection.d.ts +1 -1
  7. package/lib/types/client/CodexSubscriptionSection.d.ts.map +1 -1
  8. package/lib/types/client/ProviderHubSection.d.ts.map +1 -1
  9. package/lib/types/client/api.d.ts +12 -0
  10. package/lib/types/client/api.d.ts.map +1 -1
  11. package/lib/types/client/claude/ClaudeSection.d.ts.map +1 -1
  12. package/lib/types/client/claude/locales.d.ts +2 -11
  13. package/lib/types/client/claude/locales.d.ts.map +1 -1
  14. package/lib/types/client/locales.d.ts +13 -1
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/host/claude/model-catalog.d.ts +42 -20
  17. package/lib/types/host/claude/model-catalog.d.ts.map +1 -1
  18. package/lib/types/host/claude/types.d.ts +7 -2
  19. package/lib/types/host/claude/types.d.ts.map +1 -1
  20. package/lib/types/host/codex-account-pool.d.ts +129 -1
  21. package/lib/types/host/codex-account-pool.d.ts.map +1 -1
  22. package/lib/types/host/codex-adopt.d.ts +255 -0
  23. package/lib/types/host/codex-adopt.d.ts.map +1 -0
  24. package/lib/types/host/reasoning-collapse-guard/index.d.ts +166 -0
  25. package/lib/types/host/reasoning-collapse-guard/index.d.ts.map +1 -1
  26. package/lib/types/host/routes.d.ts +11 -1
  27. package/lib/types/host/routes.d.ts.map +1 -1
  28. package/lib/types/host/token-store.d.ts +26 -1
  29. package/lib/types/host/token-store.d.ts.map +1 -1
  30. package/lib/types/host/workbuddy/client.d.ts +1 -1
  31. package/lib/types/host/workbuddy/client.d.ts.map +1 -1
  32. package/lib/types/host/workbuddy/model-catalog.d.ts +77 -4
  33. package/lib/types/host/workbuddy/model-catalog.d.ts.map +1 -1
  34. package/lib/types/host/workbuddy/routes.d.ts +7 -1
  35. package/lib/types/host/workbuddy/routes.d.ts.map +1 -1
  36. package/lib/types/index.d.ts.map +1 -1
  37. package/lib/types/shared/contracts.d.ts +22 -0
  38. package/lib/types/shared/contracts.d.ts.map +1 -1
  39. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -993,7 +993,7 @@ function parseStoredCredentials(value) {
993
993
  if (typeof candidate !== "string") throw new Error(`${key} is invalid`);
994
994
  return candidate;
995
995
  };
996
- return {
996
+ const credentials = {
997
997
  accessToken: record.accessToken,
998
998
  refreshToken: record.refreshToken,
999
999
  expiresAt: record.expiresAt,
@@ -1002,6 +1002,16 @@ function parseStoredCredentials(value) {
1002
1002
  email: optional("email"),
1003
1003
  planType: optional("planType")
1004
1004
  };
1005
+ if (record.adopted === true) {
1006
+ const source = optional("source");
1007
+ if (source !== void 0) {
1008
+ credentials.adopted = true;
1009
+ credentials.source = source;
1010
+ const sourcePath = optional("sourcePath");
1011
+ if (sourcePath !== void 0) credentials.sourcePath = sourcePath;
1012
+ }
1013
+ }
1014
+ return credentials;
1005
1015
  }
1006
1016
  //#endregion
1007
1017
  //#region src/host/token-store-windows.ts
@@ -2895,7 +2905,433 @@ function poolQuota(fetchedAt, windows) {
2895
2905
  };
2896
2906
  }
2897
2907
  //#endregion
2908
+ //#region src/host/codex-adopt.ts
2909
+ /**
2910
+ * OPT-IN adoption of a pre-existing Codex CLI sign-in.
2911
+ *
2912
+ * WHAT THIS IS FOR. A user who already runs the unmodified Codex CLI is already
2913
+ * signed in to the very subscription this provider serves. Asking them to sign in
2914
+ * a second time is pure friction, so this module offers the one thing that
2915
+ * removes it: reading back the credential the CLI itself stored in
2916
+ * `$CODEX_HOME/auth.json` (`~/.codex/auth.json` when the variable is unset). It
2917
+ * is opt-in — nothing here runs until the user asks for it.
2918
+ *
2919
+ * ---------------------------------------------------------------------------
2920
+ * RULE 1 — THIS MODULE NEVER WRITES. IT IS A READER, AND ONLY A READER.
2921
+ * ---------------------------------------------------------------------------
2922
+ * It must not create, modify, move or delete any file belonging to the Codex CLI.
2923
+ * No file this module touches is ever opened for writing, no directory is
2924
+ * created, nothing is renamed and nothing is unlinked. The guarantee is
2925
+ * structural rather than a promise in prose: the module imports exactly two
2926
+ * functions BY NAME from 'node:fs/promises' — readFile and stat — instead of the
2927
+ * whole namespace, so introducing a write would require a visible change to the
2928
+ * import list, which is the line a reviewer reads first. Nothing exported here
2929
+ * writes. The tests assert the stronger, observable form of the rule: a
2930
+ * credential file's bytes, size and mtime are identical before and after both
2931
+ * entry points run, and its directory gains no entries.
2932
+ *
2933
+ * ---------------------------------------------------------------------------
2934
+ * RULE 2 — PRESENCE AND CONTENT ARE TWO SEPARATE FUNCTIONS
2935
+ * ---------------------------------------------------------------------------
2936
+ * codexCliCredentialPresence() is cheap enough to call on plugin startup and
2937
+ * answers only yes/no: it stats the candidate and never opens it for reading. It
2938
+ * exists so the settings card can offer the import before the user has agreed to
2939
+ * it, and so a user who never opts in never has their token read at all.
2940
+ *
2941
+ * readCodexCliCredentials() is the full read, and it is called only AFTER the
2942
+ * user explicitly opts in. Keeping the two apart is the whole point: a startup
2943
+ * path that slurped the file "just in case" would make the opt-in decorative.
2944
+ *
2945
+ * ---------------------------------------------------------------------------
2946
+ * RULE 3 — AN ADOPTED CREDENTIAL IS A SNAPSHOT, AND THIS PLUGIN NEVER REFRESHES IT
2947
+ * ---------------------------------------------------------------------------
2948
+ * An adopted credential is used only while its own accessToken is still valid.
2949
+ * Once it is expired the pool reports it as expired and tells the user to sign in
2950
+ * again in the Codex CLI and re-adopt. That is deliberate, not a gap: the
2951
+ * ChatGPT refresh token the CLI stores ROTATES, and the CLI refreshes its own
2952
+ * file in place. Two processes refreshing the same grant therefore invalidate
2953
+ * each other — whoever loses the race holds a token the server has already
2954
+ * retired, and the user ends up signed OUT of the Codex CLI by this plugin's good
2955
+ * intentions. An in-process single-flight cannot fix that, because the race is
2956
+ * across PROCESSES: this plugin and the CLI are separate programs with separate
2957
+ * memories, and the only shared state between them is the file — which this
2958
+ * module must not write back into (Rule 1).
2959
+ *
2960
+ * So the honest contract is: borrow the access token for as long as the CLI itself
2961
+ * vouched for it, and never touch the refresh token. Expiry is therefore part of
2962
+ * the adoption decision rather than a detail — see
2963
+ * {@link isAdoptedCodexCredentialExpired}.
2964
+ *
2965
+ * The marker the pool chunk must honour is the literal 'adopted: true' plus
2966
+ * {@link isAdoptedCodexCredential}; an account carrying it must be treated as
2967
+ * read-only, non-refreshable and disposable. There is deliberately no refresh
2968
+ * function in this module to find in the first place.
2969
+ *
2970
+ * ---------------------------------------------------------------------------
2971
+ * RULE 4 — AN UNRECOGNISED SHAPE YIELDS undefined, NEVER AN EXCEPTION
2972
+ * ---------------------------------------------------------------------------
2973
+ * The card shows 'unrecognised local sign-in format' for undefined, which is a far
2974
+ * better outcome than a thrown error escaping into the settings route. Nothing is
2975
+ * guessed: a document in another auth_mode, a missing or non-object tokens
2976
+ * block, a missing access or refresh token, an access token that is not a JWT
2977
+ * with a usable 'exp', an unreadable or unparsable file — all of them are
2978
+ * undefined. Extra and unknown fields are ignored rather than rejected, so a
2979
+ * future CLI that adds one does not break adoption.
2980
+ *
2981
+ * THE TRAP THIS PARSER EXISTS TO AVOID. A Codex auth.json is NOT a flat bag of
2982
+ * tokens. It is a document that may carry an API key alongside the OAuth tokens,
2983
+ * and a "find the first accessToken anywhere in the JSON" scan would pick
2984
+ * whichever one the serializer happened to emit first — which is how an API key
2985
+ * or an unrelated token ends up presented to the Responses API as the user's
2986
+ * subscription credential. The read is therefore a direct, specific lookup of
2987
+ * tokens.access_token and its siblings — never a tree walk — and the test suite
2988
+ * holds a fixture carrying an unrelated token beside the real one and asserts
2989
+ * which comes back.
2990
+ *
2991
+ * ---------------------------------------------------------------------------
2992
+ * THE CLAIMS ARE DERIVED LOCALLY, NEVER ASKED OF ANYBODY
2993
+ * ---------------------------------------------------------------------------
2994
+ * expiresAt comes from the access token's own 'exp' claim, email and planType
2995
+ * from the id token's claims. The JWT payload is decoded in process; no token is
2996
+ * ever sent anywhere to be inspected, and no token value is logged or returned
2997
+ * from anything but the credential object the caller asked for.
2998
+ *
2999
+ * WHY EXPIRY IS REFUSED RATHER THAN DEFAULTED. A snapshot whose validity cannot
3000
+ * be established must not be used. Rather than stamping "now" or "never" and
3001
+ * hoping, this module yields undefined: the card then says the local sign-in
3002
+ * format is unrecognised, which is true, instead of importing a credential that
3003
+ * would 401 on the first request.
3004
+ */
3005
+ /**
3006
+ * Environment variable that relocates the Codex CLI's home directory.
3007
+ *
3008
+ * Highest precedence, and honoured exactly as the CLI honours it: when it is set
3009
+ * to a non-blank value it names the home that holds auth.json, whatever the
3010
+ * platform's conventional location happens to be. This is also what makes the
3011
+ * whole module testable without touching a developer's real profile.
3012
+ */
3013
+ const CODEX_HOME_ENV = "CODEX_HOME";
3014
+ /** File name of the credential document inside the Codex home. */
3015
+ const CODEX_CLI_CREDENTIAL_FILE = "auth.json";
3016
+ /**
3017
+ * Top-level key holding the OAuth tokens.
3018
+ *
3019
+ * Named as a constant because the specificity of this key IS the defence
3020
+ * described in the module comment: the read addresses this key, not the first
3021
+ * token it can find anywhere in the document.
3022
+ */
3023
+ const CODEX_CLI_TOKENS_KEY = "tokens";
3024
+ /** Where an adopted snapshot came from, for the card and for the pool record. */
3025
+ const CODEX_CLI_CREDENTIAL_SOURCE = "codex";
3026
+ /** Namespace the id token carries the ChatGPT account claims under. */
3027
+ const CODEX_AUTH_CLAIMS_KEY = "https://api.openai.com/auth";
3028
+ /**
3029
+ * What the user is told when an adopted credential has aged out.
3030
+ *
3031
+ * Exported so the pool report and the settings card say the same sentence, and
3032
+ * so the instruction is stated once: the re-sign-in happens in the CODEX CLI, not
3033
+ * here, and the import has to be repeated afterwards.
3034
+ */
3035
+ const ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT = "The sign-in imported from the Codex CLI has expired. Sign in again in the Codex CLI, then import it again from this settings card.";
3036
+ function isRecord$31(value) {
3037
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3038
+ }
3039
+ /** A trimmed, non-empty string, or nothing. */
3040
+ function nonEmptyString$5(value) {
3041
+ if (typeof value !== "string") return void 0;
3042
+ const trimmed = value.trim();
3043
+ return trimmed === "" ? void 0 : trimmed;
3044
+ }
3045
+ /**
3046
+ * The Codex CLI's home directory.
3047
+ *
3048
+ * The environment override wins whenever it is set to a non-blank value — a blank
3049
+ * or whitespace-only variable is treated as unset rather than as "the directory
3050
+ * whose name is empty", which on Windows would resolve to a drive-relative path.
3051
+ * Otherwise the conventional home/.codex directory is used, os.homedir() being
3052
+ * the same %USERPROFILE% on Windows and ~ on POSIX that the CLI itself uses.
3053
+ *
3054
+ * Read at CALL time rather than captured in a module constant: a test (or an
3055
+ * embedder) that sets the variable after import must see the change.
3056
+ */
3057
+ function codexHomeDir() {
3058
+ const override = process.env[CODEX_HOME_ENV]?.trim();
3059
+ if (override) return override;
3060
+ return path.join(os.homedir(), ".codex");
3061
+ }
3062
+ /**
3063
+ * Every path this module will look at, most authoritative first.
3064
+ *
3065
+ * A single candidate, which is a judgement call stated plainly. The Claude reader
3066
+ * in this package also falls back to the conventional home when the environment
3067
+ * override points somewhere else; here the override IS the user's own statement of
3068
+ * where this CLI keeps its sign-in, and honouring a stale scratch directory is
3069
+ * more dangerous than reporting nothing: a snapshot belonging to a profile the CLI
3070
+ * is not currently using would be adopted silently. The path actually consulted is
3071
+ * returned and printed by the caller, so "not found" is always accompanied by the
3072
+ * exact place that was looked at.
3073
+ *
3074
+ * A pure function — no filesystem access at all — so it is safe to call just to
3075
+ * render the card, and so its precedence is testable without touching a disk.
3076
+ */
3077
+ function codexCliCredentialPaths() {
3078
+ return [path.join(codexHomeDir(), CODEX_CLI_CREDENTIAL_FILE)];
3079
+ }
3080
+ /**
3081
+ * Whether a plain file exists at one path.
3082
+ *
3083
+ * stat, not lstat: a credential file reached through a symlink is a legitimate
3084
+ * layout — a user may keep profiles behind links — and the question asked here is
3085
+ * whether the client has a file there, not how it is reached.
3086
+ *
3087
+ * Any error (ENOENT, EACCES, ENOTDIR, an unsupported path shape) answers "no". A
3088
+ * failure to establish existence is not evidence of existence, and this path runs
3089
+ * on startup where throwing would take the plugin down over a missing file.
3090
+ */
3091
+ async function existsAsFile$1(candidate) {
3092
+ try {
3093
+ return (await stat(candidate)).isFile();
3094
+ } catch {
3095
+ return false;
3096
+ }
3097
+ }
3098
+ /**
3099
+ * Is there a local Codex CLI sign-in to offer?
3100
+ *
3101
+ * Safe on plugin startup: this NEVER opens a candidate for reading, so a user who
3102
+ * never opts in never has the credential's contents touched — not by this
3103
+ * function, not by the route that renders the card. Only stat runs, which reveals
3104
+ * existence and timestamps and nothing else.
3105
+ *
3106
+ * Deliberately NOT reported here: whether the file is valid, expired, or even
3107
+ * JSON. Answering that would require the read this function exists to avoid, and
3108
+ * the card does not need it — validity is answered by the read, after opt-in, and
3109
+ * lands on the card as either a credential or 'unrecognised local sign-in
3110
+ * format'.
3111
+ *
3112
+ * A boolean, rather than the record the Claude reader returns, because the two
3113
+ * callers want different things and neither needs the other's shape: the status
3114
+ * route wants a flag, and reports the path it consulted beside it. paths
3115
+ * is injectable so tests drive it against fixtures instead of a real profile;
3116
+ * production callers take the default.
3117
+ */
3118
+ async function codexCliCredentialPresence(paths = codexCliCredentialPaths()) {
3119
+ for (const candidate of paths) if (await existsAsFile$1(candidate)) return true;
3120
+ return false;
3121
+ }
3122
+ /**
3123
+ * The claims of a JWT, decoded in process, or nothing.
3124
+ *
3125
+ * The payload is the second base64url segment; the signature is never touched and
3126
+ * the result is used only for the three non-secret facts below. Never sent
3127
+ * anywhere: a token handed to a decoder service is a token handed away.
3128
+ *
3129
+ * No verification is claimed and none is possible here — the signing key is the
3130
+ * issuer's, and this module has no business holding it. What it does instead is
3131
+ * refuse to invent: a token that does not decode is treated as stating nothing.
3132
+ */
3133
+ function decodeJwtClaims(token) {
3134
+ const parts = token.split(".");
3135
+ if (parts.length !== 3) return void 0;
3136
+ try {
3137
+ const value = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
3138
+ return isRecord$31(value) ? value : void 0;
3139
+ } catch {
3140
+ return;
3141
+ }
3142
+ }
3143
+ /**
3144
+ * The access token's expiry, in EPOCH MILLISECONDS, or nothing.
3145
+ *
3146
+ * RFC 7519 states 'exp' in SECONDS since the epoch, and the store's
3147
+ * StoredOAuthCredentials states milliseconds — a thousand-fold difference that is
3148
+ * silent when it goes the wrong way (a credential stamped a thousand times too
3149
+ * late reads as unexpired for centuries) and catastrophic the other. The
3150
+ * conversion is therefore done here, once, with a comment on it.
3151
+ *
3152
+ * Only a finite, non-negative number is accepted. A string, null, NaN, a
3153
+ * negative value or a missing claim are all "this document states no usable
3154
+ * expiry", and the caller's answer to that is to refuse the whole document rather
3155
+ * than import a credential whose validity nothing established.
3156
+ */
3157
+ function expiryFromAccessToken(accessToken) {
3158
+ const claims = decodeJwtClaims(accessToken);
3159
+ if (claims === void 0) return void 0;
3160
+ const exp = claims.exp;
3161
+ if (typeof exp !== "number" || !Number.isFinite(exp) || exp < 0) return void 0;
3162
+ return Math.round(exp * 1e3);
3163
+ }
3164
+ /**
3165
+ * Map one parsed credential document onto the store's credential shape.
3166
+ *
3167
+ * Pure and exported so the format rules can be tested without a filesystem, and
3168
+ * so a later chunk that reads the document from somewhere else (a backup, a pasted
3169
+ * blob) reuses the one set of rules instead of growing a second, drifting copy.
3170
+ *
3171
+ * Returns undefined for every shape it does not recognise, and never throws — see
3172
+ * Rule 4 in the module comment. The gate is deliberately narrow: the document must
3173
+ * be a JSON object in the ChatGPT auth mode, its tokens block must be an object,
3174
+ * and it must carry BOTH a non-empty access token and a non-empty refresh token.
3175
+ * An access token alone is not adopted because the store's record is a sign-in,
3176
+ * not a bare token, and a record with an empty one could never be re-recognised.
3177
+ *
3178
+ * The unrelated-token trap is handled by construction: value.tokens is addressed
3179
+ * directly and no other key of the document is ever traversed, so whatever else
3180
+ * the file carries cannot reach the result.
3181
+ */
3182
+ function parseAdoptedCodexCredential(value, sourcePath) {
3183
+ if (!isRecord$31(value)) return void 0;
3184
+ if (value.auth_mode !== "chatgpt") return void 0;
3185
+ const tokens = value[CODEX_CLI_TOKENS_KEY];
3186
+ if (!isRecord$31(tokens)) return void 0;
3187
+ const accessToken = nonEmptyString$5(tokens.access_token);
3188
+ if (accessToken === void 0) return void 0;
3189
+ const refreshToken = nonEmptyString$5(tokens.refresh_token);
3190
+ if (refreshToken === void 0) return void 0;
3191
+ const expiresAt = expiryFromAccessToken(accessToken);
3192
+ if (expiresAt === void 0) return void 0;
3193
+ const credentials = {
3194
+ accessToken,
3195
+ refreshToken,
3196
+ expiresAt
3197
+ };
3198
+ const idToken = nonEmptyString$5(tokens.id_token);
3199
+ if (idToken !== void 0) {
3200
+ credentials.idToken = idToken;
3201
+ const claims = decodeJwtClaims(idToken);
3202
+ const email = claims === void 0 ? void 0 : nonEmptyString$5(claims.email);
3203
+ if (email !== void 0) credentials.email = email;
3204
+ const authClaims = claims === void 0 ? void 0 : claims[CODEX_AUTH_CLAIMS_KEY];
3205
+ const planType = isRecord$31(authClaims) ? nonEmptyString$5(authClaims.chatgpt_plan_type) : void 0;
3206
+ if (planType !== void 0) credentials.planType = planType;
3207
+ }
3208
+ const accountId = nonEmptyString$5(tokens.account_id);
3209
+ if (accountId !== void 0) credentials.accountId = accountId;
3210
+ return {
3211
+ adopted: true,
3212
+ source: CODEX_CLI_CREDENTIAL_SOURCE,
3213
+ sourcePath,
3214
+ credentials
3215
+ };
3216
+ }
3217
+ /**
3218
+ * Read the local Codex CLI sign-in; the first usable candidate wins.
3219
+ *
3220
+ * ONLY call this after the user has explicitly opted in — see the module comment.
3221
+ *
3222
+ * Never throws and never rejects: an absent file, an unreadable file, a file that
3223
+ * is not JSON, and a JSON document of an unrecognised shape all produce undefined,
3224
+ * which the card renders as 'unrecognised local sign-in format'. A settings route
3225
+ * must not be able to fail because a user's file was mid-write.
3226
+ */
3227
+ async function readCodexCliCredentials(paths = codexCliCredentialPaths()) {
3228
+ for (const candidate of paths) try {
3229
+ const raw = await readFile(candidate, "utf8");
3230
+ const adopted = parseAdoptedCodexCredential(JSON.parse(raw), candidate);
3231
+ if (adopted !== void 0) return adopted;
3232
+ } catch {}
3233
+ }
3234
+ /**
3235
+ * Whether an adopted credential's access token is already unusable.
3236
+ *
3237
+ * A structural mirror of the expiry test the OAuth module applies to its own
3238
+ * credentials (expiresAt - Date.now() > 0), asked of a pooled credential so the
3239
+ * pool can refuse it WITHOUT ever reaching for a refresh this module deliberately
3240
+ * does not perform. A non-finite expiresAt counts as expired, matching the
3241
+ * refusal in the reader: no established validity, no use.
3242
+ *
3243
+ * now is injectable so a test can reason about a fixed clock.
3244
+ */
3245
+ function isAdoptedCodexCredentialExpired(credential, now = Date.now()) {
3246
+ const expiresAt = credential.expiresAt;
3247
+ return !(typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > now);
3248
+ }
3249
+ //#endregion
2898
3250
  //#region src/host/codex-account-pool.ts
3251
+ /**
3252
+ * The ChatGPT account pool.
3253
+ *
3254
+ * TWO KINDS OF ACCOUNT, AND ONLY ONE OF THEM MAY BE REFRESHED.
3255
+ *
3256
+ * - managed — signed in through this plugin. Its credential lives in the pool's
3257
+ * encrypted storage AND in the pre-pool credential document beside it, it is
3258
+ * refreshable, and deleting it is this plugin's business.
3259
+ * - adopted — a SNAPSHOT read out of a local Codex CLI sign-in by 'codex-adopt.ts'
3260
+ * (marker 'adopted: true', source 'codex'). It is used only while its own access
3261
+ * token is still valid.
3262
+ *
3263
+ * An adopted snapshot is NEVER refreshed, and the reason is not an optimisation:
3264
+ * the ChatGPT refresh token the CLI stores rotates, and the CLI refreshes its own
3265
+ * file in place. Two processes spending one grant invalidate each other, and
3266
+ * whoever loses the race holds a token the server has already retired — which
3267
+ * leaves the user signed OUT of the Codex CLI by this plugin's good intentions. An
3268
+ * in-process single-flight cannot fix that, because the race is across PROCESSES
3269
+ * and the only state they share is a file 'codex-adopt.ts' must never write. Once
3270
+ * such a snapshot has expired the pool takes it out of rotation and surfaces
3271
+ * ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT — sign in again in the CODEX CLI and
3272
+ * re-import, not here.
3273
+ *
3274
+ * The rule is enforced twice on purpose: the needsRefresh hook answers false for
3275
+ * an adopted credential no matter what its expiry says, and the refresh hook
3276
+ * itself refuses one, so a future caller that forgets the first fails loudly
3277
+ * instead of silently spending a grant that is not ours to spend.
3278
+ *
3279
+ * MANAGED BEATS ADOPTED on a conflict, and the marker is therefore written on
3280
+ * EVERY account this file creates, including the explicit false case: the core
3281
+ * merges a re-authorization as { ...existing, ...created }, so an omitted key
3282
+ * would leave a stored 'adopted: true' — and its never-refresh consequence — on a
3283
+ * record that is now plugin-owned.
3284
+ */
3285
+ /**
3286
+ * Whether a pooled credential is an adopted snapshot.
3287
+ *
3288
+ * The two fields, and only those two: a plain credential carries no 'adopted'
3289
+ * field and must never be mistaken for a snapshot, because treating a
3290
+ * plugin-owned account as a disposable one would freeze it out of refreshing and
3291
+ * make deleteAccount refuse to remove an account the user signed in here.
3292
+ */
3293
+ function isAdoptedCodexPoolCredential(credentials) {
3294
+ return credentials.adopted === true && credentials.source === "codex";
3295
+ }
3296
+ /**
3297
+ * Whether a credential must be refreshed before the next request.
3298
+ *
3299
+ * FALSE for every adopted snapshot, valid or expired. That is this pool's core
3300
+ * rule: an adopted credential is borrowed for exactly as long as the Codex CLI
3301
+ * vouched for it, and this pool has no business rotating a token another process
3302
+ * is rotating too. See the module comment.
3303
+ */
3304
+ function codexNeedsRefresh(credentials, now) {
3305
+ if (isAdoptedCodexPoolCredential(credentials)) return false;
3306
+ return credentials.expiresAt - now <= TOKEN_REFRESH_MARGIN_MS;
3307
+ }
3308
+ /**
3309
+ * Why a stored credential is already known to be unusable, or nothing.
3310
+ *
3311
+ * Reported purely — nothing is written here — and consulted by the core for both
3312
+ * routing eligibility and credential usability, which is what makes an expired
3313
+ * snapshot non-routable WITHOUT a write on every read. The hint names the remedy
3314
+ * in the Codex CLI, because that is where the user has to go: re-adopting here is
3315
+ * only possible after the CLI itself signed in again.
3316
+ */
3317
+ function codexAuthRejectedReason(credentials) {
3318
+ if (!isAdoptedCodexPoolCredential(credentials)) return void 0;
3319
+ return isAdoptedCodexCredentialExpired(credentials) ? ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT : void 0;
3320
+ }
3321
+ /**
3322
+ * Message raised if a refresh of a still-valid adopted snapshot were attempted.
3323
+ *
3324
+ * Unreachable through this pool's own paths (see the module comment); it exists
3325
+ * because 'unreachable' is a claim the guard has to be able to make good on.
3326
+ */
3327
+ const ADOPTED_NEVER_REFRESHED_MESSAGE$1 = "The sign-in imported from the Codex CLI is never refreshed by this plugin: the Codex CLI refreshes the same rotating token, and two refreshers would invalidate each other. Sign in again in the Codex CLI, then import the new sign-in from this settings card.";
3328
+ /** Raised when a refresh of an adopted snapshot is attempted. Never a 401. */
3329
+ var CodexAdoptedCredentialError = class extends Error {
3330
+ constructor(message) {
3331
+ super(message);
3332
+ this.name = "CodexAdoptedCredentialError";
3333
+ }
3334
+ };
2899
3335
  /** Encrypted pool file the ChatGPT line owns; the single-credential store stays the mirror. */
2900
3336
  function codexPoolPath() {
2901
3337
  return path.join(dshHomeDir(), "storages", "codex-pool.json");
@@ -2916,13 +3352,18 @@ function parseCodexPoolData(value) {
2916
3352
  const raw = item;
2917
3353
  if (typeof raw.id !== "string") continue;
2918
3354
  const credentials = parseStoredCredentials(raw.credentials);
3355
+ const adopted = isAdoptedCodexPoolCredential(credentials);
2919
3356
  const account = {
2920
3357
  id: raw.id,
2921
3358
  alias: typeof raw.alias === "string" ? raw.alias : credentials.email || "账号",
2922
3359
  credentials,
2923
3360
  addedAt: typeof raw.addedAt === "number" ? raw.addedAt : Date.now(),
2924
- isPrimary: raw.isPrimary === true
3361
+ isPrimary: raw.isPrimary === true,
3362
+ adopted,
3363
+ source: adopted ? CODEX_CLI_CREDENTIAL_SOURCE : "managed"
2925
3364
  };
3365
+ const sourcePath = optionalString$9(raw, "sourcePath") ?? credentials.sourcePath;
3366
+ if (adopted && sourcePath !== void 0) account.sourcePath = sourcePath;
2926
3367
  const email = optionalString$9(raw, "email") ?? credentials.email;
2927
3368
  if (email !== void 0) account.email = email;
2928
3369
  const planType = optionalString$9(raw, "planType") ?? credentials.planType;
@@ -2945,14 +3386,6 @@ function parseCodexPoolData(value) {
2945
3386
  return result;
2946
3387
  }
2947
3388
  /**
2948
- * ChatGPT's account pool.
2949
- *
2950
- * Thin wrapper over {@link AccountPoolCore}: it only supplies the credential
2951
- * shape, the identity a duplicate sign-in collapses onto, and the two provider
2952
- * hooks the core cannot know — how to refresh one account's tokens and which
2953
- * accounts a cached quota window already rules out.
2954
- */
2955
- /**
2956
3389
  * Per-account in-flight requests, shared by every ChatGPT request this host
2957
3390
  * makes. A fan-out of subagents is what reaches a plan's concurrency bound, so
2958
3391
  * the bound is enforced here rather than discovered by cooling every account at
@@ -2997,23 +3430,31 @@ var CodexAccountPool = class extends AccountPoolCore {
2997
3430
  parsePoolData: parseCodexPoolData,
2998
3431
  dedupeKey: (credentials) => credentials.accountId ?? credentials.email,
2999
3432
  defaultAlias: (credentials, position) => credentials.email ?? `账号 ${position}`,
3000
- createAccount: ({ id, alias, credentials, addedAt, isPrimary }) => ({
3001
- id,
3002
- alias,
3003
- credentials,
3004
- addedAt,
3005
- isPrimary,
3006
- ...credentials.email === void 0 ? {} : { email: credentials.email },
3007
- ...credentials.planType === void 0 ? {} : { planType: credentials.planType },
3008
- ...credentials.accountId === void 0 ? {} : { accountId: credentials.accountId }
3009
- }),
3433
+ createAccount: ({ id, alias, credentials, addedAt, isPrimary }) => {
3434
+ const adopted = isAdoptedCodexPoolCredential(credentials);
3435
+ return {
3436
+ id,
3437
+ alias,
3438
+ credentials,
3439
+ addedAt,
3440
+ isPrimary,
3441
+ adopted,
3442
+ source: adopted ? CODEX_CLI_CREDENTIAL_SOURCE : "managed",
3443
+ sourcePath: adopted ? credentials.sourcePath : void 0,
3444
+ ...credentials.email === void 0 ? {} : { email: credentials.email },
3445
+ ...credentials.planType === void 0 ? {} : { planType: credentials.planType },
3446
+ ...credentials.accountId === void 0 ? {} : { accountId: credentials.accountId }
3447
+ };
3448
+ },
3010
3449
  expiresAt: (credentials) => credentials.expiresAt,
3011
- needsRefresh: (credentials, now) => credentials.expiresAt - now <= TOKEN_REFRESH_MARGIN_MS,
3450
+ needsRefresh: (credentials, now) => codexNeedsRefresh(credentials, now),
3012
3451
  refresh: async (credentials) => {
3452
+ if (isAdoptedCodexPoolCredential(credentials)) throw new CodexAdoptedCredentialError(isAdoptedCodexCredentialExpired(credentials) ? ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT : ADOPTED_NEVER_REFRESHED_MESSAGE$1);
3013
3453
  const refresher = ref.current;
3014
3454
  if (refresher === void 0) throw new Error("ChatGPT 凭据刷新服务尚未就绪");
3015
3455
  return refresher.refreshAccount(credentials);
3016
3456
  },
3457
+ authRejectedReason: (credentials) => codexAuthRejectedReason(credentials),
3017
3458
  refreshFailureStatus: (error) => error instanceof OAuthServiceError && (error.status === 400 || error.status === 401) ? "expired" : void 0,
3018
3459
  legacyAccount: async () => {
3019
3460
  const stored = await store.load();
@@ -3024,6 +3465,8 @@ var CodexAccountPool = class extends AccountPoolCore {
3024
3465
  credentials: stored,
3025
3466
  addedAt: Date.now(),
3026
3467
  isPrimary: true,
3468
+ adopted: false,
3469
+ source: "managed",
3027
3470
  ...stored.email === void 0 ? {} : { email: stored.email },
3028
3471
  ...stored.planType === void 0 ? {} : { planType: stored.planType },
3029
3472
  ...stored.accountId === void 0 ? {} : { accountId: stored.accountId }
@@ -3034,17 +3477,30 @@ var CodexAccountPool = class extends AccountPoolCore {
3034
3477
  await store.clear();
3035
3478
  return;
3036
3479
  }
3480
+ if (isAdoptedCodexPoolCredential(credentials)) return;
3037
3481
  await store.save(credentials);
3038
3482
  },
3039
- extendSummary: (account, base) => ({
3040
- ...base,
3041
- ...account.email === void 0 ? {} : { email: account.email },
3042
- ...account.planType === void 0 ? {} : { planLabel: account.planType },
3043
- ...(() => {
3044
- const quota = quotaSnapshotRef.current?.(account);
3045
- return quota === void 0 ? {} : { quota };
3046
- })()
3047
- }),
3483
+ extendSummary: (account, base) => {
3484
+ const adopted = account.adopted === true;
3485
+ const expired = adopted && isAdoptedCodexCredentialExpired(account.credentials);
3486
+ return {
3487
+ ...base,
3488
+ ...account.email === void 0 ? {} : { email: account.email },
3489
+ ...account.planType === void 0 ? {} : { planLabel: account.planType },
3490
+ adopted,
3491
+ source: account.source,
3492
+ removable: !adopted,
3493
+ ...account.sourcePath === void 0 ? {} : { sourcePath: account.sourcePath },
3494
+ ...(() => {
3495
+ const quota = quotaSnapshotRef.current?.(account);
3496
+ return quota === void 0 ? {} : { quota };
3497
+ })(),
3498
+ ...expired ? {
3499
+ authStatus: "expired",
3500
+ authFailedReason: ADOPTED_CODEX_CREDENTIAL_EXPIRED_HINT
3501
+ } : {}
3502
+ };
3503
+ },
3048
3504
  emptyMessage: "未登录 ChatGPT 账号,请在「设置 → 订阅服务 → ChatGPT」中添加并登录账号。",
3049
3505
  ...options.backend === void 0 ? {} : { backend: options.backend },
3050
3506
  ...options.maxAccounts === void 0 ? {} : { maxAccounts: options.maxAccounts }
@@ -3098,6 +3554,35 @@ var CodexAccountPool = class extends AccountPoolCore {
3098
3554
  if (this.refresherRef.current === void 0) throw new Error("ChatGPT 凭据刷新服务尚未就绪");
3099
3555
  return this.renewCredential(accountId, fetch, account.credentials);
3100
3556
  }
3557
+ /**
3558
+ * Remove one account inside a transaction, refusing a borrowed snapshot.
3559
+ *
3560
+ * The check lives HERE rather than in the public delete method so it cannot be
3561
+ * separated from the removal by a concurrent change, and so every caller of the
3562
+ * core's own deletion inherits it. The remedy is a separate action
3563
+ * ({@link removeImportedAccount}): a card must not destroy an account this plugin
3564
+ * did not create with the same button that deletes its own.
3565
+ */
3566
+ deleteAccountFromPool(data, accountId) {
3567
+ if (data.accounts.find((account) => account.id === accountId)?.adopted === true) throw new Error("The sign-in imported from the Codex CLI is not an account this plugin owns; remove the import instead.");
3568
+ super.deleteAccountFromPool(data, accountId);
3569
+ }
3570
+ /**
3571
+ * Drop an imported snapshot from the pool — the card's 'remove import' action.
3572
+ *
3573
+ * Separate from {@link deleteAccount} because that refuses one, and because the
3574
+ * user is asking to stop borrowing a sign-in, not to sign out of an account. The
3575
+ * Codex CLI's own file is touched by neither path: not deleted, not rewritten,
3576
+ * not moved. This only forgets the copy.
3577
+ */
3578
+ async removeImportedAccount(accountId) {
3579
+ return this.updatePool((data) => {
3580
+ const target = data.accounts.find((account) => account.id === accountId);
3581
+ if (target === void 0) return;
3582
+ if (target.adopted !== true) throw new Error("That account was signed in through this plugin; delete it instead of removing an import.");
3583
+ super.deleteAccountFromPool(data, accountId);
3584
+ });
3585
+ }
3101
3586
  /** The single-credential store this pool mirrors its primary account into. */
3102
3587
  mirrorStore() {
3103
3588
  return this.store;
@@ -7147,12 +7632,16 @@ function identityKey(credentials) {
7147
7632
  //#endregion
7148
7633
  //#region src/host/routes.ts
7149
7634
  const MAX_BODY_BYTES$6 = 64 * 1024;
7150
- function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwitcher, routeAudit, accountPool, fetchConfiguration) {
7635
+ function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwitcher, routeAudit, accountPool, fetchConfiguration, adopt) {
7636
+ const adoptPaths = adopt?.adoptPaths;
7151
7637
  const handler = async (request, response) => {
7152
7638
  const url = new URL(request.url ?? "/", "http://dsh.local");
7153
7639
  if (request.method === "GET" && url.pathname === `/api/dsh-chatgpt-subscription/status`) {
7154
7640
  const oauthStatus = await oauth.status();
7155
7641
  const quota = await usage.status(oauthStatus.authenticated, false, { backgroundRefresh: true });
7642
+ const codexCliPaths = adoptPaths ?? codexCliCredentialPaths();
7643
+ const codexCliSignInAvailable = await codexCliCredentialPresence(codexCliPaths).catch(() => false);
7644
+ const codexCliSignInPath = codexCliPaths[0];
7156
7645
  json(response, {
7157
7646
  ok: true,
7158
7647
  value: {
@@ -7160,6 +7649,8 @@ function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwit
7160
7649
  quota,
7161
7650
  quotaRefreshing: usage.refreshing,
7162
7651
  preferences: preferences.status(),
7652
+ codexCliSignInAvailable,
7653
+ codexCliSignInPath,
7163
7654
  detectedProxy: proxyManager?.getSystemProxy() ?? null,
7164
7655
  activeProxy: proxyManager?.resolveActiveProxyUrl() ?? null,
7165
7656
  switcher: searchSwitcher?.status() ?? null,
@@ -7238,6 +7729,14 @@ function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwit
7238
7729
  });
7239
7730
  return;
7240
7731
  }
7732
+ const refreshedStatus = async () => {
7733
+ const afterAction = await oauth.status();
7734
+ return {
7735
+ ...afterAction,
7736
+ quota: await usage.status(afterAction.authenticated),
7737
+ preferences: preferences.status()
7738
+ };
7739
+ };
7241
7740
  try {
7242
7741
  switch (url.pathname) {
7243
7742
  case `${ROUTE_PREFIX$5}/login/start`:
@@ -7267,14 +7766,60 @@ function registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwit
7267
7766
  else if (action === "clear-cooldown" && accountId !== null) await accountPool.clearCooldown(accountId);
7268
7767
  else if (action === "strategy" && (body.strategy === "sequential" || body.strategy === "round-robin" || body.strategy === "sticky")) await accountPool.setStrategy(body.strategy);
7269
7768
  else if (action === "relogin" && accountId !== null) await accountPool.clearAuthFailed(accountId);
7270
- const afterAction = await oauth.status();
7271
7769
  json(response, {
7272
7770
  ok: true,
7273
- value: {
7274
- ...afterAction,
7275
- quota: await usage.status(afterAction.authenticated),
7276
- preferences: preferences.status()
7277
- }
7771
+ value: await refreshedStatus()
7772
+ });
7773
+ return;
7774
+ }
7775
+ case `${ROUTE_PREFIX$5}/adopt`: {
7776
+ if (accountPool === void 0) {
7777
+ jsonError(response, 400, {
7778
+ code: "bad-request",
7779
+ message: "Importing a local Codex CLI sign-in needs the account pool, which is not installed."
7780
+ });
7781
+ return;
7782
+ }
7783
+ if (field(body, "source") !== "codex") {
7784
+ jsonError(response, 400, {
7785
+ code: "bad-request",
7786
+ message: "That local sign-in is not imported here. Claude Code and MiniMax Code are imported from their own provider settings."
7787
+ });
7788
+ return;
7789
+ }
7790
+ const adopted = await readCodexCliCredentials(adoptPaths ?? codexCliCredentialPaths());
7791
+ if (adopted === void 0) {
7792
+ jsonError(response, 400, {
7793
+ code: "bad-request",
7794
+ message: "No usable local Codex CLI sign-in was found (unrecognised local sign-in format)."
7795
+ });
7796
+ return;
7797
+ }
7798
+ await accountPool.addAccount({
7799
+ ...adopted.credentials,
7800
+ adopted: true,
7801
+ source: CODEX_CLI_CREDENTIAL_SOURCE,
7802
+ sourcePath: adopted.sourcePath
7803
+ });
7804
+ json(response, {
7805
+ ok: true,
7806
+ value: await refreshedStatus()
7807
+ });
7808
+ return;
7809
+ }
7810
+ case `${ROUTE_PREFIX$5}/adopt/disable`: {
7811
+ if (accountPool === void 0) {
7812
+ jsonError(response, 400, {
7813
+ code: "bad-request",
7814
+ message: "The ChatGPT account pool is not installed."
7815
+ });
7816
+ return;
7817
+ }
7818
+ const imported = ((await accountPool.read().catch(() => null))?.accounts ?? []).filter((account) => account.adopted === true);
7819
+ for (const account of imported) await accountPool.removeImportedAccount(account.id);
7820
+ json(response, {
7821
+ ok: true,
7822
+ value: await refreshedStatus()
7278
7823
  });
7279
7824
  return;
7280
7825
  }
@@ -29731,6 +30276,94 @@ function convergeWorkBuddyEffort(effort, ladder) {
29731
30276
  //#endregion
29732
30277
  //#region src/host/workbuddy/model-catalog.ts
29733
30278
  /**
30279
+ * Models the gateway serves but does not publish in `/v3/config`.
30280
+ *
30281
+ * The catalog is a published list, not the served one: measured on the
30282
+ * international backend, `gpt-6-sol`, `gpt-6-luna` and `gemini-3.8-flash` all
30283
+ * answer a normal streaming completion with 200 while being absent from
30284
+ * `/v3/config` entirely, and a reachable gateway *replaces* the shipped table
30285
+ * with that list. Without these rows the picker would simply never show them, so
30286
+ * they are declared here and merged into a live catalog too
30287
+ * ({@link withUnpublishedModels}) rather than living in the fallback alone.
30288
+ *
30289
+ * What each row records, and how it was established, differs by field:
30290
+ *
30291
+ * - **Existence, the reasoning ladder, image support** were measured against the
30292
+ * live endpoint, not inferred from the name. A rejected level answers 400
30293
+ * `code 11133` with `extError` `400002`; `gpt-6-sol` takes exactly the ladder
30294
+ * `gpt-6-astra` publishes (`minimal` refused, `low`/`medium`/`high`/`xhigh`/
30295
+ * `max` accepted) and both accept a 1x1 PNG.
30296
+ * - **Context window, output cap and `canDisableThinking` are inherited from the
30297
+ * published sibling in the same family** — `gpt-6-astra` for `gpt-6-sol` and
30298
+ * `gpt-6-luna`, `gemini-3.5-flash` for `gemini-3.8-flash`. They are marked
30299
+ * here rather than measured because the gateway publishes nothing for these
30300
+ * ids and offers no cheap way to read the real figure: it accepts a
30301
+ * `max_tokens` larger than any published window (even for `gpt-6-astra`, whose
30302
+ * own catalog caps at 128000), so the number is neither enforced nor
30303
+ * discoverable from the request side.
30304
+ *
30305
+ * Every field stays overridable per model from the settings card, and an
30306
+ * inherited window errs low rather than high: over-declaring overflows the
30307
+ * gateway mid-session, while under-declaring only compresses earlier.
30308
+ */
30309
+ const UNPUBLISHED_MODELS = [
30310
+ {
30311
+ id: "gpt-6-sol",
30312
+ name: "GPT-6-Sol",
30313
+ contextWindow: 4e5,
30314
+ maxContextWindow: 1e6,
30315
+ maxTokens: 128e3,
30316
+ regions: ["intl"],
30317
+ supportsImage: true,
30318
+ reasoningEfforts: [
30319
+ "low",
30320
+ "medium",
30321
+ "high",
30322
+ "xhigh",
30323
+ "max"
30324
+ ],
30325
+ defaultReasoningEffort: "high",
30326
+ canDisableThinking: true,
30327
+ description: "OpenAI 旗舰模型,擅长复杂推理与长程任务"
30328
+ },
30329
+ {
30330
+ id: "gpt-6-luna",
30331
+ name: "GPT-6-Luna",
30332
+ contextWindow: 4e5,
30333
+ maxContextWindow: 1e6,
30334
+ maxTokens: 128e3,
30335
+ regions: ["intl"],
30336
+ supportsImage: true,
30337
+ reasoningEfforts: [
30338
+ "low",
30339
+ "medium",
30340
+ "high",
30341
+ "xhigh",
30342
+ "max"
30343
+ ],
30344
+ defaultReasoningEffort: "high",
30345
+ canDisableThinking: true,
30346
+ description: "OpenAI 轻量模型,响应快速,适合日常任务"
30347
+ },
30348
+ {
30349
+ id: "gemini-3.8-flash",
30350
+ name: "Gemini-3.8-Flash",
30351
+ contextWindow: 1e6,
30352
+ maxContextWindow: 1e6,
30353
+ maxTokens: 65536,
30354
+ regions: ["intl"],
30355
+ supportsImage: true,
30356
+ reasoningEfforts: [
30357
+ "low",
30358
+ "high",
30359
+ "max"
30360
+ ],
30361
+ defaultReasoningEffort: "medium",
30362
+ canDisableThinking: false,
30363
+ description: "能力均衡,适合日常使用"
30364
+ }
30365
+ ];
30366
+ /**
29734
30367
  * Offline fallback catalog, transcribed from a live `/v3/config` read.
29735
30368
  *
29736
30369
  * Used when the gateway cannot be reached (first run before sign-in, or a
@@ -29827,6 +30460,7 @@ const FALLBACK_MODELS$1 = [
29827
30460
  canDisableThinking: false,
29828
30461
  description: "深度推理,适合深度分析与难题"
29829
30462
  },
30463
+ ...UNPUBLISHED_MODELS.filter((model) => model.id === "gpt-6-sol" || model.id === "gpt-6-luna"),
29830
30464
  {
29831
30465
  id: "gpt-6-astra",
29832
30466
  name: "GPT-6-Astra",
@@ -29944,7 +30578,7 @@ const FALLBACK_MODELS$1 = [
29944
30578
  name: "GLM-5.3",
29945
30579
  contextWindow: 1e6,
29946
30580
  maxContextWindow: 1e6,
29947
- maxTokens: 64e3,
30581
+ maxTokens: 48e3,
29948
30582
  regions: ["intl", "cn"],
29949
30583
  supportsImage: true,
29950
30584
  reasoningEfforts: [
@@ -29959,10 +30593,10 @@ const FALLBACK_MODELS$1 = [
29959
30593
  {
29960
30594
  id: "glm-5.3-flash",
29961
30595
  name: "GLM-5.3-Flash",
29962
- contextWindow: 1e6,
30596
+ contextWindow: 3e5,
29963
30597
  maxContextWindow: 1e6,
29964
30598
  maxTokens: 32e3,
29965
- regions: ["cn"],
30599
+ regions: ["intl", "cn"],
29966
30600
  supportsImage: true,
29967
30601
  reasoningEfforts: [
29968
30602
  "low",
@@ -29978,7 +30612,7 @@ const FALLBACK_MODELS$1 = [
29978
30612
  name: "GLM-5.2",
29979
30613
  contextWindow: 1e6,
29980
30614
  maxContextWindow: 1e6,
29981
- maxTokens: 64e3,
30615
+ maxTokens: 48e3,
29982
30616
  regions: ["intl", "cn"],
29983
30617
  supportsImage: true,
29984
30618
  reasoningEfforts: ["high", "xhigh"],
@@ -30132,7 +30766,7 @@ const FALLBACK_MODELS$1 = [
30132
30766
  name: "Kimi-K2.8-Preview",
30133
30767
  contextWindow: 3e5,
30134
30768
  maxContextWindow: 1e6,
30135
- maxTokens: 64e3,
30769
+ maxTokens: 32e3,
30136
30770
  regions: ["intl", "cn"],
30137
30771
  supportsImage: true,
30138
30772
  reasoningEfforts: [
@@ -30311,7 +30945,7 @@ const FALLBACK_MODELS$1 = [
30311
30945
  description: "混元思考模型,具有增强的推理能力"
30312
30946
  },
30313
30947
  {
30314
- id: "hy4-preview-x",
30948
+ id: "hy4-preview-f",
30315
30949
  name: "Hy4 preview",
30316
30950
  contextWindow: 1e6,
30317
30951
  maxContextWindow: 1e6,
@@ -30366,6 +31000,25 @@ const FALLBACK_MODELS$1 = [
30366
31000
  canDisableThinking: false,
30367
31001
  description: ""
30368
31002
  },
31003
+ {
31004
+ id: "space-bunny",
31005
+ name: "Space-Bunny",
31006
+ contextWindow: 1e6,
31007
+ maxContextWindow: 1e6,
31008
+ maxTokens: 128e3,
31009
+ regions: ["cn"],
31010
+ supportsImage: true,
31011
+ reasoningEfforts: [
31012
+ "low",
31013
+ "medium",
31014
+ "high",
31015
+ "xhigh",
31016
+ "max"
31017
+ ],
31018
+ defaultReasoningEffort: "max",
31019
+ canDisableThinking: false,
31020
+ description: "推理速度极快,编码能力强劲,并支持原生多模态输入的匿名大模型"
31021
+ },
30369
31022
  {
30370
31023
  id: "minimax-m3",
30371
31024
  name: "MiniMax-M3",
@@ -30417,6 +31070,7 @@ const FALLBACK_MODELS$1 = [
30417
31070
  canDisableThinking: false,
30418
31071
  description: ""
30419
31072
  },
31073
+ ...UNPUBLISHED_MODELS.filter((model) => model.id === "gemini-3.8-flash"),
30420
31074
  {
30421
31075
  id: "gemini-3.5-flash",
30422
31076
  name: "Gemini-3.5-Flash",
@@ -30452,6 +31106,21 @@ const DEFAULT_VISIBLE_MODEL_IDS$1 = [
30452
31106
  /** Every id the fallback knows, in declaration order. */
30453
31107
  const WORKBUDDY_MODEL_IDS = FALLBACK_MODELS$1.map((model) => model.id);
30454
31108
  /**
31109
+ * Add the served-but-unpublished models to a live `/v3/config` catalog.
31110
+ *
31111
+ * A reachable gateway replaces the shipped table with the list it publishes, so
31112
+ * a model it serves without listing ({@link UNPUBLISHED_MODELS}) would drop out
31113
+ * of the picker the moment the catalog loads. Merging here keeps it offered.
31114
+ *
31115
+ * A published entry always wins: the gateway is the authority on anything it
31116
+ * does describe, so these rows only fill ids the payload is silent about.
31117
+ */
31118
+ function withUnpublishedModels(catalog, region) {
31119
+ const known = new Set(catalog.map((model) => model.id));
31120
+ const extra = UNPUBLISHED_MODELS.filter((model) => model.regions.includes(region) && !known.has(model.id));
31121
+ return extra.length > 0 ? [...catalog, ...extra] : [...catalog];
31122
+ }
31123
+ /**
30455
31124
  * Resolve one entry from a catalog.
30456
31125
  *
30457
31126
  * An unknown id resolves to a synthetic text-only entry rather than the first
@@ -30483,9 +31152,131 @@ function resolveWorkBuddyModel(id, catalog = FALLBACK_MODELS$1) {
30483
31152
  description: ""
30484
31153
  };
30485
31154
  }
30486
- /** Models an account in `region` can actually call, in catalog order. */
31155
+ /**
31156
+ * Vendor families, resolved from a model id.
31157
+ *
31158
+ * The gateway orders `/v3/config` the way it feels like, which interleaves
31159
+ * vendors: a DeepSeek row, then a GPT row, then two Hunyuan rows, then a Kimi
31160
+ * row, then three more GPT rows. In a picker that reads as noise — the eye
31161
+ * cannot tell that the GPT entries belong together — so the list is grouped by
31162
+ * family before it is shown.
31163
+ *
31164
+ * The rules are ordered, first match wins. Only families this subscription
31165
+ * actually serves are listed: a rule for a model that cannot appear would be a
31166
+ * guess about a future catalog.
31167
+ *
31168
+ * An id no rule claims is its own family rather than a shared `other` bucket, so
31169
+ * an unrecognised model keeps its place in the order instead of being swept into
31170
+ * a pile of models it has nothing to do with.
31171
+ */
31172
+ const WORKBUDDY_VENDOR_RULES = [
31173
+ {
31174
+ re: /^(default|fast|balanced|primary|deep)(-model)?$/,
31175
+ family: "alias"
31176
+ },
31177
+ {
31178
+ re: /^gpt-/,
31179
+ family: "openai"
31180
+ },
31181
+ {
31182
+ re: /^gemini-/,
31183
+ family: "google"
31184
+ },
31185
+ {
31186
+ re: /^glm-/,
31187
+ family: "zhipu"
31188
+ },
31189
+ {
31190
+ re: /^kimi-/,
31191
+ family: "moonshot"
31192
+ },
31193
+ {
31194
+ re: /^deepseek-/,
31195
+ family: "deepseek"
31196
+ },
31197
+ {
31198
+ re: /^hy[0-9]/,
31199
+ family: "tencent"
31200
+ },
31201
+ {
31202
+ re: /^hunyuan/,
31203
+ family: "tencent"
31204
+ },
31205
+ {
31206
+ re: /^minimax-/,
31207
+ family: "minimax"
31208
+ },
31209
+ {
31210
+ re: /^space-bunny$/,
31211
+ family: "space-bunny"
31212
+ }
31213
+ ];
31214
+ /** The vendor family one model belongs to. */
31215
+ function workBuddyModelVendor(id) {
31216
+ const value = id.toLowerCase();
31217
+ for (const rule of WORKBUDDY_VENDOR_RULES) if (rule.re.test(value)) return rule.family;
31218
+ return id;
31219
+ }
31220
+ /**
31221
+ * Numeric segments of a model id, e.g. `gpt-6-astra` → [6].
31222
+ *
31223
+ * Version numbers are compared as numbers, so GPT-6.0 sorts below GPT-6.6 rather
31224
+ * than above it as it would if the strings were compared.
31225
+ */
31226
+ function versionSegments$1(id) {
31227
+ return [...id.toLowerCase().matchAll(/\d+/g)].map((match) => Number(match[0]));
31228
+ }
31229
+ /**
31230
+ * Order two models of one family, newest generation first.
31231
+ *
31232
+ * Catalog order alone is not good enough. The models served but not published
31233
+ * are appended after the published list, which left `gpt-6-sol` below
31234
+ * `gpt-5.4` — the newest models at the bottom of their own group. Comparing the
31235
+ * numeric segments puts every GPT-6 row together and ahead of every GPT-5 one,
31236
+ * and leaves an id whose version cannot be read (an alias, a nickname) in the
31237
+ * catalog's own order rather than guessing where it belongs.
31238
+ *
31239
+ * Ties keep catalog order, so `deepseek-v4.1-flash` still leads its
31240
+ * `-flash-sg` sibling.
31241
+ */
31242
+ function compareWithinFamily(a, b) {
31243
+ const left = versionSegments$1(a.id);
31244
+ const right = versionSegments$1(b.id);
31245
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
31246
+ const x = left[index];
31247
+ const y = right[index];
31248
+ if (x === void 0) return 1;
31249
+ if (y === void 0) return -1;
31250
+ if (x !== y) return y - x;
31251
+ }
31252
+ return 0;
31253
+ }
31254
+ /**
31255
+ * Group a list so every vendor's models sit together.
31256
+ *
31257
+ * Families appear in the order their first model does — the catalog decides
31258
+ * which vendor leads, not this function — and each family is ordered newest
31259
+ * generation first.
31260
+ */
31261
+ function groupWorkBuddyModelsByVendor(models) {
31262
+ const families = /* @__PURE__ */ new Map();
31263
+ for (const model of models) {
31264
+ const family = workBuddyModelVendor(model.id);
31265
+ const bucket = families.get(family);
31266
+ if (bucket === void 0) families.set(family, [model]);
31267
+ else bucket.push(model);
31268
+ }
31269
+ return [...families.values()].map((bucket) => [...bucket].sort(compareWithinFamily)).flat();
31270
+ }
31271
+ /**
31272
+ * Models an account in `region` can actually call, grouped by vendor.
31273
+ *
31274
+ * Grouped here rather than in the settings card because this is also what the
31275
+ * request path resolves against: one ordering rule, so the picker and the
31276
+ * adapter can never disagree about which models a region offers.
31277
+ */
30487
31278
  function modelsForRegion(region, catalog = FALLBACK_MODELS$1) {
30488
- return catalog.filter((model) => model.regions.includes(region));
31279
+ return groupWorkBuddyModelsByVendor(catalog.filter((model) => model.regions.includes(region)));
30489
31280
  }
30490
31281
  /** Whether one model accepts image input. */
30491
31282
  function workBuddyModelSupportsImage(id, catalog = FALLBACK_MODELS$1) {
@@ -31435,7 +32226,7 @@ async function fetchConfigCatalog(credentials, options = {}) {
31435
32226
  signal: timeoutSignal$1(options.signal, DISCOVERY_TIMEOUT_MS$1)
31436
32227
  });
31437
32228
  if (!response.ok) throw new Error(`${PROVIDER_NAME$1} model catalog failed: ${response.status}`);
31438
- return parseConfigModels(await response.json(), credentials.region);
32229
+ return withUnpublishedModels(parseConfigModels(await response.json(), credentials.region), credentials.region);
31439
32230
  }
31440
32231
  /** Cached catalog with a TTL; a failed refresh keeps the previous snapshot.
31441
32232
  *
@@ -31469,7 +32260,7 @@ async function loadConfigCatalog(credentials, options = {}) {
31469
32260
  }, (fetchedAt, models) => {
31470
32261
  cachedCatalog = {
31471
32262
  region: credentials.region,
31472
- models,
32263
+ models: withUnpublishedModels(models, credentials.region),
31473
32264
  fetchedAt
31474
32265
  };
31475
32266
  }, 0) > 0) {
@@ -31995,7 +32786,13 @@ function resolveEnabledModelIds$2(stored, available, enabled = true) {
31995
32786
  const known = new Set(available);
31996
32787
  return stored.filter((id) => known.has(id));
31997
32788
  }
31998
- /** Build the model options the settings card renders, in catalog order. */
32789
+ /**
32790
+ * Build the model options the settings card renders.
32791
+ *
32792
+ * Order comes from the caller, which passes a vendor-grouped list so a family
32793
+ * is not interleaved with the next one; this preserves whatever order it is
32794
+ * given rather than imposing a second one.
32795
+ */
31999
32796
  function buildModelOptions(catalog, available, enabledModelIds, overrides) {
32000
32797
  const enabled = new Set(enabledModelIds);
32001
32798
  return available.map((id) => {
@@ -33974,14 +34771,19 @@ const ANTHROPIC_VERSION = "2023-06-01";
33974
34771
  * table, whether or not a floor for that model has been observed: upstream has
33975
34772
  * so far gated a new model on the release that added it (Opus 5.5 arrived in
33976
34773
  * 2.1.280 and is refused below 2.1.280). Claude Sonnet 5.5 arrived in 2.1.284,
33977
- * so 2.1.283 could not be relied on to reach it; 2.1.285 is npm `latest`.
34774
+ * so 2.1.283 could not be relied on to reach it. Claude Haiku 5.5 arrived in
34775
+ * 2.1.293, so 2.1.292 could not be relied on to reach it either — and that is
34776
+ * not hypothetical: when this row was added, npm's `stable` dist-tag was STILL
34777
+ * sitting at 2.1.285, five releases below the one that ships the model, while
34778
+ * `latest` was already 2.1.293. The warning above about reading `stable` is
34779
+ * therefore about this table's own newest model, not a hypothetical.
33978
34780
  *
33979
34781
  * It is still not a literal that only moves when this package is republished: a
33980
34782
  * user whose installed baseline has aged past the server's floor has to be able
33981
34783
  * to raise it without waiting for a release. Read the effective value with
33982
34784
  * {@link claudeCliVersion}, pin one with {@link setClaudeCliVersion}.
33983
34785
  */
33984
- const CLAUDE_CLI_VERSION = "2.1.285";
34786
+ const CLAUDE_CLI_VERSION = "2.1.293";
33985
34787
  /**
33986
34788
  * Loopback callback port the subscription flow defaults to.
33987
34789
  *
@@ -34342,10 +35144,11 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
34342
35144
  * TWO KINDS OF ROW ARE NOT FULLY TRANSCRIBED, and they are different things.
34343
35145
  * Do not collapse them.
34344
35146
  *
34345
- * 1. `claude-sonnet-5-5` — the snapshot this table was copied from has no
34346
- * entry for it, so the whole row is CURATED: every value below comes from
34347
- * the vendor's own documentation and no snapshot field can check it. The
34348
- * mirror list in the test is `LOCALLY_CURATED_MODEL_IDS`.
35147
+ * 1. `claude-sonnet-5-5` and `claude-haiku-5-5` — the snapshot this table
35148
+ * was copied from has no entry for either, so each whole row is CURATED:
35149
+ * every value below comes from the vendor's own documentation and no
35150
+ * snapshot field can check it. The mirror list in the test is
35151
+ * `LOCALLY_CURATED_MODEL_IDS`.
34349
35152
  *
34350
35153
  * 2. `claude-opus-5-5` — a NEWER snapshot (pi-ai >= 0.87.1) does carry it, so
34351
35154
  * the row sits in the snapshot's own position and EVERY field is checked
@@ -34358,22 +35161,35 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
34358
35161
  * stands. The test asserts the row still disagrees with the snapshot, so a
34359
35162
  * future snapshot that agrees fails loudly and retires the entry.
34360
35163
  *
34361
- * Both must be changed together with the mirror list in the test. An invented
34362
- * row that mimics the format of a checked one is worse than a missing row: it
34363
- * is unverifiable and it looks verified. So each row is marked at the row
34364
- * itself, the test asserts the id is on the curated list, and the test
35164
+ * 3. `claude-haiku-5-5` (released 2026-10-07) is the second fully curated
35165
+ * row, and the first one whose documented `canDisableThinking` is TRUE. That
35166
+ * flag is not a free choice here and the row's own comment records why the
35167
+ * documented answer is safe to send: `thinking: { type: 'disabled' }` is
35168
+ * accepted at `low`/`medium`/`high` and is a 400 at `xhigh`/`max`, and this
35169
+ * line only sends that form when the caller asked for thinking OFF, in which
35170
+ * case it names no effort at all and the server's own default (`medium`) is
35171
+ * what the request runs at. Its documented default effort is MEDIUM, so it is
35172
+ * 'adaptive' rather than 'mid-convo' for exactly the reason Opus 5.5 is —
35173
+ * 'mid-convo' would force `high` — and it runs the preserved-thinking prefix
35174
+ * check, so `bindsThinkingToPrefix` is set even though the form is adaptive.
35175
+ *
35176
+ * All three must be changed together with the mirror list in the test. An
35177
+ * invented row that mimics the format of a checked one is worse than a missing
35178
+ * row: it is unverifiable and it looks verified. So each row is marked at the
35179
+ * row itself, the test asserts the id is on the curated list, and the test
34365
35180
  * asserts the curated list is exactly the ids the snapshot lacks — which keeps
34366
35181
  * the lock narrow instead of merely weaker.
34367
35182
  *
34368
- * Its fields come from the vendor's own published documentation for the model
34369
- * (the model overview page plus the extended-thinking effort page), NOT from the
34370
- * snapshot, and they are the values a person read off those pages. That is a
35183
+ * `claude-sonnet-5-5`'s fields come from the vendor's own published
35184
+ * documentation for the model (the model overview page plus the extended-thinking
35185
+ * effort page), NOT from the snapshot, and they are the values a person read off
35186
+ * those pages. That is a
34371
35187
  * weaker source than the snapshot and it is labelled as such: nothing here
34372
35188
  * proves the account is entitled to the model, and the server's own
34373
35189
  * `GET /v1/models` still outranks it.
34374
35190
  *
34375
- * Two of its fields are the ones worth recording a WHY for, because both look
34376
- * like mistakes against their neighbours:
35191
+ * Two of that row's fields are the ones worth recording a WHY for, because both
35192
+ * look like mistakes against their neighbours:
34377
35193
  *
34378
35194
  * 1. `canDisableThinking: false`. The vendor documents that thinking cannot be
34379
35195
  * turned off on this model at all: sending `thinking: { type: 'disabled' }`
@@ -34399,17 +35215,18 @@ function loopbackRedirectUri(port = DEFAULT_CALLBACK_PORT) {
34399
35215
  * A future reader who notices the shared `supportsTemperature: false` and
34400
35216
  * "fixes" item 2 to 'mid-convo' has reintroduced exactly that bug.
34401
35217
  *
34402
- * One more field on that row comes from neither source: its `minCliVersion`
34403
- * floor is the number upstream itself states when it refuses the model
34404
- * (`claude_code_version_too_old`, "version 2.1.280 or newer is required"). That
35218
+ * One more field on `claude-opus-5-5` comes from neither source: its
35219
+ * `minCliVersion` floor is the number upstream itself states when it refuses
35220
+ * the model (`claude_code_version_too_old`, "version 2.1.280 or newer is
35221
+ * required"). That
34405
35222
  * is a stronger witness than a documentation page — the server said it — and it
34406
35223
  * is recorded here so a claim below it is refused locally, before the request is
34407
35224
  * sent, instead of as an upstream 400 that names neither the model nor the number
34408
35225
  * the user has to reach.
34409
35226
  */
34410
35227
  /**
34411
- * Sixteen rows: the snapshot's own 15, in the snapshot's own declaration order,
34412
- * followed by the one row the snapshot still predates (see LOCAL ADDITIONS above).
35228
+ * Seventeen rows: the snapshot's own 15, in the snapshot's own declaration order,
35229
+ * followed by the two rows the snapshot still predates (see LOCAL ADDITIONS above).
34413
35230
  *
34414
35231
  * The order matters and is not cosmetic. The test asserts the snapshot's ids
34415
35232
  * appear in the table as a SUBSEQUENCE, so a curated row may be appended or
@@ -34697,6 +35514,24 @@ const CLAUDE_MODELS = Object.freeze([
34697
35514
  ],
34698
35515
  canDisableThinking: false,
34699
35516
  bindsThinkingToPrefix: true
35517
+ },
35518
+ {
35519
+ id: "claude-haiku-5-5",
35520
+ name: "Claude Haiku 5.5",
35521
+ contextWindow: 1e6,
35522
+ maxTokens: 128e3,
35523
+ supportsImage: true,
35524
+ supportsTemperature: false,
35525
+ thinkingMode: "adaptive",
35526
+ reasoningEfforts: [
35527
+ "low",
35528
+ "medium",
35529
+ "high",
35530
+ "xhigh",
35531
+ "max"
35532
+ ],
35533
+ canDisableThinking: true,
35534
+ bindsThinkingToPrefix: true
34700
35535
  }
34701
35536
  ]);
34702
35537
  /** Every id the fallback table knows, in declaration order. */
@@ -34721,6 +35556,13 @@ const FALLBACK_MODELS = CLAUDE_MODELS;
34721
35556
  * than one that leads with the general-purpose ids. The rest stay selectable
34722
35557
  * from the model card and are one click away.
34723
35558
  *
35559
+ * The list tracks ONE current model per family rather than the newest of each,
35560
+ * so a slot follows its family's newest release in place instead of growing: the
35561
+ * Haiku slot is `claude-haiku-5-5`, which replaced `claude-haiku-4-5` when that
35562
+ * model shipped. That is a deliberate swap and not a silent append — a list that
35563
+ * offered both generations of every family would be longer than the picker it
35564
+ * feeds, and the older Haiku stays one click away in the card.
35565
+ *
34724
35566
  * This list is the SHIPPED DEFAULT, and edits to it are a deliberate onboarding
34725
35567
  * decision rather than a no-op: a stored list that still equals the shipped
34726
35568
  * default counts as "never edited", and the routes and adapter read it that way
@@ -34734,7 +35576,7 @@ const DEFAULT_VISIBLE_MODEL_IDS = [
34734
35576
  "claude-opus-5-5",
34735
35577
  "claude-opus-4-6",
34736
35578
  "claude-sonnet-4-6",
34737
- "claude-haiku-4-5",
35579
+ "claude-haiku-5-5",
34738
35580
  "claude-opus-4-5"
34739
35581
  ];
34740
35582
  new Map(CLAUDE_MODELS.map((model) => [model.id, model]));
@@ -44960,6 +45802,22 @@ function installRelayProbe(ctx, options) {
44960
45802
  * degenerate stream scores near one. On a hit it stops yielding chunks, so the
44961
45803
  * in-flight request ends, and lets the turn resume on a fresh step.
44962
45804
  *
45805
+ * Uniqueness alone only sees verbatim repetition. A stream can restate one
45806
+ * conclusion over and over in fresh words, score 0.48 where the threshold is
45807
+ * 0.85, and run to the output ceiling untouched. So a second, independent
45808
+ * signal scores the same window for recurrence: how strongly each sentence
45809
+ * repeats something the model already said at least `semanticLag` sentences
45810
+ * earlier. Enumerative reasoning repeats itself too, but adjacently — each item
45811
+ * borrows the previous item's frame — and recurrence ignores adjacency, which
45812
+ * is what separates the two.
45813
+ *
45814
+ * Neither signal stops anything on its own. A break needs both over their
45815
+ * thresholds in the same window, and then `semanticConfirmations` consecutive
45816
+ * such windows: one signal alone, or a single anomalous window, is logged and
45817
+ * released. That gradient is the whole design. A guard that cuts a healthy
45818
+ * answer is worse than one that lets a runaway finish, because the user sees a
45819
+ * turn stop mid-sentence with no explanation, and the fix costs a whole turn.
45820
+ *
44963
45821
  * Seam choice: the guard wraps `llm/stream`, not `agent/assistant-stream`.
44964
45822
  * `agent/assistant-stream` is emit-mode — it can observe but cannot stop a
44965
45823
  * stream — and it does not exist before harness 0.1.5, which would make the
@@ -44996,6 +45854,15 @@ const DEFAULT_GUARD_OPTIONS = {
44996
45854
  maxBreaksPerTurn: 3,
44997
45855
  cooldownMs: 3e4,
44998
45856
  includeModels: [],
45857
+ semanticThreshold: .35,
45858
+ semanticLag: 6,
45859
+ semanticMinChars: 360,
45860
+ semanticMinSentences: 8,
45861
+ semanticConfirmations: 3,
45862
+ semanticLiteralFloor: .6,
45863
+ semanticStrictThreshold: .45,
45864
+ semanticDistinctCeiling: .47,
45865
+ semanticStrictConfirmations: 5,
44999
45866
  now: () => Date.now()
45000
45867
  };
45001
45868
  function pruneUndefined(options) {
@@ -45021,8 +45888,21 @@ function resolveGuardOptions(options = {}) {
45021
45888
  if (!Number.isFinite(resolved.threshold) || resolved.threshold <= 0 || resolved.threshold >= 1) throw new Error(`reasoning-collapse-guard: invalid threshold ${String(resolved.threshold)} — must be strictly between 0 and 1`);
45022
45889
  if (!Number.isInteger(resolved.maxBreaksPerTurn) || resolved.maxBreaksPerTurn < 1) throw new Error(`reasoning-collapse-guard: invalid maxBreaksPerTurn ${String(resolved.maxBreaksPerTurn)} — must be an integer >= 1`);
45023
45890
  if (!Number.isInteger(resolved.cooldownMs) || resolved.cooldownMs < 0) throw new Error(`reasoning-collapse-guard: invalid cooldownMs ${String(resolved.cooldownMs)} — must be an integer >= 0`);
45891
+ if (!Number.isFinite(resolved.semanticThreshold) || resolved.semanticThreshold <= 0 || resolved.semanticThreshold >= 1) throw new Error(`reasoning-collapse-guard: invalid semanticThreshold ${String(resolved.semanticThreshold)} — must be strictly between 0 and 1`);
45892
+ if (!Number.isInteger(resolved.semanticLag) || resolved.semanticLag < 1) throw new Error(`reasoning-collapse-guard: invalid semanticLag ${String(resolved.semanticLag)} — must be an integer >= 1`);
45893
+ if (!Number.isInteger(resolved.semanticMinChars) || resolved.semanticMinChars < 1) throw new Error(`reasoning-collapse-guard: invalid semanticMinChars ${String(resolved.semanticMinChars)} — must be an integer >= 1`);
45894
+ if (resolved.semanticMinChars > resolved.windowChars) throw new Error(`reasoning-collapse-guard: invalid semanticMinChars ${resolved.semanticMinChars} — must not exceed windowChars ${resolved.windowChars}`);
45895
+ if (!Number.isInteger(resolved.semanticMinSentences) || resolved.semanticMinSentences < 2) throw new Error(`reasoning-collapse-guard: invalid semanticMinSentences ${String(resolved.semanticMinSentences)} — must be an integer >= 2`);
45896
+ if (!Number.isInteger(resolved.semanticConfirmations) || resolved.semanticConfirmations < 1) throw new Error(`reasoning-collapse-guard: invalid semanticConfirmations ${String(resolved.semanticConfirmations)} — must be an integer >= 1`);
45897
+ if (!Number.isFinite(resolved.semanticLiteralFloor) || resolved.semanticLiteralFloor <= 0 || resolved.semanticLiteralFloor >= 1) throw new Error(`reasoning-collapse-guard: invalid semanticLiteralFloor ${String(resolved.semanticLiteralFloor)} — must be strictly between 0 and 1`);
45898
+ if (!Number.isFinite(resolved.semanticStrictThreshold) || resolved.semanticStrictThreshold <= 0 || resolved.semanticStrictThreshold >= 1) throw new Error(`reasoning-collapse-guard: invalid semanticStrictThreshold ${String(resolved.semanticStrictThreshold)} — must be strictly between 0 and 1`);
45899
+ if (!Number.isFinite(resolved.semanticDistinctCeiling) || resolved.semanticDistinctCeiling <= 0 || resolved.semanticDistinctCeiling >= 1) throw new Error(`reasoning-collapse-guard: invalid semanticDistinctCeiling ${String(resolved.semanticDistinctCeiling)} — must be strictly between 0 and 1`);
45900
+ if (!Number.isInteger(resolved.semanticStrictConfirmations) || resolved.semanticStrictConfirmations < 1) throw new Error(`reasoning-collapse-guard: invalid semanticStrictConfirmations ${String(resolved.semanticStrictConfirmations)} — must be an integer >= 1`);
45024
45901
  if (!Array.isArray(resolved.includeModels) || resolved.includeModels.some((entry) => typeof entry !== "string")) throw new Error("reasoning-collapse-guard: includeModels must be an array of strings");
45025
45902
  if (typeof resolved.now !== "function") throw new Error("reasoning-collapse-guard: now must be a function");
45903
+ resolved.semanticStrictThreshold = Math.max(resolved.semanticStrictThreshold, resolved.semanticThreshold);
45904
+ resolved.semanticStrictConfirmations = Math.max(resolved.semanticStrictConfirmations, resolved.semanticConfirmations + 1);
45905
+ resolved.semanticLiteralFloor = Math.min(resolved.semanticLiteralFloor, resolved.threshold);
45026
45906
  return resolved;
45027
45907
  }
45028
45908
  /**
@@ -45045,6 +45925,92 @@ function collapseScore(window, n) {
45045
45925
  if (total === 0) return 0;
45046
45926
  return 1 - grams.size / total;
45047
45927
  }
45928
+ /**
45929
+ * Split reasoning text into sentences, for mixed CJK and Latin streams.
45930
+ *
45931
+ * A Chinese full stop ends a sentence whatever follows it. An ASCII `.!?` only
45932
+ * ends one when whitespace or the end of the text follows, because the same
45933
+ * characters carry out decimals, file names and version numbers
45934
+ * (`selectors.test.ts:745`, `3.14`) that must not become sentence
45935
+ * edges. Whitespace runs collapse and case folds, so a difference in spacing
45936
+ * or capitalisation cannot read as a difference in wording.
45937
+ *
45938
+ * @param text - reasoning text, typically one trailing window.
45939
+ * @returns non-empty normalized sentences, in order.
45940
+ */
45941
+ function splitSentences(text) {
45942
+ const parts = [];
45943
+ let start = 0;
45944
+ for (let i = 0; i < text.length; i++) {
45945
+ const char = text[i];
45946
+ const next = text[i + 1];
45947
+ const cjk = CJK_TERMINATORS.has(char);
45948
+ const latin = LATIN_TERMINATORS.has(char) && (next === void 0 || TRAILING_SPACE.test(next));
45949
+ if (!cjk && !latin) continue;
45950
+ parts.push(text.slice(start, i + 1));
45951
+ start = i + 1;
45952
+ }
45953
+ parts.push(text.slice(start));
45954
+ const sentences = [];
45955
+ for (const part of parts) {
45956
+ const normalized = part.replace(WHITESPACE_RUN, " ").trim().toLowerCase();
45957
+ if (normalized.length > 0) sentences.push(normalized);
45958
+ }
45959
+ return sentences;
45960
+ }
45961
+ /** The three Chinese sentence terminators, which need no trailing space. */
45962
+ const CJK_TERMINATORS = /* @__PURE__ */ new Set([
45963
+ "。",
45964
+ "!",
45965
+ "?"
45966
+ ]);
45967
+ /** The three ASCII terminators, which only close a sentence before whitespace. */
45968
+ const LATIN_TERMINATORS = /* @__PURE__ */ new Set([
45969
+ ".",
45970
+ "!",
45971
+ "?"
45972
+ ]);
45973
+ const TRAILING_SPACE = /\s/;
45974
+ const WHITESPACE_RUN = /\s+/g;
45975
+ /** Character n-grams of one normalized sentence, as a set. */
45976
+ function sentenceGrams(sentence) {
45977
+ const grams = /* @__PURE__ */ new Set();
45978
+ for (let i = 0; i + SEMANTIC_NGRAM_SIZE <= sentence.length; i++) grams.add(sentence.slice(i, i + SEMANTIC_NGRAM_SIZE));
45979
+ return grams;
45980
+ }
45981
+ function gramJaccard(a, b) {
45982
+ if (a.size === 0 || b.size === 0) return 0;
45983
+ let shared = 0;
45984
+ for (const gram of a) if (b.has(gram)) shared++;
45985
+ return shared / (a.size + b.size - shared);
45986
+ }
45987
+ /** {@link semanticRecurrence} over sentences the caller has already split. */
45988
+ function sentenceRecurrence(sentences, lag) {
45989
+ const grams = sentences.map(sentenceGrams);
45990
+ if (grams.length <= lag) return 0;
45991
+ let total = 0;
45992
+ let counted = 0;
45993
+ for (let i = lag; i < grams.length; i++) {
45994
+ const current = grams[i];
45995
+ let best = 0;
45996
+ for (let j = 0; j <= i - lag; j++) {
45997
+ const earlier = grams[j];
45998
+ if (Math.min(current.size, earlier.size) / Math.max(current.size, earlier.size) <= best) continue;
45999
+ const similarity = gramJaccard(current, earlier);
46000
+ if (similarity > best) best = similarity;
46001
+ }
46002
+ total += best;
46003
+ counted++;
46004
+ }
46005
+ return counted === 0 ? 0 : total / counted;
46006
+ }
46007
+ /** Character n-gram size both halves of the semantic signal are built on. */
46008
+ const SEMANTIC_NGRAM_SIZE = 3;
46009
+ /** {@link distinctSentenceRatio} over sentences the caller already split. */
46010
+ function distinctRatioOf(sentences) {
46011
+ if (sentences.length < 2) return 1;
46012
+ return new Set(sentences).size / sentences.length;
46013
+ }
45048
46014
  const RESUME_HINT = "Your previous reasoning became repetitive and was stopped before it finished. Resume from what you already established instead of restarting the analysis, and keep the rest of this response short.";
45049
46015
  /** The second resume message, used once a resume has also collapsed. */
45050
46016
  const RESUME_HINT_STRICT = "Your reasoning has now repeated across two attempts. Do not re-derive the analysis a third time. Either state the conclusion you already reached, or make the single next tool call that moves the task forward. Keep this response to a few sentences.";
@@ -45053,7 +46019,11 @@ function createBreakerState() {
45053
46019
  breaksThisTurn: 0,
45054
46020
  lastBreakAt: -Infinity,
45055
46021
  exhausted: false,
45056
- resuming: false
46022
+ resuming: false,
46023
+ dualStreak: 0,
46024
+ lowStreak: 0,
46025
+ notedLiteralOnly: false,
46026
+ notedSemanticOnly: false
45057
46027
  };
45058
46028
  }
45059
46029
  /**
@@ -45208,10 +46178,12 @@ function installReasoningCollapseGuard(ctx, options = {}) {
45208
46178
  * point — and the log says so, because a break that cannot resume is
45209
46179
  * indistinguishable from a broken turn to the user.
45210
46180
  */
45211
- function breakOff(state, score, agent, sessionId) {
46181
+ function breakOff(state, literal, semantic, path, agent, sessionId) {
45212
46182
  state.breaksThisTurn++;
45213
46183
  state.lastBreakAt = resolved.now();
45214
- ctx.logger?.warn(`reasoning-collapse-guard: stopped degenerate reasoning (score ${score.toFixed(3)}, break ${state.breaksThisTurn}/${resolved.maxBreaksPerTurn}, session ${sessionId ?? "unidentified"}${agent === void 0 ? ", no agent to resume" : ""})`);
46184
+ state.dualStreak = 0;
46185
+ state.lowStreak = 0;
46186
+ ctx.logger?.warn(`reasoning-collapse-guard: stopped degenerate reasoning on the ${path} path after ${path === "high-literal" ? resolved.semanticConfirmations : resolved.semanticStrictConfirmations} confirmed windows (literal ${literal.toFixed(3)}, semantic ${semantic.toFixed(3)}, break ${state.breaksThisTurn}/${resolved.maxBreaksPerTurn}, session ${sessionId ?? "unidentified"}${agent === void 0 ? ", no agent to resume" : ""})`);
45215
46187
  if (agent === void 0) return;
45216
46188
  agent.cancel?.({
45217
46189
  kind: "hook",
@@ -45229,6 +46201,48 @@ function installReasoningCollapseGuard(ctx, options = {}) {
45229
46201
  }
45230
46202
  });
45231
46203
  }
46204
+ /**
46205
+ * Report one signal holding without the other.
46206
+ *
46207
+ * Reported once per kind per turn, not once per window: a runaway can hold
46208
+ * one signal for hundreds of windows, and a line each would bury the report
46209
+ * of the one that mattered. This is the diagnostic half of the gradient —
46210
+ * the reason a near miss is still diagnosable after the fact.
46211
+ */
46212
+ function noteLoneSignal(state, kind, literal, semantic, sessionId) {
46213
+ if (kind === "literal" ? state.notedLiteralOnly : state.notedSemanticOnly) return;
46214
+ if (kind === "literal") state.notedLiteralOnly = true;
46215
+ else state.notedSemanticOnly = true;
46216
+ ctx.logger?.warn(`reasoning-collapse-guard: observed the ${kind} signal alone (literal ${literal === void 0 ? "not scored" : literal.toFixed(3)}, semantic ${semantic === void 0 ? "not scored" : semantic.toFixed(3)}, session ${sessionId ?? "unidentified"}) — the other signal has not agreed, not acting`);
46217
+ }
46218
+ /**
46219
+ * Report a run of agreeing windows that ended before it reached the count.
46220
+ *
46221
+ * Without this, two short runs of agreeing windows look in the log exactly
46222
+ * like one long one that simply stopped short, and the confirmation count
46223
+ * becomes impossible to reason about after the fact.
46224
+ */
46225
+ function noteStreakCleared(state, reached, sessionId) {
46226
+ ctx.logger?.warn(`reasoning-collapse-guard: the signals stopped agreeing after ${reached} of ${resolved.semanticConfirmations} windows (session ${sessionId ?? "unidentified"}) — streak cleared, not acting`);
46227
+ }
46228
+ /** Report the low-literal run that ended before it reached its longer count. */
46229
+ function noteLowStreakCleared(reached, sessionId) {
46230
+ ctx.logger?.warn(`reasoning-collapse-guard: the low-literal bars stopped holding after ${reached} of ${resolved.semanticStrictConfirmations} windows (session ${sessionId ?? "unidentified"}) — streak cleared, not acting`);
46231
+ }
46232
+ /** Report a low-literal window that qualifies but has not been confirmed enough. */
46233
+ function notePendingLowConfirmation(state, literal, semantic, distinct, sessionId) {
46234
+ ctx.logger?.warn(`reasoning-collapse-guard: low-literal bars hold in window ${state.lowStreak}/${resolved.semanticStrictConfirmations} (literal ${literal.toFixed(3)} >= ${resolved.semanticLiteralFloor}, semantic ${semantic.toFixed(3)} >= ${resolved.semanticStrictThreshold}, distinct ${distinct.toFixed(3)} <= ${resolved.semanticDistinctCeiling}, session ${sessionId ?? "unidentified"}) — not acting yet`);
46235
+ }
46236
+ /**
46237
+ * Report a window where both signals held but the streak is still short.
46238
+ *
46239
+ * `semanticConfirmations` is what makes a break a decision rather than a
46240
+ * reflex, so each pending confirmation is named, and the log then says how
46241
+ * close a stream came to being cut by a guard that did not cut it.
46242
+ */
46243
+ function notePendingConfirmation(state, literal, semantic, sessionId) {
46244
+ ctx.logger?.warn(`reasoning-collapse-guard: both signals hold in window ${state.dualStreak}/${resolved.semanticConfirmations} (literal ${literal.toFixed(3)}, semantic ${semantic.toFixed(3)}, session ${sessionId ?? "unidentified"}) — not acting yet`);
46245
+ }
45232
46246
  function guardStream(request, next) {
45233
46247
  const state = breakerFor(request.signal ?? STATIC_KEY);
45234
46248
  if (state.exhausted || !watchedModel(request.model) || !conversationCall(request)) return next();
@@ -45239,13 +46253,33 @@ function installReasoningCollapseGuard(ctx, options = {}) {
45239
46253
  if (chunk.type === "block-start") window = "";
45240
46254
  if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") {
45241
46255
  window = appendWindow(window, chunk.text, resolved.windowChars);
45242
- if (window.length >= resolved.minWindowChars && resolved.now() - state.lastBreakAt >= resolved.cooldownMs) {
45243
- const score = collapseScore(window, resolved.ngramSize);
45244
- if (score >= resolved.threshold) if (state.breaksThisTurn >= resolved.maxBreaksPerTurn) state.exhausted = true;
46256
+ if (resolved.now() - state.lastBreakAt >= resolved.cooldownMs) {
46257
+ const literal = window.length >= resolved.minWindowChars ? collapseScore(window, resolved.ngramSize) : void 0;
46258
+ const sentences = window.length >= resolved.semanticMinChars ? splitSentences(window) : [];
46259
+ const semantic = sentences.length >= resolved.semanticMinSentences ? sentenceRecurrence(sentences, resolved.semanticLag) : void 0;
46260
+ const distinct = sentences.length >= resolved.semanticMinSentences ? distinctRatioOf(sentences) : void 0;
46261
+ const high = literal !== void 0 && literal >= resolved.threshold && semantic !== void 0 && semantic >= resolved.semanticThreshold;
46262
+ const low = literal !== void 0 && literal >= resolved.semanticLiteralFloor && literal < resolved.threshold && semantic !== void 0 && semantic >= resolved.semanticStrictThreshold && distinct !== void 0 && distinct <= resolved.semanticDistinctCeiling;
46263
+ const reachedHigh = state.dualStreak;
46264
+ const reachedLow = state.lowStreak;
46265
+ state.dualStreak = high ? reachedHigh + 1 : 0;
46266
+ state.lowStreak = low ? reachedLow + 1 : 0;
46267
+ if (reachedHigh !== 0 && state.dualStreak === 0) noteStreakCleared(state, reachedHigh, request.sessionId);
46268
+ if (reachedLow !== 0 && state.lowStreak === 0) noteLowStreakCleared(reachedLow, request.sessionId);
46269
+ if (high && state.dualStreak >= resolved.semanticConfirmations) if (state.breaksThisTurn >= resolved.maxBreaksPerTurn) state.exhausted = true;
46270
+ else {
46271
+ breakOff(state, literal, semantic, "high-literal", agent, request.sessionId);
46272
+ return;
46273
+ }
46274
+ else if (low && state.lowStreak >= resolved.semanticStrictConfirmations) if (state.breaksThisTurn >= resolved.maxBreaksPerTurn) state.exhausted = true;
45245
46275
  else {
45246
- breakOff(state, score, agent, request.sessionId);
46276
+ breakOff(state, literal, semantic, "low-literal", agent, request.sessionId);
45247
46277
  return;
45248
46278
  }
46279
+ else if (high) notePendingConfirmation(state, literal, semantic, request.sessionId);
46280
+ else if (low) notePendingLowConfirmation(state, literal, semantic, distinct, request.sessionId);
46281
+ else if (literal !== void 0 && literal >= resolved.threshold) noteLoneSignal(state, "literal", literal, semantic, request.sessionId);
46282
+ else if (semantic !== void 0 && semantic >= resolved.semanticThreshold) noteLoneSignal(state, "semantic", literal, semantic, request.sessionId);
45249
46283
  }
45250
46284
  }
45251
46285
  yield chunk;