Prompted by a support ticket where a client kept failing with "the return
address this app asked for does not match the one it registered". The server
was right to refuse (RFC 8252 relaxes the PORT for a loopback redirect, never
the path), but the site owner had no way to see the stale registration, let
alone clear it, and nothing on the client side would drop it either.
Connected apps listed only clients holding a live token, because
list_authorized_clients() INNER JOINs the token table. Its own copy admitted
it: "An app that has registered but not finished signing in is not listed."
And Revoke deleted tokens while leaving the registration, which then survived
gc() forever because authorized_at was set.
- list_clients() LEFT JOINs, so a registration with no live token appears.
Connected rows sort first so the useful ones stay on page 1.
- client_state() names the three cases: connected, signed out (authorized once,
can sign back in), never signed in.
- delete_client() removes the registration and its tokens together. Tokens
first: bearer validation reads the token table alone, so one outliving its
client row would keep working.
- The panel shows each registration's callback under its name, which is the
field someone comes here to compare against a refused authorization, plus
status and registration date. Sign out (when connected) and Remove
(confirm-gated), both manage_options with a per-client nonce. Paged at 20.
Also fixes a silent action: revoke redirected with oauth_revoked=1 but nothing
rendered it, so signing an app out appeared to do nothing. All three outcomes
now show a notice.
Live-verified on msrplugins.test with three seeded registrations: cancelling
the confirm changed nothing, accepting removed the row, and Sign out flipped a
connected app to Signed out while keeping its registration.
Co-Authored-By: Claude Opus 5 <[email protected]>
The confirmation was rendered as an ordinary notice at the top of the
page and lifted into the corner by admin.js, so the browser painted it
top-left first and jumped it bottom-right a frame later. Hiding it until
the script had moved it would have fixed the flash by making the
confirmation depend on the script running at all.
PHP now renders the finished toast, so the first paint is already
bottom-right and nothing moves afterwards. admin.js only adds the
dismissing: close button, auto-hide, and a pause while it is read. If
the script never runs the toast stays until the next page load, which is
the right way round for a confirmation.
Version to 3.14.0-rc4 so the test site gets the new CSS and JS: with
WP_DEBUG off the assets are cache-busted by plugin version alone.
On jQuery ready WordPress runs
$( 'div.notice' ).not( '.inline, .below-h2' ).insertAfter( $headerEnd )
which relocates non-inline notices to just under the page h1. Our JS had already
moved this one into the toast, so core pulled it straight back and left an empty
toast at the corner with the text still at the top.
Marking the notice `inline` opts out of that relocation entirely, which beats
trying to run after it. It also degrades better: if the JS never runs the notice
renders in place rather than disappearing.
Co-Authored-By: Claude Opus 5 <[email protected]>
WordPress puts it in a stack above the page, which shifts the whole screen down
at the moment you have finished with the top of it and are looking at what you
changed. It now appears bottom right, fades after 5 seconds, and pauses if you
hover or focus it.
Only this one notice moves. Everything else stays put: page-history's rollback
conflict warning carries a "Roll back anyway" button, and a decision you have to
act on does not belong in a corner.
Bumped to rc2 because assets are cache-busted by plugin version and WP_DEBUG is
off on the test site, so syncing the files without a version change left the
browser serving the cached rc1 admin.js, which predates this.
Co-Authored-By: Claude Opus 5 <[email protected]>
A release candidate rather than a file copy onto the test site, so the test
exercises the shipping artifact: whether the Pro overlay actually places the
integration, and whether pro-manifest.txt and the Pro loader's file list agree.
Those are two separate lists, and 3.13.0 shipped a file that was in one and not
the other with a class_exists guard hiding it.
Also adds the BeTheme conflict notice for EMCP Themer and the Tools-screen
badge work to the 3.14.0 notes.
Co-Authored-By: Claude Opus 5 <[email protected]>
Reported from a live site: the Elementor Addons cards showed a greyed-out
toggle, and it took a moment to work out that this meant a missing third-party
plugin rather than a bug or a licensing limit. The explanatory line existed,
but at a glance a disabled toggle reads as broken or not-in-my-plan.
Cards now carry a badge naming the dependency ("Needs Essential Addons for
Elementor"). That needed the requirement as data rather than prose, so 53
hand-written notes became `requires => { name, kind }`. It also collapses 27
translatable strings into 2 and lets a theme dependency read differently from
a plugin one, which the single old phrasing conflated.
All 50 currently-unavailable tools carry a badge, not only the three reported.
The UAE write tool keeps its hand-written note, which varies by which UAE
plugin is present, but records the requirement too: without it, it was the one
unavailable tool on the screen with a note and no badge, sitting directly under
uae-read which had both.
The install shortcut the reporter suggested is deliberately not here. It needs
a verified wordpress.org slug per plugin, and the slugs are where this goes
wrong (UAE Pro is `ultimate-elementor`, the free one is still
`header-footer-elementor`), while several dependencies are not on
wordpress.org at all, so a search link would send people to unrelated results.
Held for 3.14.0 alongside the coming integrations, so the version is unchanged
and the notes sit under an Unreleased heading.
Co-Authored-By: Claude Opus 5 <[email protected]>
Fixes#121: editing the text of an atomic element that contains an inline tag
emptied its rich-text node tree. An html-v3 value stores the same text twice,
as markup and as a tree the editor renders from, and the wrapper always wrote
an empty tree. The page still rendered and the tool reported success, so the
only symptom was the element opening empty in the editor.
Both halves now come from one parse of the markup, with any generated id
written back before the markup is re-serialized, so they cannot drift. Ported
from Elementor's own parseHtmlChildren rather than from its description: a
node's content is the whole subtree's trimmed text, the content and children
keys are omitted when empty, and non-inline elements are descended through
rather than emitted. Three paths could flatten the tree and all three are
closed.
libxml decodes as ISO-8859-1, so input is numeric-entity encoded and decoded
on the way out; without that any non-ASCII text is mangled. Dom\HTMLDocument
would be tidier but is PHP 8.4+ and the floor is 8.1.
The snippet validator gains a third severity. Everything used to be a warning,
so a snippet built from ordinary hook code arrived covered in them, and a
reviewer told that ten routine things are warnings learns to skim. The
`definition` rule was the worst offender: its own message is about
redeclaration, which only a named function risks, yet every closure tripped
it. Messages now say what to check, and one summary() decides the verdict so
the Sandbox and PHP Templates screens cannot describe the same report
differently. The PHP Templates screen no longer paints a single routine note
as a red error.
The add path still strips inline tags. Relaxing it is what would let
add-atomic-widget author rich text, but it widens what an agent can write into
a page, so it is a separate decision.
Co-Authored-By: Claude Opus 5 <[email protected]>
The WordPress tab had grown to 21 sections, roughly half of which were not
WordPress management at all but features this plugin implements itself: the
theme builder, backup and migrate, redirects, the change ledger, the search
index, the content mirror, the snippet sandbox, project memory, cloud sync.
Thirteen sections move to a new EMCP Modules tab, leaving the WordPress tab
to mean tools that drive WordPress APIs and nothing else.
Three sections are new, filling the same gap the Themer tools had. The seven
Cloud and Marketplace tools registered and worked but were catalogued
nowhere, so they could not be toggled and no count included them. Resize
Media moves out of Stock & Media into an Image Optimization section of its
own: it is that module's only MCP tool and only registers when the module is
on, so listing it beside the stock-photo tools left a dead toggle on the
WordPress tab for anyone not using it.
After this the only uncatalogued abilities are the three compact-mode
meta-tools, which are excluded by design. Catalogued goes 260 to 267.
Nothing changed about what any tool does, its permissions, or whether it is
enabled by default.
Co-Authored-By: Claude Opus 5 <[email protected]>
The nine Themer tools have registered and worked since 3.1.0 but had no entry
in the admin catalog. Three consequences: an admin could not see them, could
not switch one off on its own, and get_available_tool_slugs() omitted them, so
every count the plugin and the website report was short by nine (251 to 260,
free 193 to 202).
They now sit in a dedicated section on the WordPress tab, module-gated exactly
like Themer PHP Templates, so turning the module off removes the section
rather than leaving dead toggles. All nine stay enabled by default: the
Modules tab toggle remains the single kill switch for the feature, and
per-tool defaults would give it a second, competing off switch.
themer_tool_slugs() joins the drift-guard exclusion list, since the tools are
legitimately absent whenever the module is off.
Co-Authored-By: Claude Opus 5 <[email protected]>
sideload-image only fetches a URL the SERVER can reach, so a client-local image had
no upload path (the reported gap). New emcp-tools/upload-media takes the file's raw
bytes as base64 (with or without a data: URI prefix), decodes to a temp file, and
hands it to media_handle_sideload() — which enforces WordPress's allowed-MIME
allowlist (executable types refused), moves it into uploads, inserts the attachment,
and generates metadata (triggering the Image Optimization / SVG modules). Gated on
upload_files; optional alt/title/caption/description/post_id and a convert_webp opt-out.
Registered by the media-library group (free, non-Elementor), listed in the Tools grid.
Live-verified: a base64 PNG uploads with correct mime/alt/dimensions; a .php upload is
refused with disallowed_type.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
migrate_tool_slugs() (4 slugs, drift-guard exclusion since the group is module-
gated); DEFAULTS_VERSION 32->33 seeds migrate-site + sync-to-live disabled-by-
default (create-backup/list-backups stay on); new 'Backup & Migrate' catalog
category so the four tools show on the Tools grid with destructive badges.
On hosts that pin the Site Address to a not-yet-live domain (e.g. Hostinger
staging), home_url() is unreachable while rest_url() answers on the real host.
The .mcpb bundle WP_URL and the OAuth issuer/authorization_endpoint used
home_url(), so they shipped/advertised an unreachable URL — the bundle couldn't
connect, OAuth discovery 404'd, and mcp-remote dropped pursuing the dead issuer.
Add EMCP_Tools_Site_Context::public_base_url() (rest_url-derived default, admin
override option, emcp_tools_public_base_url filter) + mcp_endpoint(), and route
the bundle, OAuth issuer + authorize endpoint, the Connection-tab endpoint, and
the JS config vars through it. Add an editable Server URL field on the
Connection tab. Live-verified across a simulated rest/home split; +7 tests.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
New EMCP_Tools_Redirect_Module (free, on by default) owns the store+handler boot
and gates — via static is_enabled() — the ability group in the registrar, the
Redirects admin tab, and the delete/rename suggest-only wiring, so the whole
feature can be switched off from the Modules tab. Same pattern as EMCP Themer.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The announcements panel now opens as a right-side drawer with an overlay +
close button (Escape/overlay/close all dismiss), instead of a small dropdown.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Bell + cloud buttons now share a 42px height with the Get Help pill; the cloud
button shows a dashicons-cloud with the status dot instead of the user avatar.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The 3.10.0 changelog announced bulk cloud sync but only the per-row
"Save to Cloud" was surfaced; bulk_backup() had no caller. Adds a
"Save all to Cloud" button to the sandbox cloud bar + an AJAX handler that
pushes every artifact of the kind in one call and refreshes each row's state.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
revoke_client() deletes only token rows, not the client registration, so
the gateway client stays identifiable after revoke; the before-ordering is
about observing the live token count, not identity. Comment-only.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Surfaces the number of agent-proposed project-memory entries awaiting review as
a native WordPress bubble on the Memory submenu and a red count badge on the
in-page nav, so pending proposals aren't forgotten.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Makes the "AI-safe transactions" promise real across every write domain.
- EMCP_Tools_Change_Blobs: durable {prefix}emcp_change_blobs table so large
before-images live out-of-band; the ledger row keeps only a blob_id pointer
(retention grown 200 -> 500 rows; blobs prune with their entry).
- EMCP_Tools_Change_Recorder: the facade every write site records through;
offloads >4KB before-images to the blob store and stamps an after_hash.
- Conflict guard: rollback($id, $force) refuses with 'conflict' when the target
changed since (unless force). rollback-change gains a force param; the History
tab shows a conflict notice + "roll back anyway" and a partial-rollback warning.
- New rollback types wired per domain: post-fields / post-create / post-restore
(content, media update, Gutenberg), option (settings), meta-before-image
(globals/ACF), attachment-delete (reversible delete-media incl. file trashing),
user-create / user-fields, acf-fields.
- DB hardening: update rollback refuses empty key_cols (never unscoped) and
flags a capped before-image as partial.
- Audit-log removal: the write-only fs/db guard audit options are gone; the
ledger is the single audit + rollback source.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Loads the Blocksy Pro classes via the Pro-Loader, adds them to pro-manifest.txt,
registers the two integrations in the Themes loop, adds a "Blocksy" admin card +
theme_tool_slugs entries + availability helpers, and seeds the two write
dispatchers disabled-by-default (DEFAULTS_VERSION 30->31).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Loads the GP+GB Pro classes via the Pro-Loader, adds them to pro-manifest.txt
(free-leak gate), registers them in the Themes-integration loop (present only
when Pro is loaded + the framework is active), adds a "GeneratePress +
GenerateBlocks" admin card + theme_tool_slugs entries, and seeds the two write
dispatchers disabled-by-default (DEFAULTS_VERSION 29->30).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds a reorder-global-classes tool to the global-classes-write domain: the
Elementor v4 Class Manager order IS the CSS source order, so it decides which
class wins at equal specificity. { order: [g-id, ...] } sets the top-to-bottom
order via Elementor's own update_order_and_labels() (order only, mirrored to
preview); any classes the caller omits are appended in their current order so
none drop out. Union of the current order + items map is treated as authoritative
so a class present in one but not the other is never lost. Gated on
elementor_global_classes_update_class, disabled-by-default (DEFAULTS_VERSION
28->29), in the Atomic Layout admin group. Verified live on Elementor 4.2.1.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
An artifact pushed/published before checksum tracking (or via a path that didn't
record it) has an empty _emcp_cloud_checksum baseline. artifact_changed() treated
that empty baseline as "unchanged", so the Sandbox row showed "Up to date" and
the "Push update" button never appeared — the reported Audio Player symptom.
Now an empty baseline on a pushed artifact returns changed=true (we can't prove
it's unchanged, so allow the update). Pushing (or re-saving) records a fresh
baseline via store_artifact_checksum(), which self-heals the state back to
"Up to date" until the next edit. Verified live: widget 764 now reports
changed=true -> Push update shows.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds authoring for Elementor v4 Global Classes (Class Manager), the write
counterpart to the read-only list-global-classes. New
EMCP_Tools_Global_Classes_Write_Abilities (free tree, registers when Elementor
4.0+'s Global_Classes_Repository is present) exposes three tools:
- create-global-class { label, styles?, props?, breakpoint?, state? } — mints a
g- id, builds one variant, returns the id to apply to elements.
- update-global-class { id, label?, styles?, props?, breakpoint?, state?,
replace_variant? } — merges (or replaces) props into the variant for a
breakpoint+state; updates the label.
- delete-global-class { id, confirm } — removes the class (Elementor cleans its
usages); destructive, confirm-gated.
Writes go through Elementor's own repository (read all() -> mutate id->item map ->
put(items, order), mirrored to preview so the editor reflects it). Style props
reuse our atomic builders (EMCP_Tools_Atomic_Styles / _Atomic_Props): a friendly
flat `styles` object (background/color/padding/margin/border-radius/flex...) plus
a raw `props` escape hatch, so callers don't hand-write $$type. Gated on
Elementor's own elementor_global_classes_update_class capability (fallback
manage_options); all three ship disabled-by-default (DEFAULTS_VERSION 27->28),
surfaced in the Atomic Layout admin category. Verified live on Elementor 4.2.1:
create/update(merge + hover variant)/delete all persist and read back.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds first-class Kadence support to the Themes domain, on the existing
two-dispatcher EMCP_Tools_Theme_Integration base (mirrors Astra + Spectra),
all live-verified against Kadence 1.5.2 + Kadence Blocks 3.7.9.
Theme settings — kadence-read / kadence-write (registers only when Kadence is
the active theme): get-settings / update-settings over a curated, typed
allowlist read via Kadence\kadence()->option() and written as theme_mods —
palette, colors, typography (base/heading/h1-h6), layout, buttons,
header/footer. Each key carries a shape hint (values are structured objects
referencing the 9-slot global palette). Kadence renders CSS inline, so no
cache to bust.
Kadence Blocks — kadence-blocks-read / kadence-blocks-write (registers when
Kadence Blocks is active): list-blocks (curated catalog of the 32 top-level
blocks), get-block-schema (real attributes + defaults straight from the live
WP_Block_Type_Registry, curated highlight subset, RichText content_attributes,
container inner_blocks), add-block (generates a uniqueID, scaffolds verified
container children — rowlayout->column, advancedbtn->singlebtn, icon->
single-icon, iconlist->listitem, testimonials->testimonial — and renders every
source:html|rich-text attribute into the saved markup using its own registry
selector, so heading text and info-box title/text display headlessly).
New: EMCP_Tools_Kadence_Blocks_Catalog, EMCP_Tools_Kadence_Integration,
EMCP_Tools_Kadence_Blocks_Integration. Wired in the ability registrar +
bootstrap; admin Themes tab gains a "Kadence + Kadence Blocks" card;
theme_tool_slugs() +4; DEFAULTS_VERSION 26->27 seeds the two write dispatchers
disabled-by-default. Verified end-to-end by building a Kadence Blocks landing
page over add-block (nested columns, palette styling, RichText content all
render + self-style) and a theme update-settings roundtrip.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Both the "Import a bundle" and "Cloud Library" rows were plain
disclosure-triangle text, easy to miss. Restyle both <details> as bordered
cards with a filled summary bar, dashicon, and a right-aligned chevron that
rotates on open (.emcp-sb-disclosure). The Cloud Library gets an accent variant
(purple tint + "Cross-site" pill) since it's the headline cross-site feature.
Flat colors, no gradients. Import summary drops the manual "+" for an upload
dashicon. CSS in admin.css; markup across the 3 sandbox views + the renderer.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The cross-site "save to cloud on one site, use it on another" path existed only
as MCP tools (cloud-list/cloud-pull) with no human-facing UI: the sandbox
screens shipped only the push side (Save/Update to cloud, Refresh status,
marketplace publish), so an admin on a second connected site had no way to see
or import what was pushed elsewhere. The backend already lists artifacts
workspace-wide (GET /api/cloud/v1/artifacts) and pull imports a bundle as a new
inactive draft — only the browse+import panel was missing.
Adds a lazy-loaded "Cloud Library" <details> panel to the Blocks / Widgets /
PHP Snippets screens (EMCP_Tools_Admin::render_cloud_library), gated on cloud
connection. It lists every workspace artifact of that kind across all connected
sites, badges ones that originated from this site, and Imports one into the
current site via EMCP_Tools_Cloud_Sync::pull() -> new inactive draft. Two AJAX
handlers (emcp_tools_cloud_library / emcp_tools_cloud_import), reusing the
per-kind cloud nonce + manage_options gate. Self-contained cloud-library.js
mirrors sandbox-cloud.js (no localized object; labels via data-* attrs) and
prefers a server-supplied origin site name/url when present, so a later backend
enrichment upgrades the "From" column with no plugin change.
Verified live on msrplugins.test against production: list_remote returns the 8
widgets pushed from elementor-mcp.test; importing one creates a new draft
(count 1->2, status=draft).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A cloud-side delete left the sandbox showing an artifact as Saved forever
because _emcp_cloud_pushed was set on Save and never re-verified (the
on-load sync only refreshed marketplace-listing state, not backup
existence). Add verify_cloud_backup() — GET the artifact by uuid and clear
the pushed flag only on a definitive 404/410 (transient network/5xx errors
never drop a real save) — plus an emcp_tools_resync_cloud AJAX action and a
page-level 'Refresh cloud status' button (injected above the artifact table
on all three sandbox pages) that resyncs every row. Verified live: a
cloud-deleted widget reverts to 'Save to Cloud', a 500-erroring one stays.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Top announcement bar is now a permanent full-width edge-to-edge strip
(matches the app-bar breakout); removed the dismiss button + localStorage.
- Dashboard 25% column split into two stacked banners: EMCP Cloud + EMCP Pro.
The Pro banner shows an upgrade CTA on free installs, or a 'You're on the
Pro plan' notice when premium.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Rotating announcement bar above the app bar: EMCP Cloud (always) + a
limited-time LTD promo (free installs only), auto-rotating with dots +
dismiss (localStorage). Flat, on-brand.
- 'Explore your toolkit' is now a 75/25 row with a flat EMCP Cloud banner
(back up / sync / publish) on the right.
- Header: 'Affiliate Program' -> 'Affiliate'; MCP Log moved from the tab nav
to the app-bar buttons, before History.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Restyle all Sandbox row actions (Activate/Deactivate, Edit, Delete, Export,
Save to Cloud, Publish/View, Push update, View code) to match the plugin's
design tokens (--mcp-* colors, radius) instead of default WP .button, via a
scoped .emcp-sb-actions block in admin.css. Add a dashicon to each button;
Delete gets a danger treatment. Cluster CSS moved from inline into admin.css.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
WordPress .button (display:inline-block) overrides the [hidden] attribute,
so every cloud button showed at once (both Publish and View). Render the
correct visibility server-side via inline display (works without JS) and
toggle via style.display in JS; Publish and View are now mutually exclusive
(Publish when unpublished, View once listed). Add dashicons + button-primary
styling to the marketplace CTAs; relabel the save button without wiping its
icon.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Sandbox artifact rows now show state-aware cloud buttons via a shared
renderer + sandbox-cloud.js: Save to Cloud -> (Saved/Update cloud) ->
Publish to Marketplace -> View on Marketplace, with a 'Push update' action
when a published artifact has local edits (re-push + re-review). State is
cached in per-artifact meta (_emcp_cloud_checksum / _emcp_marketplace_*) and
refreshed against the cloud on page load.
- Cloud_Sync: marketplace_state(), push_update() (re-push + flag pending),
marketplace_view_url().
- Admin: cloud_action_payload/render_sandbox_cloud_actions + ajax_push_update
+ ajax_marketplace_state; backup now stores checksum + refreshes state.
- All three sandbox views use the shared cluster; dead inline handlers removed.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Each Sandbox artifact (block/widget/snippet) gains a Publish to Marketplace
button next to Save to Cloud, disabled until the artifact is pushed to the
cloud. Once backed up it links to the website submit page for the artifact's
stable uuid (<cloud>/account/marketplace/submit?artifact=<uuid>) in a new tab;
the backup handler records the push (_emcp_cloud_pushed) and returns the URL so
the button enables without a reload. New EMCP_Tools_Cloud_Sync::publish_url()
resolves the uuid via the Sandbox_Artifact interface (works for all 3 kinds).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Wires EMCP_Tools_Forminator_Integration into the registrar loop + Pro loader,
adds the Forminator card (wp_forminator, forms group) with forminator_available(),
seeds forminator-write disabled-by-default (DEFAULTS_VERSION 26), and adds both
slugs to the drift guard + pro-manifest.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Bug report Issue 5: the client only sees generic error strings with no
server-side diagnostics. Records each MCP request (tool, status, duration,
request id) to a capped option via rest_pre/post_dispatch on the MCP route,
surfaced on a new 'MCP Log' admin tab. The underlying error is stored only
when WP_DEBUG is on. 3 unit tests.
Co-Authored-By: Claude Opus 4.8 <[email protected]>