settings = new Settings(); // One-shot correction of un-customised defaults we've since lowered (currently the // llms-full byte budget, which used to sit exactly at the 1 MB object-cache ceiling). Cheap // once the flag is set: the flag is autoloaded, so the guard short-circuits with no extra // query before it touches the settings option. $this->settings->maybe_migrate_defaults(); // Record which plugin registered each post type at runtime (a one-time // backtrace, no hardcoded plugin list) so Content::source() can name a CPT's // vendor — e.g. a generic "Products" type attributed to FluentCart vs // WooCommerce. Enabled on our settings screen AND on the Agent Preview REST // call that resolves the picker's sources; scoped tight so the backtrace cost // is never paid on a normal front-end page load. The REST route is matched on // the request URI because REST_REQUEST isn't defined this early (plugins_loaded). $on_settings = is_admin() && 'agentimus' === ( isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check, no state change. $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // The assistant's state route joins the list: it re-serves the type // chooser after a settings change, and without origin recording the // refreshed list came back as a bare "Products" where the page load had // said "Products · WooCommerce" — the same list, named two ways, // depending on how it was fetched. $on_preview = false !== strpos( $request_uri, 'agentimus/v1/preview' ) || false !== strpos( $request_uri, 'agentimus/v1/assistant/state' ); if ( $on_settings || $on_preview ) { Content::watch_origins(); } Cache::register_flush_hooks(); SeoContext::watch(); // One cache flush per solo/coexist flip — cached surfaces bake the mode in. Sitemap::register(); // Core's sitemap stands down while the solo-mode promotion is on. CachePurge::boot(); IndexNow::boot(); // Re-reads its switch at queue time — registration is unconditional. MarkdownCache::register(); // Scheduled work on a site whose loopback is blocked: the owner's own // browser asks for the jobs that are overdue. ⛔ Registered for every // request, not just our screens — a site with no working cron is not // healed by a mechanism that only runs while somebody watches it. Cron::register(); BotRanges::boot(); // Daily refresh of published bot-IP-range files (self-heals its schedule). RouteProbe::watch(); // Async self-check of /llms.txt and the home ; re-queued when the plugin/theme mix changes. ThemeImageProbe::watch(); // Async: what the SERVED page does with a featured image's description — asked once per theme, never inside a check. Discovery\Reachability::watch(); // Daily: does every address we advertise as open really answer a stranger? Read path never fetches. Activity\IdentityProbe::watch(); // Async look at the home page a self-declared crawler names in its own UA; queued only when one is on the review queue. McpToken::watch(); // Bearer connection-token auth, honored only on the MCP route. Oauth\Server::watch(); // OAuth access-token auth — same route, next seat in the inspector line. ( new McpPublicSurface( $this->settings ) )->register(); // Anonymous MCP read-surface: initialize/ping/tools-list answer without auth (they publish nothing mcp.json doesn't); everything else keeps its 401. ( new Endpoints( $this->settings ) )->register(); ( new Oauth\Consent( $this->settings ) )->register(); // The /agentimus/connect consent page. ( new Oauth\Rest( $this->settings ) )->register(); // /oauth/register · /oauth/token · /oauth/grants. ( new Tombstones() )->register(); // Records removals for the change feed (self-gates on enable_changes). ( new PageBuilders() )->register(); // Builder-owned pages: supplies the REAL body (Elementor/Beaver render) to every read surface via agentimus_markdown_source; ContentWriter asks it before replacing a body. ( new Schema( $this->settings ) )->register(); ( new Seo( $this->settings ) )->register(); // Solo-mode head output (per-page SEO title; cards + canonical land here). Stands down at request time when an SEO suite is active. ( new EditorPanel( $this->settings ) )->register(); // One "Agentimus" editor box: JSON-LD + Readability as tabs. ( new Topics( $this->settings ) )->register(); ( new Focus( $this->settings ) )->register(); // "This page is for" — the first section of the shared Search & AI box. ( new Description( $this->settings ) )->register(); ( new MediaContext( $this->settings ) )->register(); // A line of context per video/audio item, so an agent knows what a player holds. Field composed into the Topics box; writes nothing to the front end. ( new AskAi( $this->settings ) )->register(); // "Ask AI about this post" bar after singular post content (enable_ask_ai, on by default). ( new ShareCopy( $this->settings ) )->register(); // REST for the editor panel's Share tab — per-network share drafts; the tab itself is composed by EditorPanel. ( new Assist( $this->settings ) )->register(); // "Draft with AI" for the description + topics fields (no-ops without a configured AI provider). ( new InternalLinks( $this->settings ) )->register(); // "Link to your own posts": local-signal suggestions + one optional AI dressing call; Insert is a plain editor edit. ( new Assistant( $this->settings ) )->register(); // The in-admin writing assistant (nav-bar quill → drawer); gated on enable_agent_writes + a provider. ( new AssistantEditor( $this->settings ) )->register(); // Its block-editor half: per-Image-block Generate + the featured-image panel. ( new Worklist( $this->settings ) )->register(); // The grading sweep, and the saves that make a grade stale. ( new Rest( $this->settings ) )->register(); ( new Admin( $this->settings ) )->register(); ( new Discovery\Module( $this->settings ) )->register(); ( new Activity\Module( $this->settings ) )->register(); ( new WebMcp( $this->settings ) )->register(); ( new Exposure( $this->settings ) )->register(); // Agent Access must boot BEFORE the Registrar: the Registrar wraps each ability's execute // callback with AgentAccess's observer at registration time, and the ability listener has // to be hooked before any ability can run. ( new AgentAccess\Module( $this->settings ) )->register(); // Records app-password lifecycle + ability invocations. Inert where neither exists. ( new Digest\Module( $this->settings ) )->register(); // Weekly email digest: the cron handler, the one-click stop endpoint, and a self-healing schedule. ( new Integrations\Dispatcher( $this->settings ) )->register(); // Outgoing-event queue drain (cron). Inert — not one hook — until the webhook is connected. ( new Integrations\Events( $this->settings ) )->register(); // The listeners that feed that queue, + the daily findings diff. Same inertness. ( new Integrations\Rest( $this->settings ) )->register(); // The Integrations screen's routes — unconditional, they are how the webhook gets connected. ( new Integrations\Announcements( $this->settings ) )->register(); // The scheduled-announcements drain. Inert — not one hook — until a row is queued. // Every plugin provider, from the one roster they are listed in. A provider // with nothing public to declare registers nothing — so this loop is safe // for the presence-only entries and needs no second list to stay in step. foreach ( Integrations\Plugins\Provider::ROSTER as $provider ) { $provider::register(); } ( new ReviewBadge( $this->settings ) )->register(); // Review-queue count on the admin menu + Heartbeat live updates (admin-only; no-ops on the front end). ( new Abilities\AdapterBootstrap( $this->settings ) )->register(); // Boots the bundled MCP Adapter when the owner opts in (inert otherwise). ( new Abilities\Registrar( $this->settings ) )->register(); // Exposes our own read capabilities to the WP admin AI + MCP (no-ops pre-6.9). // Citation checks (opt-in, BYOK) — the Visibility screen's Citations tenant. Its config, keys and results live // in their own option/table, independent of the core settings above. $visibility = new Visibility\Settings(); ( new Visibility\Module( $visibility ) )->register(); ( new Visibility\Rest( $visibility ) )->register(); if ( is_multisite() ) { ( new Visibility\Network() )->register(); } // Cloudflare edge data source (opt-in, BYO token). Reads aggregate analytics // only — one hourly GraphQL poll stored locally; it never writes to Cloudflare. // Config and token live in their own option, the numbers in their own table. $cloudflare = new Cloudflare\Settings(); ( new Cloudflare\Module( $cloudflare ) )->register(); ( new Cloudflare\Rest( $cloudflare, $this->settings ) )->register(); // Bing search data source (opt-in, BYO key). One daily poll of Bing's own // crawl statistics stored locally — the index ChatGPT search reads today. // Also prints the msvalidate verification tag once the owner pastes a code. $bing = new Bing\Settings(); ( new Bing\Module( $bing ) )->register(); ( new Bing\Rest( $bing, $this->settings ) )->register(); // Google Search Console data source (opt-in, BYO service-account key — // local auth, never a proxy). One daily poll of query×page performance // stored locally, feeding the same Search Opportunities engine as Bing. $google = new Google\Settings(); ( new Google\Module( $google ) )->register(); ( new Google\Rest( $google ) )->register(); ( new Search\Rest( $this->settings ) )->register(); // Self-heal the /.well-known rewrite rules: flush ONCE whenever the routed-name // set changes — an Agentimus update that adds a built-in name (e.g. tdmrep.json) // OR a provider plugin that opts a name in via the `agentimus_well_known_routed` // filter. Runs after WellKnown::add_rules() (init, default priority) so the rules // are already registered. maybe_flush_rewrites() skips front-end requests and // no-ops on the unchanged-signature common case, so this stays off the hot path. add_action( 'init', array( self::class, 'maybe_flush_rewrites' ), 20 ); /** * Fires after Agentimus has booted. The seam a Pro add-on hooks to * register its own features against the shared Settings instance. * * @param Plugin $plugin The plugin instance. */ do_action( 'agentimus_booted', $this ); } /** * Activation entry point. On multisite a network-wide activation fires this * hook ONCE (with $network_wide true), NOT per site — so we loop the existing * sites and set each up. A normal single-site activation just sets up the one * current site. * * @param bool $network_wide Whether the plugin is being network-activated. */ public static function activate( $network_wide = false ) { if ( is_multisite() && $network_wide ) { foreach ( self::network_site_ids() as $site_id ) { switch_to_blog( $site_id ); self::activate_site(); restore_current_blog(); } return; } self::activate_site(); } /** * Run the per-site setup for a site created AFTER a network-wide activation * (hooked to wp_initialize_site in the bootstrap). Identical to what every site * receives at activation time. */ public static function install_site() { self::activate_site(); } /** * Per-site setup, run in the context of the current blog: seed defaults and * register the /.well-known rewrite rule before flushing, so the discovery * endpoints resolve on the very first request. */ private static function activate_site() { // Detect a truly fresh install BEFORE seeding anything, so the onboarding // wizard shows only for new users — never for an upgrade or a migrant from // the pre-rename "Agent Ready" option. $had_settings = ( false !== get_option( Settings::OPTION, false ) ) || ( false !== get_option( 'agent_ready_settings', false ) ); self::migrate_legacy_option(); ( new Settings() )->ensure_defaults(); Activity\Table::install(); Activity\Referrals::install(); Activity\UnknownSources::install(); AgentAccess\Table::install(); Activity\Module::schedule(); Visibility\Table::install(); Visibility\Module::schedule(); Cloudflare\Table::install(); Cloudflare\Module::schedule(); Bing\Table::install(); Bing\Module::schedule(); Search\Table::install(); Search\Asks::install(); Grades::install(); Grades::schedule(); Google\Module::schedule(); Digest\Module::schedule(); Discovery\WellKnown::add_rules(); self::flush_rewrites_in_context(); // Record the signature we just flushed for, so maybe_flush_rewrites() no-ops on // the next request rather than flushing again. We intentionally do NOT seed the // last-flush timestamp here: that belongs to the auto-flush path, so the first // genuine post-activation change (e.g. a provider activated moments later) still // flushes promptly instead of being caught by the thrash backstop's window. update_option( self::REWRITE_SIGNATURE_OPTION, self::rewrite_signature() ); self::seed_onboarding_state( $had_settings ); } /** * Self-healing, one-time rewrite flush when the routed /.well-known name set * changes — whether from an Agentimus update or a provider opting a name in via * the `agentimus_well_known_routed` filter. Hooked late on `init` (priority 20), * after WellKnown::add_rules() has registered the current rule set. * * flush_rewrite_rules() is expensive, so three guards keep it off the hot path * and make a runaway flush impossible: * 1. Front-end requests never flush — only the admin (or WP-CLI), where plugins * are activated/updated. An anonymous crawler hitting /.well-known/* therefore * cannot trigger a flush, and the signature is only ever evaluated in one * consistent context (it can't flip between admin/front-end filter results). * 2. The unchanged-signature case — every steady-state request — costs just a * get_option() + string compare and returns. * 3. A rate-limit backstop caps real flushes to one per REWRITE_FLUSH_MIN_INTERVAL, * so even a provider whose filter is non-deterministic within the admin context * can't thrash flushes; the timestamp is recorded only on an actual flush, so a * genuine change still persists on the next admin request once the window clears. */ /** * The site tagline, when it actually says something: WordPress's factory * placeholder counts as empty — seeding "Just another WordPress site" into * an AI-facing profile would be worse than a blank. Used by the wizard's * prefill suggestion and the skip-path identity seed. * * @return string */ public static function real_tagline() { $tagline = trim( (string) get_bloginfo( 'description' ) ); return 0 === strcasecmp( $tagline, 'Just another WordPress site' ) ? '' : $tagline; } public static function maybe_flush_rewrites() { // Guard 1 — never on the front end. if ( ! is_admin() && ! ( defined( 'WP_CLI' ) && WP_CLI ) ) { return; } // Guard 2 — unchanged set, the common case. $signature = self::rewrite_signature(); if ( $signature === get_option( self::REWRITE_SIGNATURE_OPTION ) ) { return; } // Guard 3 — thrash backstop. $last = (int) get_option( self::REWRITE_FLUSHED_AT_OPTION, 0 ); $now = time(); if ( $last > 0 && ( $now - $last ) < self::REWRITE_FLUSH_MIN_INTERVAL ) { return; } flush_rewrite_rules(); // The signature carries the plugin version, so this fires once after an update — // which is exactly when a generated document can be stale in a way that MATTERS. // (1.21.2: llms.txt used to tell agents they could fetch any page with // `Accept: text/markdown`; that route is now off by default, so a cached copy // would keep advertising a dead end — and invite the very request that poisons a // CDN cache.) Rebuilding the documents on update is cheap and they are rebuilt // lazily anyway. Cache::flush(); update_option( self::REWRITE_FLUSHED_AT_OPTION, $now ); update_option( self::REWRITE_SIGNATURE_OPTION, $signature ); } /** * A stable fingerprint of everything that determines the /.well-known rewrite * rules: the running plugin version plus the SORTED routed + nested name sets. * Sorting makes the value order-independent, so providers registering names in a * different order across requests never yield a different signature (which would * thrash flush_rewrite_rules()). Including the version preserves the "flush once * after an Agentimus update" behaviour even when a release changes how the rules * are built without changing the name list itself. * * @return string */ private static function rewrite_signature() { $routed = Discovery\WellKnown::routed_names(); $nested = Discovery\WellKnown::nested_routes(); sort( $routed ); sort( $nested ); // The OAuth consent page is a rewrite too. Version alone would carry a // release across, but a rule that isn't in the fingerprint is a rule // that can change without healing — and a consent page that 404s is a // connect flow that dies with no error to read. $pages = array( Oauth\Consent::QUERY_VAR ); return AGENTIMUS_VERSION . ':' . md5( implode( ',', $routed ) . '|' . implode( ',', $nested ) . '|' . implode( ',', $pages ) ); } /** * Carry settings over from the pre-rename "Agent Ready" option key, so an * existing install keeps its configuration after the rename to Agentimus. * One-time: runs only when the new key is absent. */ private static function migrate_legacy_option() { if ( false !== get_option( Settings::OPTION, false ) ) { return; } $legacy = get_option( 'agent_ready_settings', false ); // Old key — intentionally literal. if ( is_array( $legacy ) ) { // Run the carried-over config through sanitize() rather than trusting it // verbatim: it applies the current allow-lists, escaping and size caps, and // drops any retired keys, so an old option can't seed stale/garbage values. add_option( Settings::OPTION, ( new Settings() )->sanitize( $legacy ) ); } } /** * Decide whether the first-run setup wizard should appear. A fresh install * leaves the flag unset (wizard shows) and queues a one-time redirect to the * plugin screen; an install that already had settings is marked onboarded so * an existing user never sees the wizard. * * @param bool $had_settings Whether configuration existed before activation. */ private static function seed_onboarding_state( $had_settings ) { if ( $had_settings ) { add_option( 'agentimus_onboarded', AGENTIMUS_VERSION ); // No-op if already present. return; } set_transient( 'agentimus_activation_redirect', 1, 30 ); } /** * Deactivation: drop generated caches and the rewrite rule (options are kept; * uninstall removes them). Network-aware, so a network deactivation clears each * site's scheduled events and caches rather than only the current one. * * @param bool $network_wide Whether the plugin is being network-deactivated. */ public static function deactivate( $network_wide = false ) { if ( is_multisite() && $network_wide ) { foreach ( self::network_site_ids() as $site_id ) { switch_to_blog( $site_id ); self::deactivate_site(); restore_current_blog(); } return; } self::deactivate_site(); } /** * Per-site deactivation cleanup, run in the context of the current blog. */ private static function deactivate_site() { Cache::flush(); Activity\Module::unschedule(); Visibility\Module::unschedule(); Cloudflare\Module::unschedule(); Bing\Module::unschedule(); Google\Module::unschedule(); Digest\Module::unschedule(); wp_clear_scheduled_hook( Integrations\Dispatcher::CRON ); // The queue rows stay (an option); the drain reschedules itself on the next boot. wp_clear_scheduled_hook( Integrations\Announcements::CRON ); // Same shape: queued announcements stay, register() re-arms on the next boot. wp_clear_scheduled_hook( Integrations\Events::CRON_FINDINGS ); Grades::unschedule(); // The stored grades stay: they describe his content, not our schedule. BotRanges::clear_schedule(); RouteProbe::clear_schedule(); ThemeImageProbe::clear_schedule(); Discovery\Reachability::clear_schedule(); // The verdicts stay: they describe the site's doors, not our schedule. Activity\IdentityProbe::clear_schedule(); wp_clear_scheduled_hook( 'agentimus_warm_llms_full' ); wp_clear_scheduled_hook( Visibility\Module::HOOK_ONCE ); // the one-off "run now" event, if queued. self::flush_rewrites_in_context(); } /** * Flush rewrite rules correctly whether or not we're inside switch_to_blog(). * * flush_rewrite_rules() regenerates from the GLOBAL $wp_rewrite, whose permalink_structure is * cached for the ORIGINATING site and is NOT re-initialised by switch_to_blog(). Called while * switched to a sub-site — the network activation/deactivation loops, and wp_initialize_site for * a new sub-site — it would write the MAIN site's rules (and only the currently-loaded plugins' * rules) into that sub-site, 404ing its post URLs. In a switched context we instead DROP the * cached rules: the sub-site regenerates correct ones (its own permalink structure + its own * active plugins, including our add_rules() at init) on its next request. The real, immediate * flush is right only in the non-switched, single-site path. * * @return void */ private static function flush_rewrites_in_context() { if ( function_exists( 'ms_is_switched' ) && ms_is_switched() ) { delete_option( 'rewrite_rules' ); return; } flush_rewrite_rules(); } /** * Site IDs on the network, capped so a very large network cannot time out the * activation request. Beyond the cap each site still self-heals on first boot — * tables via Activity\Module::register(), rewrite rules via maybe_flush_rewrites(), * and the prune cron via Activity\Module::register() → schedule(). * * @return int[] */ private static function network_site_ids() { if ( ! is_multisite() ) { return array( get_current_blog_id() ); } $sites = get_sites( array( 'fields' => 'ids', 'number' => 500 ) ); return array_map( 'intval', (array) $sites ); } }