* 🥐 chore: Standardize Bun-First Workspace Typechecks
* fix: Preserve Bun 1.4.3 Checker Compatibility
* chore: Pin Bun 1.4.4 for Local and CI Typechecks
---------
Co-authored-by: Lia <lia@librechat.ai>
Pushes to main that touch packages/data-provider/package.json without bumping the version failed with "You cannot publish over the previously published versions". Check whether the exact version already exists on the registry and skip packing and publishing when it does, as the client workflow does.
* ci: Add README Translation Sync Workflow
* ci: Pin create-pull-request Action in README Sync
* ci: Check Translation Content and Attribute Text in README Sync
* ci: Harden README Translation Validation and Switcher
* ci: Validate Protected Tokens in README Translations
* ci: Keep Link Order and Fence Boundaries in README Translation
* ci: Validate README Translations Against the Markdown Tree
* ci: Check Short Prose and Link Metadata in README Translation
* ci: Handle Brand Suffixes, Titles and Table Alignment in README Validation
* ci: Preserve Emoji, Image Alt and Raw Code in README Validation
* ci: Stop Rejecting Faithful Translations That Keep Language Names
* ci: Refresh Switchers in Unselected README Translations
* ci: Compare Comment Contents and Deduplicate Pending README Sections
* ci: Revalidate Cached README Translations and Resolve Reference Links
* ci: Keep Code in Raw HTML Blocks and Accept Full-Form Reference Translations
* ci: Read Alt Text of Reference-Style README Images
* ci: Test the README Translation Helpers on Pull Requests
* ci: Keep Reference Image Alt Text and Translate Definition Titles
* ci: Resolve README Reference Definitions Before Classifying Prose
* ci: Resolve README Footnotes When Validating Chunks
* ci: Point README Fragment Links at Translated Heading Anchors
Same-document links kept the English heading slug, which no longer exists once GitHub derives anchors from the translated heading text. Assembly now rewrites #fragment links in Markdown links, definitions and raw HTML hrefs to the slug of the heading at the same position, using github-slugger.
Closesberry-13/LibreChat#384
* 🧻 fix: Keep Lighthouse Session Cookies Out of Failure Logs
* ci: Use Lockfile-Pinned Chromium for Lighthouse
* ci: Align Lighthouse Browser Launch With Playwright
---------
Co-authored-by: Lia <lia@librechat.ai>
* 🚦 ci: Throttle Codegraph E2E Votes to One Run per Window
A vote runs the whole mock suite on ten runners, about 154 job-minutes, and
every merge to dev started one: 2,227 job-minutes on 2026-10-22, queued beside
PR CI for the same runners.
With repo variable CODEGRAPH_E2E_VOTE_MIN_HOURS set to N > 0, the votes
workflow skips its push runs (in a concurrency group of their own) and the new
Codegraph E2E Vote Gate decides instead: a merge starts a vote on dev
(workflow_dispatch) once the newest started vote is N hours old, and an hourly
catch-up votes the newest merge no vote covers once the window opens. A merge
votes as before while the catch-up has not run for three hours or the history
cannot be read. Unset, empty or 0 keeps today's push-triggered votes and skips
the gate; CODEGRAPH_E2E_VOTES=off still stops both.
The decision is a pure function in .github/scripts/vote-gate.mjs, tested in
vote-gate.test.mjs and run beside the shard tests in playwright-mock.yml.
* 🚦 fix: Keep an Unfinished Handoff Run from Covering Its Merge
With the throttle on, a merge creates the votes workflow's push run, whose
vote job will be skipped, and the gate's push run at about the same time.
Until that handoff run completed, the gate read it as a queued trial: it
matched the merge on head_sha, so the merge started no vote and waited for a
later catch-up tick, and it also held the window as a started vote.
While throttled, a push run that has not completed is now read as a handoff:
it neither covers a merge nor holds the window. A finished push run that
voted still counts as before.
The vote suite grew from 375 tests (39.1 min on one runner, 2026-09-30)
to 1,321, so a single-runner vote can no longer finish. Run it as the
same ten duration-planned shards playwright-mock.yml uses; each shard
logs the specs it executed and the codegraph shadow unions them into
one trial. A shard that runs out of its 28-minute test budget logs only
its failed specs, never clean ones.
* 💉 chore: Upgrade Node.js to Security Release 24.22.1
* test: Enable Enter Submission in Native Acceptance
* test: Use the Machine Picker in Native Acceptance
---------
Co-authored-by: Lia <lia@librechat.ai>
Each merge to dev cancelled the vote run before it, and the full mock
suite needs about 43 minutes, so 480 of 499 runs since 2026-09-24 never
finished (282 superseded, 153 cut by the 45-minute timeout, 45 never
started). Graduation evidence stopped at 09-30. Keep a started run and
one pending run, and raise the timeout to 75 minutes.
* test: Spec the First-Sentence Diagnostic Comparison in the Suppression Runner
* test: Copy the Diagnostics Module into the Suppression Spec Synthetic Roots
* test: Copy the Diagnostic Comparison Spec into the Suppression Synthetic Roots
Plan the complete mock suite's shards from recorded per-file durations instead of Playwright's equal-count split, widen the memory lanes from six to ten, and give the nightly full suite ten shards per store so it fits its 30-minute job timeout again.
* ci: Type-Check Workspaces with bun check
Make every workspace tsconfig valid under TypeScript 7 semantics while
staying clean on the installed tsc 5.9: explicit types arrays (TS 7 no
longer auto-includes node_modules/@types), es2017 target for
data-provider, rootDir for api specs that import client contracts, and
noUncheckedSideEffectImports off for the CSS and matchMedia mock imports.
Fix the four client type errors newer DOM and Intl libs expose, and drop
the fetch.preconnect copy in useRum that only existed to satisfy the
leaked @types/bun globals.
Switch the client typecheck script and the CI type-check steps to
bun check (Bun 1.4.3); tsc --noEmit stays available as typecheck:tsc.
* chore: Fall Back to tsc When bun check Is Unavailable
Add scripts/typecheck.mts, which runs bun check when the installed Bun
provides it and the repository's tsc otherwise, so contributors without
Bun can still type-check. The client typecheck script and the
static-checks TypeScript gate both resolve their checker through it,
keeping local runs on the same checker CI uses whenever Bun is present.
* ci: Type-Check the Chat Package Through the Shared Checker
* ci: Run the Type Check When Only the Shared Checker Changes
A change to scripts/typecheck.mts alone started neither frontend review
nor the static-checks TypeScript gate, so a broken resolver could merge
unexercised. Add it to the frontend-review triggers and to every
typecheck project's paths.
The suppression-baseline scenarios copy static-checks.mts into synthetic
roots; copy its new typecheck.mts import alongside i18n.mts so the runner
loads there. Add scenarios for the checker choice and its fallback.
The step appliers, frame normalizer and SSE transport move from client/src/hooks/SSE into packages/chat/src/core with their specs, and the client imports them from @librechat/chat. The ask-user-question predicates the content applier reads move with them, and the client's approval module re-exports them so its consumers are unchanged.
* refactor: Judge chat imports on the resolved target with one boundary rule
scripts/eslint/chat.mjs replaces the import patterns and load selectors: every
specifier is normalized (extension, trailing slash, /index, package name)
before it is judged, across static imports, re-exports, import(), require(),
import-equals and import types, so any spelling of the /react or /components
entry or a UI package is rejected from core. A RuleTester table and a
table-driven scenario cover the spellings.
* fix: Anchor the chat boundary root to the config directory
* fix: Keep bare app paths and bare core entries behind the chat boundary
* fix: Treat aliased react paths in core as the /react entry
* feat: Scaffold the @librechat/chat package
Adds packages/chat with core, react and components entries, a tsdown build,
a node-environment jest config, turbo and root build wiring, import boundary
rules, design-lint scope, and core types for the chat transport, stream,
host and status. No code moves yet.
* test: Cover the @librechat/chat boundaries as scenarios
* fix: Run the chat suite in test:turbo and build it for the mock lane
test:turbo now selects @librechat/chat, the mock e2e job builds the package the
chat-package scenarios load, and the synthetic repository in the suppression
scenarios carries the packages/chat/src root lint:design:record names.
* chore: Build the chat package in frontend:ci too
* chore: Run the chat suite in test:all
* fix: Hold core TSX to the chat boundary and scan the package for cycles
* fix: Apply the chat import boundary to JavaScript sources
* fix: Keep nested core files off the components entry
* fix: Close the remaining chat package gates and fence aborts by generation
Blocks imports through the @librechat/frontend workspace name, adds the chat
sources to the i18n scan, the CSS color gate and the client Tailwind content,
and lets ChatAbortRequest carry the generation it means to stop.
* fix: Keep JSX out of the chat core and carry abort and reconnect outcomes
Rejects JSX under the core, copies the chat manifest before the Docker
install, keys the client app build caches on the chat sources, fences
reconnects by generation, and types the abort result the transport returns.
* test: Give the CSS gate's scratch tree the chat root it now scans
* fix: Close the remaining @librechat/chat gates and wiring
Rejects import() and require() across the package boundaries, holds chat
components to the literal-copy rule, compiles TSX in the package's jest
transform, builds the package in Frontend Unit Tests, requires its test job for
main promotion, adds it to the unused-dependency scan, and lists it wherever a
sibling workspace is listed: Dockerfile.multi, the docker smoke paths, the
remaining node_modules caches, the reinstall helper, the bun build chain and
AGENTS.md.
* fix: Tighten the chat boundaries and carry steering and Assistants aborts
Template-literal import() and require() and self-imports through the package
name are rejected, user-facing JSX attributes need localized text, the cycle
scan resolves self-imports, Dockerfile.multi builds the package for the client
stage, and the transport carries steer, cancelSteer, armSteer and the
Assistants abort key.
* feat: Match the chat transport and host contracts to the client's
The transport now reports settled and replaced starts, the stream's protocol
version and resumed flag, reconnect replay, generation status, paused-run
resume with approvals or answers, abort and steer protocol versions, and the
request error shape. The host config and auth carry what the client's
ChatSettings now supplies: rename-while-running, model specs, retention mode
and the user.
* fix: Keep lazy React-entry loads out of the core and type the Assistants stop
Relative import() and require() of the /react entry from core are rejected,
and an abortKey stop resolves to the terminal conversation and run messages the
Assistants route returns.
* fix: Fence start controls by protocol and close extension-bearing entry loads
Settled and replaced start results carry the protocol version, core loads of
the /react or /components entry with a file extension are rejected, and
test:packages:chat builds data-provider first.
* feat: Lint Disabled Dims That Compose No Disabled Recipe
* fix: Catch Bracketed Disabled Variants and Read Recipes From References
* fix: Skip Enabled-State Branches and False-Valued Disabled States
* fix: Read Full-Opacity Recipes, Primitive Recipes and Compound Conditions
* fix: Keep the Suppression Error Wording the Baseline Spec Reads
* test: Add a lint scenario for the disabled-recipe rule
* fix: Leave negated disabled selectors and conditions alone in the disabled-recipe rule
* feat: Judge disabled recipes per branch, by import and by the element they restore
* fix: Trust recipes only from the theme module and only through defaulted cva variants
* fix: Resolve namespaced recipes through their import
* fix: Read cva disabled variants, guarded recipes and trailing important in the disabled-recipe rule
* fix: Read variant dims a disabled condition picks and recipes behind the same ternary
* fix: Follow nested disabled guards and real cva defaults in the disabled-recipe rule
* fix: Trust recipes only through class helpers and string concatenation
* fix: Read cva compound variants only under compoundVariants and guard key lookups
* fix: Require the recipe that can win for the faded element in the disabled-recipe rule
* fix: Judge stacked disabled variants by their most distant element
* fix: Match recipe markers, read || polarity, compound arrays, constant class strings and helper bindings
* fix: Accept spelled recipes, default-imported primitives, inverted props and disabled-guarded recipes
* fix: Check every chosen dim, trusted helper sources, data-disabled, recipe markers and guard identity
* chore: Re-record the disabled-recipe backlog after rebasing onto dev
* refactor: Route Stylesheet Colours Through Theme Variables and Gate CSS Literals
* fix: Run the Colour Check Through Its Own Script
* fix: Read Numeric Fallbacks Inside Colour Function var() in the Colour Literal Check
* 🎨 chore: Enforce Design-System Rules With @shadcn/lint
Registers @shadcn/lint for client/src and packages/client/src, resolving\n@librechat/client as the design system so a className that overrides what a\nprimitive owns is reported with the variants and sizes to use instead.\n\nFive rules run at error. no-unknown-classes stays off: it needs Tailwind v4.\nContracts record what callers legitimately own (text typography, skeleton\nsilhouette); eslint-suppressions.json records the 3,141 violations the tree\nalready carried, so the rules gate new code without a tree-wide migration.\n
* 🩹 fix: Close the Gaps Review Found in the Design-Rule Gate
Five findings against this pull request, and one that killed its CI:
- The lockfile entries this change added resolved to a private mirror, so every
job died in `npm ci`. They now resolve from registry.npmjs.org, with each
integrity hash checked against the tarball npmjs.org serves.
- The pre-commit hook ran ESLint twice and the first run had no
`--pass-on-unpruned-suppressions`, so it exited 2 on the suppression the fix
had just made unused and lint-staged never reached the command that tolerates
it. One invocation now does both; ESLint applies suppressions after fixes, so
it reports the post-fix state.
- `scripts/static-checks.mts` omitted that same flag, so `npm run static-checks`
failed locally on a diff the Static Checks lane passes.
- `lint:design:suppress` recorded a moved file's new path and left the old one
behind, because only `--prune-suppressions` removes it and ESLint refuses both
flags in one run. The script now chains the prune, and CLAUDE.md documents the
scoped recipe that does not re-baseline the whole surface.
- The design rules matched only `.ts`/`.tsx`, so the client's `.jsx` entry points
bypassed all five. Both globs now cover `.js`/`.jsx`; the one violation that
exposed — an inline `display: none` on App.jsx's silent-audio iframe — is fixed
at the source rather than recorded.
- A suppressions-only diff started no workflow and nothing read the baseline. It
is now a trigger path and its own filter group, and a new `suppressions` check
rejects a shape ESLint cannot load, a count no run can reach, a rule the plugin
does not define, and a path that no longer exists.
The count-neutral swap two reviewers raised is not closed: ESLint's baseline
format records a count, not violation identities, so replacing one suppressed
violation with another of the same rule in the same file is invisible here.
CLAUDE.md now says that instead of implying detection.
Four `@scenario`-tagged specs cover the developer-facing behaviour: the report a
caller gets on a primitive override, a `.jsx` file being checked, the fixing
commit staying green (with the counter-proof that it exits 2 without the flag),
the move recipe dropping the old path, and every locked tarball being fetchable
from the public registry.
* 🧪 fix: Close the Dormant-Entry and Gate-Trigger Gaps
The suppressions gate did not run when the file that implements it changed, a
count recorded against a path no design-rule lint reaches passed validation, the
documented re-record chain could not run under cmd.exe, and the lockfile
scenario failed a legitimate SheetJS upgrade. The trigger scenarios also ran
four full-tree sweeps inside one test that declared a 180s deadline for work
measured at 100-280s per sweep.
- scripts/static-checks.mts: `scripts/static-checks.mts` joins the suppressions
filter and the whole-record trigger, and a recorded path ESLint never reports
on is rejected as dormant.
- package.json/CLAUDE.md: `|| node -e ""` in place of `|| true`, which cmd.exe
does not define.
- e2e: the whole-record triggers are a matrix over a miniature repository (2-6s
each instead of 100-280s), the oversized scenario is split into five
behaviour-shaped ones, scratch baselines live in os.tmpdir(), and the lint
gate runs in one project because it has no viewport.
* 📏 chore: Re-Record the Counts Dev Moved Under the Backlog
Rebasing onto dev brought #16067, which restyled three files the recorded
backlog speaks for: MarketplaceSidebar.tsx gained one arbitrary value and two
`no-restyle` violations, ToolsMarketplaceDialog.tsx gained one, and SkillsDialog
lost one. Counts above the file fail the lint for whoever edits it next; the one
below leaves capacity a later violation would spend. Scoped re-record and prune
of those three paths, per the recipe in CLAUDE.md.
* 🧭 test: Run the Copied Runner From the Tree It Is Testing
The trigger matrix pointed the copied static-checks runner at a miniature
repository but invoked it from the real one. The runner derives its repository
from its own location and relativizes file arguments against the working
directory, so `eslint.config.mjs` resolved to a path outside the miniature tree,
the suppressions group read as "not affected by this diff", and the scenario
passed a check that never ran (verify recorded it as a failure: exit 0 where a
rejection was expected). `run` now takes a `cwd`, and both synthetic-root probes
use it.
* 🔇 fix: Reject a Disable Comment With Nothing Behind It Yet
Comparing the two ESLint runs finds a directive only while it is silencing
something. A `/* eslint-disable shadcn/no-raw-colors */` over a file with no raw
colour left both runs agreeing, so it landed unseen and then silenced the first
violation anyone added to that file. The configured run now also asks for unused
directives, which is how ESLint names a disable with nothing behind it: a
dormant design-rule disable and a dormant blanket disable are rejected, a
dormant directive for another rule is not. Neither root carries one today (0
reports across 2,241 files), so what this rejects is new ones.
The same pass also has to see the primitives' variants, so `validateSuppressions`
builds `packages/client/dist` before the directive check rather than inside the
capacity scan that follows it; without the metadata both runs report strictly
fewer design diagnostics and a comment silencing a `no-restyle` violation is
invisible to the comparison.
* 🧱 fix: Count the Library's Build Inputs as Sources
`designMetadataIsFresh` compared `packages/client/dist` with `packages/client/src`
alone, so a commit that changed only the library's manifest or its tsdown config
left every source older than the build and the runner skipped the rebuild. The
design rules then classified against output emitted under the old contract —
locally only, since CI always builds before validating, which is the shape of
divergence nobody sees until the lane disagrees with the commit that passed.
Both files already count as design metadata everywhere else (the suppressions
filter, the whole-record trigger); they now count in the freshness comparison
too. The scenario runs the miniature repository with an observable
`build:client-package`: a current build is not rebuilt, and touching either
input asks for one.
* ⏱️ test: Set the Times the Freshness Rule Compares
The rebuild scenario wrote the build output and then the manifest, and node
wrote both inside the same millisecond, so the manifest was not newer than the
build and the runner correctly declined to rebuild — the assertion failed on the
filesystem clock rather than on the rule. The scenario now sets each mtime it
depends on with `utimesSync`, which is what it meant to say.
* 🩹 fix: Restore the Write the Import Edit Dropped
Adding \ to the import list replaced \ instead of joining it, and nothing local caught it: no tsconfig covers e2e/, and the flat config leaves \ to TypeScript there. The mock harness did — every scenario in the file failed with ReferenceError — which is the point of running it. Typechecked the four scenario files directly this time.
* 🚫 fix: Reject Every Inline Escape, Not Only the Ones in Use
Codex found three more ways past the gate, one shape each. A blanket
\ busy silencing an unrelated rule is used, so ESLint's
unused-directive report never names it, and the configured-versus-ignored
comparison sees no design delta — it waits for the file's first design
violation and takes it. Inline \ over a
clean file is invisible the same way. And a nested baseline that drops an entry
for an unchanged source was checked against its remaining keys only, so the
violation it used to speak for went unread.
The directive check now has two halves: ESLint still answers what a comment is
silencing today, and the comments themselves are read out of the parser — not
the text, so a string that reads like a directive is not one — for a disable
naming a design rule, a blanket disable, or inline configuration naming a
design rule, whether or not anything is behind it yet. That supersedes
\, which could only see the unused ones.
Neither design root carries any of the three today.
\ unions the entries a diff removes into a nested baseline's
file list, and \ joins the design metadata: it
carries the \ mapping, the entry emit and the include/exclude that decide
what reaches the bundle the rules read, which is why the repository's own build
caches key on \.
* 🔐 fix: Make the Hook Select What the Lane Selects, and Distrust a Half-Built Bundle
Two more shapes of the same mistake — trusting a proxy for the thing itself.
The pre-commit group listed individual files while the Static Checks lane's
filter also names both design roots, so staging a primitive ran ESLint locally
and the whole-record sweep only in CI: the commit passed and the lane failed on
callers nobody staged. The hook's group is now the lane's filter pattern for
pattern, and the count-edit scenario reads both files and fails when the two
lists differ, so the next path added to one has to reach the other.
Freshness took the newest timestamp under \ as proof of a
finished build. The build empties that directory before it writes, so a build
that failed halfway leaves a recent directory over a bundle with no entry
point, and every later run skipped rebuilding it. The check now also requires
what \ promises inside \ — its entry fields
and every string leaf of \ — to exist.
* 🧾 fix: Give the CDN Exception to xlsx, Not to the Host
The lockfile rule allowed \ for anything and then compared
versions against the base branch, so regenerating the lockfile against that CDN
could move any package off the public registry at the same version and pass.
The exception now belongs to the package — \, at any version, nested or
not — and every other entry has to come from the registry, which drops the
version comparison and the shallow-clone caveat with it.
The rule is a function the scenario applies to the repository's lockfile and to
lockfiles written for the purpose: an xlsx upgrade on its CDN passes, lodash
rewritten onto the same CDN at the same version fails, a new package from a
private mirror fails, and a nested xlsx is still xlsx.
* ↩️ fix: One Local Entry Point, Asked of the Runner
The pre-commit group I added for the backlog and the design metadata was
redundant and slower: \ already ends by running the runner
over the staged diff with \, and that run
selects its groups from the same filters — including both design roots, and
including deletions, which lint-staged never hands to a task. The group made
the whole-record sweep run twice for one commit. It is gone.
The scenario now asserts the property instead of a copy of the list: the hook
still runs the runner over the staged diff, and for every path the lane's
suppressions paths-filter names, the runner reports that check as selected
(\), while a path outside both roots is not affected.
* 🗺️ fix: Assert Where the Rules Are Asked, Not Only What They Found
A report cannot say where the rules were asked. Narrowing the design block over
a subtree that happens to be clean today — \, say —
produces no new diagnostic, no changed count, and no baseline entry to inspect,
so every gate stays green while the styling written there next goes unread.
The new scenario asks the resolved flat configuration instead: for one
representative path of each source extension in every directory under both
roots, all five design rules must be at error in the app and at the library's
own profile inside \ (overriding a primitive is off there;
colour and inline geometry still hold), and server code must carry none of
them. 1,184 paths, answered through ESLint's API in one process in under a
second — \ would have been eleven minutes of subprocesses.
* 🎯 fix: Spend the Budget Once — Reject a Swap Inside the Allowance
The recorded count is a budget per file and rule, and until now it could be
respent: removing one raw colour while adding a different one keeps the total
equal, so ESLint suppresses the new violation inside the old allowance and the
count checks see nothing move. The backlog was documented as unable to tell
that apart, and it was the one hole Codex kept finding its way back to.
What the totals cannot distinguish the diagnostics can. For every design source
a diff touches, the runner now lints that file's version at the base — through
ESLint's API, one process for the whole diff — and reports a design-rule
message the head has that the base did not, naming it. A violation that only
moved keeps its message and is not reported; a file with no version at the base
has no allowance to hide in and the count checks already answer for it.
Measured on the checkout: swapping one `fill="#AFC1FF"` for `fill="#BEEF11"`
in a file recording five raw colours is rejected in 4.9s naming the new one,
and the unchanged file passes. The scenario proves all three states — recorded,
moved, swapped — in a miniature repository with a history of its own, and
CLAUDE.md no longer claims the swap is undetectable.
* 🧰 fix: The Root Manifests Are Build Inputs Too
\ is defined in the root manifest and runs with the
toolchain the root lockfile pins, and both already select the suppressions
check — but neither counted as an input to bundle freshness, so a commit that
changed the build definition could validate locally against output made by the
previous one while CI rebuilt. They count now, beside the library's own
manifest, build config and compiler options, and the rebuild scenario drives
the lockfile case through the miniature repository's observable build.
* 🧭 fix: Read the Base From the Merge Base, and Give a New File No Allowance
Two holes in the swap check, both about what "the base" means.
It read each changed file's prior version from the base branch's tip, while the
changed-file set comes from \. With \ moving under a branch,
that calls a violation dev added since the branch diverged this change's doing,
and forgives one this change added that dev happens to carry. It resolves the
merge base once and reads from there.
And a file with no version at the base was skipped, so adding a source with a
violation and recording it in the same commit passed as "already recorded".
A file the change adds has no allowance to inherit: every design violation in
it is new. Renames are followed (\), so the
documented re-record of a moved path stays green — its violations came with the
file.
The scenario now walks recorded, moved, swapped, added-with-its-own-entry and
renamed through one miniature repository with a history of its own.
* 🔗 fix: One Base Commit, Asked Once
\ resolved the merge base; \ still read
the baseline from the base branch's tip, so the two checks in the same run
disagreed about what "before" meant — and an entry the base branch edited after
the branch diverged entered \ as though this change had edited it.
\ resolves it once and both ask it.
* 🔤 fix: A Suppressed Message Carries Its Text
\ omitted \, and the swap check reads it
— the union of messages and suppressed messages is what a file's diagnostics
are, and what the baseline silences is exactly what that check has to see.
Nothing local caught it: node strips types without checking them and no
tsconfig covers scripts/, so the runner is now typechecked directly
([41m [0m
[41m[37m This is not the tsc command you are looking for [0m
[41m [0m
To get access to the TypeScript compiler, [34mtsc[0m, from the command line either:
- Use [1mnpm install typescript[0m to first add TypeScript to your project [1mbefore[0m using npx
- Use [1myarn[0m to avoid accidentally running code from un-installed packages, clean).
* ⚖️ fix: Let the Record Grow, Out Loud
The swap check reported every violation a change added, which also forbade the
one path CLAUDE.md sanctions: add the violation and raise the count, so the
debt arrives as a line in the diff a reviewer reads. What separates that from a
swap is not the violation, it is whether the record said so — a swap keeps the
count still.
The check now charges each new diagnostic against how much that entry's count
grew between the merge base and here, and reports only what the growth does not
cover. Measured: a swap at an unchanged count fails, the same violation with
the count raised passes, and raising nothing fails naming the class. CLAUDE.md
says which of the three is which.
* 🚧 fix: A File the Change Adds Brings No Allowance
Letting the record grow to cover a new violation is right for a file that
already existed — the raised count is the line a reviewer reads — but it also
let a brand-new source arrive with its own entry, which is the backlog
recording what the tree never owed. Growth applies only where there is
something to grow: a file present at the merge base, renamed or not. A file the
change adds owes everything in it, and the message says so.
* ✏️ test: Assert the Wording the Added-File Rule Uses
The scenario still expected "is new here" for a file the change adds, which now
gets its own message — the backlog records what the tree owed, not what a new
file brings. The harness caught it; the assertion follows the rule.
* 🧮 fix: Ask Git What Existed, and Where Its Entry Lived
Three faults in the new swap check, one cause: it inferred from content what
only git can answer.
A tracked empty file at the base reads as absent, so an existing file was
treated as newly added and its recorded growth refused. A renamed file's
growth was measured at the new key, where the base has no entry at all, so the
move itself looked like room to spend. And a base commit missing from the clone
made every changed file look new, silently.
Existence is now \ succeeding, the allowance is read under the name
the entry had (the rename map already resolved the source; it resolves the
entry too), and a base commit that is not in the clone is reported rather than
guessed at. Measured: a rename carrying a new violation with its count raised
at the new key fails, a plain rename passes, a swap at an equal count fails.
* 🗃️ fix: A Deleted Baseline Is a Deleted Baseline, Nested or Not
The root baseline's deletion failed the check; a nested one was quietly dropped
from the target list by the same \ filter that was meant to skip
paths never present. What a nested baseline silences goes back to being
reported — or stops being — and nothing else reads it, so its deletion is the
same failure and now says which file is missing.
* 📚 fix: A Baseline in the Diff Is Read in Full
The comment said a baseline named in the diff is checked in full; the code
checked only the entries the diff moved, because \ made
\ non-empty and the full-record branch required it to be empty. So a
count edit never revalidated the rest of the record — dormant paths and stale
counts in unedited entries went unread. Editing the baseline now reads every
path it records.
And the rename map keyed on git's raw line, so a CRLF checkout left a \ on
the new path and the rename never matched — the file read as added and its
inherited allowance was refused. The lines are trimmed.
* 🧷 fix: Lint the Base Under the Name It Had, and Compare a Commit With Its Parent
Two last seams in the swap check.
A renamed file's base contents were linted under the new path, so moving a
component out of \ — where overriding a primitive is the
library's own business — into \ made the destination's stricter
rules apply to both sides: the diagnostics the move itself introduces looked
inherited, and a matching entry would have hidden them. The base is linted as
the path it had.
And \, the documented single-commit mode, resolved its
comparison to \ — the commit against itself, which forgives everything.
It resolves to the commit's parent.
* 🔖 fix: Quotes Do Not Hide an Inline Rule Configuration
ESLint reads \ exactly as it reads
the unquoted key, but the comment scan required the colon to follow the rule id
directly, so the quoted form passed over a file that does not violate the rule
yet — and then silences the first violation a primitive or config fan-out
creates there. Over a violating file the two-run comparison always caught it;
the dormant case is the one that slipped. The scan accepts either quoting, and
the scenario carries the quoted spelling beside the rest.
* 🧪 perf: A Spec Is Not Component Metadata
Both roots the design rules police are full of specs — every primitive in
`packages/client/src/components` has one — and editing any of them ran the
two-root suppression sweep, because the metadata predicate asked only whether
the path was a lintable source under a metadata root. A spec is neither: the
flat config turns all five design rules off inside it and the library's
tsconfig keeps it out of the bundle the caller rules resolve primitives
through, so it carries no `cva` variant and can report no design diagnostic of
its own.
`packages/client/src/components/Button.spec.tsx` through the runner:
59.7s before, 0.0s after.
The same `DESIGN_TEST_SOURCE` test is applied everywhere the invariant holds:
the freshness input no longer treats a spec as something `dist` is built from
(directory mtimes stay, so an added or deleted source still forces a rebuild),
the directive gate no longer fails a commit over a comment in a file where the
design rules are already off, and the base-vs-head lint no longer fetches a
spec's base version to ask a question with one possible answer.
* 🔎 fix: A Build That Reports Success Is Not a Build
Two gates trusted a signal instead of the condition it stands for.
`buildClientPackage` returned on the build's exit code. A build that exits 0
while leaving `packages/client/dist` incomplete or older than its sources left
`designMetadataIsFresh()`'s answer exactly as it found it, and the suppression
lint that follows then classified callers against a bundle describing something
else — reporting fewer violations than CI, which is the failure the rebuild
exists to prevent, reached through the branch meant to fix it. The freshness
question is now asked again after a successful build, and a bundle still stale
is an error naming `packages/client/dist`.
The workflow's `on.pull_request.paths` named only the root
`eslint-suppressions.json` while the inner `suppressions` filter names
`**/eslint-suppressions.json`. A diff touching only a nested baseline outside
`api|client|config|packages|scripts` — `e2e/fixtures`, say — started no
workflow at all, so `validateSuppressions()` never ran over exactly the nested
baselines it advertises. The trigger carries both spellings now.
The miniature repository's `build:client-package` writes the bundle its
manifest promises instead of only a marker, so the rebuild rows assert a
finished build rather than a claim of one, and a build that writes nothing is
its own row.
* 🧭 fix: Ask the Tool, Don't Reconstruct Its Answer
Four findings on the last head, and three of them were the next variant of a
family this branch had already patched once. The shared cause is one habit:
the gate re-derived a fact from a cheaper proxy instead of asking whatever owns
it, and every round found another case the proxy got wrong.
An inline `eslint` configuration comment was matched by the spelling of its
rule key, so `shadcn/x`, `"shadcn/x"`, `"shadcn\/x"` and `"shadcn\u002fx"` —
one key, four spellings, all of which ESLint honours — needed four regexes.
The key is now decoded before it is compared, which ends the family rather
than adding the fourth.
Bundle freshness reduced a set of outputs to the newest file anywhere under
`dist`. A resolver loads one entry, not a directory, so a build that rewrote
the ESM bundle and left the CJS one or the type declarations behind passed
while `@shadcn/lint` read the stale one. Every declared output is compared
now, which is the same reason their existence was already checked one by one.
Target discovery read `git diff --name-only` with rename detection, which
names one side. `git mv eslint-suppressions.json backlog.json` therefore
selected no group that owns the source, and the baseline's disappearance was
reported by nothing. `--no-renames` at the single funnel makes a rename two
paths; the one caller that wants the pairing asks for it separately.
The fourth was the parity assertion added last round, sitting in the mock
Playwright lane — whose own trigger excludes `.github/workflows` apart from its
own file, so a workflow-only diff never ran the assertion guarding it. The
claim moved into the runner, where the file that can break it already
activates the group, and the scenario now exercises the runner both ways over
a copy of the real workflow instead of reimplementing its parser.
* 📝 docs: Say Which Swaps the Ratchet Misses
The ratchet paragraph claimed a swap arrives as a failure, full stop. Two do
not, and a reader deciding whether to trust the gate needs both.
A swap whose diagnostic message equals the one it replaced is invisible,
because the comparison keys on the message and some rules name the property
without the value — `Inline style sets display.` is one message for `none` and
for `flex`. And a swap the diff itself causes, by changing a primitive or the
config, is invisible because both versions are linted under the head's design
inputs.
Tracked at berry-13/LibreChat#70 and berry-13/LibreChat#69.
* 📏 chore: Re-Record the Count Dev Moved Under the Backlog
`dev` fixed one `shadcn/no-restyle` violation in
`client/src/components/SharePoint/SharePointPickerDialog.tsx`, so the rebased
branch records 8 where the file now has 7. An entry with slack is exactly what
this change added a check for: the unused 1 would silence the next violation
anyone writes there, and the gate says so rather than letting it through.
`npm run lint:design:prune` drops the one count; the trailing newline it strips
is restored by prettier, the way the pre-commit hook's `*.json` group does.
* 📝 docs: Drop a Recorded Count That Dev Keeps Moving
The backlog total moves with every rebase that picks up new violations from
`dev`, so a number written into CLAUDE.md is stale by the next re-record — it
read 3,230 against a baseline holding 3,232. The recipe does not need the
figure; `eslint-suppressions.json` is the record.
* 📏 chore: Re-Record the Counts Canary Moved Under the Backlog
Retargeting onto canary changes the tree the design rules read, so five
files carry different violation counts than the recorded backlog claims:
Wakeup and SubagentConversation shed no-restyle violations and picked up
a require-static-classes one, and Ledger, UnifiedSidebar and ChatRoute
each gained one. Re-recorded those entries and pruned; nothing else in
the baseline moved.
* 🧱 fix: Build the Primitives Before Recording What They Own
`lint:design:record` was the one exposed design script that linted without
building `packages/client/dist` first. The rules read each primitive's `cva`
variants out of that bundle, so a fresh checkout or a primitive change left
the recording run classifying against a library that does not exist yet and
writing counts lower than the ones CI reports.
Moved the build onto the record script, where the other three already have
it, and dropped it from the `lint:design:suppress` wrapper so the wrapper
still pays for exactly two builds. A build failure inside the wrapper is
still loud: the record step cannot rewrite the baseline when the build it is
chained to fails, and the prune that follows does not swallow its own.
* 📏 chore: Re-Record the Counts Canary Moved Under the Backlog
Rebasing onto canary picks up PR #15798, which rewrote the agent marketplace
components, so eighteen recorded entries no longer describe the files they
name: AgentDetail is gone, AgentCard traded its restyle and arbitrary-value
violations for inline styles, AgentDetailContent grew to eighteen restyles,
and CopyLink, GridSkeleton, Marketplace, MineFilterToggle, SortDropdown and
LoadingDots arrived with violations nothing had recorded.
Re-recorded and pruned; every entry that moved belongs to a file that commit
touched, and nothing else in the baseline changed.
* 🧪 test: Assert the Record Asks for a Build Before It Writes
The build-freshness scenario covers the runner. Nothing covered the other
entry point: `npm run lint:design:record`, which writes the budget every
later change is measured against and, until now, linted whatever bundle
happened to be on disk.
The miniature repository's `build:client-package` appends to a log, so the
scenario can ask whether the record built before it wrote, and whether a
build that fails still stops the wrapper now that the wrapper no longer
carries a build of its own.
---------
Co-authored-by: berry-13 <berry-13@users.noreply.github.com>
(cherry picked from commit 8d5e1a75ee)
Original-PR: https://github.com/LibreChat-AI/LibreChat/pull/15981
* feat: Attach or leave a workspace on a saved chat
A saved chat that recorded running without an attached environment could
never take one: `without_attached` is sealed for the life of the chat, and
the only transition was a move between attached environments. Switching an
existing chat to a coding agent therefore ended in a composer that could
send but never reach files or commands. A chat sealed to a machine that went
offline had the opposite dead end: Send stayed disabled with nothing on
offer but a reconnect hint, and the "Move the chat to continue without it"
copy promised a request the server rejected as `invalid`.
One transition mechanism now covers all three directions. `from` still
repeats the persisted decision, so a stale view cannot replace a decision it
has not seen, and only a decision the chat actually recorded can move:
- attach, from a recorded `without_attached` onto the environments its
agents use, with each target workspace revalidated on its worker;
- detach, an empty `to`, which clears the selections without polling the
machine the chat is leaving;
- move, unchanged, including its refusal to switch the workspace of an
environment the decision already covers.
The composer keeps the control visible while a chat runs without a
workspace, so that state is named and reversible, and offers "Continue
without workspace" whenever a sealed machine stops being usable. History is
untouched and no file is copied.
A send now also records the decision it was submitted under on its own
conversation. The server seals that pair for the rest of the chat, so a chat
that became saved mid-run held none while the stored one was already sealed:
the composer re-derived an agent default the seal refuses and reported
"Choose workspace" with Send disabled until the page was reloaded.
* 🎨 style: Format the Workspace Transition Hook Tests
* fix: Never seal a partial workspace decision, and gate the new directions
Two review findings on the transition mechanism.
A transition replaces the sealed decision whole, and the next turn resolves
every environment or none. The move path could not truncate before, because it
was offered only in `choose` state, which `aggregateState` reaches only when no
environment is unavailable, unsupported or missing: every environment was then
either carried over or selectable. Widening the gate to `!canSubmit` so an
unreachable machine could be left behind broke that invariant, and an
environment nothing can cover would have been dropped from a decision the
composer then sealed, blocking the next send and needing a second transition to
escape. A nonempty transition now requires every environment to be retained or
selectable; when one cannot be, only leaving attached execution is offered,
which is the escape.
The move protocol version becomes 2. Attach and detach travel on the same
owner-authorized endpoint as the move, so a client built against this change
would otherwise offer both to a deployment that only implements moves, where an
attach comes back `locked` and an empty target set `invalid`. The client already
compares the advertised number against the constant, so the bump is the gate.
`conversationMoves.enabled` still decides whether an owner may replace a sealed
decision at all.
* fix: Keep v1 moves working, and take the conversation updater from its owner
Two review findings on the previous head.
Bumping the move protocol wholesale made the client's exact-version check treat
a replica still advertising 1 as supporting nothing, so an attached chat whose
agent moved to another machine lost the relocation that replica already served
-- the previous head's own compatibility test pinned that regression. The
protocol is now two constants: version 1 is the move between attached
environments, version 2 adds attaching an environment to a chat that recorded
running without one and leaving attached execution behind. The server
advertises the highest it implements and the client keeps every lower
capability, so a v1 replica keeps its move while attach and detach wait for the
version that serves them. Where an environment cannot be covered and leaving is
not served either, no transition is offered and the composer reports the state.
Recording the submitted decision had made this hook a second Recoil writer of
the conversation atom, widening the surface that migration has to convert. The
host that owns the atom already holds its setter, so the hook receives it
instead of reaching through `~/store`.
* fix: Advertise attach and detach beside the move instead of above it
Raising the move protocol to 2 kept a client and server of the same build in
agreement, but not the two builds a rollout actually runs at once. The deployed
client compares the advertised number for equality against its own constant of
1, so a server advertising 2 tells every client already loaded in a browser that
moves are unsupported, and an attached chat whose agent changed machines loses
its relocation until that tab reloads.
The move version therefore stays 1, exactly as before this branch, and the two
new directions are advertised on their own field:
codeEnvironmentMoveVersion: 1 // move between attached environments
codeEnvironmentTransitionVersion: 2 // attach an environment, or leave one
Both come from `conversationMoves.enabled`, since all three are one owner
replacing one sealed decision. A client that predates this capability reads only
the move version and keeps working; a client that has it offers attach and
detach only where the second field appears, so it never sends a replica an
attach it refuses as `locked` or an empty target set it calls `invalid`.
* fix: Hold a send while a workspace decision is being replaced
Confirming an attach left Send enabled. The composer disables only the
workspace menu while the request is in flight, so a turn submitted right after
the confirmation started under the decision being replaced: it ran without the
workspace its owner had just chosen, and the replacement still landed, because
the stored decision the compare-and-swap expects was unchanged. The chat then
reported an environment its last turn never used, and nothing said so.
The server's own check has the same shape. It reads the conversation's
generation state, then revalidates each target workspace over the network, then
swaps; a turn that starts during that round trip is invisible to the check that
already ran. It now re-reads the generation state immediately before the swap,
leaving only the instant the compare-and-swap itself covers, which closes the
window for every client rather than just this one.
On the client the submission resolver refuses while a replacement is pending,
so the same signal disables the Send button and makes a programmatic send
return false. The workspace control stays reachable, so the state a chat is in
remains visible while it settles.
* fix: Fence Workspace Transitions and Reconcile Rejected Choices
Advance a private conversation revision after publishing an active generation and before reading its authoritative workspace decision. Transition CAS writes compare that revision, covering admission during the final idle-check window without expiring locks. Preserve tenant middleware and exclude private counter writes from chat activity and indexing.
Default existing undecided chats to no workspace, reconcile failed submissions from the server, scope pending guards to the affected chat, compare selection tuples without delimiter collisions, and opt attach/detach into deployment policy independently of existing moves. Retain legacy selection-only choices through first-send events.
* fix: Retain Failed Workspace Recovery and Skip Disabled Fences
Keep unconfirmed code-environment decisions in conversation-scoped Jotai state after authoritative reads fail, mutation observers reset, or navigation unmounts them. Expose a retry action and release submission only after a matching successful read. Ignore stale completions and reject mismatched conversation responses.
Reuse the owner-scoped conversation already loaded by each ingress when effective conversation moves are disabled. Retain the admitted revision fence for enabled moves, including move-only deployments. Cover failure/retry lifecycle and both policy branches at the helper and ingress levels.
* fix: Reuse the Atomic Admitted Snapshot Through Recovery
Return the admission revision and persisted decision from one Mongo findOneAndUpdate. Skip content indexing per internal query while retaining tenant middleware, private projection, and timestamps. Carry the returned snapshot through web and API initialization instead of validating against a stale pre-admission selection.
Keep first-send workspace choices in both the recovery preset and conversation template, and reconcile using the generated conversation ID from error or message data rather than requiring a saved submission snapshot. Cover single-query storage, indexing isolation, moved-machine validation, and first-send error recovery.
* fix: Reconcile Cancelled Turns and Keep Detach Reachable
Reconcile the submitted code decision before the local cancellation path finalizes streamed content or recovers a minimal submission. A cancel that beats the server rejection must not leave the attempted choice sealed locally; navigation must not update a different conversation.
Offer the authorized detach-only transition when a sealed conversation no longer has any attached agents, while ordinary chat stays sendable. Keep the selector visible for that transition and retain existing policy gates. Cover cancellation branches, no-attached-agent states, managed-only agents and the detach action.
* fix: Recover Workspace Transitions Across Navigation
* test: Verify Saved Workspace Transitions Against Current Dev
* test: Assert Explicit No-Workspace State in Native Journey
* test: Await Browser Settlement Before Switching Native Agents
* test: Settle URL Agent Selection Before Native Chat
* test: Wait for Worker Lease Expiry Before Offline Detach
---------
Co-authored-by: Lia <lia@librechat.ai>
Co-authored-by: Danny Avila <danny@librechat.ai>
Move the mirror out of the merge job and into a job that declares the legacy-mirror environment, so the token that can write to the legacy namespace is reachable only from workflows running on the branches that environment allows. A failed mirror can no longer fail a publish either: the job carries continue-on-error.
After a repository moves to another owner, images publish under the new owner while existing deployments still pull the old namespace. The merge job now copies each published tag to LEGACY_GHCR_OWNER when that variable and the LEGACY_GHCR_TOKEN secret are set, using GHCR cross-repo blob mounts. It stays off while the legacy owner matches the publishing owner, and never fails the build.
* 🔌 feat: OpenAPI spec and docs for the public Agents API
* 🔧 fix: address review on Agents API OpenAPI spec and docs:
- adapter: mark JSON request bodies as required
- openapi.js: move gate, config read and spec serving into packages/api
as an injected router factory; leave /api as wiring only
- docs: compute URLs in-browser and use a relative servers url so docs
work under a base path (e.g. /chat)
- build: copy the generated spec into dist so the multi-stage image ships it
- agents: reuse the enforced list envelope for the docs so the AgentList
schema cannot drift from the runtime cursor and strictness
* 🔧 fix: align Agents API OpenAPI contract with enforced runtime
Second review round on the public Agents API spec. Three cases where the
published contract described something the server does not do, so generated
clients would fail against the live API:
- Upload form: document the `purpose` field (enum: file_search, execute_code,
context) that POST /agents/{id}/files actually requires, instead of the
internal-only `tool_resource`; requests following the old form were rejected
with 400 because `purpose` was absent.
- Auth: drop the `apiKeyBearer` scheme from the management and skill endpoints.
They gate on OIDC/M2M via requireAgentManagementAuth and deliberately reject
the API-key fallback; the API key belongs to the out-of-scope
OpenAI-compatible endpoints and returns when those are documented.
- Skill update: document the 409 conflict PATCH /skills/{id} returns on a stale
expectedVersion. The agent update path collapses its internal 409 to 400, so
it correctly keeps no 409.
* 🔧 fix: serve docs on experimental server and document the 401
Third review round on the public Agents API spec.
- Experimental server: mount routes.openapi in api/server/experimental.js before its /api 404 handler. Without it, npm run backend:experimental returns 404 for /api/docs and /api/openapi.json even when openapi.enabled is true, because that server never mounted the router.
- Auth failure: document the 401 that requireAgentManagementAuth returns on a missing, expired, malformed, or unbound token. Its body is { "error": "Unauthorized" }, a flatter shape than the management error schema, so add a dedicated UnauthorizedError schema and a shared 401 response to the agent and skill contracts rather than reusing the standard error schema.
* 🔧 fix: document skill-update, auth-conflict, upload-limit, and watch-copy gaps
* 🔧 fix: added error shapes for permission denied and internal server errors in API responses docs
* fix: cover the auth-layer 500 shape and pin the account-deletion code
* fix: update OpenApiRouterDeps documentation and simplify request handling in isEnabled function
* 🔧 fix: gate the Swagger asset mount behind the OpenAPI toggle
* 🔧 fix: accept the malformed-JSON 400 envelope in the contract
* fix: accept the tenant-isolation 403 envelope in the contract
* fix: add description for content property in SkillFileUpdateRequest to clarify byte limit
Registry names must be lowercase, so derive the GHCR image and Helm chart repository from a lowercased GITHUB_REPOSITORY_OWNER instead of github.repository_owner. Compare a11y PR heads against github.repository rather than a hardcoded owner, and remove the unused Azure self-hosted runner workflows.
Profiling a full-tree lint showed import/no-cycle at 80% of rule time and
prettier/prettier at 14%. Cycles are already checked by
config/circular-deps.mjs over the bundler graph and formatting drift by
the per-file Prettier step, so the sweep now switches both rules off,
skips the base sweep when eslint.config.mjs is unchanged, and the config
drops import/no-cycle along with parserOptions.project blocks that fed
no type-checked rule. Findings for every remaining rule are unchanged.
npx joins the command into one shell string, and Linux rejects a single
argv string over 128 KiB (MAX_ARG_STRLEN), so past ~2,200 changed files
the ESLint and Prettier steps exited 249 with no output. The binaries
npm ci installed take the file list as separate arguments.