diff --git a/.github/product-facts.json b/.github/product-facts.json index 21a3ec3..c197a55 100644 --- a/.github/product-facts.json +++ b/.github/product-facts.json @@ -1,10 +1,10 @@ { "_comment": "Source of truth for the product facts that appear in more than one document. scripts/check-doc-consistency.php fails the build when a document contradicts this file, so a number that changes gets changed once here and then everywhere the checker points at. Pro totals that cannot be derived from this repository are deliberately absent: an aggregate nobody can verify from source is how the last set of contradictions got in.", - "version": "1.15.1", + "version": "1.16.0", "requires_wordpress": "6.9", "tested_wordpress": "7.1", "requires_php": "8.0", - "free_abilities": 168, + "free_abilities": 216, "free_elementor_abilities": 17, "elementor_minimum": "3.6", "elementor_atomic_style_properties": 73, diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 336f5cd..f6a3126 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,27 @@ +v1.16.0 - 2026/09/30 +* New, formerly WPPilot Pro: WooCommerce basics. wppilot/woocommerce-check-setup, list and read products and variations, product categories and tags, store settings, and read orders and customers (orders and customers also need manage_woocommerce), with the input and output Pro gives them. wppilot/woocommerce-edit-product edits one product's name, description, prices and stock, saves through WooCommerce so its lookup tables and caches follow, keeps backslashes, and can be undone from the Changes screen: the undo writes the old values back through WooCommerce and says which fields did not come back. Bulk prices and stock, order and customer writes, refunds, coupons, reports, shipping, tax, gateways and full product editing (SKU, categories, images, attributes) stay in Pro, whose richer edit-product takes over when Pro is active. +* New, formerly WPPilot Pro: one post's SEO title, meta description and robots in Yoast SEO, Rank Math, All in One SEO, SEOPress, The SEO Framework, Slim SEO and SmartCrawl (a get and an edit per plugin, under the names Pro uses; only the active plugins' abilities register). A raw meta write could trip Yoast's 0/2/1 noindex codes or AIOSEO's all-or-nothing robots switch; these write the way each plugin's own editor does. Every write keeps a before-image of exactly what it changed and wppilot/rollback-change restores and checks it; AIOSEO, which keeps SEO in its own table, is restored through its own model. Slim SEO cannot force index and has no follow flag, and SEOPress has no per-post force-index; the descriptions say so. Focus keywords, canonicals, social previews, schema, redirects and bulk SEO stay in Pro, whose richer per-post abilities register first on a site running it. +* New, formerly WPPilot Pro: read-only forms. List the forms of WPForms, Contact Form 7, Gravity Forms and Forminator, and read their entries (Contact Form 7's through Flamingo; WPForms entries need WPForms Pro). Entries come with email and phone answers, passwords, card data, uploads, signatures and the IP withheld, and email addresses and phone numbers cut out of free text; full values, exports and form editing are Pro's. +* New, formerly WPPilot Pro: backup and security status. wppilot/backup-status and wppilot/backup-list read UpdraftPlus, Duplicator 5 and BackWPup; wppilot/security-plugin-status, wppilot/security-scan-findings and wppilot/security-lockouts read Wordfence and Solid Security, with IP addresses shown as /24 or /48 networks and logins masked. Starting a backup, the require-a-fresh-backup hold, safe updates, hardening, IP blocks and scans stay in Pro. +* New, formerly WPPilot Pro: scheduled audits. A routine runs the read-only accessibility, content and media alt-text audits on WP-Cron, daily or weekly in the site's timezone, stores each run, compares it with the one before (new, resolved, changed) and emails the administrators it names. wppilot/routines-list and wppilot/routines-report read; wppilot/routines-save and wppilot/routines-delete can be undone from Changes; wppilot/routines-run-now queues a run. A Routines screen under Activity has a Run now button. Routines made with Pro keep running unchanged: the storage, cron hooks and undo are the same. +* New: undo a whole agent session. Every write an agent makes is filed under its session - one MCP connection, one wp wppilot mcp serve process, or one credential and client until it has been idle for 30 minutes - and wppilot/undo-session takes back everything that session did, newest first, each change verified against its before-image. Before writing anything it checks that nothing else changed a target after the session did; if something did, nothing is changed and the conflict is named (which change, which fields, which later edit). It stops at the first change that fails verification and reports what was undone, what failed and what was not attempted, and a change that cannot be undone is listed rather than skipped (the run is refused unless allow_partial is true). wppilot/redo-session puts an undone session back under the same checks, verified against the state the change first produced. wppilot/list-sessions lists sessions and shows what undo and redo would do now. The Changes screen lists recent sessions and has Undo session and Redo session buttons on a session's view. Works for Pro's writes that go through the change log. +* New: a local connection over WP-CLI. wp wppilot mcp serve --user= serves the same MCP server as /wp-json/mcp/wppilot over stdio, for Claude Code, Cursor or a CI job on the machine that runs the site, with the same abilities, safety profile, confirmations and permission checks and no credential to create. The Connect screen shows the claude mcp add command and a Cursor mcp.json with this site's path filled in. +* New: native OAuth entries for Zed, Cline, Kilo Code, OpenCode, Amazon Q / Kiro and OpenClaw, which now run the sign-in themselves (only Roo Code still uses the mcp-remote bridge on a public site), and OAuth tabs for Kimi Code CLI, Qwen Code, ZCode, Factory Droid and OpenClaw, which had none (Gemini CLI gets one on a local site; its public-site entry is still to come). Manus can take an access token, as its documentation lists Bearer tokens for custom servers. +* Works beside WPPilot Pro 1.10.0, which still carries its own copies of all of the above: Pro registers first, and each of these abilities stands aside for a name that is already registered instead of registering it twice, so nothing changes on a site running Pro 1.10.0 until Pro's next release. While Pro 1.10.0 runs routines, the free copy stays off, so no routine runs twice. +* Kits: runtime 1.2 adds Runtime\unclaimed(), kit.json requires.any (a kit serving whichever of several plugins is active) and a SessionLedger host interface through which a kit lets session undo check and redo its own kind of change (the WooCommerce product edit, All in One SEO and routines use it), and loads Pro-tier kits ahead of Free ones in an export, so a richer copy of an ability wins there as it does inside WPPilot. scripts/test-kit-coexistence.php loads Pro's kits in the order Pro's engine does and takes --installed-pro= to prove Free stands aside for a release that still carries the same abilities. +* Fixed: wppilot/delete-comment deletes a comment permanently, but the change log filed it as a reversible status change, so the Changes screen and session undo offered an undo that could only fail. It is now recorded as irreversible. +* Fixed: wppilot/delete-theme failed on every MCP call with "undefined function delete_theme()", because WordPress does not load its theme admin functions on REST requests. +* Fixed: wppilot/adopt-design-from-site threw a TypeError on themes whose spacing sizes have numeric slugs, such as Twenty Twenty-Five. +* Fixed: wppilot/update-media and wppilot/upload-media dropped backslashes from alt text, because WordPress unslashes meta on write. +* Fixed: the approval, OAuth consent, Block Editor Queue, visual runtime and Connected Apps screens logged a PHP deprecation on every load because they had no title. +* Fixed: the Dashboard's list of recent changes logged a WordPress notice for every change made by an ability that is no longer registered, such as an SEO plugin's edit after that plugin was switched off. It now reads the label only for abilities still registered. +* Fixed: every call made through the legacy MCP transport recorded a second change row for the adapter's execute tool, reads included, each saying "No supported before-image". The target ability's own row is unchanged; the extra row is no longer written. +* Fixed: generated client configuration that no longer matched the clients' own documentation (each re-checked against the vendor's docs on 2026-09-30, with the source cited in the code). Kimi now targets Kimi Code CLI (~/.kimi-code/mcp.json, /mcp-config, and /mcp-config login for OAuth) instead of the archived Python kimi-cli. GitHub Copilot snippets are for Copilot CLI (~/.copilot/mcp-config.json or .github/mcp.json, "mcpServers" with "tools"), since .github/copilot/mcp.json is not a documented location and Copilot CLI rejects the "servers" key; the notes say Copilot Chat in VS Code uses the VS Code tab and the cloud agent cannot use OAuth. Devin Desktop lists ~/.config/devin/mcp_config.json alongside the old Windsurf path. Kilo Code entries go in kilo.jsonc under "mcp". OpenClaw's token entry uses mcp.servers with transport "streamable-http" (it fell back to SSE before). Zed connects natively instead of through mcp-remote, without the retired "source" field. Claude Desktop's access-token entry runs through the mcp-remote bridge, because claude_desktop_config.json is for local servers. The Claude Code note no longer says /mcp shows a working token as "needs authentication", the Antigravity IDE hint names the IDE's own menu, and Roo Code points to Cline or ZooCode. +* Fixed: the change history hid fewer credentials than it should. Option names such as LiteSpeed's object-pswd and cdn-cloudflare_key, NitroPack's siteId, Autoptimize's ccss_key and any *_pass, *_pwd or *_auth key were shown in plain text in get-change, list-changes and the Changes screen; they are now withheld like passwords and tokens. +* Fixed: wppilot/read-file and wppilot/list-directory ran under Production Safe and even Read Only, because a read-only ability passed every profile, so an agent on those profiles could read wp-config.php with its database password and authentication salts. Both now need Developer Full Access like the other server-file abilities, and each call is recorded in the change log. +* The change log table gains two columns (session_id, redo_data); existing rows are kept and carry no session. +* 48 new abilities (216 in all). wppilot/read-file and wppilot/list-directory now need Developer Full Access; no other existing ability changed its permissions, and existing connections keep working and do not need re-authorising. + v1.15.1 - 2026/09/29 * Fixed: with Wordfence active, every MCP call made with an Application Password returned 401 and nothing said why. Wordfence switches Application Passwords off by default (its "Disable WordPress application passwords" setting), and the Connection Doctor, Diagnostics and the Connect screen only said "a plugin or code". They now name Wordfence and the exact setting to uncheck, name any other plugin that switches Application Passwords off by the file its filter lives in, and point to OAuth or an access token, which do not need Application Passwords. * Fixed: change records showed email addresses in full. wppilot/get-change, wppilot/export-changes, the Changes screen and its download redacted secrets by key name only, so an address typed into an excerpt or a comment came back as written, and get-change returned before-images with no redaction at all. Email addresses in the input, the result, the before-image and the undo verification are now masked when a record is read (j***@e***.com), and before-images get the key-name redaction too. What is stored is unchanged, so undo still restores the exact original. diff --git a/README.md b/README.md index 6a5835c..0b19f9f 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,33 @@ An access token borrows the capabilities of the user who created it, and that ch None of the three is a product licence. WPPilot needs no activation key, entitlement check or subscription service to run. +### Local connection over WP-CLI (stdio) + +A client that runs on the same machine as the site - Claude Code, Cursor, a CI job - can start WPPilot itself instead of connecting over HTTP, with no credential to create. `wp wppilot mcp serve` serves the same MCP server as `/wp-json/mcp/wppilot` over stdin/stdout, as the WordPress user named in WP-CLI's `--user`, with the same abilities, safety profile, confirmations and permission checks: + +```bash +# Claude Code +claude mcp add wppilot-local -- wp --path=/var/www/html wppilot mcp serve --user=admin + +# WordPress in Docker +claude mcp add wppilot-local -- docker exec -i wp --path=/var/www/html wppilot mcp serve --user=admin +``` + +Cursor and other clients that take a command (`.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "wppilot-local": { + "command": "wp", + "args": ["--path=/var/www/html", "wppilot", "mcp", "serve", "--user=admin"] + } + } +} +``` + +The **Connect** screen shows these with this site's path and your login filled in. Nothing is written to stdout except protocol messages, and each process is one agent session on the Changes screen. + ## Safety model | Profile | What it allows | @@ -118,11 +145,13 @@ None of the three is a product licence. WPPilot needs no activation key, entitle On top of the profile: WordPress user capabilities still apply, individual abilities can be switched off, destructive operations require an explicit confirmation flag, writes are rate-limited per credential, and supported changes are recorded in a redacted change ledger with rollback. +**Undo a whole agent session.** Every write an agent makes is filed under its session - one MCP connection, one `wp wppilot mcp serve` process, or one credential and client until it has been idle for 30 minutes. `wppilot/undo-session` (or **Undo session** on the Changes screen) takes back everything that session did, newest first, verifying each change against its before-image. It refuses without touching anything when something else changed a target after the session did, stops at the first change that fails verification and says exactly what was and was not undone, and never skips a change that cannot be undone without saying so. `wppilot/redo-session` puts an undone session back, under the same checks. `wppilot/list-sessions` shows each session and what undo and redo would do right now. + Every ledger entry names the agent behind the write, not only the WordPress user. Claude Code, Cursor and Codex usually connect as the same administrator, so the user alone cannot answer which of them made a change: the OAuth client id or application-password UUID can, and it is what the ledger records, alongside the client name the agent introduced itself with. A write with no agent behind it - wp-admin, WP-CLI, cron - is recorded as `direct` rather than credited to the last agent seen. OAuth client ids are stored hashed. ## What the free plugin can do -168 registered abilities on a fresh install, plus one MCP prompt per skill you save. The WordPress ones are grouped under a single **WordPress** category in the Abilities screen and can be switched off individually. +216 registered abilities, plus one MCP prompt per skill you save. The WooCommerce, SEO, form, backup and security abilities register only while their plugin is active, and developer abilities only under Developer Full Access, so a fresh install with none of those plugins registers 154 on the default profile. The WordPress ones are grouped under a single **WordPress** category in the Abilities screen and can be switched off individually. | Domain | Abilities | What it covers | | --- | --- | --- | @@ -140,7 +169,7 @@ Every ledger entry names the agent behind the write, not only the WordPress user | **Design system** | `19` | Typed design tokens, saved designs and activation, plus the checks that grade a built page against them: contrast, composition, layout grammars and a rendered-page verification pass. | | **Preview** | `8` | Compute what a write would change without performing it, then apply the reviewed result. Plus a view link, so an agent with a browser can look at the page it built - including one still in draft - and a capture store that compares two screenshots of a page and reports which regions moved. | | **Skills** | `4` + prompts | Reusable skills and site-wide instructions. Each saved skill also registers one MCP prompt, so this grows with the skills you write. | -| **Changes** | `5` | Read the redacted change ledger, attributed to the agent credential that made each write, export it as rows for a client report or an audit, and roll a change back. | +| **Changes** | `8` | Read the redacted change ledger, attributed to the agent credential that made each write, export it as rows for a client report or an audit, roll a change back, and undo or redo everything one agent session did. | | **Diagnostics** | `4` | Scoped health, performance and configuration-security checks, and a Connection Doctor that finds what blocks an MCP client (firewalls, a stripped Authorization header, disabled Application Passwords) and names the fix. | | **Search and replace** | `4` | Preview a search-and-replace across posts and their meta (serialized and builder JSON included) as a reviewed plan, apply only posts unchanged since, 100 per call or as a background job, cancel it, and undo one post or the whole run. | | **Media and accessibility** | `5` | Resize, crop, rotate or flip an image as a copy or in place, with undo. Audit a served page against WCAG 2.2, find images with missing or filename alt text, show an image to the model so it can write real alt text, and set alt text in bulk with undo. | @@ -148,6 +177,11 @@ Every ledger entry names the agent behind the write, not only the WordPress user | **Block Notes** | `4` | Leave a note on a block for a person, reply, resolve, and read the notes they resolved. Every one can be undone. | | **Site tools** | `8` | WP-Cron list, run and delete (undoable), Site Health tests, transient flush, and - on Developer Full Access, audited - an options explorer and a read-only database SELECT with redaction. | | **Multisite** | `2` | List a network's sites and run an ability on one of them through that site's own safety profile and gates. Loads only on a multisite network. | +| **WooCommerce** | `13` | Check the setup, list and read products, variations, categories, tags and store settings, read orders and customers, and edit one product's name, description, prices and stock with undo. Pro adds bulk prices and stock, order and customer writes, refunds, coupons, reports and full product editing. | +| **SEO** | `14` | One post's SEO title, meta description and robots in Yoast SEO, Rank Math, All in One SEO, SEOPress, The SEO Framework, Slim SEO and SmartCrawl, read and set with undo. Pro adds focus keywords, canonicals, social previews, schema, redirects and bulk SEO. | +| **Forms** | `8` | List the forms of WPForms, Contact Form 7, Gravity Forms and Forminator and read their entries with email addresses, phone numbers and sensitive fields withheld. Pro adds full values, exports and form editing. | +| **Backups and security** | `5` | Backup status and history (UpdraftPlus, Duplicator, BackWPup); security plugin status, scan findings and lockouts with IPs shown as networks (Wordfence, Solid Security). Pro starts backups, holds risky calls until a fresh backup exists, hardens settings and blocks IPs. | +| **Scheduled audits** | `5` | Routines that run the accessibility, content and alt-text audits on a schedule, store each report, compare it with the last and email what changed. | | **Developer** | `13` | PHP execution, WP-CLI, filesystem and temporary admin access. Blocked outside Developer Full Access, and excluded entirely from the WordPress.org build. | Content creation is draft-first: an absent, blank or malformed status resolves to `draft` before any capability check, so nothing is published by accident. Capabilities are read from each post type's and taxonomy's own capability object, so a custom type declaring its own set is enforced on its own terms. @@ -190,7 +224,7 @@ The dividing line is simple: free can **edit** an Elementor page, Pro can **comp ## WPPilot Pro: plugin-aware abilities across 88 integrations -The free plugin in this repository is a complete WordPress MCP server: connection, authentication, safety profiles, Gutenberg workflows, **Elementor editing**, the design system, diagnostics, change evidence and **168 abilities**, including the whole WordPress core surface: content, taxonomies, media, comments, revisions, menus, user reads, allowlisted settings and the plugin/theme lifecycle. Free needs no licence, entitlement service or Pro install. +The free plugin in this repository is a complete WordPress MCP server: connection, authentication, safety profiles, Gutenberg workflows, **Elementor editing**, the design system, diagnostics, change evidence and **216 abilities**, including the whole WordPress core surface: content, taxonomies, media, comments, revisions, menus, user reads, allowlisted settings and the plugin/theme lifecycle. Free needs no licence, entitlement service or Pro install. [**WPPilot Pro**](https://wppilot.co/pro) adds **plugin-aware abilities across 88 integrations** (the plugins, themes and builders in the table below plus [26 caching and optimization layers](https://wppilot.co/solutions/performance)), typed operations that understand each plugin's own data model rather than writing generic content. Modules load only when their plugin is detected, and each loads in isolation, so a missing or broken plugin cannot stop the rest of the registry from registering. @@ -199,14 +233,14 @@ The free plugin in this repository is a complete WordPress MCP server: connectio | **Page builders** | [Elementor](https://wppilot.co/integrations/elementor) `51` · [Bricks](https://wppilot.co/integrations/bricks) `49` · [Breakdance](https://wppilot.co/integrations/breakdance) `33` · [Divi](https://wppilot.co/integrations/divi) `47` · [Oxygen](https://wppilot.co/integrations/oxygen) `37` · [Beaver Builder](https://wppilot.co/integrations/beaver-builder) `21` · [WPBakery](https://wppilot.co/integrations/wpbakery) `18` · [Etch](https://wppilot.co/integrations/etch) `60` · [Mosaic](https://wppilot.co/integrations/mosaic) `41` · [Flatsome UX Builder](https://wppilot.co/integrations/flatsome) `11` | | **Blocks and site design** | [GenerateBlocks](https://wppilot.co/integrations/generateblocks) `3` · [Kadence Blocks](https://wppilot.co/integrations/kadenceblocks) `5` · [Spectra](https://wppilot.co/integrations/spectra) `20` · [Spectra One](https://wppilot.co/integrations/spectra-one) `22` | | **Themes** | [Astra](https://wppilot.co/integrations/astra) `34` · [Avada](https://wppilot.co/integrations/avada) `16` · [GeneratePress](https://wppilot.co/integrations/generatepress) `23` · [Kadence](https://wppilot.co/integrations/kadence) `5` · [OceanWP](https://wppilot.co/integrations/oceanwp) `15` · [WordPress Block Themes](https://wppilot.co/integrations/block-themes) `4` · [Blocksy](https://wppilot.co/integrations/blocksy) `4` · [Neve](https://wppilot.co/integrations/neve) `4` · [WoodMart](https://wppilot.co/integrations/woodmart) `4` | -| **Commerce** | [WooCommerce](https://wppilot.co/integrations/woocommerce) `65` · [FunnelKit](https://wppilot.co/integrations/funnelkit) `4` | -| **Forms** | [WPForms](https://wppilot.co/integrations/wpforms) `28` · [Gravity Forms](https://wppilot.co/integrations/gravityforms) `28` · [Fluent Forms](https://wppilot.co/integrations/fluentforms) `37` · [Formidable Forms](https://wppilot.co/integrations/formidable) `39` · [Contact Form 7](https://wppilot.co/integrations/contact-form-7) `9` · [Ninja Forms](https://wppilot.co/integrations/ninja-forms) `21` · [Forminator](https://wppilot.co/integrations/forminator) `4` · [WS Form](https://wppilot.co/integrations/ws-form) `4` | +| **Commerce** | [WooCommerce](https://wppilot.co/integrations/woocommerce) `53` · [FunnelKit](https://wppilot.co/integrations/funnelkit) `4` | +| **Forms** | [WPForms](https://wppilot.co/integrations/wpforms) `27` · [Gravity Forms](https://wppilot.co/integrations/gravityforms) `27` · [Fluent Forms](https://wppilot.co/integrations/fluentforms) `37` · [Formidable Forms](https://wppilot.co/integrations/formidable) `39` · [Contact Form 7](https://wppilot.co/integrations/contact-form-7) `8` · [Ninja Forms](https://wppilot.co/integrations/ninja-forms) `21` · [Forminator](https://wppilot.co/integrations/forminator) `3` · [WS Form](https://wppilot.co/integrations/ws-form) `4` | | **SEO suites** | [AIOSEO](https://wppilot.co/integrations/aioseo) `12` · [Rank Math](https://wppilot.co/integrations/rank-math) `8` · [SEOPress](https://wppilot.co/integrations/seopress) `16` · [Yoast SEO](https://wppilot.co/integrations/yoast) `10` · [The SEO Framework](https://wppilot.co/integrations/the-seo-framework) `3` · [Slim SEO](https://wppilot.co/integrations/slim-seo) `3` · [SmartCrawl](https://wppilot.co/integrations/smartcrawl) `3` | | **Custom data** | [Advanced Custom Fields](https://wppilot.co/integrations/acf) `23` · [ACPT](https://wppilot.co/integrations/acpt) `24` · [Admin and Site Enhancements](https://wppilot.co/integrations/ase) `18` · [JetEngine](https://wppilot.co/integrations/jetengine) `26` · [Meta Box](https://wppilot.co/integrations/meta-box) `32` · [Pods](https://wppilot.co/integrations/pods) `25` · [Dynamic Shortcodes](https://wppilot.co/integrations/dynamic-shortcodes) `9` | | **Localization** | [Weglot](https://wppilot.co/integrations/weglot) `19` · [WPML](https://wppilot.co/integrations/wpml) `8` · [Polylang](https://wppilot.co/integrations/polylang) `6` | -| **Backups** | [UpdraftPlus](https://wppilot.co/integrations/updraftplus) `3` · [Duplicator](https://wppilot.co/integrations/duplicator) `3` · [BackWPup](https://wppilot.co/integrations/backwpup) `3` (one kit serves all three: status, history, start a backup, and an optional "require a fresh backup" hold on destructive calls) | -| **Security** | [Wordfence](https://wppilot.co/integrations/wordfence) `8` · [Solid Security](https://wppilot.co/integrations/solid-security) `8` (status, scan findings, lockouts, a hardening plan you confirm, IP blocks, all undoable) | -| **Site maintenance** | [Safe plugin and theme updates](https://wppilot.co/wordpress-ai-safe-updates) `3` (checks pages before and after, rolls back on its own) · [Scheduled audits](https://wppilot.co/use-cases/scheduled-site-audits) `5` · [Content fixes](https://wppilot.co/use-cases/fix-broken-links-with-ai) `2` | +| **Backups** | [UpdraftPlus](https://wppilot.co/integrations/updraftplus) `1` · [Duplicator](https://wppilot.co/integrations/duplicator) `1` · [BackWPup](https://wppilot.co/integrations/backwpup) `1` (start a backup, and an optional "require a fresh backup" hold on destructive calls; status and history are free) | +| **Security** | [Wordfence](https://wppilot.co/integrations/wordfence) `5` · [Solid Security](https://wppilot.co/integrations/solid-security) `5` (a hardening plan you confirm, IP blocks and scans, all undoable; status, findings and lockouts are free) | +| **Site maintenance** | [Safe plugin and theme updates](https://wppilot.co/wordpress-ai-safe-updates) `3` (checks pages before and after, rolls back on its own) · [Content fixes](https://wppilot.co/use-cases/fix-broken-links-with-ai) `2` | | **Site operations** | [The Events Calendar](https://wppilot.co/integrations/the-events-calendar) `7` · [Paid Memberships Pro](https://wppilot.co/integrations/paid-memberships-pro) `5` · [Tutor LMS](https://wppilot.co/integrations/tutor-lms) `7` · [BuddyPress](https://wppilot.co/integrations/buddypress) `8` | | **Developer tools** | [Code Snippets](https://wppilot.co/integrations/code-snippets) `11` · [Bricksforge](https://wppilot.co/integrations/bricksforge) `21` | | **WordPress platform** | [WordPress extras](https://wppilot.co/integrations/wordpress) `23` · [Brand Kit](https://wppilot.co/integrations/brand-kit) `8` · [Agent Memory](https://wppilot.co/integrations/memory) `4` · [Pro skill library](https://wppilot.co/integrations/skills) `1` (skills themselves are free) | @@ -332,7 +366,7 @@ Those are [WPPilot Pro](https://wppilot.co/pro), which registers builder-aware a Yes, in [WPPilot Pro](https://wppilot.co/pro). Products, variations, orders, coupons and stock become typed abilities on the same endpoint, capability-checked against the connected WordPress user - an agent connected as a shop manager cannot do what that account could not do by hand. Anything touching money is classed destructive, so it needs explicit confirmation and lands in the change ledger with rollback. **Do I need Pro to use this?** -No. The free plugin in this repository is a complete WordPress MCP server with 168 abilities - including Elementor editing and the design system - and it needs no licence, activation key or entitlement service. Pro is additive. +No. The free plugin in this repository is a complete WordPress MCP server with 216 abilities - including Elementor editing and the design system - and it needs no licence, activation key or entitlement service. Pro is additive. **Can an agent build an Elementor page with the free plugin?** It can build one element at a time, which is what `elementor-add-element`, `elementor-edit-element` and `elementor-set-content` are for, and the design system in free gives it the palette, the type and spacing ladders and the compositions to build against. The single-call whole-page builders, `elementor-build-page` and `elementor-build-from-spec`, are Pro. diff --git a/composer.json b/composer.json index 39c0f3e..b6b3a5e 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "wppilot/wppilot", - "description": "WordPress MCP server with free Elementor MCP editing. 168 typed abilities for posts, pages, Gutenberg, Elementor, media, menus and themes, served over MCP with OAuth 2.1, safety profiles, change evidence and rollback.", + "description": "WordPress MCP server with free Elementor MCP editing. 216 typed abilities for posts, pages, Gutenberg, Elementor, media, menus and themes, served over MCP with OAuth 2.1, safety profiles, change evidence and rollback.", "type": "wordpress-plugin", "keywords": [ "wordpress", diff --git a/docs/SAFETY.md b/docs/SAFETY.md index e0baf33..ea6f7fc 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -92,6 +92,16 @@ The ledger retains at most 500 records and is also capped by total serialized si Permanent deletion, payment refunds, and other irreversible external side effects are recorded as non-reversible. +### Session undo and redo + +Each record written by an agent carries the agent session it belongs to: the `Mcp-Session-Id` of a legacy MCP connection (stored as a keyed hash), one `wp wppilot mcp serve` process, or, for the stateless 2026-07-28 transport and the REST run route, one credential and client until it has been quiet for 30 minutes. Writes from wp-admin, cron and plain WP-CLI carry none and are never part of a session. The record also keeps a digest of the target's state straight after the write. + +`wppilot/undo-session` undoes a session's changes newest first through the same verified rollback as `wppilot/rollback-change`. Before anything is written it checks every target: the newest session write to each must still match the state it left, and consecutive session writes to one target must chain (the newer one's before-image equals the older one's after-state). A mismatch means a person, another agent or another plugin changed the target since, and the whole run is refused with the conflict named - which change, which fields, and the later ledger rows that touched it. Caches that change without an edit (Elementor's CSS and asset meta, edit locks, `post_modified`) are not counted. Each change is checked again just before it is undone. The run stops at the first change that fails verification and reports what was undone, the failure, and what was not attempted. A session that holds a change which cannot be undone is refused unless `allow_partial` is true, and the change is listed either way. A change whose restore strategy is missing (its plugin was deactivated) fails and stops the run the same way. + +Every undo keeps the state it replaced. `wppilot/redo-session` puts those back oldest first under the same rules, and checks each result against the state recorded when the change was first made. A created term or comment is deleted permanently by its undo and cannot be redone; such changes are listed. + +Both are destructive and need the same confirmation as any other destructive call; Read Only refuses them. One run handles up to 2,000 changes and only one run per session goes at a time. The Changes screen lists recent sessions and has **Undo session** and **Redo session** on a session's view. + Each record also says how the call was confirmed (`confirmation.method`): `argument`, `elicitation`, `approval-url` (with the approving user's id), `chat`, `approval-queue`, or `not-required` for a write that needed no confirmation. Each record names the agent behind the write as well as the WordPress user. The user is not an agent identity - several AI clients usually connect as the same administrator - so the credential is what distinguishes them: an OAuth client id, stored hashed, or an application-password UUID. The client name and version the agent introduced itself with are recorded alongside it. Writes that arrive outside an authenticated MCP request, from wp-admin, WP-CLI or another plugin, are recorded as `direct` rather than attributed to the last agent seen. diff --git a/docs/wordpress-mcp.md b/docs/wordpress-mcp.md index f405f16..4defbc3 100644 --- a/docs/wordpress-mcp.md +++ b/docs/wordpress-mcp.md @@ -101,14 +101,19 @@ rather than appearing as a white screen. ## What is registered on a fresh install -168 abilities, plus one MCP prompt per saved skill. They are grouped into a +WPPilot ships 216 abilities, plus one MCP prompt per saved skill. Not all of +them register everywhere: the WooCommerce, SEO, form, backup and security +abilities appear only while their plugin is active, and developer abilities +only under Developer Full Access, so a fresh install on the default profile +registers 154. They are grouped into a single **WordPress** category on the Abilities screen and can be switched off individually: content, taxonomies, media, comments, menus, revisions, user reads, allowlisted site settings, the plugin and theme lifecycle, Gutenberg block workflows, Elementor editing, the design system, preview, skills, the -change ledger and diagnostics. Thirteen developer abilities - PHP execution, -WP-CLI, filesystem, temporary admin access - register only under Developer Full -Access. +change ledger and diagnostics. Twenty abilities register only under +Developer Full Access: the fifteen developer abilities (PHP execution, WP-CLI, +reading and writing server files, temporary admin access) plus database +queries, the options explorer, and creating or updating users. Content creation is draft-first: an absent, blank or malformed status resolves to `draft` before any capability check runs, so nothing is published by @@ -135,6 +140,46 @@ Three routes, all configured on **WPPilot → Connect**: None of the three is a product licence. The free plugin has no activation key and contacts no entitlement service to run. +## Local stdio connection + +`wp wppilot mcp serve` runs the same MCP server over stdin/stdout, for a client +on the machine that runs WordPress. There is no credential: WP-CLI's global +`--user` names the WordPress user, and every ability checks that user's +permissions exactly as over HTTP. The command drives the adapter's own request +router, so tool listing, the `mcp_adapter_pre_tool_call` controls (safety +profile, confirmation, rate limit, design and preview gates) and the permission +callbacks are the HTTP ones, not a copy. + +```bash +claude mcp add wppilot-local -- wp --path=/var/www/html wppilot mcp serve --user=admin +claude mcp add wppilot-local -- docker exec -i wp --path=/var/www/html wppilot mcp serve --user=admin + +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}' \ + | wp --path=/var/www/html wppilot mcp serve --user=admin +``` + +A client that takes a command and arguments (Cursor's `.cursor/mcp.json`) gets +`"command": "wp"` and `"args": ["--path=/var/www/html", "wppilot", "mcp", +"serve", "--user=admin"]`. + +What differs from HTTP, and why: + +- It refuses to start without `--user`, and when WPPilot's AI abilities are + switched off. `--server=` picks another registered MCP server; the default + is `wppilot` (the adapter's own `wp mcp-adapter serve` picks the first server + registered, which is Elementor's when both are installed). +- stdout carries only JSON-RPC, one message per line. PHP errors go to stderr, + and anything a plugin prints while a message is handled is moved there. +- WordPress's in-memory cache is dropped before each message, so an edit made + in wp-admin while the client is connected is seen. Abilities switched on or + off on the Abilities screen are registered once per process, so restart the + client after changing them. +- Writes are credited to method `stdio` and the client name from `initialize`, + appear on the Connections list as "WP-CLI stdio", and each process is one + session for `wppilot/undo-session`. +- `mcp_adapter_enable_stdio_transport` and `wppilot_stdio_enabled` returning + false switch it off. + ## Related - [Safety profiles and the change ledger](SAFETY.md) diff --git a/includes/abilities/bootstrap.php b/includes/abilities/bootstrap.php index 4ce0267..733d3cf 100644 --- a/includes/abilities/bootstrap.php +++ b/includes/abilities/bootstrap.php @@ -145,6 +145,7 @@ function wppilot_register_builtin_abilities(): void require_once $dir . 'discover-abilities.php'; require_once $dir . 'agent-context.php'; require_once $dir . 'change-log.php'; + require_once $dir . 'sessions.php'; require_once $dir . 'diagnostics.php'; require_once $dir . 'preview.php'; wppilot_load_wordpress_abilities(); diff --git a/includes/abilities/change-log.php b/includes/abilities/change-log.php index 0609c9b..c45bf23 100644 --- a/includes/abilities/change-log.php +++ b/includes/abilities/change-log.php @@ -102,6 +102,9 @@ function wppilot_change_public_summary(array $entry): array 'duration_ms' => (float) ($entry['duration_ms'] ?? 0), 'user' => $entry['user'] ?? [], 'agent' => $entry['agent'] ?? [], + // The agent session the write belongs to, for wppilot/undo-session. Empty for writes no + // agent made and for rows recorded before 1.16.0. + 'session_id' => (string) ($entry['session'] ?? ''), 'reversible' => ($rollback['reversible'] ?? false) === true, 'rollback_reason' => (string) ($rollback['reason'] ?? ''), 'rolled_back' => ($entry['rolled_back'] ?? false) === true, diff --git a/includes/abilities/list-directory.php b/includes/abilities/list-directory.php index fc58539..ebc5b5f 100644 --- a/includes/abilities/list-directory.php +++ b/includes/abilities/list-directory.php @@ -88,6 +88,10 @@ 'meta' => [ 'show_in_rest' => true, 'mcp' => ['public' => true], + // A read, so the risk class alone would let it run on Production Safe and even Read + // Only, where it could read wp-config.php: the database password and the auth salts. + // Those profiles promise no server filesystem access, so it is Developer Full Access only. + 'safety' => ['min_profile' => 'developer', 'audit_reads' => true], 'annotations' => [ 'instructions' => 'TIP: AI-written PHP plugins live in wp-content/wppilot-sandbox/. Check wp-content/wppilot-sandbox/.crashed to see if safe mode is active.', 'readonly' => true, diff --git a/includes/abilities/read-file.php b/includes/abilities/read-file.php index bdcb3ad..7743188 100644 --- a/includes/abilities/read-file.php +++ b/includes/abilities/read-file.php @@ -63,6 +63,10 @@ 'meta' => [ 'show_in_rest' => true, 'mcp' => ['public' => true], + // A read, so the risk class alone would let it run on Production Safe and even Read + // Only, where it could read wp-config.php: the database password and the auth salts. + // Those profiles promise no server filesystem access, so it is Developer Full Access only. + 'safety' => ['min_profile' => 'developer', 'audit_reads' => true], 'annotations' => [ 'instructions' => 'TIP: AI-written PHP plugins live in wp-content/wppilot-sandbox/. Check wp-content/wppilot-sandbox/.crashed to see if safe mode is active.', 'readonly' => true, diff --git a/includes/abilities/sessions.php b/includes/abilities/sessions.php new file mode 100644 index 0000000..14c9a02 --- /dev/null +++ b/includes/abilities/sessions.php @@ -0,0 +1,110 @@ + +// SPDX-License-Identifier: GPL-2.0-or-later + +declare(strict_types=1); + +if (!defined('ABSPATH')) { + exit(); +} + +/* + * Session undo and redo over MCP. The work is in includes/change-sessions.php; these are its + * typed entry points. Undo and redo are destructive (they overwrite the current state of every + * target the session touched), so they go through the same confirmation contract as any other + * destructive ability, and a read-only safety profile refuses them. + */ + +wp_register_ability('wppilot/list-sessions', [ + 'label' => __('List Agent Sessions', domain: 'wppilot'), + 'description' => __( + 'Lists agent sessions recorded in the WPPilot change ledger, most recently active first: one per MCP connection (or per stdio process, or per agent credential and client until it has been idle for 30 minutes). Each shows the agent, the time span, and how many of its changes can be undone, are undone, or cannot be undone. current_session_id is the session this call belongs to. Pass session_id to get that session\'s changes and what undo-session and redo-session would do right now: conflicts (targets changed by something else since), changes that cannot be undone or redone, and whether a run is ready.', + domain: 'wppilot', + ), + 'category' => 'changes', + 'input_schema' => [ + 'type' => 'object', + 'default' => [], + 'properties' => [ + 'session_id' => ['type' => 'string', 'maxLength' => 64, 'description' => 'A session to show in full, with its undo and redo plan.'], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20], + ], + 'additionalProperties' => false, + ], + 'output_schema' => ['type' => 'object'], + 'execute_callback' => static function (array $input): array|WP_Error { + $current = wppilot_current_session_id(); + $session = trim((string) ($input['session_id'] ?? '')); + if ($session !== '') { + $detail = wppilot_session_detail($session, (int) ($input['limit'] ?? 100)); + return $detail instanceof WP_Error ? $detail : array_merge(['current_session_id' => $current], $detail); + } + $sessions = wppilot_list_change_sessions((int) ($input['limit'] ?? 20)); + return ['current_session_id' => $current, 'sessions' => $sessions, 'count' => count($sessions)]; + }, + 'permission_callback' => 'wppilot_permission_callback', + 'meta' => [ + 'show_in_rest' => true, + 'mcp' => ['public' => true], + 'annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true], + ], +]); + +$wppilot_session_run_schema = [ + 'type' => 'object', + 'properties' => [ + 'session_id' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64, 'description' => 'From wppilot/list-sessions.'], + 'allow_partial' => [ + 'type' => 'boolean', + 'default' => false, + 'description' => 'Go ahead when some of the session\'s changes cannot be handled; they are listed in the result and left as they are. Without it, such a session is refused and nothing is changed.', + ], + ], + 'required' => ['session_id'], + 'additionalProperties' => false, +]; + +wp_register_ability('wppilot/undo-session', [ + 'label' => __('Undo Agent Session', domain: 'wppilot'), + 'description' => __( + 'Undoes every change one agent session made, newest first, each restored and verified against its before-image. Before touching anything it checks that no target was changed by something else after the session wrote to it; if one was, nothing is changed and the conflicts are listed. It stops at the first change that fails verification and reports what was undone, what failed and what was not attempted. Changes that cannot be undone are listed, never skipped silently: the run is refused unless allow_partial is true. Check wppilot/list-sessions with the session_id first to see the plan. Every undo can be put back with wppilot/redo-session. Requires explicit confirmation.', + domain: 'wppilot', + ), + 'category' => 'changes', + 'input_schema' => $wppilot_session_run_schema, + 'output_schema' => ['type' => 'object'], + 'execute_callback' => static fn(array $input): array|WP_Error => wppilot_undo_session( + (string) $input['session_id'], + allow_partial: ($input['allow_partial'] ?? false) === true, + ), + 'permission_callback' => 'wppilot_permission_callback', + 'meta' => [ + 'show_in_rest' => true, + 'mcp' => ['public' => true], + 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => false], + ], +]); + +wp_register_ability('wppilot/redo-session', [ + 'label' => __('Redo Agent Session', domain: 'wppilot'), + 'description' => __( + 'Puts back the changes of one agent session that were undone, oldest first, each verified against the state recorded when the change was first made. Refuses without changing anything when a target was changed by something else after the undo, and stops at the first change that fails verification. Undone changes that cannot be redone (a created term or comment the undo deleted, a target too large to copy) are listed; the run is refused unless allow_partial is true. Requires explicit confirmation.', + domain: 'wppilot', + ), + 'category' => 'changes', + 'input_schema' => $wppilot_session_run_schema, + 'output_schema' => ['type' => 'object'], + 'execute_callback' => static fn(array $input): array|WP_Error => wppilot_redo_session( + (string) $input['session_id'], + allow_partial: ($input['allow_partial'] ?? false) === true, + ), + 'permission_callback' => 'wppilot_permission_callback', + 'meta' => [ + 'show_in_rest' => true, + 'mcp' => ['public' => true], + 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => false], + ], +]); + +unset($wppilot_session_run_schema); diff --git a/includes/abilities/wordpress/extensions.php b/includes/abilities/wordpress/extensions.php index c94e8cf..670c1d2 100644 --- a/includes/abilities/wordpress/extensions.php +++ b/includes/abilities/wordpress/extensions.php @@ -811,6 +811,8 @@ function wordpress_delete_theme(array $input): array|WP_Error } require_once ABSPATH . 'wp-admin/includes/file.php'; + // delete_theme() lives here, and wp-admin/includes is not loaded on a REST or MCP request. + require_once ABSPATH . 'wp-admin/includes/theme.php'; $summary = wordpress_theme_summary($theme); /** @var mixed $deleted */ diff --git a/includes/abilities/wordpress/media.php b/includes/abilities/wordpress/media.php index f4e7ba6..da06e2a 100644 --- a/includes/abilities/wordpress/media.php +++ b/includes/abilities/wordpress/media.php @@ -235,7 +235,7 @@ function wordpress_import_media_url(array $input): array|WP_Error update_post_meta( (int) $attachment_id, meta_key: '_wp_attachment_image_alt', - meta_value: (string) $input['alt'], + meta_value: wp_slash((string) $input['alt']), ); } $attachment = get_post((int) $attachment_id); @@ -271,7 +271,7 @@ function wordpress_update_media(array $input): array|WP_Error } } if (array_key_exists('alt', $input)) { - update_post_meta($attachment_id, meta_key: '_wp_attachment_image_alt', meta_value: (string) $input['alt']); + update_post_meta($attachment_id, meta_key: '_wp_attachment_image_alt', meta_value: wp_slash((string) $input['alt'])); } $updated_attachment = get_post($attachment_id); return ( diff --git a/includes/admin/changes/assets/changes.css b/includes/admin/changes/assets/changes.css index 615ffa7..272aa27 100644 --- a/includes/admin/changes/assets/changes.css +++ b/includes/admin/changes/assets/changes.css @@ -113,3 +113,30 @@ justify-content: flex-start; } } + +.wppilot-changes__session, +.wppilot-changes__sessions { + margin: 0 0 1rem; + padding: 0.75rem 1rem; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; +} + +.wppilot-changes__sessions h2 { + margin-top: 0; + font-size: 14px; +} + +.wppilot-changes__session-facts { + margin: 0 0 0.75rem; +} + +.wppilot-changes__session-conflict { + color: #b32d2e; +} + +.wppilot-changes__session form { + display: flex; + gap: 0.5rem; + align-items: center; +} diff --git a/includes/admin/changes/changes.php b/includes/admin/changes/changes.php index 16a23fc..f266ff9 100644 --- a/includes/admin/changes/changes.php +++ b/includes/admin/changes/changes.php @@ -34,7 +34,7 @@ const EXPORT_PAGE = 200; /** The query-string keys the screen and its downloads accept as filters. */ -const FILTER_KEYS = ['kind', 'ability', 'user_id', 'agent', 'group', 'status', 'since', 'until']; +const FILTER_KEYS = ['kind', 'ability', 'user_id', 'agent', 'group', 'session', 'status', 'since', 'until']; function current_user_can_manage(): bool { @@ -72,6 +72,8 @@ function register_post_handlers(): void add_action('admin_post_wppilot_changes_undo_group', __NAMESPACE__ . '\\handle_undo_group'); add_action('admin_post_wppilot_changes_undo_matching', __NAMESPACE__ . '\\handle_undo_matching'); add_action('admin_post_wppilot_changes_export', __NAMESPACE__ . '\\handle_export'); + add_action('admin_post_wppilot_changes_undo_session', __NAMESPACE__ . '\\handle_undo_session'); + add_action('admin_post_wppilot_changes_redo_session', __NAMESPACE__ . '\\handle_redo_session'); } function render_page(): void @@ -175,6 +177,93 @@ function handle_undo_matching(): void redirect_with_notice(summary_notice_type($result), summary_message($result), $filters); } +/** + * Undo or redo one agent session from the screen. The same run the MCP abilities perform: planned + * first, refused whole on a conflict, stopped at the first change that does not verify. + */ +function handle_undo_session(): void +{ + require_capability_and_nonce('wppilot_changes_undo_session'); + handle_session_run('undo'); +} + +function handle_redo_session(): void +{ + require_capability_and_nonce('wppilot_changes_redo_session'); + handle_session_run('redo'); +} + +function handle_session_run(string $operation): void +{ + $session = posted('session'); + $allow_partial = posted('allow_partial') === '1'; + $result = $operation === 'undo' + ? \wppilot_undo_session($session, $allow_partial) + : \wppilot_redo_session($session, $allow_partial); + if ($result instanceof WP_Error) { + redirect_with_notice('error', $result->get_error_message(), ['session' => $session]); + } + redirect_with_notice(session_notice_type($result), session_message($result), ['session' => $session]); +} + +/** + * @param array $result + */ +function session_notice_type(array $result): string +{ + return match ((string) ($result['status'] ?? '')) { + 'completed' => 'success', + 'nothing-to-do' => 'info', + 'stopped' => 'warning', + default => 'error', + }; +} + +/** + * The run's own message, plus the first few conflicts or the failure by name, so the notice says + * what to look at without opening anything. + * + * @param array $result + */ +function session_message(array $result): string +{ + $message = (string) ($result['message'] ?? ''); + $details = []; + // @mago-expect analysis:mixed-assignment -- Conflict rows are checked below. + foreach (array_slice(is_array($result['conflicts'] ?? null) ? $result['conflicts'] : [], 0, 5) as $conflict) { + if (!is_array($conflict)) { + continue; + } + $changed = is_array($conflict['changed'] ?? null) ? implode(', ', array_map('strval', array_slice($conflict['changed'], 0, 6))) : ''; + $details[] = sprintf( + /* translators: 1: ability name, 2: target, 3: fields that differ */ + __('%1$s on %2$s (changed: %3$s)', domain: 'wppilot'), + (string) ($conflict['ability'] ?? ''), + (string) ($conflict['target'] ?? ''), + $changed !== '' ? $changed : __('unknown', domain: 'wppilot'), + ); + } + $failed = is_array($result['failed'] ?? null) ? $result['failed'] : null; + if ($failed !== null) { + $details[] = sprintf( + /* translators: 1: ability name, 2: change id */ + __('Failed: %1$s (%2$s)', domain: 'wppilot'), + (string) ($failed['ability'] ?? ''), + (string) ($failed['change_id'] ?? ''), + ); + } + + return $details === [] ? $message : $message . ' ' . implode('; ', $details) . '.'; +} + +/** + * A session id short enough for a table cell. + */ +function session_label(string $session): string +{ + return strlen($session) > 18 ? substr($session, 0, 18) . '…' : $session; +} + /** * Download what the filters show, as CSV or JSON. */ diff --git a/includes/admin/changes/templates/detail.php b/includes/admin/changes/templates/detail.php index 0763ac5..9e6bbd6 100644 --- a/includes/admin/changes/templates/detail.php +++ b/includes/admin/changes/templates/detail.php @@ -89,6 +89,16 @@ + + + + + + +

+ + + 1) { ?> diff --git a/includes/admin/changes/templates/list.php b/includes/admin/changes/templates/list.php index cd907fa..191dc03 100644 --- a/includes/admin/changes/templates/list.php +++ b/includes/admin/changes/templates/list.php @@ -38,6 +38,7 @@ )))); $datetime_format = \wppilot_get_datetime_format(); $active_group = (string) ($filters['group'] ?? ''); +$active_session = (string) ($filters['session'] ?? ''); ?> @@ -51,6 +52,9 @@ + + +