settings = new Settings(); // 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'] ) ) : ''; $on_preview = false !== strpos( $request_uri, 'agentimus/v1/preview' ); if ( $on_settings || $on_preview ) { Content::watch_origins(); } Cache::register_flush_hooks(); CachePurge::boot(); MarkdownCache::register(); ( new Endpoints( $this->settings ) )->register(); ( new Tombstones() )->register(); // Records removals for the change feed (self-gates on enable_changes). ( new Schema( $this->settings ) )->register(); ( new EditorPanel( $this->settings ) )->register(); // One "Agentimus" editor box: JSON-LD + AI Readability as tabs. ( new Topics( $this->settings ) )->register(); ( new Description( $this->settings ) )->register(); ( new Assist( $this->settings ) )->register(); // "Draft with AI" for the description + topics fields (no-ops without a configured AI provider). ( 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(); ( new Abilities\Registrar( $this->settings ) )->register(); // Exposes our own read capabilities to the WP admin AI + MCP (no-ops pre-6.9). // AI Visibility monitoring (opt-in, BYOK). 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(); } // 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(); Activity\Module::schedule(); Visibility\Table::install(); Visibility\Module::schedule(); Discovery\WellKnown::add_rules(); flush_rewrite_rules(); // 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. */ 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(); 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 ); return AGENTIMUS_VERSION . ':' . md5( implode( ',', $routed ) . '|' . implode( ',', $nested ) ); } /** * 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(); wp_clear_scheduled_hook( 'agentimus_warm_llms_full' ); 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 ); } }