# desktop-mode/1.1.10/includes/agents/store.php

OpenStation: Desktop Windows, Dock &amp; Virtual Desktops for WP Admin, version 1.1.10. 1,271 lines.

- Page: https://pluginprobe.com/plugins/desktop-mode/1.1.10/code/includes/agents/store.php
- Raw: https://pluginprobe.com/plugins/desktop-mode/1.1.10/raw/includes/agents/store.php
- Modified: 2026-08-28T08:28:50+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/desktop-mode/1.1.10/code/includes/agents/store.php#L10-L20`.

```php
<?php
/**
 * OpenStation — Agents: definition store (user meta on the agent row).
 *
 * Everything that defines an agent beyond its `wp_users` row lives as
 * user meta on that row, in one `_openstation_agent_*` key family:
 *
 *   - `_desktop_mode_agent`              marker ('1') — the existence test
 *   - `_desktop_mode_agent_description`  "when to use" short text
 *   - `_desktop_mode_agent_instructions` system prompt (markdown)
 *   - `_desktop_mode_agent_abilities`    JSON array of ability slugs
 *   - `_desktop_mode_agent_triggers`     JSON array of { kind, config }
 *   - `_desktop_mode_agent_model`        model override (unused by the
 *                                        runner until the Core AI Client
 *                                        exposes model selection)
 *   - `_desktop_mode_agent_rate_limit`   invocations/hour, 0 = default
 *   - `_desktop_mode_agent_created_by`   creating user id (audit aid)
 *
 * User meta has no revisions — the audit trail for definition changes
 * is the `openstation_agent_{created,updated,deleted}` actions fired
 * from this module's orchestrators, each carrying before/after values
 * so logging plugins can persist a history.
 *
 * This module owns every key: constants, `register_meta()` calls,
 * sanitization, getters/setters, and the create/update orchestrators
 * the REST surface calls. `identity.php` owns the user row itself.
 *
 * @package OpenStation
 */

defined( 'ABSPATH' ) || exit;

require_once OPENSTATION_DIR . 'includes/agents/guard.php';

/**
 * Meta keys owned by the agents store. Constants so the other layer
 * files reuse them instead of typing the literals.
 *
 * `OPENSTATION_AGENT_USER_MARKER_META` is the exception — it lives in
 * guard.php, which loads unconditionally, because the agent test has to
 * resolve even when this module does not load.
 *
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_DESCRIPTION_META = '_desktop_mode_agent_description';
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_INSTRUCTIONS_META = '_desktop_mode_agent_instructions';
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_ABILITIES_META = '_desktop_mode_agent_abilities';
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_TRIGGERS_META = '_desktop_mode_agent_triggers';
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_MODEL_META = '_desktop_mode_agent_model';
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_RATE_LIMIT_META = '_desktop_mode_agent_rate_limit';
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_CREATED_BY_META = '_desktop_mode_agent_created_by';
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_VIBES_META = '_desktop_mode_agent_vibes';

/**
 * Longest voice line an agent may carry.
 *
 * Short enough that it stays a voice rather than becoming a second
 * instruction block by volume.
 */
const OPENSTATION_AGENT_VIBES_MAX_LENGTH = 120;
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_FACE_META = '_desktop_mode_agent_face';
/**
 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
 * persisted or externally-visible identifier, so renaming it would
 * orphan data already written by live installs (or break a live
 * URL). The mismatch between this constant's name and its value is
 * deliberate — it is NOT a half-finished rename.
 */
const OPENSTATION_AGENT_FACE_SEED_META = '_desktop_mode_agent_face_seed';

/**
 * Every meta key the store writes — the privacy eraser and any future
 * cleanup path iterate this list instead of re-typing the constants.
 *
 * @return string[]
 */
function openstation_agent_meta_keys() {
	return array(
		OPENSTATION_AGENT_USER_MARKER_META,
		OPENSTATION_AGENT_DESCRIPTION_META,
		OPENSTATION_AGENT_INSTRUCTIONS_META,
		OPENSTATION_AGENT_ABILITIES_META,
		OPENSTATION_AGENT_TRIGGERS_META,
		OPENSTATION_AGENT_MODEL_META,
		OPENSTATION_AGENT_RATE_LIMIT_META,
		OPENSTATION_AGENT_CREATED_BY_META,
		OPENSTATION_AGENT_VIBES_META,
		OPENSTATION_AGENT_FACE_META,
		OPENSTATION_AGENT_FACE_SEED_META,
	);
}

/**
 * Register the user-meta keys.
 *
 * `show_in_rest` stays false on every key — the module's own REST
 * surface (rest.php) is the only reader/writer; core `wp/v2/users`
 * never exposes agent definitions.
 *
 * @return void
 */
function openstation_agents_register_user_meta() {
	$auth = static function () {
		return current_user_can( 'edit_users' );
	};

	register_meta(
		'user',
		OPENSTATION_AGENT_DESCRIPTION_META,
		array(
			'type'              => 'string',
			'single'            => true,
			'default'           => '',
			'show_in_rest'      => false,
			'sanitize_callback' => 'sanitize_text_field',
			'auth_callback'     => $auth,
		)
	);
	register_meta(
		'user',
		OPENSTATION_AGENT_INSTRUCTIONS_META,
		array(
			'type'              => 'string',
			'single'            => true,
			'default'           => '',
			'show_in_rest'      => false,
			'sanitize_callback' => 'wp_kses_post',
			'auth_callback'     => $auth,
		)
	);
	register_meta(
		'user',
		OPENSTATION_AGENT_ABILITIES_META,
		array(
			'type'              => 'string',
			'single'            => true,
			'default'           => '',
			'show_in_rest'      => false,
			'sanitize_callback' => 'openstation_agent_sanitize_abilities_json',
			'auth_callback'     => $auth,
		)
	);
	register_meta(
		'user',
		OPENSTATION_AGENT_TRIGGERS_META,
		array(
			'type'              => 'string',
			'single'            => true,
			'default'           => '',
			'show_in_rest'      => false,
			'sanitize_callback' => 'openstation_agent_sanitize_triggers_json',
			'auth_callback'     => $auth,
		)
	);
	register_meta(
		'user',
		OPENSTATION_AGENT_MODEL_META,
		array(
			'type'              => 'string',
			'single'            => true,
			'default'           => '',
			'show_in_rest'      => false,
			'sanitize_callback' => 'sanitize_text_field',
			'auth_callback'     => $auth,
		)
	);
	register_meta(
		'user',
		OPENSTATION_AGENT_RATE_LIMIT_META,
		array(
			'type'              => 'integer',
			'single'            => true,
			'default'           => 0,
			'show_in_rest'      => false,
			'sanitize_callback' => 'absint',
			'auth_callback'     => $auth,
		)
	);
	register_meta(
		'user',
		OPENSTATION_AGENT_VIBES_META,
		array(
			'type'              => 'string',
			'single'            => true,
			'default'           => '',
			'show_in_rest'      => false,
			'sanitize_callback' => 'openstation_agent_sanitize_vibes',
			'auth_callback'     => $auth,
		)
	);
	register_meta(
		'user',
		OPENSTATION_AGENT_FACE_META,
		array(
			'type'              => 'string',
			'single'            => true,
			'default'           => '',
			'show_in_rest'      => false,
			'sanitize_callback' => 'openstation_agent_sanitize_face_json',
			'auth_callback'     => $auth,
		)
	);
	register_meta(
		'user',
		OPENSTATION_AGENT_FACE_SEED_META,
		array(
			'type'              => 'integer',
			'single'            => true,
			'default'           => 0,
			'show_in_rest'      => false,
			'sanitize_callback' => 'absint',
			'auth_callback'     => $auth,
		)
	);
}
add_action( 'init', 'openstation_agents_register_user_meta' );

// ---------------------------------------------------------------------------
// Sanitizers
// ---------------------------------------------------------------------------

/**
 * Normalize an ability-slug list: strings only, trimmed, deduped.
 *
 * @param mixed $value Incoming list.
 * @return string[]
 */
function openstation_agents_sanitize_ability_slugs( $value ) {
	if ( is_string( $value ) ) {
		$decoded = json_decode( $value, true );
		$value   = is_array( $decoded ) ? $decoded : array();
	}
	if ( ! is_array( $value ) ) {
		return array();
	}
	$out = array();
	foreach ( $value as $slug ) {
		if ( ! is_string( $slug ) ) {
			continue;
		}
		$clean = sanitize_text_field( $slug );
		if ( '' === $clean ) {
			continue;
		}
		$out[] = $clean;
	}
	return array_values( array_unique( $out ) );
}

/**
 * `register_meta` sanitize callback — abilities land on disk as a JSON
 * string so a read is one meta row and no PHP-serialized arrays exist.
 *
 * @param mixed $value Incoming value (array or JSON string).
 * @return string JSON-encoded slug list.
 */
function openstation_agent_sanitize_abilities_json( $value ) {
	return (string) wp_json_encode( openstation_agents_sanitize_ability_slugs( $value ) );
}

/**
 * Sanitize an agent's voice line.
 *
 * One short line of character: "blunt, precise, no sugarcoating". It
 * is appended to the agent's instructions at run time, so it reaches a
 * language model.
 *
 * **That is not a new privilege boundary.** Writing it needs
 * `edit_users`, the same capability that already lets you write
 * `instructions`, which is the entire system prompt. A 120-character
 * tone line is strictly less reach than that, so it deliberately sits
 * behind the same gate rather than a stricter one, and this note
 * exists so nobody "hardens" it later into a confusing split.
 *
 * Two structural guards it does need. `sanitize_text_field()` strips
 * line breaks, which is load-bearing: the runner marks operator turns
 * in the composed prompt, and a multi-line voice line could otherwise
 * fake a turn boundary. And the length cap keeps a "voice" from
 * becoming a second instruction block by volume.
 *
 * @param mixed $value Incoming line.
 * @return string
 */
function openstation_agent_sanitize_vibes( $value ) {
	if ( ! is_scalar( $value ) ) {
		return '';
	}
	$clean = sanitize_text_field( (string) $value );
	return mb_substr( $clean, 0, OPENSTATION_AGENT_VIBES_MAX_LENGTH );
}

/**
 * Sanitize an agent's face for storage.
 *
 * The narrowing itself is `openstation_mio_narrow_look()`, which the
 * WP Explorer config also calls to preview the shipped cast while the
 * feature flag is off. Shared on purpose: two copies of "clamp, then
 * keep what was carried" is how a preview starts drawing a face the
 * seeder would never store. What this adds on top is the storage
 * shape — a JSON string, and an empty one when nothing was set, so an
 * agent with no opinion keeps no row at all rather than an empty blob.
 *
 * @param mixed $value Incoming look (array or JSON string).
 * @return string JSON, or an empty string when nothing was set.
 */
function openstation_agent_sanitize_face_json( $value ) {
	if ( is_string( $value ) ) {
		$decoded = json_decode( $value, true );
		$value   = is_array( $decoded ) ? $decoded : array();
	}
	$out = openstation_mio_narrow_look( $value );
	if ( empty( $out['appearance'] ) && empty( $out['physics'] ) ) {
		return '';
	}
	return (string) wp_json_encode( $out );
}

/**
 * Read an agent's voice line.
 *
 * @param int $user_id Agent user id.
 * @return string
 */
function openstation_agent_get_vibes( $user_id ) {
	return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_VIBES_META, true );
}

/**
 * Read an agent's stored face.
 *
 * A partial look: only what was overridden. Empty means "no face
 * chosen", which the avatar resolver reads as the shipped robot.
 *
 * @param int $user_id Agent user id.
 * @return array {
 *     @type array $appearance Partial appearance overrides.
 *     @type array $physics    Partial silhouette overrides.
 * }
 */
function openstation_agent_get_face( $user_id ) {
	$raw = (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_FACE_META, true );
	if ( '' === $raw ) {
		return array(
			'appearance' => array(),
			'physics'    => array(),
		);
	}
	return openstation_sanitize_mio_look( json_decode( $raw, true ) );
}

/**
 * Read the seed an agent's face was rolled from.
 *
 * Kept alongside the face rather than instead of it. The face is what
 * gets drawn; the seed is provenance, and it is what lets a future
 * change to the randomizer's ranges re-roll every agent in one
 * migration instead of stranding them on an old palette.
 *
 * @param int $user_id Agent user id.
 * @return int Seed, or 0 when none was recorded.
 */
function openstation_agent_get_face_seed( $user_id ) {
	return (int) get_user_meta( (int) $user_id, OPENSTATION_AGENT_FACE_SEED_META, true );
}

/**
 * Sanitize the triggers array.
 *
 * Validates each row against the kind catalogue. Drops any row that
 * doesn't match a known kind — one bad row never rejects the whole
 * array.
 *
 * @param mixed $value Incoming triggers array (or JSON string).
 * @return array
 */
function openstation_agent_sanitize_triggers( $value ) {
	if ( is_string( $value ) ) {
		$decoded = json_decode( $value, true );
		$value   = is_array( $decoded ) ? $decoded : array();
	}
	if ( ! is_array( $value ) ) {
		return array();
	}

	$known_kinds = array();
	foreach ( openstation_agent_trigger_kinds() as $kind ) {
		$known_kinds[ $kind['slug'] ] = $kind;
	}

	$out = array();
	foreach ( $value as $row ) {
		if ( ! is_array( $row ) ) {
			continue;
		}
		$kind = isset( $row['kind'] ) ? sanitize_key( $row['kind'] ) : '';
		if ( '' === $kind || ! isset( $known_kinds[ $kind ] ) ) {
			continue;
		}

		$config = isset( $row['config'] ) && is_array( $row['config'] ) ? $row['config'] : array();
		$config = openstation_agent_sanitize_trigger_config_deep( $config );

		$out[] = array(
			'kind'   => $kind,
			'config' => $config,
		);
	}

	return $out;
}

/**
 * `register_meta` sanitize callback — triggers land on disk as JSON.
 *
 * @param mixed $value Incoming value.
 * @return string JSON-encoded triggers list.
 */
function openstation_agent_sanitize_triggers_json( $value ) {
	return (string) wp_json_encode( openstation_agent_sanitize_triggers( $value ) );
}

/**
 * Recursively coerce trigger-config values into safe primitives.
 *
 * Keys are camelCase by convention (`entityKinds`, `mimeTypes`,
 * `fromAgents`) because they round-trip through the JS REST adapter
 * verbatim — so the case is preserved and only non-identifier
 * characters are stripped. `sanitize_key()` would lower-case
 * everything, breaking the contract with the client.
 *
 * @param mixed $value Arbitrary input.
 * @return mixed
 */
function openstation_agent_sanitize_trigger_config_deep( $value ) {
	if ( is_array( $value ) ) {
		$out = array();
		foreach ( $value as $k => $v ) {
			if ( is_string( $k ) ) {
				$key = preg_replace( '/[^A-Za-z0-9_\-]/', '', $k );
				if ( '' === $key ) {
					continue;
				}
			} else {
				$key = (int) $k;
			}
			$out[ $key ] = openstation_agent_sanitize_trigger_config_deep( $v );
		}
		return $out;
	}
	if ( is_bool( $value ) || is_int( $value ) ) {
		return $value;
	}
	if ( is_numeric( $value ) ) {
		return $value + 0;
	}
	if ( is_string( $value ) ) {
		return sanitize_text_field( $value );
	}
	return null;
}

// ---------------------------------------------------------------------------
// Catalogues
// ---------------------------------------------------------------------------

/**
 * Built-in trigger kinds.
 *
 * `chat`, `send-to`, and `drag` are wired; the other kinds are declared so the
 * Triggers pane can already store configuration for them, and later
 * phases add the intake plumbing without a storage migration.
 *
 * Plugins can extend the list via the `openstation_agent_trigger_kinds`
 * filter — each entry must declare a `slug`, `label`, and a JSON-Schema
 * `config_schema` describing the shape of `trigger.config`.
 *
 * @return array<int, array{slug:string,wired:bool,label:string,description:string,icon:string,config_schema:array}>
 */
function openstation_agent_trigger_kinds() {
	$kinds = array(
		array(
			'slug'          => 'chat',
			'wired'         => true,
			'label'         => __( 'Chat', 'desktop-mode' ),
			'description'   => __( 'Open a conversation window with the agent.', 'desktop-mode' ),
			'icon'          => 'dashicons-format-chat',
			'config_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'capability' => array( 'type' => 'string' ),
				),
			),
		),
		array(
			'slug'          => 'send-to',
			'wired'         => true,
			'label'         => __( 'Send to (right-click menu)', 'desktop-mode' ),
			'description'   => __( 'The agent appears as a "Send to…" action in the right-click menu for the entity kinds you pick.', 'desktop-mode' ),
			'icon'          => 'dashicons-share-alt',
			'config_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'entityKinds' => array(
						'type'  => 'array',
						'items' => array(
							'type' => 'string',
							'enum' => array( 'post', 'page', 'media', 'user', 'comment' ),
						),
					),
				),
			),
		),
		array(
			'slug'          => 'drag',
			'wired'         => true,
			'label'         => __( 'Drag & drop', 'desktop-mode' ),
			'description'   => __( 'Drop a tile onto the agent.', 'desktop-mode' ),
			'icon'          => 'dashicons-move',
			'config_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'mimeTypes'   => array(
						'type'  => 'array',
						'items' => array( 'type' => 'string' ),
					),
					'entityKinds' => array(
						'type'  => 'array',
						'items' => array( 'type' => 'string' ),
					),
				),
			),
		),
		array(
			'slug'          => 'hook',
			'wired'         => false,
			'label'         => __( 'WordPress hook', 'desktop-mode' ),
			'description'   => __( 'Run automatically when a WordPress action fires.', 'desktop-mode' ),
			'icon'          => 'dashicons-admin-plugins',
			'config_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'hook'     => array( 'type' => 'string' ),
					'priority' => array( 'type' => 'integer' ),
				),
				'required'   => array( 'hook' ),
			),
		),
		array(
			'slug'          => 'endpoint',
			'wired'         => false,
			'label'         => __( 'REST endpoint', 'desktop-mode' ),
			'description'   => __( 'Expose a REST URL for external services to call.', 'desktop-mode' ),
			'icon'          => 'dashicons-rest-api',
			'config_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'auth'       => array(
						'type' => 'string',
						'enum' => array( 'capability', 'application-password' ),
					),
					'capability' => array( 'type' => 'string' ),
				),
			),
		),
		array(
			'slug'          => 'agent',
			'wired'         => false,
			'label'         => __( 'Agent-to-agent', 'desktop-mode' ),
			'description'   => __( 'Run when another agent on this site emits a completion event.', 'desktop-mode' ),
			'icon'          => 'dashicons-networking',
			'config_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'fromAgents' => array(
						'type'  => 'array',
						'items' => array( 'type' => 'string' ),
					),
				),
			),
		),
	);

	/**
	 * Filter the trigger kinds available to agents.
	 *
	 * @param array $kinds Default trigger kinds.
	 */
	$filtered = apply_filters( 'openstation_agent_trigger_kinds', $kinds );
	if ( ! is_array( $filtered ) ) {
		return $kinds;
	}
	return array_values( $filtered );
}

/**
 * Curated catalogue of WordPress hooks suggested for the Hook trigger.
 *
 * Not exhaustive — just the ones agents are most likely to subscribe
 * to. The renderer offers it as an autocomplete; the user can type any
 * hook name.
 *
 * @return array<int, array{hook:string, when:string}>
 */
function openstation_agent_hooks_catalogue() {
	$hooks = array(
		array(
			'hook' => 'save_post',
			'when' => __( 'Every time a post is saved.', 'desktop-mode' ),
		),
		array(
			'hook' => 'wp_insert_post',
			'when' => __( 'A new post is inserted.', 'desktop-mode' ),
		),
		array(
			'hook' => 'transition_post_status',
			'when' => __( 'A post status changes.', 'desktop-mode' ),
		),
		array(
			'hook' => 'wp_insert_comment',
			'when' => __( 'A new comment is inserted.', 'desktop-mode' ),
		),
		array(
			'hook' => 'comment_post',
			'when' => __( 'A new comment is posted.', 'desktop-mode' ),
		),
		array(
			'hook' => 'user_register',
			'when' => __( 'A new user registers.', 'desktop-mode' ),
		),
		array(
			'hook' => 'profile_update',
			'when' => __( 'A user profile is updated.', 'desktop-mode' ),
		),
		array(
			'hook' => 'add_attachment',
			'when' => __( 'A new attachment is added.', 'desktop-mode' ),
		),
	);

	/**
	 * Filter the curated catalogue of suggested hooks for the Hook
	 * trigger configurator.
	 *
	 * @param array $hooks Default catalogue.
	 */
	$filtered = apply_filters( 'openstation_agent_hooks_catalogue', $hooks );
	return is_array( $filtered ) ? array_values( $filtered ) : $hooks;
}

/**
 * Whether the acting user may grant `$role` to an agent.
 *
 * An agent runs with its role's capabilities, so granting a role IS
 * granting capability — it has to be gated like the promotion it is.
 * Three constraints, all of which must hold:
 *
 *  1. `promote_users` — the capability wp-admin requires to set anyone's
 *     role. `edit_users` alone is not enough: role plugins hand
 *     `edit_users` to shop-manager-shaped roles routinely.
 *  2. `get_editable_roles()` — core's extension point for "roles this
 *     install lets you hand out". NOTE this is a site-wide filtered
 *     list, NOT a per-user one: core's implementation is a bare
 *     `apply_filters( 'editable_roles', wp_roles()->roles )` with no
 *     reference to the current user. It is a useful constraint because
 *     plugins like WooCommerce filter it, but on a stock install it
 *     excludes nothing, so it cannot be the only gate.
 *  3. `administrator` additionally requires the actor to genuinely be
 *     an administrator (super admin on multisite). This is the one that
 *     stops an `edit_users`-capable non-admin minting an agent that
 *     outranks them — the capability the agent would then act with.
 *
 * @param string $role Role slug being assigned.
 * @return bool
 */
function openstation_agent_actor_can_assign_role( $role ) {
	$role = sanitize_key( (string) $role );
	$can  = current_user_can( 'promote_users' );

	if ( $can && 'administrator' === $role ) {
		$can = is_multisite()
			? is_super_admin()
			: ( current_user_can( 'manage_options' ) && current_user_can( 'create_users' ) );
	}

	/**
	 * Filter whether the acting user may assign a role to an agent.
	 *
	 * The seam for automation that legitimately creates agents outside
	 * a request context (an activation routine, WP-CLI, a scheduled
	 * provisioning job), where there is no current user and the default
	 * answer is therefore a hard no.
	 *
	 * Granting a role here grants the capabilities an agent will act
	 * with — widen it only for code paths you control.
	 *
	 * @param bool   $can     Whether the assignment is allowed.
	 * @param string $role    Role slug being assigned.
	 * @param int    $user_id Acting user id (0 when there is none).
	 */
	return (bool) apply_filters(
		'openstation_agent_actor_can_assign_role',
		$can,
		$role,
		get_current_user_id()
	);
}

/**
 * Roles an agent may be assigned, constrained to what the acting user
 * can actually hand out.
 *
 * The whitelist keeps agents in the standard content-role band; each
 * survivor is then run through
 * {@see openstation_agent_actor_can_assign_role()}, which is where the
 * real gating lives.
 *
 * @return string[] Role slugs.
 */
function openstation_agent_allowed_roles() {
	$whitelist = array( 'administrator', 'editor', 'author', 'contributor' );

	/**
	 * Filter the roles an agent may be assigned.
	 *
	 * The result is always intersected with `get_editable_roles()` and
	 * then filtered through the per-role actor check — this filter can
	 * narrow or extend the candidate list, but a role it adds still has
	 * to clear both constraints.
	 *
	 * @param string[] $whitelist Default role slugs.
	 */
	$whitelist = apply_filters( 'openstation_agent_allowed_roles', $whitelist );
	if ( ! is_array( $whitelist ) ) {
		return array();
	}

	if ( ! function_exists( 'get_editable_roles' ) ) {
		require_once ABSPATH . 'wp-admin/includes/user.php';
	}
	$editable = array_keys( get_editable_roles() );

	$candidates = array_intersect( array_map( 'strval', $whitelist ), $editable );

	$allowed = array();
	foreach ( $candidates as $role ) {
		if ( openstation_agent_actor_can_assign_role( $role ) ) {
			$allowed[] = $role;
		}
	}

	return array_values( $allowed );
}

// ---------------------------------------------------------------------------
// Getters / setters
// ---------------------------------------------------------------------------

/**
 * Read the "when to use" description.
 *
 * @param int $user_id Agent user id.
 * @return string
 */
function openstation_agent_get_description( $user_id ) {
	return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_DESCRIPTION_META, true );
}

/**
 * Read the system prompt.
 *
 * @param int $user_id Agent user id.
 * @return string
 */
function openstation_agent_get_instructions( $user_id ) {
	return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_INSTRUCTIONS_META, true );
}

/**
 * Read the ability allowlist.
 *
 * @param int $user_id Agent user id.
 * @return string[]
 */
function openstation_agent_get_abilities( $user_id ) {
	$raw = get_user_meta( (int) $user_id, OPENSTATION_AGENT_ABILITIES_META, true );
	if ( '' === $raw || null === $raw ) {
		return array();
	}
	return openstation_agents_sanitize_ability_slugs( $raw );
}

/**
 * Read triggers.
 *
 * @param int $user_id Agent user id.
 * @return array
 */
function openstation_agent_get_triggers( $user_id ) {
	$raw = get_user_meta( (int) $user_id, OPENSTATION_AGENT_TRIGGERS_META, true );
	if ( '' === $raw || null === $raw ) {
		return array();
	}
	return openstation_agent_sanitize_triggers( $raw );
}

/**
 * Read the model override.
 *
 * @param int $user_id Agent user id.
 * @return string Empty string if not set.
 */
function openstation_agent_get_model( $user_id ) {
	return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_MODEL_META, true );
}

/**
 * Read the rate limit (invocations per hour).
 *
 * @param int $user_id Agent user id.
 * @return int Zero when no per-agent override is set.
 */
function openstation_agent_get_rate_limit( $user_id ) {
	return (int) get_user_meta( (int) $user_id, OPENSTATION_AGENT_RATE_LIMIT_META, true );
}

// ---------------------------------------------------------------------------
// Per-agent invocation gate
// ---------------------------------------------------------------------------

/**
 * The agent's trigger row for a given invocation source, if any.
 *
 * Source slugs on the invoke route map 1:1 onto trigger kinds
 * (`chat`, `drag`, `send-to`).
 *
 * @param int    $agent_user_id Agent user id.
 * @param string $source        Invocation source slug.
 * @return array|null Trigger row, or null when the agent declares none
 *                    for this source.
 */
function openstation_agent_trigger_for_source( $agent_user_id, $source ) {
	$source = sanitize_key( (string) $source );
	foreach ( openstation_agent_get_triggers( (int) $agent_user_id ) as $trigger ) {
		if ( isset( $trigger['kind'] ) && $source === $trigger['kind'] ) {
			return $trigger;
		}
	}
	return null;
}

/**
 * Whether the current user may invoke THIS agent through THIS source.
 *
 * The route-level `openstation_agents_user_can_invoke()` check is
 * site-wide — it answers "may this user invoke agents at all". This is
 * the per-agent half: a trigger may declare a `capability` in its
 * config, and until it is enforced here the field is decorative. The
 * Triggers pane collects it and the store persists it, so an
 * administrator restricting an agent to `manage_options` has every
 * reason to believe it took effect.
 *
 * An agent with no trigger for the source, or a trigger that declares
 * no capability, is left to the route-level check — requiring a
 * configured trigger would lock out every agent created before triggers
 * were set up, which is all of them by default.
 *
 * @param int    $agent_user_id Agent user id.
 * @param string $source        Invocation source slug.
 * @return bool
 */
function openstation_agent_user_can_invoke_agent( $agent_user_id, $source = 'chat' ) {
	$trigger    = openstation_agent_trigger_for_source( $agent_user_id, $source );
	$capability = '';
	if ( is_array( $trigger ) && isset( $trigger['config']['capability'] ) ) {
		$capability = trim( (string) $trigger['config']['capability'] );
	}

	$can = '' === $capability || current_user_can( $capability );

	/**
	 * Filter whether the current user may invoke a specific agent.
	 *
	 * @param bool       $can           Whether invocation is allowed.
	 * @param int        $agent_user_id Agent user id.
	 * @param string     $source        Invocation source slug.
	 * @param array|null $trigger       The matching trigger row, if any.
	 */
	return (bool) apply_filters(
		'openstation_agent_user_can_invoke_agent',
		$can,
		(int) $agent_user_id,
		(string) $source,
		$trigger
	);
}

// ---------------------------------------------------------------------------
// List helper
// ---------------------------------------------------------------------------

/**
 * Every agent on the site, ordered by display name.
 *
 * @param array $args Optional overrides merged into the `get_users()` query.
 * @return WP_User[]
 */
function openstation_agent_get_agents( $args = array() ) {
	$defaults = array(
		'meta_key'   => OPENSTATION_AGENT_USER_MARKER_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
		'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
		'orderby'    => 'display_name',
		'order'      => 'ASC',
		'number'     => 200,
	);
	return get_users( array_merge( $defaults, is_array( $args ) ? $args : array() ) );
}

// ---------------------------------------------------------------------------
// Orchestrators — the only write paths, each firing one audit action
// ---------------------------------------------------------------------------

/**
 * Create an agent: synthetic user row + definition meta.
 *
 * @param array{name:string, role:string, slug?:string, description?:string, instructions?:string, abilities?:array, triggers?:array, vibes?:string, face?:array|string, faceSeed?:int} $args Creation args.
 * @return WP_User|WP_Error
 */
function openstation_agent_create( $args ) {
	$role    = isset( $args['role'] ) ? sanitize_key( (string) $args['role'] ) : '';
	$allowed = openstation_agent_allowed_roles();
	if ( '' === $role || ! in_array( $role, $allowed, true ) ) {
		return new WP_Error(
			'openstation_agent_invalid_role',
			__( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' )
		);
	}

	$user = openstation_agent_create_user( $args );
	if ( is_wp_error( $user ) ) {
		return $user;
	}

	$description  = isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : '';
	$instructions = isset( $args['instructions'] ) ? wp_kses_post( (string) $args['instructions'] ) : '';
	$abilities    = isset( $args['abilities'] ) ? openstation_agents_sanitize_ability_slugs( $args['abilities'] ) : array();
	$triggers     = isset( $args['triggers'] ) ? openstation_agent_sanitize_triggers( $args['triggers'] ) : array();
	$vibes        = isset( $args['vibes'] ) ? openstation_agent_sanitize_vibes( $args['vibes'] ) : '';
	$face         = isset( $args['face'] ) ? openstation_agent_sanitize_face_json( $args['face'] ) : '';
	// Every agent gets a seed even when nobody chose a face, so the
	// backfill has something deterministic to roll from and two admins
	// racing it land on the same portrait. `crc32` of the login is
	// stable, cheap, and already unique per agent.
	$seed = isset( $args['faceSeed'] ) ? absint( $args['faceSeed'] ) : 0;
	if ( 0 === $seed ) {
		$seed = crc32( (string) $user->user_login );
	}

	if ( '' !== $description ) {
		update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
	}
	if ( '' !== $instructions ) {
		update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions );
	}
	if ( ! empty( $abilities ) ) {
		update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
	}
	if ( ! empty( $triggers ) ) {
		update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
	}
	if ( '' !== $vibes ) {
		update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
	}
	if ( '' !== $face ) {
		update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
	}
	update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
	update_user_meta( $user->ID, OPENSTATION_AGENT_CREATED_BY_META, get_current_user_id() );

	/**
	 * Fires after an agent is created.
	 *
	 * @param int   $user_id  Agent user id.
	 * @param array $args     Sanitized creation fields (name, role,
	 *                        description, instructions, abilities).
	 * @param int   $actor_id User who created the agent.
	 */
	do_action(
		'openstation_agent_created',
		(int) $user->ID,
		array(
			'name'         => (string) $user->display_name,
			'role'         => $role,
			'description'  => $description,
			'instructions' => $instructions,
			'abilities'    => $abilities,
			'vibes'        => $vibes,
			'face'         => $face,
			'faceSeed'     => $seed,
		),
		get_current_user_id()
	);

	return $user;
}

/**
 * Update an agent's definition. Accepts any subset of the recognized
 * fields, applies the valid ones, and fires `openstation_agent_updated`
 * once with a before/after map of everything that changed.
 *
 * Recognized fields: `name`, `role`, `description`, `instructions`,
 * `abilities`, `triggers`, `model`, `rateLimit`, `vibes`, `face`,
 * `faceSeed`.
 *
 * @param int   $user_id Agent user id.
 * @param array $fields  Field map.
 * @return true|WP_Error
 */
function openstation_agent_update( $user_id, array $fields ) {
	$user = get_userdata( (int) $user_id );
	if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
		return new WP_Error(
			'openstation_agent_not_found',
			__( 'Agent not found.', 'desktop-mode' )
		);
	}

	$changed = array();

	if ( isset( $fields['name'] ) ) {
		$name = sanitize_text_field( (string) $fields['name'] );
		if ( '' === $name ) {
			return new WP_Error(
				'openstation_agent_invalid_name',
				__( 'Agent name cannot be empty.', 'desktop-mode' )
			);
		}
		if ( $name !== (string) $user->display_name ) {
			$changed['name'] = array(
				'from' => (string) $user->display_name,
				'to'   => $name,
			);
			wp_update_user(
				array(
					'ID'           => (int) $user->ID,
					'display_name' => $name,
					'nickname'     => $name,
				)
			);
		}
	}

	if ( isset( $fields['role'] ) ) {
		$role = sanitize_key( (string) $fields['role'] );
		if ( ! in_array( $role, openstation_agent_allowed_roles(), true ) ) {
			return new WP_Error(
				'openstation_agent_invalid_role',
				__( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' )
			);
		}
		$current_role = is_array( $user->roles ) && ! empty( $user->roles ) ? (string) reset( $user->roles ) : '';
		if ( $role !== $current_role ) {
			$changed['role'] = array(
				'from' => $current_role,
				'to'   => $role,
			);
			$user->set_role( $role );
		}
	}

	if ( isset( $fields['description'] ) ) {
		$description = sanitize_text_field( (string) $fields['description'] );
		$before      = openstation_agent_get_description( $user->ID );
		if ( $description !== $before ) {
			$changed['description'] = array(
				'from' => $before,
				'to'   => $description,
			);
			update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
		}
	}

	if ( isset( $fields['instructions'] ) ) {
		$instructions = wp_kses_post( (string) $fields['instructions'] );
		$before       = openstation_agent_get_instructions( $user->ID );
		if ( $instructions !== $before ) {
			$changed['instructions'] = array(
				'from' => $before,
				'to'   => $instructions,
			);
			update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions );
		}
	}

	if ( isset( $fields['abilities'] ) ) {
		$abilities = openstation_agents_sanitize_ability_slugs( $fields['abilities'] );
		$before    = openstation_agent_get_abilities( $user->ID );
		if ( $abilities !== $before ) {
			$changed['abilities'] = array(
				'from' => $before,
				'to'   => $abilities,
			);
			update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
		}
	}

	if ( isset( $fields['triggers'] ) ) {
		$triggers = openstation_agent_sanitize_triggers( $fields['triggers'] );
		$before   = openstation_agent_get_triggers( $user->ID );
		if ( $triggers !== $before ) {
			$changed['triggers'] = array(
				'from' => $before,
				'to'   => $triggers,
			);
			update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
		}
	}

	if ( isset( $fields['model'] ) ) {
		$model  = sanitize_text_field( (string) $fields['model'] );
		$before = openstation_agent_get_model( $user->ID );
		if ( $model !== $before ) {
			$changed['model'] = array(
				'from' => $before,
				'to'   => $model,
			);
			if ( '' === $model ) {
				delete_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META );
			} else {
				update_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META, $model );
			}
		}
	}

	if ( isset( $fields['rateLimit'] ) ) {
		$rate   = max( 0, (int) $fields['rateLimit'] );
		$before = openstation_agent_get_rate_limit( $user->ID );
		if ( $rate !== $before ) {
			$changed['rateLimit'] = array(
				'from' => $before,
				'to'   => $rate,
			);
			if ( 0 === $rate ) {
				delete_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META );
			} else {
				update_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META, $rate );
			}
		}
	}

	if ( isset( $fields['vibes'] ) ) {
		$vibes  = openstation_agent_sanitize_vibes( $fields['vibes'] );
		$before = openstation_agent_get_vibes( $user->ID );
		if ( $vibes !== $before ) {
			$changed['vibes'] = array(
				'from' => $before,
				'to'   => $vibes,
			);
			if ( '' === $vibes ) {
				delete_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META );
			} else {
				update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
			}
		}
	}

	if ( isset( $fields['face'] ) ) {
		$face   = openstation_agent_sanitize_face_json( $fields['face'] );
		$before = (string) get_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, true );
		if ( $face !== $before ) {
			$changed['face'] = array(
				'from' => $before,
				'to'   => $face,
			);
			if ( '' === $face ) {
				delete_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META );
			} else {
				update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
			}
		}
	}

	if ( isset( $fields['faceSeed'] ) ) {
		$seed   = absint( $fields['faceSeed'] );
		$before = openstation_agent_get_face_seed( $user->ID );
		if ( $seed !== $before ) {
			$changed['faceSeed'] = array(
				'from' => $before,
				'to'   => $seed,
			);
			update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
		}
	}

	if ( ! empty( $changed ) ) {
		/**
		 * Fires after an agent's definition changed.
		 *
		 * User meta has no revisions, so this action IS the audit
		 * trail — each changed field carries its before/after value.
		 *
		 * @param int   $user_id  Agent user id.
		 * @param array $changed  Map of field => { from, to }.
		 * @param int   $actor_id User who made the change.
		 */
		do_action( 'openstation_agent_updated', (int) $user->ID, $changed, get_current_user_id() );
	}

	return true;
}

```
