diff --git a/CHANGELOG.md b/CHANGELOG.md index fb61aa46d..009aca906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ Each GitHub release body is the matching section of this file. ## [Unreleased] +### Changed + +- The Codex accounts table shows per-account quota windows and remaining usage; + an idle quota check refreshes accounts periodically, and limit-aware failover + prefers an account with remaining quota. When all accounts are limited, the + request still reaches Codex on the account whose limit resets first. +- Outbound webhooks may target private and homelab addresses, while cloud-metadata + destinations remain blocked. Redirects and DNS are validated at delivery, and + webhook signatures are never forwarded to another origin. +- MCP calls keep the first response for a request ID on all transports; later + responses are ignored with a payload-free warning. The handshake remains strict. +- The completion judge no longer imposes a 128-token output cap. Native Codex + requests are unchanged; compatible and Ollama reasoning judges can use their + normal output budget. Empty or ambiguous judge answers are logged as warnings. +- Knowledge ingestion splits unusually long words into bounded chunks. Existing + documents are unchanged until re-ingested. + +### Fixed + +- `/stop` removes its private deferred acknowledgement after Odin's public stop + message is delivered, avoiding a duplicate private notification. +- Outbound webhook edits made in the API or WebUI persist across restarts and + report their durable state. Configured per-target TLS verification and secret + scrubbing settings now apply at startup. +- Malformed permission overrides and API token records survive unrelated writes, + with operator-visible diagnostics; existing effective tiers and token access + are not changed by the migration. +- Trusted-proxy forwarding validates IP addresses, supports CIDR trust ranges, + and walks the forwarded chain from the nearest proxy to prevent rate-limit + bucket spoofing and inaccurate audit IPs. +- Concurrent knowledge ingests no longer bypass duplicate checks or add + spurious versions, and colliding chunk ID prefixes cannot overwrite chunks + belonging to another source, including on version restore. +- Usage coverage recovers from transient scan failures and detects oversized + unfinished trajectory rows instead of stalling later records silently. +- Computer-use audit and System Logs retain specific refusal reasons, including + `target_changed_observe_again`, instead of collapsing them to a generic + rejection; desktop input and receipts are unchanged. + +### Removed + +- Removed the unused standalone plan engine. The `python -m src.odin` plan + runner is gone; remove any scripts or automation that invoke it when upgrading. +- Removed the Slack integration, its configuration fields, API routes and WebUI + controls. Existing `slack:` configuration sections are ignored for upgrade + compatibility; generic Slack-token redaction remains active. + ## [4.6.0] - 2026-09-23 ### Added diff --git a/README.md b/README.md index 87583966e..85a8219ff 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ The package also grants the `odin` service account passwordless sudo; restrict ` - Create, edit, enable, disable, import, export, and invoke Python skills at runtime. - Configure skills with JSON schemas, dependencies, and operator-managed settings. -- Integrate external systems through webhooks, email, MCP servers, Slack, Grafana alerts, and custom skill code. +- Integrate external systems through webhooks, email, MCP servers, Grafana alerts, and custom skill code. ### Management interface diff --git a/config.yml b/config.yml index 891d0808e..b22b0ab01 100644 --- a/config.yml +++ b/config.yml @@ -322,16 +322,6 @@ web: # --- Optional integrations (uncomment and configure to enable) --- -# # Slack webhook forwarding -# slack: -# enabled: false -# webhook_urls: {} # { "channel-name": "https://hooks.slack.com/..." } -# default_webhook_url: '' -# scrub_secrets: true -# rate_limit_seconds: 1 -# forward_alerts: true -# forward_webhooks: false - # # Grafana alert auto-remediation # grafana_alerts: # auto_remediate: false diff --git a/coverage-baseline.json b/coverage-baseline.json index 8e7ed0df0..4d60064b4 100644 --- a/coverage-baseline.json +++ b/coverage-baseline.json @@ -1193,12 +1193,6 @@ "percent": 100.0, "statements": 12 }, - "src/notifications/slack.py": { - "covered": 129, - "missing": 0, - "percent": 100.0, - "statements": 129 - }, "src/observability/__init__.py": { "covered": 3, "missing": 0, @@ -1235,96 +1229,6 @@ "percent": 87.5, "statements": 16 }, - "src/odin/__init__.py": { - "covered": 7, - "missing": 0, - "percent": 100.0, - "statements": 7 - }, - "src/odin/cli.py": { - "covered": 46, - "missing": 13, - "percent": 77.97, - "statements": 59 - }, - "src/odin/context.py": { - "covered": 122, - "missing": 7, - "percent": 94.57, - "statements": 129 - }, - "src/odin/executor.py": { - "covered": 42, - "missing": 1, - "percent": 97.67, - "statements": 43 - }, - "src/odin/plan_loader.py": { - "covered": 29, - "missing": 8, - "percent": 78.38, - "statements": 37 - }, - "src/odin/planner.py": { - "covered": 95, - "missing": 3, - "percent": 96.94, - "statements": 98 - }, - "src/odin/registry.py": { - "covered": 26, - "missing": 0, - "percent": 100.0, - "statements": 26 - }, - "src/odin/reporter.py": { - "covered": 25, - "missing": 0, - "percent": 100.0, - "statements": 25 - }, - "src/odin/tools/__init__.py": { - "covered": 0, - "missing": 0, - "percent": 100.0, - "statements": 0 - }, - "src/odin/tools/base.py": { - "covered": 8, - "missing": 1, - "percent": 88.89, - "statements": 9 - }, - "src/odin/tools/file_ops.py": { - "covered": 19, - "missing": 0, - "percent": 100.0, - "statements": 19 - }, - "src/odin/tools/http.py": { - "covered": 8, - "missing": 13, - "percent": 38.1, - "statements": 21 - }, - "src/odin/tools/process.py": { - "covered": 29, - "missing": 0, - "percent": 100.0, - "statements": 29 - }, - "src/odin/tools/shell.py": { - "covered": 19, - "missing": 1, - "percent": 95.0, - "statements": 20 - }, - "src/odin/types.py": { - "covered": 37, - "missing": 0, - "percent": 100.0, - "statements": 37 - }, "src/odin_log/__init__.py": { "covered": 2, "missing": 0, diff --git a/docs/reference/api.md b/docs/reference/api.md index 8ab774d48..c37d2bb9a 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -4,7 +4,7 @@ Source commit: `e1318eca83a40b9418d873ad85c31686ea6b57d0`. Generated by `scripts/docs/generate_api_reference.py`; do not edit by hand. -The **236 REST registrations** below follow [`create_api_routes`](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/__init__.py) in exact order, using the same route-table mechanism as the [parity test](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/tests/characterization/test_api_route_parity.py). Order matters for overlapping literal and parameter paths. Implicit aiohttp HEAD routes are not counted separately; GET registrations also accept HEAD. Purpose is the first handler-docstring line; **—** means the handler has no docstring, not a guessed description. +The **237 REST registrations** below follow [`create_api_routes`](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/__init__.py) in exact order, using the same route-table mechanism as the [parity test](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/tests/characterization/test_api_route_parity.py). Order matters for overlapping literal and parameter paths. Implicit aiohttp HEAD routes are not counted separately; GET registrations also accept HEAD. Purpose is the first handler-docstring line; **—** means the handler has no docstring, not a guessed description. ## Authentication and authorization @@ -16,33 +16,34 @@ This describes the normal authenticated deployment. With no configured tokens (i | Method | Path | Owning module / handler source | Admin-gated | Purpose | | --- | --- | --- | --- | --- | -| POST | /api/auth/login | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L520) | No | — | -| POST | /api/auth/logout | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L624) | No | — | -| GET | /api/auth/session | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L646) | No | — | -| GET | /api/setup/status | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L184) | Yes | Check whether first-boot setup is needed. | -| POST | /api/setup/complete | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L201) | Yes | Receive wizard data and report restart-required settings, without restarting. | -| POST | /api/setup/listener | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L333) | Yes | Reauthenticate a raw admin bearer and consent to web.host on next restart. | -| GET | /api/status | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L399) | Yes | — | -| GET | /api/discord/guilds | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L468) | Yes | — | -| GET | /api/discord/members | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L507) | Yes | — | -| PUT | /api/discord/guild/{guild_id}/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L524) | Yes | — | -| PUT | /api/discord/channel/{channel_id}/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L539) | Yes | — | -| GET | /api/health/components | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L555) | Yes | — | -| GET | /api/resource-usage | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L560) | Yes | — | -| GET | /api/tool-streams | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L565) | Yes | — | -| GET | /api/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L577) | Yes | — | -| GET | /api/config/meta | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L582) | Yes | Every configuration leaf, and how it reaches the running bot. | -| POST | /api/config/image-models | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L612) | Yes | Adopt shipped defaults or pin transaction-current effective strings. | -| PUT | /api/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L694) | Yes | — | +| POST | /api/auth/login | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L636) | No | — | +| POST | /api/auth/logout | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L750) | No | — | +| GET | /api/auth/session | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L772) | No | — | +| GET | /api/setup/status | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L194) | Yes | Check whether first-boot setup is needed. | +| POST | /api/setup/complete | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L211) | Yes | Receive wizard data and report restart-required settings, without restarting. | +| POST | /api/setup/listener | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L348) | Yes | Reauthenticate a raw admin bearer and consent to web.host on next restart. | +| GET | /api/status | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L420) | Yes | — | +| GET | /api/discord/guilds | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L487) | Yes | — | +| GET | /api/discord/members | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L534) | Yes | — | +| PUT | /api/discord/guild/{guild_id}/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L551) | Yes | — | +| PUT | /api/discord/channel/{channel_id}/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L566) | Yes | — | +| GET | /api/health/components | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L582) | Yes | — | +| GET | /api/resource-usage | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L588) | Yes | — | +| GET | /api/tool-streams | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L594) | Yes | — | +| GET | /api/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L608) | Yes | — | +| GET | /api/config/meta | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L613) | Yes | Every configuration leaf, and how it reaches the running bot. | +| POST | /api/config/image-models | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L643) | Yes | Adopt shipped defaults or pin transaction-current effective strings. | +| PUT | /api/config | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L735) | Yes | — | | GET | /api/discord/connection | [src.web.api.discord_connection](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/discord_connection.py#L91) | Yes + local | — | | POST | /api/discord/connection | [src.web.api.discord_connection](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/discord_connection.py#L98) | Yes + local | — | -| POST | /api/restart | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L874) | Yes + local | Cleanly restart the running process, on operator request. | -| POST | /api/sessions/clear-all | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L903) | Yes + local | — | -| POST | /api/reload | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L911) | Yes | — | -| GET | /api/personality | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L966) | Yes | — | -| PUT | /api/personality | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L983) | Yes | — | -| POST | /api/personality/presets | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1037) | Yes | — | -| DELETE | /api/personality/presets/{name} | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1101) | Yes | — | +| GET | /api/discord/users/{user_id} | [src.web.api.discord_identity](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/discord_identity.py#L23) | Yes + local | — | +| POST | /api/restart | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L925) | Yes + local | Cleanly restart the running process, on operator request. | +| POST | /api/sessions/clear-all | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L955) | Yes + local | — | +| POST | /api/reload | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L963) | Yes | — | +| GET | /api/personality | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1018) | Yes | — | +| PUT | /api/personality | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1040) | Yes | — | +| POST | /api/personality/presets | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1095) | Yes | — | +| DELETE | /api/personality/presets/{name} | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1161) | Yes | — | | GET | /api/update/check | [src.web.api.self_update](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/self_update.py#L55) | Yes | — | | POST | /api/update/apply | [src.web.api.self_update](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/self_update.py#L83) | Yes | — | | POST | /api/loops/stop-all | [src.web.api.self_update](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/self_update.py#L254) | Yes | — | @@ -108,15 +109,12 @@ This describes the normal authenticated deployment. With no configured tokens (i | POST | /api/mcp/enabled | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L364) | Yes | — | | POST | /api/mcp/limits | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L401) | Yes | Persist only submitted limits; publication reads them live, without reconnecting. | | POST | /api/mcp/servers/{name}/enabled | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L454) | Yes | Single-purpose per-server switch (panel card toggle). | -| GET | /api/slack/status | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L514) | Yes | — | -| POST | /api/slack/test | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L522) | Yes | — | -| POST | /api/slack/send | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L537) | Yes | — | -| GET | /api/grafana-alerts/status | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L571) | Yes | — | -| GET | /api/grafana-alerts/history | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L579) | Yes | — | -| GET | /api/grafana-alerts/rules | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L589) | Yes | — | -| POST | /api/grafana-alerts/rules | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L597) | Yes | — | -| DELETE | /api/grafana-alerts/rules/{rule_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L631) | Yes | — | -| GET | /api/grafana-alerts/remediations | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L642) | Yes | — | +| GET | /api/grafana-alerts/status | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L514) | Yes | — | +| GET | /api/grafana-alerts/history | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L522) | Yes | — | +| GET | /api/grafana-alerts/rules | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L532) | Yes | — | +| POST | /api/grafana-alerts/rules | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L540) | Yes | — | +| DELETE | /api/grafana-alerts/rules/{rule_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L574) | Yes | — | +| GET | /api/grafana-alerts/remediations | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L585) | Yes | — | | GET | /api/knowledge | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L94) | Yes | — | | POST | /api/knowledge | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L101) | Yes | — | | DELETE | /api/knowledge/{source} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L126) | Yes | — | @@ -171,18 +169,20 @@ This describes the normal authenticated deployment. With no configured tokens (i | GET | /api/risk/recent | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L399) | Yes | — | | GET | /api/governor/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L410) | Yes | — | | GET | /api/audit/risk | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L417) | Yes | — | -| GET | /api/permissions/tiers | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L49) | Yes | — | -| GET | /api/permissions/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L67) | Yes | — | -| PUT | /api/permissions/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L83) | Yes | — | -| DELETE | /api/permissions/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L121) | Yes | — | +| GET | /api/permissions/tiers | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L75) | Yes | — | +| POST | /api/permissions/user/{user_id}/repair | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L99) | Yes | — | +| DELETE | /api/permissions/user/{user_id}/repair | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L128) | Yes | — | +| GET | /api/permissions/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L152) | Yes | — | +| PUT | /api/permissions/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L170) | Yes | — | +| DELETE | /api/permissions/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L197) | Yes | — | | GET | /api/codex/status | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L23) | Yes | — | -| POST | /api/codex/device-code | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L63) | Yes | — | -| POST | /api/codex/device-poll | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L72) | Yes | — | -| POST | /api/codex/account/{index}/refresh | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L148) | Yes | — | -| POST | /api/codex/account/{index}/activate | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L189) | Yes | — | -| POST | /api/codex/reload | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L206) | Yes | — | -| PUT | /api/codex/account/{index}/label | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L212) | Yes | — | -| DELETE | /api/codex/account/{index} | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L266) | Yes | — | +| POST | /api/codex/device-code | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L89) | Yes | — | +| POST | /api/codex/device-poll | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L98) | Yes | — | +| POST | /api/codex/account/{index}/refresh | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L174) | Yes | — | +| POST | /api/codex/account/{index}/activate | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L215) | Yes | — | +| POST | /api/codex/reload | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L232) | Yes | — | +| PUT | /api/codex/account/{index}/label | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L238) | Yes | — | +| DELETE | /api/codex/account/{index} | [src.web.api.codex_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/codex_admin.py#L292) | Yes | — | | GET | /api/llm/status | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L445) | Yes | — | | POST | /api/llm/switch | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L616) | Yes | — | | PUT | /api/llm/main-model | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L669) | Yes | Set the main model and persist its provider derived from the ref. | @@ -208,10 +208,10 @@ This describes the normal authenticated deployment. With no configured tokens (i | GET | /api/openai-compatible/models | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2012) | Yes | — | | POST | /api/openai-compatible/model | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2024) | Yes | — | | GET | /api/openai-compatible/diagnostic | [src.web.api.llm_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/llm_admin.py#L2066) | Yes | Return bounded connectivity evidence without exposing credentials. | -| GET | /api/host-access | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L160) | Yes + local | — | -| PUT | /api/host-access/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L181) | Yes + local | — | -| DELETE | /api/host-access/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L225) | Yes + local | — | -| PUT | /api/host-access/default-policy | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L244) | Yes + local | — | +| GET | /api/host-access | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L227) | Yes + local | — | +| PUT | /api/host-access/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L250) | Yes + local | — | +| DELETE | /api/host-access/user/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L286) | Yes + local | — | +| PUT | /api/host-access/default-policy | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L305) | Yes + local | — | | GET | /api/hosts | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L222) | Yes + local | — | | POST | /api/hosts/settings | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L235) | Yes + local | — | | GET | /api/hosts/public-key | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L319) | Yes + local | — | @@ -223,11 +223,12 @@ This describes the normal authenticated deployment. With no configured tokens (i | GET | /api/hosts/{alias}/references | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L492) | Yes + local | — | | DELETE | /api/hosts/{alias} | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L501) | Yes + local | — | | POST | /api/hosts/{alias}/force-revoke | [src.web.api.hosts](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/hosts.py#L532) | Yes + local | — | -| GET | /api/tokens | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L281) | Yes + local | — | -| POST | /api/tokens | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L297) | Yes + local | — | -| PUT | /api/tokens/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L375) | Yes + local | — | -| POST | /api/tokens/{user_id}/regenerate | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L458) | Yes + local | — | -| DELETE | /api/tokens/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L483) | Yes + local | — | +| GET | /api/tokens | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L342) | Yes + local | — | +| DELETE | /api/tokens/unusable/{index} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L365) | Yes + local | — | +| POST | /api/tokens | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L400) | Yes + local | — | +| PUT | /api/tokens/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L485) | Yes + local | — | +| POST | /api/tokens/{user_id}/regenerate | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L572) | Yes + local | — | +| DELETE | /api/tokens/{user_id} | [src.web.api.security](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/security.py#L600) | Yes + local | — | | GET | /api/recovery/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L437) | Yes | — | | GET | /api/recovery/recent | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L444) | Yes | — | | GET | /api/freshness/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L459) | Yes | — | @@ -238,18 +239,18 @@ This describes the normal authenticated deployment. With no configured tokens (i | PUT | /api/learned/{key} | [src.web.api.knowledge_mem](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/knowledge_mem.py#L537) | Yes | — | | GET | /api/affordances | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L495) | Yes | — | | GET | /api/compression/stats | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L507) | Yes | — | -| GET | /api/startup/diagnostics | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1152) | Yes | — | +| GET | /api/startup/diagnostics | [src.web.api.config_admin](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/config_admin.py#L1214) | Yes | — | | GET | /api/subsystems/status | [src.web.api.observability](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/observability.py#L541) | Yes | — | | GET | /api/agent-trajectories | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L488) | Yes | — | | GET | /api/agent-trajectories/agent/{agent_id} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L496) | Yes | — | | GET | /api/agent-trajectories/search/query | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L507) | Yes | — | | GET | /api/agent-trajectories/{filename} | [src.web.api.sessions_chat](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/sessions_chat.py#L526) | Yes | — | -| GET | /api/outbound-webhooks | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L657) | Yes | — | -| POST | /api/outbound-webhooks | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L664) | Yes | — | -| PUT | /api/outbound-webhooks/{webhook_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L691) | Yes | — | -| DELETE | /api/outbound-webhooks/{webhook_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L718) | Yes | — | -| POST | /api/outbound-webhooks/{webhook_id}/test | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L729) | Yes | — | -| GET | /api/outbound-webhooks/stats | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L740) | Yes | — | +| GET | /api/outbound-webhooks | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L738) | Yes | — | +| POST | /api/outbound-webhooks | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L745) | Yes | — | +| PUT | /api/outbound-webhooks/{webhook_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L786) | Yes | — | +| DELETE | /api/outbound-webhooks/{webhook_id} | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L825) | Yes | — | +| POST | /api/outbound-webhooks/{webhook_id}/test | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L839) | Yes | — | +| GET | /api/outbound-webhooks/stats | [src.web.api.integrations](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/integrations.py#L850) | Yes | — | | GET | /api/turn-state/turns | [src.web.api.turn_state](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/turn_state.py#L55) | Yes + local | — | | GET | /api/turn-state/capacity-breakers | [src.web.api.turn_state](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/api/turn_state.py#L86) | Yes + local | — | @@ -259,18 +260,18 @@ Registered by [HealthServer](https://github.com/Calmingstorm/Odin/blob/e1318eca8 | Method | Path | Owning module / handler source | Registration / access | Purpose | | --- | --- | --- | --- | --- | -| GET | /health | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1252) | HealthServer construction; no API authentication | Combined health endpoint. | -| GET | /health/live | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1310) | HealthServer construction; no API authentication | Liveness probe — always 200 if the process is running. | -| GET | /health/ready | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1319) | HealthServer construction; no API authentication | Readiness probe — 200 only when the bot is fully initialised. | -| GET | /metrics | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1345) | HealthServer construction; no API authentication | Prometheus metrics endpoint. | -| POST | /webhook/gitea | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1451) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| POST | /webhook/grafana | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1499) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| POST | /webhook/generic | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1573) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| POST | /webhook/github | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1596) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| POST | /webhook/gitlab | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1669) | webhooks.enabled; handler verifies webhook signature/shared secret | — | -| GET | / | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1038) | web.enabled + UI directory exists; no API authentication | Redirect / to /ui/. | -| GET | /ui/{path:.*} | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1042) | web.enabled + UI directory exists; no API authentication | Serve static UI files, defaulting to index.html for SPA routing. | -| GET | /ui | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1038) | web.enabled + UI directory exists; no API authentication | Redirect / to /ui/. | +| GET | /health | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1257) | HealthServer construction; no API authentication | Combined health endpoint. | +| GET | /health/live | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1315) | HealthServer construction; no API authentication | Liveness probe — always 200 if the process is running. | +| GET | /health/ready | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1324) | HealthServer construction; no API authentication | Readiness probe — 200 only when the bot is fully initialised. | +| GET | /metrics | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1350) | HealthServer construction; no API authentication | Prometheus metrics endpoint. | +| POST | /webhook/gitea | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1444) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| POST | /webhook/grafana | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1492) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| POST | /webhook/generic | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1566) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| POST | /webhook/github | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1589) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| POST | /webhook/gitlab | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1662) | webhooks.enabled; handler verifies webhook signature/shared secret | — | +| GET | / | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1051) | web.enabled + UI directory exists; no API authentication | Redirect / to /ui/. | +| GET | /ui/{path:.*} | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1055) | web.enabled + UI directory exists; no API authentication | Serve static UI files, defaulting to index.html for SPA routing. | +| GET | /ui | [src.health.server](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/health/server.py#L1051) | web.enabled + UI directory exists; no API authentication | Redirect / to /ui/. | | GET | /api/ws | [src.web.websocket](https://github.com/Calmingstorm/Odin/blob/e1318eca83a40b9418d873ad85c31686ea6b57d0/src/web/websocket.py#L462) | web.enabled + set_bot; authenticated, not admin-only; scoped subscriptions | Handle a WebSocket connection at /api/ws. | ### Conditional registration diff --git a/package.json b/package.json index 6fdcac636..f847d8bd2 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "build": "vite build --config vite.config.mjs", "preview": "vite preview --config vite.config.mjs", "check:templates": "node scripts/check-vue-templates.mjs", + "check:codex-quota-ui": "node scripts/check-codex-quota-ui.mjs", "check:llm-config-refresh": "node --experimental-vm-modules scripts/check-llm-config-refresh.mjs", "check:setup-ui": "node scripts/check-setup-ui.mjs", "check:template-bindings": "node scripts/check-template-bindings.mjs", "check:modal-race": "node scripts/check-modal-race.mjs", - "check": "npm run check:templates && npm run check:llm-config-refresh && npm run check:template-bindings && npm run check:listener-consent && npm run check:w1-webui && npm run check:modal-race && npm run check:host-access-races && npm run check:host-enrollment && npm run check:socket-identity && npm run check:ws-lifecycle && npm run check:w2-freshness && npm run check:toggle-serialization && npm run check:search-races && npm run check:config-keydown && npm run check:css-tokens && npm run check:turn-state-webui && npm run check:audit-verify && npm run check:schedule-time && npm run check:schedule-report-format && npm run check:config-health && npm run check:config-center-ui2 && npm run check:mcp-webui && npm run check:tools-webui && npm run check:learning-toggle && npm run check:config-save-boundaries && npm run check:usage-activity && npm run check:output-renderer && npm run check:live-logs && npm run check:logs-preset-search-races && npm run build", + "check": "npm run check:templates && npm run check:codex-quota-ui && npm run check:llm-config-refresh && npm run check:template-bindings && npm run check:permission-token-repair && npm run check:listener-consent && npm run check:w1-webui && npm run check:modal-race && npm run check:host-access-races && npm run check:host-enrollment && npm run check:socket-identity && npm run check:ws-lifecycle && npm run check:w2-freshness && npm run check:toggle-serialization && npm run check:search-races && npm run check:config-keydown && npm run check:css-tokens && npm run check:turn-state-webui && npm run check:audit-verify && npm run check:schedule-time && npm run check:schedule-report-format && npm run check:config-health && npm run check:config-center-ui2 && npm run check:mcp-webui && npm run check:tools-webui && npm run check:learning-toggle && npm run check:config-save-boundaries && npm run check:usage-activity && npm run check:output-renderer && npm run check:live-logs && npm run check:logs-preset-search-races && npm run check:discord-identity && npm run build", "check:listener-consent": "node scripts/check-listener-consent.mjs", - "check:host-access-races": "node scripts/check-host-access-races.mjs", + "check:host-access-races": "node scripts/check-host-access-races.mjs && node scripts/check-host-access-redesign.mjs && node scripts/check-host-access-modal.mjs", "check:host-enrollment": "node scripts/check-host-enrollment-browser.mjs", "check:socket-identity": "node scripts/check-socket-identity.mjs", "check:ws-lifecycle": "node scripts/check-ws-lifecycle.mjs", @@ -41,7 +42,9 @@ "check:output-renderer": "node scripts/check-output-renderer.mjs", "check:live-logs": "node scripts/check-live-log-records.mjs && node scripts/check-live-log-browser.mjs", "check:logs-preset-search-races": "node scripts/check-logs-preset-search-races.mjs", - "check:computer": "node scripts/check-computer-browser.mjs" + "check:computer": "node scripts/check-computer-browser.mjs", + "check:permission-token-repair": "node scripts/check-permission-token-repair.mjs", + "check:discord-identity": "node scripts/check-discord-identity.mjs" }, "dependencies": { "dompurify": "^3.2.0", diff --git a/scripts/check-codex-quota-ui.mjs b/scripts/check-codex-quota-ui.mjs new file mode 100644 index 000000000..a219cbffc --- /dev/null +++ b/scripts/check-codex-quota-ui.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { quotaBlocks, quotaFailureVisible } from '../ui/js/codex-quota.js'; + +const make = (used, resets_at = 1) => ({ quota: { primary: { used_percent: used, window_minutes: 300, resets_at } } }); +const formatDate = (value) => `date-${value}`; +const almost = quotaBlocks(make(99.5, 1234), formatDate)[0]; +assert.equal(almost.remaining, 1, 'remaining percentage is rounded from 100 - used'); +assert.equal(almost.statusLabel, '', '99.5% usage is not a reached limit'); +assert.equal(almost.limitReached, false); + +const full = quotaBlocks(make(100, 1234), formatDate)[0]; +assert.equal(full.remaining, 0); +assert.equal(full.resetLabel, 'date-1234'); +assert.equal(full.statusLabel, 'Limit reached'); +assert.equal(full.limitReached, true); +assert.equal(quotaBlocks(make(135))[0].statusLabel, 'Limit reached'); +assert.equal(quotaBlocks(make(135))[0].remaining, 0); +assert.equal(quotaBlocks(make(0))[0].remaining, 100); +assert.equal(quotaBlocks(make(50))[0].label, '5-hour usage limit'); +assert.equal(quotaBlocks({ quota: { secondary: { window_minutes: 0, used_percent: 0, resets_at: 0 } } }).length, 0, + 'zero-minute secondary placeholder is not displayed'); +assert.equal(quotaBlocks({ quota: { secondary: { used_percent: 0, resets_at: 0 } } }).length, 0, + 'window without a duration is not displayed'); + +assert.equal(quotaFailureVisible({ quota_check_failed: 'timeout', quota: make(20).quota }), true, + 'check failure remains visible alongside an older quota snapshot'); +assert.equal(quotaFailureVisible({ quota_check_failed: null, quota: make(20).quota }), false); +console.log('Codex quota UI math and labels passed'); diff --git a/scripts/check-discord-identity.mjs b/scripts/check-discord-identity.mjs new file mode 100644 index 000000000..5075e1d39 --- /dev/null +++ b/scripts/check-discord-identity.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +globalThis.sessionStorage = globalThis.localStorage; +const { api } = await import('../ui/js/api.js'); +const { DiscordIdentity, lookupDiscordIdentity } = await import('../ui/js/discord-identity.js'); + +const originalGet = api.get; +const requested = []; +try { + api.get = async path => { + requested.push(path); + if (path === '/api/discord/members') return [{ id: '12345678901234567', display_name: 'Member', avatar_url: null }]; + if (path === '/api/discord/users/12345678901234568') return { user: { id: '12345678901234568', display_name: 'Former member' } }; + return { user: null }; + }; + assert.equal(await lookupDiscordIdentity('Admin'), null, 'non-Discord identities remain untouched'); + assert.equal((await lookupDiscordIdentity('12345678901234567')).display_name, 'Member'); + assert.equal((await lookupDiscordIdentity('12345678901234568')).display_name, 'Former member'); + assert.equal((await lookupDiscordIdentity('12345678901234568')).display_name, 'Former member'); + assert.deepEqual(requested, ['/api/discord/members', '/api/discord/users/12345678901234568'], 'one guild-list read then cached by-ID fallback'); +} finally { + api.get = originalGet; +} + +assert.match(DiscordIdentity.template, /avatar_url/); +assert.match(DiscordIdentity.template, /userId/); +assert.match(DiscordIdentity.template, /showId/); +for (const [page, marker] of [ + ['api-tokens', 't.user_id'], ['learned', 'entry.user_id'], ['traces', 'singleTrace.user_id'], + ['audit', 'e.user_id'], ['sessions', 'r.user_id'], ['logs', 'entry.user_id'], ['computer', 'status.owner_id'], +]) { + const source = readFileSync(new URL(`../ui/js/pages/${page}.js`, import.meta.url), 'utf8'); + assert.ok(source.includes("from '../discord-identity.js'"), `${page} imports the shared identity component`); + assert.ok(source.includes('
`; +server.middlewares.use(async (req, res, next) => { + if (req.url !== '/__host_modal__.html') return next(); + res.setHeader('Content-Type', 'text/html'); + res.end(await server.transformIndexHtml(req.url, html)); +}); +let browser; +try { + await server.listen(); + const executablePath = [process.env.CHROME_PATH, '/usr/bin/google-chrome', '/usr/bin/chromium'].filter(Boolean).find(p => fs.existsSync(p)); + assert.ok(executablePath, 'Chrome/Chromium required'); + browser = await chromium.launch({ executablePath, headless: true, args: ['--no-sandbox'] }); + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + await page.coverage.startJSCoverage({ resetOnNavigation: false }); + const errors = [], writes = [], unexpected = []; + page.on('pageerror', error => errors.push(error.message)); + const uid = '123456789012345', empty = '123456789012346', inherited = '123456789012347'; + const hosts = ['alpha', 'beta', 'gamma', ...Array.from({ length: 40 }, (_, i) => `host-${i}`)]; + const fixture = { + available_hosts: hosts, host_descriptions: { beta: 'Backup machine' }, + default_policy: { allowed_hosts: null, default_host: 'alpha' }, + users: { [uid]: { allowed_hosts: ['alpha', 'beta', 'gamma'], default_host: 'alpha' }, [empty]: { allowed_hosts: [], default_host: '' } }, + }; + let failSave = false, holdSave = null; + await page.route('**/api/**', async route => { + const path = new URL(route.request().url()).pathname; + const method = route.request().method(); + let body = {}, status = 200; + if (method === 'GET' && path === '/api/host-access') body = fixture; + else if (method === 'GET' && path === '/api/permissions/tiers') body = { overrides: { [inherited]: 'guest' }, config_tiers: {}, invalid_overrides: {} }; + else if (method === 'GET' && path === '/api/discord/members') body = [{ id: uid, display_name: 'Fixture User' }]; + else if (method === 'GET' && [empty, inherited].some(id => path === `/api/discord/users/${id}`)) body = { id: path.split('/').pop() }; + else if (method === 'PUT' && (path === '/api/host-access/default-policy' || path.startsWith('/api/host-access/user/'))) { + const value = route.request().postDataJSON(); + writes.push({ path, value }); + if (holdSave) await holdSave; + if (failSave) { status = 500; body = { error: 'Fixture save failed' }; } + else if (path.endsWith('/default-policy')) fixture.default_policy = value; + else fixture.users[path.split('/').pop()] = value; + } else { unexpected.push(`${method} ${path}`); status = 500; } + await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + }); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__host_modal__.html`); + await page.waitForFunction(() => window.hostView && !hostView.loading && hostView.members.length); + const dialog = page.getByRole('dialog'); + const openDefault = () => page.getByRole('button', { name: 'Edit hosts for Default Policy', exact: true }).click(); + const openUser = () => page.getByRole('button', { name: 'Edit hosts for Fixture User', exact: true }).click(); + const checkbox = host => dialog.getByRole('checkbox', { name: host, exact: true }); + const allHosts = () => dialog.getByRole('switch'); + const picker = () => dialog.getByLabel('Default host', { exact: true }); + const save = () => dialog.getByRole('button', { name: 'Save', exact: true }); + const cancel = async () => { + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.waitForFunction(() => !document.querySelector('dialog').open); + }; + const inViewport = async () => { + const rect = await dialog.boundingBox(), viewport = page.viewportSize(); + assert.ok(rect && rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= viewport.width + 1 && rect.y + rect.height <= viewport.height + 1, JSON.stringify(rect)); + return rect; + }; + assert.equal(await page.getByRole('button', { name: /^Edit hosts for / }).count(), 4, 'button for default, populated, empty and tier-only rows'); + assert.equal(await page.locator('tbody tr').count(), 3); + assert.match(await page.locator('tbody tr').first().innerText(), /\+1/); + assert.equal(await page.locator('tbody tr').first().locator('select').count(), 1, 'only tier remains a row select'); + await openUser(); + await inViewport(); + assert.equal(await dialog.getByLabel('Search hosts').evaluate(el => el === document.activeElement), true); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Shift+Tab'); + assert.equal(await save().evaluate(el => el === document.activeElement), true, 'native dialog traps backward focus'); + await page.keyboard.press('Tab'); + assert.equal(await allHosts().evaluate(el => el === document.activeElement), true, 'forward focus wraps inside modal'); + await dialog.getByLabel('Search hosts').fill('backup'); + assert.equal(await dialog.getByRole('checkbox').count(), 1, 'description search filters checklist'); + assert.equal(await dialog.getByRole('checkbox').inputValue(), 'beta'); + await dialog.getByLabel('Search hosts').fill('no-match'); + assert.match(await dialog.innerText(), /No matching hosts/); + await dialog.getByLabel('Search hosts').fill(''); + await checkbox('alpha').uncheck(); + assert.equal(await save().isDisabled(), true, 'removing default requires explicit replacement'); + assert.match(await dialog.innerText(), /Choose a new default/); + assert.deepEqual(await picker().locator('option:not([disabled])').evaluateAll(els => els.map(e => e.value)), ['beta', 'gamma']); + await picker().selectOption('beta'); + assert.equal(await save().isEnabled(), true); + assert.equal(writes.length, 0, 'draft edits never save'); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => !document.querySelector('dialog').open); + assert.equal(await page.getByRole('button', { name: 'Edit hosts for Fixture User', exact: true }).evaluate(el => el === document.activeElement), true, 'focus restored'); + await openUser(); + assert.equal(await checkbox('alpha').isChecked(), true, 'Escape discarded detached draft'); + await checkbox('gamma').uncheck(); + await cancel(); + assert.equal(writes.length, 0, 'Cancel does not save'); + await openUser(); + await allHosts().check(); + assert.equal(await checkbox('alpha').isDisabled(), true); + assert.equal(await dialog.getByRole('checkbox', { checked: true }).count(), 3, 'All hosts does not expand selection'); + assert.equal(await picker().locator('option').count(), hosts.length + 1); + await allHosts().uncheck(); + assert.equal(await checkbox('gamma').isChecked(), true, 'finite selection restored'); + await checkbox('alpha').uncheck(); + await picker().selectOption('beta'); + await save().click(); + await page.waitForFunction(() => !hostView.saving && !document.querySelector('dialog').open); + assert.deepEqual(writes.at(-1), { path: `/api/host-access/user/${uid}`, value: { allowed_hosts: ['beta', 'gamma'], default_host: 'beta' } }); + assert.equal(await page.locator('tbody tr').first().locator('.host-default-summary').innerText(), 'beta'); + await openDefault(); + assert.equal(await allHosts().isChecked(), true); + await picker().selectOption('gamma'); + await save().click(); + await page.waitForFunction(() => !hostView.saving && !document.querySelector('dialog').open); + assert.deepEqual(writes.at(-1), { path: '/api/host-access/default-policy', value: { allowed_hosts: null, default_host: 'gamma' } }, 'null preserves future-host access'); + await page.getByRole('button', { name: `Edit hosts for ${empty}`, exact: true }).click(); + assert.equal(await dialog.getByRole('checkbox', { checked: true }).count(), 0); + assert.equal(await picker().inputValue(), ''); + await checkbox('alpha').check(); + await picker().selectOption('alpha'); + await save().click(); + await page.waitForFunction(() => !hostView.saving && !document.querySelector('dialog').open); + assert.deepEqual(writes.at(-1), { path: `/api/host-access/user/${empty}`, value: { allowed_hosts: ['alpha'], default_host: 'alpha' } }); + // Deny-all requires explicit clearing of the default, not silent reassignment. + await page.getByRole('button', { name: `Edit hosts for ${empty}`, exact: true }).click(); + await checkbox('alpha').uncheck(); + assert.equal(await save().isDisabled(), true); + await picker().selectOption(''); + assert.equal(await save().isEnabled(), true); + await save().click(); + await page.waitForFunction(() => !hostView.saving && !document.querySelector('dialog').open); + assert.deepEqual(writes.at(-1).value, { allowed_hosts: [], default_host: '' }); + await page.getByRole('button', { name: `Edit hosts for ${inherited}`, exact: true }).click(); + assert.equal(await allHosts().isChecked(), true, 'tier-only row starts from inherited policy'); + await picker().selectOption('beta'); + failSave = true; + await save().click(); + await dialog.getByRole('alert').waitFor(); + assert.equal(await picker().inputValue(), 'beta', 'failed Save retains draft'); + assert.equal(await page.evaluate(id => hostView.users[id], inherited), undefined, 'failed create rolls back row'); + failSave = false; + let release; + holdSave = new Promise(resolve => { release = resolve; }); + await save().click(); + await page.waitForFunction(() => hostView.saving); + assert.equal(await dialog.getByRole('button', { name: 'Saving…' }).isDisabled(), true); + await page.keyboard.press('Tab'); + assert.equal(await dialog.evaluate(el => el === document.activeElement), true, 'focus stays in modal while controls are disabled'); + await page.keyboard.press('Escape'); + assert.equal(await dialog.isVisible(), true, 'submitted Save cannot be misleadingly cancelled'); + release(); holdSave = null; + await page.waitForFunction(() => !hostView.saving && !document.querySelector('dialog').open); + assert.deepEqual(writes.at(-1), { path: `/api/host-access/user/${inherited}`, value: { allowed_hosts: null, default_host: 'beta' } }); + assert.equal(writes.length, 6, 'exactly one PUT per explicit Save, including failed attempt'); + await openDefault(); + await picker().selectOption('beta'); + failSave = true; + await save().click(); + await dialog.getByRole('alert').waitFor(); + assert.equal(await page.evaluate(() => hostView.defaultPolicy.default_host), 'gamma', 'default policy rolls back'); + await cancel(); + failSave = false; + await page.setViewportSize({ width: 390, height: 680 }); + await openDefault(); + assert.deepEqual(await inViewport(), { x: 0, y: 0, width: 390, height: 680 }, 'phone modal fills viewport'); + assert.ok(await save().isVisible()); + await page.setViewportSize({ width: 680, height: 390 }); + await inViewport(); + await cancel(); + const count = writes.length; + await openDefault(); + await picker().selectOption('beta'); + await page.evaluate(() => setActive(false)); + await page.waitForFunction(() => !document.querySelector('dialog')?.open); + await page.evaluate(() => setActive(true)); + await openDefault(); + assert.equal(await picker().inputValue(), 'gamma', 'tab switch discards draft'); + await cancel(); + assert.equal(writes.length, count); + assert.deepEqual(errors, []); + assert.deepEqual(unexpected, []); + const coverage = (await page.coverage.stopJSCoverage()).filter(entry => entry.url.includes('/ui/js/pages/host-access.js')); + assert.equal(coverage.length, 1); + const { source, functions } = coverage[0]; + const ranges = functions.flatMap(fn => fn.ranges).sort((a, b) => (b.endOffset - b.startOffset) - (a.endOffset - a.startOffset)); + const sourceLength = source.indexOf('//# sourceMappingURL=') >= 0 ? source.indexOf('//# sourceMappingURL=') : source.length; + const bytes = new Uint8Array(sourceLength); + for (const range of ranges) bytes.fill(range.count > 0 ? 1 : 0, Math.min(range.startOffset, sourceLength), Math.min(range.endOffset, sourceLength)); + const covered = bytes.reduce((sum, value) => sum + value, 0); + console.log(`host-access modal browser coverage (excluding source map): ${covered}/${sourceLength} source characters (${(covered / sourceLength * 100).toFixed(1)}%)`); + console.log('host-access-modal: desktop/phone, focus, search, validation, Cancel/Escape, Save/retry/rollback and tab cleanup passed'); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/scripts/check-host-access-redesign.mjs b/scripts/check-host-access-redesign.mjs new file mode 100644 index 000000000..582c407e7 --- /dev/null +++ b/scripts/check-host-access-redesign.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { baseParse } from '@vue/compiler-dom'; +import { createSSRApp } from 'vue'; +import { renderToString } from '@vue/server-renderer'; +const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; +globalThis.localStorage = storage; +globalThis.sessionStorage = storage; +const { api } = await import('../ui/js/api.js'); +const { default: HostAccess } = await import('../ui/js/pages/host-access.js'); +const { TABS } = await import('../ui/js/pages/system.js'); + +function walk(node, predicate, found = []) { + if (predicate(node)) found.push(node); + for (const child of node.children || []) walk(child, predicate, found); + return found; +} +const tree = baseParse(HostAccess.template); +const tables = walk(tree, node => node.type === 1 && node.tag === 'table'); +assert.equal(tables.length, 1); +const headers = walk(tables[0], node => node.type === 1 && node.tag === 'th').map(n => n.children?.[0]?.content); +assert.deepEqual(headers, ['User', 'Tier', 'Hosts', 'Default Host', 'Remove']); +assert.ok(!HostAccess.template.includes('v-for="host in availableHosts" :key="\'th-'), 'no per-host table columns'); +assert.match(HostAccess.template, /hostChips\(defaultPolicy\)/, 'default policy renders chips'); +assert.match(HostAccess.template, /visibleHostChips\(uid\)/, 'each user renders bounded host chips'); +assert.match(readFileSync(new URL('../ui/css/style.css', import.meta.url), 'utf8'), /\.host-access-table\s*\{\s*table-layout:\s*fixed/, 'host list cannot expand the table width'); +assert.match(HostAccess.template, /All hosts, including hosts added later/); +assert.match(HostAccess.template, /Search hosts/); +assert.match(HostAccess.template, /Search users/); +assert.match(HostAccess.template, /discord-identity :user-id="uid" :members="members"/); +assert.ok(!TABS.some(tab => tab.id === 'permissions'), 'System Permissions tab is removed'); + +const original = { get: api.get, put: api.put, del: api.del, post: api.post }; +const calls = []; +const warn = console.warn; +console.warn = () => {}; +try { + api.get = async path => { + if (path === '/api/host-access') return { available_hosts: ['a', 'b'], default_policy: { allowed_hosts: null, default_host: 'a' }, users: { '123456789012345': { allowed_hosts: ['a'], default_host: 'a' } } }; + if (path === '/api/permissions/tiers') return { valid_tiers: ['admin', 'user', 'guest'], default_tier: 'user', config_tiers: {}, overrides: { '123456789012345': 'guest' }, invalid_overrides: {}, store_corrupt: false }; + return []; + }; + api.put = async (...args) => calls.push(['PUT', ...args]); + api.del = async (...args) => calls.push(['DELETE', ...args]); + api.post = async (...args) => calls.push(['POST', ...args]); + const vm = HostAccess.setup(); + await vm.fetchData(); + const rendered = await renderToString(createSSRApp({ + template: HostAccess.template, + setup: () => vm, + components: { + DiscordIdentity: { props: ['userId'], template: '{{ userId }}' }, + DiscordUserCombobox: { template: '' }, + }, + })); + assert.equal((rendered.match(/)/g) || []).length, 5, 'rendered table has five fixed columns'); + assert.match(rendered, /host-chip[^>]*>All hosts]*>.*?guest.*?<\/select>/s, 'existing tier is rendered'); + assert.equal(vm.defaultPolicy.value.allow_all, true, 'null host ACL stays all-hosts'); + assert.equal(vm.hostSummary(vm.defaultPolicy.value), 'All hosts'); + assert.equal(vm.tierValue('123456789012345'), 'guest', 'existing override is visible'); + assert.deepEqual(vm.hostChips({ allowed_hosts: ['a', 'b', 'c', 'd'], allow_all: false }), ['a', 'b', '+2']); + assert.equal(vm.hostChips(vm.defaultPolicy.value)[0], 'All hosts'); + assert.ok(vm.visibleUserIds.value.includes('123456789012345')); + vm.userQuery.value = 'no matching user'; + assert.equal(vm.visibleUserIds.value.length, 0, 'search filters rows'); + vm.userQuery.value = ''; + await vm.setTier('123456789012345', 'admin'); + assert.deepEqual(calls[0], ['PUT', '/api/permissions/user/123456789012345', { tier: 'admin' }]); + await vm.setTier('123456789012345', 'default'); + assert.deepEqual(calls[1], ['DELETE', '/api/permissions/user/123456789012345']); + await vm.openEditor('default'); + vm.draft.value.allow_all = true; + await vm.saveEditor(); + assert.ok(calls.some(call => call[0] === 'PUT' && call[1] === '/api/host-access/default-policy' && call[2].allowed_hosts === null), 'all-hosts persists as null for future hosts'); + vm.permissions.value.invalid_overrides = { '123456789012346': 'bad tier' }; + assert.ok(vm.visibleUserIds.value.includes('123456789012346'), 'permission-only broken entry gets a row'); + await vm.repairTier('123456789012346'); + assert.ok(!calls.some(call => call[1]?.includes('/repair')), 'repair requires explicit tier'); + vm.repairTiers.value['123456789012346'] = 'guest'; + await vm.repairTier('123456789012346'); + assert.ok(calls.some(call => call[0] === 'POST' && call[1] === '/api/permissions/user/123456789012346/repair' && call[2].tier === 'guest')); + await vm.openEditor('123456789012346'); + vm.draft.value.allow_all = false; + vm.draft.value.allowed_hosts = ['a']; + await vm.saveEditor(); + assert.ok(calls.some(call => call[0] === 'PUT' && call[1] === '/api/host-access/user/123456789012346' && call[2].allowed_hosts?.includes('a') && !call[2].allowed_hosts.includes('b')), 'host editor can create host override on tier-only row'); +} finally { + Object.assign(api, original); + console.warn = warn; +} +console.log('host-access redesign: rendered layout, future-host semantics, permission tier API, Permissions tab removed'); diff --git a/scripts/check-llm-config-refresh.mjs b/scripts/check-llm-config-refresh.mjs index 06995d30d..cfd120745 100644 --- a/scripts/check-llm-config-refresh.mjs +++ b/scripts/check-llm-config-refresh.mjs @@ -61,6 +61,9 @@ const modules = new Map([ openaiCompatibleAdvancedPayload() { return {}; }, openaiCompatibleBasicPayload() { return {}; }, ollamaAdvancedPayload() { return {}; }, ollamaBasicPayload() { return {}; }, })], + ['../codex-quota.js', synthetic('codex-quota', { + quotaBlocks() { return []; }, quotaFailureVisible() { return false; }, + })], ]); const page = new vm.SourceTextModule(source, { context, identifier: 'llm-config.js' }); diff --git a/scripts/check-permission-token-repair.mjs b/scripts/check-permission-token-repair.mjs new file mode 100644 index 000000000..7528a7d36 --- /dev/null +++ b/scripts/check-permission-token-repair.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { baseParse } from '@vue/compiler-dom'; + +const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; +globalThis.localStorage = storage; +globalThis.sessionStorage = storage; +const { api } = await import('../ui/js/api.js'); +const { default: hostAccess } = await import('../ui/js/pages/host-access.js'); +const { default: tokens } = await import('../ui/js/pages/api-tokens.js'); +function elements(node, tag) { return [...(node.type === 1 && node.tag === tag ? [node] : []), ...(node.children || []).flatMap(child => elements(child, tag))]; } +function directive(node, name) { return node.props.find(prop => prop.type === 7 && prop.name === name); } + +const hostTree = baseParse(hostAccess.template); +const repair = elements(hostTree, 'button').find(node => directive(node, 'on')?.exp?.content === 'repairTier(uid)'); +assert.equal(directive(repair, 'bind')?.arg?.content, 'disabled'); +assert.equal(directive(repair, 'bind')?.exp?.content, '!repairTiers[uid]'); +assert.ok(elements(hostTree, 'option').some(node => directive(node, 'for')?.exp?.content === 'tier in validTiers')); +assert.match(hostAccess.template, /permissions\.invalid_overrides\?\.\[uid\]/); + +const tokenTree = baseParse(tokens.template); +const remove = elements(tokenTree, 'button').find(node => directive(node, 'on')?.exp?.content === 'removeUnusable(item)'); +assert.ok(remove, 'remove must use the displayed diagnosis, not only its index'); +assert.ok(elements(tokenTree, 'span').some(node => directive(node, 'if')?.exp?.content === 'item.user_id')); + +const original = { get: api.get, post: api.post, put: api.put, del: api.del, _request: api._request }; +const originalWarning = console.warn; +console.warn = () => {}; +try { + const calls = []; + api.get = async path => path === '/api/permissions/tiers' + ? { invalid_overrides: { admin: 'wizard' }, overrides: {}, config_tiers: {}, default_tier: 'user' } + : { available_hosts: [], default_policy: { allowed_hosts: null, default_host: '' }, users: {} }; + api.post = async (...args) => { calls.push(args); throw Error('write failed'); }; + const page = hostAccess.setup(); + await page.fetchData(); + await page.repairTier('admin'); + assert.equal(calls.length, 0, 'no implicit tier selection'); + page.repairTiers.value.admin = 'guest'; + await page.repairTier('admin'); + assert.deepEqual(calls, [['/api/permissions/user/admin/repair', { tier: 'guest' }]]); + assert.equal(page.permissions.value.invalid_overrides.admin, 'wizard', 'failed repair retains row state'); + + const deletes = []; + api._request = async (...args) => { deletes.push(args); return {}; }; + api.get = async () => ({ tokens: [] }); + const tokenPage = tokens.setup(); + await tokenPage.removeUnusable({ index: 2, reason: 'invalid tier', user_id: 'alice' }); + await tokenPage.removeUnusable({ index: 3, reason: 'entry is not an object' }); + assert.deepEqual(deletes, [ + ['DELETE', '/api/tokens/unusable/2', { reason: 'invalid tier', user_id: 'alice' }], + ['DELETE', '/api/tokens/unusable/3', { reason: 'entry is not an object' }], + ]); +} finally { + Object.assign(api, original); + console.warn = originalWarning; +} +console.log('permission/token repair UI: inline explicit tier repair, intact failure state, bound row deletion'); diff --git a/scripts/check-toggle-serialization.mjs b/scripts/check-toggle-serialization.mjs index 26cad2cba..c5ac0803d 100644 --- a/scripts/check-toggle-serialization.mjs +++ b/scripts/check-toggle-serialization.mjs @@ -155,9 +155,8 @@ console.warn = quietWarn; } // --------------------------------------------------------------------------- -// Host-access: per-keypress select changes coalesce to ONE save carrying the -// final draft; a user deleted inside the quiet window is never resurrected; -// flushing commits instead of dropping. +// Host-access: modal changes never write before Save; one submission carries +// the final draft, and Cancel never leaves behind a delayed write. // --------------------------------------------------------------------------- { const calls = []; @@ -172,33 +171,33 @@ console.warn = quietWarn; u1: { allowed_hosts: ['alpha', 'beta', 'gamma'], default_host: 'alpha', allow_all: false }, }; - const captured = capturingTimers(() => { - state.setUserDefault('u1', 'beta'); - state.setUserDefault('u1', 'gamma'); // arrow keypresses in one quiet window - }); + state.availableHosts.value = ['alpha', 'beta', 'gamma']; + await state.openEditor('u1'); + state.draft.value.default_host = 'beta'; + state.draft.value.default_host = 'gamma'; assert.equal(calls.filter(c => c.method === 'PUT').length, 0, - 'debounced save fired before the quiet window elapsed'); - for (const entry of captured) entry.cb(); - await tick(); + 'modal wrote before Save'); + await state.saveEditor(); const puts = calls.filter(c => c.method === 'PUT'); assert.equal(puts.length, 1, `coalescing failed: ${JSON.stringify(puts)}`); assert.equal(puts[0].body.default_host, 'gamma', 'save did not carry the FINAL draft'); - // Deleted inside the quiet window: the pending save resolves to nothing. + // Cancel leaves no queued save to resurrect a subsequently deleted user. calls.length = 0; - const ghost = capturingTimers(() => state.setUserDefault('u1', 'beta')); + await state.openEditor('u1'); + state.draft.value.default_host = 'beta'; + state.closeEditor(); delete state.users.value.u1; - for (const entry of ghost) entry.cb(); await tick(); assert.equal(calls.filter(c => c.method === 'PUT').length, 0, 'pending save resurrected a deleted user'); - // Flush commits the last edit rather than dropping it. + // Explicit Save commits the final draft. calls.length = 0; state.users.value = { u2: { allowed_hosts: ['alpha'], default_host: '', allow_all: false } }; - capturingTimers(() => state.setUserDefault('u2', 'alpha')); - state.flushPendingSaves(); - await tick(); + await state.openEditor('u2'); + state.draft.value.default_host = 'alpha'; + await state.saveEditor(); const flushed = calls.filter(c => c.method === 'PUT'); assert.equal(flushed.length, 1, 'flush dropped the pending edit'); assert.equal(flushed[0].body.default_host, 'alpha'); @@ -206,13 +205,10 @@ console.warn = quietWarn; // The default-policy select coalesces identically. calls.length = 0; state.defaultPolicy.value = { allowed_hosts: ['alpha', 'beta'], default_host: 'alpha', allow_all: false }; - const dp = capturingTimers(() => { - state.saveDefaultPolicy(); - state.defaultPolicy.value.default_host = 'beta'; - state.saveDefaultPolicy(); - }); - for (const entry of dp) entry.cb(); - await tick(); + await state.openEditor('default'); + state.draft.value.default_host = 'beta'; + assert.equal(state.defaultPolicy.value.default_host, 'alpha'); + await state.saveEditor(); const dpPuts = calls.filter(c => c.method === 'PUT'); assert.equal(dpPuts.length, 1, `default-policy coalescing failed: ${JSON.stringify(dpPuts)}`); assert.equal(dpPuts[0].body.default_host, 'beta'); diff --git a/scripts/docs/generate_api_reference.py b/scripts/docs/generate_api_reference.py index 8efe3ba31..127f6ea47 100644 --- a/scripts/docs/generate_api_reference.py +++ b/scripts/docs/generate_api_reference.py @@ -111,7 +111,7 @@ def collect_server_routes( """ from aiohttp import web - from src.config.schema import SlackConfig, WebConfig, WebhookConfig + from src.config.schema import WebConfig, WebhookConfig from src.health import server found = [] @@ -144,7 +144,6 @@ def record_post(router, path, handler, **kwargs): health = server.HealthServer( web_config=config, webhook_config=WebhookConfig(enabled=webhooks_enabled), - slack_config=SlackConfig(enabled=False), ) if wire_bot: health.set_bot(bot) diff --git a/src/__main__.py b/src/__main__.py index 4a7be1a0f..ba937e7d6 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -632,7 +632,6 @@ def _warn_fallback(path, configured, reason) -> None: port=config.web.port, webhook_config=config.webhook, web_config=config.web, - slack_config=getattr(config, "slack", None), grafana_alert_config=getattr(config, "grafana_alerts", None), ) bot = OdinBot(config) diff --git a/src/audit/logger.py b/src/audit/logger.py index bb5549ebe..9598abe31 100644 --- a/src/audit/logger.py +++ b/src/audit/logger.py @@ -653,7 +653,8 @@ async def _count_by_tool_unlocked(self) -> dict[str, int]: except json.JSONDecodeError: continue name = entry.get("tool_name") - if name and not entry.get("audit_observer"): + if (name and entry.get("type") not in {"token_change", "permission_change"} + and not entry.get("audit_observer")): counts[name] = counts.get(name, 0) + 1 offset = stat.st_size self._tool_count_cache[identity] = (offset, tail, counts) @@ -818,7 +819,8 @@ async def get_log_stats(self) -> dict: if entry.get("error"): errors += 1 tn = entry.get("tool_name") - if tn: + if (tn and entry.get("type") not in {"token_change", "permission_change"} + and not entry.get("audit_observer")): tools.add(tn) if entry.get("type") == "web_action": web_actions += 1 diff --git a/src/computer/error_guidance.py b/src/computer/error_guidance.py index 78b79c0d4..0043935ee 100644 --- a/src/computer/error_guidance.py +++ b/src/computer/error_guidance.py @@ -21,74 +21,573 @@ def __init__(self, code: str, *, execution: dict, state: str): self.state = state -_SELECTION = frozenset({ - "target_selection_required", "target_selection_stale", "target_selection_changed", - "invalid_source_selection", "source_selection_unavailable", - "hyprland_explicit_output_changed", - "hyprland_stale_topology_epoch", "hyprland_stale_or_ineligible_candidate", -}) -_OBSERVATION = frozenset({ - "stale_observation", "stale_source_binding", "geometry_changed", - "visual_target_changed", "sequence_visual_target_changed", - "observation_expired", "wayland_scope_evidence_stale", - "wayland_scope_evidence_expired", - "hyprland_fresh_application_observation_required", "hyprland_observation_expired", - "hyprland_observation_changed", "hyprland_capture_settle_budget_exhausted", - "hyprland_scope_evidence_expired", "hyprland_capture_scope_changed", - "hyprland_fractional_or_unknown_geometry", "hyprland_snapshot_capacity", - "hyprland_fresh_observation_required", "hyprland_application_group_target_changed", -}) -_FOCUS = frozenset({ - "input_focus_unavailable", "hyprland_focus_changed", - "hyprland_focus_changed_before_dispatch", "hyprland_focus_outside_source", - "human_focus_changed", - "hyprland_native_focus_not_confirmed", "hyprland_unknown_or_nonnative_focus", -}) -_SESSION_STATE = frozenset({ - "stale_generation", "resume_unavailable", "hyprland_resume_retryable", -}) -_GROUP_PREFLIGHT = frozenset({ - "hyprland_stale_snapshot", "hyprland_application_group_target_epoch", - "hyprland_application_group_member_refused", "hyprland_application_group_target_output", - "hyprland_application_group_target_layer_surface", "hyprland_application_group_target_unknown", - "hyprland_application_group_target_surface", "hyprland_application_group_target_ineligible", - "hyprland_application_group_target_focus_unconfirmed", -}) -_PREFLIGHT_RETRY = frozenset({ - "unsupported_operation", "target_inventory_unavailable", - "inventory_targets_unsupported", "operation_unavailable", - "focus_preflight_refused", "focus_candidate_unavailable", - "focus_candidate_changed", "focus_full_observation_required", - "focus_visual_target_changed", "focus_transition_unavailable", - "focus_requires_new_observation", -}) +_SELECTION = frozenset( + { + "target_selection_required", + "target_selection_stale", + "target_selection_changed", + "invalid_source_selection", + "source_selection_unavailable", + "hyprland_explicit_output_changed", + "hyprland_stale_topology_epoch", + "hyprland_stale_or_ineligible_candidate", + } +) +_OBSERVATION = frozenset( + { + "stale_observation", + "stale_source_binding", + "geometry_changed", + "visual_target_changed", + "sequence_visual_target_changed", + "observation_expired", + "wayland_scope_evidence_stale", + "wayland_scope_evidence_expired", + "hyprland_fresh_application_observation_required", + "hyprland_observation_expired", + "hyprland_observation_changed", + "hyprland_capture_settle_budget_exhausted", + "hyprland_scope_evidence_expired", + "hyprland_capture_scope_changed", + "hyprland_fractional_or_unknown_geometry", + "hyprland_snapshot_capacity", + "hyprland_fresh_observation_required", + "hyprland_application_group_target_changed", + } +) +_FOCUS = frozenset( + { + "input_focus_unavailable", + "hyprland_focus_changed", + "hyprland_focus_changed_before_dispatch", + "hyprland_focus_outside_source", + "human_focus_changed", + "hyprland_native_focus_not_confirmed", + "hyprland_unknown_or_nonnative_focus", + } +) +_SESSION_STATE = frozenset( + { + "stale_generation", + "resume_unavailable", + "hyprland_resume_retryable", + } +) +_GROUP_PREFLIGHT = frozenset( + { + "hyprland_stale_snapshot", + "hyprland_application_group_target_epoch", + "hyprland_application_group_member_refused", + "hyprland_application_group_target_output", + "hyprland_application_group_target_layer_surface", + "hyprland_application_group_target_unknown", + "hyprland_application_group_target_surface", + "hyprland_application_group_target_ineligible", + "hyprland_application_group_target_focus_unconfirmed", + } +) +_PREFLIGHT_RETRY = frozenset( + { + "unsupported_operation", + "target_inventory_unavailable", + "inventory_targets_unsupported", + "operation_unavailable", + "focus_preflight_refused", + "focus_candidate_unavailable", + "focus_candidate_changed", + "focus_full_observation_required", + "focus_visual_target_changed", + "focus_transition_unavailable", + "focus_requires_new_observation", + } +) _INPUT_OUTCOMES = frozenset({"not_dispatched", "released_verified", "release_unknown"}) # Only stable, fixed reason codes enter durable audit metadata. Unknown values # remain useful in the immediate response, but collapse to a generic audit code. -_AUDIT_REASON_CODES = frozenset({ - "computer_rejected", "computer_not_satisfied", "outcome_unknown", - "permission_denied", "unsupported_operation", "target_inventory_unavailable", - "inventory_targets_unsupported", "operation_unavailable", "input_not_granted", - "capture_not_granted", "input_focus_unavailable", "focus_transition_unavailable", - "target_selection_required", "target_selection_stale", "target_selection_changed", - "invalid_source_selection", "source_selection_unavailable", "stale_observation", - "stale_source_binding", "geometry_changed", "visual_target_changed", - "sequence_visual_target_changed", "observation_expired", "human_focus_changed", - "hyprland_focus_changed", "hyprland_focus_changed_before_dispatch", - "hyprland_native_focus_not_confirmed", "hyprland_unknown_or_nonnative_focus", - "hyprland_dispatch_interrupted_after_release", "effect_unknown_reconcile_no_replay", - "input_release_unknown", "input_outcome_unknown", "unexpected_dialog_transition", - "hyprland_fresh_modal_binding_required", "stale_generation", "resume_unavailable", - "hyprland_resume_retryable", "hyprland_inventory_owned_recovery_pending", - "hyprland_owned_recovery_pending", "hyprland_inventory_seat_button_held", - "hyprland_inventory_scope_armed", "hyprland_inventory_environment_unavailable", - "hyprland_inventory_device_input_held_or_unavailable", "hyprland_lock_or_input_held", - "focus_preflight_refused", "focus_candidate_unavailable", "focus_candidate_changed", - "focus_full_observation_required", "focus_visual_target_changed", - "focus_transition_unavailable", "focus_not_obtained", - "focus_requires_new_observation", -}) +_AUDIT_REASON_CODES = frozenset( + { + "computer_rejected", + "computer_not_satisfied", + "outcome_unknown", + "permission_denied", + "unsupported_operation", + "backend_refused", + "unsupported_character", + "sequence_interrupted", + "sequence_cancelled", + "wayland_portal_start_failed", + "hyprland_native_start_failed", + "topology_monitor_unavailable", + "hyprland_preflight_failed", + "probe_behavior_failed", + "probe_deadline_exceeded", + "probe_unavailable", + "wayland_kwin_mapping_unavailable", + "wayland_mutter_mapping_unavailable", + "hyprland_owner_adoption_reply_invalid", + "hyprland_owner_retirement_reply_invalid", + "hyprland_owner_reply_invalid", + "hyprland_scope_selection_invalid", + "hyprland_scope_reply_invalid", + "hyprland_guardian_configuration_invalid", + "hyprland_identity_transport_failed", + "hyprland_capture_transport_failed", + "hyprland_capture_header_invalid", + "hyprland_capture_configuration_invalid", + "hyprland_capture_peer_mismatch", + "hyprland_capture_helper_failed", + "hyprland_owner_identity_invalid", + "hyprland_owner_descriptor_invalid", + "hyprland_owner_reconnect_unavailable", + "hyprland_owner_protocol_unavailable", + "hyprland_retirement_protocol_unavailable", + "hyprland_cross_compositor_retirement_unavailable", + "hyprland_scope_window_continuity_unavailable", + "hyprland_scope_instance_status_invalid", + "hyprland_scope_plugin_incarnation_changed", + "hyprland_scope_closed", + "hyprland_focus_outside_source", + "hyprland_parent_chain_unverified", + "hyprland_identity_unavailable", + "hyprland_identity_invalid_budget", + "hyprland_identity_deadline", + "hyprland_application_group_invalid", + "hyprland_application_group_changed", + "hyprland_executable_changed", + "hyprland_executable_untrusted", + "hyprland_explicit_build_trust_required", + "hyprland_explicit_output_required", + "hyprland_explicit_session_required", + "hyprland_guardian_scope_unavailable", + "hyprland_guardian_scope_invalid", + "hyprland_guardian_scope_expired", + "hyprland_guardian_mapping_changed", + "hyprland_guardian_revoked", + "hyprland_guardian_uid_unavailable", + "hyprland_peer_unavailable", + "hyprland_peer_mismatch", + "hyprland_process_changed", + "hyprland_version_reply_invalid", + "hyprland_invalid_expected_peer", + "hyprland_invalid_explicit_output", + "hyprland_pixel_out_of_bounds", + "hyprland_owner_capture_late_or_retired", + "hyprland_remote_portal_unqualified", + "hyprland_native_no_input_sent", + "input_guardian_unavailable", + "sequence_not_dispatched", + "target_unavailable", + "explicit_x11_capture_unavailable", + "kwin_connect_to_eis_requires_6_1", + "portal_remotedesktop_eis_unavailable", + "wayland_application_changed", + "wayland_bounds_unavailable", + "wayland_compositor_backend_unavailable", + "wayland_compositor_backend_unidentified", + "wayland_compositor_executable_unqualified", + "wayland_compositor_exited", + "wayland_compositor_identity_unavailable", + "wayland_compositor_name_mismatch", + "wayland_compositor_start_changed", + "wayland_compositor_uid_changed", + "wayland_eis_compositor_peer_mismatch", + "wayland_explicit_session_required", + "wayland_focus_stale", + "wayland_focus_unavailable", + "wayland_guardian_binary_unavailable", + "wayland_guardian_disconnected", + "wayland_guardian_invalid_action", + "wayland_guardian_not_active", + "wayland_guardian_path_invalid", + "wayland_guardian_path_untrusted", + "wayland_guardian_revoked", + "wayland_guardian_scope_lease_unavailable", + "wayland_guardian_single_use", + "wayland_guardian_uid_unavailable", + "wayland_guardian_unexpected_receipt", + "wayland_mapped_object_changed", + "wayland_mapped_object_deleted", + "wayland_mapped_object_untrusted_or_replaced", + "wayland_mapping_identifier_invalid", + "wayland_process_identity_unavailable", + "wayland_process_identity_untrusted", + "wayland_provider_owner_changed", + "wayland_provider_untrusted", + "wayland_source_geometry_unavailable", + "wayland_source_unavailable", + "target_inventory_unavailable", + "inventory_targets_unsupported", + "operation_unavailable", + "input_not_granted", + "capture_not_granted", + "input_focus_unavailable", + "focus_transition_unavailable", + "target_selection_required", + "target_selection_stale", + "target_selection_changed", + "invalid_source_selection", + "source_selection_unavailable", + "stale_observation", + "stale_source_binding", + "geometry_changed", + "visual_target_changed", + "sequence_visual_target_changed", + "observation_expired", + "human_focus_changed", + "hyprland_focus_changed", + "hyprland_focus_changed_before_dispatch", + "hyprland_native_focus_not_confirmed", + "hyprland_unknown_or_nonnative_focus", + "hyprland_dispatch_interrupted_after_release", + "effect_unknown_reconcile_no_replay", + "input_release_unknown", + "input_outcome_unknown", + "unexpected_dialog_transition", + "hyprland_fresh_modal_binding_required", + "stale_generation", + "resume_unavailable", + "hyprland_resume_retryable", + "hyprland_inventory_owned_recovery_pending", + "hyprland_owned_recovery_pending", + "hyprland_inventory_seat_button_held", + "hyprland_inventory_scope_armed", + "hyprland_inventory_environment_unavailable", + "hyprland_inventory_device_input_held_or_unavailable", + "hyprland_lock_or_input_held", + "focus_preflight_refused", + "focus_candidate_unavailable", + "focus_candidate_changed", + "focus_full_observation_required", + "focus_visual_target_changed", + "focus_transition_unavailable", + "focus_not_obtained", + "focus_requires_new_observation", + "hyprland_resource_absence_unproven", + "hyprland_plugin_approved_tuple_required", + "hyprland_plugin_artifact_untrusted", + "hyprland_plugin_command_refused", + "hyprland_plugin_companion_identity_mismatch", + "hyprland_plugin_compositor_pin_mismatch", + "hyprland_plugin_identity_required", + "hyprland_plugin_instance_status_invalid", + "hyprland_plugin_ipc_unavailable", + "hyprland_plugin_load_unconfirmed", + "hyprland_plugin_manifest_invalid", + "hyprland_plugin_manifest_required", + "hyprland_plugin_manifest_untrusted", + "hyprland_plugin_mapped_image_unavailable", + "hyprland_plugin_mapped_image_unverified", + "hyprland_plugin_reply_invalid", + "hyprland_plugin_root_required", + "hyprland_plugin_runtime_unqualified", + "hyprland_plugin_task_authorization_required", + "hyprland_plugin_unready", + "hyprland_focus_not_contained_or_ambiguous", + "hyprland_unknown_or_nonnative_focus", + "hyprland_fractional_or_unknown_geometry", + "hyprland_snapshot_capacity", + "hyprland_lock_or_unknown_state", + "hyprland_lock_or_input_held", + "hyprland_stale_topology_epoch", + "hyprland_requested_identity_required", + "hyprland_stale_or_ineligible_candidate", + "hyprland_native_focus_not_confirmed", + "hyprland_foreign_or_unknown_toplevel_provenance", + "hyprland_native_process_lifetime_unavailable", + "hyprland_native_process_image_unavailable", + "hyprland_parent_lifetime_unavailable", + "hyprland_owner_incarnation_refused", + "hyprland_owner_identity_refused", + "hyprland_owner_ledger_cap_or_late_capture", + "hyprland_owner_lifetime_unavailable", + "hyprland_owner_ledger_missing", + "hyprland_owner_adoption_authority_refused", + "hyprland_owner_adoption_unknown", + "hyprland_owner_adoption_query_refused", + "hyprland_owner_adoption_query_required", + "hyprland_owner_adoption_original_refused", + "hyprland_owner_recovery_still_live_or_unproven", + "hyprland_owner_recovery_peer_refused", + "hyprland_owner_command_unknown", + "hyprland_owner_command_refused", + "hyprland_owner_command_conflict", + "hyprland_release_status_unknown", + "hyprland_release_command_refused", + "hyprland_diagnostic_token_refused", + "hyprland_diagnostic_snapshot_refused", + "hyprland_unknown_operation", + "hyprland_absolute_scope_deadline_required", + "hyprland_invalid_lease_or_cleanup_failed", + "hyprland_renew_binding_refused", + "hyprland_already_armed", + "hyprland_owner_admission_retired", + "hyprland_stale_snapshot", + "hyprland_ambiguous_keyboard", + "hyprland_missing_guardian_keyboard", + "hyprland_ambiguous_pointer", + "hyprland_missing_or_wrong_output_pointer", + "hyprland_human_input_held", + "hyprland_owner_device_incarnation_changed", + "hyprland_owner_ledger_cap", + "hyprland_invalid_json", + "hyprland_application_group_identity_refused", + "hyprland_application_group_refresh_not_released", + "hyprland_application_group_capacity", + "hyprland_application_group_unbounded", + "hyprland_application_group_member_refused", + "hyprland_application_group_refresh_type", + "hyprland_application_group_target_not_released", + "hyprland_application_group_target_fields", + "hyprland_application_group_target_epoch", + "hyprland_application_group_target_output", + "hyprland_application_group_target_layer_surface", + "hyprland_application_group_target_unknown", + "hyprland_application_group_target_surface", + "hyprland_application_group_target_ineligible", + "hyprland_application_group_target_focus_unconfirmed", + "hyprland_inventory_environment_unavailable", + "hyprland_inventory_scope_armed", + "hyprland_inventory_seat_button_held", + "hyprland_inventory_device_input_held_or_unavailable", + "hyprland_inventory_owned_recovery_pending", + "hyprland_owned_recovery_pending", + "hyprland_resource_containment_late", + "hyprland_resource_containment_unavailable", + # Fixed ComputerError/RenderError/ProvisioningError codes and fixed reason + # values surfaced by the computer tool boundary. Keep in sync with the + # AST drift test in test_computer_audit_reason_codes.py. + "accessible_native_identity_unavailable", + "accessible_target_changed", + "accessible_target_unavailable", + "action_id_conflict", + "application_environment_unsupported", + "arguments_too_large", + "aspect_ratio_cannot_fit_delivered_bounds", + "aspect_ratio_cannot_fit_png_budget", + "assisted_input_lifecycle_unproven", + "attachment_unavailable", + "backend_capabilities_changed", + "backend_capabilities_unknown", + "capture_crop_mismatch", + "capture_render_mapping_mismatch", + "capture_transform_outside_source", + "cleanup_persistence_failed", + "computer_dependency_unavailable", + "computer_target_incomplete", + "controller_lost", + "delivered_image_too_large", + "disabled", + "display_asleep", + "evidence_changed", + "evidence_quota", + "evidence_too_large", + "evidence_unavailable", + "existing_session_export_not_granted", + "explicit_acknowledgment_required", + "explicit_fresh_target", + "field_text_verification_required", + "field_text_verification_requires_replace_field", + "focus_visual_verification_required", + "foreground_only", + "grant_revoked", + "grant_revoked_or_limit", + "grounded_actions_unavailable", + "hyprland_action_revoked_outcome_unknown", + "hyprland_application_group_target_changed", + "hyprland_application_identity_unavailable", + "hyprland_backend_not_startable", + "hyprland_capture_generation_changed", + "hyprland_capture_raster_invalid", + "hyprland_capture_scope_changed", + "hyprland_capture_settle_budget_exhausted", + "hyprland_capture_source_not_granted", + "hyprland_compositor_exited", + "hyprland_durable_owner_required", + "hyprland_explicit_output_changed", + "hyprland_explicit_session_configuration_required", + "hyprland_explicit_socket_required", + "hyprland_fresh_application_observation_required", + "hyprland_fresh_observation_required", + "hyprland_fresh_target_required", + "hyprland_generation_revoked", + "hyprland_handoff_binding_unavailable", + "hyprland_handoff_not_safe", + "hyprland_input_extent_mismatch", + "hyprland_invalid_point", + "hyprland_native_output_unavailable", + "hyprland_observation_changed", + "hyprland_observation_expired", + "hyprland_original_application_changed", + "hyprland_original_target_changed", + "hyprland_original_target_continuity_unproven", + "hyprland_owned_cleanup_unverified", + "hyprland_plugin_manifest_required", + "hyprland_provider_owner_changed", + "hyprland_reconciliation_required", + "hyprland_reconnect_outcome_unknown", + "hyprland_recovered_fresh_observation_required", + "hyprland_recovery_authority_invalid", + "hyprland_recovery_authority_unavailable", + "hyprland_recovery_evidence_invalid", + "hyprland_recovery_identity_unavailable", + "hyprland_recovery_pending", + "hyprland_recovery_revoked", + "hyprland_recovery_trust_changed", + "hyprland_recovery_unavailable", + "hyprland_renewed_session_consent_required", + "hyprland_resource_witness_invalid", + "hyprland_scope_evidence_expired", + "hyprland_scope_unavailable", + "hyprland_scope_unknown_locked_or_stale", + "hyprland_session_revoked", + "invalid_arguments", + "invalid_target", + "display_power_unavailable", + "stale_capture_consent", + "stale_capture_topology", + "stale_modal_binding", + "stale_observation_binding", + "stale_recovery_pending", + "wayland_focus_changed", + "wayland_focus_changed_before_dispatch", + "render_dependency_unavailable", + "isolated_request_conflicts_with_existing_session", + "input_mapping_unknown", + "input_outside_capture_scope", + "invalid_accessibility_metadata", + "invalid_application_provenance", + "invalid_backend_observation", + "invalid_bounds", + "invalid_consent_generation", + "invalid_delivered_bounds", + "invalid_evidence", + "invalid_export_name", + "invalid_hyprland_application_identity", + "invalid_hyprland_output_grant", + "invalid_input_lifecycle", + "invalid_input_separation", + "invalid_limit", + "invalid_modal_classification", + "invalid_modifiers", + "invalid_observation_crop", + "invalid_observation_response", + "invalid_packed_raster", + "invalid_provenance", + "invalid_receipt", + "invalid_recovery_pending", + "invalid_render_rotation", + "invalid_runtime_identity", + "invalid_sequence", + "invalid_sequence_length", + "invalid_source_crop", + "invalid_source_dimensions", + "invalid_state", + "invalid_task_context", + "invalid_text", + "isolated_app_required", + "legacy_acknowledgment_unavailable", + "modal_not_present", + "neutral_observation_required", + "not_found", + "observation_not_delivered", + "operator_surface_required", + "pending_no_replay", + "pixel_field_requires_single_action", + "pixel_field_visual_verification_required", + "png_budget_unachievable", + "post_action_capture_unavailable", + "postcondition_binding_changed", + "postcondition_region_changed_geometry", + "postcondition_target_mismatch", + "raster_render_failed", + "recovery_unavailable", + "requested_mark_requires_visual_inspection", + "runtime_identity_changed", + "runtime_identity_required", + "sequence_backend_limit", + "sequence_budget_exceeded", + "sequence_deadline", + "sequence_sampled_target_changed", + "sequence_step_not_verified", + "sequence_target_changed", + "session_busy", + "source_allocation_limit", + "source_geometry_required", + "start_unavailable", + "storage_not_private", + "storage_schema_unsupported", + "storage_selection_required", + "storage_unavailable", + "target_changed_observe_again", + "target_selection_forbidden", + "target_selection_invalid", + "target_selection_unsupported", + "task_expired", + "topology_changed", + "topology_changed_during_capture", + "topology_changed_during_render", + "unexpected_modal", + "unsafe_storage_path", + "unsupported_app", + "unsupported_backend_contract", + "unsupported_key", + "unsupported_postcondition", + "unsupported_raster_mode", + "visual_target_unavailable", + "wayland_action_revoked_outcome_unknown", + "wayland_backend_not_startable", + "wayland_capture_allocation_limit", + "wayland_capture_clock_unverified", + "wayland_capture_dimensions_changed", + "wayland_capture_generation_changed", + "wayland_capture_path_lost", + "wayland_capture_source_changed", + "wayland_capture_source_not_granted", + "wayland_capture_sources_unavailable", + "wayland_compositor_identity_changed", + "wayland_existing_session_target_required", + "wayland_explicit_session_configuration_required", + "wayland_fresh_qualified_application_observation_required", + "wayland_generation_revoked", + "wayland_guardian_input_path_lost", + "wayland_input_extent_mismatch", + "wayland_invalid_configuration", + "wayland_invalid_point", + "wayland_invalid_polyline", + "wayland_invalid_scroll", + "wayland_invalid_unicode_text", + "wayland_observation_changed", + "wayland_observation_expired", + "wayland_scope_unavailable", + "application_identity_unavailable", + "application_identity_changed", + "application_process_unreadable", + "application_scope_unavailable", + "application_scope_changed", + "application_uid_mismatch", + "source_scope_unavailable", + "no_focused_application", + "focused_application_outside_source", + "wayland_owned_cleanup_unverified", + "wayland_pixel_fields_unavailable", + "wayland_point_outside_authenticated_application", + "wayland_pointer_modifiers_unavailable", + "wayland_probe_evidence_invalid", + "wayland_probe_identity_mismatch", + "wayland_renewed_session_consent_required", + "wayland_runtime_identity_missing", + "wayland_runtime_process_limit", + "wayland_scope_evidence_expired", + "wayland_scope_evidence_stale", + "wayland_session_identity_changed", + "wayland_session_revoked", + "wayland_source_mapping_unavailable", + "wayland_stale_source_binding", + "wayland_unsupported_grounded_action", + "wayland_visual_postcondition_required", + } +) def exception_reason(error: BaseException) -> str: @@ -126,8 +625,8 @@ def guidance( "Stop input. Have the operator inspect safety and release state. RELEASE-ALL " "is appropriate only when release is unverified or the input outcome is unknown; " "then close the fenced session and start anew with renewed consent and fresh observation." - if terminal else - "The receipt establishes a safe input boundary. This is not task failure. " + if terminal + else "The receipt establishes a safe input boundary. This is not task failure. " "Observe once, inspect what happened, then CONTINUE the task with new action ids. " "Do not assume the previous input was sent or released without a receipt." ) @@ -142,11 +641,12 @@ def guidance( "The focus transition was dispatched and input release is verified, but focus is not " "confirmed. It granted no typing authority; obtain and inspect a fresh observation " "before any next action." - if reason == "focus_not_obtained" else - "The focus transition completed with verified input release. It did not grant typing " + if reason == "focus_not_obtained" + else "The focus transition completed with verified input release. " + "It did not grant typing " "authority; obtain and inspect a fresh observation before any next action." - if reason == "focus_requires_new_observation" else - "Preflight rejected this operation before dispatch; no input was sent. It is safe " + if reason == "focus_requires_new_observation" + else "Preflight rejected this operation before dispatch; no input was sent. It is safe " "to retry with a supported operation. No input-release intervention is needed." ) if reason in {"hyprland_inventory_owned_recovery_pending", "hyprland_owned_recovery_pending"}: @@ -170,8 +670,10 @@ def guidance( "Plugin reload alone does not clear it." ) elif reason in { - "hyprland_inventory_scope_armed", "hyprland_inventory_environment_unavailable", - "hyprland_inventory_device_input_held_or_unavailable", "hyprland_lock_or_input_held", + "hyprland_inventory_scope_armed", + "hyprland_inventory_environment_unavailable", + "hyprland_inventory_device_input_held_or_unavailable", + "hyprland_lock_or_input_held", }: instruction = ( "No new action press was authorized. Check the native preflight blocker: " @@ -201,7 +703,8 @@ def guidance( "not require operator input-release intervention." ) elif not terminal and reason in { - "unexpected_dialog_transition", "hyprland_fresh_modal_binding_required", + "unexpected_dialog_transition", + "hyprland_fresh_modal_binding_required", }: next_action = "inspect_returned_view_and_use_new_modal_binding" instruction = ( @@ -276,13 +779,24 @@ def _receipt_nodes(value, local_release=False): if isinstance(value, dict): execution = value.get("execution") local_release = local_release or ( - value.get("released") is True or value.get("release_confirmed") is True - or isinstance(execution, dict) and execution.get("released") is True + value.get("released") is True + or value.get("release_confirmed") is True + or isinstance(execution, dict) + and execution.get("released") is True ) yield value, local_release for key, child in value.items(): - if key in {"execution", "verification", "cleanup", "diagnostics", - "receipt", "receipts", "steps", "results", "release"}: + if key in { + "execution", + "verification", + "cleanup", + "diagnostics", + "receipt", + "receipts", + "steps", + "results", + "release", + }: yield from _receipt_nodes(child, local_release) elif isinstance(value, (list, tuple)): for child in value: @@ -294,39 +808,76 @@ def safety_terminal(result: dict) -> bool: # Exact, controller-owned capability response: it describes a rejected # operation before any input session or dispatch existed. Keep the envelope # narrow so arbitrary caller data cannot claim non-dispatch. - if isinstance(result, dict) and result == { - "status": "unsupported_operation", - "backend": result.get("backend") if isinstance(result, dict) else None, - "operation": "inventory_targets", - "dispatch": "none", - "supported_next_step": "start", - } and result.get("backend") in {"x11", "wayland", "hyprland"}: + if ( + isinstance(result, dict) + and result + == { + "status": "unsupported_operation", + "backend": result.get("backend") if isinstance(result, dict) else None, + "operation": "inventory_targets", + "dispatch": "none", + "supported_next_step": "start", + } + and result.get("backend") in {"x11", "wayland", "hyprland"} + ): return False for item, local_release in _receipt_nodes(result): # Present but malformed safety evidence is not equivalent to absence. - for key in ("released", "release_confirmed", "release_ack", "held_input", - "unknown_release", "uncertain_outcome", "terminal"): + for key in ( + "released", + "release_confirmed", + "release_ack", + "held_input", + "unknown_release", + "uncertain_outcome", + "terminal", + ): if key in item and type(item[key]) is not bool: return True - if "release" in item and not isinstance(item["release"], dict) and ( - item["release"] not in ("confirmed", "released", "complete", "completed") + if ( + "release" in item + and not isinstance(item["release"], dict) + and (item["release"] not in ("confirmed", "released", "complete", "completed")) ): return True if ( item.get("released") is False or item.get("release_confirmed") is False - or item.get("release_ack") is False and not local_release + or item.get("release_ack") is False + and not local_release or item.get("held_input") is True or item.get("unknown_release") is True or item.get("uncertain_outcome") is True or item.get("terminal") is True - or "state" in item and item["state"] not in ( - "starting", "active", "paused", "closed", "cancelled", "fresh_target_required", + or "state" in item + and item["state"] + not in ( + "starting", + "active", + "paused", + "closed", + "cancelled", + "fresh_target_required", ) - or "status" in item and item["status"] not in ( - "executed", "verified", "not_satisfied", "interrupted", "unavailable", - "rejected", "failed", "satisfied", "visual_review_required", "complete", - "completed", "ok", "success", "released", "confirmed", "observed", + or "status" in item + and item["status"] + not in ( + "executed", + "verified", + "not_satisfied", + "interrupted", + "unavailable", + "rejected", + "failed", + "satisfied", + "visual_review_required", + "complete", + "completed", + "ok", + "success", + "released", + "confirmed", + "observed", ) or item.get("release") in ("unknown", "held", "failed", "unconfirmed") ): @@ -353,8 +904,9 @@ def safe_input_receipt(result: dict) -> bool: aggregates.append(execution) if isinstance(cleanup, dict) and cleanup.get("complete") is True: aggregates.append(cleanup) - if any(item.get("released") is True or item.get("release_confirmed") is True - for item in aggregates): + if any( + item.get("released") is True or item.get("release_confirmed") is True for item in aggregates + ): return True # A not-sent suffix or contradictory top-level flag cannot settle an earlier # sent step. Without independent release, all present dispatch evidence must @@ -362,8 +914,7 @@ def safe_input_receipt(result: dict) -> bool: for item, _ in _receipt_nodes(result): if any(key in item and item[key] is not False for key in ("injected", "sent")): return False - return any(item.get("injected") is False or item.get("sent") is False - for item in aggregates) + return any(item.get("injected") is False or item.get("sent") is False for item in aggregates) def input_outcome(result: dict) -> str: @@ -371,7 +922,8 @@ def input_outcome(result: dict) -> str: if not isinstance(result, dict): return "release_unknown" if isinstance(result, dict) and ( - result == { + result + == { "status": "unsupported_operation", "backend": result.get("backend"), "operation": "inventory_targets", @@ -385,16 +937,12 @@ def input_outcome(result: dict) -> str: if safety_terminal(result): return "release_unknown" dispatch_evidence = [ - item[key] - for item, _ in nodes - for key in ("injected", "sent") - if key in item + item[key] for item, _ in nodes for key in ("injected", "sent") if key in item ] if dispatch_evidence and all(value is False for value in dispatch_evidence): return "not_dispatched" if any( - item.get("released") is True or item.get("release_confirmed") is True - for item, _ in nodes + item.get("released") is True or item.get("release_confirmed") is True for item, _ in nodes ): return "released_verified" return "release_unknown" @@ -412,7 +960,9 @@ def failure_guidance(result: dict, *, terminal: bool = False) -> dict: evidence = result.get("verification") evidence = evidence if isinstance(evidence, dict) else {} reason = ( - result.get("reason") or evidence.get("reason") or result.get("error") + result.get("reason") + or evidence.get("reason") + or result.get("error") or (result.get("status") if result.get("status") in _PREFLIGHT_RETRY else None) ) if not isinstance(reason, str): @@ -427,28 +977,42 @@ def failure_guidance(result: dict, *, terminal: bool = False) -> dict: ) if not summary["terminal"]: for source in (result, evidence, result.get("diagnostics")): - if (isinstance(source, dict) and isinstance(source.get("next_action"), str) - and source["next_action"] not in { - "", "stop", "operator_intervention_required", - }): + if ( + isinstance(source, dict) + and isinstance(source.get("next_action"), str) + and source["next_action"] + not in { + "", + "stop", + "operator_intervention_required", + } + ): summary["next_action"] = source["next_action"] break execution = result.get("execution") - if (result.get("released") is True or result.get("release_confirmed") is True - or isinstance(execution, dict) and execution.get("released") is True): + if ( + result.get("released") is True + or result.get("release_confirmed") is True + or isinstance(execution, dict) + and execution.get("released") is True + ): summary["instruction"] = ( "Owned input is cleanly released according to the receipt. " + summary["instruction"] ) - elif (result.get("injected") is False or result.get("sent") is False - or isinstance(execution, dict) and ( - execution.get("injected") is False or execution.get("sent") is False - )): + elif ( + result.get("injected") is False + or result.get("sent") is False + or isinstance(execution, dict) + and (execution.get("injected") is False or execution.get("sent") is False) + ): summary["instruction"] = ( "The previous requested input was not sent. " + summary["instruction"] ) - if (result.get("fresh_session_required") is True - or result.get("state") in {"closed", "cancelled"}): + if result.get("fresh_session_required") is True or result.get("state") in { + "closed", + "cancelled", + }: summary["instruction"] += ( " Obtain a fresh authorized session before observing; " "do not act on the closed binding." diff --git a/src/computer/integration.py b/src/computer/integration.py index e167a723b..413b5a2eb 100644 --- a/src/computer/integration.py +++ b/src/computer/integration.py @@ -301,6 +301,17 @@ async def _tool(self, name, values): result = receipt else: image["__computer_action_receipt__"] = receipt + image["__computer_audit_metadata__"] = { + "computer_call_id": grant.call_id, + "computer_turn_id": grant.context.turn_id, + "computer_reason_code": audit_reason_code( + receipt.get("reason") or ( + receipt.get("verification", {}).get("reason") + if isinstance(receipt.get("verification"), dict) else None + ) or receipt.get("status") + ), + "computer_input_outcome": input_outcome(receipt), + } image["__prompt__"] += ( "\nAction receipt (effect status is independent of image delivery): " + json.dumps(receipt, ensure_ascii=True, separators=(",", ":")) @@ -331,7 +342,10 @@ async def _tool(self, name, values): } rejected = rejected and not capability_refusal safe_reason = audit_reason_code( - (result.get("reason") or result.get("status")) + (result.get("reason") or ( + result.get("verification", {}).get("reason") + if isinstance(result.get("verification"), dict) else None + ) or result.get("status")) if isinstance(result, dict) else None ) return ToolResult( diff --git a/src/config/apply_registry.py b/src/config/apply_registry.py index 9b6de304d..54ab8f338 100644 --- a/src/config/apply_registry.py +++ b/src/config/apply_registry.py @@ -267,11 +267,6 @@ class SectionSpec: "desired state to this file and reconcile the running control plane " "in the same operation. Direct file edits apply on restart.", ), - "slack": SectionSpec( - "restart", - "Slack destinations and internal alert forwarding.", - restart_reason="The Slack notifier is constructed at startup.", - ), "audit": SectionSpec( "restart", "Audit signing, verification, and retention.", @@ -294,7 +289,7 @@ class SectionSpec: owner="outbound_webhooks", restart_reason="The dispatcher is built at startup, and only if it was " "enabled then. Saving configuration does not create or update it; the " - "dedicated endpoint edits the running dispatcher without persisting.", + "dedicated endpoint atomically persists target changes before applying them.", consumers=( Consumer( "Saving configuration", @@ -304,8 +299,7 @@ class SectionSpec: Consumer( "Outbound webhook endpoints", "live_apply", - "Edits the running dispatcher immediately, and does NOT write " - "config — the change is lost on restart.", + "Atomically saves targets to config and updates the running dispatcher.", ), ), ), @@ -586,8 +580,7 @@ class SectionSpec: label="Hyprland plugin manifest", apply_mode="restart", restart_reason=( - "The desktop lifecycle snapshots the trusted manifest location " - "at construction." + "The desktop lifecycle snapshots the trusted manifest location at construction." ), description=( "Absolute root-owned build-identity manifest. The default installed " @@ -686,12 +679,6 @@ class SectionSpec: description="Directory containing the persistent Usage & Activity rollup.", restart_reason="The SQLite store and background indexer bind this path at startup.", ), - "slack.forward_alerts": FieldSpec( - apply_mode="activation_required", - description="Forward normalized internal alerts to tested Slack destinations.", - activation_policy="Requires an effective notifier, a tested destination, " - "and an activation receipt.", - ), # ---------------- agents ---------------- "agents.max_concurrent_agents": FieldSpec( apply_mode="live_for_new_work", @@ -1225,10 +1212,13 @@ class SectionSpec: apply_mode="restart", description="Complete serialized tool-output delivery budget.", restart_reason="The executor and result delivery share the budget loaded at startup.", - consumers=(Consumer( - "Tool output delivery", "restart", - "All delivery envelopes use this ceiling, independent of model context size.", - ),), + consumers=( + Consumer( + "Tool output delivery", + "restart", + "All delivery envelopes use this ceiling, independent of model context size.", + ), + ), ), "tools.tool_timeouts": FieldSpec( apply_mode="restart", @@ -1570,28 +1560,47 @@ def _pattern_spec(path: str) -> FieldSpec | None: description="Credential-bearing container for this MCP server.", ) if path.startswith("outbound_webhooks.targets."): + endpoint_consumers = ( + Consumer( + "Outbound webhook endpoints", + "live_apply", + "Dedicated target edits persist and apply to the running dispatcher. " + "Direct config.yml edits still require restart.", + ), + ) if path.endswith(".secret"): return FieldSpec( + apply_mode="live_apply", + apply_handler="POST/PUT/DELETE /api/outbound-webhooks", + consumers=endpoint_consumers, owner="secrets", sensitivity="sensitive", description="Signing secret for this target.", ) if path.endswith(".scrub_secrets") or path.endswith(".verify_ssl"): return FieldSpec( - description="Target-bound safety override. Boot wiring drops " - "the persisted value and the live target defaults to true; " - "the dedicated endpoint can change the running dispatcher " - "without persisting it.", - boot_snapshot_is_effective=False, + apply_mode="live_apply", + apply_handler="POST/PUT/DELETE /api/outbound-webhooks", + consumers=endpoint_consumers, + description=( + "Target-bound safety override applied and persisted by the " + "dedicated webhook endpoint." + ) ) + return FieldSpec( + apply_mode="live_apply", + apply_handler="POST/PUT/DELETE /api/outbound-webhooks", + consumers=endpoint_consumers, + description="Webhook target managed by the dedicated endpoint.", + ) return None def _is_sensitive_path(path: str) -> bool: """Use the same compound-key rule as GET /api/config redaction. - A credential-bearing scalar or plain mapping makes its descendants secret - (``slack.webhook_urls.ops``). A container OF schema records does not: + A credential-bearing scalar or plain mapping makes its descendants secret. + A container OF schema records does not: ``web.api_tokens.0.tier`` is public metadata beside the token field. The schema distinction prevents both leaking arbitrary-key maps and redacting whole records into uselessness. diff --git a/src/config/persistence.py b/src/config/persistence.py index e1cb42d83..717ba965d 100644 --- a/src/config/persistence.py +++ b/src/config/persistence.py @@ -13,7 +13,9 @@ * the target is always the file the live config was LOADED from (``active_config_path()``), never a CWD-relative guess; * the document is edited in place with ruamel round-trip, so comments, ordering, - quoting, anchors, and untouched ``${VAR}`` placeholders survive; + quoting, anchors, and untouched ``${VAR}`` placeholders survive; webhook CRUD + instead splices source spans and validates the result without re-emitting + unrelated rows or allowing shared anchors; * only the leaves a caller actually changed are patched — an untouched placeholder is never rewritten, because it is never visited; * the commit is atomic: temp file in the same directory, original mode restored, @@ -35,10 +37,11 @@ import stat import tempfile import threading +import uuid import weakref from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence from pathlib import Path -from typing import Any +from typing import Any, cast from ..odin_log import get_logger from .schema import active_config_path @@ -259,7 +262,12 @@ def _load_document(config_path: Path) -> tuple[Any, int]: def _dump_atomic( - document: Any, config_path: Path, orig_mode: int, *, raw_text: str | None = None, + document: Any, + config_path: Path, + orig_mode: int, + *, + raw_text: str | None = None, + sequence_indent: int | None = None, ) -> None: """Serialize *document* over *config_path* atomically, preserving mode.""" import io @@ -268,6 +276,8 @@ def _dump_atomic( ry = YAML() ry.preserve_quotes = True + if sequence_indent is not None: + ry.indent(mapping=2, sequence=sequence_indent, offset=2) buf = io.StringIO() if raw_text is None: ry.dump(document, buf) @@ -344,13 +354,19 @@ def _config_file_lock(target: Path): if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700: raise ConfigPersistError("unsafe config lock directory ownership or permissions") fd = os.open( - _config_identity(target), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, - 0o600, dir_fd=directory_fd, + _config_identity(target), + os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, + 0o600, + dir_fd=directory_fd, ) try: info = os.fstat(fd) - if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() - or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1): + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) != 0o600 + or info.st_nlink != 1 + ): raise ConfigPersistError("unsafe config lock file ownership or permissions") fcntl.flock(fd, fcntl.LOCK_EX) yield @@ -361,7 +377,9 @@ def _config_file_lock(target: Path): def patch_config_paths( - changes: Iterable[ConfigChange], *, path: Path | str | None = None, + changes: Iterable[ConfigChange], + *, + path: Path | str | None = None, image_model_intent: Mapping[str, str] | None = None, ) -> None: """Serialize the file revision with startup migration and other processes.""" @@ -373,8 +391,204 @@ def patch_config_paths( _patch_config_paths(changes, path=target, image_model_intent=image_model_intent) +def _webhook_identity(item: Any, index: int) -> str | None: + """Return the identity assigned by startup to one raw webhook mapping.""" + if not isinstance(item, Mapping): + return None + explicit = item.get("id") + if explicit: + return str(explicit) + url = item.get("url") + if not isinstance(url, str) or not url: + return None + # Wiring derives legacy IDs from the env-substituted URL, while this reader + # sees the round-trip YAML node. Resolve only for identity; never write the + # resolved value back to the document. + try: + from .schema import _substitute_env_vars + + url = _substitute_env_vars(url) + except ValueError: + pass + return uuid.uuid5(uuid.NAMESPACE_URL, f"outbound-webhook:{index}:{url}").hex[:12] + + +def patch_webhook_targets( + targets: Iterable[Mapping[str, Any]], + *, + changed_fields: Mapping[str, Iterable[str] | tuple[set[str], set[str]]], + delete_ids: Iterable[str] = (), + create_ids: Iterable[str] = (), + path: Path | str | None = None, +) -> None: + """Patch only explicitly created, updated or deleted webhook rows. + + ``targets`` contains only rows named by ``changed_fields``; the runtime + dispatcher is not a snapshot of operator edits made to config since boot. + """ + from .webhook_text import WebhookTextPatch + + config_path = _resolve_path(path).resolve() + if not config_path.exists(): + raise ConfigPersistError("config file does not exist") + with _config_file_lock(config_path): + document, orig_mode = _load_document(config_path) + with config_path.open(newline="") as stream: + source = WebhookTextPatch(stream.read()) + # Validate source spans before any mutation, and reject shared YAML + # anchors rather than editing another row through its alias. + source._nodes() + section = document.get("outbound_webhooks") if isinstance(document, Mapping) else None + if section is None: + from ruamel.yaml.comments import CommentedMap + + section = CommentedMap() + document["outbound_webhooks"] = section + if not isinstance(section, MutableMapping): + raise ConfigPersistError("outbound_webhooks must be a mapping") + sequence = section.get("targets") + if sequence is None: + from ruamel.yaml.comments import CommentedSeq + + sequence = CommentedSeq() + section["targets"] = sequence + if not isinstance(sequence, list): + raise ConfigPersistError("outbound_webhooks.targets must be a list") + rows = [ + row.model_dump() if hasattr(row, "model_dump") else dict(row) + for row in targets + ] + wanted = {str(row.get("id")): row for row in rows if row.get("id")} + changes: dict[str, set[str]] = {} + force_fields = {} + for key, field_spec in changed_fields.items(): + if ( + isinstance(field_spec, tuple) + and len(field_spec) == 2 + and all(isinstance(fields, (set, frozenset)) for fields in field_spec) + ): + changes[str(key)] = set(field_spec[0]) + force_fields[str(key)] = set(field_spec[1]) + else: + changes[str(key)] = set(cast(Iterable[str], field_spec)) + force_fields[str(key)] = set() + deleted = set(map(str, delete_ids)) + creates = set(map(str, create_ids)) + existing_ids = [_webhook_identity(row, index) for index, row in enumerate(sequence)] + changed = False + + # Keep on-disk rows untouched except the requested row. Deleting an + # earlier legacy row changes the later row's derived ID at next boot; + # it does not authorize writing an ID into that unrelated row. + for ident in deleted: + if ident not in existing_ids: + raise ConfigPersistError("webhook target changed on disk") + for ident in changes: + if ident not in wanted: + raise ConfigPersistError("webhook update lacks target") + + for ident in sorted(deleted, key=lambda key: existing_ids.index(key), reverse=True): + index = next((i for i, value in enumerate(existing_ids) if value == ident), None) + if index is not None: + source.delete(index) + del sequence[index] + del existing_ids[index] + changed = True + + consumed: set[int] = set() + for ident, row in wanted.items(): + index = next( + (i for i, value in enumerate(existing_ids) if value == ident and i not in consumed), + None, + ) + if index is not None and ident in creates: + # Creates must never overwrite an operator-created row with the + # same identity since the runtime snapshot was taken. + raise ConfigPersistError("webhook target changed on disk") + if index is None: + if ident not in changes: + raise ConfigPersistError("webhook target changed on disk") + if force_fields.get(ident): + raise ConfigPersistError("webhook target changed on disk") + # Do not duplicate an on-disk entry whose identity is already + # owned by another (unregistered) configured mapping. + if ident in existing_ids: + raise ConfigPersistError("webhook target identity conflicts with config") + from ruamel.yaml.comments import CommentedMap + + source.append(row) + new_row = CommentedMap(row) + sequence.append(new_row) + existing_ids.append(ident) + consumed.add(len(sequence) - 1) + changed = True + continue + + current = sequence[index] + if not isinstance(current, MutableMapping): + raise ConfigPersistError("webhook target entry must be a mapping") + consumed.add(index) + edits = {} + for field in sorted(changes.get(ident, ())): + if field not in row: + continue + new_value = row[field] + old_value = current.get(field) + if field not in force_fields.get(ident, ()) and _placeholder_still_accurate( + old_value, new_value + ): + continue + if old_value != new_value: + current[field] = new_value + edits[field] = new_value + changed = True + # A legacy target's URL-derived ID would otherwise change after a + # rename. Persist its runtime ID only when the URL actually changes. + if "url" in changes.get(ident, ()) and not current.get("id"): + current["id"] = ident + edits["id"] = ident + changed = True + source.update(index, edits) + + if changed: + source.validate(document) + _dump_atomic(document, config_path, orig_mode, raw_text=source.text) + + +async def persist_webhook_targets_locked( + targets: Iterable[Mapping[str, Any]], + *, + changed_fields: Mapping[str, Iterable[str] | tuple[set[str], set[str]]], + delete_ids: Iterable[str] = (), + create_ids: Iterable[str] = (), + path: Path | str | None = None, +) -> PersistOutcome: + """Thread-settled webhook patch for callers holding config_transaction().""" + rows = [ + row.model_dump() if hasattr(row, "model_dump") else dict(row) + for row in targets + ] + changes: dict[str, Iterable[str] | tuple[set[str], set[str]]] = {} + for key, field_spec in changed_fields.items(): + if isinstance(field_spec, tuple) and len(field_spec) == 2 and all( + isinstance(part, (set, frozenset)) for part in field_spec + ): + changes[str(key)] = (set(field_spec[0]), set(field_spec[1])) + else: + changes[str(key)] = set(cast(Iterable[str], field_spec)) + deleted = tuple(delete_ids) + created = tuple(create_ids) + return await _run_settled( + lambda: patch_webhook_targets( + rows, changed_fields=changes, delete_ids=deleted, create_ids=created, path=path + ) + ) + + def _patch_config_paths( - changes: Iterable[ConfigChange], *, path: Path | str | None = None, + changes: Iterable[ConfigChange], + *, + path: Path | str | None = None, image_model_intent: Mapping[str, str] | None = None, ) -> None: """Apply leaf *changes* to the active config file, touching nothing else. @@ -425,9 +639,20 @@ def _patch_config_paths( explicit_pin = is_image_model and intents.get(segments[-1]) == "pin" if value is DELETE_CONFIG_PATH and not present_leaf: continue - if not explicit_pin and not aliases and ( - (present_leaf and (previous == value or _placeholder_still_accurate(previous, value))) - or (is_image_model and not present_leaf and value == IMAGE_MODEL_DEFAULTS[segments[-1]]) + if ( + not explicit_pin + and not aliases + and ( + ( + present_leaf + and (previous == value or _placeholder_still_accurate(previous, value)) + ) + or ( + is_image_model + and not present_leaf + and value == IMAGE_MODEL_DEFAULTS[segments[-1]] + ) + ) ): continue node = document @@ -465,7 +690,53 @@ def _patch_config_paths( # placeholder; writing the resolved value would put the secret on # disk in plaintext. continue - node[target] = value + if tuple(segments) == ("outbound_webhooks", "targets") and isinstance(value, list): + # Reconcile webhook entries in place. Replacing the sequence + # destroys ruamel comments/flow styles and can materialize + # resolved secret placeholders. + existing = node.get(target, []) + if not isinstance(existing, list): + existing = [] + node[target] = existing + + def identity(item, index): + if not isinstance(item, dict): + return None + if item.get("id"): + return item.get("id") + return uuid.uuid5( + uuid.NAMESPACE_URL, + f"outbound-webhook:{index}:{item.get('url')}", + ).hex[:12] + + old_by_id = {identity(item, i): item for i, item in enumerate(existing)} + old_by_url = { + item.get("url"): item + for item in existing + if isinstance(item, dict) and item.get("url") + } + ordered = [] + for entry in value: + key = entry.get("id") + previous = old_by_id.get(key) + if previous is None: + candidate = old_by_url.get(entry.get("url")) + if candidate is not None and not candidate.get("id"): + previous = candidate + if previous is None: + previous = {} + for field_name, field_value in entry.items(): + if field_name == "secret" and _placeholder_still_accurate( + previous.get(field_name), field_value + ): + continue + if previous.get(field_name) != field_value: + previous[field_name] = field_value + ordered.append(previous) + existing[:] = ordered + changed = True + else: + node[target] = value changed = True if changed: @@ -529,7 +800,9 @@ def config_transaction(): async def persist_config_paths_locked( - changes: Iterable[ConfigChange], *, path: Path | str | None = None, + changes: Iterable[ConfigChange], + *, + path: Path | str | None = None, image_model_intent: Mapping[str, str] | None = None, ) -> PersistOutcome: """Patch leaves to settlement while the caller holds the transaction. diff --git a/src/config/schema.py b/src/config/schema.py index ee9f34c60..bc0f2893a 100644 --- a/src/config/schema.py +++ b/src/config/schema.py @@ -1326,6 +1326,8 @@ class PermissionsConfig(BaseModel): class OutboundWebhookTarget(BaseModel): + id: str = "" # Empty for id-less rows; runtime IDs derive from index and URL. + created_at: str = "" name: str = "" url: str = "" secret: str = "" # HMAC-SHA256 signing key; empty = unsigned @@ -1403,9 +1405,10 @@ class WebConfig(BaseModel): # now configurable so a deployment can bind localhost and front it with a # reverse proxy. host: str = "0.0.0.0" - # Trusted reverse-proxy IPs. When the request's peer is one of these, the - # left-most X-Forwarded-For entry is used as the client IP for rate-limiting - # and audit — otherwise all clients behind the proxy collapse to one IP. + # Trusted proxy CIDRs must contain only proxies you control. When the + # request's peer is trusted, X-Forwarded-For is walked right-to-left, + # ignoring trusted hops until the first untrusted address is found; that + # address is used for rate-limiting and audit. trusted_proxies: list[str] = Field(default_factory=list) @field_validator("port") @@ -1500,16 +1503,6 @@ class ImageConfig(BaseModel): openai: ImageOpenAIConfig = ImageOpenAIConfig() -class SlackConfig(BaseModel): - enabled: bool = False - webhook_urls: dict[str, str] = Field(default_factory=dict) - default_webhook_url: str = "" - scrub_secrets: bool = True - rate_limit_seconds: int = 1 - forward_alerts: bool = True - forward_webhooks: bool = False - - class GrafanaRemediationRuleConfig(BaseModel): id: str = "" name_pattern: str = "*" # fnmatch pattern for alertname @@ -1798,7 +1791,6 @@ class Config(BaseModel): attachments: AttachmentsConfig = AttachmentsConfig() personality: PersonalityConfig = PersonalityConfig() mcp: MCPConfig = MCPConfig() - slack: SlackConfig = SlackConfig() audit: AuditConfig = AuditConfig() agents: AgentsConfig = AgentsConfig() grafana_alerts: GrafanaAlertConfig = GrafanaAlertConfig() @@ -2034,7 +2026,7 @@ def load_config(path: str | Path = "config.yml") -> Config: _KNOWN_REMOVED_TOP_LEVEL_CONFIG_KEYS = frozenset( - {"comfyui", "issue_tracker", "reaction_triggers", "message_triggers"} + {"comfyui", "issue_tracker", "reaction_triggers", "message_triggers", "slack"} ) diff --git a/src/config/webhook_text.py b/src/config/webhook_text.py new file mode 100644 index 000000000..d51df842f --- /dev/null +++ b/src/config/webhook_text.py @@ -0,0 +1,231 @@ +"""Source-span edits for webhook rows, without re-emitting the config document. + +YAML collection end marks include following comments. Only token/leaf ends are +content boundaries: a nested block list must not swallow the section trailer. +The caller validates the complete parsed document before the atomic commit. +""" + +from __future__ import annotations + +import io +from typing import Any + +from ruamel.yaml import YAML +from ruamel.yaml.nodes import MappingNode, ScalarNode, SequenceNode +from ruamel.yaml.scalarstring import DoubleQuotedScalarString, SingleQuotedScalarString +from ruamel.yaml.tokens import AliasToken, AnchorToken, BlockEntryToken, FlowEntryToken + + +def _yaml() -> YAML: + parser = YAML() + parser.preserve_quotes = True + parser.width = 1000000 + parser.indent(mapping=2, sequence=4, offset=2) + parser.representer.ignore_aliases = lambda _value: True + return parser + + +def _render(value: Any, *, flow: bool = False, style: str | None = None) -> str: + if isinstance(value, str): + if style == "'": + value = SingleQuotedScalarString(value) + elif style == '"' or "\n" in value: + value = DoubleQuotedScalarString(value) + parser = _yaml() + parser.default_flow_style = flow + parser.representer.default_flow_style = flow + output = io.StringIO() + parser.dump(value, output) + return output.getvalue().removesuffix("...\n").rstrip("\n") + + +def _entry(node: Any, key: str) -> tuple[Any, Any]: + if isinstance(node, MappingNode): + for key_node, value_node in node.value: + if key_node.value == key: + return key_node, value_node + return None, None + + +def _content_end(node: Any) -> int: + """Exclude comments attached to block containers, including nested ones.""" + if isinstance(node, MappingNode) and not node.flow_style: + return max( + # Implicit nulls can start AFTER the trailer. The colon is the + # last syntax belonging to such an entry, not that null's mark. + key.end_mark.index + 1 if _implicit_null(value) else _content_end(value) + for key, value in node.value + ) + if isinstance(node, SequenceNode) and not node.flow_style: + return max(_content_end(value) for value in node.value) + return int(node.end_mark.index) + + +def _implicit_null(node: Any) -> bool: + return isinstance(node, ScalarNode) and node.tag == "tag:yaml.org,2002:null" and not node.value + + +class WebhookTextPatch: + """Accumulate in-memory edits; never write a file or serialize untouched rows.""" + + def __init__(self, text: str): + self.text = text + self.newline = "\r\n" if "\r\n" in text else "\n" + + def _nodes(self) -> tuple[Any, Any, Any, Any]: + from .persistence import ConfigPersistError + + parser = _yaml() + root = parser.compose(self.text) + _, section = _entry(root, "outbound_webhooks") + key, sequence = _entry(section, "targets") + # Aliases point at the original node's source marks, not the alias's + # location. Refuse rather than accidentally edit its shared definition. + if section is not None: + start, end = section.start_mark.index, section.end_mark.index + if any( + isinstance(token, (AnchorToken, AliasToken)) + and start <= token.start_mark.index < end + for token in parser.scan(self.text) + ): + raise ConfigPersistError("cannot safely edit anchored webhook config") + return root, section, key, sequence + + def _splice(self, start: int, end: int, replacement: str) -> None: + self.text = self.text[:start] + replacement + self.text[end:] + + def _line_end(self, position: int) -> int: + if position and self.text[position - 1] == "\n": + return position + end = self.text.find("\n", position) + return len(self.text) if end < 0 else end + 1 + + def _insert_lines(self, position: int, lines: str) -> None: + prefix = self.newline if position and self.text[position - 1] != "\n" else "" + self._splice(position, position, prefix + lines) + + def _indented(self, value: Any, indent: int) -> str: + return "".join( + " " * indent + line + self.newline for line in _render(value).splitlines() + ) + + def _append_entry(self, mapping: Any, key: str, value: Any) -> None: + if mapping.flow_style: + position = mapping.end_mark.index - 1 + fragment = _render({key: value}, flow=True)[1:-1] + self._splice(position, position, (", " if mapping.value else "") + fragment) + else: + self._insert_lines( + self._line_end(_content_end(mapping)), + self._indented({key: value}, mapping.start_mark.column), + ) + + def append(self, row: dict[str, Any]) -> None: + root, section, key, sequence = self._nodes() + if section is None: + self._append_entry(root, "outbound_webhooks", {"targets": [row]}) + return + if not isinstance(section, MappingNode): + section_key, _ = _entry(root, "outbound_webhooks") + self._replace_value(section_key, section, {"targets": [row]}) + return + if key is None: + self._append_entry(section, "targets", [row]) + return + if not isinstance(sequence, SequenceNode) or not sequence.value: + if section.flow_style: + self._replace_value(key, sequence, [row]) + return + # Keep the key's inline comment exactly where it is. Remove only + # the empty/null value, then insert rows before any following text. + if not _implicit_null(sequence): + start = sequence.start_mark.index + if self.text[start - 1:start] == " ": + start -= 1 + self._splice(start, sequence.end_mark.index, "") + position = self._line_end(key.end_mark.index) + self._insert_lines(position, self._indented([row], key.start_mark.column)) + return + if sequence.flow_style: + position = sequence.end_mark.index - 1 + self._splice(position, position, ", " + _render(row, flow=True)) + else: + # Our emitter offsets the dash by two spaces; match the existing + # dash, not the mapping's first key, for indentless sequences too. + indent = sequence.start_mark.column - 2 + self._insert_lines( + self._line_end(_content_end(sequence.value[-1])), + self._indented([row], indent), + ) + + def delete(self, index: int) -> None: + _, _, key, sequence = self._nodes() + row = sequence.value[index] + if sequence.flow_style: + start, end = row.start_mark.index, _content_end(row) + separators = [ + token.start_mark.index for token in _yaml().scan(self.text) + if isinstance(token, FlowEntryToken) + ] + if index + 1 < len(sequence.value): + comma = next( + position for position in separators + if end <= position < sequence.value[index + 1].start_mark.index + ) + self._splice(comma, comma + 1, "") + elif index: + comma = next( + position for position in separators + if _content_end(sequence.value[index - 1]) <= position < start + ) + self._splice(start, end, "") + self._splice(comma, comma + 1, "") + return + self._splice(start, end, "") + return + entries = [ + token for token in _yaml().scan(self.text) + if isinstance(token, BlockEntryToken) + and token.start_mark.column == sequence.start_mark.column + and sequence.start_mark.index <= token.start_mark.index < sequence.end_mark.index + ] + start = entries[index].start_mark.index - entries[index].start_mark.column + end = self._line_end(_content_end(row)) + self._splice(start, end, "") + if len(sequence.value) == 1: + # Do not remove/re-attach any leading, between-row or tail comments. + colon = self.text.index(":", key.end_mark.index) + self._splice(colon + 1, colon + 1, " []") + + def _replace_value(self, key: Any, value: Any, replacement: Any) -> None: + rendered = _render(replacement, flow=True, style=getattr(value, "style", None)) + if _implicit_null(value): + position = self.text.index(":", key.end_mark.index) + 1 + self._splice(position, position, " " + rendered) + return + start, end = value.start_mark.index, _content_end(value) + # Literal/folded scalar spans include their final newline. Preserve the + # line boundary when replacing them with a quoted scalar/flow value. + if self.text[start:end].endswith("\n"): + rendered += self.newline + self._splice(start, end, rendered) + + def update(self, index: int, fields: dict[str, Any]) -> None: + for field, replacement in fields.items(): + _, _, _, sequence = self._nodes() + row = sequence.value[index] + key, value = _entry(row, field) + if key is None: + self._append_entry(row, field, replacement) + else: + self._replace_value(key, value, replacement) + + def validate(self, expected: Any) -> None: + from .persistence import ConfigPersistError + + try: + actual = _yaml().load(self.text) + except Exception: + raise ConfigPersistError("webhook source edit could not be validated") from None + if actual != expected: + raise ConfigPersistError("webhook source edit changed unexpected config values") diff --git a/src/discord/channel_state.py b/src/discord/channel_state.py index 49022c81a..80978c0d6 100644 --- a/src/discord/channel_state.py +++ b/src/discord/channel_state.py @@ -34,6 +34,17 @@ STEER_MESSAGES_PER_TURN = 128 +class StopResult(str): + """Text-compatible result with an explicit confirmed-stop signal.""" + + confirmed: bool + + def __new__(cls, message: str, *, confirmed: bool = False): + result = super().__new__(cls, message) + result.confirmed = confirmed + return result + + @dataclass class ChatTurnInbox: """Process-local steering primitives, shared by the registry and one turn. @@ -254,7 +265,7 @@ def finish_stop(self, channel_id: str, request_id: str, result: str) -> None: """Publish this request's terminal /stop result to its slash waiter.""" waiter = self._stop_waiters.pop((channel_id, request_id), None) if waiter is not None and not waiter.done(): - waiter.set_result(result) + waiter.set_result(StopResult(result, confirmed=True)) if self.stop_results.get(channel_id) is waiter: self.stop_results.pop(channel_id, None) @@ -290,7 +301,9 @@ def clear_active_request( if resolve_stop_waiter: self._stop_waiters.pop((channel_id, request_id), None) if waiter is not None and not waiter.done(): - waiter.set_result("Task had already finished before /stop took effect.") + waiter.set_result( + StopResult("Task had already finished before /stop took effect.") + ) if self.stop_results.get(channel_id) is waiter: self.stop_results.pop(channel_id, None) self.active_requests.pop(channel_id, None) @@ -426,9 +439,9 @@ def cleanup(self, *, active_channels: set[str]) -> None: if cid not in self.active_requests and cid not in active_channels: waiter = self.stop_results.pop(cid) if not waiter.done(): - waiter.set_result("No active task in this channel.") + waiter.set_result(StopResult("No active task in this channel.")) for key, waiter in list(self._stop_waiters.items()): if key[0] not in self.active_requests and key[0] not in active_channels: self._stop_waiters.pop(key, None) if not waiter.done(): - waiter.set_result("No active task in this channel.") + waiter.set_result(StopResult("No active task in this channel.")) diff --git a/src/discord/client.py b/src/discord/client.py index f07d24506..621a37129 100644 --- a/src/discord/client.py +++ b/src/discord/client.py @@ -113,6 +113,7 @@ def __init__(self, config: Config) -> None: self.permissions = services.permissions self.tool_executor = services.tool_executor self.skill_manager = services.skill_manager + self.codex_quota_check = services.codex_quota_check self.scheduler = services.scheduler self.mcp_manager = services.mcp_manager self.audit = services.audit @@ -299,6 +300,12 @@ async def start_application(self) -> None: except Exception: log.exception("Usage backfill startup failed (non-fatal)") + if getattr(self, "codex_quota_check", None) is not None: + try: + await self.codex_quota_check.start() # type: ignore[attr-defined] + except Exception: + log.exception("Codex quota check startup failed (non-fatal)") + await start_mcp(self) # The scheduler is an application service, not a gateway service. diff --git a/src/discord/completion.py b/src/discord/completion.py index e56eba6ec..f3ae40977 100644 --- a/src/discord/completion.py +++ b/src/discord/completion.py @@ -17,7 +17,7 @@ log = get_logger("discord") CLASSIFIER_TIMEOUT_SECONDS = 10.0 -CLASSIFIER_MAX_TOKENS = 128 +COMPAT_CLASSIFIER_TIMEOUT_SECONDS = 60.0 CLASSIFIER_SYSTEM_PROMPT = ( "You are a completion judge. A user asked an AI assistant to do something. " @@ -59,12 +59,12 @@ async def classify( response_text: str, tools_used: list[str], *, - timeout_seconds: float = CLASSIFIER_TIMEOUT_SECONDS, + timeout_seconds: float | None = None, ) -> tuple[bool, str]: """Judge whether a tool-using assistant response fully addresses its request. Uses a configured auxiliary client when present, otherwise the active - provider, for a small judgment call. Fail-open on errors, timeout, or + provider, for a judgment call. Fail-open on errors, timeout, or ambiguity. ``timeout_seconds`` lets bounded callers enforce their own remaining execution lifetime. @@ -77,7 +77,7 @@ async def classify( Returns (is_complete, reason). reason is non-empty only for INCOMPLETE. """ - if timeout_seconds <= 0: + if timeout_seconds is not None and timeout_seconds <= 0: return True, "" # A configured auxiliary model is intended for bounded background @@ -93,6 +93,20 @@ async def classify( if not client: return True, "" + # Codex keeps its existing bound. Compatible and Ollama judges get + # longer for inference, including the auxiliary wrapper's fallback. + from ..llm.openai_codex import CodexChatClient + + judge_provider = getattr(auxiliary, "provider", None) if auxiliary is not None else None + is_codex = ( + judge_provider == "codex" + if judge_provider is not None + else isinstance(client, CodexChatClient) + ) + classifier_timeout = ( + CLASSIFIER_TIMEOUT_SECONDS if is_codex else COMPAT_CLASSIFIER_TIMEOUT_SECONDS + ) + if "start_loop" in tools_used: log.info( "Completion classifier: start_loop called — loop runs in " @@ -112,7 +126,6 @@ async def classify( messages=[{"role": "user", "content": classifier_user_msg}], system=CLASSIFIER_SYSTEM_PROMPT, task="completion_classifier", - max_tokens=CLASSIFIER_MAX_TOKENS, ) else: request = client.chat( @@ -121,7 +134,11 @@ async def classify( ) raw = await asyncio.wait_for( request, - timeout=min(CLASSIFIER_TIMEOUT_SECONDS, timeout_seconds), + timeout=( + classifier_timeout + if timeout_seconds is None + else min(classifier_timeout, timeout_seconds) + ), ) except Exception as e: log.warning("Completion classifier: error/timeout (%s) — fail-open to COMPLETE", e) @@ -158,7 +175,7 @@ def parse_response(raw: str) -> tuple[bool, str]: return True, "" # Ambiguous / gibberish → fail-open - log.info( + log.warning( "Completion classifier: ambiguous response, treating as COMPLETE (raw: %r)", stripped[:80], ) diff --git a/src/discord/slash_commands.py b/src/discord/slash_commands.py index f45c89fa2..87a543e56 100644 --- a/src/discord/slash_commands.py +++ b/src/discord/slash_commands.py @@ -521,7 +521,18 @@ async def cmd_stop(interaction: discord.Interaction) -> None: "Stop requested, but the in-flight operation could not be " "safely interrupted yet." ) - await interaction.followup.send(result, ephemeral=True) + confirmed = False + else: + # Legacy/non-registry test doubles may still resolve to text; + # only the registry's explicit confirmation suppresses the + # private interaction acknowledgement. + confirmed = bool(getattr(result, "confirmed", False)) + if confirmed: + await interaction.delete_original_response() + else: + # A failed or unconfirmed stop is the public stop result; the + # deferred private acknowledgement must not be duplicated. + await interaction.followup.send(result, ephemeral=False) else: await interaction.response.send_message( "No active task in this channel.", ephemeral=True diff --git a/src/discord/tool_loop.py b/src/discord/tool_loop.py index 6a37caea1..626757166 100644 --- a/src/discord/tool_loop.py +++ b/src/discord/tool_loop.py @@ -2892,6 +2892,7 @@ async def _run_one_tool_captured(self, st: _ChatTurn, block) -> dict: error="computer_not_satisfied" if failed else None, uncertain_outcome=action_receipt.get("status") in {"unknown", "interrupted"}, tool_name=tool_name, + audit_metadata=result.get("__computer_audit_metadata__"), ) try: computer = self._computer_service() diff --git a/src/discord/wiring.py b/src/discord/wiring.py index c2b1f678f..d5d184953 100644 --- a/src/discord/wiring.py +++ b/src/discord/wiring.py @@ -93,6 +93,7 @@ if TYPE_CHECKING: from ..computer.manager import ComputerLifecycle + from ..llm.codex_quota_check import CodexQuotaCheckService @dataclass @@ -117,6 +118,7 @@ class BotServices: tool_executor: ToolExecutor skill_manager: SkillManager codex_client: CodexChatClient | None + codex_quota_check: CodexQuotaCheckService ollama_client: OllamaClient | None kimi_client: OpenAICompatibleClient | None compatible_client: OpenAICompatibleClient | None @@ -329,6 +331,11 @@ def build_services( # Initialize Codex client if configured codex_client: CodexChatClient | None = None + from ..llm.codex_quota_check import CodexQuotaCheckService + + # Bound to the gateway after it is constructed. Do not retain a pool here: + # live disable/re-enable replaces the serving pool and its credentials. + codex_quota_check = CodexQuotaCheckService(lambda: None) if config.openai_codex.enabled: codex_auth = CodexAuthPool(config.openai_codex.credentials_path) if codex_auth.is_configured(): @@ -606,7 +613,9 @@ def _release_loop_calibration(loop_id: str) -> None: scrub_secrets=config.outbound_webhooks.scrub_secrets, rate_limit_seconds=config.outbound_webhooks.rate_limit_seconds, ) - for tgt in getattr(config.outbound_webhooks, "targets", []) or []: + import uuid + + for index, tgt in enumerate(getattr(config.outbound_webhooks, "targets", []) or []): try: outbound_webhook_dispatcher.register( name=tgt.name, @@ -614,6 +623,13 @@ def _release_loop_calibration(loop_id: str) -> None: secret=tgt.secret, events=tgt.events or None, enabled=tgt.enabled, + scrub_secrets=tgt.scrub_secrets, + verify_ssl=tgt.verify_ssl, + webhook_id=tgt.id or uuid.uuid5( + uuid.NAMESPACE_URL, + f"outbound-webhook:{index}:{tgt.url}", + ).hex[:12], + created_at=tgt.created_at, ) except Exception: log.exception("Failed to register outbound webhook target") @@ -665,6 +681,7 @@ def _release_loop_calibration(loop_id: str) -> None: tool_executor=tool_executor, skill_manager=skill_manager, codex_client=codex_client, + codex_quota_check=codex_quota_check, ollama_client=ollama_client, kimi_client=kimi_client, compatible_client=compatible_client, @@ -771,6 +788,11 @@ def build_components(bot, services: BotServices) -> BotComponents: recovery_policy_source=_live_recovery_policy_source(bot), ) + services.codex_quota_check.get_pool = lambda: ( + getattr(llm_gateway.codex_client, "auth", None) + if bot.config.openai_codex.enabled else None + ) + # Dependency-inverted clamp scope: the observer sees only an opaque-key # snapshot supplied by the LIVE Codex client, never the auth pool itself. if services.window_observer is not None: @@ -1153,6 +1175,13 @@ async def shutdown_services(bot) -> None: """ await close_computer_once(bot) + quota_check = getattr(bot, "codex_quota_check", None) + if quota_check is not None: + try: + await quota_check.close() + except Exception: + log.exception("Error stopping Codex quota check") + channel_state = getattr(bot, "channel_state", None) if channel_state is not None: # Close pending steering before disconnecting Discord. Only graceful diff --git a/src/health/server.py b/src/health/server.py index 42fb9fdc5..f72807f82 100644 --- a/src/health/server.py +++ b/src/health/server.py @@ -3,6 +3,7 @@ import asyncio import hashlib import hmac +import ipaddress import json import secrets import time @@ -14,8 +15,7 @@ from aiohttp import web -from ..config.schema import GrafanaAlertConfig, SlackConfig, WebConfig, WebhookConfig -from ..notifications.slack import SlackNotifier +from ..config.schema import GrafanaAlertConfig, WebConfig, WebhookConfig from ..odin_log import get_logger from ..version import get_version from ..web.api_common import contains_redaction_mask @@ -50,6 +50,7 @@ class ListenerSocket(Protocol): def getsockname(self) -> tuple[object, ...]: ... + # --- Route auth policy table --- # Single source of truth for which routes bypass authentication. # PUBLIC_PREFIXES: any path starting with these skips auth entirely. @@ -88,7 +89,6 @@ def getsockname(self) -> tuple[object, ...]: ... "/api/pools", "/api/outbound-webhooks", "/api/grafana-alerts", - "/api/slack", "/api/context", "/api/restart", "/api/turn-state", @@ -174,12 +174,51 @@ def _client_ip(request: web.Request, trusted_proxies: tuple[str, ...] = ()) -> s records the proxy). Only honor X-Forwarded-For when the immediate peer is a configured trusted proxy — otherwise a client could spoof the header.""" peer = request.remote or "unknown" - if trusted_proxies and peer in trusted_proxies: - fwd = request.headers.get("X-Forwarded-For", "") - if fwd: - # Left-most entry is the original client. - return fwd.split(",")[0].strip() or peer - return peer + + def parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + try: + return ipaddress.ip_address(value.strip()) + except ValueError: + return None + + peer_ip = parse_ip(peer) + if peer_ip is None: + return peer + + networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [] + for entry in trusted_proxies: + try: + networks.append(ipaddress.ip_network(entry.strip(), strict=False)) + except (AttributeError, ValueError): + # Invalid configuration must not make a peer trusted. + continue + + def is_trusted(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + return any( + address.version == network.version and address in network for network in networks + ) + + if not is_trusted(peer_ip): + return str(peer_ip) + + forwarded_values = request.headers.getall("X-Forwarded-For", []) + forwarded = ",".join(forwarded_values) + if not forwarded: + return str(peer_ip) + + # Proxies append the peer they received the request from. Walk from the + # nearest hop outward, ignoring trusted proxies; stop at the first client. + current = peer_ip + for value in reversed(forwarded.split(",")): + address = parse_ip(value) + if address is None: + # Never let malformed header text become an audit/rate-limit key, + # nor trust addresses farther left past a broken chain. + return str(current) + current = address + if not is_trusted(address): + return str(address) + return str(current) # Rate-limit: max requests per window per IP on /api/ routes @@ -413,11 +452,7 @@ async def auth_middleware( if is_websocket and "token" in request.query: return await handler(request) - current_web_config = ( - web_config() - if callable(web_config) - else web_config - ) + current_web_config = web_config() if callable(web_config) else web_config configured_token = getattr(current_web_config, "api_token", "") or "" dynamic_recovery = bool( token_snapshot @@ -464,8 +499,10 @@ async def auth_middleware( ) return await handler(request) - identity = None if dynamic_recovery else ( - token_snapshot.resolve(bearer_value) if token_snapshot else None + identity = ( + None + if dynamic_recovery + else (token_snapshot.resolve(bearer_value) if token_snapshot else None) ) if identity is not None: request._session_id = identity.user_id @@ -507,17 +544,13 @@ async def auth_middleware( ) elif source == "dynamic" and not dynamic_recovery: current = ( - token_snapshot.get(user_id) - if token_snapshot and user_id - else None + token_snapshot.get(user_id) if token_snapshot and user_id else None ) elif source is None and not dynamic_recovery: # Compatibility for sessions created before provenance # tracking; never use this path during store recovery. current = ( - token_snapshot.get(user_id) - if token_snapshot and user_id - else None + token_snapshot.get(user_id) if token_snapshot and user_id else None ) if current is None: current = next( @@ -574,11 +607,7 @@ async def admin_middleware( return web.json_response( {"error": "API credential store requires recovery"}, status=403 ) - current_web_config = ( - web_config() - if callable(web_config) - else web_config - ) + current_web_config = web_config() if callable(web_config) else web_config has_any_token = _static_credential_count(current_web_config) or ( _snapshot_dynamic_auth_required(snapshot) ) @@ -807,7 +836,6 @@ def __init__( port: int = 3000, webhook_config: WebhookConfig | None = None, web_config: WebConfig | None = None, - slack_config: SlackConfig | None = None, grafana_alert_config: GrafanaAlertConfig | None = None, initialization_store=None, ) -> None: @@ -815,12 +843,10 @@ def __init__( self._ready = False self._webhook_config = webhook_config or WebhookConfig() self._web_config = web_config or WebConfig() - self._slack_config = slack_config or SlackConfig() self._grafana_alert_config = grafana_alert_config or GrafanaAlertConfig() self._send_message: SendMessageCallback | None = None self._trigger_callback: TriggerCallback | None = None self._loop_spawn_callback: Callable | None = None - self._slack_notifier: SlackNotifier | None = None self._start_time = time.monotonic() self._components: dict[str, ComponentCheck] = {} self._initialization_store = initialization_store @@ -856,15 +882,6 @@ def __init__( max_concurrent=self._grafana_alert_config.max_concurrent_remediations, ) - if self._slack_config.enabled: - self._slack_notifier = SlackNotifier( - webhook_urls=self._slack_config.webhook_urls, - default_webhook_url=self._slack_config.default_webhook_url, - scrub_secrets=self._slack_config.scrub_secrets, - rate_limit_seconds=self._slack_config.rate_limit_seconds, - ) - log.info("Slack notifier enabled") - # Session management self._session_manager = SessionManager( timeout_minutes=self._web_config.session_timeout_minutes, @@ -944,10 +961,6 @@ def register_component(self, name: str, check: ComponentCheck) -> None: # Keep metrics collector aware of component checks self._metrics_collector.set_component_check(self._check_components) - @property - def slack_notifier(self) -> SlackNotifier | None: - return self._slack_notifier - @property def grafana_handler(self) -> GrafanaAlertHandler: return self._grafana_handler @@ -969,9 +982,9 @@ def set_loop_spawn_callback(self, callback: Callable) -> None: def set_bot(self, bot: OdinBot) -> None: """Wire the bot instance to enable the REST API and WebSocket endpoints.""" # Backlink first, and before the enabled check: the bot-facing admin - # routes reach the Slack notifier and Grafana handler through + # routes reach the Grafana handler through # ``bot.health_server``, and nothing ever assigned it — so - # /api/slack/status and /api/grafana-alerts/status reported + # /api/grafana-alerts/status reported # ``enabled: false`` on a working install while every mutating route # 503'd. Doing it here rather than at the __main__ call site covers # every construction path, including tests and future entry points. @@ -1229,25 +1242,17 @@ async def stop(self) -> None: await task except asyncio.CancelledError: pass - # Quiesce the HTTP server first and independently — a notifier - # close failure must never leave the runner (and its open - # handlers) alive past the stop window. - try: - if self._runner: - # Both shutdown_services (via the bot backlink) and __main__ - # hold a reference now, so stop() can be called twice. The - # runner is dropped only AFTER cleanup succeeds: clearing it - # first would make a second call a no-op that silently - # abandons unfinished cleanup, so a raised cleanup could never - # be retried by the later caller. - await self._runner.cleanup() - self._runner = None - finally: - if self._slack_notifier: - try: - await self._slack_notifier.close() - except Exception: - log.exception("Slack notifier close failed during shutdown") + # Quiesce the HTTP server first. A cleanup failure must not leave + # the runner (and its open handlers) alive past the stop window. + if self._runner: + # Both shutdown_services (via the bot backlink) and __main__ + # hold a reference now, so stop() can be called twice. The + # runner is dropped only AFTER cleanup succeeds: clearing it + # first would make a second call a no-op that silently + # abandons unfinished cleanup, so a raised cleanup could never + # be retried by the later caller. + await self._runner.cleanup() + self._runner = None async def _health(self, request: web.Request) -> web.Response: """Combined health endpoint. @@ -1434,18 +1439,6 @@ async def _send(self, source: str, text: str) -> web.Response: log.error("Webhook %s delivery failed: %s", source, e) return web.json_response({"error": str(e)}, status=500) - if self._slack_notifier and self._slack_config.forward_webhooks: - try: - await self._slack_notifier.send_formatted( - title=f"Webhook: {source}", - message=text, - severity="info", - source=source, - channel=source, - ) - except Exception as exc: - log.warning("Slack forward for webhook %s failed: %s", source, exc) - return web.json_response({"status": "delivered"}) async def _webhook_gitea(self, request: web.Request) -> web.Response: diff --git a/src/knowledge/importer.py b/src/knowledge/importer.py index 8dd1ca8f4..ebb1979d2 100644 --- a/src/knowledge/importer.py +++ b/src/knowledge/importer.py @@ -99,6 +99,10 @@ def _canonical_file_source(path: Path) -> str: """Return a base-independent identity for a resolved local file.""" return path.as_uri() + def _limit_import_content(self, content: str, limit: int) -> str: + """Enforce the input-size cap without charging for chunk overlap.""" + return content[:limit] + @staticmethod def _legacy_source_matches_path(source: str, path: Path) -> bool: """Whether *source* could be an old base-relative name for *path*.""" @@ -443,8 +447,7 @@ async def import_pdf_url( content = "\n\n".join(parts) if not content.strip(): return ImportResult(source=src, status="skipped", error="PDF contains no text") - if len(content) > PDF_MAX_CHARS: - content = content[:PDF_MAX_CHARS] + content = self._limit_import_content(content, PDF_MAX_CHARS) return self._classify_ingest( src, await self._store.ingest( @@ -501,8 +504,7 @@ async def import_web_url( if not content.strip(): return ImportResult(source=src, status="skipped", error="page has no content") - if len(content) > FETCH_MAX_CHARS: - content = content[:FETCH_MAX_CHARS] + content = self._limit_import_content(content, FETCH_MAX_CHARS) try: return self._classify_ingest( diff --git a/src/knowledge/store.py b/src/knowledge/store.py index a1f5c180c..1329063ad 100644 --- a/src/knowledge/store.py +++ b/src/knowledge/store.py @@ -3,6 +3,7 @@ Uses SQLite + sqlite-vec for vector storage and FTS5 for keyword search. Documents are chunked into ~500-token segments with overlap for better retrieval. """ + from __future__ import annotations import asyncio @@ -60,7 +61,10 @@ class IngestOutcome(int): duplicate_of: str def __new__( - cls, chunks: int, status: IngestStatus = "failure", duplicate_of: str = "", + cls, + chunks: int, + status: IngestStatus = "failure", + duplicate_of: str = "", ) -> IngestOutcome: outcome = super().__new__(cls, chunks) outcome.status = status @@ -83,7 +87,6 @@ class KnowledgeStore: invariant where mypy cannot see across the caller boundary. """ - def __init__(self, db_path: str, fts_index: FullTextIndex | None = None) -> None: self._conn: sqlite3.Connection | None = None self._has_vec = False @@ -131,9 +134,7 @@ def __init__(self, db_path: str, fts_index: FullTextIndex | None = None) -> None ("doc_content_hash", "TEXT"), ): try: - conn.execute( - f"ALTER TABLE knowledge_chunks ADD COLUMN {col} {typedef}" - ) + conn.execute(f"ALTER TABLE knowledge_chunks ADD COLUMN {col} {typedef}") except sqlite3.OperationalError: pass # column already exists conn.execute( @@ -241,58 +242,18 @@ async def ingest( doc_content_hash = self._content_hash(content) now = datetime.now().isoformat() + # Exact repeats should not pay the embedding cost. Recheck under the + # lock after embedding as well, since another ingest may race this one. if dedup: - # --- exact duplicate check (full document) --- - existing = await asyncio.to_thread( - self._find_by_doc_hash, doc_content_hash - ) - if existing: - existing_source = existing[0] - if existing_source == source and await asyncio.to_thread( - self.source_is_durable, source, existing[1] - ): - log.info( - "Skipping ingest of '%s': content unchanged (hash=%s)", - source, doc_content_hash[:12], - ) - # Existing durable chunk count — an unchanged document, not - # a durability failure (gist M7 / operator ruling). - return IngestOutcome(existing[1], INGEST_UNCHANGED, source) - if existing_source != source and await asyncio.to_thread( - self.source_is_durable, existing_source, existing[1] - ): - log.info( - "Skipping ingest of '%s': identical content already " - "ingested as '%s' (hash=%s)", - source, existing_source, doc_content_hash[:12], - ) - return IngestOutcome(0, INGEST_DUPLICATE, existing_source) - log.warning( - "Ignoring non-durable duplicate source '%s' while ingesting '%s'", - existing_source, - source, - ) - - # --- near-duplicate check (chunk-level overlap) --- - chunk_hashes = [self._content_hash(c) for c in chunks] - near_dup = await asyncio.to_thread( - self._find_near_duplicate, chunk_hashes, source - ) - if near_dup: - if await asyncio.to_thread(self.source_is_durable, near_dup[0]): - log.warning( - "Skipping ingest of '%s': %.0f%% chunk overlap with " - "existing source '%s'", - source, near_dup[1] * 100, near_dup[0], - ) - return IngestOutcome(0, INGEST_CONFLICT, near_dup[0]) - log.warning( - "Ignoring non-durable near-duplicate source '%s' while ingesting '%s'", - near_dup[0], - source, + async with self._write_lock: + outcome = await self._dedup_outcome( + source, chunks, doc_content_hash, log_non_durable=True ) + if outcome is not None: + return outcome - # Embed all chunks first (async, non-blocking — no DB state touched). + # Chunking and embedding can run without the write lock. Dedup cannot: + # it must observe the state admitted immediately before this write. vectors: list[list[float] | None] = [] for chunk in chunks: if self._has_vec and embedder: @@ -303,35 +264,141 @@ async def ingest( else: vectors.append(None) - # Serialize ALL writes (delete + insert + version record) behind - # the async write lock. Pre-PR #18 this section raced itself under - # concurrent ingest and produced silent SQLite misuse errors. async with self._write_lock: - # Capture the version base inside the same write admission that - # installs the replacement. Existing rows remain searchable until - # the complete replacement has been verified in DB and FTS. - old_content = await asyncio.to_thread(self.get_source_content, source) - is_update = old_content is not None - indexed = await asyncio.to_thread( - self._write_chunks_sync, chunks, vectors, doc_hash_id, source, - now, uploader, doc_content_hash, + return await self._ingest_locked( + content, + source, + chunks, + vectors, + doc_hash_id, + doc_content_hash, + now, + uploader, + dedup, + dedup_warnings=False, ) - # A version record is a success claim. Write it only after the - # complete source has been verified in every configured store. - if indexed == len(chunks): - action = "update" if is_update else "create" - diff_summary = self._make_diff_summary(old_content, content) - await asyncio.to_thread( - self._record_version, source, doc_content_hash, content, - indexed, uploader, action, diff_summary, - ) + async def _ingest_locked( + self, + content: str, + source: str, + chunks: list[str], + vectors: list[list[float] | None], + doc_hash_id: str, + doc_content_hash: str, + now: str, + uploader: str, + dedup: bool, + dedup_warnings: bool = True, + ) -> IngestOutcome: + """Recheck duplicates and install a document under write admission.""" + if dedup: + outcome = await self._dedup_outcome( + source, chunks, doc_content_hash, log_non_durable=dedup_warnings + ) + if outcome is not None: + return outcome + + # Existing rows remain searchable until replacement is verified. + old_content = await asyncio.to_thread(self.get_source_content, source) + is_update = old_content is not None + indexed = await asyncio.to_thread( + self._write_chunks_sync, + chunks, + vectors, + doc_hash_id, + source, + now, + uploader, + doc_content_hash, + ) + + # A version record is a success claim, not a pre-write intention. + if indexed == len(chunks): + action = "update" if is_update else "create" + diff_summary = self._make_diff_summary(old_content, content) + await asyncio.to_thread( + self._record_version, + source, + doc_content_hash, + content, + indexed, + uploader, + action, + diff_summary, + ) log.info("Ingested '%s': %d/%d chunks indexed", source, indexed, len(chunks)) if indexed == len(chunks): return IngestOutcome(indexed, INGEST_STORED) return IngestOutcome(indexed, INGEST_FAILURE) + async def _dedup_outcome( + self, + source: str, + chunks: list[str], + doc_content_hash: str, + *, + log_non_durable: bool = True, + ) -> IngestOutcome | None: + """Check exact and near duplicates while the caller holds the lock.""" + existing = await asyncio.to_thread(self._find_by_doc_hash, doc_content_hash) + if existing: + existing_source, count = existing + if existing_source == source and await asyncio.to_thread( + self.source_is_durable, + source, + count, + ): + log.info( + "Skipping ingest of '%s': content unchanged (hash=%s)", + source, + doc_content_hash[:12], + ) + return IngestOutcome(count, INGEST_UNCHANGED, source) + if existing_source != source and await asyncio.to_thread( + self.source_is_durable, + existing_source, + count, + ): + log.info( + "Skipping ingest of '%s': identical content already ingested as '%s'", + source, + existing_source, + ) + return IngestOutcome(0, INGEST_DUPLICATE, existing_source) + if existing_source != source: + if log_non_durable: + log.warning( + "Ignoring non-durable duplicate source '%s' while ingesting '%s'", + existing_source, + source, + ) + elif log_non_durable: + log.warning( + "Ignoring non-durable duplicate source '%s' while re-ingesting it", + existing_source, + ) + + hashes = [self._content_hash(chunk) for chunk in chunks] + near_dup = await asyncio.to_thread(self._find_near_duplicate, hashes, source) + if near_dup: + if await asyncio.to_thread(self.source_is_durable, near_dup[0]): + log.warning( + "Skipping ingest of '%s': %.0f%% chunk overlap with existing source '%s'", + source, + near_dup[1] * 100, + near_dup[0], + ) + return IngestOutcome(0, INGEST_CONFLICT, near_dup[0]) + if log_non_durable: + log.warning( + "Ignoring non-durable near-duplicate source '%s' while ingesting '%s'", + near_dup[0], + source, + ) + return None + def _write_chunks_sync( self, chunks: list[str], @@ -348,13 +415,45 @@ def _write_chunks_sync( old_ids = { str(row[0]) for row in conn.execute( - "SELECT chunk_id FROM knowledge_chunks WHERE source = ?", (source,), + "SELECT chunk_id FROM knowledge_chunks WHERE source = ?", + (source,), ).fetchall() } desired_rows: list[tuple[str, str, int]] = [] all_writes_ok = True for i, chunk in enumerate(chunks): - chunk_id = f"{doc_hash}_{i}_{doc_content_hash[:12]}" + # Legacy IDs are retained wherever they belong to this source. + # A short source-hash collision must never REPLACE another owner's + # DB, FTS or vector row, even for restore and dedup=False imports. + base_id = f"{doc_hash}_{i}_{doc_content_hash[:12]}" + chunk_id = base_id + suffix = hashlib.sha256(source.encode()).hexdigest() + fallback_id = f"{base_id}_{suffix}" + # Once a source has a fallback ID, keep that identity even if + # another source later releases the original short ID. + prior = conn.execute( + "SELECT source FROM knowledge_chunks WHERE chunk_id = ?", + (fallback_id,), + ).fetchone() + if prior is not None and prior[0] == source: + chunk_id = fallback_id + owner = conn.execute( + "SELECT source FROM knowledge_chunks WHERE chunk_id = ?", + (chunk_id,), + ).fetchone() + if owner is not None and owner[0] != source: + chunk_id = fallback_id + counter = 0 + while True: + owner = conn.execute( + "SELECT source FROM knowledge_chunks WHERE chunk_id = ?", + (chunk_id,), + ).fetchone() + if owner is None or owner[0] == source: + break + counter += 1 + chunk_id = f"{base_id}_{suffix}_{counter}" + log.warning("Chunk ID collision for '%s', using source-specific ID", source) desired_rows.append((chunk_id, chunk, i)) chunk_hash = self._content_hash(chunk) try: @@ -363,18 +462,31 @@ def _write_chunks_sync( "(chunk_id, content, source, chunk_index, total_chunks, " "uploader, ingested_at, content_hash, doc_content_hash) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - (chunk_id, chunk, source, i, len(chunks), uploader, now, - chunk_hash, doc_content_hash), + ( + chunk_id, + chunk, + source, + i, + len(chunks), + uploader, + now, + chunk_hash, + doc_content_hash, + ), ) if self._fts and not self._fts.index_knowledge_chunk( - chunk_id, chunk, source, i, + chunk_id, + chunk, + source, + i, ): all_writes_ok = False log.error("Failed to index chunk %d of '%s' in FTS", i, source) if vectors[i] is not None: vec_bytes = serialize_vector(vectors[i]) # type: ignore[arg-type] conn.execute( - "DELETE FROM knowledge_vec WHERE chunk_id = ?", (chunk_id,), + "DELETE FROM knowledge_vec WHERE chunk_id = ?", + (chunk_id,), ) conn.execute( "INSERT INTO knowledge_vec (chunk_id, embedding) VALUES (?, ?)", @@ -403,14 +515,12 @@ def _write_chunks_sync( obsolete_fts_ids: set[str] = set() if self._fts: obsolete_fts_ids = { - chunk_id for chunk_id in obsolete_ids - if self._fts.has_knowledge_chunk(chunk_id) + chunk_id for chunk_id in obsolete_ids if self._fts.has_knowledge_chunk(chunk_id) } if obsolete_fts_ids: removed_fts = self._fts.delete_knowledge_chunks(obsolete_fts_ids) if removed_fts != len(obsolete_fts_ids) or any( - self._fts.has_knowledge_chunk(chunk_id) - for chunk_id in obsolete_fts_ids + self._fts.has_knowledge_chunk(chunk_id) for chunk_id in obsolete_fts_ids ): log.error("Failed to retire old FTS rows for '%s'", source) return 0 @@ -440,9 +550,7 @@ def _write_chunks_sync( return 0 if self._has_vec: expected_vector_ids = { - desired_rows[i][0] - for i, vector in enumerate(vectors) - if vector is not None + desired_rows[i][0] for i, vector in enumerate(vectors) if vector is not None } vector_ids = { str(row[0]) @@ -468,8 +576,7 @@ def _source_contains_rows( return False try: db_rows = self._conn.execute( # type: ignore[union-attr] - "SELECT chunk_id, content, chunk_index FROM knowledge_chunks " - "WHERE source = ?", + "SELECT chunk_id, content, chunk_index FROM knowledge_chunks WHERE source = ?", (source,), ).fetchall() expected = set(expected_rows) @@ -519,13 +626,15 @@ async def search( # Cosine distance: 0 = identical, higher = more different. Skip poor matches. if distance > 0.8: continue - out.append({ - "chunk_id": str(row[0]), - "content": row[2], - "source": row[3], - "score": round(1 - distance, 3), # Convert to similarity - "chunk_index": row[4], - }) + out.append( + { + "chunk_id": str(row[0]), + "content": row[2], + "source": row[3], + "score": round(1 - distance, 3), # Convert to similarity + "chunk_index": row[4], + } + ) return out[:limit] @@ -730,14 +839,15 @@ def delete_source(self, source: str, *, _record_version: bool = True) -> int: return 0 if self._fts is not None: return self.delete_source_confirmed( - source, _record_version=_record_version, + source, + _record_version=_record_version, ) try: # Get chunk IDs for this source ids = [ - r[0] for r in - self._conn.execute( # type: ignore[union-attr] + r[0] + for r in self._conn.execute( # type: ignore[union-attr] "SELECT chunk_id FROM knowledge_chunks WHERE source = ?", (source,) ).fetchall() ] @@ -761,14 +871,21 @@ def delete_source(self, source: str, *, _record_version: bool = True) -> int: ) self._conn.commit() # type: ignore[union-attr] db_remains = self._conn.execute( # type: ignore[union-attr] - "SELECT 1 FROM knowledge_chunks WHERE source = ? LIMIT 1", (source,), + "SELECT 1 FROM knowledge_chunks WHERE source = ? LIMIT 1", + (source,), ).fetchone() if db_remains: log.error("Delete failed durable DB verification for '%s'", source) return 0 if _record_version: self._record_version( - source, content_hash, None, 0, "system", "delete", "deleted", + source, + content_hash, + None, + 0, + "system", + "delete", + "deleted", ) log.info("Deleted %d chunks for source '%s'", len(ids), source) return len(ids) @@ -845,12 +962,23 @@ def delete_source_confirmed( # refusing the migration. restored = True for chunk_id, content, chunk_index in rows: - restored = self._fts.index_knowledge_chunk( - chunk_id, content, source, int(chunk_index), - ) and restored - restored = self.source_is_durable( - source, expected_chunks=len(rows), require_fts=True, - ) and restored + restored = ( + self._fts.index_knowledge_chunk( + chunk_id, + content, + source, + int(chunk_index), + ) + and restored + ) + restored = ( + self.source_is_durable( + source, + expected_chunks=len(rows), + require_fts=True, + ) + and restored + ) log.error( "Confirmed delete refused for '%s': FTS removal was not durable; " "restore_verified=%s", @@ -862,17 +990,17 @@ def delete_source_confirmed( if self._has_vec: for chunk_id, _content, _index in rows: conn.execute( - "DELETE FROM knowledge_vec WHERE chunk_id = ?", (chunk_id,), + "DELETE FROM knowledge_vec WHERE chunk_id = ?", + (chunk_id,), ) conn.execute("DELETE FROM knowledge_chunks WHERE source = ?", (source,)) conn.commit() db_remains = conn.execute( - "SELECT 1 FROM knowledge_chunks WHERE source = ? LIMIT 1", (source,), + "SELECT 1 FROM knowledge_chunks WHERE source = ? LIMIT 1", + (source,), ).fetchone() - fts_remains = bool( - self._fts and self._fts.has_knowledge_source(source) - ) + fts_remains = bool(self._fts and self._fts.has_knowledge_source(source)) if db_remains or fts_remains: log.error( "Confirmed delete failed for '%s': db_remains=%s fts_remains=%s", @@ -883,7 +1011,13 @@ def delete_source_confirmed( return 0 if _record_version: self._record_version( - source, content_hash, None, 0, "system", "delete", "deleted", + source, + content_hash, + None, + 0, + "system", + "delete", + "deleted", ) log.info("Confirmed deletion of %d chunks for source '%s'", len(rows), source) return len(rows) @@ -896,22 +1030,32 @@ def delete_source_confirmed( # its searchable rows from the still-authoritative DB snapshot. db_remains = False try: - db_remains = conn.execute( - "SELECT 1 FROM knowledge_chunks WHERE source = ? LIMIT 1", (source,), - ).fetchone() is not None + db_remains = ( + conn.execute( + "SELECT 1 FROM knowledge_chunks WHERE source = ? LIMIT 1", + (source,), + ).fetchone() + is not None + ) except Exception: pass - fts_missing = bool( - self._fts and not self._fts.has_knowledge_source(source) - ) + fts_missing = bool(self._fts and not self._fts.has_knowledge_source(source)) if db_remains and fts_missing and self._fts: restored = True for chunk_id, content, chunk_index in rows: - restored = self._fts.index_knowledge_chunk( - chunk_id, content, source, int(chunk_index), - ) and restored + restored = ( + self._fts.index_knowledge_chunk( + chunk_id, + content, + source, + int(chunk_index), + ) + and restored + ) if not restored or not self.source_is_durable( - source, expected_chunks=len(rows), require_fts=True, + source, + expected_chunks=len(rows), + require_fts=True, ): log.error("Failed to restore FTS rows for '%s'", source) log.error("Confirmed delete failed for source '%s': %s", source, exc) @@ -955,7 +1099,10 @@ async def merge_sources_async(self, keep_source: str, remove_source: str) -> int return await asyncio.to_thread(self.merge_sources, keep_source, remove_source) async def search_hybrid( - self, query: str, embedder: LocalEmbedder | None = None, limit: int = 5, + self, + query: str, + embedder: LocalEmbedder | None = None, + limit: int = 5, ) -> list[dict]: """Combined FTS5 + semantic search with Reciprocal Rank Fusion. @@ -973,7 +1120,9 @@ async def search_hybrid( fts_results = [] if self._fts: fts_results = await asyncio.to_thread( - self._fts.search_knowledge, query, limit * 2, + self._fts.search_knowledge, + query, + limit * 2, ) searched = True @@ -983,7 +1132,10 @@ async def search_hybrid( return [] return reciprocal_rank_fusion( - semantic_results, fts_results, id_key="chunk_id", limit=limit, + semantic_results, + fts_results, + id_key="chunk_id", + limit=limit, ) def backfill_fts(self) -> int: @@ -1004,7 +1156,10 @@ def backfill_fts(self) -> int: continue if content: indexed = self._fts.index_knowledge_chunk( - chunk_id, content, source, chunk_index, + chunk_id, + content, + source, + chunk_index, ) if indexed and self._fts.has_knowledge_chunk(chunk_id): count += 1 @@ -1078,7 +1233,8 @@ def find_duplicates(self) -> list[dict]: return [] def find_near_duplicates( - self, threshold: float = 0.5, + self, + threshold: float = 0.5, ) -> list[dict]: """Find source pairs sharing more than *threshold* of chunk hashes. @@ -1115,7 +1271,7 @@ def find_near_duplicates( set_a = hash_sets.get(src_a) if not set_a: continue - for src_b in source_list[i + 1:]: + for src_b in source_list[i + 1 :]: if (src_a, src_b) in seen: continue seen.add((src_a, src_b)) @@ -1128,14 +1284,16 @@ def find_near_duplicates( min_len = min(len(set_a), len(set_b)) ratio = shared / min_len if min_len else 0 if ratio >= threshold: - results.append({ - "source_a": src_a, - "source_b": src_b, - "shared_chunks": shared, - "total_a": len(set_a), - "total_b": len(set_b), - "overlap_ratio": round(ratio, 3), - }) + results.append( + { + "source_a": src_a, + "source_b": src_b, + "shared_chunks": shared, + "total_a": len(set_a), + "total_b": len(set_b), + "overlap_ratio": round(ratio, 3), + } + ) return results def merge_sources(self, keep_source: str, remove_source: str) -> int: @@ -1152,7 +1310,8 @@ def merge_sources(self, keep_source: str, remove_source: str) -> int: if not keep_exists: return 0 if not self.source_is_durable( - keep_source, require_fts=self._fts is not None, + keep_source, + require_fts=self._fts is not None, ): log.error( "Merge refused: survivor '%s' is not durable in DB and FTS", @@ -1194,8 +1353,17 @@ def _record_version( "(source, version, content_hash, content, chunk_count, " "uploader, action, created_at, diff_summary) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - (source, version, content_hash, content, chunk_count, - uploader, action, now, diff_summary), + ( + source, + version, + content_hash, + content, + chunk_count, + uploader, + action, + now, + diff_summary, + ), ) self._conn.commit() return version @@ -1287,10 +1455,14 @@ def get_version_diff(self, source: str, v1: int, v2: int) -> dict | None: new_content = ver2.get("content") or "" old_lines = old_content.splitlines(keepends=True) new_lines = new_content.splitlines(keepends=True) - diff_lines = list(difflib.unified_diff( - old_lines, new_lines, - fromfile=f"v{v1}", tofile=f"v{v2}", - )) + diff_lines = list( + difflib.unified_diff( + old_lines, + new_lines, + fromfile=f"v{v1}", + tofile=f"v{v2}", + ) + ) added = sum(1 for ln in diff_lines if ln.startswith("+") and not ln.startswith("+++")) removed = sum(1 for ln in diff_lines if ln.startswith("-") and not ln.startswith("---")) return { @@ -1318,8 +1490,11 @@ async def restore_version( if not ver or not ver.get("content"): return 0 return await self.ingest( - ver["content"], source, embedder=embedder, - uploader=f"restore-v{version}", dedup=False, + ver["content"], + source, + embedder=embedder, + uploader=f"restore-v{version}", + dedup=False, ) @staticmethod @@ -1342,21 +1517,49 @@ def _chunk_text(text: str) -> list[str]: if len(current_chunk) + len(para) + 2 <= CHUNK_SIZE: current_chunk = f"{current_chunk}\n\n{para}" if current_chunk else para else: - if current_chunk: + if current_chunk.strip(): chunks.append(current_chunk.strip()) + current_chunk = "" # If a single paragraph is longer than chunk size, split it if len(para) > CHUNK_SIZE: words = para.split() current_chunk = "" for word in words: + if len(word) > CHUNK_SIZE: + if current_chunk.strip(): + chunks.append(current_chunk.strip()) + current_chunk = "" + # Hard-split words without overlap. Repeated long + # base64/minified runs must not inflate reconstructed + # source content past importer size limits. + start = 0 + while start < len(word): + piece = word[start : start + CHUNK_SIZE] + if start + CHUNK_SIZE >= len(word): + current_chunk = piece + break + chunks.append(piece) + start += CHUNK_SIZE + continue if len(current_chunk) + len(word) + 1 <= CHUNK_SIZE: current_chunk = f"{current_chunk} {word}" if current_chunk else word else: - chunks.append(current_chunk.strip()) + if current_chunk.strip(): + chunks.append(current_chunk.strip()) # Overlap: keep last portion - overlap_start = max(0, len(current_chunk) - CHUNK_OVERLAP) - current_chunk = current_chunk[overlap_start:] + " " + word + overlap_size = max( + 0, + min( + len(current_chunk), + CHUNK_OVERLAP, + CHUNK_SIZE - len(word) - 1, + ), + ) + overlap = current_chunk[-overlap_size:] if overlap_size else "" + current_chunk = f"{overlap} {word}" if overlap else word else: + # Keep the remaining paragraph even when it is whitespace; + # the next iteration decides whether it can be combined. current_chunk = para if current_chunk.strip(): diff --git a/src/llm/codex_auth.py b/src/llm/codex_auth.py index 11211bc0a..62f363989 100644 --- a/src/llm/codex_auth.py +++ b/src/llm/codex_auth.py @@ -146,7 +146,7 @@ async def get_access_token(self) -> str: creds = self._load() if time.time() >= creds.get("expires_at", 0) - REFRESH_MARGIN: log.info("Access token expired or expiring soon, refreshing...") - await self._refresh(creds) + await self._refresh_shielded(creds) # _load()/_refresh() above always leave _credentials set; # mypy only sees the Optional attribute declaration. creds = self._credentials # type: ignore[assignment] @@ -192,7 +192,7 @@ async def force_refresh(self, stale_token: str | None = None) -> bool: if stale_token and creds.get("access_token") != stale_token: return True try: - await self._refresh(creds) + await self._refresh_shielded(creds) return True except Exception as e: from ..observability.diagnostics import safe_error @@ -263,13 +263,30 @@ async def _refresh(self, creds: dict) -> None: self._save(new_creds) log.info("Codex tokens refreshed successfully") + async def _refresh_shielded(self, creds: dict) -> None: + """Persist a completed single-use refresh even during shutdown.""" + task = asyncio.create_task(self._refresh(creds)) + try: + await asyncio.shield(task) + except asyncio.CancelledError: + try: + await task + finally: + raise + def mark_rate_limited(self, seconds: float = 60) -> None: """Mark this credential set as unavailable for ``seconds`` (default 60s).""" + self._rate_limit_marked_at = time.time() self._rate_limited_until = time.time() + seconds def is_rate_limited(self) -> bool: return time.time() < getattr(self, "_rate_limited_until", 0) + def clear_rate_limit(self) -> None: + """Clear a stale local bench after operator activation or fresh quota data.""" + self._rate_limited_until = 0.0 + self._rate_limit_marked_at = 0.0 + @staticmethod def build_auth_url() -> tuple[str, str]: """Build the authorization URL and return (url, code_verifier).""" @@ -424,6 +441,8 @@ def __init__(self, credentials_path: str) -> None: # Pool-owned so the primary and auxiliary clients, which share this # pool by identity, contribute to ONE account-scoped quota view. self.quota = CodexQuotaTracker() + self._quota_check_failures: dict[str, str] = {} + self._manual_active_index: int | None = None self._init_accounts() def _init_accounts(self) -> None: @@ -565,13 +584,106 @@ def quota_view(self): known = [row["key"] for row in rows if row["key"]] current = next((row["key"] for row in rows if row["is_current"]), None) self.quota.forget_missing(known) + failures = getattr(self, "_quota_check_failures", None) + if failures is not None: + for key in list(failures): + if key not in known: + del failures[key] return self.quota.view(current_key=current, known_keys=known) + def _quota_key(self, index: int) -> str | None: + from .account_key import opaque_account_key + + if index < 0 or index >= len(self._accounts): + return None + try: + return opaque_account_key(self._accounts[index].get_account_id()) + except Exception: + # One unreadable account slot must not make the entire pool fail. + return None + + def quota_check_failure(self, index: int) -> str | None: + key = self._quota_key(index) + return getattr(self, "_quota_check_failures", {}).get(key) if key else None + + def set_quota_check_failure(self, index: int, reason: str | None) -> None: + key = self._quota_key(index) + if key is None: + return + failures = getattr(self, "_quota_check_failures", None) + if failures is None: + failures = self._quota_check_failures = {} + if reason is None: + failures.pop(key, None) + else: + # Only operator-safe categories belong here; never exception text. + failures[key] = reason[:80] + + def _quota_reset(self, index: int, *, now: float | None = None) -> float | None: + """Known exhaustion lasts only until a reported future reset.""" + import time + + key = self._quota_key(index) + tracker = getattr(self, "quota", None) + snapshot = tracker.snapshot_for(key) if tracker is not None and key else None + if snapshot is None: + return None + # A fresh successful quota observation showing room supersedes a + # local 429 backoff left from an older response. + reported = [ + window for window in (snapshot.primary, snapshot.secondary) + if window is not None and window.window_minutes is not None + and window.window_minutes > 0 + ] + if reported and all(window.used_percent < 100 for window in reported): + auth = self._accounts[index] + marked_at = getattr(auth, "_rate_limit_marked_at", None) + if not isinstance(marked_at, (int, float)): + marked_at = None + clear_bench = getattr(auth, "clear_rate_limit", None) + # Older quota responses can finish after a newer 429. + if clear_bench is not None and ( + marked_at is None or snapshot.observed_at > marked_at + ): + clear_bench() + now = time.time() if now is None else now + windows = { + "primary": snapshot.primary, + "secondary": snapshot.secondary, + } + exhausted = [ + window.resets_at + for window in windows.values() + if window is not None + and window.window_minutes is not None + and window.window_minutes > 0 + and window.used_percent >= 100 + and window.resets_at is not None + and window.resets_at > now + ] + # The upstream type identifies which window reached its limit. Do not + # infer that an unrelated, merely reported window is also exhausted. + limit_type = (snapshot.limit_reached_type or "").strip().lower() + if limit_type in {"primary", "primary_window", "primary-limit", "primary_limit"}: + limit_type = "primary" + elif limit_type in {"secondary", "secondary_window", "secondary-limit", "secondary_limit"}: + limit_type = "secondary" + limited_window = windows.get(limit_type) + if (limited_window is not None and limited_window.window_minutes is not None + and limited_window.window_minutes > 0 and limited_window.resets_at is not None + and limited_window.resets_at > now): + exhausted.append(limited_window.resets_at) + return max(exhausted) if exhausted else None + def eligible_account_ids_snapshot(self) -> frozenset[str]: """Stable non-secret IDs for accounts eligible to serve right now.""" result: set[str] = set() - for auth in self._accounts: - if auth.is_rate_limited() or not auth.is_configured(): + for index, auth in enumerate(self._accounts): + if not auth.is_configured(): + continue + if self._quota_reset(index) is not None: + continue + if auth.is_rate_limited(): continue account_id = auth.get_account_id() if isinstance(account_id, str) and account_id: @@ -598,6 +710,47 @@ async def acquire(self) -> tuple[str, str | None, int]: if not self._accounts: raise LLMAuthError("No Codex credentials configured.", provider="codex") errors: list[tuple[int, str]] = [] + # A WebUI activation is an explicit operator override. Honour it until + # that account itself receives an actual 429; passive quota snapshots + # must not immediately undo the operator's choice. + manual_index = getattr(self, "_manual_active_index", None) + if manual_index is not None: + if manual_index < len(self._accounts): + auth = self._accounts[manual_index] + self._current_index = manual_index + try: + token = await auth.get_access_token() + except Exception as e: + errors.append((manual_index, str(e))) + self._manual_active_index = None + else: + return token, auth.get_account_id(), manual_index + else: + self._manual_active_index = None + + # If every slot is benched/exhausted, do not reject locally. Stored + # quota can be stale and the upstream service is the authority. Pick + # the account expected to recover first, falling back to the first + # account if no reset is known. + available = [] + for i, auth in enumerate(self._accounts): + if not auth.is_configured(): + continue + quota_reset = self._quota_reset(i) + if not auth.is_rate_limited() and quota_reset is None: + available.append(i) + if not available: + import time + + now = time.time() + def retry_at(index: int) -> float: + auth = self._accounts[index] + quota_reset = self._quota_reset(index) + local_reset = getattr(auth, "_rate_limited_until", 0.0) + return min((v for v in (quota_reset, local_reset) if v and v > now), default=now) + + if self._accounts: + self._current_index = min(range(len(self._accounts)), key=retry_at) for _ in range(len(self._accounts)): async with self._pool_lock: if not self._accounts: @@ -605,7 +758,9 @@ async def acquire(self) -> tuple[str, str | None, int]: self._current_index %= len(self._accounts) idx = self._current_index auth = self._accounts[idx] - if auth.is_rate_limited(): + quota_reset = self._quota_reset(idx) + locally_limited = quota_reset is not None or auth.is_rate_limited() + if locally_limited and available: self._rotate() continue try: @@ -625,23 +780,28 @@ async def acquire(self) -> tuple[str, str | None, int]: self._rotate() continue return token, auth.get_account_id(), idx - # Pool exhaustion is part of the typed taxonomy (PR #242 review - # blocker #7): every rotation avenue is spent by the time these - # raise, so the shared recovery must FAST-FAIL them — never treat - # them as unclassified defects, and the subsystem guard must not - # count quota exhaustion as generic subsystem failure. Both types - # subclass RuntimeError, so legacy handlers are unaffected. if errors: raise LLMAuthError( f"All {len(self._accounts)} Codex accounts failed: " + "; ".join(f"#{i}: {err}" for i, err in errors), provider="codex", ) - raise LLMRateLimitError( - f"All {len(self._accounts)} Codex accounts are rate-limited or " - "backing off; retry shortly.", - provider="codex", - ) + # The upstream gets the request even when every local slot looks + # exhausted. It can reject stale quota information authoritatively. + if self._accounts and not errors: + idx = self._current_index % len(self._accounts) + auth = self._accounts[idx] + try: + token = await auth.get_access_token() + return token, auth.get_account_id(), idx + except Exception as e: + errors.append((idx, str(e))) + if errors: + raise LLMAuthError( + f"All {len(self._accounts)} Codex accounts failed: " + + "; ".join(f"#{i}: {err}" for i, err in errors), provider="codex" + ) + raise LLMRateLimitError("No configured Codex accounts.", provider="codex") async def get_access_token(self) -> str: """Get a token from a healthy account, rotating on failure or rate-limit.""" @@ -669,7 +829,12 @@ async def mark_limited(self, index: int) -> None: if index >= len(self._accounts): return account = self._accounts[index] - account.mark_rate_limited() + import time + + reset = self._quota_reset(index) + if getattr(self, "_manual_active_index", None) == index: + self._manual_active_index = None + account.mark_rate_limited(max(0.0, reset - time.time()) if reset else 60.0) label = self._account_label(account, index) if len(self._accounts) > 1: if self._current_index == index: @@ -750,7 +915,16 @@ async def force_refresh(self, index: int, stale_token: str | None = None) -> boo return await account.force_refresh(stale_token) def _rotate(self) -> None: - self._current_index = (self._current_index + 1) % len(self._accounts) + count = len(self._accounts) + for offset in range(1, count): + candidate = (self._current_index + offset) % count + quota_reset = self._quota_reset(candidate) + if quota_reset is None and not self._accounts[candidate].is_rate_limited(): + self._current_index = candidate + return + # Preserve the old fallback; acquire raises the existing typed + # pool-exhaustion error if none of the accounts can serve. + self._current_index = (self._current_index + 1) % count async def set_active(self, index: int) -> None: """Switch the active account to the given index.""" @@ -758,6 +932,10 @@ async def set_active(self, index: int) -> None: raise ValueError(f"index {index} out of range (0-{len(self._accounts)-1})") async with self._pool_lock: self._current_index = index + self._manual_active_index = index + clear_bench = getattr(self._accounts[index], "clear_rate_limit", None) + if clear_bench is not None: + clear_bench() try: email = self._accounts[index]._load().get("email", f"account {index}") except Exception: diff --git a/src/llm/codex_quota.py b/src/llm/codex_quota.py index e857ddcfd..39ab3bf4a 100644 --- a/src/llm/codex_quota.py +++ b/src/llm/codex_quota.py @@ -198,6 +198,12 @@ def _window( used_percent = _finite_float(fields.get(used), maximum=_MAX_PERCENT) if used_percent is None: return None + # Upstream sometimes emits an empty placeholder (0 minutes, 0 usage, + # reset immediately). A window without a positive duration is not a + # reported quota window and must not influence UI or failover. + window_minutes = _bounded_int(fields.get(window), maximum=_MAX_WINDOW_MINUTES) + if window_minutes is None or window_minutes == 0: + return None resets_at: float | None = None after = _finite_float(fields.get(reset_after), maximum=_MAX_RESET_SECONDS) if after is not None: @@ -208,7 +214,7 @@ def _window( resets_at = absolute return QuotaWindow( used_percent=used_percent, - window_minutes=_bounded_int(fields.get(window), maximum=_MAX_WINDOW_MINUTES), + window_minutes=window_minutes, resets_at=resets_at, ) @@ -240,7 +246,8 @@ def parse_quota_headers( reset_at=_SECONDARY_RESET_AT, observed_at=now, ) - if primary is None and secondary is None: + limit_reached_type = _text(fields.get(_LIMIT_REACHED)) + if primary is None and secondary is None and limit_reached_type is None: return None return QuotaSnapshot( observed_at=now, @@ -253,7 +260,7 @@ def parse_quota_headers( credits_balance=_finite_float(fields.get(_CREDITS_BALANCE), maximum=1e12), has_credits=_boolean(fields.get(_HAS_CREDITS)), credits_unlimited=_boolean(fields.get(_CREDITS_UNLIMITED)), - limit_reached_type=_text(fields.get(_LIMIT_REACHED)), + limit_reached_type=limit_reached_type, ) diff --git a/src/llm/codex_quota_check.py b/src/llm/codex_quota_check.py new file mode 100644 index 000000000..fd92a5952 --- /dev/null +++ b/src/llm/codex_quota_check.py @@ -0,0 +1,132 @@ +"""Background, read-only quota refreshes for idle Codex accounts.""" +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Callable + +import aiohttp + +from .account_key import opaque_account_key +from .codex_auth import CodexAuthPool +from .openai_codex import CODEX_API_URL, CodexChatClient + +_INTERVAL_SECONDS = 15 * 60 +_REQUEST_TIMEOUT_SECONDS = 12 +_CHECK_BODY = { + "model": "gpt-6-luna", + "instructions": "Reply with one word.", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "quota"}], + }], + "store": False, + "stream": True, + "reasoning": {"effort": "none"}, +} + + +class CodexQuotaCheckService: + """Periodically check stale account quota using a pinned minimal request. + + It deliberately does not consume response bodies or use the Codex chat + retry/failover machinery. The only pool writes are quota headers and the + per-account display failure state. + """ + + def __init__(self, get_pool: Callable[[], CodexAuthPool | None], *, + interval: float = _INTERVAL_SECONDS, + timeout: float = _REQUEST_TIMEOUT_SECONDS) -> None: + self.get_pool = get_pool + self.interval = interval + self.timeout = timeout + self._session: aiohttp.ClientSession | None = None + self._task: asyncio.Task | None = None + self._closed = False + + async def start(self) -> None: + if self._closed: + return + if self._task is None or self._task.done(): + self._task = asyncio.create_task(self._run(), name="codex-quota-check") + + async def close(self) -> None: + self._closed = True + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + if self._session is not None and not self._session.closed: + await self._session.close() + + async def _run(self) -> None: + while not self._closed: + try: + await self.check_once() + except asyncio.CancelledError: + raise + except Exception: + # A transient check must not kill the application service. + pass + try: + await asyncio.sleep(self.interval) + except asyncio.CancelledError: + raise + + async def check_once(self) -> None: + """Refresh accounts without quota data from the last 15 minutes.""" + pool = self.get_pool() + if pool is None: + return + now = pool.quota._clock() + for index in range(pool.account_count): + if self.get_pool() is not pool: + return + # Configured slots only. This query is read-only and does not + # choose, rotate, or activate an account. + account = pool.describe_accounts()[index] + if not account.get("configured"): + continue + key = account.get("key") + previous = pool.quota.snapshot_for(key) + if previous is not None and now - previous.observed_at < self.interval: + continue + request_started = False + try: + token, account_id = await pool.token_for(index) + if self.get_pool() is not pool: + return + key = opaque_account_key(account_id) + if not key: + pool.set_quota_check_failure(index, "account identity unavailable") + continue + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + auto_decompress=False, headers={"Accept-Encoding": "identity"}, + ) + request_started = True + async with self._session.post( + CODEX_API_URL, + headers={**CodexChatClient._auth_headers(token, account_id), + "Accept-Encoding": "identity"}, + json=_CHECK_BODY, + timeout=aiohttp.ClientTimeout(total=self.timeout), + ) as response: + if self.get_pool() is not pool: + return + pool.quota.record_headers(key, response.headers) + if 200 <= response.status < 300: + pool.set_quota_check_failure(index, None) + else: + pool.set_quota_check_failure(index, f"HTTP {response.status}") + # Do not read the stream. Quota is in the response headers. + except asyncio.CancelledError: + raise + except Exception: + # Keep this display-safe: exception text can contain upstream + # content or credential-adjacent details. + reason = "request failed" if request_started else "credential refresh failed" + if self.get_pool() is pool: + pool.set_quota_check_failure(index, reason) diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py index 105c327fe..829ffb6c5 100644 --- a/src/notifications/__init__.py +++ b/src/notifications/__init__.py @@ -1,4 +1,3 @@ from .outbound_webhooks import OutboundWebhookDispatcher -from .slack import SlackNotifier -__all__ = ["SlackNotifier", "OutboundWebhookDispatcher"] +__all__ = ["OutboundWebhookDispatcher"] diff --git a/src/notifications/outbound_webhooks.py b/src/notifications/outbound_webhooks.py index b00652ac5..c1f422e4e 100644 --- a/src/notifications/outbound_webhooks.py +++ b/src/notifications/outbound_webhooks.py @@ -5,23 +5,30 @@ webhook specifies which event types it subscribes to. Payloads are HMAC-SHA256 signed when a per-webhook secret is configured. """ + from __future__ import annotations import asyncio +import base64 import hashlib import hmac +import ipaddress import json +import socket import time import uuid from dataclasses import dataclass, field from datetime import UTC, datetime from enum import Enum from typing import Any +from urllib.parse import unquote, urljoin, urlparse import aiohttp from ..llm.secret_scrubber import scrub_output_secrets from ..odin_log import get_logger +from ..tools.safe_fetch import BlockedAddressError, _same_origin, _ValidatingResolver +from ..tools.url_safety import _METADATA_HOSTS, _is_metadata_ip, is_metadata_url from .payloads import scrub_payload log = get_logger("outbound_webhooks") @@ -54,13 +61,23 @@ class EventType(str, Enum): # noqa: UP042 — str(member) output differs under ALL_EVENT_TYPES: frozenset[str] = frozenset(e.value for e in EventType) +def _display_url(url: str) -> str: + """Use the same password-free URL in API listings and operational logs.""" + parsed = urlparse(url) + if parsed.password is not None: + user = parsed.username or "" + return parsed._replace( + netloc=f"{user}:[REDACTED]@{parsed.netloc.rsplit('@', 1)[-1]}" + ).geturl() + return url + + def validate_events(events: list[str] | None) -> list[str]: """Empty/omitted remains the legacy all selection; 'all' is explicit too.""" if events is None: return [] if not isinstance(events, list) or any( - not isinstance(event, str) or event not in ALL_EVENT_TYPES | {"all"} - for event in events + not isinstance(event, str) or event not in ALL_EVENT_TYPES | {"all"} for event in events ): raise ValueError("Unknown webhook event filter; use a known event or 'all'") if "all" in events and len(events) != 1: @@ -97,7 +114,7 @@ def to_dict(self) -> dict[str, Any]: return { "id": self.id, "name": self.name, - "url": self.url, + "url": _display_url(self.url), "has_secret": bool(self.secret), "events": list(self.events), "enabled": self.enabled, @@ -179,6 +196,93 @@ def sign_payload(body: bytes, secret: str) -> str: return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() +def _is_link_local_ip(value: str) -> bool: + """Check IP literals after normalizing IPv4-mapped IPv6 addresses.""" + try: + address = ipaddress.ip_address(value.split("%", 1)[0]) + except ValueError: + return False + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: + address = address.ipv4_mapped + return address.is_link_local + + +def _canonical_address(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + """Parse address spellings consistently, including scoped/mapped IPv6.""" + try: + address = ipaddress.ip_address(value.split("%", 1)[0]) + except ValueError: + return None + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: + return address.ipv4_mapped + return address + + +def _numeric_host_address(host: str): + """Recognize legacy inet_aton numeric forms aiohttp treats as IP literals.""" + try: + packed = socket.inet_aton(host) + except OSError: + return None + return ipaddress.IPv4Address(packed) + + +def _is_webhook_metadata_address(value: str) -> bool: + address = _canonical_address(value) + return bool( + address + and ( + _is_metadata_ip(value) + or str(address) in {"100.100.100.200", "169.254.170.2", "fd20:ce::254"} + ) + ) + + +def _validate_webhook_url(url: str, *, redirect: bool = False) -> None: + """Private endpoints are intentional; metadata, userinfo and unsafe schemes are not.""" + try: + parsed = urlparse(url) + host = (parsed.hostname or "").lower().rstrip(".") + port = parsed.port + except ValueError: + raise ValueError("Invalid webhook URL") from None + if ( + parsed.scheme not in {"http", "https"} + or not host + or (redirect and (parsed.username is not None or parsed.password is not None)) + or any(ord(c) < 32 or ord(c) == 127 for c in url) + ): + raise ValueError("Webhook URL must start with http:// or https://") + if ( + port == 0 + or host in _METADATA_HOSTS + or _is_webhook_metadata_address(host) + or ( + (numeric_address := _numeric_host_address(host)) is not None + and ( + _is_webhook_metadata_address(str(numeric_address)) + or _is_link_local_ip(str(numeric_address)) + ) + ) + or _is_link_local_ip(host) + or is_metadata_url(url, resolve_dns=False) + ): + raise ValueError("Webhook URL targets a cloud-metadata address") + + +class _WebhookResolver(_ValidatingResolver): + """Allow private destinations but fail closed for metadata on every connect.""" + + async def resolve(self, host, port=0, family=socket.AF_INET): + results = await self._inner.resolve(host, port, family) + if any( + _is_webhook_metadata_address(row["host"]) or _is_link_local_ip(row["host"]) + for row in results + ): + raise BlockedAddressError("Webhook resolved to a cloud-metadata address") + return results + + def build_event_payload( event_type: str, data: dict[str, Any], @@ -262,6 +366,7 @@ def register( scrub_secrets: bool = True, verify_ssl: bool = True, webhook_id: str = "", + created_at: str = "", ) -> WebhookTarget: """Register a new outbound webhook. Returns the created target.""" if len(self._webhooks) >= MAX_WEBHOOKS: @@ -270,12 +375,7 @@ def register( raise ValueError("Webhook URL is required") if len(url) > _MAX_URL_LEN: raise ValueError(f"URL must be under {_MAX_URL_LEN} characters") - if not url.startswith(("http://", "https://")): - raise ValueError("URL must start with http:// or https://") - from ..tools.url_safety import is_url_blocked - if is_url_blocked(url): - raise ValueError("Webhook URL targets a blocked address (localhost, private IP, or " - "metadata endpoint)") + _validate_webhook_url(url) if len(name) > _MAX_NAME_LEN: raise ValueError(f"Name must be under {_MAX_NAME_LEN} characters") if len(secret) > _MAX_SECRET_LEN: @@ -296,16 +396,17 @@ def register( enabled=enabled, scrub_secrets=scrub_secrets, verify_ssl=verify_ssl, + created_at=created_at, ) self._webhooks[wh_id] = target - log.info("Registered outbound webhook %s -> %s", wh_id, url) + log.info("Registered outbound webhook %s -> %s", wh_id, _display_url(url)) return target def unregister(self, webhook_id: str) -> bool: """Remove a webhook. Returns True if it existed.""" removed = self._webhooks.pop(webhook_id, None) if removed: - log.info("Unregistered outbound webhook %s (%s)", webhook_id, removed.url) + log.info("Unregistered outbound webhook %s (%s)", webhook_id, _display_url(removed.url)) return removed is not None def get(self, webhook_id: str) -> WebhookTarget | None: @@ -338,11 +439,7 @@ def update( raise ValueError("Webhook URL is required") if len(url) > _MAX_URL_LEN: raise ValueError(f"URL must be under {_MAX_URL_LEN} characters") - if not url.startswith(("http://", "https://")): - raise ValueError("URL must start with http:// or https://") - from ..tools.url_safety import is_url_blocked - if is_url_blocked(url): - raise ValueError("Webhook URL targets a blocked address") + _validate_webhook_url(url) wh.url = url if name is not None: if len(name) > _MAX_NAME_LEN: @@ -380,9 +477,22 @@ async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: self._session = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=_SEND_TIMEOUT), + connector=aiohttp.TCPConnector( + resolver=_WebhookResolver(), + use_dns_cache=False, + force_close=True, + ), + cookie_jar=aiohttp.DummyCookieJar(), + trust_env=False, ) return self._session + def _report_delivery_error(self, exc: Exception) -> str: + """Expose no remote URL/query, payload, HMAC, or transport echo.""" + if isinstance(exc, (BlockedAddressError, ValueError)): + return "webhook destination rejected (metadata, URL, or redirect policy)" + return "webhook transport failed" + async def _deliver_one( self, target: WebhookTarget, @@ -391,6 +501,17 @@ async def _deliver_one( ) -> DeliveryResult: """Deliver a payload to a single webhook with retries.""" headers: dict[str, str] = {"Content-Type": "application/json"} + parsed_target = urlparse(target.url) + if parsed_target.username is not None: + credentials = ( + f"{unquote(parsed_target.username)}:{unquote(parsed_target.password or '')}" + ) + encoded_credentials = base64.b64encode(credentials.encode()).decode("ascii") + headers["Authorization"] = f"Basic {encoded_credentials}" + request_target_url = target.url + if parsed_target.username is not None: + host_port = parsed_target.netloc.rsplit("@", 1)[-1] + request_target_url = parsed_target._replace(netloc=host_port).geturl() if target.secret: sig = sign_payload(payload_body, target.secret) headers["X-Webhook-Signature"] = f"sha256={sig}" @@ -402,33 +523,77 @@ async def _deliver_one( t0 = time.monotonic() try: session = await self._get_session() - async with session.post( - target.url, - data=payload_body, - headers=headers, - # aiohttp accepts ssl=None ("use default") at - # runtime; the public stub omits None. - ssl=ssl_ctx, # type: ignore[arg-type] - ) as resp: - latency = (time.monotonic() - t0) * 1000 - success = 200 <= resp.status < 300 - error = "" - if not success: - body_text = await resp.text() - error = f"HTTP {resp.status}: {body_text[:200]}" - - last_result = DeliveryResult( - webhook_id=target.id, - webhook_name=target.name, - event_type=event_type, - status_code=resp.status, - success=success, - error=error, - attempt=attempt, - latency_ms=latency, - ) - if success: - return last_result + current_url = request_target_url + current_headers = headers + current_method = "POST" + for hop in range(6): + _validate_webhook_url(current_url, redirect=current_url != target.url) + request = session.post if current_method == "POST" else session.get + async with request( + current_url, + data=payload_body if current_method == "POST" else None, + headers=current_headers, + allow_redirects=False, + # aiohttp accepts ssl=None ("use default") at runtime. + ssl=ssl_ctx, # type: ignore[arg-type] + ) as resp: + if resp.status in (301, 302, 303, 307, 308) and resp.headers.get( + "Location" + ): + location = resp.headers["Location"] + # Check credentials only in the untrusted header. Relative + # redirects naturally inherit configured URL userinfo. + raw_location = urlparse(location) + if ( + raw_location.username is not None + or raw_location.password is not None + ): + raise ValueError("Redirect URL must not contain userinfo") + next_url = urljoin(current_url, location) + _validate_webhook_url(next_url) + if resp.status in (301, 302, 303): + current_method = "GET" + current_headers = { + key: value + for key, value in current_headers.items() + if key.lower() not in {"x-webhook-signature", "content-type"} + } + if not _same_origin(target.url, next_url): + current_headers = { + key: value + for key, value in current_headers.items() + if key.lower() not in {"x-webhook-signature", "authorization"} + } + current_url = next_url + if hop == 5: + return DeliveryResult( + webhook_id=target.id, + webhook_name=target.name, + event_type=event_type, + error="webhook destination rejected (redirect policy)", + attempt=attempt, + latency_ms=(time.monotonic() - t0) * 1000, + ) + continue + status = resp.status + break + latency = (time.monotonic() - t0) * 1000 + success = 200 <= status < 300 + # Remote response bodies are untrusted. An endpoint can reflect + # our signature or sensitive payload into diagnostics/API stats. + error = "" if success else f"HTTP {status}" + last_result = DeliveryResult( + webhook_id=target.id, + webhook_name=target.name, + event_type=event_type, + status_code=status, + success=success, + error=error, + attempt=attempt, + latency_ms=latency, + ) + if success: + return last_result except TimeoutError: latency = (time.monotonic() - t0) * 1000 @@ -440,13 +605,22 @@ async def _deliver_one( attempt=attempt, latency_ms=latency, ) + except (BlockedAddressError, ValueError) as exc: + return DeliveryResult( + webhook_id=target.id, + webhook_name=target.name, + event_type=event_type, + error=self._report_delivery_error(exc), + attempt=attempt, + latency_ms=(time.monotonic() - t0) * 1000, + ) except Exception as exc: latency = (time.monotonic() - t0) * 1000 last_result = DeliveryResult( webhook_id=target.id, webhook_name=target.name, event_type=event_type, - error=str(exc)[:200], + error=self._report_delivery_error(exc), attempt=attempt, latency_ms=latency, ) @@ -472,15 +646,16 @@ async def dispatch( targets = [ wh for wh in self._webhooks.values() - if wh.enabled - and wh.accepts_event(event_type) - and self._check_rate_limit(wh.id) + if wh.enabled and wh.accepts_event(event_type) and self._check_rate_limit(wh.id) ] if not targets: return [] payload = build_event_payload( - event_type, data, event_id=event_id, source=source, + event_type, + data, + event_id=event_id, + source=source, ) results: list[DeliveryResult] = [] @@ -501,12 +676,17 @@ async def dispatch( if result.success: log.debug( "Delivered %s event to webhook %s (%s)", - event_type, target.id, target.name, + event_type, + target.id, + target.name, ) else: log.warning( "Failed to deliver %s event to webhook %s (%s): %s", - event_type, target.id, target.name, result.error, + event_type, + target.id, + target.name, + result.error, ) return results @@ -524,15 +704,20 @@ async def dispatch_fire_and_forget( Suitable for use as an audit event callback where blocking is undesirable. """ + async def _do_dispatch(): try: await self.dispatch( - event_type, data, event_id=event_id, source=source, + event_type, + data, + event_id=event_id, + source=source, ) except Exception as exc: log.warning("Fire-and-forget dispatch error: %s", exc) from ..async_utils import fire_and_forget + fire_and_forget(_do_dispatch(), name="webhook_dispatch") async def send_test_event(self, webhook_id: str) -> DeliveryResult | None: diff --git a/src/notifications/slack.py b/src/notifications/slack.py deleted file mode 100644 index f9663d030..000000000 --- a/src/notifications/slack.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Slack webhook notifier for posting messages alongside Discord output. - -Sends messages to Slack incoming webhooks with optional severity-colored -formatting, rate limiting per webhook URL, and secret scrubbing. -""" -from __future__ import annotations - -import time -from typing import Any - -import aiohttp - -from ..llm.secret_scrubber import scrub_output_secrets -from ..odin_log import get_logger -from .payloads import scrub_payload - -log = get_logger("slack") - -MAX_TEXT_LEN = 3000 -DEFAULT_RATE_LIMIT = 1 # seconds between messages to same webhook -_SEND_TIMEOUT = 10 # seconds - -SEVERITY_COLORS = { - "info": "#2196F3", - "warning": "#FF9800", - "error": "#F44336", - "success": "#4CAF50", -} - -DEFAULT_COLOR = "#9E9E9E" - - -def _truncate(text: str, limit: int = MAX_TEXT_LEN) -> str: - if len(text) <= limit: - return text - return text[: limit - 20] + "\n…(truncated)" - - -def _discord_to_slack_markdown(text: str) -> str: - """Convert common Discord markdown to Slack mrkdwn.""" - import re - text = re.sub(r"\*\*(.+?)\*\*", r"*\1*", text) - text = re.sub(r"__(.+?)__", r"_\1_", text) - return text - - -def build_plain_payload(text: str) -> dict[str, Any]: - return {"text": _truncate(text)} - - -def build_formatted_payload( - title: str, - message: str, - severity: str = "info", - source: str = "odin", -) -> dict[str, Any]: - color = SEVERITY_COLORS.get(severity, DEFAULT_COLOR) - fields = [] - if source: - fields.append({"title": "Source", "value": source, "short": True}) - if severity: - fields.append({"title": "Severity", "value": severity.upper(), "short": True}) - - attachment: dict[str, Any] = { - "color": color, - "title": _truncate(title, 150), - "text": _truncate(message), - "mrkdwn_in": ["text"], - } - if fields: - attachment["fields"] = fields - - return { - "text": _truncate(title, 150), - "attachments": [attachment], - } - - -class SlackNotifier: - """Async Slack webhook poster with rate limiting and secret scrubbing.""" - - def __init__( - self, - webhook_urls: dict[str, str] | None = None, - default_webhook_url: str = "", - scrub_secrets: bool = True, - rate_limit_seconds: int = DEFAULT_RATE_LIMIT, - ) -> None: - self._webhook_urls = dict(webhook_urls) if webhook_urls else {} - self._default_url = default_webhook_url - self._scrub = scrub_secrets - self._rate_limit = max(0, rate_limit_seconds) - self._last_sent: dict[str, float] = {} - self._session: aiohttp.ClientSession | None = None - self._send_count = 0 - self._error_count = 0 - - @property - def send_count(self) -> int: - return self._send_count - - @property - def error_count(self) -> int: - return self._error_count - - @property - def configured_channels(self) -> list[str]: - return list(self._webhook_urls.keys()) - - def resolve_url(self, channel: str | None = None) -> str | None: - if channel and channel in self._webhook_urls: - return self._webhook_urls[channel] - if channel and channel.startswith("https://"): - return channel - return self._default_url or None - - def _check_rate_limit(self, url: str) -> bool: - if self._rate_limit <= 0: - return True - last = self._last_sent.get(url, 0.0) - return (time.monotonic() - last) >= self._rate_limit - - def _mark_sent(self, url: str) -> None: - self._last_sent[url] = time.monotonic() - - async def _get_session(self) -> aiohttp.ClientSession: - if self._session is None or self._session.closed: - self._session = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=_SEND_TIMEOUT), - ) - return self._session - - async def send( - self, - text: str, - channel: str | None = None, - payload: dict[str, Any] | None = None, - ) -> bool: - """Send a message to a Slack webhook. Returns True on success.""" - url = self.resolve_url(channel) - if not url: - log.warning("Slack send skipped: no webhook URL for channel=%s", channel) - return False - - if not self._check_rate_limit(url): - log.debug("Slack send rate-limited for %s", channel or "default") - return False - - if self._scrub: - text = scrub_output_secrets(text) - - if payload is None: - payload = build_plain_payload(_discord_to_slack_markdown(text)) - if self._scrub: - payload = scrub_payload(payload, scrub_output_secrets) - - try: - session = await self._get_session() - async with session.post(url, json=payload) as resp: - if resp.status == 200: - self._mark_sent(url) - self._send_count += 1 - return True - body = await resp.text() - log.warning("Slack webhook returned %d: %s", resp.status, body[:200]) - self._error_count += 1 - return False - except TimeoutError: - log.warning("Slack webhook timed out for channel=%s", channel) - self._error_count += 1 - return False - except Exception as exc: - log.warning("Slack webhook error for channel=%s: %s", channel, exc) - self._error_count += 1 - return False - - async def send_formatted( - self, - title: str, - message: str, - severity: str = "info", - source: str = "odin", - channel: str | None = None, - ) -> bool: - """Send a formatted Slack message with color-coded severity.""" - if self._scrub: - title = scrub_output_secrets(title) - message = scrub_output_secrets(message) - title = _discord_to_slack_markdown(title) - message = _discord_to_slack_markdown(message) - payload = build_formatted_payload(title, message, severity, source) - return await self.send(title, channel=channel, payload=payload) - - async def broadcast( - self, - text: str, - channels: list[str] | None = None, - ) -> dict[str, bool]: - """Send a message to multiple channels. Returns {channel: success}.""" - targets = channels or list(self._webhook_urls.keys()) - if not targets: - if self._default_url: - result = await self.send(text) - return {"default": result} - return {} - results: dict[str, bool] = {} - for ch in targets: - results[ch] = await self.send(text, channel=ch) - return results - - def get_status(self) -> dict[str, Any]: - return { - "configured_channels": self.configured_channels, - "has_default_url": bool(self._default_url), - "scrub_secrets": self._scrub, - "rate_limit_seconds": self._rate_limit, - "send_count": self._send_count, - "error_count": self._error_count, - } - - async def close(self) -> None: - if self._session and not self._session.closed: - await self._session.close() - self._session = None diff --git a/src/odin/__init__.py b/src/odin/__init__.py deleted file mode 100644 index 95b78507f..000000000 --- a/src/odin/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Odin DAG planner — dependency-aware parallel task execution.""" - -from src.odin.context import ExecutionContext -from src.odin.plan_loader import load_plan -from src.odin.planner import Planner, PlanValidationError -from src.odin.registry import ToolRegistry -from src.odin.reporter import Reporter -from src.odin.types import PlanResult, PlanSpec, StepResult, StepSpec, StepStatus - -__all__ = [ - "PlanSpec", - "StepSpec", - "PlanResult", - "StepResult", - "StepStatus", - "Planner", - "PlanValidationError", - "ToolRegistry", - "ExecutionContext", - "load_plan", - "Reporter", -] diff --git a/src/odin/__main__.py b/src/odin/__main__.py deleted file mode 100644 index 5e2f9d89d..000000000 --- a/src/odin/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Allow ``python -m odin`` invocation.""" - -from src.odin.cli import main - -main() diff --git a/src/odin/cli.py b/src/odin/cli.py deleted file mode 100644 index ca73200ba..000000000 --- a/src/odin/cli.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Command-line interface for Odin.""" - -from __future__ import annotations - -import argparse -import asyncio -import logging -import sys - -from src.odin.plan_loader import load_plan -from src.odin.planner import Planner, PlanValidationError -from src.odin.registry import ToolRegistry -from src.odin.reporter import Reporter - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="odin", description="Odin execution agent") - sub = parser.add_subparsers(dest="command") - - run_p = sub.add_parser("run", help="Execute a plan") - run_p.add_argument("plan", help="Path to a YAML plan file, or inline JSON") - run_p.add_argument("--json", action="store_true", dest="json_output", help="JSON output") - run_p.add_argument("-v", "--verbose", action="store_true") - - val_p = sub.add_parser("validate", help="Validate a plan without executing") - val_p.add_argument("plan", help="Path to a YAML plan file") - - sub.add_parser("list-tools", help="List registered tools") - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - - if not args.command: - parser.print_help() - sys.exit(0) - - logging.basicConfig( - level=logging.DEBUG if getattr(args, "verbose", False) else logging.WARNING, - format="%(levelname)s %(name)s: %(message)s", - ) - - exit_code = asyncio.run(_dispatch(args)) - sys.exit(exit_code) - - -async def _dispatch(args: argparse.Namespace) -> int: - if args.command == "list-tools": - registry = ToolRegistry.with_defaults() - for name in registry.list_tools(): - print(f" {name}") - return 0 - - if args.command == "validate": - plan = load_plan(args.plan) - planner = Planner(ToolRegistry.with_defaults()) - errors = planner.validate(plan) - if errors: - for e in errors: - print(f"ERROR: {e}", file=sys.stderr) - return 2 - print("Plan is valid.") - return 0 - - if args.command == "run": - plan = load_plan(args.plan) - planner = Planner(ToolRegistry.with_defaults()) - - try: - result = await planner.execute(plan) - except PlanValidationError as exc: - for e in exc.errors: - print(f"ERROR: {e}", file=sys.stderr) - return 2 - - if args.json_output: - print(Reporter.to_json(result)) - else: - print(Reporter.to_summary(result)) - - return 0 if result.success else 1 - - return 0 diff --git a/src/odin/context.py b/src/odin/context.py deleted file mode 100644 index 5517b6281..000000000 --- a/src/odin/context.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Execution context for variable resolution in plan steps. - -Supports two namespaces for interpolation: - - Step results: ${step_id.field.path} or {steps.step_id.field.path} - - Plan inputs: ${inputs.key.path} or {inputs.key.path} - -Full-value references (the entire param value is a single placeholder) return -the raw object. Embedded references (placeholder inside a larger string) are -stringified before substitution. -""" - -from __future__ import annotations - -import copy -import enum -import re -from typing import Any - -from src.odin.types import StepResult - - -def _numeric_compare(left: str, right: str, fn) -> bool: - """Try to compare *left* and *right* as numbers using *fn*. - - Returns ``False`` if either side cannot be parsed as a number. - Supports ints and floats. - """ - try: - lv = int(left) if left.lstrip("-").isdigit() else float(left) - rv = int(right) if right.lstrip("-").isdigit() else float(right) - except (ValueError, ArithmeticError): - return False - return fn(lv, rv) - - -def _interpolation_str(value: Any) -> str: - """Stringify a value for embedding in an interpolated string. - - Python's default ``str()`` produces representations that don't match what - users naturally write in conditions/params (e.g. ``str(True)`` → ``"True"`` - instead of ``"true"``, ``str(SomeEnum.X)`` → ``"SomeEnum.X"`` instead of - the enum *value*). This helper normalises the common cases. - """ - # bool must be checked before int (bool is a subclass of int) - if isinstance(value, bool): - return "true" if value else "false" - if value is None: - return "none" - if isinstance(value, enum.Enum): - return str(value.value) - return str(value) - -# --------------------------------------------------------------------------- -# Regex patterns -# --------------------------------------------------------------------------- -# ${...} — dollar-brace syntax -_DOLLAR_REF = re.compile(r"\$\{([^}]+)\}") -# {steps.…} but NOT ${steps.…} — bare-brace step syntax -_STEPS_REF = re.compile(r"(? None: - self._results: dict[str, StepResult] = {} - self._inputs: dict[str, Any] = dict(inputs) if inputs else {} - - # -- result bookkeeping -------------------------------------------------- - - def record(self, step_id: str, result: StepResult) -> None: - self._results[step_id] = result - - def get(self, step_id: str) -> StepResult | None: - return self._results.get(step_id) - - @property - def results(self) -> dict[str, StepResult]: - return dict(self._results) - - @property - def inputs(self) -> dict[str, Any]: - return dict(self._inputs) - - # -- condition evaluation ------------------------------------------------ - - def evaluate_condition(self, expr: str) -> bool: - """Resolve interpolation in *expr* and evaluate as a condition. - - After interpolation the result is checked for truthiness: - - Non-string objects use Python truthiness directly. - - Strings support ``==`` / ``!=`` comparison operators (whitespace-trimmed). - - Strings support ``>``, ``>=``, ``<``, ``<=`` numeric comparison operators. - Both sides must be parseable as numbers; otherwise the condition is ``False``. - - Plain strings are truthy unless empty or one of the canonical false - literals: ``"false"``, ``"0"``, ``"no"``, ``"null"``, ``"none"`` (case-insensitive). - """ - resolved = self._resolve_value(expr) - - # Full-value ref returned a non-string object → Python truthiness - if not isinstance(resolved, str): - return bool(resolved) - - # Check for comparison operators in the *resolved* string. - # Order matters: multi-char operators (!=, >=, <=) must be tried - # before their single-char prefixes (>, <) to avoid partial matches; - # == before = is already ensured. != / == use string comparison; - # >= / <= / > / < use numeric comparison. - for op, fn in (("!=", lambda a, b: a != b), ("==", lambda a, b: a == b)): - if op in resolved: - left, _, right = resolved.partition(op) - return fn(left.strip(), right.strip()) - - for op, fn in ( - (">=", lambda a, b: a >= b), - ("<=", lambda a, b: a <= b), - (">", lambda a, b: a > b), - ("<", lambda a, b: a < b), - ): - if op in resolved: - left, _, right = resolved.partition(op) - return _numeric_compare(left.strip(), right.strip(), fn) - - # Plain string truthiness - return resolved.strip().lower() not in ("", "false", "0", "no", "null", "none") - - # -- param resolution ---------------------------------------------------- - - def resolve_params(self, params: dict[str, Any]) -> dict[str, Any]: - """Deep-copy *params* and resolve all placeholder references.""" - resolved = copy.deepcopy(params) - return self._resolve_value(resolved) - - # -- internals ----------------------------------------------------------- - - def _resolve_value(self, value: Any) -> Any: - if isinstance(value, str): - return self._resolve_string(value) - if isinstance(value, dict): - return {k: self._resolve_value(v) for k, v in value.items()} - if isinstance(value, list): - return [self._resolve_value(v) for v in value] - return value - - def _resolve_string(self, value: str) -> Any: - # Full-value reference — return raw object - m = _DOLLAR_REF.fullmatch(value) - if m: - return self._lookup(m.group(1)) - - m = _STEPS_REF.fullmatch(value) - if m: - return self._lookup_step(m.group(1)) - - m = _INPUTS_REF.fullmatch(value) - if m: - return self._lookup_input(m.group(1)) - - # Embedded references — substitute as strings - if _ANY_REF.search(value): - def _replacer(match: re.Match) -> str: - dollar, steps, inputs = match.group(1), match.group(2), match.group(3) - if dollar is not None: - return _interpolation_str(self._lookup(dollar)) - if steps is not None: - return _interpolation_str(self._lookup_step(steps)) - return _interpolation_str(self._lookup_input(inputs)) - return _ANY_REF.sub(_replacer, value) - - return value - - def _lookup(self, path_str: str) -> Any: - """Route a ${...} reference to step-result or input lookup.""" - parts = path_str.split(".") - if parts[0] == "inputs": - return self._lookup_input(".".join(parts[1:])) - if parts[0] == "steps": - return self._lookup_step(".".join(parts[1:])) - return self._lookup_step(path_str) - - def _lookup_step(self, path_str: str) -> Any: - """Resolve a step-result reference like step_id.output.key .""" - parts = path_str.split(".") - step_id = parts[0] - result = self._results.get(step_id) - if result is None: - raise KeyError(f"step '{step_id}' not found in context") - - if len(parts) > 1 and parts[1] in _STEP_RESULT_FIELDS: - obj = getattr(result, parts[1]) - remaining = parts[2:] - else: - obj = result.output - remaining = parts[1:] - - return self._drill(obj, remaining, path_str) - - def _lookup_input(self, path_str: str) -> Any: - """Resolve a plan-input reference like key.nested .""" - if not path_str: - raise KeyError("empty input reference") - parts = path_str.split(".") - key = parts[0] - if key not in self._inputs: - raise KeyError( - f"input '{key}' not found in plan inputs " - f"(available: {', '.join(sorted(self._inputs)) or 'none'})" - ) - obj = self._inputs[key] - return self._drill(obj, parts[1:], f"inputs.{path_str}") - - @staticmethod - def _drill(obj: Any, parts: list[str], full_path: str) -> Any: - """Traverse *obj* by the remaining *parts* (dict keys / list indices).""" - for i, part in enumerate(parts): - traversed = ".".join(parts[: i + 1]) - if isinstance(obj, dict): - if part not in obj: - raise KeyError( - f"key '{part}' not found at '{traversed}' in ref '${{{full_path}}}'" - ) - obj = obj[part] - elif isinstance(obj, (list, tuple)): - try: - obj = obj[int(part)] - except (ValueError, IndexError) as exc: - raise KeyError( - f"invalid index '{part}' at '{traversed}' in ref '${{{full_path}}}'" - ) from exc - elif hasattr(obj, part): - obj = getattr(obj, part) - else: - raise KeyError( - f"cannot traverse '{part}' at '{traversed}' in ref '${{{full_path}}}'" - ) - return obj diff --git a/src/odin/executor.py b/src/odin/executor.py deleted file mode 100644 index 28bbf8634..000000000 --- a/src/odin/executor.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Step executor with retry, timeout, and async support.""" - -from __future__ import annotations - -import inspect -import time -from typing import Any - -from src.odin.context import ExecutionContext -from src.odin.registry import ToolRegistry -from src.odin.types import StepResult, StepSpec, StepStatus - - -class StepExecutor: - def __init__(self, registry: ToolRegistry) -> None: - self._registry = registry - - async def execute_step(self, spec: StepSpec, ctx: ExecutionContext) -> StepResult: - try: - tool_cls = self._registry.get(spec.tool) - except KeyError: - return StepResult( - status=StepStatus.FAILED, - error=f"unknown tool '{spec.tool}'", - ) - - # Resolve params — interpolation errors fail the step, not the plan - try: - resolved = ctx.resolve_params(spec.params) - except KeyError as exc: - return StepResult( - status=StepStatus.FAILED, - error=f"param resolution failed: {exc}", - ) - - tool = tool_cls() - last_error: str | None = None - attempts = 0 - max_attempts = 1 + spec.retries - start = time.monotonic() - - while attempts < max_attempts: - attempts += 1 - start = time.monotonic() - try: - output = await self._invoke_tool(tool, resolved, ctx) - elapsed = time.monotonic() - start - if elapsed > spec.timeout: - return StepResult( - status=StepStatus.TIMEOUT, - error=f"step exceeded {spec.timeout}s timeout", - duration=elapsed, - attempts=attempts, - ) - return StepResult( - status=StepStatus.SUCCESS, - output=output, - duration=elapsed, - attempts=attempts, - ) - except Exception as exc: - last_error = str(exc) - continue - - return StepResult( - status=StepStatus.FAILED, - error=last_error, - duration=time.monotonic() - start, - attempts=attempts, - ) - - @staticmethod - async def _invoke_tool( - tool: Any, params: dict[str, Any], ctx: ExecutionContext - ) -> Any: - result = tool.execute(params, ctx) - if inspect.isawaitable(result): - return await result - return result diff --git a/src/odin/plan_loader.py b/src/odin/plan_loader.py deleted file mode 100644 index 9fce5d465..000000000 --- a/src/odin/plan_loader.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Load PlanSpec from dict, JSON string, or YAML file.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -from src.odin.types import PlanSpec, StepSpec - - -def load_plan(source: str | dict[str, Any]) -> PlanSpec: - if isinstance(source, dict): - return _from_dict(source) - - # Try JSON string - if isinstance(source, str): - source = source.strip() - if source.startswith("{"): - return _from_dict(json.loads(source)) - - # File path - path = Path(source) - if path.suffix in (".yml", ".yaml"): - try: - import yaml - except ImportError as exc: - raise ImportError("PyYAML required for YAML plans") from exc - data = yaml.safe_load(path.read_text()) - return _from_dict(data) - elif path.suffix == ".json": - data = json.loads(path.read_text()) - return _from_dict(data) - else: - raise ValueError(f"unsupported plan file format: {path.suffix}") - - raise TypeError(f"expected dict or str, got {type(source).__name__}") - - -def _from_dict(data: dict[str, Any]) -> PlanSpec: - if "name" not in data: - raise ValueError("plan must have a 'name'") - if "steps" not in data or not data["steps"]: - raise ValueError("plan must have at least one step") - - steps = [] - for s in data["steps"]: - deps = s.get("depends_on", []) - if isinstance(deps, str): - deps = [deps] - steps.append( - StepSpec( - id=s["id"], - tool=s["tool"], - params=s.get("params", {}), - depends_on=tuple(deps), - timeout=float(s.get("timeout", 30)), - retries=int(s.get("retries", 0)), - continue_on_failure=bool(s.get("continue_on_failure", False)), - when=s.get("when"), - ) - ) - - return PlanSpec( - name=data["name"], - steps=tuple(steps), - description=data.get("description", ""), - inputs=data.get("inputs", {}), - ) diff --git a/src/odin/planner.py b/src/odin/planner.py deleted file mode 100644 index 0466635a7..000000000 --- a/src/odin/planner.py +++ /dev/null @@ -1,164 +0,0 @@ -"""DAG planner with dependency-aware parallel execution.""" - -from __future__ import annotations - -import asyncio -from graphlib import TopologicalSorter -from typing import Any - -from src.odin.context import ExecutionContext -from src.odin.executor import StepExecutor -from src.odin.registry import ToolRegistry -from src.odin.types import PlanResult, PlanSpec, StepResult, StepSpec, StepStatus - - -class PlanValidationError(Exception): - """Raised when a plan fails validation; carries the individual errors - (TS-0003 — the CLI error reporter iterates ``.errors``).""" - - def __init__(self, errors: list[str]) -> None: - super().__init__("; ".join(errors)) - self.errors = errors - - -class Planner: - def __init__(self, registry: ToolRegistry) -> None: - self._registry = registry - self._executor = StepExecutor(registry) - - def validate(self, plan: PlanSpec) -> list[str]: - errors: list[str] = [] - ids = {s.id for s in plan.steps} - - # duplicate ids - seen: set[str] = set() - for s in plan.steps: - if s.id in seen: - errors.append(f"duplicate step id '{s.id}'") - seen.add(s.id) - - # unknown tools - for s in plan.steps: - if not self._registry.has(s.tool): - errors.append(f"step '{s.id}': unknown tool '{s.tool}'") - - # dangling deps - for s in plan.steps: - for dep in s.depends_on: - if dep not in ids: - errors.append(f"step '{s.id}': depends on unknown step '{dep}'") - - # cycle detection via TopologicalSorter - graph: dict[str, set[str]] = {s.id: set(s.depends_on) for s in plan.steps} - try: - ts = TopologicalSorter(graph) - ts.prepare() - except Exception: - errors.append("dependency cycle detected") - - return errors - - async def execute( - self, plan: PlanSpec, inputs: dict[str, Any] | None = None - ) -> PlanResult: - errors = self.validate(plan) - if errors: - raise PlanValidationError(errors) - - # Merge runtime inputs over spec-level defaults - merged_inputs = dict(plan.inputs) - if inputs: - merged_inputs.update(inputs) - - ctx = ExecutionContext(inputs=merged_inputs) - step_map = {s.id: s for s in plan.steps} - - graph: dict[str, set[str]] = {s.id: set(s.depends_on) for s in plan.steps} - sorter = TopologicalSorter(graph) - sorter.prepare() - - result = PlanResult(name=plan.name, success=True) - skipped: set[str] = set() - - while sorter.is_active(): - ready = sorter.get_ready() - - # Separate steps into those we can dispatch vs. those we skip - to_run: list[str] = [] - for step_id in ready: - spec = step_map[step_id] - - # Skip if any dependency failed (unless continue_on_failure) - if step_id in skipped: - sr = _make_skip(spec) - ctx.record(step_id, sr) - result.steps[step_id] = sr - sorter.done(step_id) - continue - - # Evaluate conditional — skip if `when` resolves to false - if spec.when is not None: - try: - condition_met = ctx.evaluate_condition(spec.when) - except KeyError: - condition_met = False - if not condition_met: - sr = StepResult( - status=StepStatus.SKIPPED, - error="condition not met", - ) - ctx.record(step_id, sr) - result.steps[step_id] = sr - sorter.done(step_id) - continue - - to_run.append(step_id) - - if not to_run: - continue - - # Execute all ready steps concurrently with isolation — - # one step's exception must not cancel sibling steps. - async def _run_step(sid: str) -> tuple[str, StepResult]: - return sid, await self._executor.execute_step(step_map[sid], ctx) - - raw_results = await asyncio.gather( - *(_run_step(sid) for sid in to_run), - return_exceptions=True, - ) - - # Unpack results, converting unhandled exceptions to FAILED steps - step_results: list[tuple[str, StepResult]] = [] - for sid, raw in zip(to_run, raw_results): - if isinstance(raw, BaseException): - step_results.append((sid, StepResult( - status=StepStatus.FAILED, - error=f"unhandled exception: {raw}", - ))) - else: - step_results.append(raw) - - # Record results and handle failures - for step_id, sr in step_results: - ctx.record(step_id, sr) - result.steps[step_id] = sr - - if (sr.status not in (StepStatus.SUCCESS,) - and not step_map[step_id].continue_on_failure): - result.success = False - # cascade-skip dependents - for s in plan.steps: - if step_id in s.depends_on: - skipped.add(s.id) - - sorter.done(step_id) - - return result - - -def _make_skip(spec: StepSpec) -> StepResult: - from src.odin.types import StepResult - return StepResult( - status=StepStatus.SKIPPED, - error="skipped due to upstream failure", - ) diff --git a/src/odin/registry.py b/src/odin/registry.py deleted file mode 100644 index aa129951e..000000000 --- a/src/odin/registry.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Tool registry for the DAG planner.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from src.odin.tools.base import BaseTool - - -class ToolRegistry: - """Register and look up tool classes by name.""" - - def __init__(self) -> None: - self._tools: dict[str, type[BaseTool]] = {} - - def register(self, name: str, tool_cls: type[BaseTool]) -> None: - self._tools[name] = tool_cls - - def get(self, name: str) -> type[BaseTool]: - if name not in self._tools: - raise KeyError(f"Unknown tool: {name}") - return self._tools[name] - - def has(self, name: str) -> bool: - return name in self._tools - - def list_tools(self) -> list[str]: - return sorted(self._tools) - - @classmethod - def with_defaults(cls) -> ToolRegistry: - """Return a registry pre-loaded with built-in tools.""" - from src.odin.tools.file_ops import ListDirTool, ReadFileTool - from src.odin.tools.http import HttpRequestTool - from src.odin.tools.shell import ShellTool - - reg = cls() - reg.register("shell", ShellTool) - reg.register("read_file", ReadFileTool) - reg.register("list_dir", ListDirTool) - reg.register("http_request", HttpRequestTool) - return reg diff --git a/src/odin/reporter.py b/src/odin/reporter.py deleted file mode 100644 index 91d4f6671..000000000 --- a/src/odin/reporter.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Reporting utilities for plan execution results.""" - -from __future__ import annotations - -import json -from typing import Any - -from src.odin.types import PlanResult, StepStatus - -_STATUS_ICONS = { - StepStatus.SUCCESS: "[OK]", - StepStatus.FAILED: "[FAIL]", - StepStatus.TIMEOUT: "[TIMEOUT]", - StepStatus.SKIPPED: "[SKIP]", -} - - -class Reporter: - """Format plan results for consumption.""" - - @staticmethod - def to_dict(result: PlanResult) -> dict[str, Any]: - steps = {} - for sid, sr in result.steps.items(): - steps[sid] = { - "status": sr.status.value, - "output": sr.output, - "error": sr.error, - "duration": round(sr.duration, 4), - "attempts": sr.attempts, - } - return { - "name": result.name, - "success": result.success, - "steps": steps, - } - - @staticmethod - def to_json(result: PlanResult) -> str: - return json.dumps(Reporter.to_dict(result), indent=2, default=str) - - @staticmethod - def to_summary(result: PlanResult) -> str: - lines = [ - f"Plan: {result.name}", - f"Status: {'SUCCESS' if result.success else 'FAILED'}", - "", - ] - for sid, sr in result.steps.items(): - icon = _STATUS_ICONS.get(sr.status, "[?]") - line = f" {icon} {sid} ({sr.duration:.3f}s)" - if sr.error: - line += f" — {sr.error}" - lines.append(line) - return "\n".join(lines) diff --git a/src/odin/tools/__init__.py b/src/odin/tools/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/odin/tools/base.py b/src/odin/tools/base.py deleted file mode 100644 index fb54c30fc..000000000 --- a/src/odin/tools/base.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Abstract base class for all DAG planner tools.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from src.odin.context import ExecutionContext - - -class BaseTool(ABC): - """Base class that all planner tools must implement.""" - - @abstractmethod - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - """Execute the tool with given parameters and return output.""" - - @classmethod - def param_schema(cls) -> dict[str, Any]: - """Optional JSON-Schema-like description of accepted params.""" - return {} diff --git a/src/odin/tools/file_ops.py b/src/odin/tools/file_ops.py deleted file mode 100644 index 5fa09b7ca..000000000 --- a/src/odin/tools/file_ops.py +++ /dev/null @@ -1,32 +0,0 @@ -"""File operation tools.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from src.odin.context import ExecutionContext -from src.odin.tools.base import BaseTool - - -class ReadFileTool(BaseTool): - """Read a file and return its contents.""" - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - path = Path(params["path"]) - encoding = params.get("encoding", "utf-8") - return path.read_text(encoding=encoding) - - -class ListDirTool(BaseTool): - """List directory contents.""" - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - path = Path(params.get("path", ".")) - pattern = params.get("pattern", "*") - recursive = params.get("recursive", False) - if recursive: - entries = [str(p) for p in path.rglob(pattern)] - else: - entries = [str(p) for p in path.glob(pattern)] - return entries diff --git a/src/odin/tools/http.py b/src/odin/tools/http.py deleted file mode 100644 index a82d5d3a9..000000000 --- a/src/odin/tools/http.py +++ /dev/null @@ -1,43 +0,0 @@ -"""HTTP request tool.""" - -from __future__ import annotations - -from typing import Any - -from src.odin.context import ExecutionContext -from src.odin.tools.base import BaseTool - - -class HttpRequestTool(BaseTool): - """Make an async HTTP request.""" - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - import aiohttp - - method = params.get("method", "GET").upper() - url = params["url"] - headers = params.get("headers", {}) - body = params.get("body") - - async with aiohttp.ClientSession() as session: - async with session.request( - method, url, headers=headers, json=body if body else None - ) as resp: - try: - data = await resp.json() - except Exception: - data = await resp.text() - return { - "status": resp.status, - "body": data, - "headers": dict(resp.headers), - } - - @classmethod - def param_schema(cls) -> dict[str, Any]: - return { - "url": {"type": "string", "required": True}, - "method": {"type": "string", "default": "GET"}, - "headers": {"type": "object"}, - "body": {"type": "object"}, - } diff --git a/src/odin/tools/process.py b/src/odin/tools/process.py deleted file mode 100644 index 7fa8a49f0..000000000 --- a/src/odin/tools/process.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Process management tools: run and kill.""" - -from __future__ import annotations - -import asyncio -import os -import signal -from typing import Any - -from src.odin.context import ExecutionContext -from src.odin.tools.base import BaseTool - - -class ProcessRunTool(BaseTool): - """Start a process, optionally detached. - - Params: command (str), cwd (str, optional), env (dict, optional), detach (bool, default False). - Returns: {"pid": int, "returncode": int|None, "stdout": str, "stderr": str} - """ - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> dict[str, Any]: - command: str = params["command"] - cwd: str | None = params.get("cwd") - env: dict[str, str] | None = params.get("env") - detach: bool = params.get("detach", False) - - proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=cwd, - env=env, - ) - - if detach: - return { - "pid": proc.pid, - "returncode": None, - "stdout": "", - "stderr": "", - } - - stdout_bytes, stderr_bytes = await proc.communicate() - return { - "pid": proc.pid, - "returncode": proc.returncode, - "stdout": stdout_bytes.decode(errors="replace"), - "stderr": stderr_bytes.decode(errors="replace"), - } - - -class ProcessKillTool(BaseTool): - """Kill a process by PID. - - Params: pid (int), signal (int, default SIGTERM). - Returns: {"pid": int, "killed": bool} - """ - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> dict[str, Any]: - pid: int = params["pid"] - sig: int = params.get("signal", signal.SIGTERM) - - # A pid <= 0 is not a single process: os.kill(0, sig) hits the caller's - # whole process group and os.kill(-1, sig) every process the user owns. - # This tool kills ONE process by pid; reject broadcast targets. - if pid <= 0: - return {"pid": pid, "killed": False} - - try: - os.kill(pid, sig) - return {"pid": pid, "killed": True} - except ProcessLookupError: - return {"pid": pid, "killed": False} diff --git a/src/odin/tools/shell.py b/src/odin/tools/shell.py deleted file mode 100644 index 11de6211d..000000000 --- a/src/odin/tools/shell.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Shell command execution tool.""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from src.odin.context import ExecutionContext -from src.odin.tools.base import BaseTool - - -class ShellTool(BaseTool): - """Execute a shell command and return stdout/stderr/returncode.""" - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - command = params["command"] - cwd = params.get("cwd") - check = params.get("check", False) - env = params.get("env") - - proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=cwd, - env=env, - ) - stdout, stderr = await proc.communicate() - result = { - "returncode": proc.returncode, - "stdout": stdout.decode(errors="replace"), - "stderr": stderr.decode(errors="replace"), - } - if check and proc.returncode != 0: - raise RuntimeError( - f"Command failed (rc={proc.returncode}): {stderr.decode(errors='replace')}" - ) - return result - - @classmethod - def param_schema(cls) -> dict[str, Any]: - return { - "command": {"type": "string", "required": True}, - "cwd": {"type": "string"}, - "check": {"type": "boolean", "default": False}, - } diff --git a/src/odin/types.py b/src/odin/types.py deleted file mode 100644 index 5e5bacb0c..000000000 --- a/src/odin/types.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Core data types for the Odin planner.""" - -from __future__ import annotations - -import enum -from dataclasses import dataclass, field -from typing import Any - - -class StepStatus(enum.Enum): - SUCCESS = "success" - FAILED = "failed" - SKIPPED = "skipped" - TIMEOUT = "timeout" - - -@dataclass(frozen=True) -class StepSpec: - id: str - tool: str - params: dict[str, Any] = field(default_factory=dict) - depends_on: tuple[str, ...] = () - timeout: float = 30.0 - retries: int = 0 - continue_on_failure: bool = False - when: str | None = None - - -@dataclass -class StepResult: - status: StepStatus - output: Any = None - error: str | None = None - duration: float = 0.0 - attempts: int = 1 - - -@dataclass(frozen=True) -class PlanSpec: - name: str - steps: tuple[StepSpec, ...] - description: str = "" - inputs: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class PlanResult: - name: str - success: bool - steps: dict[str, StepResult] = field(default_factory=dict) diff --git a/src/permissions/manager.py b/src/permissions/manager.py index 43da3e392..e1af83301 100644 --- a/src/permissions/manager.py +++ b/src/permissions/manager.py @@ -23,7 +23,7 @@ def _validate_overrides(data: dict) -> None: def _valid_overrides(data: dict) -> dict[str, str]: - """Keep legacy tolerance for unknown tier names while preserving valid entries.""" + """Keep legacy effective tiers for unknown names; never publish them as tiers.""" return {user_id: tier for user_id, tier in data.items() if tier in VALID_TIERS} @@ -38,19 +38,21 @@ def _valid_overrides(data: dict) -> dict[str, str]: # "read-only monitoring", and it sat here under that comment. With the RBAC # gate now wired into ToolExecutor, leaving it would grant every user-tier # caller a shell (subject to host_access/governor, but still). -USER_TIER_TOOLS = frozenset({ - "get_tool_output", - "search_history", - "search_knowledge", - "web_search", - "fetch_url", - "list_schedules", - "list_tasks", - "list_skills", - "list_knowledge", - "manage_list", - "parse_time", -}) +USER_TIER_TOOLS = frozenset( + { + "get_tool_output", + "search_history", + "search_knowledge", + "web_search", + "fetch_url", + "list_schedules", + "list_tasks", + "list_skills", + "list_knowledge", + "manage_list", + "parse_time", + } +) class PermissionManager: @@ -66,6 +68,7 @@ def __init__( self._default_tier = default_tier if default_tier in VALID_TIERS else "user" self._overrides_path = Path(overrides_path) self._overrides: dict[str, str] = {} + self._invalid_overrides: dict[str, str] = {} self._store_corrupt = False self._lock = asyncio.Lock() self._publication_lock = threading.RLock() @@ -78,12 +81,33 @@ def _load_overrides(self) -> None: what="permission overrides", ) self._overrides = _valid_overrides(data) + self._invalid_overrides = { + uid: tier for uid, tier in data.items() if tier not in VALID_TIERS + } + if self._invalid_overrides: + log.warning( + "Permission overrides contain %d unrecognized tier(s); " + "entries retained for operator repair", + len(self._invalid_overrides), + ) self._store_corrupt = not ok def _load_overrides_for_write(self) -> dict[str, str]: - """Strictly reload the mutation base so corruption is never overwritten.""" - data = load_json_store(self._overrides_path, validate=_validate_overrides) - return _valid_overrides(data) + """Strictly reload ALL entries; never erase unknown tiers on unrelated writes.""" + return load_json_store(self._overrides_path, validate=_validate_overrides) + + @property + def invalid_overrides(self) -> dict[str, str]: + """Unrecognized tiers, for operator diagnostics (not effective policy).""" + return dict(self._invalid_overrides) + + def _publish_overrides(self, candidate: dict[str, str]) -> None: + self._save_overrides(candidate) + self._overrides = _valid_overrides(candidate) + self._invalid_overrides = { + uid: tier for uid, tier in candidate.items() if tier not in VALID_TIERS + } + self._store_corrupt = False def _save_overrides(self, candidate: dict[str, str] | None = None) -> None: import json @@ -123,9 +147,7 @@ def set_tier(self, user_id: str, tier: str) -> None: with self._publication_lock: current = self._load_overrides_for_write() candidate = {**current, user_id: tier} - self._save_overrides(candidate) - self._overrides = candidate - self._store_corrupt = False + self._publish_overrides(candidate) log.info("Permission tier for user %s set to %s", user_id, tier) async def async_set_tier(self, user_id: str, tier: str) -> None: @@ -141,14 +163,19 @@ async def async_delete_tier(self, user_id: str) -> bool: if user_id in current: candidate = dict(current) del candidate[user_id] - self._save_overrides(candidate) - self._overrides = candidate - self._store_corrupt = False + self._publish_overrides(candidate) return True - self._overrides = current + self._overrides = _valid_overrides(current) + self._invalid_overrides = { + uid: tier for uid, tier in current.items() if tier not in VALID_TIERS + } self._store_corrupt = False return False + async def async_repair_tier(self, user_id: str, tier: str) -> None: + """Replace an unusable persisted override with an explicitly chosen tier.""" + await self.async_set_tier(user_id, tier) + def filter_tools(self, user_id: str, tools: list[dict]) -> list[dict] | None: """Filter tool list based on user's tier. diff --git a/src/permissions/token_manager.py b/src/permissions/token_manager.py index 516fc79f8..0cc6c776a 100644 --- a/src/permissions/token_manager.py +++ b/src/permissions/token_manager.py @@ -57,8 +57,12 @@ def issue(self, entry: _StoredToken) -> ApiTokenIdentity: def matches(self, identity: ApiTokenIdentity, entry: _StoredToken | None) -> bool: issued = self._issued.get(id(identity)) - return bool(issued is not None and issued[0]() is identity - and issued[1] is entry and identity == entry.identity) + return bool( + issued is not None + and issued[0]() is identity + and issued[1] is entry + and identity == entry.identity + ) @dataclass(frozen=True) @@ -111,6 +115,9 @@ def __init__(self, path: str = "./data/api_tokens.json") -> None: self._path = Path(path) self._lock = asyncio.Lock() self._tokens: dict[str, _StoredToken] = {} + self._raw_entries: list[object] = [] + self._valid_positions: dict[str, list[int]] = {} + self._invalid_entries: list[dict[str, object]] = [] self._identity_issuer = _IdentityIssuer() self._store_status = "missing" self._store_signature: _StoreSignature = None @@ -122,8 +129,16 @@ def __init__(self, path: str = "./data/api_tokens.json") -> None: @staticmethod def _signature(info: os.stat_result) -> _StoreSignature: - return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, - info.st_ctime_ns, info.st_uid, info.st_gid, info.st_mode) + return ( + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + info.st_uid, + info.st_gid, + info.st_mode, + ) def _stat_signature(self) -> _StoreSignature: """Return a cheap change signature without reading token contents.""" @@ -162,35 +177,45 @@ def _invalidate_store(self, status: str, signature: _StoreSignature = None) -> N if status not in _VALID_STATUSES: raise ValueError("invalid token store status") self._tokens = {} + self._raw_entries = [] + self._valid_positions = {} + self._invalid_entries = [] self._store_status = status self._store_signature = signature self._protection_required = True @staticmethod - def _parse_store(data: object) -> dict[str, _StoredToken]: + def _parse_store( + data: object, + ) -> tuple[dict[str, _StoredToken], dict[str, list[int]], list[dict[str, object]]]: """Load every entry accepted by v3.98.0 and isolate invalid entries.""" if not isinstance(data, list): raise ValueError("token store root must be a list") parsed: dict[str, _StoredToken] = {} - for entry in data: + positions: dict[str, list[int]] = {} + invalid: list[dict[str, object]] = [] + for index, entry in enumerate(data): + reason = "invalid token entry" try: + if not isinstance(entry, dict): + reason = "entry is not an object" + continue user_id = entry.get("user_id", "") token_hash = entry.get("token_hash", "") token_prefix = entry.get("token_prefix", "") if not user_id or not token_hash: - log.warning("Skipping token entry: missing user_id or token_hash") + reason = "missing user_id or token_hash" continue tier = entry.get("tier", "admin") if tier not in _VALID_TIERS: - log.warning("Skipping token %s: invalid tier %r", user_id, tier) + reason = "invalid tier" continue allowed_tools = entry.get("allowed_tools", []) - if (not isinstance(allowed_tools, list) - or not all(isinstance(tool, str) for tool in allowed_tools)): - log.warning( - "Skipping token %s: allowed_tools must be a list of strings", user_id - ) + if not isinstance(allowed_tools, list) or not all( + isinstance(tool, str) for tool in allowed_tools + ): + reason = "allowed_tools must be a list of strings" continue raw_hosts = entry.get("allowed_hosts") if raw_hosts is None: @@ -200,10 +225,7 @@ def _parse_store(data: object) -> dict[str, _StoredToken]: ): allowed_hosts = raw_hosts else: - log.warning( - "Skipping token %s: allowed_hosts must be a list of strings or null", - user_id, - ) + reason = "allowed_hosts must be a list of strings or null" continue identity = ApiTokenIdentity( token="", @@ -215,12 +237,29 @@ def _parse_store(data: object) -> dict[str, _StoredToken]: allowed_hosts=allowed_hosts, default_host=str(entry.get("default_host", "")), ) + if user_id in positions: + invalid.append( + { + "index": positions[user_id][-1], + "reason": "duplicate user_id (shadowed)", + "user_id": user_id, + } + ) parsed[user_id] = _StoredToken(token_hash, token_prefix, identity) - except Exception as exc: - log.warning("Skipping invalid token entry: %s", exc) + positions.setdefault(user_id, []).append(index) + reason = "" + except Exception: + reason = "invalid token identity fields" + finally: + if reason: + # Expose only string user IDs, never hashes, prefixes or arbitrary row contents. + safe_item = {"index": index, "reason": reason} + if isinstance(entry, dict) and isinstance(entry.get("user_id"), str): + safe_item["user_id"] = entry["user_id"] + invalid.append(safe_item) if data and not parsed: raise ValueError("token store has no valid entries") - return parsed + return parsed, positions, invalid def _refresh_store(self, *, force: bool = False) -> None: """Reload only after an external store change and fail closed on error.""" @@ -238,11 +277,15 @@ def _refresh_store(self, *, force: bool = False) -> None: log.warning("Previously observed API token store is missing") return self._tokens = {} + self._raw_entries = [] + self._valid_positions = {} + self._invalid_entries = [] self._store_status = "missing" self._store_signature = None return try: - parsed = self._parse_store(json.loads(self._read_store(signature))) + raw_entries = json.loads(self._read_store(signature)) + parsed, positions, invalid = self._parse_store(raw_entries) # A non-atomic external writer may have changed the file while it # was read. Do not authenticate against an uncertain snapshot. if self._stat_signature() != signature: @@ -260,8 +303,17 @@ def _refresh_store(self, *, force: bool = False) -> None: log.warning("API token store is malformed") return self._tokens = parsed + self._raw_entries = raw_entries + self._valid_positions = positions + self._invalid_entries = invalid self._store_status = "valid" self._store_signature = signature + if invalid: + log.warning( + "API token store contains %d unusable entry/entries; " + "retained on writes (see Tokens page)", + len(invalid), + ) if parsed: self._protection_required = True @@ -271,19 +323,37 @@ def _require_writable_store(self) -> None: raise RuntimeError("API token store must be repaired before credentials can change") def _save( - self, candidate: dict[str, _StoredToken] | None = None, *, allow_empty: bool = False, + self, + candidate: dict[str, _StoredToken] | None = None, + *, + allow_empty: bool = False, expected_signature: _StoreSignature = None, ) -> bool: self._path.parent.mkdir(parents=True, exist_ok=True) - data = [] - for st in (self._tokens if candidate is None else candidate).values(): + data = list(self._raw_entries) + desired = self._tokens if candidate is None else candidate + removed = { + index + for user_id, indices in self._valid_positions.items() + for index in indices + if user_id not in desired + } + data = [row for index, row in enumerate(data) if index not in removed] + for user_id, st in desired.items(): if st.identity.tier not in ("admin", "user", "guest"): raise ValueError("Invalid token tier") d = st.identity.model_dump() del d["token"] d["token_hash"] = st.token_hash d["token_prefix"] = st.token_prefix - data.append(d) + if ( + user_id in self._valid_positions + and self._valid_positions[user_id][-1] not in removed + ): + index = self._valid_positions[user_id][-1] + data[index - sum(i < index for i in removed)] = d + else: + data.append(d) # A last-credential guard may await. Do not resurrect credentials # revoked by an external writer while that guard ran. expected = self._store_signature if expected_signature is None else expected_signature @@ -296,12 +366,7 @@ def _save( # Never pair our candidate with a separately observed writer's inode. # Publish ONLY a complete safe read, not the stale local candidate. self._refresh_store(force=True) - actual = [] - for st in self._tokens.values(): - record = st.identity.model_dump(exclude={"token"}) - record.update(token_hash=st.token_hash, token_prefix=st.token_prefix) - actual.append(record) - if self._store_status != "valid" or actual != data: + if self._store_status != "valid" or self._raw_entries != data: raise RuntimeError("API token store changed during credential publication") if not self._tokens and allow_empty: self._protection_required = False @@ -381,6 +446,54 @@ def list_tokens(self) -> list[dict]: result.append(d) return result + def invalid_entries(self) -> list[dict[str, object]]: + """Non-secret location/reason for entries excluded from authentication.""" + self._refresh_store() + return [dict(item) for item in self._invalid_entries] + + async def remove_unusable_entry( + self, index: int, expected_reason: str, expected_user_id: str | None = None + ) -> bool: + """Explicitly remove one diagnosed raw row without changing other rows.""" + async with config_transaction(), self._lock: + self._require_writable_store() + if not isinstance(index, int) or index < 0: + return False + if index >= len(self._raw_entries): + raise ValueError("unusable token entry changed; refresh and try again") + diagnosis = next( + (item for item in self._invalid_entries if item["index"] == index), None + ) + if diagnosis is None: + raise ValueError("unusable token entry changed; refresh and try again") + if ( + diagnosis["reason"] != expected_reason + or diagnosis.get("user_id") != expected_user_id + ): + raise ValueError("unusable token entry changed; refresh and try again") + rows = [row for i, row in enumerate(self._raw_entries) if i != index] + candidate, _, _ = self._parse_store(rows) + if not await self._may_publish_candidate(candidate): + raise PermissionError( + "cannot remove the last usable credential from a non-loopback listener" + ) + expected = self._store_signature + if self._stat_signature() != expected: + self._refresh_store(force=True) + raise RuntimeError("API token store changed before credential publication") + self.durability_degraded = not write_private_atomic( + self._path, json.dumps(rows, indent=2) + ) + self._refresh_store(force=True) + if self._store_status != "valid" or self._raw_entries != rows: + raise RuntimeError("API token store changed during credential publication") + return True + + def unusable_entry_count(self) -> int: + """Number of malformed entries and shadowed duplicate identities.""" + self._refresh_store() + return len(self._invalid_entries) + def get(self, user_id: str) -> ApiTokenIdentity | None: return self.auth_snapshot().get(user_id) @@ -455,7 +568,9 @@ async def regenerate_token(self, user_id: str) -> str | None: raw_token = secrets.token_urlsafe(48) candidate = dict(self._tokens) candidate[user_id] = _StoredToken( - _hash_token(raw_token), raw_token[:8], st.identity.model_copy(deep=True), + _hash_token(raw_token), + raw_token[:8], + st.identity.model_copy(deep=True), ) self._save(candidate) log.info("Regenerated API token for user_id=%s", user_id) @@ -469,12 +584,30 @@ async def delete_token(self, user_id: str) -> bool: candidate = dict(self._tokens) expected_signature = self._store_signature del candidate[user_id] + if not candidate and self._invalid_entries: + removed_positions = set(self._valid_positions[user_id]) + remaining = [ + row for i, row in enumerate(self._raw_entries) if i not in removed_positions + ] + try: + remaining_usable, _, remaining_invalid = self._parse_store(remaining) + except ValueError: + remaining_usable = {} + remaining_invalid = [ + item for item in self._invalid_entries + if item["index"] not in removed_positions + ] + if remaining_invalid and not remaining_usable: + count = len(remaining_invalid) + noun = "entry" if count == 1 else "entries" + raise ValueError(f"remove or repair the {count} unusable {noun} first") if not await self._may_publish_candidate(candidate): raise PermissionError( "cannot remove the last usable credential from a non-loopback listener" ) self._save( - candidate, allow_empty=self._last_credential_guard is not None, + candidate, + allow_empty=self._last_credential_guard is not None, expected_signature=expected_signature, ) log.info("Deleted API token for user_id=%s", user_id) diff --git a/src/tools/mcp/client.py b/src/tools/mcp/client.py index 8809d4dc7..e8c228d83 100644 --- a/src/tools/mcp/client.py +++ b/src/tools/mcp/client.py @@ -29,6 +29,7 @@ import itertools import json import re +from collections import deque from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -67,6 +68,7 @@ _DEFAULT_CALL_TIMEOUT = 120.0 _MAX_SERVER_REPLY_TASKS = 32 _SERVER_REPLY_DRAIN_TIMEOUT = 5.0 +_COMPLETED_REQUEST_IDS_LIMIT = 256 _CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") @@ -150,6 +152,8 @@ def __init__( self._http: HttpTransport | None = None self._ids = itertools.count(1) self._pending: dict[Any, asyncio.Future[dict]] = {} + self._completed_request_ids: set[Any] = set() + self._completed_request_id_order: deque[Any] = deque() self._lost_reason: str | None = None self._disconnect_task: asyncio.Task[None] | None = None self._server_reply_tasks: set[asyncio.Task[None]] = set() @@ -412,15 +416,43 @@ async def exchange() -> dict: finally: self._pending.pop(req_id, None) + def _remember_completed_request_id(self, req_id: Any) -> None: + """Remember successful response IDs briefly to recognize later duplicates.""" + if req_id in self._completed_request_ids: + return + self._completed_request_ids.add(req_id) + self._completed_request_id_order.append(req_id) + while len(self._completed_request_id_order) > _COMPLETED_REQUEST_IDS_LIMIT: + expired = self._completed_request_id_order.popleft() + self._completed_request_ids.discard(expired) + def _on_stdio_message(self, msg: dict) -> None: kind = proto.message_kind(msg) if kind == proto.KIND_RESPONSE: - future = self._pending.get(msg.get("id")) - if future is not None and not future.done(): - future.set_result(msg) + req_id = msg.get("id") + future = self._pending.get(req_id) + if future is not None: + if not future.done(): + self._remember_completed_request_id(req_id) + future.set_result(msg) + elif not future.cancelled(): + log.warning( + "MCP %s: dropping duplicate response for request id=%r", + self.name, + msg.get("id"), + ) + else: + log.debug("MCP %s: dropping late/unknown response id=%r", self.name, req_id) else: - # Late response after timeout/cancellation: ignored by rule. - log.debug("MCP %s: dropping late/unknown response id=%r", self.name, msg.get("id")) + if req_id in self._completed_request_ids: + log.warning( + "MCP %s: dropping duplicate response for request id=%r", + self.name, + req_id, + ) + else: + # Late response after timeout/cancellation and unknown IDs stay quiet. + log.debug("MCP %s: dropping late/unknown response id=%r", self.name, req_id) elif kind == proto.KIND_NOTIFICATION: self._handle_notification(msg) elif kind == proto.KIND_REQUEST: @@ -980,7 +1012,14 @@ async def _http_roundtrip( def on_message(msg: dict) -> None: kind = proto.message_kind(msg) if kind == proto.KIND_RESPONSE and msg.get("id") == req_id: - collected["response"] = msg + if collected["response"] is None: + collected["response"] = msg + else: + log.warning( + "MCP %s: dropping duplicate response for request id=%r", + self.name, + req_id, + ) elif kind == proto.KIND_NOTIFICATION: self._handle_notification(msg) elif kind == proto.KIND_REQUEST: @@ -1001,15 +1040,38 @@ def on_message(msg: dict) -> None: ) if outcome.kind == RESULT_JSON: response = self._match_response(outcome.messages, req_id) + duplicates = sum( + 1 + for msg in outcome.messages + if proto.message_kind(msg) == proto.KIND_RESPONSE and msg.get("id") == req_id + ) - (1 if response is not None else 0) + for _ in range(duplicates): + log.warning( + "MCP %s: dropping duplicate response for request id=%r", + self.name, + req_id, + ) for msg in outcome.messages: - if msg is not response: - on_message(msg) + if proto.message_kind(msg) == proto.KIND_RESPONSE and msg.get("id") == req_id: + continue + on_message(msg) if response is not None: return response if collected["response"] is not None: return collected["response"] if outcome.kind == RESULT_HTTP_ERROR: matched = self._match_response(outcome.messages, req_id) + duplicates = sum( + 1 + for msg in outcome.messages + if proto.message_kind(msg) == proto.KIND_RESPONSE and msg.get("id") == req_id + ) - (1 if matched is not None else 0) + for _ in range(duplicates): + log.warning( + "MCP %s: dropping duplicate response for request id=%r", + self.name, + req_id, + ) err = proto.rpc_error(matched) if matched is not None else None if outcome.status == 404 and self._http.session_id: raise _SessionLostError(self.name) diff --git a/src/usage/rollup.py b/src/usage/rollup.py index efca926bb..4a51948d1 100644 --- a/src/usage/rollup.py +++ b/src/usage/rollup.py @@ -552,9 +552,9 @@ def _ingest_trajectory(self, record: dict, kind: str, conn=None) -> bool: self._lock.release() @staticmethod - def _tool_fact(raw: bytes) -> tuple | None: + def _tool_fact(raw: bytes | memoryview) -> tuple | None: try: - record = json.loads(raw) + record = json.loads(raw if isinstance(raw, bytes) else raw.tobytes()) except (json.JSONDecodeError, UnicodeDecodeError): return None if not isinstance(record, dict): @@ -645,16 +645,19 @@ def _source_cursor( def _apply_raw_rows( self, conn, - raws: list[bytes], + raws: list[bytes | memoryview], *, trajectory_kind: str | None, ) -> tuple[int, int]: accepted = malformed = 0 for raw in raws: - if not raw.strip(): + if isinstance(raw, memoryview): + if not any(byte not in b" \t\r\n\v\f" for byte in raw): + continue + elif not raw.strip(): continue try: - parsed = json.loads(raw) + parsed = json.loads(raw if isinstance(raw, bytes) else raw.tobytes()) except (json.JSONDecodeError, UnicodeDecodeError): malformed += 1 continue @@ -691,7 +694,7 @@ def _consume_reverse_batch( conn.execute("BEGIN IMMEDIATE") cursor = self._source_cursor(conn, kind, display_path, stat) low = min(int(cursor["low_offset"]), stat.st_size) - raws: list[bytes] = [] + raws: list[bytes | memoryview] = [] consumed_bytes = 0 while low > 0 and len(raws) < _BACKFILL_RECORDS: previous = _previous_line(handle, low) @@ -741,20 +744,66 @@ def _consume_tail( if high >= stat.st_size: conn.commit() return - handle.seek(high) - data = handle.read(min(stat.st_size - high, _BACKFILL_BYTES + 1)) - newline = data.rfind(b"\n") - if newline < 0: - conn.commit() - return - complete = data[: newline + 1] - if len(complete) > _BACKFILL_BYTES and b"\n" not in data[:_BACKFILL_BYTES]: - first_newline = complete.find(b"\n") - raws = [b""] + complete[first_newline + 1 :].splitlines() - else: - raws = complete.splitlines() + raws: list[bytes | memoryview] = [] + consumed = 0 + batch_start = high + handle.seek(batch_start) + batch_buffer = handle.read(min(stat.st_size - batch_start, _BACKFILL_BYTES + 1)) + # Process a bounded batch. A single oversized row is represented by + # a sentinel, but scan its boundary in fixed-size chunks so it + # cannot permanently block later appended rows. + while high < stat.st_size and len(raws) < _BACKFILL_RECORDS: + remaining = stat.st_size - high + relative = high - batch_start + # The read above is bounded to one batch. A row may begin near + # its end, so clamp the search to bytes actually present rather + # than treating the requested probe length as available input. + probe_end = min( + len(batch_buffer), relative + min(remaining, _BACKFILL_BYTES + 1) + ) + newline = batch_buffer.find(b"\n", relative, probe_end) + if newline < 0: + if probe_end - relative < min(remaining, _BACKFILL_BYTES + 1) and raws: + break + if remaining <= _BACKFILL_BYTES: + # No complete row yet; retain the cursor at its start. + if not raws: + conn.commit() + return + break + row_size = _BACKFILL_BYTES + 1 + else: + row_size = newline - relative + 1 + if row_size > _BACKFILL_BYTES: + # Locate the terminator without retaining a potentially + # unbounded trajectory row in memory. + boundary = high + found_boundary = None + while boundary < stat.st_size: + handle.seek(boundary) + chunk = handle.read(min(64 * 1024, stat.st_size - boundary)) + found = chunk.find(b"\n") + if found >= 0: + found_boundary = boundary + found + 1 + break + boundary += len(chunk) + if found_boundary is None: + conn.commit() + if not raws: + return + break + raws.append(b"") + consumed += found_boundary - high + high = found_boundary + else: + # Keep a view into the bounded batch buffer. Copying each + # row here duplicates up to an entire batch before parsing. + raws.append(memoryview(batch_buffer)[relative:newline]) + consumed += row_size + high += row_size + if consumed >= _BACKFILL_BYTES: + break _, malformed = self._apply_raw_rows(conn, raws, trajectory_kind=trajectory_kind) - high += newline + 1 conn.execute( """UPDATE ingestion_cursors SET high_offset=?, malformed_rows=malformed_rows+?, updated_at=? WHERE source_id=?""", @@ -806,6 +855,8 @@ async def _audit_snapshots( ] async def _one_backfill_pass(self) -> bool: + # Scan failures describe this pass, not the lifetime of the process. + self._source_scan_errors = 0 trajectory = await asyncio.to_thread(self._trajectory_snapshots) audit = await self._audit_snapshots() # Global newest-first order across chat, agent, and rotated audit @@ -854,15 +905,22 @@ async def _one_backfill_pass(self) -> bool: trajectory_kind=record_kind, ) with self._lock, closing(self._connect()) as conn: - complete = all( - bool( - conn.execute( - "SELECT initial_complete FROM ingestion_cursors WHERE source_id=?", - (f"{kind}:{stat.st_dev}:{stat.st_ino}",), - ).fetchone()[0] - ) - for kind, _path, _handle, stat, _record_kind in snapshots - ) + cursor_complete = True + for kind, _path, _handle, stat, _record_kind in snapshots: + source_id = f"{kind}:{stat.st_dev}:{stat.st_ino}" + row = conn.execute( + "SELECT initial_complete, high_offset FROM ingestion_cursors " + "WHERE source_id=?", + (source_id,), + ).fetchone() + # A non-newline-terminated suffix is not a source row yet. + # Coverage is complete through the last complete record, + # not through arbitrary bytes a writer may still append. + tail_offset = self._last_complete_offset(_handle, stat.st_size) + if row is None or not bool(row[0]) or int(row[1]) < tail_offset: + cursor_complete = False + break + complete = cursor_complete and self._source_scan_errors == 0 self._set_backfill_state(complete) return complete finally: @@ -892,18 +950,33 @@ def _set_backfill_state(self, complete: bool) -> None: async def _backfill_loop(self) -> None: while not self._stop.is_set(): try: - complete = await self._one_backfill_pass() + await self._one_backfill_pass() except asyncio.CancelledError: raise except Exception: log.exception("Usage backfill pass failed (non-fatal; will resume)") - complete = False - delay = _TAIL_INTERVAL_SECONDS if complete else _BACKFILL_PAUSE_SECONDS + delay = ( + _BACKFILL_PAUSE_SECONDS + if self._has_cursor_work() + else _TAIL_INTERVAL_SECONDS + ) try: await asyncio.wait_for(self._stop.wait(), timeout=delay) except TimeoutError: pass + def _has_cursor_work(self) -> bool: + """Whether a known source has historical cursor work remaining.""" + try: + with self._lock, closing(self._connect()) as conn: + return conn.execute( + "SELECT 1 FROM ingestion_cursors WHERE initial_complete=0 LIMIT 1" + ).fetchone() is not None + except Exception: + # If cursor state cannot be read, avoid a hot loop; the next normal + # tail pass will retry and log through the ordinary failure path. + return False + async def start(self) -> None: """Start bounded reconciliation without waiting for any source scan.""" if not self.available or (self._task is not None and not self._task.done()): @@ -1206,9 +1279,7 @@ def _summary_sync(self, range_name: str) -> dict: "cost": { "modeled_cost_usd": None, "actual_spend_usd": ( - float(actual_cost["total"]) - if actual_cost["total"] is not None - else None + float(actual_cost["total"]) if actual_cost["total"] is not None else None ), "actual_spend_generations": int(actual_cost["reported"] or 0), "note": ( diff --git a/src/web/api/__init__.py b/src/web/api/__init__.py index 3a2c41f79..063d96ecf 100644 --- a/src/web/api/__init__.py +++ b/src/web/api/__init__.py @@ -55,12 +55,12 @@ register_status_info, ) from .discord_connection import register_discord_connection +from .discord_identity import register_discord_identity from .hosts import register_hosts from .integrations import ( register_grafana_alerts, register_mcp_servers, register_outbound_webhooks, - register_slack, ) from .knowledge_mem import ( register_knowledge, @@ -128,6 +128,8 @@ def create_api_routes(bot: OdinBot) -> web.RouteTableDef: register_discord_connection(routes, bot) + register_discord_identity(routes, bot) + register_quick_actions(routes, bot) register_personality(routes, bot) @@ -156,8 +158,6 @@ def create_api_routes(bot: OdinBot) -> web.RouteTableDef: register_mcp_servers(routes, bot) - register_slack(routes, bot) - register_grafana_alerts(routes, bot) register_knowledge(routes, bot) diff --git a/src/web/api/codex_admin.py b/src/web/api/codex_admin.py index 7b74549e7..5e3e9bbd3 100644 --- a/src/web/api/codex_admin.py +++ b/src/web/api/codex_admin.py @@ -31,6 +31,7 @@ async def codex_status(_request: web.Request) -> web.Response: import time as _time + from ...llm.account_key import opaque_account_key from ...llm.codex_auth import _decode_jwt_payload accounts = [] @@ -38,17 +39,42 @@ async def codex_status(_request: web.Request) -> web.Response: try: creds = auth._load() payload = _decode_jwt_payload(creds.get("access_token", "")) + account_id = creds.get("account_id", payload.get("chatgpt_account_id", "")) + snapshot = pool.quota.snapshot_for(opaque_account_key(account_id)) + quota = ({ + "primary": ( + snapshot.primary.to_dict() + if snapshot.primary and snapshot.primary.window_minutes else None + ), + "secondary": ( + snapshot.secondary.to_dict() + if snapshot.secondary and snapshot.secondary.window_minutes else None + ), + "observed_at": snapshot.observed_at, + "limit_reached_type": snapshot.limit_reached_type, + } if snapshot is not None else None) + failure_reader = getattr(pool, "quota_check_failure", None) + check_failure = failure_reader(i) if callable(failure_reader) else None expires_at = creds.get("expires_at", 0) accounts.append({ "index": i, "label": creds.get("label", ""), "email": creds.get("email", payload.get("email", "unknown")), - "account_id": creds.get("account_id", payload.get("chatgpt_account_id", "")), + "account_id": account_id, "plan_type": creds.get("plan_type", payload.get("chatgpt_plan_type", "")), "expires_at": expires_at, "expired": _time.time() >= expires_at, "rate_limited": auth.is_rate_limited(), "is_current": i == pool._current_index, + "quota": quota, + "limit_reached": bool(snapshot and ( + any(window is not None and window.used_percent >= 100 + for window in (snapshot.primary, snapshot.secondary)) + or (snapshot.limit_reached_type and not any( + window is not None for window in (snapshot.primary, snapshot.secondary) + )) + )), + "quota_check_failed": check_failure, }) except Exception as e: accounts.append({"index": i, "error": str(e)}) diff --git a/src/web/api/config_admin.py b/src/web/api/config_admin.py index 2e0ffcf14..624efa5c4 100644 --- a/src/web/api/config_admin.py +++ b/src/web/api/config_admin.py @@ -73,14 +73,20 @@ def _listener_admin_current(request: web.Request, bot) -> bool: manager = getattr(bot, "api_token_manager", None) current = bot.config.web if _usable_web_credential(current.api_token) and hmac.compare_digest( - current.api_token, bearer, + current.api_token, + bearer, ): return True identity = manager.resolve(bearer) if manager else None if identity is None: - identity = next((entry for entry in current.api_tokens - if _usable_web_credential(entry.token) - and hmac.compare_digest(entry.token, bearer)), None) + identity = next( + ( + entry + for entry in current.api_tokens + if _usable_web_credential(entry.token) and hmac.compare_digest(entry.token, bearer) + ), + None, + ) return identity is not None and identity.tier == "admin" @@ -132,8 +138,10 @@ def _listener_status_payload(bot, initialization_state) -> dict[str, object]: else [] ) running_scope = ( - "unavailable" if not listening_hosts - else "loopback" if all(numeric_loopback(host) for host in listening_hosts) + "unavailable" + if not listening_hosts + else "loopback" + if all(numeric_loopback(host) for host in listening_hosts) else "beyond_loopback" ) authorized = not initialization_state.loopback_restricted @@ -159,8 +167,10 @@ def _listener_status_payload(bot, initialization_state) -> dict[str, object]: return { "authorized": authorized, "authorization_source": ( - "explicit" if initialization_state.explicit_widening - else "legacy" if authorized + "explicit" + if initialization_state.explicit_widening + else "legacy" + if authorized else "restricted" ), "loopback_restricted": initialization_state.loopback_restricted, @@ -239,9 +249,7 @@ async def setup_complete(request: web.Request) -> web.Response: # web-only installation and Discord can attach later. discord_token = (data.get("discord_token") or "").strip() or None if discord_token is not None and not validate_token_format(discord_token): - return web.json_response( - {"error": "discord_token format is invalid"}, status=400 - ) + return web.json_response({"error": "discord_token format is invalid"}, status=400) # Extract optional fields hosts: dict[str, dict[str, str]] = {} @@ -295,7 +303,9 @@ async def setup_complete(request: web.Request) -> web.Response: updates["browser"] = {"enabled": features["browser"]} try: result = await coordinator.submit( - bot, discord_token=discord_token, web_api_token=web_api_token, + bot, + discord_token=discord_token, + web_api_token=web_api_token, config_updates=updates, ) except (InitializationAlreadyCompleteError, InitializationRecoveryRequiredError): @@ -320,26 +330,35 @@ async def setup_complete(request: web.Request) -> web.Response: "state": "failed", "error": result.activation_detail or "gateway unavailable", } - return web.json_response({ - "status": "ok", "mode": "complete", "persisted": result.persisted, - "discord": discord, - "restart_required": list(result.restart_required), - "message": ( - "Setup saved. Restart Odin to apply: " + ", ".join(result.restart_required) - if result.restart_required else "Setup saved." - ), - }) + return web.json_response( + { + "status": "ok", + "mode": "complete", + "persisted": result.persisted, + "discord": discord, + "restart_required": list(result.restart_required), + "message": ( + "Setup saved. Restart Odin to apply: " + ", ".join(result.restart_required) + if result.restart_required + else "Setup saved." + ), + } + ) @routes.post("/api/setup/listener") async def setup_listener(request: web.Request) -> web.Response: """Reauthenticate a raw admin bearer and consent to web.host on next restart.""" if getattr(request, "_session_managed", False) or not request.headers.get( - "Authorization", "", + "Authorization", + "", ).startswith("Bearer "): - return web.json_response({ - "error": "Re-enter a current admin API token to authorize listener exposure; " - "browser sessions and query credentials cannot record consent.", - }, status=403) + return web.json_response( + { + "error": "Re-enter a current admin API token to authorize listener exposure; " + "browser sessions and query credentials cannot record consent.", + }, + status=403, + ) identity = getattr(request, "_api_identity", None) if identity is None or getattr(identity, "tier", None) != "admin": # Dev-mode access must never authorize a durable exposure decision. @@ -371,23 +390,25 @@ async def setup_listener(request: web.Request) -> web.Response: return web.json_response({"error": str(exc)}, status=409) state = await coordinator.state() listener = _listener_status_payload(bot, state) - return web.json_response({ - "persisted": True, - # Keep the original scalar readback for API clients while the UI - # consumes the complete desired-versus-running listener record. - "loopback_restricted": listener["loopback_restricted"], - "explicit_widening": listener["explicit_widening"], - "configured_host": listener["configured_host"], - "listener": listener, - "restart_required": ["web.listener"], - "message": ( - "Exposure authorization saved. Restart Odin to apply the configured listener; " - "the running listener is unchanged." - if expose_beyond_loopback else - "Loopback restriction saved. Restart Odin to narrow the listener; " - "the running listener is unchanged." - ), - }) + return web.json_response( + { + "persisted": True, + # Keep the original scalar readback for API clients while the UI + # consumes the complete desired-versus-running listener record. + "loopback_restricted": listener["loopback_restricted"], + "explicit_widening": listener["explicit_widening"], + "configured_host": listener["configured_host"], + "listener": listener, + "restart_required": ["web.listener"], + "message": ( + "Exposure authorization saved. Restart Odin to apply the configured listener; " + "the running listener is unchanged." + if expose_beyond_loopback + else "Loopback restriction saved. Restart Odin to narrow the listener; " + "the running listener is unchanged." + ), + } + ) def register_status_info(routes: web.RouteTableDef, bot) -> None: @@ -416,9 +437,7 @@ async def get_status(_request: web.Request) -> web.Response: if not isinstance(agent_agents, dict): raise AttributeError agent_count = len(agent_agents) - agent_running = sum( - 1 for a in agent_agents.values() if a.status == "running" - ) + agent_running = sum(1 for a in agent_agents.values() if a.status == "running") except (AttributeError, TypeError): agent_count = 0 agent_running = 0 @@ -429,34 +448,34 @@ async def get_status(_request: web.Request) -> web.Response: if not isinstance(proc_procs, dict): raise AttributeError process_count = len(proc_procs) - process_running = sum( - 1 for p in proc_procs.values() if p.status == "running" - ) + process_running = sum(1 for p in proc_procs.values() if p.status == "running") except (AttributeError, TypeError): process_count = 0 process_running = 0 - return web.json_response({ - "version": get_version(), - "status": "online" if bot.is_ready() else "starting", - "uptime_seconds": round(uptime, 1), - "guilds": guilds, - "guild_count": len(guilds), - "user_count": user_count, - "tool_count": len(tools), - "skill_count": len(bot.skill_manager.list_skills()), - "session_count": bot.sessions.count(), - "loop_count": bot.loop_manager.active_count, - "schedule_count": len(bot.scheduler.list_all()), - "schedule_failing": sum( - 1 for s in bot.scheduler.list_all() if s.get("consecutive_failures", 0) > 0 - ), - "schedule_paused": sum(1 for s in bot.scheduler.list_all() if s.get("paused")), - "agent_count": agent_count, - "agent_running": agent_running, - "process_count": process_count, - "process_running": process_running, - }) + return web.json_response( + { + "version": get_version(), + "status": "online" if bot.is_ready() else "starting", + "uptime_seconds": round(uptime, 1), + "guilds": guilds, + "guild_count": len(guilds), + "user_count": user_count, + "tool_count": len(tools), + "skill_count": len(bot.skill_manager.list_skills()), + "session_count": bot.sessions.count(), + "loop_count": bot.loop_manager.active_count, + "schedule_count": len(bot.scheduler.list_all()), + "schedule_failing": sum( + 1 for s in bot.scheduler.list_all() if s.get("consecutive_failures", 0) > 0 + ), + "schedule_paused": sum(1 for s in bot.scheduler.list_all() if s.get("paused")), + "agent_count": agent_count, + "agent_running": agent_running, + "process_count": process_count, + "process_running": process_running, + } + ) def register_discord_config(routes: web.RouteTableDef, bot) -> None: @@ -477,31 +496,39 @@ async def discord_guilds(_request: web.Request) -> web.Response: cid = str(ch.id) ch_cfg = cc.get_channel_config(cid) effective_mention = cc.should_require_mention( - gid, cid, bot.config.discord.require_mention, + gid, + cid, + bot.config.discord.require_mention, ) effective_enabled = cc.is_enabled(gid, cid) effective_bots = cc.should_respond_to_bots( - gid, cid, bot.config.discord.respond_to_bots, + gid, + cid, + bot.config.discord.respond_to_bots, ) - channels.append({ - "id": cid, - "name": ch.name, - "category": ch.category.name if ch.category else None, - "config": ch_cfg, - "effective": { - "enabled": effective_enabled, - "require_mention": effective_mention, - "respond_to_bots": effective_bots, - }, - }) - result.append({ - "id": gid, - "name": g.name, - "member_count": g.member_count or 0, - "icon_url": str(g.icon.url) if g.icon else None, - "config": guild_cfg, - "channels": channels, - }) + channels.append( + { + "id": cid, + "name": ch.name, + "category": ch.category.name if ch.category else None, + "config": ch_cfg, + "effective": { + "enabled": effective_enabled, + "require_mention": effective_mention, + "respond_to_bots": effective_bots, + }, + } + ) + result.append( + { + "id": gid, + "name": g.name, + "member_count": g.member_count or 0, + "icon_url": str(g.icon.url) if g.icon else None, + "config": guild_cfg, + "channels": channels, + } + ) return web.json_response(result) @routes.get("/api/discord/members") @@ -555,11 +582,13 @@ async def update_channel_config(request: web.Request) -> web.Response: @routes.get("/api/health/components") async def get_health_components(_request: web.Request) -> web.Response: from ...health.checker import check_all + return web.json_response(check_all(bot)) @routes.get("/api/resource-usage") async def get_resource_usage(_request: web.Request) -> web.Response: from ...monitoring.resource_usage import collect_all + return web.json_response(collect_all(bot)) @routes.get("/api/tool-streams") @@ -568,11 +597,13 @@ async def get_tool_streams(_request: web.Request) -> web.Response: streamer = getattr(executor, "output_streamer", None) if executor else None if streamer is None: return web.json_response({"enabled": False, "streams": []}) - return web.json_response({ - "enabled": True, - "enabled_tools": sorted(streamer.enabled_tools), - "active_streams": streamer.get_active_streams(), - }) + return web.json_response( + { + "enabled": True, + "enabled_tools": sorted(streamer.enabled_tools), + "active_streams": streamer.get_active_streams(), + } + ) @routes.get("/api/config") async def get_config(_request: web.Request) -> web.Response: @@ -625,9 +656,11 @@ async def update_image_model_intent(request: web.Request) -> web.Response: return web.json_response({"error": "invalid JSON"}, status=400) operations = body.get("operations") if isinstance(body, dict) else None if ( - not isinstance(body, dict) or set(body) != {"operations", "expected_revision"} + not isinstance(body, dict) + or set(body) != {"operations", "expected_revision"} or not isinstance(body.get("expected_revision"), str) - or not isinstance(operations, dict) or not operations + or not isinstance(operations, dict) + or not operations or not set(operations).issubset({"image_model", "outer_model"}) or any(value not in ("follow", "pin") for value in operations.values()) ): @@ -655,10 +688,12 @@ async def update_image_model_intent(request: web.Request) -> web.Response: desired = Config(**current) except Exception as exc: return web.json_response( - {"error": f"Image models not changed: {_sanitize_error(exc)}"}, status=500, + {"error": f"Image models not changed: {_sanitize_error(exc)}"}, + status=500, ) persist_exc, was_cancelled = await persist_config_paths_locked( - changes, image_model_intent=operations, + changes, + image_model_intent=operations, ) if persist_exc is not None: if was_cancelled: @@ -671,9 +706,11 @@ async def update_image_model_intent(request: web.Request) -> web.Response: # rebuild a backend or pretend an in-flight generation changed. bot.config = desired for leaf, operation in operations.items(): - metadata[leaf] = {**metadata[leaf], - "effective": getattr(desired.image.openai, leaf), - "status": operation} + metadata[leaf] = { + **metadata[leaf], + "effective": getattr(desired.image.openai, leaf), + "status": operation, + } if was_cancelled: raise asyncio.CancelledError # Equal-value pin/follow changes still change operator intent. @@ -681,15 +718,19 @@ async def update_image_model_intent(request: web.Request) -> web.Response: from ...audit.diff_tracker import compute_dict_diff request["_config_diff"] = compute_dict_diff( - before_intent, metadata, label="image model intent", + before_intent, + metadata, + label="image model intent", ) except Exception: request["_config_diff"] = None - return web.json_response({ - "config": _redact_config(desired.model_dump()), - "image_model_defaults": metadata, - "image_model_revision": _image_intent_revision(metadata), - }) + return web.json_response( + { + "config": _redact_config(desired.model_dump()), + "image_model_defaults": metadata, + "image_model_revision": _image_intent_revision(metadata), + } + ) @routes.put("/api/config") async def update_config(request: web.Request) -> web.Response: @@ -701,6 +742,14 @@ async def update_config(request: web.Request) -> web.Response: if not isinstance(updates, dict): return web.json_response({"error": "expected JSON object"}, status=400) + if (isinstance(updates.get("outbound_webhooks"), dict) + and "targets" in updates["outbound_webhooks"]): + return web.json_response( + {"error": "outbound_webhooks.targets is read-only on this route", + "detail": "Use the outbound webhook management API and panel."}, + status=409, + ) + # The Learned panel owns this live switch. Keep its generic config write # route-level admin-gated as well as centrally protected by middleware, # so alternate route composition cannot turn the UI control into a @@ -719,7 +768,8 @@ async def update_config(request: web.Request) -> web.Response: return denied if not isinstance(updates["computer"], dict): return web.json_response( - {"error": "computer must be a provisioning object"}, status=400) + {"error": "computer must be a provisioning object"}, status=400 + ) if "enabled" in updates["computer"]: return web.json_response( { @@ -849,6 +899,7 @@ async def update_config(request: web.Request) -> web.Response: after_config = _redact_config(new_config.model_dump()) try: from ...audit.diff_tracker import compute_dict_diff + config_diff = compute_dict_diff(before_config, after_config, label="config.yml") except Exception: config_diff = None @@ -895,6 +946,7 @@ async def restart_odin(request: web.Request) -> web.Response: restart.request_restart() import os as _os import signal as _signal + loop = asyncio.get_running_loop() loop.call_later(2.0, _os.kill, _os.getpid(), _signal.SIGTERM) log.info("Restart requested via /api/restart") @@ -966,19 +1018,24 @@ def register_personality(routes: web.RouteTableDef, bot) -> None: @routes.get("/api/personality") async def get_personality(_request: web.Request) -> web.Response: from src.llm.system_prompt import PERSONALITY_PRESETS + p = bot.config.personality if hasattr(bot.config, "personality") else None - user_presets = {k: {"name": v.name, "identity": v.identity, "voice": v.voice} - for k, v in (p.user_presets.items() if p else {})} + user_presets = { + k: {"name": v.name, "identity": v.identity, "voice": v.voice} + for k, v in (p.user_presets.items() if p else {}) + } all_presets = {**{k: v for k, v in PERSONALITY_PRESETS.items()}, **user_presets} - return web.json_response({ - "preset": p.preset if p else "odin", - "custom_name": p.custom_name if p else "", - "custom_identity": p.custom_identity if p else "", - "custom_voice": p.custom_voice if p else "", - "presets": all_presets, - "builtin_presets": list(PERSONALITY_PRESETS.keys()), - "user_presets": list(user_presets.keys()), - }) + return web.json_response( + { + "preset": p.preset if p else "odin", + "custom_name": p.custom_name if p else "", + "custom_identity": p.custom_identity if p else "", + "custom_voice": p.custom_voice if p else "", + "presets": all_presets, + "builtin_presets": list(PERSONALITY_PRESETS.keys()), + "user_presets": list(user_presets.keys()), + } + ) @routes.put("/api/personality") async def update_personality(request: web.Request) -> web.Response: @@ -991,14 +1048,14 @@ async def update_personality(request: web.Request) -> web.Response: custom_identity = data.get("custom_identity", "") custom_voice = data.get("custom_voice", "") from src.config.schema import PersonalityConfig + # ONE transaction over read → compute → persist → publish. Computing the # desired value outside the lock and publishing after releasing it lets # two concurrent saves interleave: runtime ends up with one preset and # disk with the other. async with config_transaction(): existing_user_presets = ( - bot.config.personality.user_presets - if hasattr(bot.config, "personality") else {} + bot.config.personality.user_presets if hasattr(bot.config, "personality") else {} ) desired = PersonalityConfig( preset=preset, @@ -1021,6 +1078,7 @@ async def update_personality(request: web.Request) -> web.Response: ) bot.config.personality = desired from src.llm.system_prompt import register_user_presets + register_user_presets( { k: {"name": v.name, "identity": v.identity, "voice": v.voice} @@ -1041,6 +1099,7 @@ async def save_preset(request: web.Request) -> web.Response: except Exception: return web.json_response({"error": "invalid JSON"}, status=400) import re as _re + name = (data.get("name") or "").strip().lower().replace(" ", "_") if not name: return web.json_response({"error": "name is required"}, status=400) @@ -1055,6 +1114,7 @@ async def save_preset(request: web.Request) -> web.Response: status=400, ) from src.llm.system_prompt import PERSONALITY_PRESETS + if name in PERSONALITY_PRESETS: return web.json_response( {"error": f"cannot overwrite built-in preset '{name}'"}, status=400 @@ -1065,14 +1125,13 @@ async def save_preset(request: web.Request) -> web.Response: if not identity and not voice: return web.json_response({"error": "identity or voice is required"}, status=400) from src.config.schema import PersonalityPreset + async with config_transaction(): desired_presets = dict(bot.config.personality.user_presets) desired_presets[name] = PersonalityPreset( name=display_name, identity=identity, voice=voice ) - desired = bot.config.personality.model_copy( - update={"user_presets": desired_presets} - ) + desired = bot.config.personality.model_copy(update={"user_presets": desired_presets}) persist_exc, was_cancelled = await _persist_personality(desired) if persist_exc is not None: if was_cancelled: @@ -1084,6 +1143,7 @@ async def save_preset(request: web.Request) -> web.Response: ) bot.config.personality = desired from src.llm.system_prompt import register_user_presets + register_user_presets( { k: {"name": v.name, "identity": v.identity, "voice": v.voice} @@ -1102,6 +1162,7 @@ async def save_preset(request: web.Request) -> web.Response: async def delete_preset(request: web.Request) -> web.Response: name = request.match_info["name"] from src.llm.system_prompt import PERSONALITY_PRESETS + if name in PERSONALITY_PRESETS: return web.json_response( {"error": f"cannot delete built-in preset '{name}'"}, status=400 @@ -1128,6 +1189,7 @@ async def delete_preset(request: web.Request) -> web.Response: ) bot.config.personality = desired from src.llm.system_prompt import register_user_presets + register_user_presets( { k: {"name": v.name, "identity": v.identity, "voice": v.voice} @@ -1154,6 +1216,31 @@ async def startup_diagnostics(_request: web.Request) -> web.Response: report = getattr(bot, "startup_report", None) if report is None: return web.json_response({"error": "startup diagnostics not available"}, status=503) - return web.json_response(report.to_dict()) - - + payload = report.to_dict() + payload.setdefault("results", []) + payload.setdefault("failed_count", 0) + payload.setdefault("total_checks", len(payload["results"])) + payload.setdefault("all_passed", True) + pm = getattr(bot, "permissions", None) + tm = getattr(bot, "api_token_manager", None) + unusable = (len(pm.invalid_overrides) if pm else 0) + ( + tm.unusable_entry_count() if tm else 0 + ) + if unusable: + entry_word = "entry" if unusable == 1 else "entries" + payload["results"].append( + { + "name": "credential_store_entries", + "passed": False, + "severity": "warning", + "detail": f"{unusable} unusable credential store {entry_word} found", + "recommendation": ( + "Repair or remove unusable entries on the Permissions " + "and API Tokens pages." + ), + } + ) + payload["failed_count"] += 1 + payload["total_checks"] += 1 + payload["all_passed"] = False + return web.json_response(payload) diff --git a/src/web/api/discord_identity.py b/src/web/api/discord_identity.py new file mode 100644 index 000000000..10d91e9b2 --- /dev/null +++ b/src/web/api/discord_identity.py @@ -0,0 +1,53 @@ +"""Authenticated, bounded-cache Discord identity lookup by snowflake.""" + +from __future__ import annotations + +import asyncio +import re +import time + +from aiohttp import web + +from ..api_common import admin_gate + +_SNOWFLAKE = re.compile(r"^\d{15,25}$") +_CACHE_TTL = 3600 +_CACHE_MAX = 512 + + +def register_discord_identity(routes: web.RouteTableDef, bot) -> None: + cache: dict[str, tuple[float, dict | None]] = {} + lock = asyncio.Lock() + require_admin = admin_gate(bot) + + @routes.get("/api/discord/users/{user_id}") + async def discord_user(request: web.Request) -> web.Response: + denied = require_admin(request) + if denied: + return denied + user_id = request.match_info["user_id"] + if not _SNOWFLAKE.fullmatch(user_id): + return web.json_response({"error": "invalid Discord user ID"}, status=400) + now = time.monotonic() + async with lock: + cached = cache.get(user_id) + if cached and now - cached[0] < _CACHE_TTL: + return web.json_response({"user": cached[1]}) + if len(cache) >= _CACHE_MAX and user_id not in cache: + oldest = min(cache, key=lambda key: cache[key][0]) + cache.pop(oldest, None) + try: + user = await bot.fetch_user(int(user_id)) + except Exception: + # Negative-cache failed lookups briefly to avoid hammering Discord. + cache[user_id] = (now, None) + return web.json_response({"user": None}) + result = { + "id": str(user.id), + "username": str(user.name), + "display_name": str(getattr(user, "global_name", None) or user.name), + "avatar_url": str(user.display_avatar.url) if user.display_avatar else None, + "bot": bool(user.bot), + } + cache[user_id] = (now, result) + return web.json_response({"user": result}) diff --git a/src/web/api/integrations.py b/src/web/api/integrations.py index 763984146..aa924d594 100644 --- a/src/web/api/integrations.py +++ b/src/web/api/integrations.py @@ -505,63 +505,6 @@ async def mutate(): return response -def register_slack(routes: web.RouteTableDef, bot) -> None: - """Slack notifications (verbatim from the monolith).""" - # ------------------------------------------------------------------ - # Slack notifications - # ------------------------------------------------------------------ - - @routes.get("/api/slack/status") - async def slack_status(_request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - notifier = getattr(hs, "slack_notifier", None) if hs else None - if notifier is None: - return web.json_response({"enabled": False}) - return web.json_response({"enabled": True, **notifier.get_status()}) - - @routes.post("/api/slack/test") - async def slack_test(request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - notifier = getattr(hs, "slack_notifier", None) if hs else None - if notifier is None: - return web.json_response({"error": "Slack not enabled"}, status=503) - try: - data = await request.json() - except Exception: - data = {} - channel = data.get("channel") - message = data.get("message", "Test message from Odin") - ok = await notifier.send(str(message)[:500], channel=channel) - return web.json_response({"sent": ok}) - - @routes.post("/api/slack/send") - async def slack_send(request: web.Request) -> web.Response: - hs = getattr(bot, "health_server", None) - notifier = getattr(hs, "slack_notifier", None) if hs else None - if notifier is None: - return web.json_response({"error": "Slack not enabled"}, status=503) - try: - data = await request.json() - except Exception: - return web.json_response({"error": "invalid JSON"}, status=400) - text = data.get("text", "") - if not text: - return web.json_response({"error": "text is required"}, status=400) - channel = data.get("channel") - severity = data.get("severity") - if severity: - ok = await notifier.send_formatted( - title=str(data.get("title", "Odin"))[:150], - message=str(text)[:3000], - severity=str(severity), - source=str(data.get("source", "odin"))[:50], - channel=channel, - ) - else: - ok = await notifier.send(str(text)[:3000], channel=channel) - return web.json_response({"sent": ok}) - - def register_grafana_alerts(routes: web.RouteTableDef, bot) -> None: """Grafana alerts (verbatim from the monolith).""" # ------------------------------------------------------------------ @@ -649,7 +592,145 @@ async def grafana_alerts_remediations(_request: web.Request) -> web.Response: def register_outbound_webhooks(routes: web.RouteTableDef, bot) -> None: - """Outbound webhooks (CRUD + test + stats) (verbatim from the monolith).""" + """Outbound webhook CRUD. Persist before changing the running dispatcher.""" + from copy import deepcopy + + from ...config.persistence import ( + ConfigPersistError, + config_transaction, + persist_webhook_targets_locked, + ) + from ...config.schema import OutboundWebhookTarget + from ...notifications.outbound_webhooks import OutboundWebhookDispatcher + + def _clone(dispatcher): + candidate = OutboundWebhookDispatcher() + for target in dispatcher.list_webhooks(): + # Existing targets were already validated when adopted. Avoid a + # fresh DNS lookup of every sibling on an unrelated CRUD write. + candidate._webhooks[target.id] = deepcopy(target) + return candidate + + async def _mutate(dispatcher, method, *args, **kwargs): + async with config_transaction(): + original = {target.id: deepcopy(target) for target in dispatcher.list_webhooks()} + candidate = _clone(dispatcher) + result = getattr(candidate, method)(*args, **kwargs) + if result is None or result is False: + return result + rows_by_id = { + t.id: OutboundWebhookTarget( + id=t.id, + created_at=t.created_at, + name=t.name, + url=t.url, + secret=t.secret, + events=t.events, + enabled=t.enabled, + scrub_secrets=t.scrub_secrets, + verify_ssl=t.verify_ssl, + ) + for t in candidate.list_webhooks() + } + changed_fields: dict[str, tuple[set[str], set[str]]] = {} + delete_ids: set[str] = set() + if method == "register": + changed_fields[result.id] = (set(rows_by_id[result.id].model_dump()), set()) + persist_rows = [rows_by_id[result.id].model_dump()] + elif method == "update": + ident = str(args[0]) + persist_rows = [rows_by_id[ident].model_dump()] + before = original.get(ident) + if before is not None: + after = candidate.get(ident) + changed_fields[ident] = ( + { + field for field in rows_by_id[ident].model_dump() + if getattr(before, field) != getattr(after, field) + }, + set(rows_by_id[ident].model_dump()), + ) + elif method == "unregister": + delete_ids.add(str(args[0])) + persist_rows = [] + exc, cancelled = await persist_webhook_targets_locked( + persist_rows, + changed_fields=changed_fields, + delete_ids=delete_ids, + create_ids=[result.id] if method == "register" else (), + ) + if exc is not None: + raise exc + dispatcher._webhooks = candidate._webhooks + # Rebind configured runtime rows without discarding entries that + # failed dispatcher registration at boot. + import uuid + + configured = list(bot.config.outbound_webhooks.targets) + configured_runtime_ids = [ + item.id or uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:{index}:{item.url}" + ).hex[:12] + for index, item in enumerate(configured) + ] + for index, item in enumerate(configured): + ident = item.id or uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:{index}:{item.url}" + ).hex[:12] + if ident in rows_by_id: + updated = rows_by_id[ident] + # An id-less row stays id-less on disk when only its name, + # flags, etc. change. Keep that fact in the config snapshot: + # the dispatcher's index-derived ID is not an explicit ID. + # A URL edit is different: persistence writes an explicit + # ID to keep the row addressable after the URL changes. + if not item.id and "url" not in changed_fields.get(ident, (set(),))[0]: + updated = updated.model_copy(update={"id": ""}) + configured[index] = updated + elif ident in delete_ids: + configured[index] = None + active = [item for item in configured if item is not None] + remapped_dispatcher = {} + # Keep each row tied to its pre-mutation disk identity. URLs are + # not identities: duplicate legacy URLs are valid and otherwise + # cause a shifted row to inherit its sibling's runtime target. + active_runtime_ids = [ + ident for ident, item in zip(configured_runtime_ids, configured) + if item is not None and ident not in delete_ids + ] + for index, item in enumerate(active): + old_runtime_id = ( + active_runtime_ids[index] if index < len(active_runtime_ids) else item.id + ) + if not item.id: + new_id = uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:{index}:{item.url}" + ).hex[:12] + if old_runtime_id is not None: + runtime_target = candidate._webhooks.pop(old_runtime_id, None) + if runtime_target is not None: + runtime_target.id = new_id + remapped_dispatcher[new_id] = runtime_target + elif old_runtime_id in candidate._webhooks: + remapped_dispatcher[old_runtime_id] = candidate._webhooks[old_runtime_id] + for ident, target in candidate._webhooks.items(): + remapped_dispatcher.setdefault(ident, target) + dispatcher._webhooks = remapped_dispatcher + present = {item.id for item in active} + bot.config.outbound_webhooks.targets = active + [ + row for ident, row in rows_by_id.items() + if ident not in present and ident not in active_runtime_ids + ] + if cancelled: + raise asyncio.CancelledError + return result + + def _failure(exc): + log.warning("Outbound webhook persistence failed: %s", type(exc).__name__) + if isinstance(exc, ConfigPersistError) and "changed on disk" in str(exc): + return web.json_response({"error": "webhook target changed on disk"}, status=409) + return web.json_response({"error": "could not save outbound webhook targets"}, status=503) + # ------------------------------------------------------------------ # Outbound webhooks (CRUD + test + stats) # ------------------------------------------------------------------ @@ -670,12 +751,24 @@ async def create_outbound_webhook(request: web.Request) -> web.Response: body = await request.json() except Exception: return web.json_response({"error": "invalid JSON body"}, status=400) + if not isinstance(body, dict): + return web.json_response({"error": "invalid webhook configuration"}, status=400) + for field in ("enabled", "scrub_secrets", "verify_ssl"): + if field in body and type(body[field]) is not bool: + return web.json_response({"error": f"{field} must be a boolean"}, status=400) url = body.get("url", "") name = body.get("name", "") - if err := _validate_string(name, "name", 128): + for field, value in (("name", name), ("url", url)): + if not isinstance(value, str): + return web.json_response({"error": f"{field} must be a string"}, status=400) + if err := _validate_string(name, "name", 100): + return web.json_response({"error": err}, status=400) + if err := _validate_string(url, "url", 2048): return web.json_response({"error": err}, status=400) try: - target = dispatcher.register( + target = await _mutate( + dispatcher, + "register", name=name, url=url, secret=body.get("secret", ""), @@ -684,8 +777,10 @@ async def create_outbound_webhook(request: web.Request) -> web.Response: scrub_secrets=body.get("scrub_secrets", True), verify_ssl=body.get("verify_ssl", True), ) - except ValueError as exc: - return web.json_response({"error": str(exc)}, status=400) + except ValueError: + return web.json_response({"error": "invalid webhook configuration"}, status=400) + except Exception as exc: + return _failure(exc) return web.json_response(target.to_dict(), status=201) @routes.put("/api/outbound-webhooks/{webhook_id}") @@ -698,8 +793,18 @@ async def update_outbound_webhook(request: web.Request) -> web.Response: body = await request.json() except Exception: return web.json_response({"error": "invalid JSON body"}, status=400) + if not isinstance(body, dict): + return web.json_response({"error": "invalid webhook configuration"}, status=400) + for field in ("enabled", "scrub_secrets", "verify_ssl"): + if field in body and body[field] is not None and type(body[field]) is not bool: + return web.json_response({"error": f"{field} must be a boolean"}, status=400) + for field in ("name", "url", "secret"): + if field in body and body[field] is not None and not isinstance(body[field], str): + return web.json_response({"error": f"{field} must be a string"}, status=400) try: - target = dispatcher.update( + target = await _mutate( + dispatcher, + "update", webhook_id, name=body.get("name"), url=body.get("url"), @@ -709,8 +814,10 @@ async def update_outbound_webhook(request: web.Request) -> web.Response: scrub_secrets=body.get("scrub_secrets"), verify_ssl=body.get("verify_ssl"), ) - except ValueError as exc: - return web.json_response({"error": str(exc)}, status=400) + except ValueError: + return web.json_response({"error": "invalid webhook configuration"}, status=400) + except Exception as exc: + return _failure(exc) if target is None: return web.json_response({"error": "webhook not found"}, status=404) return web.json_response(target.to_dict()) @@ -721,7 +828,10 @@ async def delete_outbound_webhook(request: web.Request) -> web.Response: if dispatcher is None: return web.json_response({"error": "outbound webhooks not available"}, status=503) webhook_id = request.match_info["webhook_id"] - removed = dispatcher.unregister(webhook_id) + try: + removed = await _mutate(dispatcher, "unregister", webhook_id) + except Exception as exc: + return _failure(exc) if not removed: return web.json_response({"error": "webhook not found"}, status=404) return web.json_response({"status": "deleted", "webhook_id": webhook_id}) diff --git a/src/web/api/security.py b/src/web/api/security.py index f7f986a05..c2dcac3cd 100644 --- a/src/web/api/security.py +++ b/src/web/api/security.py @@ -19,6 +19,32 @@ log = get_logger("web.api") +async def _audit_change(bot, request, event_type: str, action: str, detail: str) -> None: + try: + audit = getattr(bot, "audit", None) + if audit: + identity = getattr(request, "_api_identity", None) + user_id = getattr(identity, "user_id", None) + # Session IDs are bearer credentials. Never put one in an audit log. + actor = f"web:{user_id}" if user_id else "web:unknown" + await audit.log_event( + event_type=event_type, + action=action, + actor=actor, + detail=detail, + ) + except Exception: + pass # A failed audit sink must not undo a committed credential change. + + +async def _audit_permission_change(bot, request, action: str, detail: str) -> None: + await _audit_change(bot, request, "permission_change", action, detail) + + +async def _audit_token_change(bot, request, action: str, detail: str) -> None: + await _audit_change(bot, request, "token_change", action, detail) + + def _auth_snapshot(manager): if manager is None: return None @@ -54,15 +80,74 @@ async def list_tiers(_request: web.Request) -> web.Response: if not pm: return web.json_response({"error": "permission manager not available"}, status=503) from ...permissions.manager import USER_TIER_TOOLS, VALID_TIERS + config_tiers = dict(pm._config_tiers) overrides = dict(pm._overrides) - return web.json_response({ - "valid_tiers": list(VALID_TIERS), - "default_tier": pm._default_tier, - "config_tiers": config_tiers, - "overrides": overrides, - "user_tier_tools": sorted(USER_TIER_TOOLS), - }) + invalid_overrides = pm.invalid_overrides + return web.json_response( + { + "valid_tiers": list(VALID_TIERS), + "default_tier": pm._default_tier, + "config_tiers": config_tiers, + "overrides": overrides, + "invalid_overrides": invalid_overrides, + "store_corrupt": pm._store_corrupt, + "user_tier_tools": sorted(USER_TIER_TOOLS), + } + ) + + @routes.post("/api/permissions/user/{user_id}/repair") + async def repair_user_tier(request: web.Request) -> web.Response: + denied = admin_gate(bot)(request) + if denied: + return denied + pm = getattr(bot, "permissions", None) + if not pm: + return web.json_response({"error": "permission manager not available"}, status=503) + try: + data = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + tier = data.get("tier") if isinstance(data, dict) else None + if tier not in ("admin", "user", "guest"): + return web.json_response({"error": "tier must be admin, user, or guest"}, status=400) + uid = request.match_info["user_id"] + if uid not in pm.invalid_overrides: + return web.json_response( + {"error": "unrecognized permission entry not found"}, status=404 + ) + try: + await pm.async_repair_tier(uid, tier) + except StoreCorruptError: + return web.json_response( + {"error": "permission store is corrupt; refusing to modify"}, status=409 + ) + await _audit_permission_change(bot, request, "set_tier", f"Set user {uid} to tier {tier}") + return web.json_response({"user_id": uid, "tier": tier, "status": "repaired"}) + + @routes.delete("/api/permissions/user/{user_id}/repair") + async def remove_invalid_user_tier(request: web.Request) -> web.Response: + denied = admin_gate(bot)(request) + if denied: + return denied + pm = getattr(bot, "permissions", None) + if not pm: + return web.json_response({"error": "permission manager not available"}, status=503) + uid = request.match_info["user_id"] + if uid not in pm.invalid_overrides: + return web.json_response( + {"error": "unrecognized permission entry not found"}, status=404 + ) + try: + await pm.async_delete_tier(uid) + except StoreCorruptError: + return web.json_response( + {"error": "permission store is corrupt; refusing to modify"}, status=409 + ) + await _audit_permission_change( + bot, request, "delete_tier", f"Removed tier override for user {uid}" + ) + return web.json_response({"user_id": uid, "status": "removed"}) @routes.get("/api/permissions/user/{user_id}") async def get_user_tier(request: web.Request) -> web.Response: @@ -74,11 +159,13 @@ async def get_user_tier(request: web.Request) -> web.Response: uid = request.match_info["user_id"] tier = pm.get_tier(uid) allowed = pm.allowed_tool_names(uid) - return web.json_response({ - "user_id": uid, - "tier": tier, - "allowed_tools": sorted(allowed) if allowed is not None else None, - }) + return web.json_response( + { + "user_id": uid, + "tier": tier, + "allowed_tools": sorted(allowed) if allowed is not None else None, + } + ) @routes.put("/api/permissions/user/{user_id}") async def set_user_tier(request: web.Request) -> web.Response: @@ -104,18 +191,7 @@ async def set_user_tier(request: web.Request) -> web.Response: {"error": "permission store is corrupt; refusing to modify"}, status=409, ) - try: - audit = getattr(bot, "audit", None) - if audit: - session_id = getattr(request, "_session_id", "web-api") - await audit.log_event( - event_type="permission_change", - action="set_tier", - actor=f"web:{session_id}", - detail=f"Set user {uid} to tier {tier}", - ) - except Exception: - pass + await _audit_permission_change(bot, request, "set_tier", f"Set user {uid} to tier {tier}") return web.json_response({"user_id": uid, "tier": tier, "status": "updated"}) @routes.delete("/api/permissions/user/{user_id}") @@ -134,18 +210,9 @@ async def delete_user_tier(request: web.Request) -> web.Response: status=409, ) if removed: - try: - audit = getattr(bot, "audit", None) - if audit: - session_id = getattr(request, "_session_id", "web-api") - await audit.log_event( - event_type="permission_change", - action="delete_tier", - actor=f"web:{session_id}", - detail=f"Removed tier override for user {uid}", - ) - except Exception: - pass + await _audit_permission_change( + bot, request, "delete_tier", f"Removed tier override for user {uid}" + ) return web.json_response({"user_id": uid, "status": "override_removed"}) return web.json_response({"error": "no override found for user"}, status=404) @@ -164,19 +231,21 @@ async def get_host_access(request: web.Request) -> web.Response: ham = getattr(bot, "host_access_manager", None) if not ham: return web.json_response({"error": "host access manager not available"}, status=503) - return web.json_response({ - "available_hosts": ham.available_hosts, - "host_descriptions": ( - { - row["alias"]: row.get("description", "") - for row in bot.host_registry.status_rows() - } - if getattr(bot, "host_registry", None) is not None - else {} - ), - "default_policy": ham.default_policy.to_dict(), - "users": ham.list_users(), - }) + return web.json_response( + { + "available_hosts": ham.available_hosts, + "host_descriptions": ( + { + row["alias"]: row.get("description", "") + for row in bot.host_registry.status_rows() + } + if getattr(bot, "host_registry", None) is not None + else {} + ), + "default_policy": ham.default_policy.to_dict(), + "users": ham.list_users(), + } + ) @routes.put("/api/host-access/user/{user_id}") async def set_host_access_user(request: web.Request) -> web.Response: @@ -205,21 +274,13 @@ async def set_host_access_user(request: web.Request) -> web.Response: {"error": "host access store is corrupt; refusing to modify"}, status=409, ) - try: - audit = getattr(bot, "audit", None) - if audit: - session_id = getattr(request, "_session_id", "web-api") - await audit.log_event( - event_type="host_access_change", - action="set_user", - actor=f"web:{session_id}", - detail=( - f"Set host access for user {uid}: " - f"hosts={allowed_hosts}, default={default_host}" - ), - ) - except Exception: - pass + await _audit_change( + bot, + request, + "host_access_change", + "set_user", + f"Set host access for user {uid}: hosts={allowed_hosts}, default={default_host}", + ) return web.json_response({"user_id": uid, "status": "updated"}) @routes.delete("/api/host-access/user/{user_id}") @@ -292,7 +353,49 @@ async def list_api_tokens(request: web.Request) -> web.Response: tokens.append(d) ham = getattr(bot, "host_access_manager", None) available_hosts = ham.available_hosts if ham else [] - return web.json_response({"tokens": tokens, "available_hosts": available_hosts}) + return web.json_response( + { + "tokens": tokens, + "available_hosts": available_hosts, + "invalid_entries": tm.invalid_entries() if tm else [], + "store_status": tm.credential_store_status if tm else "unavailable", + } + ) + + @routes.delete("/api/tokens/unusable/{index}") + async def remove_unusable_token_entry(request: web.Request) -> web.Response: + denied = _require_admin(request) + if denied: + return denied + tm = getattr(bot, "api_token_manager", None) + if not tm: + return web.json_response({"error": "token manager not available"}, status=503) + try: + index = int(request.match_info["index"]) + except ValueError: + return web.json_response({"error": "index must be an integer"}, status=400) + try: + data = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + if not isinstance(data, dict) or not isinstance(data.get("reason"), str) or ( + "user_id" in data and not isinstance(data["user_id"], str) + ): + return web.json_response( + {"error": "reason and optional user_id are required"}, status=400 + ) + try: + removed = await tm.remove_unusable_entry(index, data["reason"], data.get("user_id")) + except (ValueError, PermissionError) as exc: + return web.json_response({"error": str(exc)}, status=409) + if not removed: + return web.json_response({"error": "unusable token entry not found"}, status=404) + await _audit_token_change( + bot, request, "delete_token", + f"Removed unusable token entry {index}: reason={data['reason']}, " + f"user_id={data.get('user_id')!r}", + ) + return web.json_response({"status": "removed", "index": index}) @routes.post("/api/tokens") async def create_api_token(request: web.Request) -> web.Response: @@ -310,6 +413,7 @@ async def create_api_token(request: web.Request) -> web.Response: if not user_id: return web.json_response({"error": "user_id is required"}, status=400) import re as _re + if not _re.fullmatch(r"[a-zA-Z0-9_.-]{1,64}", user_id): return web.json_response( {"error": "user_id must be alphanumeric/dash/underscore, max 64 chars"}, status=400 @@ -327,8 +431,8 @@ async def create_api_token(request: web.Request) -> web.Response: return web.json_response( {"error": "allowed_hosts must be a list of strings or null"}, status=400 ) - if not isinstance( - allowed_tools, list) or not all(isinstance(t, str) for t in allowed_tools + if not isinstance(allowed_tools, list) or not all( + isinstance(t, str) for t in allowed_tools ): return web.json_response( {"error": "allowed_tools must be a list of strings"}, status=400 @@ -361,16 +465,22 @@ async def create_api_token(request: web.Request) -> web.Response: ) except ValueError as e: return web.json_response({"error": str(e)}, status=409) - return web.json_response({ - "user_id": identity.user_id, - "token": identity.token, - "username": identity.username, - "tier": identity.tier, - "label": identity.label, - "allowed_tools": identity.allowed_tools, - "allowed_hosts": identity.allowed_hosts, - "default_host": identity.default_host, - }, status=201) + await _audit_token_change( + bot, request, "create_token", f"Created token for user {user_id} with tier {tier}" + ) + return web.json_response( + { + "user_id": identity.user_id, + "token": identity.token, + "username": identity.username, + "tier": identity.tier, + "label": identity.label, + "allowed_tools": identity.allowed_tools, + "allowed_hosts": identity.allowed_hosts, + "default_host": identity.default_host, + }, + status=201, + ) @routes.put("/api/tokens/{user_id}") async def update_api_token(request: web.Request) -> web.Response: @@ -387,16 +497,19 @@ async def update_api_token(request: web.Request) -> web.Response: return web.json_response({"error": "invalid JSON"}, status=400) kwargs = {} for field in ( - "username", "tier", "label", "allowed_tools", "allowed_hosts", "default_host" + "username", + "tier", + "label", + "allowed_tools", + "allowed_hosts", + "default_host", ): if field in data: kwargs[field] = data[field] if "tier" in kwargs and kwargs["tier"] not in ("admin", "user", "guest"): return web.json_response({"error": "tier must be admin, user, or guest"}, status=400) if "allowed_tools" in kwargs: - if not isinstance( - kwargs["allowed_tools"], list - ) or not all( + if not isinstance(kwargs["allowed_tools"], list) or not all( isinstance(t, str) for t in kwargs["allowed_tools"] ): return web.json_response( @@ -404,10 +517,8 @@ async def update_api_token(request: web.Request) -> web.Response: ) if "allowed_hosts" in kwargs: if kwargs["allowed_hosts"] is not None: - if not isinstance( - kwargs["allowed_hosts"], list - ) or not all( - isinstance(h, str) for h in kwargs["allowed_hosts"] + if not isinstance(kwargs["allowed_hosts"], list) or not all( + isinstance(h, str) for h in kwargs["allowed_hosts"] ): return web.json_response( {"error": "allowed_hosts must be a list of strings or null"}, status=400 @@ -453,6 +564,9 @@ async def update_api_token(request: web.Request) -> web.Response: if sm: sm.destroy_by_user_id(uid) await ws_mgr.close_by_user_id(uid) + await _audit_token_change( + bot, request, "update_token", f"Updated token for user {uid} with tier {identity.tier}" + ) return web.json_response({"user_id": uid, "status": "updated"}) @routes.post("/api/tokens/{user_id}/regenerate") @@ -478,6 +592,9 @@ async def regenerate_api_token(request: web.Request) -> web.Response: ws_mgr = request.app.get("ws_manager") if ws_mgr: await ws_mgr.close_by_user_id(uid) + await _audit_token_change( + bot, request, "regenerate_token", f"Regenerated token for user {uid}" + ) return web.json_response({"user_id": uid, "token": new_token}) @routes.delete("/api/tokens/{user_id}") @@ -496,7 +613,7 @@ async def delete_api_token(request: web.Request) -> web.Response: deleted = await tm.delete_token(uid) else: deleted = await tm.delete_token(uid) - except PermissionError as exc: + except (PermissionError, ValueError) as exc: return web.json_response({"error": str(exc)}, status=409) if not deleted: return web.json_response({"error": "token not found"}, status=404) @@ -506,11 +623,10 @@ async def delete_api_token(request: web.Request) -> web.Response: ws_mgr = request.app.get("ws_manager") if ws_mgr: await ws_mgr.close_by_user_id(uid) + await _audit_token_change(bot, request, "delete_token", f"Deleted token for user {uid}") return web.json_response({"user_id": uid, "status": "deleted"}) - - def register_auth(routes: web.RouteTableDef, bot) -> None: """Auth (login / logout / session check) (verbatim from the monolith).""" # ------------------------------------------------------------------ @@ -532,8 +648,7 @@ async def auth_login(request: web.Request) -> web.Response: tm = getattr(bot, "api_token_manager", None) snapshot = _auth_snapshot(tm) store_requires_recovery = bool( - snapshot - and getattr(snapshot, "credential_store_auth_required", False) is True + snapshot and getattr(snapshot, "credential_store_auth_required", False) is True ) has_any_token = bool( api_token @@ -564,10 +679,12 @@ async def auth_login(request: web.Request) -> web.Response: sm = request.app.get("session_manager") if sm: sid, timeout = sm.create() - return web.json_response({ - "session_id": sid, - "timeout_seconds": timeout, - }) + return web.json_response( + { + "session_id": sid, + "timeout_seconds": timeout, + } + ) return web.json_response({"error": "no session manager"}, status=500) # Preserve dynamic-before-static collision behavior during healthy @@ -594,13 +711,16 @@ async def auth_login(request: web.Request) -> web.Response: set_source = getattr(sm, "set_auth_source", None) if callable(set_source): set_source(sid, identity_source) - return web.json_response({ - "session_id": sid, - "timeout_seconds": timeout, - }) + return web.json_response( + { + "session_id": sid, + "timeout_seconds": timeout, + } + ) # Fall back to legacy single token import hmac as _hmac + if api_token and not _hmac.compare_digest(token, api_token): return web.json_response({"error": "invalid token"}, status=401) if not api_token: @@ -611,15 +731,21 @@ async def auth_login(request: web.Request) -> web.Response: return web.json_response({"error": "no session manager"}, status=500) from ...config.schema import ApiTokenIdentity + legacy_identity = ApiTokenIdentity( - token="", user_id="api-admin", - username="Admin", tier="admin", label="default", + token="", + user_id="api-admin", + username="Admin", + tier="admin", + label="default", ) sid, timeout = sm.create(identity=legacy_identity) - return web.json_response({ - "session_id": sid, - "timeout_seconds": timeout, - }) + return web.json_response( + { + "session_id": sid, + "timeout_seconds": timeout, + } + ) @routes.post("/api/auth/logout") async def auth_logout(request: web.Request) -> web.Response: @@ -634,7 +760,7 @@ async def auth_logout(request: web.Request) -> web.Response: if not sid: auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): - sid = auth_header[len("Bearer "):] + sid = auth_header[len("Bearer ") :] if sid: # SessionManager owns the one terminal teardown contract. Its # callback enters WebSocketManager.close_by_session_id for both @@ -652,10 +778,12 @@ async def auth_session(request: web.Request) -> web.Response: is_authed = identity is not None user_id = identity.user_id if identity else "web-user" timeout = sm.timeout_seconds if sm else 0 - return web.json_response({ - "authenticated": is_authed, - "timeout_seconds": timeout, - "active_sessions": sm.active_count if sm else 0, - "user_id": user_id, - "channel_id": user_id, - }) + return web.json_response( + { + "authenticated": is_authed, + "timeout_seconds": timeout, + "active_sessions": sm.active_count if sm else 0, + "user_id": user_id, + "channel_id": user_id, + } + ) diff --git a/tests/characterization/test_api_route_parity.py b/tests/characterization/test_api_route_parity.py index ddb6b1697..711761267 100644 --- a/tests/characterization/test_api_route_parity.py +++ b/tests/characterization/test_api_route_parity.py @@ -43,6 +43,7 @@ ("PUT", "/api/config", "update_config"), ("GET", "/api/discord/connection", "get_connection"), ("POST", "/api/discord/connection", "update_connection"), + ("GET", "/api/discord/users/{user_id}", "discord_user"), ("POST", "/api/restart", "restart_odin"), ("POST", "/api/sessions/clear-all", "clear_all_sessions"), ("POST", "/api/reload", "reload_config"), @@ -116,9 +117,6 @@ ("POST", "/api/mcp/enabled", "set_mcp_enabled"), ("POST", "/api/mcp/limits", "set_mcp_publication_limits"), ("POST", "/api/mcp/servers/{name}/enabled", "set_mcp_server_enabled"), - ("GET", "/api/slack/status", "slack_status"), - ("POST", "/api/slack/test", "slack_test"), - ("POST", "/api/slack/send", "slack_send"), ("GET", "/api/grafana-alerts/status", "grafana_alerts_status"), ("GET", "/api/grafana-alerts/history", "grafana_alerts_history"), ("GET", "/api/grafana-alerts/rules", "grafana_alerts_rules"), @@ -184,6 +182,8 @@ ("GET", "/api/governor/stats", "governor_stats"), ("GET", "/api/audit/risk", "audit_by_risk"), ("GET", "/api/permissions/tiers", "list_tiers"), + ("POST", "/api/permissions/user/{user_id}/repair", "repair_user_tier"), + ("DELETE", "/api/permissions/user/{user_id}/repair", "remove_invalid_user_tier"), ("GET", "/api/permissions/user/{user_id}", "get_user_tier"), ("PUT", "/api/permissions/user/{user_id}", "set_user_tier"), ("DELETE", "/api/permissions/user/{user_id}", "delete_user_tier"), @@ -236,6 +236,7 @@ ("DELETE", "/api/hosts/{alias}", "delete_host"), ("POST", "/api/hosts/{alias}/force-revoke", "force_revoke"), ("GET", "/api/tokens", "list_api_tokens"), + ("DELETE", "/api/tokens/unusable/{index}", "remove_unusable_token_entry"), ("POST", "/api/tokens", "create_api_token"), ("PUT", "/api/tokens/{user_id}", "update_api_token"), ("POST", "/api/tokens/{user_id}/regenerate", "regenerate_api_token"), @@ -284,7 +285,7 @@ class TestRouteTableParity: def test_exact_route_list_and_order(self): actual = _routes() expected = [tuple(e) for e in EXPECTED_ROUTES] - assert len(actual) == len(expected) == 236 + assert len(actual) == len(expected) == 237 # set equality first for a readable diff on failure missing = set(expected) - set(actual) added = set(actual) - set(expected) diff --git a/tests/conftest.py b/tests/conftest.py index d77858881..152b23577 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -137,110 +137,6 @@ def odin_config(): return Config(discord={"token": "test-token-not-real"}) -# ── Planner / DAG execution test support ────────────────────── - -import asyncio # noqa: E402 — deliberate import order (env/bootstrap before heavy imports) -import time # noqa: E402 — deliberate import order (env/bootstrap before heavy imports) -from typing import Any # noqa: E402 — deliberate import order (env/bootstrap before heavy imports) - -from src.odin.context import ( # noqa: E402 — deliberate mid-file import (see block comment) - ExecutionContext, -) -from src.odin.registry import ( # noqa: E402 — deliberate mid-file import (see block comment) - ToolRegistry, -) -from src.odin.tools.base import ( # noqa: E402 — deliberate mid-file import (see block comment) - BaseTool, -) -from src.odin.types import ( # noqa: E402 — deliberate import order (env/bootstrap before heavy imports) - PlanSpec, - StepSpec, -) - - -class TimestampTool(BaseTool): - """Sleeps for a configured duration and returns start/end timestamps.""" - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - duration = params.get("sleep", 0.01) - start = time.time() - await asyncio.sleep(duration) - end = time.time() - return {"start": start, "end": end, "value": params.get("value", "ok")} - - -class FailingTool(BaseTool): - """Always raises an exception.""" - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - raise RuntimeError(params.get("message", "deliberate failure")) - - -class EchoTool(BaseTool): - """Echoes the message param or the full params payload.""" - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - return params.get("message", dict(params)) - - -class SlowTool(BaseTool): - """Sleeps longer than typical timeouts for timeout testing.""" - - async def execute(self, params: dict[str, Any], ctx: ExecutionContext) -> Any: - await asyncio.sleep(params.get("sleep", 60)) - return "done" - - -@pytest.fixture -def registry() -> ToolRegistry: - return ToolRegistry.with_defaults() - - -@pytest.fixture -def ts_registry() -> ToolRegistry: - """Registry with timestamp, failing, echo, and slow tools.""" - reg = ToolRegistry() - reg.register("ts", TimestampTool) - reg.register("fail", FailingTool) - reg.register("echo", EchoTool) - reg.register("slow", SlowTool) - from src.odin.tools.shell import ShellTool as _ShellTool - reg.register("shell", _ShellTool) - return reg - - -@pytest.fixture -def ctx() -> ExecutionContext: - return ExecutionContext() - - -@pytest.fixture -def linear_plan() -> PlanSpec: - """A -> B -> C linear chain.""" - return PlanSpec( - name="linear", - steps=( - StepSpec(id="a", tool="ts", params={"sleep": 0.01}), - StepSpec(id="b", tool="ts", params={"sleep": 0.01}, depends_on=("a",)), - StepSpec(id="c", tool="ts", params={"sleep": 0.01}, depends_on=("b",)), - ), - ) - - -@pytest.fixture -def diamond_plan() -> PlanSpec: - """A -> (B, C) -> D diamond for parallel testing.""" - return PlanSpec( - name="diamond", - steps=( - StepSpec(id="a", tool="ts", params={"sleep": 0.01}), - StepSpec(id="b", tool="ts", params={"sleep": 0.1}, depends_on=("a",)), - StepSpec(id="c", tool="ts", params={"sleep": 0.1}, depends_on=("a",)), - StepSpec(id="d", tool="ts", params={"sleep": 0.01}, depends_on=("b", "c")), - ), - ) - - @pytest.fixture(autouse=True) def _reset_restart_intent(): """Restart intent (src/restart.py) is process-global state — never let diff --git a/tests/fakes/mcp_http.py b/tests/fakes/mcp_http.py index 052bc1154..94c336ec8 100644 --- a/tests/fakes/mcp_http.py +++ b/tests/fakes/mcp_http.py @@ -131,6 +131,7 @@ def make_app(mode: str) -> tuple[web.Application, dict[str, Any]]: # Per-method fault knobs for streamed direct replies. "sse_wrong_id_methods": set(), "sse_duplicate_methods": set(), + "json_duplicate_methods": set(), # Replace a direct reply id with JSON boolean true to prove it cannot # alias the client's integer request id 1. "boolean_id_methods": set(), @@ -150,6 +151,8 @@ def _direct(msg: dict, *, method: str = "") -> web.StreamResponse: msg = dict(msg) msg["id"] = True if not state["respond_in_sse"]: + if method and method in state["json_duplicate_methods"]: + return web.json_response([msg, msg]) return web.json_response(msg) messages = [msg, msg] if method and method in state["sse_duplicate_methods"] else [msg] return web.Response(text=_sse_body(messages), content_type="text/event-stream") @@ -158,6 +161,7 @@ async def endpoint(request: web.Request) -> web.StreamResponse: if mode == "auth-401": return web.json_response({"error": "unauthorized"}, status=401) body = await request.json() + request["_json_body"] = body method = body.get("method") msg_id = body.get("id") params = body.get("params") or {} @@ -355,6 +359,8 @@ async def _legacy( def _reply(request: web.Request, msg_id: Any, result: dict) -> web.StreamResponse: response_msg = _rpc_response(msg_id, result) + method = (request.get("_json_body") or {}).get("method", "") + duplicate = _rpc_error(msg_id, -32603, "duplicate payload must lose") if mode.endswith("-sse") or state["respond_in_sse"]: progress = { "jsonrpc": "2.0", @@ -362,9 +368,15 @@ def _reply(request: web.Request, msg_id: Any, result: dict) -> web.StreamRespons "params": {"progress": 1}, } return web.Response( - text=_sse_body([progress, response_msg]), + text=_sse_body( + [progress, response_msg, duplicate] + if method in state["sse_duplicate_methods"] + else [progress, response_msg] + ), content_type="text/event-stream", ) + if method in state["json_duplicate_methods"]: + return web.json_response([response_msg, duplicate]) return web.json_response(response_msg) def _run_tool(name: str, arguments: dict) -> dict: diff --git a/tests/test_agent_completion_classifier.py b/tests/test_agent_completion_classifier.py index 72f8c5780..e04d3bf9d 100644 --- a/tests/test_agent_completion_classifier.py +++ b/tests/test_agent_completion_classifier.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import hashlib import json from types import SimpleNamespace from unittest.mock import AsyncMock @@ -12,6 +13,10 @@ from src.agents.manager import AgentInfo, AgentManager, AgentState, _run_agent from src.discord.completion import CompletionClassifier from src.discord.native_tools.agents_tasks import AgentTaskTools +from src.llm.auxiliary import AuxiliaryLLMClient +from src.llm.ollama import OllamaClient +from src.llm.openai_codex import CodexChatClient +from src.llm.openai_compatible import OpenAICompatibleClient from src.web.api.agents_loops import register_agents @@ -300,7 +305,7 @@ async def test_iteration_cap_failure_visible_to_wait_results_and_agents_api(): @pytest.mark.asyncio -async def test_completion_classifier_uses_live_auxiliary_client_with_bounded_output(): +async def test_completion_classifier_uses_live_auxiliary_client_without_output_cap(): auxiliary = SimpleNamespace(chat=AsyncMock(return_value="INCOMPLETE: one check remains")) primary = SimpleNamespace(chat=AsyncMock(return_value="COMPLETE")) current_aux = {"client": auxiliary} @@ -314,7 +319,7 @@ async def test_completion_classifier_uses_live_auxiliary_client_with_bounded_out assert (is_complete, reason) == (False, "one check remains") auxiliary.chat.assert_awaited_once() assert auxiliary.chat.await_args.kwargs["task"] == "completion_classifier" - assert auxiliary.chat.await_args.kwargs["max_tokens"] == 128 + assert "max_tokens" not in auxiliary.chat.await_args.kwargs primary.chat.assert_not_awaited() current_aux["client"] = None @@ -323,6 +328,140 @@ async def test_completion_classifier_uses_live_auxiliary_client_with_bounded_out primary.chat.assert_awaited_once() +class _JudgeTestAuth: + async def get_access_token(self): + return "test-only" + + def get_account_id(self): + return None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("aux_failure", ["none", "empty", "error"]) +async def test_codex_judge_wire_bytes_unchanged_auxiliary_and_fallback( + monkeypatch, aux_failure, +): + """Pin actual HTTP request bytes captured from the pre-fix judge on both routes. + + SHA-256 constants were recorded from the 128-token-cap baseline before + removing it. They include the prompt, input, ordering and JSON serialization. + """ + from src.llm import openai_codex + + captured = [] + + async def respond(request): + captured.append(await request.read()) + if aux_failure == "error" and len(captured) == 1: + return web.Response(status=400, text="invalid auxiliary request") + result = "" if aux_failure == "empty" and len(captured) == 1 else "COMPLETE" + event = {"type": "response.completed", "response": { + "output": ([{"type": "message", "content": [{"text": result}]}] + if result else []), + }} + return web.Response( + text=f"data: {json.dumps(event)}\n\ndata: [DONE]\n\n", + content_type="text/event-stream", + ) + + app = web.Application() + app.router.add_post("/responses", respond) + async with TestServer(app) as server: + monkeypatch.setattr(openai_codex, "CODEX_API_URL", str(server.make_url("/responses"))) + auxiliary_client = CodexChatClient( + _JudgeTestAuth(), model="gpt-6-luna", reasoning_effort="high", max_retries=1, + ) + primary_client = CodexChatClient( + _JudgeTestAuth(), model="gpt-6-sol", reasoning_effort="medium", max_retries=1, + ) + auxiliary = AuxiliaryLLMClient(auxiliary_client, primary_client) + classifier = CompletionClassifier( + get_llm_client=lambda: primary_client, + get_auxiliary_llm_client=lambda: auxiliary, + ) + try: + assert await classifier.classify("finish this", "done", ["run_command"]) == (True, "") + finally: + await auxiliary_client.close() + await primary_client.close() + + assert [hashlib.sha256(body).hexdigest() for body in captured] == ( + ["a9ac2b229cb52b4e62e8293bf9d0e3a75133fd5354c08f849f48f83a4a8689e6", + "6c21e5c550a70cd5dd7c2c1299ea81d56c6a8ec94f2606c9f3e770f9eaed323a"] + if aux_failure != "none" else + ["a9ac2b229cb52b4e62e8293bf9d0e3a75133fd5354c08f849f48f83a4a8689e6"] + ) + assert all("max_tokens" not in json.loads(body) for body in captured) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("backend", ["compat", "ollama"]) +@pytest.mark.parametrize("fallback", [False, True]) +async def test_judge_uses_endpoint_normal_output_budget_for_aux_and_fallback( + monkeypatch, backend, fallback, +): + """Both routes reach provider body generation with no 128-token cap.""" + bodies = [] + if backend == "compat": + def make_client(model): + client = OpenAICompatibleClient( + api_key="test-only", model=model, max_tokens=4096, + ) + + async def capture(body): + bodies.append(body) + return {"choices": [{"message": {"content": "COMPLETE"}}]} + + monkeypatch.setattr(client, "_request_with_retry", capture) + return client + + def budget(body): + return body["max_tokens"] + else: + def make_client(model): + client = OllamaClient(model=model, max_tokens=4096) + + async def capture(body): + bodies.append(body) + return {"message": {"content": "COMPLETE"}} + + monkeypatch.setattr(client, "_request_with_retry", capture) + return client + + def budget(body): + return body["options"]["num_predict"] + + aux_client = make_client("reasoning-aux") + primary = make_client("reasoning-primary") + if fallback: + async def empty(body): + bodies.append(body) + return ({"choices": [{"message": {"content": ""}}]} + if backend == "compat" else {"message": {"content": ""}}) + monkeypatch.setattr(aux_client, "_request_with_retry", empty) + auxiliary = AuxiliaryLLMClient( + aux_client, primary, provider=backend, model="reasoning-aux", + ) + classifier = CompletionClassifier( + get_llm_client=lambda: primary, + get_auxiliary_llm_client=lambda: auxiliary, + ) + assert await classifier.classify("finish this", "done", ["run_command"]) == (True, "") + assert [budget(body) for body in bodies] == ([4096, 4096] if fallback else [4096]) + assert [body["model"] for body in bodies] == ( + ["reasoning-aux", "reasoning-primary"] if fallback else ["reasoning-aux"] + ) + + +def test_ambiguous_and_empty_judge_answers_warn_without_changing_verdict(caplog): + classifier = CompletionClassifier(get_llm_client=lambda: None) + for response in ("", "unknown"): + with caplog.at_level("WARNING"): + assert classifier.parse_response(response) == (True, "") + assert "Completion classifier: ambiguous response, treating as COMPLETE" in caplog.text + caplog.clear() + + @pytest.mark.asyncio async def test_completion_classifier_clamps_rpc_timeout_to_remaining_lifetime(monkeypatch): client = SimpleNamespace(chat=AsyncMock(return_value="COMPLETE")) @@ -338,6 +477,65 @@ async def wait_for(awaitable, *, timeout): assert seen["timeout"] == 0.25 +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("provider", "auxiliary", "expected"), + [ + ("compat", False, 60.0), + ("ollama", True, 60.0), + ("codex", True, 10.0), + ], +) +async def test_completion_classifier_provider_timeout(monkeypatch, provider, auxiliary, expected): + from src.llm.openai_codex import CodexChatClient + + seen = {} + + async def wait_for(awaitable, *, timeout): + seen["timeout"] = timeout + return await awaitable + + monkeypatch.setattr("src.discord.completion.asyncio.wait_for", wait_for) + primary = ( + CodexChatClient(_JudgeTestAuth(), model="test") + if provider == "codex" and not auxiliary + else SimpleNamespace(chat=AsyncMock(return_value="COMPLETE")) + ) + if auxiliary: + judge = SimpleNamespace( + provider=provider, + chat=AsyncMock(return_value="COMPLETE"), + ) + classifier = CompletionClassifier( + get_llm_client=lambda: primary, + get_auxiliary_llm_client=lambda: judge, + ) + else: + classifier = CompletionClassifier(get_llm_client=lambda: primary) + + assert await classifier.classify("goal", "done", ["tool"]) == (True, "") + assert seen["timeout"] == expected + + +@pytest.mark.asyncio +async def test_codex_main_judge_keeps_ten_second_timeout(monkeypatch): + from src.llm.openai_codex import CodexChatClient + + primary = CodexChatClient(_JudgeTestAuth(), model="test") + # Replace chat to avoid any provider I/O while retaining concrete Codex identity. + primary.chat = AsyncMock(return_value="COMPLETE") + seen = {} + + async def wait_for(awaitable, *, timeout): + seen["timeout"] = timeout + return await awaitable + + monkeypatch.setattr("src.discord.completion.asyncio.wait_for", wait_for) + classifier = CompletionClassifier(get_llm_client=lambda: primary) + assert await classifier.classify("goal", "done", ["tool"]) == (True, "") + assert seen["timeout"] == 10.0 + + @pytest.mark.asyncio async def test_completion_classifier_timeout_fails_open_within_lifetime_bound(): started = asyncio.get_running_loop().time() diff --git a/tests/test_apply_registry.py b/tests/test_apply_registry.py index 6126299d1..d87346aca 100644 --- a/tests/test_apply_registry.py +++ b/tests/test_apply_registry.py @@ -186,7 +186,7 @@ def test_removed_noop_switches_are_absent_and_siblings_require_restart(self): facts = schema_facts() # Includes managed activation and its qualified companion manifest. - assert len(facts) == 328 + assert len(facts) == 323 # Slack was removed from the schema. assert "openai_compatible.openrouter.model_pins" in facts assert "openai_compatible.openrouter.catalogue_profiles" in facts assert "mcp.max_published_tools_per_server" in facts @@ -303,14 +303,21 @@ def test_no_credential_shaped_schema_leaf_is_public(self): def test_compound_webhook_url_is_redacted(self): record = build_field_record( - "slack.default_webhook_url", "https://hooks.slack.invalid/secret" + "outbound_webhooks.targets.ops.secret", "synthetic-secret" ) assert record["sensitivity"] == "sensitive" assert record["desired"] == REDACTED def test_arbitrary_key_inside_webhook_url_map_is_redacted(self): record = build_field_record( - "slack.webhook_urls.ops", "https://hooks.slack.invalid/secret" + "mcp.servers.ops.env.ALERT_WEBHOOK_URL", "synthetic-secret" + ) + assert record["sensitivity"] == "sensitive" + assert record["desired"] == REDACTED + + def test_webhook_url_substring_outside_env_or_headers_is_redacted(self): + record = build_field_record( + "integrations.custom.public_webhook_url_backup", "synthetic-secret" ) assert record["sensitivity"] == "sensitive" assert record["desired"] == REDACTED @@ -510,6 +517,18 @@ def test_drift_outranks_dormant(self): ) assert state == "drift" + def test_activation_required_reports_dormant_when_current_value_is_known(self): + """A gated setting stays dormant even when its effective value is known.""" + from src.config.apply_registry import _apply_state + + assert _apply_state( + apply_mode="activation_required", + pending_restart=False, + drift=False, + valid=True, + effective_known=True, + ) == "dormant" + def test_secret_list_is_emptied_not_masked(self): record = build_field_record("outbound_webhooks.webhook_urls", ["https://x"]) assert record["desired"] == [] @@ -686,7 +705,7 @@ def test_nesting_depth_is_knowable_now_both_paths_consult_it(self): assert record["apply_state"] == "applied" @pytest.mark.parametrize("path", ["scrub_secrets", "verify_ssl"]) - def test_dropped_webhook_target_boot_value_is_not_reported_effective(self, path): + def test_webhook_target_endpoint_applies_without_restart_claim(self, path): record = build_field_record( f"outbound_webhooks.targets.0.{path}", False, @@ -694,6 +713,8 @@ def test_dropped_webhook_target_boot_value_is_not_reported_effective(self, path) has_boot=True, ) assert record["desired"] is False + assert record["apply_mode"] == "live_apply" + assert record["apply_handler"] == "POST/PUT/DELETE /api/outbound-webhooks" assert record["effective"] is None assert record["pending_restart"] is False assert record["apply_state"] == "unknown" @@ -914,6 +935,11 @@ def test_generic_live_apply_keeps_its_real_apply_claim(self): ) assert "PUT /api/config" in record["runtime_effect"] + def test_user_preset_leaf_matches_dynamic_live_apply_pattern(self): + spec = spec_for("personality.user_presets.custom.voice") + assert spec.apply_mode == "live_apply" + assert "PUT /api/config" in spec.apply_handler + def test_logging_directory_names_only_the_workspace_fence(self): record = build_field_record("logging.directory", "/srv/not-a-log-sink") assert record["apply_mode"] == "restart" diff --git a/tests/test_audit_logger_search.py b/tests/test_audit_logger_search.py index f21cc61ee..3358a0305 100644 --- a/tests/test_audit_logger_search.py +++ b/tests/test_audit_logger_search.py @@ -87,6 +87,17 @@ async def test_count_by_tool(self, logger, tmp_path): assert counts == {"run_command": 2, "read_file": 1} assert list(counts)[0] == "run_command" # most-used first + async def test_token_change_events_are_not_tool_runs(self, logger): + await _exec(logger, tool_name="run_command") + await logger.log_event(event_type="token_change", action="create_token", + actor="web:admin-user", detail="Created token") + await logger.log_event(event_type="permission_change", action="set_tier", + actor="web:admin-user", detail="Set tier") + assert await logger.count_by_tool() == {"run_command": 1} + stats = await logger.get_log_stats() + assert stats["tool_count"] == 1 + assert stats["tools"] == ["run_command"] + async def test_get_log_stats(self, logger): await _exec(logger, tool_name="run_command") await _exec(logger, tool_name="read_file", error="failed") diff --git a/tests/test_auth_entry_preservation.py b/tests/test_auth_entry_preservation.py new file mode 100644 index 000000000..f9051b82b --- /dev/null +++ b/tests/test_auth_entry_preservation.py @@ -0,0 +1,113 @@ +"""Per-entry auth faults remain visible and are never silently discarded.""" + +import json +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.permissions.manager import PermissionManager +from src.permissions.token_manager import ApiTokenManager, _hash_token +from src.web.api.security import register_api_tokens, register_permissions_rbac + + +@pytest.mark.asyncio +async def test_unknown_permission_tier_keeps_effective_policy_and_survives_unrelated_mutations( + tmp_path, caplog +): + path = tmp_path / "permissions.json" + path.write_text(json.dumps({"alice": "mystery", "bob": "guest"})) + manager = PermissionManager({}, "admin", str(path)) + assert manager.get_tier("alice") == "admin" + assert manager.get_tier("bob") == "guest" + assert manager.invalid_overrides == {"alice": "mystery"} + assert "unrecognized tier" in caplog.text + await manager.async_set_tier("charlie", "user") + assert json.loads(path.read_text()) == {"alice": "mystery", "bob": "guest", "charlie": "user"} + assert await manager.async_delete_tier("bob") is True + assert json.loads(path.read_text())["alice"] == "mystery" + assert await manager.async_delete_tier("alice") is True # explicit operator repair + assert "alice" not in json.loads(path.read_text()) + + +@pytest.mark.asyncio +async def test_invalid_token_entries_survive_create_update_regenerate_delete(tmp_path, caplog): + path = tmp_path / "api_tokens.json" + original = [ + { + "user_id": "owner", + "token_hash": _hash_token("owner-secret"), + "token_prefix": "owner-se", + "tier": "admin", + }, + { + "user_id": "broken", + "token_hash": "SENSITIVE-HASH", + "token_prefix": "SENSITIVE-PREFIX", + "tier": "wizard", + "other": {"a": [1, None]}, + }, + ["arbitrary", {"nested": "record"}], + ] + path.write_text(json.dumps(original)) + manager = ApiTokenManager(str(path)) + assert manager.resolve("owner-secret").user_id == "owner" + assert manager.resolve("broken-secret") is None + assert manager.invalid_entries() == [ + {"index": 1, "reason": "invalid tier", "user_id": "broken"}, + {"index": 2, "reason": "entry is not an object"}, + ] + assert "SENSITIVE-HASH" not in caplog.text and "SENSITIVE-PREFIX" not in caplog.text + created = await manager.create_token("new") + assert manager.resolve(created.token).user_id == "new" + await manager.update_token("new", label="updated") + await manager.regenerate_token("new") + assert await manager.delete_token("new") is True + rows = json.loads(path.read_text()) + assert rows[1:3] == original[1:3] + assert manager.resolve("owner-secret").user_id == "owner" + assert manager.invalid_entries() == [ + {"index": 1, "reason": "invalid tier", "user_id": "broken"}, + {"index": 2, "reason": "entry is not an object"}, + ] + + +@pytest.mark.asyncio +async def test_auth_diagnostics_exposed_on_admin_routes_without_token_material(tmp_path): + (tmp_path / "permissions.json").write_text('{"alice":"unknown"}') + (tmp_path / "api_tokens.json").write_text( + json.dumps( + [ + {"user_id": "valid", "token_hash": _hash_token("valid-secret"), "tier": "admin"}, + {"user_id": "invalid", "token_hash": "SECRET-HASH", "tier": "wrong"}, + ] + ) + ) + bot = SimpleNamespace( + permissions=PermissionManager({}, "admin", str(tmp_path / "permissions.json")), + api_token_manager=ApiTokenManager(str(tmp_path / "api_tokens.json")), + host_access_manager=None, + config=SimpleNamespace(web=SimpleNamespace(api_tokens=[])), + ) + routes = web.RouteTableDef() + register_permissions_rbac(routes, bot) + register_api_tokens(routes, bot) + + @web.middleware + async def identity(request, handler): + request.__dict__["_api_identity"] = SimpleNamespace(tier="admin", user_id="operator") + return await handler(request) + + app = web.Application(middlewares=[identity]) + app.router.add_routes(routes) + async with TestClient(TestServer(app)) as client: + permissions = await (await client.get("/api/permissions/tiers")).json() + assert permissions["invalid_overrides"] == {"alice": "unknown"} + assert permissions["overrides"] == {} + tokens = await (await client.get("/api/tokens")).json() + assert tokens["invalid_entries"] == [ + {"index": 1, "reason": "invalid tier", "user_id": "invalid"} + ] + assert tokens["store_status"] == "valid" + assert "SECRET-HASH" not in json.dumps(tokens) diff --git a/tests/test_bulkhead.py b/tests/test_bulkhead.py index 2dc1bb3c8..63f6b451e 100644 --- a/tests/test_bulkhead.py +++ b/tests/test_bulkhead.py @@ -402,109 +402,6 @@ async def test_browser_bulkhead_wraps_handler(self): assert browser_bh.total == 1 -# ===================================================================== -# Planner gather isolation -# ===================================================================== - - -class TestPlannerGatherIsolation: - @pytest.mark.asyncio - async def test_step_exception_does_not_crash_gather(self): - from src.odin.planner import Planner - from src.odin.registry import ToolRegistry - from src.odin.types import PlanSpec, StepSpec, StepStatus - - class GoodTool: - def execute(self, params, ctx): - return "ok" - - class BadTool: - def execute(self, params, ctx): - raise RuntimeError("kaboom") - - registry = ToolRegistry() - registry.register("good", GoodTool) - registry.register("bad", BadTool) - - plan = PlanSpec( - name="test", - steps=[ - StepSpec(id="s1", tool="good", params={}), - StepSpec(id="s2", tool="bad", params={}), - ], - ) - - planner = Planner(registry) - result = await planner.execute(plan) - # s1 should succeed, s2 should fail — both recorded, no crash - assert result.steps["s1"].status == StepStatus.SUCCESS - assert result.steps["s2"].status == StepStatus.FAILED - - @pytest.mark.asyncio - async def test_parallel_steps_both_recorded(self): - from src.odin.planner import Planner - from src.odin.registry import ToolRegistry - from src.odin.types import PlanSpec, StepSpec, StepStatus - - class SlowGood: - async def execute(self, params, ctx): - await asyncio.sleep(0.01) - return "done" - - class SlowBad: - async def execute(self, params, ctx): - await asyncio.sleep(0.01) - raise Exception("fail") - - registry = ToolRegistry() - registry.register("slow_good", SlowGood) - registry.register("slow_bad", SlowBad) - - plan = PlanSpec( - name="parallel-test", - steps=[ - StepSpec(id="a", tool="slow_good", params={}), - StepSpec(id="b", tool="slow_bad", params={}), - ], - ) - - planner = Planner(registry) - result = await planner.execute(plan) - assert "a" in result.steps - assert "b" in result.steps - assert result.steps["a"].status == StepStatus.SUCCESS - assert result.steps["b"].status == StepStatus.FAILED - - @pytest.mark.asyncio - async def test_failed_step_cascades_to_dependents(self): - from src.odin.planner import Planner - from src.odin.registry import ToolRegistry - from src.odin.types import PlanSpec, StepSpec, StepStatus - - class OkTool: - def execute(self, params, ctx): - return "ok" - - class FailTool: - def execute(self, params, ctx): - raise RuntimeError("nope") - - registry = ToolRegistry() - registry.register("ok", OkTool) - registry.register("fail", FailTool) - - plan = PlanSpec( - name="cascade-test", - steps=[ - StepSpec(id="root", tool="fail", params={}), - StepSpec(id="child", tool="ok", params={}, depends_on=["root"]), - ], - ) - - planner = Planner(registry) - result = await planner.execute(plan) - assert result.steps["root"].status == StepStatus.FAILED - assert result.steps["child"].status == StepStatus.SKIPPED # ===================================================================== diff --git a/tests/test_campaign_discord_final_coverage.py b/tests/test_campaign_discord_final_coverage.py index 480072e27..70e1d2b99 100644 --- a/tests/test_campaign_discord_final_coverage.py +++ b/tests/test_campaign_discord_final_coverage.py @@ -171,6 +171,7 @@ async def test_application_startup_completes_services_despite_nonfatal_component bot.audit_signer = object() bot.audit = SimpleNamespace(initialize_chain=AsyncMock(side_effect=RuntimeError("bad chain"))) bot.usage_rollup = SimpleNamespace(start=AsyncMock(side_effect=RuntimeError("backfill failed"))) + bot.codex_quota_check = SimpleNamespace(start=AsyncMock()) bot.computer = SimpleNamespace(start=AsyncMock(side_effect=RuntimeError("desktop unavailable"))) bot.scheduler = SimpleNamespace(start=Mock()) bot.scheduled_events = SimpleNamespace( @@ -204,6 +205,7 @@ async def start_mcp_stub(received): bot.load_extension.assert_awaited_once_with("test.extension") bot.audit.initialize_chain.assert_awaited_once() bot.usage_rollup.start.assert_awaited_once() + bot.codex_quota_check.start.assert_awaited_once() bot.scheduler.start.assert_called_once_with( bot.scheduled_events._on_scheduled_task, bot.scheduled_events._on_schedule_failure, @@ -211,6 +213,50 @@ async def start_mcp_stub(received): bot.computer.start.assert_awaited_once() +@pytest.mark.asyncio +async def test_application_startup_tolerates_quota_check_failure(monkeypatch): + """The optional quota poller is isolated from required app startup.""" + import src.discord.client as client_module + + bot = object.__new__(OdinBot) + bot._application_start_lock = __import__("asyncio").Lock() + bot._application_started = False + bot._application_shutdown = False + bot.loop = __import__("asyncio").get_running_loop() + bot.config = SimpleNamespace() + bot.audit_signer = None + bot.usage_rollup = SimpleNamespace(start=AsyncMock()) + bot.codex_quota_check = SimpleNamespace(start=AsyncMock(side_effect=RuntimeError("offline"))) + bot.scheduler = SimpleNamespace(start=Mock()) + bot.scheduled_events = SimpleNamespace( + _on_scheduled_task=AsyncMock(), _on_schedule_failure=AsyncMock() + ) + bot.load_extension = AsyncMock() + bot.computer = SimpleNamespace(start=AsyncMock()) + bot._run_startup_diagnostics = lambda *, yaml_config: SimpleNamespace(results=[]) + monkeypatch.setattr(client_module, "INITIAL_EXTENSIONS", ()) + monkeypatch.setattr(client_module, "start_mcp", AsyncMock()) + + await bot.start_application() + + bot.codex_quota_check.start.assert_awaited_once() + assert bot._application_started is True + + +def test_startup_config_logging_covers_nonempty_host_and_option_flags(): + from src.discord.client import OdinBot + + bot = object.__new__(OdinBot) + bot.config = SimpleNamespace( + tools=SimpleNamespace(hosts={"lab": object(), "prod": object()}), + openai_codex=SimpleNamespace(enabled=True), + discord=SimpleNamespace(respond_to_bots=True, require_mention=True), + ) + bot.llm_gateway = SimpleNamespace(codex_client=None) + + bot._log_startup_config() + + @pytest.mark.asyncio async def test_application_lifecycle_is_idempotent_and_terminal_shutdown_refuses_restart( monkeypatch, diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 9ce7095cd..000000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Tests for the CLI entry point.""" - -from __future__ import annotations - -import json -import textwrap -from pathlib import Path - -import pytest - -from src.odin.cli import _dispatch, build_parser - - -@pytest.fixture -def plan_file(tmp_path) -> Path: - f = tmp_path / "plan.yml" - f.write_text( - textwrap.dedent("""\ - name: cli-test - steps: - - id: greet - tool: shell - params: - command: echo hello - """) - ) - return f - - -@pytest.mark.asyncio -async def test_run_text_output(plan_file, capsys): - parser = build_parser() - args = parser.parse_args(["run", str(plan_file)]) - code = await _dispatch(args) - assert code == 0 - out = capsys.readouterr().out - assert "cli-test" in out - assert "OK" in out - - -@pytest.mark.asyncio -async def test_run_json_output(plan_file, capsys): - parser = build_parser() - args = parser.parse_args(["run", str(plan_file), "--json"]) - code = await _dispatch(args) - assert code == 0 - data = json.loads(capsys.readouterr().out) - assert data["success"] is True - - -@pytest.mark.asyncio -async def test_validate_good_plan(plan_file, capsys): - parser = build_parser() - args = parser.parse_args(["validate", str(plan_file)]) - code = await _dispatch(args) - assert code == 0 - assert "valid" in capsys.readouterr().out.lower() - - -@pytest.mark.asyncio -async def test_validate_bad_plan(tmp_path, capsys): - f = tmp_path / "bad.yml" - f.write_text( - textwrap.dedent("""\ - name: bad - steps: - - id: a - tool: shell - depends_on: [ghost] - """) - ) - parser = build_parser() - args = parser.parse_args(["validate", str(f)]) - code = await _dispatch(args) - assert code == 2 - - -@pytest.mark.asyncio -async def test_list_tools(capsys): - parser = build_parser() - args = parser.parse_args(["list-tools"]) - code = await _dispatch(args) - assert code == 0 - out = capsys.readouterr().out - assert "shell" in out diff --git a/tests/test_codex_auth.py b/tests/test_codex_auth.py index 307642029..c9d7f78d7 100644 --- a/tests/test_codex_auth.py +++ b/tests/test_codex_auth.py @@ -7,8 +7,10 @@ """ from __future__ import annotations +import asyncio import base64 import json +from pathlib import Path import pytest @@ -86,6 +88,77 @@ def test_decode_jwt_flattens_nested_claims(self): def test_decode_jwt_malformed_returns_empty(self): assert ca._decode_jwt_payload("not-a-jwt") == {} assert ca._decode_jwt_payload(_jwt({}) .split(".")[0]) == {} # single part + assert ca._decode_jwt_payload("header.!!!!.signature") == {} + + @pytest.mark.asyncio + async def test_device_auth_poll_handles_pending_then_success(self, monkeypatch): + responses = [_FakeResp(403), _FakeResp(200, json.dumps({ + "authorization_code": "code", + "code_verifier": "verifier", + }).encode())] + + class Session(_FakeSession): + def post(self, url, **kwargs): + assert url == ca.DEVICE_TOKEN_URL + return responses.pop(0) + + async def no_wait(_interval): + return None + + async def exchange(code, verifier, redirect_uri): + assert (code, verifier, redirect_uri) == ("code", "verifier", ca.DEVICE_REDIRECT_URI) + return {"access_token": "fresh"} + + monkeypatch.setattr(ca.aiohttp, "ClientSession", Session) + monkeypatch.setattr(ca.asyncio, "sleep", no_wait) + monkeypatch.setattr(CodexAuth, "exchange_code", staticmethod(exchange)) + + assert await CodexAuth.poll_device_auth("device", "ABCD", timeout=5) == { + "access_token": "fresh" + } + + @pytest.mark.asyncio + async def test_device_auth_poll_reports_unexpected_status(self, monkeypatch): + class Session(_FakeSession): + def post(self, url, **kwargs): + return _FakeResp(500, b"upstream unavailable") + + monkeypatch.setattr(ca.aiohttp, "ClientSession", Session) + with pytest.raises(RuntimeError, match=r"Device auth polling failed \(500\)"): + await CodexAuth.poll_device_auth("device", "ABCD", timeout=5) + + @pytest.mark.asyncio + async def test_device_auth_poll_times_out_without_completion(self, monkeypatch): + monkeypatch.setattr(ca.aiohttp, "ClientSession", _FakeSession) + monkeypatch.setattr(ca.asyncio, "sleep", lambda _interval: asyncio.sleep(0)) + with pytest.raises(TimeoutError, match="did not complete login"): + await CodexAuth.poll_device_auth("device", "ABCD", timeout=0) + + def test_refresh_pool_helpers_tolerate_corrupt_files(self, tmp_path): + path = tmp_path / "canonical.json" + path.write_text("{") + pool = CodexAuthPool(str(path)) + assert pool._load_creds_file(path) is None + assert pool._account_label(CodexAuth(str(path)), 2) == "account 2" + sync = pool._canonical_sync(0) + sync({"access_token": "ignored"}) + + path.write_text(json.dumps({"access_token": "single"})) + sync({"access_token": "ignored"}) # wrong format is left untouched + assert json.loads(path.read_text()) == {"access_token": "single"} + + @pytest.mark.asyncio + async def test_set_active_uses_safe_slot_label_for_unreadable_record(self): + pool = CodexAuthPool.__new__(CodexAuthPool) + pool._path = Path("/unused") + pool._accounts = [CodexAuth.__new__(CodexAuth)] + pool._current_index = 0 + pool._pool_lock = asyncio.Lock() + pool._accounts[0]._load = lambda: (_ for _ in ()).throw(OSError("unreadable")) + + await pool.set_active(0) + + assert pool._manual_active_index == 0 # ── CodexAuth ──────────────────────────────────────────────────────── @@ -358,12 +431,12 @@ async def test_acquire_rotates_past_rate_limited(self, tmp_path): assert token == "a1" and idx == 1 @pytest.mark.asyncio - async def test_acquire_all_rate_limited_raises(self, tmp_path): + async def test_acquire_all_rate_limited_still_sends_upstream(self, tmp_path): pool = self._pool(tmp_path) for a in pool._accounts: a.mark_rate_limited(60) - with pytest.raises(RuntimeError, match="rate-limited or"): - await pool.acquire() + token, _, idx = await pool.acquire() + assert token == "a0" and idx == 0 @pytest.mark.asyncio async def test_acquire_all_failed_raises_with_errors(self, tmp_path, monkeypatch): diff --git a/tests/test_codex_auth_rotation.py b/tests/test_codex_auth_rotation.py index 537fe0dd1..c91b7f355 100644 --- a/tests/test_codex_auth_rotation.py +++ b/tests/test_codex_auth_rotation.py @@ -66,21 +66,13 @@ def test_mark_rate_limited_accepts_custom_window(): assert auth2.is_rate_limited() is True -async def test_pool_exhaustion_is_typed_rate_limit(): - """Review blocker #7 (PR #242): all-accounts-rate-limited must raise - LLMRateLimitError (fast-fail at the recovery layer, never an - unclassified defect), with the historical message preserved.""" - import pytest - - from src.llm.errors import LLMRateLimitError - +async def test_pool_exhaustion_is_sent_upstream(): + """Locally exhausted accounts are tried because quota state can be stale.""" pool = _pool(2) for acct in pool._accounts: acct.is_rate_limited.return_value = True - with pytest.raises(LLMRateLimitError) as exc_info: - await pool.acquire() - assert "rate-limited or backing off" in str(exc_info.value) - assert exc_info.value.provider == "codex" + token, _, index = await pool.acquire() + assert token and index == 0 async def test_pool_exhaustion_all_failed_is_typed_auth(): diff --git a/tests/test_codex_limit_aware.py b/tests/test_codex_limit_aware.py new file mode 100644 index 000000000..690d45433 --- /dev/null +++ b/tests/test_codex_limit_aware.py @@ -0,0 +1,412 @@ +"""Known Codex quota exhaustion influences automatic selection, not manual activation.""" + +import asyncio +import json +import time +from unittest.mock import MagicMock + +import pytest + +from src.llm.codex_auth import CodexAuth, CodexAuthPool +from src.llm.codex_quota import CodexQuotaTracker + + +def pool_with_accounts(count=3): + pool = CodexAuthPool.__new__(CodexAuthPool) + pool._accounts = [] + pool._current_index = 0 + pool._pool_lock = asyncio.Lock() + pool.quota = CodexQuotaTracker() + pool._quota_check_failures = {} + for index in range(count): + auth = MagicMock(spec=CodexAuth) + auth.get_account_id.return_value = f"account-{index}" + auth.get_access_token.return_value = f"token-{index}" + auth.is_rate_limited.return_value = False + auth._load.return_value = {"email": f"account-{index}@example.invalid"} + pool._accounts.append(auth) + return pool + + +def record(pool, index, used=100, reset=3600): + from src.llm.account_key import opaque_account_key + + headers = {"x-codex-primary-used-percent": str(used), + "x-codex-primary-reset-after-seconds": str(reset), + "x-codex-primary-window-minutes": "300"} + pool.quota.record_headers(opaque_account_key(f"account-{index}"), headers) + + +@pytest.mark.asyncio +async def test_skips_known_limited_when_acquiring_and_rotating(): + pool = pool_with_accounts() + record(pool, 0) + record(pool, 1) + token, _, index = await pool.acquire() + assert (token, index) == ("token-2", 2) + assert pool._current_index == 2 + pool._current_index = 0 + await pool.mark_limited(0) + assert pool._current_index == 2 + assert 3500 < pool._accounts[0].mark_rate_limited.call_args.args[0] <= 3600 + + +@pytest.mark.asyncio +async def test_rotation_after_token_failure_skips_known_limited_account(): + pool = pool_with_accounts() + record(pool, 1) + pool._accounts[0].get_access_token.side_effect = RuntimeError("refresh failed") + + token, _, index = await pool.acquire() + + assert (token, index) == ("token-2", 2) + assert pool._accounts[1].get_access_token.await_count == 0 + + +@pytest.mark.asyncio +async def test_unreadable_account_fails_over_and_records_only_that_failure(): + pool = pool_with_accounts(2) + pool._accounts[0].get_access_token.side_effect = OSError("credential file unreadable") + + token, account_id, index = await pool.acquire() + + assert (token, account_id, index) == ("token-1", "account-1", 1) + pool._accounts[0].mark_rate_limited.assert_called_once() + pool._accounts[1].mark_rate_limited.assert_not_called() + + +@pytest.mark.asyncio +async def test_cancelled_quota_failover_does_not_bench_next_account(): + pool = pool_with_accounts(2) + pool._accounts[0].get_access_token.side_effect = OSError("unreadable credentials") + pool._accounts[1].get_access_token.side_effect = asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await pool.acquire() + + pool._accounts[0].mark_rate_limited.assert_called_once() + pool._accounts[1].mark_rate_limited.assert_not_called() + + +@pytest.mark.asyncio +async def test_manual_account_unreadable_clears_override_and_fails_over(): + pool = pool_with_accounts(2) + pool._manual_active_index = 0 + pool._accounts[0].get_access_token.side_effect = OSError("credential file unreadable") + + token, account_id, index = await pool.acquire() + + assert (token, account_id, index) == ("token-1", "account-1", 1) + assert pool._manual_active_index is None + pool._accounts[0].mark_rate_limited.assert_called_once() + + +@pytest.mark.asyncio +async def test_all_quota_exhausted_fallback_still_asks_upstream(): + pool = pool_with_accounts(2) + record(pool, 0) + record(pool, 1) + + token, account_id, index = await pool.acquire() + + assert (token, account_id, index) == ("token-0", "account-0", 0) + pool._accounts[1].get_access_token.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_all_quota_exhausted_upstream_failure_is_reported(): + pool = pool_with_accounts(1) + record(pool, 0) + pool._accounts[0].get_access_token.side_effect = OSError("credential file unreadable") + + with pytest.raises(Exception, match="All 1 Codex accounts failed"): + await pool.acquire() + + +@pytest.mark.asyncio +async def test_invalid_manual_index_is_discarded_before_normal_selection(): + pool = pool_with_accounts(1) + pool._manual_active_index = 4 + + assert (await pool.acquire())[2] == 0 + assert pool._manual_active_index is None + + +@pytest.mark.asyncio +async def test_single_account_limit_and_auth_failure_do_not_rotate(): + pool = pool_with_accounts(1) + + await pool.mark_limited(0) + assert pool._current_index == 0 + assert pool._accounts[0].mark_rate_limited.call_count == 1 + assert await pool.mark_auth_failed(0) is False + + +def test_limited_accounts_are_not_advertised_as_eligible(): + pool = pool_with_accounts(2) + record(pool, 0) + assert pool.eligible_account_ids_snapshot() == frozenset({"account-1"}) + + +@pytest.mark.asyncio +async def test_manual_activation_works_when_quota_exhausted(): + pool = pool_with_accounts(2) + record(pool, 0) + record(pool, 1) + assert (await pool.acquire())[2] in {0, 1} + await pool.set_active(1) + assert pool._current_index == 1 + + +@pytest.mark.asyncio +async def test_all_limited_still_sends_via_earliest_reset_account(): + pool = pool_with_accounts(2) + record(pool, 0, reset=1800) + record(pool, 1, reset=3600) + pool._accounts[0].mark_rate_limited(1800) + pool._accounts[1].mark_rate_limited(3600) + + token, _, index = await pool.acquire() + + assert (token, index) == ("token-0", 0) + + +@pytest.mark.asyncio +async def test_missing_or_expired_quota_remains_eligible(): + pool = pool_with_accounts(2) + record(pool, 0, used=100, reset=0) + assert (await pool.acquire())[2] == 0 + record(pool, 0, used=99, reset=3600) + assert (await pool.acquire())[2] == 0 + record(pool, 0, used=100, reset=3600) + assert pool._quota_reset(0, now=time.time() + 3601) is None + pool._current_index = 1 + await pool.mark_limited(1) + pool._accounts[1].mark_rate_limited.assert_called_once_with(60.0) + + +@pytest.mark.asyncio +async def test_manual_activation_clears_bench_and_sticks_until_429(): + pool = pool_with_accounts(2) + record(pool, 1) + pool._accounts[1].mark_rate_limited(600) + + await pool.set_active(1) + assert (await pool.acquire())[2] == 1 + assert (await pool.acquire())[2] == 1 + await pool.mark_limited(1) + assert pool._manual_active_index is None + + +def test_fresh_quota_with_room_clears_old_429_bench(): + pool = pool_with_accounts(1) + auth = pool._accounts[0] + auth.mark_rate_limited(600) + from src.llm.account_key import opaque_account_key + + pool.quota.record_headers(opaque_account_key("account-0"), { + "x-codex-primary-used-percent": "45", + "x-codex-primary-reset-after-seconds": "900", + "x-codex-primary-window-minutes": "300", + }) + + assert pool._quota_reset(0) is None + assert not auth.is_rate_limited() + + +@pytest.mark.parametrize("path", ["eligible", "acquire", "rotate"]) +@pytest.mark.asyncio +async def test_fresh_room_clears_bench_before_pool_short_circuits(path): + pool = pool_with_accounts(2) + auth = pool._accounts[0] + from src.llm.account_key import opaque_account_key + + benched = True + auth.is_rate_limited.side_effect = lambda: benched + + def clear_bench(): + nonlocal benched + benched = False + + auth.clear_rate_limit.side_effect = clear_bench + auth.mark_rate_limited(7200) + pool.quota.record_headers(opaque_account_key("account-0"), { + "x-codex-primary-used-percent": "10", + "x-codex-primary-reset-after-seconds": "7200", + "x-codex-primary-window-minutes": "300", + }) + + if path == "eligible": + assert "account-0" in pool.eligible_account_ids_snapshot() + elif path == "acquire": + assert (await pool.acquire())[2] == 0 + else: + pool._current_index = 1 + pool._rotate() + assert pool._current_index == 0 + auth.clear_rate_limit.assert_called() + assert not benched + + +@pytest.mark.asyncio +async def test_rotation_checks_fresh_quota_before_bench_state(): + pool = pool_with_accounts(2) + auth = pool._accounts[1] + from src.llm.account_key import opaque_account_key + + benched = True + auth.is_rate_limited.side_effect = lambda: benched + + def clear_bench(): + nonlocal benched + benched = False + + auth.clear_rate_limit.side_effect = clear_bench + auth.mark_rate_limited(7200) + pool.quota.record_headers(opaque_account_key("account-1"), { + "x-codex-primary-used-percent": "10", + "x-codex-primary-reset-after-seconds": "7200", + "x-codex-primary-window-minutes": "300", + }) + + pool._rotate() + + assert pool._current_index == 1 + assert not benched + + +def test_stale_room_snapshot_does_not_clear_a_newer_429_bench(): + pool = pool_with_accounts(1) + auth = pool._accounts[0] + from src.llm.account_key import opaque_account_key + + pool.quota.record_headers(opaque_account_key("account-0"), { + "x-codex-primary-used-percent": "45", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-after-seconds": "900", + }) + snapshot = pool.quota.snapshot_for(opaque_account_key("account-0")) + auth._rate_limit_marked_at = snapshot.observed_at + 1 + auth.mark_rate_limited(600) + # mark_rate_limited normally records its own real wall time. Restore the + # modeled ordering after marking it. + auth._rate_limit_marked_at = snapshot.observed_at + 1 + + assert pool._quota_reset(0) is None + auth.clear_rate_limit.assert_not_called() + + +def test_limit_type_only_exhausts_the_matching_window(): + pool = pool_with_accounts(1) + from src.llm.account_key import opaque_account_key + + pool.quota.record_headers(opaque_account_key("account-0"), { + "x-codex-rate-limit-reached-type": "primary", + "x-codex-primary-used-percent": "60", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-after-seconds": "900", + "x-codex-secondary-used-percent": "20", + "x-codex-secondary-window-minutes": "10080", + "x-codex-secondary-reset-after-seconds": "3600", + }) + snapshot = pool.quota.snapshot_for(opaque_account_key("account-0")) + assert snapshot is not None + assert pool._quota_reset(0, now=snapshot.observed_at) == snapshot.primary.resets_at + + +def test_empty_zero_minute_window_never_triggers_failover(): + pool = pool_with_accounts(2) + from src.llm.account_key import opaque_account_key + + pool.quota.record_headers(opaque_account_key("account-0"), { + "x-codex-secondary-window-minutes": "0", + "x-codex-secondary-used-percent": "100", + "x-codex-secondary-reset-after-seconds": "3600", + "x-codex-rate-limit-reached-type": "secondary", + }) + assert pool._quota_reset(0) is None + + +def test_check_failures_are_keyed_per_account_and_clearable(): + pool = pool_with_accounts(2) + pool.set_quota_check_failure(0, "HTTP 401") + assert pool.quota_check_failure(0) == "HTTP 401" + assert pool.quota_check_failure(1) is None + pool.set_quota_check_failure(0, None) + assert pool.quota_check_failure(0) is None + + +def test_quota_view_discards_removed_account_failures(): + pool = pool_with_accounts(1) + pool.set_quota_check_failure(0, "HTTP 401") + pool._quota_check_failures["removed-account-key"] = "timeout" + + pool.quota_view() + + assert pool._quota_check_failures == { + pool._quota_key(0): "HTTP 401", + } + + +def test_quota_failure_helpers_handle_invalid_index_and_lazy_state(): + pool = pool_with_accounts(1) + del pool._quota_check_failures + + assert pool.quota_check_failure(-1) is None + assert pool.quota_check_failure(1) is None + pool.set_quota_check_failure(1, "ignored") + assert not hasattr(pool, "_quota_check_failures") + + pool.set_quota_check_failure(0, "timeout") + assert pool._quota_check_failures[pool._quota_key(0)] == "timeout" + + +def test_quota_key_isolates_unreadable_account_id(): + pool = pool_with_accounts(1) + pool._accounts[0].get_account_id.side_effect = OSError("credential file unreadable") + assert pool._quota_key(0) is None + assert pool.quota_check_failure(0) is None + + +def test_secondary_limit_type_uses_its_future_reset(): + pool = pool_with_accounts(1) + from src.llm.account_key import opaque_account_key + + pool.quota.record_headers(opaque_account_key("account-0"), { + "x-codex-rate-limit-reached-type": "secondary", + "x-codex-primary-used-percent": "10", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-after-seconds": "900", + "x-codex-secondary-used-percent": "40", + "x-codex-secondary-window-minutes": "10080", + "x-codex-secondary-reset-after-seconds": "1800", + }) + snapshot = pool.quota.snapshot_for(opaque_account_key("account-0")) + + assert snapshot is not None + assert pool._quota_reset(0, now=snapshot.observed_at) == snapshot.secondary.resets_at + + +@pytest.mark.asyncio +async def test_cancelled_auth_refresh_finishes_rotation_and_persistence(tmp_path): + auth = CodexAuth(str(tmp_path / "credentials.json")) + started = asyncio.Event() + finish = asyncio.Event() + async def rotate(_creds): + started.set() + await finish.wait() + auth._save({"access_token": "new", "refresh_token": "rotated"}) + + auth._refresh = rotate + auth._credentials = {"access_token": "old", "refresh_token": "single-use", "expires_at": 0} + task = asyncio.create_task(auth.get_access_token()) + await started.wait() + task.cancel() + await asyncio.sleep(0) + finish.set() + with pytest.raises(asyncio.CancelledError): + await task + + assert json.loads((tmp_path / "credentials.json").read_text()) == { + "access_token": "new", "refresh_token": "rotated" + } diff --git a/tests/test_codex_quota.py b/tests/test_codex_quota.py index 0af48734e..e38f461ba 100644 --- a/tests/test_codex_quota.py +++ b/tests/test_codex_quota.py @@ -79,6 +79,17 @@ def test_parse_returns_none_without_any_quota_window(): ) is None +def test_parse_preserves_limit_signal_without_usage_percentages(): + snap = parse_quota_headers( + {"x-codex-rate-limit-reached-type": "primary"}, + account_key="k", + observed_at=NOW, + ) + assert snap is not None + assert snap.primary is None and snap.secondary is None + assert snap.limit_reached_type == "primary" + + @pytest.mark.parametrize( "value", ["nan", "inf", "-1", "abc", "", " ", "1e400", "1" * 40, "100001"] ) @@ -126,7 +137,7 @@ def test_parse_bounded_text_and_booleans(): assert snap.has_credits is True assert snap.credits_unlimited is None assert snap.limit_reached_type == "primary" - assert snap.primary.window_minutes is None + assert snap.primary is None def test_parse_never_retains_unknown_headers(): @@ -140,6 +151,28 @@ def test_parse_never_retains_unknown_headers(): # tracker # -------------------------------------------------------------------------- +def test_zero_minute_placeholder_window_is_not_reported(): + # Exact upstream secondary shape observed in live accounts. + headers = { + "x-codex-secondary-window-minutes": "0", + "x-codex-secondary-used-percent": "0", + "x-codex-secondary-reset-after-seconds": "0", + } + assert parse_quota_headers(headers, account_key="k", observed_at=NOW) is None + snap = parse_quota_headers({**headers, + "x-codex-primary-window-minutes": "300", + "x-codex-primary-used-percent": "25", + }, account_key="k", observed_at=NOW) + assert snap is not None and snap.primary is not None and snap.secondary is None + + +def test_missing_window_minutes_is_not_reported(): + snap = parse_quota_headers({ + "x-codex-secondary-used-percent": "0", + "x-codex-secondary-reset-after-seconds": "0", + }, account_key="k", observed_at=NOW) + assert snap is None + def test_tracker_records_per_account_and_views_current_first(): clock = [NOW] tracker = CodexQuotaTracker(clock=lambda: clock[0]) diff --git a/tests/test_codex_quota_check.py b/tests/test_codex_quota_check.py new file mode 100644 index 000000000..4b4c22ec5 --- /dev/null +++ b/tests/test_codex_quota_check.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import asyncio +import json +import time +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from src.discord.llm_gateway import LLMGateway +from src.llm.account_key import opaque_account_key +from src.llm.codex_auth import CodexAuth, CodexAuthPool +from src.llm.codex_quota import CodexQuotaTracker +from src.llm.codex_quota_check import CodexQuotaCheckService +from src.llm.openai_codex import CodexChatClient + + +class Pool: + account_count = 3 + + def __init__(self): + self.quota = CodexQuotaTracker(clock=lambda: 1000) + self.accounts = [ + {"configured": True, "key": opaque_account_key(f"acct-{i}")} + for i in range(3) + ] + self.failures = {} + self.tokens = [] + self.current_index = 1 + self.limited = [False] * self.account_count + self.mutations = [] + + def describe_accounts(self): + return self.accounts + + async def token_for(self, index): + self.tokens.append(index) + return f"token-{index}", f"acct-{index}" + + def set_quota_check_failure(self, index, reason): + self.failures[index] = reason + + async def mark_limited(self, index): + self.mutations.append(("limited", index)) + self.limited[index] = True + + async def mark_auth_failed(self, index): + self.mutations.append(("auth_failed", index)) + self.limited[index] = True + return True + + +class Response: + status = 200 + headers = {"x-codex-primary-used-percent": "25", "x-codex-primary-window-minutes": "300"} + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + +class Session: + closed = False + + def __init__(self): + self.calls = [] + self.auto_decompress = False + + def post(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return Response() + + async def close(self): + self.closed = True + + +@pytest.mark.asyncio +async def test_disable_enable_check_uses_only_current_serving_pool(tmp_path, monkeypatch): + """A boot pool may be retired, but must never refresh or rewrite credentials.""" + path = tmp_path / "accounts.json" + path.write_text(json.dumps([ + {"access_token": f"token-{i}", "refresh_token": f"refresh-{i}", + "account_id": f"acct-{i}", "expires_at": time.time() + 3600} + for i in range(2) + ])) + original_credentials = path.read_bytes() + boot_pool = CodexAuthPool(str(path)) + config = SimpleNamespace(openai_codex=SimpleNamespace( + enabled=False, credentials_path=str(path), model="gpt-6-luna", + reasoning_effort="none", request_timeout_seconds=60, + stream_stall_timeout_seconds=30, + retry=SimpleNamespace(max_retries=1, base_delay=1, max_delay=2), + connection_pool=SimpleNamespace(max_connections=2, keepalive_timeout=30), + )) + gateway = LLMGateway( + get_config=lambda: config, + codex_client=CodexChatClient(auth=boot_pool, model="gpt-6-luna"), + ollama_client=None, kimi_client=None, + subsystem_guard=None, auxiliary_llm_client=None, + cost_tracker=None, sessions=MagicMock(), reflector=MagicMock(), + ) + service = CodexQuotaCheckService(lambda: getattr(gateway.codex_client, "auth", None)) + session = Session() + monkeypatch.setattr("src.llm.codex_quota_check.aiohttp.ClientSession", lambda **kwargs: session) + tokens_used = [] + + async def token_once(self): + tokens_used.append(self._load()["refresh_token"]) + return self._load()["access_token"] + + monkeypatch.setattr(CodexAuth, "get_access_token", token_once) + await gateway.reload_codex_inner() # Disable clears the boot generation. + await service.check_once() + assert session.calls == [] and tokens_used == [] + config.openai_codex.enabled = True + await gateway.reload_codex_inner() # Re-enable constructs a NEW serving pool. + serving_pool = gateway.codex_client.auth + assert serving_pool is not boot_pool + await service.check_once() + assert [call[1]["headers"]["Authorization"] for call in session.calls] == [ + "Bearer token-0", "Bearer token-1", + ] + assert tokens_used == ["refresh-0", "refresh-1"] + assert path.read_bytes() == original_credentials + assert boot_pool.quota.snapshot_for(opaque_account_key("acct-0")) is None + assert serving_pool.quota.snapshot_for(opaque_account_key("acct-0")) is not None + await gateway.codex_client.close() + await service.close() + + +@pytest.mark.asyncio +async def test_hot_reload_mid_refresh_never_records_to_retired_pool(monkeypatch): + old, new = Pool(), Pool() + serving = SimpleNamespace(pool=old) + service = CodexQuotaCheckService(lambda: serving.pool) + session = Session() + monkeypatch.setattr("src.llm.codex_quota_check.aiohttp.ClientSession", lambda **kwargs: session) + + async def rotate_during_token(index): + serving.pool = new + return "stale-token", f"acct-{index}" + + old.token_for = rotate_during_token + await service.check_once() + assert session.calls == [] + assert old.failures == {} and new.failures == {} + await service.check_once() + assert new.tokens == [0, 1, 2] + + +def test_bot_wiring_check_lookup_tracks_current_gateway_and_live_config(tmp_path, monkeypatch): + """Pin the actual build_components binding, not merely the check's provider contract.""" + from tests.fakes import make_bot + + monkeypatch.chdir(tmp_path) + bot = make_bot() + check = bot.codex_quota_check + assert check.get_pool() is None + pool = Pool() + bot.llm_gateway.codex_client = SimpleNamespace(auth=pool) + bot.config.openai_codex.enabled = True + assert check.get_pool() is pool + bot.config.openai_codex.enabled = False + assert check.get_pool() is None + bot.config.openai_codex.enabled = True + replacement = Pool() + bot.llm_gateway.codex_client = SimpleNamespace(auth=replacement) + assert check.get_pool() is replacement + + +@pytest.mark.asyncio +async def test_checks_each_stale_account_pinned_shape_and_headers_only(monkeypatch): + pool = Pool() + service = CodexQuotaCheckService(lambda: pool, interval=900, timeout=3) + session = Session() + options = [] + + def make_session(**kwargs): + options.append(kwargs) + return session + + monkeypatch.setattr("src.llm.codex_quota_check.aiohttp.ClientSession", make_session) + await service.check_once() + assert pool.tokens == [0, 1, 2] + assert session.calls[1][1]["headers"] == { + **CodexChatClient._auth_headers("token-1", "acct-1"), + "Accept-Encoding": "identity", + } + assert set(session.calls[1][1]["headers"]) == { + "Authorization", + "Content-Type", + "ChatGPT-Account-Id", + "Accept-Encoding", + } + assert len(session.calls) == 3 + (url,), kwargs = session.calls[0] + assert url == "https://chatgpt.com/backend-api/codex/responses" + assert kwargs["headers"] == {**CodexChatClient._auth_headers("token-0", "acct-0"), + "Accept-Encoding": "identity"} + assert kwargs["json"] == { + "model": "gpt-6-luna", + "instructions": "Reply with one word.", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "quota"}], + }], + "store": False, + "stream": True, + "reasoning": {"effort": "none"}, + } + assert kwargs["timeout"].total == 3 + assert options == [{"auto_decompress": False, "headers": {"Accept-Encoding": "identity"}}] + snap = pool.quota.snapshot_for(opaque_account_key("acct-0")) + assert snap and snap.primary.used_percent == 25 + assert pool.failures == {0: None, 1: None, 2: None} + assert pool.current_index == 1 + assert pool.limited == [False, False, False] + assert pool.mutations == [] + + +@pytest.mark.asyncio +async def test_fresh_accounts_are_skipped_and_unconfigured_slots_ignored(monkeypatch): + pool = Pool() + pool.quota.record_headers(opaque_account_key("acct-0"), { + "x-codex-primary-used-percent": "10", + "x-codex-primary-window-minutes": "300", + }) + pool.accounts[1]["configured"] = False + service = CodexQuotaCheckService(lambda: pool) + session = Session() + monkeypatch.setattr("src.llm.codex_quota_check.aiohttp.ClientSession", lambda **kwargs: session) + await service.check_once() + assert pool.tokens == [2] + assert len(session.calls) == 1 + + +@pytest.mark.asyncio +async def test_401_and_429_record_failure_without_pool_rotation_or_marking(monkeypatch): + pool = Pool() + service = CodexQuotaCheckService(lambda: pool) + session = Session() + monkeypatch.setattr("src.llm.codex_quota_check.aiohttp.ClientSession", lambda **kwargs: session) + + class StatusResponse(Response): + def __init__(self, status): + self.status = status + self.headers = {} + + responses = [StatusResponse(401), StatusResponse(429), StatusResponse(200)] + session.post = lambda *args, **kwargs: responses.pop(0) + await service.check_once() + assert pool.tokens == [0, 1, 2] + assert pool.failures == {0: "HTTP 401", 1: "HTTP 429", 2: None} + assert pool.current_index == 1 + assert pool.limited == [False, False, False] + assert pool.mutations == [] + pool.failures[2] = "HTTP 429" + pool.quota._clock = lambda: 1901 + session.post = lambda *args, **kwargs: StatusResponse(200) + await service.check_once() + assert pool.failures[2] is None + + +@pytest.mark.asyncio +async def test_token_failure_is_captured_and_continues(monkeypatch): + pool = Pool() + original = pool.token_for + + async def token_for(index): + if index == 1: + raise RuntimeError("sensitive error string") + return await original(index) + + pool.token_for = token_for + service = CodexQuotaCheckService(lambda: pool) + session = Session() + monkeypatch.setattr("src.llm.codex_quota_check.aiohttp.ClientSession", lambda **kwargs: session) + await service.check_once() + assert pool.failures[1] == "credential refresh failed" + assert pool.tokens == [0, 2] + + +@pytest.mark.asyncio +async def test_run_survives_check_failure_and_close_closes_session(): + pool = Pool() + service = CodexQuotaCheckService(lambda: pool, interval=0) + attempts = 0 + + async def fail_once(): + nonlocal attempts + attempts += 1 + service._closed = True + raise RuntimeError("temporary failure") + + service.check_once = fail_once + await service._run() + assert attempts == 1 + + session = Session() + service._session = session + await service.close() + assert session.closed is True + await service.start() + assert service._task is None + + +@pytest.mark.asyncio +async def test_missing_identity_and_http_exception_are_display_safe(monkeypatch): + pool = Pool() + service = CodexQuotaCheckService(lambda: pool) + monkeypatch.setattr("src.llm.codex_quota_check.opaque_account_key", lambda _: None) + await service.check_once() + assert pool.failures[0] == "account identity unavailable" + + pool = Pool() + service = CodexQuotaCheckService(lambda: pool) + monkeypatch.undo() + session = Session() + + def fail_request(*args, **kwargs): + raise RuntimeError("sensitive upstream detail") + + session.post = fail_request + monkeypatch.setattr("src.llm.codex_quota_check.aiohttp.ClientSession", lambda **kwargs: session) + await service.check_once() + assert pool.failures == {0: "request failed", 1: "request failed", 2: "request failed"} + + +@pytest.mark.asyncio +async def test_start_close_are_idempotent_and_stop_task(): + pool = Pool() + service = CodexQuotaCheckService(lambda: pool, interval=60) + service.check_once = lambda: asyncio.sleep(0) + await service.start() + task = service._task + await service.start() + assert service._task is task + await service.close() + assert service._task is None + await service.close() diff --git a/tests/test_codex_reliability.py b/tests/test_codex_reliability.py index 40f299d32..6daf67e1e 100644 --- a/tests/test_codex_reliability.py +++ b/tests/test_codex_reliability.py @@ -339,12 +339,12 @@ async def test_acquire_skips_rate_limited_accounts(): assert token == "tok-1" -async def test_all_rate_limited_raises_distinct_error(): +async def test_all_rate_limited_still_sends_upstream(): pool = _mem_pool(2) for acct in pool._accounts: acct.mark_rate_limited() - with pytest.raises(RuntimeError, match="rate-limited or backing off"): - await pool.acquire() + token, _, index = await pool.acquire() + assert token and index == 0 async def test_acquire_does_not_hold_pool_lock_during_refresh(): diff --git a/tests/test_computer_audit_reason_codes.py b/tests/test_computer_audit_reason_codes.py new file mode 100644 index 000000000..54cecf11f --- /dev/null +++ b/tests/test_computer_audit_reason_codes.py @@ -0,0 +1,172 @@ +"""Pin fixed computer-boundary reason codes to the durable audit allowlist.""" + +import ast +import hashlib +from pathlib import Path + +from src.computer.error_guidance import _AUDIT_REASON_CODES, audit_reason_code + +COMPUTER_ROOT = Path(__file__).parents[1] / "src" / "computer" +WRAPPERS = {"preflight", "_fail", "_identity_error"} + +# These are intentionally not audit failure reasons: property_read is an +# accessibility status label, while bounded_lifetime describes an internal +# receiver shutdown event. Neither is surfaced as a tool-boundary refusal. +_INTERNAL_ONLY = { + "property_read": "accessibility property-read status, not a tool failure", + "bounded_lifetime": "internal Wayland probe receiver shutdown event", +} + +# Internal codes must each justify why they are not a stable tool-boundary +# refusal. Never subtract the allowlist: removing any approved code must fail. +_INTERNAL_REASON_CODES = { + "cgroup_absence_unproven": "Cgroup cleanup helper cannot prove absence, not a tool refusal.", + "host_rebooted": "Persisted ownership recovery flag, not an action refusal.", + "inspection_timeout": "Private inspection helper timeout, not a tool reason.", + "inspection_unavailable": "Private inspection helper unavailable, not an action refusal.", + "launch_identity_incomplete": "Persisted launch-provenance diagnostic, not a tool refusal.", + "legacy_runtime_identity_missing": "Migration-era identity diagnostic, not a tool refusal.", + "operator_reconciliation_unsupported": "Operator cleanup policy state, not an action refusal.", + "operator_verified_external_cleanup": "Operator cleanup attestation, not an action refusal.", + "owned_input_release_unproven": "Internal release-proof state, not an action refusal.", + "owned_runtime_gone": "Owner retirement state, not an action refusal.", + "persistent_input_state_unproven": "Durable release ledger state, not an action refusal.", + "recorded_processes_gone": "Cleanup bookkeeping state, not an action refusal.", + "unit_absence_unproven": "Systemd cleanup evidence diagnostic, not an action refusal.", + "hyprland_postcapture_unavailable": "Postcapture helper state, not a boundary refusal.", + "native_field_failed": "Accessibility field helper result, not a tool refusal.", + "no_requested_path_raster_change": "Path-raster verification detail, not a tool refusal.", + "operator_identity_unavailable": "Operator inspection identity detail, not an action refusal.", + "property_unavailable": "Accessibility property helper status, not a tool refusal.", + "read_timeout": "Accessibility property-read timeout, not a tool refusal.", + "stroke_evidence_binding_unavailable": "Stroke raster evidence diagnostic, not a tool refusal.", + "stroke_evidence_geometry_unavailable": "Stroke raster geometry detail, not a tool refusal.", + "stroke_evidence_pixels_unavailable": "Stroke raster pixel diagnostic, not a tool refusal.", +} + + +def _fixed_boundary_codes(): + trees = {} + class_bases = {} + for path in COMPUTER_ROOT.rglob("*.py"): + if path.name == "error_guidance.py": + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + trees[path] = tree + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + class_bases[node.name] = { + base.id for base in node.bases if isinstance(base, ast.Name) + } + + error_classes = {"ComputerError"} + while True: + subclasses = {name for name, bases in class_bases.items() if bases & error_classes} + new = subclasses - error_classes + if not new: + break + error_classes.update(new) + + codes = set() + for tree in trees.values(): + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in WRAPPERS: + defaults = node.args.defaults + [ + default for default in node.args.kw_defaults if default is not None + ] + for default in defaults: + _add_code(codes, default) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id in error_classes and node.args: + _add_code(codes, node.args[0]) + elif node.func.id in WRAPPERS and node.args: + _add_code(codes, node.args[0]) + for keyword in node.keywords: + if keyword.arg == "reason": + _add_code(codes, keyword.value) + if isinstance(node, ast.Dict): + for key, value in zip(node.keys, node.values): + if isinstance(key, ast.Constant) and key.value == "reason": + _add_code(codes, value) + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "_NATIVE_REFUSALS" + for target in node.targets + ): + collection = node.value + if isinstance(collection, ast.Call) and collection.args: + collection = collection.args[0] + for item in getattr(collection, "elts", ()): + # Native refusal strings are hyphenated receiver errors, + # translated to the stable hyprland_ audit vocabulary. + if isinstance(item, ast.Constant) and isinstance(item.value, str): + codes.add("hyprland_" + item.value.replace("-", "_")) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id == "HyprlandPluginError" and node.args: + _add_code(codes, node.args[0]) + if node.func.id == "HyprlandScopeFailure" and node.args: + _add_code(codes, node.args[0]) + if isinstance(node, ast.Assign): + if any( + isinstance(target, ast.Subscript) + and isinstance(target.slice, ast.Constant) + and target.slice.value == "reason" + for target in node.targets + ): + _add_code(codes, node.value) + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any( + isinstance(target, ast.Name) and target.id in {"reason", "code"} + for target in targets + ): + _add_code(codes, node.value) + return codes + + +def _add_code(codes, node): + if isinstance(node, ast.IfExp): + _add_code(codes, node.body) + _add_code(codes, node.orelse) + return + if isinstance(node, ast.BoolOp): + for value in node.values: + _add_code(codes, value) + return + if isinstance(node, ast.Constant) and isinstance(node.value, str): + # Some codes include explanatory text after a colon. Runtime prose is + # never retained, only the stable code prefix. + code = node.value.split(":", 1)[0] + if code.isidentifier() and code.islower(): + codes.add(code) + + +def test_all_fixed_computer_boundary_codes_are_audited_or_documented_internal(): + fixed = _fixed_boundary_codes() + assert not (_AUDIT_REASON_CODES & _INTERNAL_REASON_CODES.keys()) + assert fixed - _AUDIT_REASON_CODES == _INTERNAL_REASON_CODES.keys() + assert not (_AUDIT_REASON_CODES & set(_INTERNAL_ONLY)) + assert all(_INTERNAL_ONLY.values()) + assert all(_INTERNAL_REASON_CODES.values()) + + +def test_regression_codes_cannot_silently_fall_back_to_internal(): + assert {"hyprland_guardian_revoked", "focus_not_obtained", + "wayland_portal_start_failed", "hyprland_native_start_failed", + "topology_monitor_unavailable"} <= _AUDIT_REASON_CODES + fixed = _fixed_boundary_codes() + assert {"hyprland_stale_snapshot", "hyprland_native_start_failed", + "wayland_kwin_mapping_unavailable", "probe_deadline_exceeded", + "hyprland_resource_absence_unproven"} <= fixed + + +def test_approved_audit_vocabulary_is_not_silently_shrunk(): + # Independent of the scanner: removing a code that is no longer emitted + # must still fail. Update only after reviewing intentional vocabulary edits. + assert hashlib.sha256("\n".join(sorted(_AUDIT_REASON_CODES)).encode()).hexdigest() == ( + "f4e49528648970c12035ed87046b60757499473ce6bcde16db81e166144ec0d1" + ) + + +def test_every_allowlisted_code_survives_and_runtime_text_stays_generic(): + assert all(audit_reason_code(code) == code for code in _AUDIT_REASON_CODES) + assert audit_reason_code("desktop content supplied at runtime") == "computer_rejected" diff --git a/tests/test_computer_r19_delivery.py b/tests/test_computer_r19_delivery.py index 4e9e1f2b5..a336ba93a 100644 --- a/tests/test_computer_r19_delivery.py +++ b/tests/test_computer_r19_delivery.py @@ -174,3 +174,41 @@ async def test_native_facade_only_delivers_exact_post_action_image_from_its_call replay = await controller.act(context, action) assert replay == receipt assert len(calls) == 1 + + +async def test_nested_verification_reason_survives_post_action_image(tmp_path, monkeypatch): + from tests.test_computer_native_vision_r5 import client, serving + + async with fixture(tmp_path, monkeypatch) as (controller, context, action, state, calls): + action.update(operation="key", key="Right") + frame = await controller.observe(context, {"session_id": action["session_id"], + "generation": 1}) + + async def changed_target(_context, _payload): + return {"status": "not_satisfied", "verification": { + "reason": "target_changed_observe_again"}, + "execution": {"injected": True, "released": True}, + "next_observation": frame} + + monkeypatch.setattr(controller, "act", changed_target) + bot = SimpleNamespace( + config=SimpleNamespace(computer=SimpleNamespace(enabled=True)), + host_access_manager=SimpleNamespace(is_host_allowed=lambda *_: True), + tool_executor=SimpleNamespace(check_permission=lambda *_: None), + ) + service = ComputerIntegration(bot, controller=controller) + monkeypatch.setattr(service, "_context", lambda _: context) + turn = SimpleNamespace(user_id=context.owner_id, + message=SimpleNamespace(channel=SimpleNamespace(id=context.channel_id)), + _computer_serving=serving(client())) + block = SimpleNamespace(id="native-call", name="computer_act", input=action) + with service.foreground(turn, block): + image = await service._tool(block.name, action) + assert isinstance(image, dict), image + assert image["__computer_action_receipt__"]["verification"]["reason"] == ( + "target_changed_observe_again" + ) + metadata = image["__computer_audit_metadata__"] + assert metadata["computer_reason_code"] == "target_changed_observe_again" + assert metadata["computer_input_outcome"] == "released_verified" + assert "__computer_audit_metadata__" not in image["__prompt__"] diff --git a/tests/test_config_liveness_wiring.py b/tests/test_config_liveness_wiring.py index 5ce4b2e67..bdda15ac5 100644 --- a/tests/test_config_liveness_wiring.py +++ b/tests/test_config_liveness_wiring.py @@ -19,10 +19,9 @@ class TestHealthServerBacklink: """``bot.health_server`` was never assigned anywhere in src/. - The bot-facing Slack and Grafana admin routes resolve their runtime through - that attribute, so on a working install /api/slack/status answered - ``{"enabled": false}`` and every mutating route 503'd — while Slack - forwarding itself kept working, because HealthServer owns its own notifier. + The bot-facing Grafana admin routes resolve their runtime through + that attribute, so the API must report the active handler and mutations + must reach the running server. """ def _server(self, enabled=True): diff --git a/tests/test_config_persistence.py b/tests/test_config_persistence.py index 38b253419..c27737317 100644 --- a/tests/test_config_persistence.py +++ b/tests/test_config_persistence.py @@ -123,6 +123,26 @@ def test_empty_change_set_is_a_noop(self, config_file): patch_config_paths([], path=config_file) assert config_file.read_text() == before + def test_follow_intent_without_existing_leaf_is_a_noop(self, config_file): + """Following the image default removes the persisted override if any. + + With no other changes and the override absent, intent processing leaves + an empty change set and must return without resolving a config path. + """ + from src.config.image_defaults import IMAGE_MODEL_DEFAULTS + + leaf = next(iter(IMAGE_MODEL_DEFAULTS)) + before = config_file.read_text() + from src.config.persistence import _patch_config_paths + + _patch_config_paths([], path=config_file, image_model_intent={leaf: "follow"}) + assert config_file.read_text() == before + + def test_only_empty_paths_are_a_noop(self, config_file): + before = config_file.read_text() + patch_config_paths([((), None)], path=config_file) + assert config_file.read_text() == before + def test_missing_file_raises(self, tmp_path): with pytest.raises(ConfigPersistError, match="does not exist"): patch_config_paths([(("a",), 1)], path=tmp_path / "absent.yml") @@ -156,6 +176,15 @@ def boom(*_a, **_k): class TestPersistConfigPaths: + async def test_locked_writer_empty_change_set_is_a_noop(self, tmp_path): + """An empty locked patch must not resolve or touch the active config.""" + from src.config.persistence import persist_config_paths_locked + + outcome = await persist_config_paths_locked([], path=tmp_path / "absent.yml") + + assert outcome == (None, False) + assert not (tmp_path / "absent.yml").exists() + async def test_async_wrapper_writes_under_the_shared_lock(self, config_file): from src.config.persistence import config_transaction @@ -196,6 +225,17 @@ def test_type_coerced_scalar_matches(self, tmp_path, monkeypatch): patch_config_paths([(("web", "port"), 3002)], path=path) assert "port: ${ODIN_PORT}" in path.read_text() + def test_invalid_numeric_placeholder_does_not_block_a_real_edit(self, tmp_path, monkeypatch): + """An uncoercible env value must not make a requested numeric edit a no-op.""" + path = tmp_path / "config.yml" + path.write_text("web:\n port: ${ODIN_PORT}\n") + monkeypatch.setenv("ODIN_PORT", "not-a-port") + + patch_config_paths([(("web", "port"), 3002)], path=path) + + assert "port: 3002" in path.read_text() + assert "${ODIN_PORT}" not in path.read_text() + def test_unresolvable_placeholder_does_not_block_a_real_edit(self, tmp_path, monkeypatch): path = tmp_path / "config.yml" path.write_text("web:\n host: ${MISSING_VAR}\n") @@ -211,6 +251,115 @@ def test_default_syntax_placeholder_is_respected(self, tmp_path, monkeypatch): assert "host: ${ODIN_HOST:-0.0.0.0}" in path.read_text() +class TestWebhookPlaceholderGuard: + """Whole-list webhook edits retain signing-key placeholders by identity.""" + + def test_matching_ids_and_legacy_urls_preserve_resolved_secrets(self, tmp_path, monkeypatch): + from src.config.persistence import patch_config_paths + + monkeypatch.setenv("WEBHOOK_A", "resolved-a") + monkeypatch.setenv("WEBHOOK_B", "resolved-b") + path = tmp_path / "config.yml" + path.write_text( + "outbound_webhooks:\n" + " targets:\n" + " - id: stable\n" + " url: https://stable.invalid/hook\n" + " secret: ${WEBHOOK_A}\n" + " - url: https://legacy.invalid/hook\n" + " secret: ${WEBHOOK_B}\n" + ) + + patch_config_paths( + [ + ( + ("outbound_webhooks", "targets"), + [ + { + "id": "stable", + "url": "https://stable.invalid/hook", + "secret": "resolved-a", + }, + # Legacy rows receive deterministic IDs at validation; + # the index/url match must still find their old placeholder. + { + "id": __import__("uuid") + .uuid5( + __import__("uuid").NAMESPACE_URL, + "outbound-webhook:1:https://legacy.invalid/hook", + ) + .hex[:12], + "url": "https://legacy.invalid/hook", + "secret": "resolved-b", + }, + ], + ) + ], + path=path, + ) + text = path.read_text() + assert "${WEBHOOK_A}" in text + assert "${WEBHOOK_B}" in text + assert "resolved-a" not in text + assert "resolved-b" not in text + + def test_reordered_legacy_row_falls_back_to_url_and_real_edits_write( + self, tmp_path, monkeypatch + ): + from src.config.persistence import patch_config_paths + + monkeypatch.setenv("WEBHOOK_KEY", "resolved-key") + path = tmp_path / "config.yml" + path.write_text( + "outbound_webhooks:\n" + " targets:\n" + " - url: https://remove.invalid/hook\n" + " secret: obsolete\n" + " - url: https://keep.invalid/hook\n" + " secret: ${WEBHOOK_KEY}\n" + ) + patch_config_paths( + [ + ( + ("outbound_webhooks", "targets"), + [ + { + "id": "new-id", + "url": "https://keep.invalid/hook", + "secret": "resolved-key", + }, + { + "id": "new-row", + "url": "https://new.invalid/hook", + "secret": "typed-secret", + }, + ], + ) + ], + path=path, + ) + text = path.read_text() + assert "${WEBHOOK_KEY}" in text + assert "typed-secret" in text + assert "resolved-key" not in text + + def test_non_list_existing_value_is_replaced_without_placeholder_merge(self, tmp_path): + from src.config.persistence import patch_config_paths + + path = tmp_path / "config.yml" + path.write_text("outbound_webhooks:\n targets: disabled\n") + patch_config_paths( + [ + ( + ("outbound_webhooks", "targets"), + [{"id": "fresh", "url": "https://x.invalid", "secret": "s"}], + ) + ], + path=path, + ) + assert "id: fresh" in path.read_text() + + class TestCancellationSettlement: """A cancelled caller must not release the lock with a write still running. @@ -249,13 +398,11 @@ async def test_cancelled_write_settles_before_returning(self, config_file, monke persistence = self._slow_writer(monkeypatch) task = asyncio.create_task( - persistence.persist_config_paths( - [(("logging", "level"), "DEBUG")], path=config_file - ) + persistence.persist_config_paths([(("logging", "level"), "DEBUG")], path=config_file) + ) + assert await asyncio.to_thread(persistence._test_write_started.wait, 10), ( + "write never started" ) - assert await asyncio.to_thread( - persistence._test_write_started.wait, 10 - ), "write never started" task.cancel() persistence._test_write_release.set() with pytest.raises(asyncio.CancelledError): @@ -273,22 +420,18 @@ async def test_later_writer_is_not_overwritten_by_a_cancelled_one( persistence = self._slow_writer(monkeypatch) task = asyncio.create_task( - persistence.persist_config_paths( - [(("logging", "level"), "DEBUG")], path=config_file - ) + persistence.persist_config_paths([(("logging", "level"), "DEBUG")], path=config_file) + ) + assert await asyncio.to_thread(persistence._test_write_started.wait, 10), ( + "write never started" ) - assert await asyncio.to_thread( - persistence._test_write_started.wait, 10 - ), "write never started" task.cancel() persistence._test_write_release.set() with pytest.raises(asyncio.CancelledError): await task monkeypatch.undo() - await persistence.persist_config_paths( - [(("logging", "level"), "ERROR")], path=config_file - ) + await persistence.persist_config_paths([(("logging", "level"), "ERROR")], path=config_file) # The last writer wins — a cancelled one cannot come back and stomp it. assert "level: ERROR" in config_file.read_text() @@ -313,10 +456,13 @@ def test_submitted_alias_resolves_to_the_canonical_field(self): def test_without_the_schema_an_alias_is_still_dropped(self): """Documents why the call site must pass the model: no schema, no alias resolution.""" - assert submitted_leaves( - {"search": {"chromadb_path": "/new/path"}}, - {"search": {"search_db_path": "/old", "enabled": True}}, - ) == [] + assert ( + submitted_leaves( + {"search": {"chromadb_path": "/new/path"}}, + {"search": {"search_db_path": "/old", "enabled": True}}, + ) + == [] + ) def test_schema_owned_mapping_persists_canonicalized_keys_as_one_leaf(self): from src.config.schema import Config @@ -374,9 +520,7 @@ def test_writer_uses_the_canonical_key_when_the_file_has_it(self, tmp_path): def test_writer_creates_the_canonical_key_when_neither_exists(self, tmp_path): path = tmp_path / "config.yml" path.write_text("discord:\n token: x\n") - patch_config_paths( - [(("search", "search_db_path"), "/p", ("chromadb_path",))], path=path - ) + patch_config_paths([(("search", "search_db_path"), "/p", ("chromadb_path",))], path=path) assert "search_db_path: /p" in path.read_text() def test_legacy_config_round_trips_to_the_same_effective_value(self, tmp_path): @@ -388,9 +532,7 @@ def test_legacy_config_round_trips_to_the_same_effective_value(self, tmp_path): path = tmp_path / "config.yml" path.write_text("discord:\n token: x\nsearch:\n chromadb_path: /old\n") - patch_config_paths( - [(("search", "search_db_path"), "/new", ("chromadb_path",))], path=path - ) + patch_config_paths([(("search", "search_db_path"), "/new", ("chromadb_path",))], path=path) reloaded = Config(**YAML().load(path.read_text())) assert reloaded.search.search_db_path == "/new" @@ -439,9 +581,7 @@ class TestDualSpellings: def test_both_present_keys_are_updated(self, tmp_path): path = tmp_path / "config.yml" path.write_text("search:\n search_db_path: /old\n chromadb_path: /old\n") - patch_config_paths( - [(("search", "search_db_path"), "/new", ("chromadb_path",))], path=path - ) + patch_config_paths([(("search", "search_db_path"), "/new", ("chromadb_path",))], path=path) text = path.read_text() assert "search_db_path: /new" in text assert "chromadb_path: /new" in text @@ -455,9 +595,7 @@ def test_dual_spelling_file_reloads_with_the_new_value(self, tmp_path): path.write_text( "discord:\n token: x\nsearch:\n search_db_path: /old\n chromadb_path: /old\n" ) - patch_config_paths( - [(("search", "search_db_path"), "/new", ("chromadb_path",))], path=path - ) + patch_config_paths([(("search", "search_db_path"), "/new", ("chromadb_path",))], path=path) reloaded = Config(**YAML().load(path.read_text())) assert reloaded.search.search_db_path == "/new" @@ -468,11 +606,7 @@ class TestAnchorSafety: a leaf-only write that isn't. Fail loudly instead of corrupting.""" ANCHORED = ( - "defaults: &defaults\n" - " enabled: true\n" - "search:\n" - " <<: *defaults\n" - " search_db_path: /p\n" + "defaults: &defaults\n enabled: true\nsearch:\n <<: *defaults\n search_db_path: /p\n" ) def test_merge_key_section_is_refused(self, tmp_path): @@ -618,9 +752,7 @@ def mismatched_source_stat(path, *args, **kwargs): ) with pytest.raises(ConfigPersistError, match="preserve config file ownership"): - persistence._dump_atomic( - {"logging": {"level": "DEBUG"}}, config_file, 0o640 - ) + persistence._dump_atomic({"logging": {"level": "DEBUG"}}, config_file, 0o640) assert config_file.read_text() == original assert [p.name for p in config_file.parent.iterdir()] == ["config.yml"] diff --git a/tests/test_config_schema_validators.py b/tests/test_config_schema_validators.py index 34b9fd775..0f0850a27 100644 --- a/tests/test_config_schema_validators.py +++ b/tests/test_config_schema_validators.py @@ -199,6 +199,23 @@ def test_real_legacy_image_shape_loads_without_rewrite_or_false_typo_warning( "real-checkpoint.safetensors" ) + def test_real_legacy_slack_section_loads_silently(self, tmp_path, caplog): + """Removed Slack settings stay silently inert for existing installs.""" + text = ( + "discord:\n token: legacy\n" + "slack:\n enabled: true\n webhook_urls:\n ops: https://hooks.slack.com/example\n" + ) + path = self._write(tmp_path, text) + before = path.read_bytes() + + with caplog.at_level("WARNING"): + cfg = load_config(path) + + assert cfg.discord.token == "legacy" + assert not hasattr(cfg, "slack") + assert path.read_bytes() == before + assert "slack" not in " ".join(record.getMessage() for record in caplog.records) + def test_real_legacy_issue_tracker_shape_loads_silently(self, tmp_path, caplog): """Removed issue-tracker settings remain inert and do not look like typos.""" text = ( diff --git a/tests/test_context.py b/tests/test_context.py deleted file mode 100644 index ae8606ee3..000000000 --- a/tests/test_context.py +++ /dev/null @@ -1,500 +0,0 @@ -"""Tests for ExecutionContext — step-result and plan-input interpolation.""" - -import pytest - -from src.odin.context import ExecutionContext -from src.odin.types import StepResult, StepStatus - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _ctx_with_results(**kwargs) -> ExecutionContext: - ctx = ExecutionContext(inputs=kwargs.pop("_inputs", None)) - for step_id, output in kwargs.items(): - ctx.record(step_id, StepResult(status=StepStatus.SUCCESS, output=output)) - return ctx - - -# --------------------------------------------------------------------------- -# Step-result interpolation (prior work — regression coverage) -# --------------------------------------------------------------------------- - -class TestStepResultInterpolation: - def test_full_ref_returns_raw(self): - ctx = _ctx_with_results(a={"key": "val"}) - assert ctx.resolve_params({"x": "${a.output}"}) == {"x": {"key": "val"}} - - def test_nested_key_access(self): - ctx = _ctx_with_results(a={"nested": {"deep": 42}}) - assert ctx.resolve_params({"x": "${a.output.nested.deep}"}) == {"x": 42} - - def test_implicit_output(self): - ctx = _ctx_with_results(a={"k": "v"}) - assert ctx.resolve_params({"x": "${a.k}"}) == {"x": "v"} - - def test_embedded_ref_stringified(self): - ctx = _ctx_with_results(a="world") - assert ctx.resolve_params({"x": "hello ${a.output}!"}) == {"x": "hello world!"} - - def test_list_index(self): - ctx = _ctx_with_results(a=["x", "y", "z"]) - assert ctx.resolve_params({"x": "${a.output.1}"}) == {"x": "y"} - - def test_steps_prefix_syntax(self): - ctx = _ctx_with_results(a="val") - assert ctx.resolve_params({"x": "{steps.a.output}"}) == {"x": "val"} - - def test_non_output_field(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.FAILED, error="oops", attempts=3)) - r = ctx.resolve_params({"s": "${a.status}", "e": "${a.error}", "n": "${a.attempts}"}) - assert r["s"] == StepStatus.FAILED - assert r["e"] == "oops" - assert r["n"] == 3 - - def test_unknown_step_raises(self): - ctx = _ctx_with_results() - with pytest.raises(KeyError, match="not found"): - ctx.resolve_params({"x": "${nope.output}"}) - - def test_bad_key_raises(self): - ctx = _ctx_with_results(a={"k": 1}) - with pytest.raises(KeyError, match="not found"): - ctx.resolve_params({"x": "${a.output.missing}"}) - - -# --------------------------------------------------------------------------- -# Plan-input interpolation (new feature) -# --------------------------------------------------------------------------- - -class TestPlanInputInterpolation: - def test_full_ref_returns_raw(self): - ctx = ExecutionContext(inputs={"target": {"host": "example.com", "port": 443}}) - r = ctx.resolve_params({"server": "${inputs.target}"}) - assert r == {"server": {"host": "example.com", "port": 443}} - - def test_nested_key(self): - ctx = ExecutionContext(inputs={"target": {"host": "example.com", "port": 443}}) - r = ctx.resolve_params({"h": "${inputs.target.host}"}) - assert r == {"h": "example.com"} - - def test_embedded_ref_stringified(self): - ctx = ExecutionContext(inputs={"env": "prod", "region": "us-east-1"}) - r = ctx.resolve_params({"url": "https://${inputs.env}.${inputs.region}.api.internal"}) - assert r == {"url": "https://prod.us-east-1.api.internal"} - - def test_bare_brace_syntax(self): - ctx = ExecutionContext(inputs={"x": 99}) - r = ctx.resolve_params({"v": "{inputs.x}"}) - assert r == {"v": 99} - - def test_list_input(self): - ctx = ExecutionContext(inputs={"tags": ["a", "b", "c"]}) - r = ctx.resolve_params({"first": "${inputs.tags.0}", "last": "${inputs.tags.2}"}) - assert r == {"first": "a", "last": "c"} - - def test_deep_nesting(self): - ctx = ExecutionContext(inputs={"cfg": {"db": {"host": "localhost", "port": 5432}}}) - r = ctx.resolve_params({"dsn": "pg://${inputs.cfg.db.host}:${inputs.cfg.db.port}/app"}) - assert r == {"dsn": "pg://localhost:5432/app"} - - def test_mixed_step_and_input_refs(self): - ctx = ExecutionContext(inputs={"base_url": "https://api.example.com"}) - ctx.record("auth", StepResult(status=StepStatus.SUCCESS, output={"token": "abc123"})) - r = ctx.resolve_params({ - "url": "${inputs.base_url}/data", - "auth": "Bearer ${auth.output.token}", - }) - assert r == {"url": "https://api.example.com/data", "auth": "Bearer abc123"} - - def test_input_in_nested_param_dict(self): - ctx = ExecutionContext(inputs={"retries": 5}) - r = ctx.resolve_params({"config": {"max_retries": "${inputs.retries}"}}) - assert r == {"config": {"max_retries": 5}} - - def test_input_in_param_list(self): - ctx = ExecutionContext(inputs={"flag": "--verbose"}) - r = ctx.resolve_params({"args": ["run", "${inputs.flag}"]}) - assert r == {"args": ["run", "--verbose"]} - - # -- error cases --------------------------------------------------------- - - def test_missing_input_key_raises(self): - ctx = ExecutionContext(inputs={"a": 1}) - with pytest.raises(KeyError, match="input 'nope' not found.*available: a"): - ctx.resolve_params({"x": "${inputs.nope}"}) - - def test_missing_input_key_empty_inputs(self): - ctx = ExecutionContext() - with pytest.raises(KeyError, match="input 'x' not found.*available: none"): - ctx.resolve_params({"x": "${inputs.x}"}) - - def test_bad_nested_path_raises(self): - ctx = ExecutionContext(inputs={"a": {"b": 1}}) - with pytest.raises(KeyError, match="not found"): - ctx.resolve_params({"x": "${inputs.a.c}"}) - - def test_bad_list_index_raises(self): - ctx = ExecutionContext(inputs={"a": [1, 2]}) - with pytest.raises(KeyError, match="invalid index"): - ctx.resolve_params({"x": "${inputs.a.99}"}) - - def test_empty_input_ref_raises(self): - ctx = ExecutionContext(inputs={"a": 1}) - with pytest.raises(KeyError, match="empty input reference"): - ctx.resolve_params({"x": "${inputs.}"}) - - def test_error_is_step_local_not_plan_crash(self): - """Bad input ref in one param doesn't prevent resolving others.""" - ctx = ExecutionContext(inputs={"ok": "fine"}) - # Good ref resolves fine - assert ctx.resolve_params({"x": "${inputs.ok}"}) == {"x": "fine"} - # Bad ref raises KeyError (step executor catches this per-step) - with pytest.raises(KeyError): - ctx.resolve_params({"x": "${inputs.missing}"}) - - -# --------------------------------------------------------------------------- -# Condition evaluation -# --------------------------------------------------------------------------- - -class TestConditionEvaluation: - def test_truthy_bool_true(self): - ctx = ExecutionContext(inputs={"flag": True}) - assert ctx.evaluate_condition("${inputs.flag}") is True - - def test_truthy_bool_false(self): - ctx = ExecutionContext(inputs={"flag": False}) - assert ctx.evaluate_condition("${inputs.flag}") is False - - def test_truthy_nonempty_string(self): - ctx = ExecutionContext(inputs={"val": "yes"}) - assert ctx.evaluate_condition("${inputs.val}") is True - - def test_truthy_empty_string(self): - ctx = ExecutionContext(inputs={"val": ""}) - assert ctx.evaluate_condition("${inputs.val}") is False - - def test_truthy_none(self): - ctx = ExecutionContext(inputs={"val": None}) - assert ctx.evaluate_condition("${inputs.val}") is False - - def test_truthy_zero(self): - ctx = ExecutionContext(inputs={"val": 0}) - assert ctx.evaluate_condition("${inputs.val}") is False - - def test_truthy_nonzero_int(self): - ctx = ExecutionContext(inputs={"val": 42}) - assert ctx.evaluate_condition("${inputs.val}") is True - - def test_truthy_nonempty_list(self): - ctx = ExecutionContext(inputs={"val": [1, 2]}) - assert ctx.evaluate_condition("${inputs.val}") is True - - def test_truthy_empty_list(self): - ctx = ExecutionContext(inputs={"val": []}) - assert ctx.evaluate_condition("${inputs.val}") is False - - def test_false_string_literals(self): - for val in ("false", "False", "FALSE", "0", "no", "null", "none", "None"): - ctx = ExecutionContext(inputs={"val": val}) - assert ctx.evaluate_condition("${inputs.val}") is False, f"'{val}' should be falsy" - - def test_equality_match(self): - ctx = ExecutionContext(inputs={"env": "prod"}) - assert ctx.evaluate_condition("${inputs.env} == prod") is True - - def test_equality_mismatch(self): - ctx = ExecutionContext(inputs={"env": "staging"}) - assert ctx.evaluate_condition("${inputs.env} == prod") is False - - def test_inequality_match(self): - ctx = ExecutionContext(inputs={"env": "staging"}) - assert ctx.evaluate_condition("${inputs.env} != prod") is True - - def test_inequality_mismatch(self): - ctx = ExecutionContext(inputs={"env": "prod"}) - assert ctx.evaluate_condition("${inputs.env} != prod") is False - - def test_step_result_in_condition(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.SUCCESS, output="ready")) - assert ctx.evaluate_condition("${a.output} == ready") is True - - def test_step_status_in_condition(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.FAILED, error="boom")) - # Enum values stringify to their .value ("failed"), not repr ("StepStatus.FAILED") - assert ctx.evaluate_condition("${a.status} == failed") is True - - def test_missing_ref_raises(self): - ctx = ExecutionContext() - with pytest.raises(KeyError): - ctx.evaluate_condition("${inputs.missing}") - - # -- step-output references in conditions -------------------------------- - - def test_step_output_equality_dollar_brace(self): - ctx = _ctx_with_results(check={"status": "ready"}) - assert ctx.evaluate_condition("${check.output.status} == ready") is True - - def test_step_output_equality_dollar_brace_mismatch(self): - ctx = _ctx_with_results(check={"status": "pending"}) - assert ctx.evaluate_condition("${check.output.status} == ready") is False - - def test_step_output_truthiness_bool(self): - ctx = _ctx_with_results(gate=True) - assert ctx.evaluate_condition("${gate.output}") is True - - def test_step_output_truthiness_false(self): - ctx = _ctx_with_results(gate=False) - assert ctx.evaluate_condition("${gate.output}") is False - - def test_step_output_truthiness_nonempty_dict(self): - ctx = _ctx_with_results(gate={"key": "val"}) - assert ctx.evaluate_condition("${gate.output}") is True - - def test_step_output_truthiness_empty_dict(self): - ctx = _ctx_with_results(gate={}) - assert ctx.evaluate_condition("${gate.output}") is False - - def test_step_output_nested_dict_in_condition(self): - ctx = _ctx_with_results(fetch={"response": {"code": 200, "ok": True}}) - assert ctx.evaluate_condition("${fetch.output.response.code} == 200") is True - - def test_step_output_list_index_in_condition(self): - ctx = _ctx_with_results(scan={"hosts": ["alpha", "beta", "gamma"]}) - assert ctx.evaluate_condition("${scan.output.hosts.0} == alpha") is True - - def test_step_output_list_index_mismatch(self): - ctx = _ctx_with_results(scan={"hosts": ["alpha", "beta"]}) - assert ctx.evaluate_condition("${scan.output.hosts.1} == alpha") is False - - def test_step_output_implicit_output_in_condition(self): - """${step.key} implicitly resolves through .output.""" - ctx = _ctx_with_results(check="go") - assert ctx.evaluate_condition("${check.output} == go") is True - - def test_step_output_inequality_in_condition(self): - ctx = _ctx_with_results(env_check="staging") - assert ctx.evaluate_condition("${env_check.output} != prod") is True - - def test_dollar_brace_steps_prefix(self): - """${steps.step_name.output} should work the same as {steps.step_name.output}.""" - ctx = _ctx_with_results(check="go") - assert ctx.evaluate_condition("${steps.check.output} == go") is True - - def test_dollar_brace_steps_prefix_truthiness(self): - ctx = _ctx_with_results(gate=True) - assert ctx.evaluate_condition("${steps.gate.output}") is True - - def test_dollar_brace_steps_prefix_nested(self): - ctx = _ctx_with_results(fetch={"data": {"ready": True}}) - assert ctx.evaluate_condition("${steps.fetch.output.data.ready}") is True - - def test_bare_brace_steps_in_condition(self): - ctx = _ctx_with_results(check="go") - assert ctx.evaluate_condition("{steps.check.output} == go") is True - - def test_bare_brace_steps_nested_in_condition(self): - ctx = _ctx_with_results(api={"result": {"count": 5}}) - assert ctx.evaluate_condition("{steps.api.output.result.count} == 5") is True - - def test_step_output_none_is_falsy(self): - ctx = _ctx_with_results(check=None) - assert ctx.evaluate_condition("${check.output}") is False - - def test_step_output_zero_is_falsy(self): - ctx = _ctx_with_results(check=0) - assert ctx.evaluate_condition("${check.output}") is False - - def test_step_output_empty_list_is_falsy(self): - ctx = _ctx_with_results(check=[]) - assert ctx.evaluate_condition("${check.output}") is False - - # -- numeric comparisons --------------------------------------------------- - - def test_gt_true(self): - ctx = ExecutionContext(inputs={"count": 10}) - assert ctx.evaluate_condition("${inputs.count} > 5") is True - - def test_gt_false(self): - ctx = ExecutionContext(inputs={"count": 3}) - assert ctx.evaluate_condition("${inputs.count} > 5") is False - - def test_gt_equal_is_false(self): - ctx = ExecutionContext(inputs={"count": 5}) - assert ctx.evaluate_condition("${inputs.count} > 5") is False - - def test_gte_true(self): - ctx = ExecutionContext(inputs={"count": 5}) - assert ctx.evaluate_condition("${inputs.count} >= 5") is True - - def test_gte_false(self): - ctx = ExecutionContext(inputs={"count": 4}) - assert ctx.evaluate_condition("${inputs.count} >= 5") is False - - def test_lt_true(self): - ctx = ExecutionContext(inputs={"duration": 2}) - assert ctx.evaluate_condition("${inputs.duration} < 10") is True - - def test_lt_false(self): - ctx = ExecutionContext(inputs={"duration": 15}) - assert ctx.evaluate_condition("${inputs.duration} < 10") is False - - def test_lte_true(self): - ctx = ExecutionContext(inputs={"duration": 10}) - assert ctx.evaluate_condition("${inputs.duration} <= 10") is True - - def test_lte_false(self): - ctx = ExecutionContext(inputs={"duration": 11}) - assert ctx.evaluate_condition("${inputs.duration} <= 10") is False - - def test_numeric_comparison_with_floats(self): - ctx = ExecutionContext(inputs={"ratio": "0.75"}) - assert ctx.evaluate_condition("${inputs.ratio} >= 0.5") is True - assert ctx.evaluate_condition("${inputs.ratio} < 0.5") is False - - def test_numeric_comparison_with_negative(self): - ctx = ExecutionContext(inputs={"delta": -3}) - assert ctx.evaluate_condition("${inputs.delta} < 0") is True - assert ctx.evaluate_condition("${inputs.delta} > 0") is False - - def test_numeric_comparison_non_numeric_returns_false(self): - ctx = ExecutionContext(inputs={"val": "abc"}) - assert ctx.evaluate_condition("${inputs.val} > 5") is False - - def test_numeric_comparison_from_step_output(self): - ctx = _ctx_with_results(scan={"count": 42}) - assert ctx.evaluate_condition("${scan.output.count} > 10") is True - assert ctx.evaluate_condition("${scan.output.count} <= 41") is False - - def test_numeric_comparison_step_output_nested(self): - ctx = _ctx_with_results(fetch={"response": {"code": 200}}) - assert ctx.evaluate_condition("${fetch.output.response.code} >= 200") is True - assert ctx.evaluate_condition("${fetch.output.response.code} < 200") is False - - def test_equality_still_works_with_numbers(self): - """== remains string comparison — '10 == 10' should still pass.""" - ctx = ExecutionContext(inputs={"val": 10}) - assert ctx.evaluate_condition("${inputs.val} == 10") is True - - def test_inequality_still_works_with_numbers(self): - ctx = ExecutionContext(inputs={"val": 10}) - assert ctx.evaluate_condition("${inputs.val} != 20") is True - - -# --------------------------------------------------------------------------- -# Interpolation stringification — booleans, enums, None (regression) -# --------------------------------------------------------------------------- - -class TestInterpolationStringification: - """Embedded references must stringify booleans, enums, and None in a - user-friendly way so that condition comparisons work naturally.""" - - # -- booleans in conditions ----------------------------------------------- - - def test_bool_true_eq_true(self): - ctx = ExecutionContext(inputs={"flag": True}) - assert ctx.evaluate_condition("${inputs.flag} == true") is True - - def test_bool_false_eq_false(self): - ctx = ExecutionContext(inputs={"flag": False}) - assert ctx.evaluate_condition("${inputs.flag} == false") is True - - def test_bool_true_ne_false(self): - ctx = ExecutionContext(inputs={"flag": True}) - assert ctx.evaluate_condition("${inputs.flag} != false") is True - - def test_bool_false_ne_true(self): - ctx = ExecutionContext(inputs={"flag": False}) - assert ctx.evaluate_condition("${inputs.flag} != true") is True - - def test_bool_true_eq_false_is_false(self): - ctx = ExecutionContext(inputs={"flag": True}) - assert ctx.evaluate_condition("${inputs.flag} == false") is False - - # -- booleans from step outputs ------------------------------------------- - - def test_step_output_bool_comparison(self): - ctx = _ctx_with_results(gate=True) - assert ctx.evaluate_condition("${gate.output} == true") is True - - def test_step_output_bool_false_comparison(self): - ctx = _ctx_with_results(gate=False) - assert ctx.evaluate_condition("${gate.output} == false") is True - - # -- enum values in conditions -------------------------------------------- - - def test_step_status_enum_eq_value(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.SUCCESS, output="ok")) - assert ctx.evaluate_condition("${a.status} == success") is True - - def test_step_status_enum_eq_wrong_value(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.SUCCESS, output="ok")) - assert ctx.evaluate_condition("${a.status} == failed") is False - - def test_step_status_enum_ne(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.SKIPPED, error="skip")) - assert ctx.evaluate_condition("${a.status} != success") is True - - def test_step_status_timeout_eq(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.TIMEOUT, error="slow")) - assert ctx.evaluate_condition("${a.status} == timeout") is True - - # -- None in conditions --------------------------------------------------- - - def test_none_eq_none_string(self): - ctx = ExecutionContext(inputs={"val": None}) - assert ctx.evaluate_condition("${inputs.val} == none") is True - - def test_none_ne_something(self): - ctx = ExecutionContext(inputs={"val": None}) - assert ctx.evaluate_condition("${inputs.val} != ready") is True - - # -- embedded stringification in params (not just conditions) -------------- - - def test_bool_embedded_in_param_string(self): - ctx = ExecutionContext(inputs={"verbose": True}) - r = ctx.resolve_params({"flag": "--verbose=${inputs.verbose}"}) - assert r == {"flag": "--verbose=true"} - - def test_bool_false_embedded_in_param_string(self): - ctx = ExecutionContext(inputs={"debug": False}) - r = ctx.resolve_params({"flag": "debug=${inputs.debug}"}) - assert r == {"flag": "debug=false"} - - def test_enum_embedded_in_param_string(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.SUCCESS, output="data")) - r = ctx.resolve_params({"msg": "step status: ${a.status}"}) - assert r == {"msg": "step status: success"} - - def test_none_embedded_in_param_string(self): - ctx = ExecutionContext(inputs={"val": None}) - r = ctx.resolve_params({"msg": "value is ${inputs.val}"}) - assert r == {"msg": "value is none"} - - # -- full-value refs still preserve raw types ----------------------------- - - def test_full_ref_bool_preserves_type(self): - ctx = ExecutionContext(inputs={"flag": True}) - r = ctx.resolve_params({"x": "${inputs.flag}"}) - assert r == {"x": True} - assert isinstance(r["x"], bool) - - def test_full_ref_none_preserves_type(self): - ctx = ExecutionContext(inputs={"val": None}) - r = ctx.resolve_params({"x": "${inputs.val}"}) - assert r["x"] is None - - def test_full_ref_enum_preserves_type(self): - ctx = ExecutionContext() - ctx.record("a", StepResult(status=StepStatus.FAILED, error="boom")) - r = ctx.resolve_params({"s": "${a.status}"}) - assert r["s"] is StepStatus.FAILED diff --git a/tests/test_executor.py b/tests/test_executor.py deleted file mode 100644 index d0347ce87..000000000 --- a/tests/test_executor.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Tests for the step executor.""" - - -from src.odin.context import ExecutionContext -from src.odin.executor import StepExecutor -from src.odin.types import StepResult, StepSpec, StepStatus - - -class TestStepExecutor: - async def test_success(self, ts_registry): - ex = StepExecutor(ts_registry) - ctx = ExecutionContext() - spec = StepSpec(id="a", tool="echo", params={"message": "hi"}) - r = await ex.execute_step(spec, ctx) - assert r.status == StepStatus.SUCCESS - assert r.output == "hi" - - async def test_unknown_tool(self, ts_registry): - ex = StepExecutor(ts_registry) - ctx = ExecutionContext() - spec = StepSpec(id="a", tool="nope") - r = await ex.execute_step(spec, ctx) - assert r.status == StepStatus.FAILED - assert "unknown tool" in r.error - - async def test_param_resolution_from_context(self, ts_registry): - ex = StepExecutor(ts_registry) - ctx = ExecutionContext() - ctx.record("prev", StepResult(status=StepStatus.SUCCESS, output="data")) - spec = StepSpec(id="a", tool="echo", params={"message": "${prev.output}"}) - r = await ex.execute_step(spec, ctx) - assert r.status == StepStatus.SUCCESS - assert r.output == "data" - - async def test_param_resolution_from_inputs(self, ts_registry): - ex = StepExecutor(ts_registry) - ctx = ExecutionContext(inputs={"val": 42}) - spec = StepSpec(id="a", tool="echo", params={"message": "${inputs.val}"}) - r = await ex.execute_step(spec, ctx) - assert r.status == StepStatus.SUCCESS - assert r.output == 42 - - async def test_bad_ref_fails_step(self, ts_registry): - ex = StepExecutor(ts_registry) - ctx = ExecutionContext() - spec = StepSpec(id="a", tool="echo", params={"message": "${inputs.nope}"}) - r = await ex.execute_step(spec, ctx) - assert r.status == StepStatus.FAILED - assert "param resolution failed" in r.error diff --git a/tests/test_executor_integration_smoke.py b/tests/test_executor_integration_smoke.py index e5f381254..8394cd913 100644 --- a/tests/test_executor_integration_smoke.py +++ b/tests/test_executor_integration_smoke.py @@ -431,6 +431,29 @@ def test_outbound_webhook_dispatcher_wired_when_enabled(self): bot = OdinBot(cfg) assert isinstance(bot.outbound_webhook_dispatcher, OutboundWebhookDispatcher) + def test_outbound_webhook_startup_passes_target_safety_flags(self): + from src.config.schema import Config + + cfg = Config( + discord={"token": "[REDACTED]"}, + outbound_webhooks={ + "enabled": True, + "targets": [ + { + "id": "safety-flags", + "name": "startup safety", + "url": "http://127.0.0.1:9/hook", + "scrub_secrets": False, + "verify_ssl": False, + } + ], + }, + ) + bot = OdinBot(cfg) + target = bot.outbound_webhook_dispatcher.get("safety-flags") + assert target.scrub_secrets is False + assert target.verify_ssl is False + def test_context_compressor_wired_when_enabled(self): from src.config.schema import Config from src.llm.context_compressor import PrefixTracker diff --git a/tests/test_generated_api_reference.py b/tests/test_generated_api_reference.py index dfe6ef839..2063a8a76 100644 --- a/tests/test_generated_api_reference.py +++ b/tests/test_generated_api_reference.py @@ -26,7 +26,7 @@ def test_committed_api_reference_is_byte_identical(): def test_routes_exactly_match_characterization_method_path_name_and_order(): rows = reference.collect_rest_routes() assert [(r.method, r.path, r.handler_name) for r in rows] == EXPECTED_ROUTES - assert len(rows) == 236 + assert len(rows) == 237 assert len({(r.method, r.path) for r in rows}) == len(rows) rendered = reference.render().split("## Other HTTP and WebSocket routes", 1)[0] table = [line.split(" | ") for line in rendered.splitlines() if line.startswith("| ")][2:] @@ -205,7 +205,7 @@ def forbidden(*args, **kwargs): # Constructors can register routes but must not inspect UI assets on disk. monkeypatch.setattr(reference.Path, "is_dir", forbidden) monkeypatch.setattr(reference.Path, "is_file", forbidden) - assert "**236 REST registrations**" in reference.render() + assert "**237 REST registrations**" in reference.render() def test_cli_is_offline_and_works_outside_repo_without_git(tmp_path): diff --git a/tests/test_graceful_shutdown.py b/tests/test_graceful_shutdown.py index fc16ce570..09a449021 100644 --- a/tests/test_graceful_shutdown.py +++ b/tests/test_graceful_shutdown.py @@ -390,3 +390,15 @@ async def test_shutdown_usage_failure_is_nonfatal(caplog): with caplog.at_level("ERROR"): await shutdown_services(SimpleNamespace(usage_rollup=usage)) assert "Error stopping usage_rollup" in caplog.text + + +@pytest.mark.asyncio +async def test_shutdown_stops_codex_quota_check(): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from src.discord.wiring import shutdown_services + + quota_check = SimpleNamespace(close=AsyncMock()) + await shutdown_services(SimpleNamespace(codex_quota_check=quota_check)) + quota_check.close.assert_awaited_once() diff --git a/tests/test_health_shutdown.py b/tests/test_health_shutdown.py index 31a95715b..f105fc2d6 100644 --- a/tests/test_health_shutdown.py +++ b/tests/test_health_shutdown.py @@ -14,7 +14,7 @@ hook so cleanup closes sockets after the listener stops accepting (no reconnect race). - ``HealthServer``: bounded runner shutdown; ``stop()`` quiesces the HTTP - server independently of the Slack notifier. + server independently of notifier lifecycle. Plus the integration artifact: a real server with a live WebSocket stops promptly and the client sees the close. @@ -284,26 +284,6 @@ def cleanup(): await server.stop() assert len(attempts) >= 3 - async def test_slack_close_failure_cannot_skip_runner_cleanup(self): - server = _bare_server() - cleanup = _Recorder() - slack_close = _Recorder(exc=RuntimeError("slack down")) - server._runner = SimpleNamespace(cleanup=cleanup) - server._slack_notifier = SimpleNamespace(close=slack_close) - await server.stop() # must not raise - assert cleanup.called - assert slack_close.called - - async def test_runner_cleanup_failure_still_closes_slack(self): - server = _bare_server() - cleanup = _Recorder(exc=RuntimeError("cleanup boom")) - slack_close = _Recorder() - server._runner = SimpleNamespace(cleanup=cleanup) - server._slack_notifier = SimpleNamespace(close=slack_close) - with pytest.raises(RuntimeError, match="cleanup boom"): - await server.stop() - assert slack_close.called - async def test_runner_gets_bounded_shutdown_timeout(self): server = _bare_server() await server.start() diff --git a/tests/test_import_smoke.py b/tests/test_import_smoke.py index 9dcac1544..f433ebf8e 100644 --- a/tests/test_import_smoke.py +++ b/tests/test_import_smoke.py @@ -38,69 +38,3 @@ class TestTopLevelImport: def test_import_src(self): mod = importlib.import_module("src") assert hasattr(mod, "__version__") - - def test_import_src_odin(self): - importlib.import_module("src.odin") - - -class TestNoBarOdinImports: - """All imports within src/odin/ must use 'src.odin', not bare 'odin'. - - Bare ``from odin.X import ...`` breaks when the package is imported via - the ``src`` layout because ``odin`` is not a top-level package — it lives - under ``src.odin``. This regression test scans every ``.py`` file under - ``src/odin/`` (and ``src/tools/plan_runner.py``) to ensure no bare - ``odin.*`` imports sneak back in. - """ - - def _python_files(self): - from pathlib import Path - root = Path(__file__).resolve().parent.parent - yield from (root / "src" / "odin").rglob("*.py") - plan_runner = root / "src" / "tools" / "plan_runner.py" - if plan_runner.exists(): - yield plan_runner - - def test_no_bare_odin_import_in_source(self): - """No file in src/odin/ should use 'from odin.' or 'import odin.'.""" - import re - bare_import = re.compile( - r"^\s*(?:from|import)\s+odin\b", re.MULTILINE - ) - violations = [] - for path in self._python_files(): - text = path.read_text() - for i, line in enumerate(text.splitlines(), 1): - if bare_import.match(line): - violations.append(f"{path.name}:{i}: {line.strip()}") - assert not violations, ( - "Bare 'odin.*' imports found (should be 'src.odin.*'):\n" - + "\n".join(violations) - ) - - def test_src_odin_public_api_importable(self): - """All public names re-exported by src.odin.__init__ must resolve.""" - mod = importlib.import_module("src.odin") - for name in mod.__all__: - assert hasattr(mod, name), f"src.odin.__all__ lists '{name}' but it is not importable" - - def test_src_odin_submodules_importable(self): - """Key submodules must be importable via the src.odin path.""" - submodules = [ - "src.odin.types", - "src.odin.planner", - "src.odin.executor", - "src.odin.registry", - "src.odin.context", - "src.odin.plan_loader", - "src.odin.reporter", - "src.odin.cli", - "src.odin.tools.base", - "src.odin.tools.shell", - "src.odin.tools.file_ops", - "src.odin.tools.http", - "src.odin.tools.process", - ] - for name in submodules: - mod = importlib.import_module(name) - assert mod is not None, f"Failed to import {name}" diff --git a/tests/test_knowledge_concurrency_chunks_collision.py b/tests/test_knowledge_concurrency_chunks_collision.py new file mode 100644 index 000000000..b72923702 --- /dev/null +++ b/tests/test_knowledge_concurrency_chunks_collision.py @@ -0,0 +1,212 @@ +"""Regression tests for concurrent dedup, long words and chunk-id ownership.""" + +import asyncio +import hashlib +import logging +from unittest.mock import patch + +from src.knowledge import store as knowledge_module +from src.knowledge.store import CHUNK_SIZE, KnowledgeStore +from src.search.fts import FullTextIndex + + +async def test_non_durable_duplicate_warning_is_logged_once(tmp_path, monkeypatch, caplog): + store = KnowledgeStore(str(tmp_path / "knowledge.db")) + try: + monkeypatch.setattr(store, "_find_by_doc_hash", lambda _digest: ("same.md", 1)) + original_durable = store.source_is_durable + monkeypatch.setattr( + store, + "source_is_durable", + lambda source, *args, **kwargs: ( + False if source == "same.md" and args else original_durable(source, *args, **kwargs) + ), + ) + with caplog.at_level(logging.WARNING, logger="src.knowledge.store"): + outcome = await store.ingest("same content", "same.md") + matching = [ + record + for record in caplog.records + if "non-durable duplicate source" in record.message + ] + assert outcome.status == "stored" + assert len(matching) == 1 + assert "re-ingesting" in matching[0].message + finally: + store.close() + + +async def test_concurrent_identical_sources_and_versions(tmp_path): + store = KnowledgeStore(str(tmp_path / "knowledge.db")) + try: + outcomes = await asyncio.gather( + *(store.ingest("concurrent document", name) for name in ("a.md", "b.md")) + ) + assert sorted(outcome.status for outcome in outcomes) == ["duplicate", "stored"] + assert store.count() == 1 + source = store.list_sources()[0]["source"] + outcomes = await asyncio.gather( + *(store.ingest("concurrent document", source) for _ in range(2)) + ) + assert [outcome.status for outcome in outcomes] == ["unchanged", "unchanged"] + assert len(store.get_versions(source)) == 1 + finally: + store.close() + + +def test_long_words_never_emit_empty_or_oversized_chunks(): + text = "x" * (CHUNK_SIZE * 3 + 17) + for candidate in ( + text, + "before " + text + " after", + "prefix\n\n" + text, + "before " + "x" * CHUNK_SIZE + " after", + ): + chunks = KnowledgeStore._chunk_text(candidate) + assert chunks + assert all(0 < len(chunk) <= CHUNK_SIZE for chunk in chunks) + assert "x" * (CHUNK_SIZE if len(candidate) < len(text) else len(text)) in "".join(chunks) + + +def test_whitespace_paragraph_does_not_emit_empty_chunk(): + chunks = KnowledgeStore._chunk_text("a" * CHUNK_SIZE + "\n\n \n\n" + "b" * CHUNK_SIZE) + assert all(chunk.strip() for chunk in chunks) + + +def test_consecutive_whitespace_paragraphs_do_not_emit_empty_chunk(): + chunks = KnowledgeStore._chunk_text( + "a" * CHUNK_SIZE + "\n\n \n\n\t\n\n" + "b" * CHUNK_SIZE + ) + assert all(chunk.strip() for chunk in chunks) + assert chunks[0] == "a" * CHUNK_SIZE + assert chunks[-1] == "b" * CHUNK_SIZE + + +def test_blank_paragraph_after_oversized_paragraph_does_not_erase_following_text(): + text = "a" * CHUNK_SIZE + "\n\n \n\n" + "b" * (CHUNK_SIZE + 1) + chunks = KnowledgeStore._chunk_text(text) + assert all(chunk.strip() for chunk in chunks) + assert chunks[0] == "a" * CHUNK_SIZE + assert "b" * (CHUNK_SIZE + 1) in "".join(chunks) + + +def test_whitespace_only_paragraph_after_full_chunk_does_not_create_chunk(): + chunks = KnowledgeStore._chunk_text("a" * CHUNK_SIZE + "\n\n \t \n\n" + "tail") + assert chunks == ["a" * CHUNK_SIZE, "tail"] + + +def test_short_nonblank_paragraph_after_full_chunk_is_kept(): + chunks = KnowledgeStore._chunk_text("a" * CHUNK_SIZE + "\n\nsmall") + assert chunks == ["a" * CHUNK_SIZE, "small"] + + +async def test_duplicate_ingest_skips_embedding_before_precheck(tmp_path): + store = KnowledgeStore(str(tmp_path / "knowledge.db")) + + class Embedder: + calls = 0 + + async def embed(self, _text): + self.calls += 1 + return [0.0] * 3 + + try: + assert (await store.ingest("unchanged content", "same-source")).status == "stored" + store._has_vec = True + embedder = Embedder() + outcome = await store.ingest("unchanged content", "same-source", embedder=embedder) + assert outcome.status == "unchanged" + assert embedder.calls == 0 + finally: + store.close() + + +def test_long_word_after_regular_words_flushes_pending_chunk(): + word = "x" * (CHUNK_SIZE + 7) + chunks = KnowledgeStore._chunk_text("before " + word + " after") + assert chunks[0] == "before" + assert "".join(chunks[1:]).startswith(word) + assert all(0 < len(chunk) <= CHUNK_SIZE for chunk in chunks) + + +async def test_existing_source_specific_chunk_id_is_kept_after_collision_owner_removed(tmp_path): + store = KnowledgeStore(str(tmp_path / "knowledge.db")) + original_md5 = knowledge_module.hashlib.md5 + + def same_prefix_md5(data, *args, **kwargs): + if data in (b"first", b"second"): + return original_md5(b"same-source-prefix", *args, **kwargs) + return original_md5(data, *args, **kwargs) + + try: + with patch.object(knowledge_module.hashlib, "md5", side_effect=same_prefix_md5): + assert (await store.ingest("shared", "first", dedup=False)).status == "stored" + assert (await store.ingest("shared", "second", dedup=False)).status == "stored" + fallback_id = store.get_source_chunks("second")[0]["chunk_id"] + assert hashlib.sha256(b"second").hexdigest() in fallback_id + assert store.delete_source("first") == 1 + assert (await store.ingest("shared", "second", dedup=False)).status == "stored" + assert store.get_source_chunks("second")[0]["chunk_id"] == fallback_id + finally: + store.close() + + +async def test_occupied_source_specific_id_gets_numbered_suffix(tmp_path): + store = KnowledgeStore(str(tmp_path / "knowledge.db")) + original_md5 = knowledge_module.hashlib.md5 + + def same_prefix_md5(data, *args, **kwargs): + if data in (b"first", b"second"): + return original_md5(b"same-source-prefix", *args, **kwargs) + return original_md5(data, *args, **kwargs) + + try: + with patch.object(knowledge_module.hashlib, "md5", side_effect=same_prefix_md5): + assert (await store.ingest("shared", "first", dedup=False)).status == "stored" + base_id = store.get_source_chunks("first")[0]["chunk_id"] + reserved = f"{base_id}_{hashlib.sha256(b'second').hexdigest()}" + store._conn.execute( + "INSERT INTO knowledge_chunks " + "(chunk_id, content, source, chunk_index, total_chunks, uploader, " + "ingested_at, content_hash, doc_content_hash) " + "SELECT ?, content, ?, chunk_index, total_chunks, uploader, " + "ingested_at, content_hash, doc_content_hash " + "FROM knowledge_chunks WHERE chunk_id = ?", + (reserved, "third", base_id), + ) + store._conn.commit() + assert (await store.ingest("shared", "second", dedup=False)).status == "stored" + assert store.get_source_chunks("second")[0]["chunk_id"] == reserved + "_1" + assert store.get_source_chunks("third")[0]["chunk_id"] == reserved + finally: + store.close() + + +async def test_colliding_sources_survive_restore_and_dedup_bypass(tmp_path): + fts = FullTextIndex(str(tmp_path / "search.db")) + store = KnowledgeStore(str(tmp_path / "knowledge.db"), fts_index=fts) + original_md5 = knowledge_module.hashlib.md5 + + def same_prefix_md5(data, *args, **kwargs): + if data in (b"first", b"second"): + return original_md5(b"same-source-prefix", *args, **kwargs) + return original_md5(data, *args, **kwargs) + + try: + with patch.object(knowledge_module.hashlib, "md5", side_effect=same_prefix_md5): + assert (await store.ingest("old", "first")).status == "stored" + assert (await store.ingest("shared", "second", dedup=False)).status == "stored" + assert (await store.ingest("shared", "first", dedup=False)).status == "stored" + first_id = store.get_source_chunks("first")[0]["chunk_id"] + second_id = store.get_source_chunks("second")[0]["chunk_id"] + assert first_id != second_id + assert store.source_is_durable("first", 1) + assert store.source_is_durable("second", 1) + assert await store.restore_version("first", 1) == 1 + assert store.source_is_durable("first", 1) + assert store.source_is_durable("second", 1) + assert store.get_source_chunks("second")[0]["chunk_id"] == second_id + assert store.get_source_content("second") == "shared" + finally: + store.close() + fts._conn.close() diff --git a/tests/test_knowledge_import.py b/tests/test_knowledge_import.py index d124afe48..ed329d5f7 100644 --- a/tests/test_knowledge_import.py +++ b/tests/test_knowledge_import.py @@ -1015,9 +1015,9 @@ async def test_pdf_content_truncation(self): patch("src.tools.safe_fetch.safe_fetch", mock_session): r = await importer.import_pdf_url("https://example.com/big.pdf") assert r.status == "ok" - content = store.get_source_content(r.source) + content = store.get_source_snapshot(r.source) assert content is not None - assert len(content) <= PDF_MAX_CHARS + 100 + assert len(content) <= PDF_MAX_CHARS finally: _cleanup(store) @@ -1185,9 +1185,9 @@ async def test_content_truncation(self): with patch("src.tools.safe_fetch.safe_fetch", mock_session): r = await importer.import_web_url("https://example.com/huge") assert r.status == "ok" - content = store.get_source_content("https://example.com/huge") + content = store.get_source_snapshot("https://example.com/huge") assert content is not None - assert len(content) <= FETCH_MAX_CHARS + 100 + assert len(content) <= FETCH_MAX_CHARS finally: _cleanup(store) diff --git a/tests/test_knowledge_targeted_coverage.py b/tests/test_knowledge_targeted_coverage.py new file mode 100644 index 000000000..b771f1f67 --- /dev/null +++ b/tests/test_knowledge_targeted_coverage.py @@ -0,0 +1,26 @@ +"""Targeted edge coverage for knowledge import and chunking limits.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from src.knowledge.importer import BulkImporter +from src.knowledge.store import CHUNK_SIZE, KnowledgeStore + + +def test_import_limit_returns_empty_when_chunks_cannot_fit(): + store = MagicMock() + # Every non-empty candidate becomes a chunk larger than the requested cap. + store._chunk_text.side_effect = lambda text: [text + "x"] + importer = BulkImporter(store) + + assert importer._limit_import_content("content", 0) == "" + + +def test_chunk_text_hard_splits_oversized_unbroken_words(): + text = "a" * (CHUNK_SIZE * 2 + 7) + + chunks = KnowledgeStore._chunk_text(text) + + assert chunks == ["a" * CHUNK_SIZE, "a" * CHUNK_SIZE, "a" * 7] + assert "".join(chunks) == text diff --git a/tests/test_local_workspace.py b/tests/test_local_workspace.py index 75b8ab47d..2bf42551c 100644 --- a/tests/test_local_workspace.py +++ b/tests/test_local_workspace.py @@ -313,61 +313,6 @@ def test_remote_execution_signature_has_no_workspace() -> None: assert "cwd" not in inspect.signature(run_ssh_command).parameters -def test_discord_execution_path_excludes_the_legacy_dag_surfaces( - workspace: Path, fake_install: Path -) -> None: - """The legacy `src/odin/tools/{shell,process}.py` surfaces spawn subprocesses - that inherit the application cwd, so if the Discord path adopted them the - 2026-07-27 mechanism reopens behind this fix. - - Round-2 rewrote this from a grep; round 3 showed it was still vacuous — - `__new__` creates no handler owners, so the loop inspected nothing, and it - reloaded the executor rather than the Discord tool-loop path. This builds a - REAL executor and inspects every resolved owner's module, then imports the - Discord path in a CLEAN interpreter and proves neither legacy module loads. - """ - import subprocess - import sys - - from src.tools.executor import EXECUTOR_HANDLERS - - executor = _executor_with_workspace(workspace, fake_install) - - inspected: list[str] = [] - for tool_name, (owner_key, attr) in EXECUTOR_HANDLERS.items(): - # _handler_owners is the real late-bound registry (RFC-004); owner_key - # is a registry key like "system", not an attribute name. - owner = executor._handler_owners.get(owner_key) - assert owner is not None, f"{tool_name}: handler owner {owner_key!r} did not resolve" - module = type(owner).__module__ - inspected.append(module) - assert not module.startswith("src.odin.tools"), ( - f"{tool_name} is served by legacy module {module}.{attr}" - ) - assert inspected, "no handler owners were inspected — the check would be vacuous" - - # A clean interpreter: importing the Discord execution path must not pull - # the legacy surfaces in, transitively or otherwise. - probe = ( - "import sys; import src.discord.tool_loop; " - "leaked=[m for m in ('src.odin.tools.shell','src.odin.tools.process') " - "if m in sys.modules]; print(','.join(leaked))" - ) - result = subprocess.run( - [sys.executable, "-c", probe], - capture_output=True, - text=True, - timeout=120, - cwd=str(Path(__file__).resolve().parents[1]), - ) - assert result.returncode == 0, result.stderr[-500:] - leaked = result.stdout.strip() - assert leaked == "", ( - f"the Discord tool-loop path imports {leaked}; those surfaces inherit " - "the application cwd and would reopen the wipe mechanism" - ) - - def _executor_with_workspace(workspace: Path, protected: Path): """A ToolExecutor whose local commands run in ``workspace``.""" from src.config.schema import ToolHost, ToolsConfig @@ -1954,9 +1899,6 @@ def counting_walk(*args, **kwargs): ), "src/packaging/validate.py": "build-time packaging check, not a runtime path", "src/restart.py": "os.execve re-exec of Odin himself", - # --- legacy CLI surface, unreachable from Discord (pinned separately) ---- - "src/odin/tools/shell.py": "legacy CLI ShellTool; excluded from the Discord tool loop", - "src/odin/tools/process.py": "legacy CLI ProcessTool; excluded from the Discord tool loop", } diff --git a/tests/test_mcp_client_eras.py b/tests/test_mcp_client_eras.py index 54d262552..7b440fc26 100644 --- a/tests/test_mcp_client_eras.py +++ b/tests/test_mcp_client_eras.py @@ -403,6 +403,32 @@ async def test_session_expiry_is_definite_failure_never_reissued(self): class TestHttpCallsAndStreams: + @pytest.mark.parametrize( + ("streamed", "duplicate_key"), + [(False, "json_duplicate_methods"), (True, "sse_duplicate_methods")], + ) + async def test_duplicate_call_response_first_wins_and_warns( + self, caplog, streamed, duplicate_key + ): + # JSON batches are permitted only by the legacy batch wire version; + # modern servers must stream separate responses instead. + server, url, state = await _http_server("modern-sse" if streamed else "legacy-batch") + state["respond_in_sse"] = streamed + state[duplicate_key] = {"tools/call"} + conn = MCPServerConnection("dup-http", "http", url=url) + try: + await conn.connect() + discovery = await conn.discover_tools() + echo = next(t for t in discovery.tools if t.name == "echo") + with caplog.at_level("WARNING", logger="mcp.client"): + outcome = await conn.call_tool(echo, {"text": "first"}) + assert outcome.ok and "echo: first" in outcome.text + assert "dropping duplicate response" in caplog.text + assert "dup-http" in caplog.text and "request id=" in caplog.text + finally: + await conn.disconnect() + await server.close() + async def test_modern_sse_response_with_notifications(self): server, url, state = await _http_server("modern-sse") conn = MCPServerConnection("msse", "http", url=url) @@ -583,6 +609,26 @@ def test_http_5xx_body_never_establishes_era(self, code): with pytest.raises(MCPConnectError, match="not era evidence"): conn._classify_http_probe(outcome, outcome.messages[0]) # noqa: SLF001 + async def test_duplicate_response_in_non_2xx_reply_warns(self, caplog): + conn = MCPServerConnection("http-duplicate", "http", url="http://example.invalid/mcp") + conn.negotiated_version = "2025-06-18" + conn._http = type("FakeHttp", (), {"started": True, "session_id": None})() # noqa: SLF001 + duplicate = {"jsonrpc": "2.0", "id": 7, "result": {"ok": True}} + outcome = client_mod.PostOutcome( + client_mod.RESULT_HTTP_ERROR, + status=429, + messages=[duplicate, duplicate.copy()], + ) + + async def post(*_args, **_kwargs): + return outcome + + conn._http.post = post # type: ignore[method-assign] # noqa: SLF001 + with caplog.at_level("WARNING", logger="mcp.client"): + with pytest.raises(MCPProtocolError, match="HTTP 429"): + await conn._http_roundtrip(7, {}, 1, mcp_method="tools/list", mcp_name=None) # noqa: SLF001 + assert caplog.text.count("dropping duplicate response") == 1 + async def test_repeated_session_rejection_marks_connection_lost(self): server, url, state = await _http_server("legacy-session") lost: list[str] = [] @@ -779,7 +825,8 @@ def forbidden(*args, **kwargs): monkeypatch.setattr(client_mod.asyncio, "create_task", forbidden) conn._handle_server_request( - {"jsonrpc": "2.0", "id": 42, "method": "roots/list"}, channel="stdio", + {"jsonrpc": "2.0", "id": 42, "method": "roots/list"}, + channel="stdio", ) assert not conn._server_reply_tasks @@ -856,7 +903,9 @@ async def test_strict_legacy_two_spawns_then_full_service(self, monkeypatch): await conn.disconnect() async def test_initialized_replacement_that_closes_is_never_published( - self, monkeypatch, tmp_path, + self, + monkeypatch, + tmp_path, ): spawns = self._count_spawns(monkeypatch) conn = _stdio("legacy-die-after-initialize") @@ -937,7 +986,9 @@ async def send(self, message): if method == "server/discover": conn._on_stdio_message( # noqa: SLF001 proto.build_error_response( - message["id"], proto.ERROR_METHOD_NOT_FOUND, "legacy server", + message["id"], + proto.ERROR_METHOD_NOT_FOUND, + "legacy server", ) ) elif method == "initialize": @@ -955,7 +1006,8 @@ async def send(self, message): elif method == "notifications/initialized": self.closed_fired = True conn._on_stdio_transport_closed( # type: ignore[arg-type] # noqa: SLF001 - self, "direct-path stdout closed during handshake", + self, + "direct-path stdout closed during handshake", ) async def shutdown(self): diff --git a/tests/test_mcp_transport_stdio.py b/tests/test_mcp_transport_stdio.py index 61e41d2a7..294e6bb3a 100644 --- a/tests/test_mcp_transport_stdio.py +++ b/tests/test_mcp_transport_stdio.py @@ -137,6 +137,61 @@ async def test_double_shutdown_is_idempotent(self): class TestBoundedLines: + async def test_duplicate_response_first_wins_and_warns(self, caplog): + conn = MCPServerConnection( + "dup-stdio", "stdio", command=sys.executable, args=[FAKE, "legacy"] + ) + loop = asyncio.get_running_loop() + future = loop.create_future() + conn._pending[91] = future # noqa: SLF001 + first = {"jsonrpc": "2.0", "id": 91, "result": {"first": True}} + second = {"jsonrpc": "2.0", "id": 91, "result": {"secret": "must not be logged"}} + try: + with caplog.at_level("WARNING", logger="mcp.client"): + conn._on_stdio_message(first) + conn._on_stdio_message(second) + assert future.result() is first + assert "dropping duplicate response" in caplog.text + assert "dup-stdio" in caplog.text and "id=91" in caplog.text + assert "must not be logged" not in caplog.text + finally: + conn._pending.clear() # noqa: SLF001 + + async def test_later_duplicate_warns_but_cancelled_and_unknown_ids_stay_debug(self, caplog): + conn = MCPServerConnection("later-dup", "stdio", command=sys.executable) + loop = asyncio.get_running_loop() + completed = loop.create_future() + conn._pending[91] = completed # noqa: SLF001 + conn._on_stdio_message({"jsonrpc": "2.0", "id": 91, "result": {}}) + conn._pending.pop(91) # noqa: SLF001 + cancelled = loop.create_future() + conn._pending[92] = cancelled # noqa: SLF001 + cancelled.cancel() + try: + with caplog.at_level("DEBUG"): + conn._on_stdio_message({"jsonrpc": "2.0", "id": 91, "result": {}}) + conn._on_stdio_message({"jsonrpc": "2.0", "id": 92, "result": {}}) + conn._on_stdio_message({"jsonrpc": "2.0", "id": 93, "result": {}}) + assert caplog.text.count("dropping duplicate response") == 1 + warnings = [ + record.getMessage() for record in caplog.records if record.levelname == "WARNING" + ] + assert len(warnings) == 1 and "id=91" in warnings[0] + assert any("id=92" in record.getMessage() for record in caplog.records) + assert any("id=93" in record.getMessage() for record in caplog.records) + finally: + conn._pending.clear() # noqa: SLF001 + + def test_completed_request_ids_are_bounded(self): + conn = MCPServerConnection("bounded-ids", "stdio", command=sys.executable) + from src.tools.mcp.client import _COMPLETED_REQUEST_IDS_LIMIT + + for req_id in range(_COMPLETED_REQUEST_IDS_LIMIT + 10): + conn._remember_completed_request_id(req_id) # noqa: SLF001 + assert len(conn._completed_request_ids) == _COMPLETED_REQUEST_IDS_LIMIT # noqa: SLF001 + assert 0 not in conn._completed_request_ids # noqa: SLF001 + assert _COMPLETED_REQUEST_IDS_LIMIT + 9 in conn._completed_request_ids # noqa: SLF001 + async def test_oversized_response_line_closes_connection(self, monkeypatch): # Shrink the ceiling so the fake's echo of a large payload overflows # the reader limit; the pump must close and the call classify diff --git a/tests/test_notification_payload_contracts.py b/tests/test_notification_payload_contracts.py index a76ae7234..1a8ad0be3 100644 --- a/tests/test_notification_payload_contracts.py +++ b/tests/test_notification_payload_contracts.py @@ -1,7 +1,7 @@ """Exercise final delivery seams, not just notification formatters.""" import json -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest @@ -11,7 +11,6 @@ OutboundWebhookDispatcher, WebhookTarget, ) -from src.notifications.slack import SlackNotifier @pytest.mark.parametrize( @@ -30,15 +29,6 @@ async def test_final_secret_key_values_scrubbed_on_both_transports(monkeypatch, assert webhook["data"]["metadata"][0]["ordinary"] == "keep me" assert webhook["data"]["metadata"][0]["input_tokens"] == 123 - notifier = SlackNotifier(default_webhook_url="https://example.com") - session = MagicMock() - session.post.return_value.__aenter__ = AsyncMock(return_value=MagicMock(status=200)) - monkeypatch.setattr(notifier, "_get_session", AsyncMock(return_value=session)) - assert await notifier.send("hello", payload=payload) - slack = session.post.call_args.kwargs["json"] - assert slack["metadata"][0][field] == "[REDACTED]" - assert slack["metadata"][0]["ordinary"] == "keep me" - assert slack["metadata"][0]["input_tokens"] == 123 assert payload["metadata"][0][field] == "shortvalue" @@ -94,21 +84,3 @@ async def test_webhook_final_json_scrub_and_bounded_omissions(monkeypatch): body = deliver.call_args.args[1] assert len(body) <= MAX_PAYLOAD_CHARS assert json.loads(body)["_omission"]["fields"] >= 1 - - -async def test_slack_final_metadata_and_nested_scrub(monkeypatch): - monkeypatch.setattr( - "src.notifications.slack.scrub_output_secrets", - lambda text: text.replace("SYNTHETIC", "removed"), - ) - notifier = SlackNotifier(default_webhook_url="https://example.com") - session = MagicMock() - session.post.return_value.__aenter__ = AsyncMock(return_value=MagicMock(status=200)) - monkeypatch.setattr(notifier, "_get_session", AsyncMock(return_value=session)) - assert await notifier.send_formatted("title", "message", source="SYNTHETIC") - assert "SYNTHETIC" not in json.dumps(session.post.call_args.kwargs["json"]) - notifier._last_sent.clear() - payload = {"attachments": [{"fields": [{"value": "SYNTHETIC"}]}]} - assert await notifier.send("hello", payload=payload) - assert "SYNTHETIC" not in json.dumps(session.post.call_args.kwargs["json"]) - assert payload["attachments"][0]["fields"][0]["value"] == "SYNTHETIC" diff --git a/tests/test_outbound_webhooks.py b/tests/test_outbound_webhooks.py index 1329a1a36..804ccfcb1 100644 --- a/tests/test_outbound_webhooks.py +++ b/tests/test_outbound_webhooks.py @@ -4,6 +4,7 @@ HMAC signing, rate limiting, secret scrubbing, retries, payload building, config schema, and REST API endpoints. """ + from __future__ import annotations import hashlib @@ -28,6 +29,8 @@ WebhookStats, WebhookTarget, _truncate_payload, + _validate_webhook_url, + _WebhookResolver, build_event_payload, sign_payload, ) @@ -40,10 +43,12 @@ def _make_mock_session(*, status=200, text="ok"): mock_resp.text = AsyncMock(return_value=text) mock_session = AsyncMock() - mock_session.post = MagicMock(return_value=AsyncMock( - __aenter__=AsyncMock(return_value=mock_resp), - __aexit__=AsyncMock(return_value=False), - )) + mock_session.post = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_resp), + __aexit__=AsyncMock(return_value=False), + ) + ) mock_session.closed = False return mock_session, mock_resp @@ -93,6 +98,26 @@ def test_defaults(self): assert t.verify_ssl is True assert t.created_at # auto-populated + @pytest.mark.asyncio + async def test_resolver_rejects_metadata_address(self): + resolver = _WebhookResolver() + resolver._inner = AsyncMock() + resolver._inner.resolve.return_value = [{"host": "169.254.169.254"}] + with pytest.raises(Exception, match="cloud-metadata"): + await resolver.resolve("example.test") + + def test_invalid_port_is_normalized_to_value_error(self): + with pytest.raises(ValueError, match="Invalid webhook URL"): + _validate_webhook_url("https://example.test:invalid/hook") + + @pytest.mark.asyncio + async def test_resolver_returns_nonmetadata_resolution(self): + resolver = _WebhookResolver() + resolver._inner = AsyncMock() + rows = [{"host": "192.0.2.10"}] + resolver._inner.resolve.return_value = rows + assert await resolver.resolve("example.test") == rows + def test_accepts_event_empty_list(self): t = WebhookTarget(id="a", name="t", url="https://x.com", events=[]) assert t.accepts_event("tool_execution") is True @@ -150,8 +175,12 @@ def test_defaults(self): def test_to_dict_minimal(self): r = DeliveryResult( - webhook_id="a", webhook_name="n", event_type="alert", success=True, - status_code=200, latency_ms=42.6789, + webhook_id="a", + webhook_name="n", + event_type="alert", + success=True, + status_code=200, + latency_ms=42.6789, ) d = r.to_dict() assert d["webhook_id"] == "a" @@ -162,7 +191,10 @@ def test_to_dict_minimal(self): def test_to_dict_with_error(self): r = DeliveryResult( - webhook_id="a", webhook_name="n", event_type="alert", error="timeout", + webhook_id="a", + webhook_name="n", + event_type="alert", + error="timeout", ) d = r.to_dict() assert d["error"] == "timeout" @@ -188,8 +220,12 @@ def test_initial_zeros(self): def test_record_success(self): s = WebhookStats() r = DeliveryResult( - webhook_id="a", webhook_name="n", event_type="alert", - success=True, status_code=200, attempt=1, + webhook_id="a", + webhook_name="n", + event_type="alert", + success=True, + status_code=200, + attempt=1, ) s.record(r) assert s.total_dispatched == 1 @@ -201,8 +237,12 @@ def test_record_success(self): def test_record_failure(self): s = WebhookStats() r = DeliveryResult( - webhook_id="a", webhook_name="n", event_type="alert", - success=False, error="timeout", attempt=1, + webhook_id="a", + webhook_name="n", + event_type="alert", + success=False, + error="timeout", + attempt=1, ) s.record(r) assert s.total_dispatched == 1 @@ -212,8 +252,12 @@ def test_record_failure(self): def test_record_retry(self): s = WebhookStats() r = DeliveryResult( - webhook_id="a", webhook_name="n", event_type="alert", - success=True, status_code=200, attempt=2, + webhook_id="a", + webhook_name="n", + event_type="alert", + success=True, + status_code=200, + attempt=2, ) s.record(r) assert s.total_retries == 1 @@ -223,7 +267,10 @@ def test_recent_deliveries_capped(self): s = WebhookStats() for i in range(MAX_RECENT_DELIVERIES + 50): r = DeliveryResult( - webhook_id=str(i), webhook_name="n", event_type="alert", success=True, + webhook_id=str(i), + webhook_name="n", + event_type="alert", + success=True, ) s.record(r) assert len(s.recent_deliveries) == MAX_RECENT_DELIVERIES @@ -242,7 +289,10 @@ def test_as_dict_recent_capped_at_20(self): s = WebhookStats() for i in range(30): r = DeliveryResult( - webhook_id=str(i), webhook_name="n", event_type="alert", success=True, + webhook_id=str(i), + webhook_name="n", + event_type="alert", + success=True, ) s.record(r) d = s.as_dict() @@ -251,7 +301,10 @@ def test_as_dict_recent_capped_at_20(self): def test_json_serializable(self): s = WebhookStats() r = DeliveryResult( - webhook_id="a", webhook_name="n", event_type="alert", success=True, + webhook_id="a", + webhook_name="n", + event_type="alert", + success=True, ) s.record(r) json.dumps(s.as_dict()) @@ -329,6 +382,23 @@ def test_long_payload_truncated(self): class TestDispatcherRegister: + def test_basic_auth_password_is_redacted_in_register_and_unregister_logs(self): + url = "https://operator:private-password@example.com:9443/hook?route=one" + with patch("src.notifications.outbound_webhooks.log.info") as info: + dispatcher = OutboundWebhookDispatcher() + target = dispatcher.register(name="private", url=url) + assert target.url == url # delivery still uses the original credential + assert target.to_dict()["url"] == ( + "https://operator:[REDACTED]@example.com:9443/hook?route=one" + ) + assert dispatcher.unregister(target.id) + assert info.call_count == 2 + for call in info.call_args_list: + assert "private-password" not in repr(call) + assert "[REDACTED]" in repr(call) + assert info.call_args_list[0].args[2] == target.to_dict()["url"] + assert info.call_args_list[1].args[2] == target.to_dict()["url"] + def test_register_basic(self): d = OutboundWebhookDispatcher() t = d.register(name="test", url="https://example.com/hook") @@ -340,9 +410,12 @@ def test_register_basic(self): def test_register_with_all_fields(self, monkeypatch): # Registration-field semantics must not depend on a five-second mDNS # lookup for jenkins.local. Keep the URL policy active, with fixed DNS. - monkeypatch.setattr("socket.getaddrinfo", lambda *a, **kw: [ - (2, 1, 6, "", ("93.184.216.34", 443)), - ]) + monkeypatch.setattr( + "socket.getaddrinfo", + lambda *a, **kw: [ + (2, 1, 6, "", ("93.184.216.34", 443)), + ], + ) d = OutboundWebhookDispatcher() t = d.register( name="jenkins", @@ -547,6 +620,91 @@ class TestDispatchDelivery: def dispatcher(self): return OutboundWebhookDispatcher(rate_limit_seconds=0) + async def test_redirect_limit_returns_failure(self, dispatcher): + dispatcher.register(name="test", url="https://x.com/hook", webhook_id="wh1") + response = AsyncMock() + response.status = 307 + response.headers = {"Location": "/again"} + session = AsyncMock() + session.post = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=response), + __aexit__=AsyncMock(return_value=False), + ) + ) + session.closed = False + dispatcher._session = session + + with patch("src.notifications.outbound_webhooks.asyncio.sleep", new_callable=AsyncMock): + results = await dispatcher.dispatch("alert", {"msg": "test"}) + assert results[0].success is False + assert "redirect policy" in results[0].error + + async def test_redirect_303_switches_to_get_and_removes_signature(self, dispatcher): + dispatcher.register( + name="test", url="https://x.com/hook", secret="signing", webhook_id="wh1" + ) + redirect = AsyncMock() + redirect.status = 303 + redirect.headers = {"Location": "/next"} + ok = AsyncMock() + ok.status = 204 + ok.headers = {} + responses = [redirect, ok] + session = AsyncMock() + session.post = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(side_effect=responses), + __aexit__=AsyncMock(return_value=False), + ) + ) + session.get = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(side_effect=responses), + __aexit__=AsyncMock(return_value=False), + ) + ) + session.closed = False + dispatcher._session = session + + results = await dispatcher.dispatch("alert", {"msg": "test"}) + assert results[0].success is True + assert "X-Webhook-Signature" not in session.get.call_args.kwargs["headers"] + + async def test_cross_origin_redirect_strips_signature(self, dispatcher): + dispatcher.register( + name="test", url="https://x.com/hook", secret="signing", webhook_id="wh1" + ) + redirect = AsyncMock() + redirect.status = 307 + redirect.headers = {"Location": "https://elsewhere.test/next"} + ok = AsyncMock() + ok.status = 204 + ok.headers = {} + responses = [redirect, ok] + session = AsyncMock() + calls = [] + + def post(*args, **kwargs): + calls.append((args, kwargs)) + response = responses.pop(0) + return AsyncMock( + __aenter__=AsyncMock(return_value=response), + __aexit__=AsyncMock(return_value=False), + ) + + session.post = MagicMock(side_effect=post) + session.closed = False + dispatcher._session = session + with ( + patch("src.notifications.outbound_webhooks._validate_webhook_url"), + patch("src.notifications.outbound_webhooks._same_origin", return_value=False), + ): + result = await dispatcher.dispatch("alert", {"msg": "test"}) + assert result[0].success is True + assert "X-Webhook-Signature" in calls[0][1]["headers"] + assert "X-Webhook-Signature" not in calls[1][1]["headers"] + async def test_dispatch_no_targets(self, dispatcher): results = await dispatcher.dispatch("alert", {"msg": "test"}) assert results == [] @@ -614,7 +772,7 @@ async def test_dispatch_connection_error(self, dispatcher): results = await dispatcher.dispatch("alert", {"msg": "test"}) assert len(results) == 1 assert results[0].success is False - assert "refused" in results[0].error + assert results[0].error == "webhook transport failed" async def test_dispatch_multiple_targets(self, dispatcher): dispatcher.register(name="a", url="https://a.com/hook", webhook_id="wh1") @@ -1027,12 +1185,14 @@ def test_public_symbols(self): OutboundWebhookDispatcher, WebhookTarget, ) + assert EventType is not None assert WebhookTarget is not None assert OutboundWebhookDispatcher is not None def test_from_package(self): from src.notifications import OutboundWebhookDispatcher + assert OutboundWebhookDispatcher is not None diff --git a/tests/test_output_streamer.py b/tests/test_output_streamer.py index b1675e8c2..72694c0a5 100644 --- a/tests/test_output_streamer.py +++ b/tests/test_output_streamer.py @@ -1638,13 +1638,23 @@ async def test_abandon_is_idempotent(self): class TestStaleStreamSweep: - async def test_stream_ttl_covers_effective_tool_timeout_with_grace(self): + async def test_stream_ttl_covers_effective_tool_timeout_with_grace(self, monkeypatch): + from types import SimpleNamespace + + from src.tools import output_streamer from src.tools.output_streamer import ( STREAM_TIMEOUT_GRACE_SECONDS, ToolOutputStreamer, current_tool_timeout, ) + # An arbitrary real monotonic timestamp can straddle a float exponent + # boundary: (start + ttl) - start then rounds just below ttl. Use an + # exactly representable clock to test the exact expiry predicate, not + # the host's uptime. Replace this module's clock, never asyncio's time. + monkeypatch.setattr(output_streamer, "time", SimpleNamespace( + monotonic=lambda: 1024.0, monotonic_ns=lambda: 1024000000000, + )) streamer = ToolOutputStreamer(enabled_tools={"run_command"}) token = current_tool_timeout.set(7200) try: @@ -1658,7 +1668,13 @@ async def test_stream_ttl_covers_effective_tool_timeout_with_grace(self): assert not streamer.has_stale_streams( now=stream.started_at + 3600 + 1 ) + assert not streamer.has_stale_streams( + now=stream.started_at + stream.ttl_seconds - 0.5 + ) assert streamer.has_stale_streams(now=stream.started_at + stream.ttl_seconds) + assert await streamer.sweep_stale_streams( + now=stream.started_at + stream.ttl_seconds + ) == 1 async def test_short_effective_timeout_does_not_shrink_default_ttl(self): from src.tools.output_streamer import ToolOutputStreamer, current_tool_timeout diff --git a/tests/test_plan_loader.py b/tests/test_plan_loader.py deleted file mode 100644 index ccdbf24bd..000000000 --- a/tests/test_plan_loader.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Tests for plan loading.""" - -import json - -import pytest - -from src.odin.plan_loader import load_plan - - -class TestLoadPlan: - def test_from_dict(self): - plan = load_plan({"name": "t", "steps": [{"id": "a", "tool": "echo"}]}) - assert plan.name == "t" - assert len(plan.steps) == 1 - - def test_from_json_string(self): - data = json.dumps({"name": "t", "steps": [{"id": "a", "tool": "echo"}]}) - plan = load_plan(data) - assert plan.name == "t" - - def test_deps_as_list(self): - plan = load_plan({ - "name": "t", - "steps": [ - {"id": "a", "tool": "echo"}, - {"id": "b", "tool": "echo", "depends_on": ["a"]}, - ], - }) - assert plan.steps[1].depends_on == ("a",) - - def test_options(self): - plan = load_plan({ - "name": "t", - "steps": [{ - "id": "a", - "tool": "echo", - "timeout": 10, - "retries": 3, - "continue_on_failure": True, - }], - }) - s = plan.steps[0] - assert s.timeout == 10.0 - assert s.retries == 3 - assert s.continue_on_failure is True - - def test_inputs_loaded(self): - plan = load_plan({ - "name": "t", - "steps": [{"id": "a", "tool": "echo", "params": {"msg": "${inputs.x}"}}], - "inputs": {"x": "hello"}, - }) - assert plan.inputs == {"x": "hello"} - - def test_missing_name_raises(self): - with pytest.raises(ValueError, match="name"): - load_plan({"steps": [{"id": "a", "tool": "echo"}]}) - - def test_missing_steps_raises(self): - with pytest.raises(ValueError, match="step"): - load_plan({"name": "t"}) diff --git a/tests/test_planner.py b/tests/test_planner.py deleted file mode 100644 index ed5a6123d..000000000 --- a/tests/test_planner.py +++ /dev/null @@ -1,653 +0,0 @@ -"""Tests for the DAG planner — execution, validation, and input interpolation.""" - -import asyncio - -import pytest - -from src.odin.planner import Planner -from src.odin.types import PlanSpec, StepSpec, StepStatus -from tests.conftest import TimestampTool - - -@pytest.fixture -def parallel_barrier(monkeypatch): - """Prove overlap by rendezvous, not by a wall-clock speed threshold.""" - def install(participants): - entered = set() - completed = set() - ready = asyncio.Event() - sequence = 0 - - async def execute(self, params, ctx): - nonlocal sequence - name = params["participant"] - sequence += 1 - start = sequence - if name in participants: - entered.add(name) - if entered == participants: - ready.set() - # Safety fuse only: serial execution cannot satisfy this barrier. - await asyncio.wait_for(ready.wait(), timeout=10) - assert entered == participants - if name == "d": - assert completed == participants - sequence += 1 - if name in participants: - completed.add(name) - return {"start": start, "end": sequence} - - monkeypatch.setattr(TimestampTool, "execute", execute) - return entered, completed - - return install - - -class TestExecution: - async def test_linear(self, ts_registry, linear_plan): - p = Planner(ts_registry) - r = await p.execute(linear_plan) - assert r.success - assert r.steps["c"].status == StepStatus.SUCCESS - - async def test_diamond(self, ts_registry, diamond_plan): - p = Planner(ts_registry) - r = await p.execute(diamond_plan) - assert r.success - assert set(r.steps) == {"a", "b", "c", "d"} - - async def test_failure_cascades(self, ts_registry): - plan = PlanSpec( - name="cascade", - steps=( - StepSpec(id="a", tool="fail", params={"fail_count": 999}), - StepSpec(id="b", tool="echo", params={"message": "hi"}, depends_on=("a",)), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert not r.success - assert r.steps["a"].status == StepStatus.FAILED - assert r.steps["b"].status == StepStatus.SKIPPED - - async def test_step_result_interpolation_chain(self, ts_registry): - plan = PlanSpec( - name="chain", - steps=( - StepSpec(id="a", tool="echo", params={"message": "first"}), - StepSpec( - id="b", - tool="echo", - params={"message": "got ${a.output}"}, - depends_on=("a",), - ), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert r.steps["b"].output == "got first" - - -class TestValidation: - def test_unknown_tool(self, ts_registry): - plan = PlanSpec(name="bad", steps=(StepSpec(id="a", tool="nope"),)) - p = Planner(ts_registry) - errors = p.validate(plan) - assert any("unknown tool" in e for e in errors) - - def test_dangling_dep(self, ts_registry): - plan = PlanSpec(name="bad", steps=(StepSpec(id="a", tool="echo", depends_on=("z",)),)) - p = Planner(ts_registry) - errors = p.validate(plan) - assert any("unknown step" in e for e in errors) - - def test_duplicate_id(self, ts_registry): - plan = PlanSpec( - name="bad", - steps=(StepSpec(id="a", tool="echo"), StepSpec(id="a", tool="echo")), - ) - p = Planner(ts_registry) - errors = p.validate(plan) - assert any("duplicate" in e for e in errors) - - -# --------------------------------------------------------------------------- -# Plan inputs — end-to-end through the planner -# --------------------------------------------------------------------------- - -class TestPlanInputs: - async def test_inputs_from_spec(self, ts_registry): - plan = PlanSpec( - name="with_inputs", - steps=(StepSpec(id="a", tool="echo", params={"message": "${inputs.greeting}"}),), - inputs={"greeting": "hello world"}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert r.steps["a"].output == "hello world" - - async def test_runtime_inputs_override_spec(self, ts_registry): - plan = PlanSpec( - name="override", - steps=(StepSpec(id="a", tool="echo", params={"message": "${inputs.x}"}),), - inputs={"x": "default"}, - ) - p = Planner(ts_registry) - r = await p.execute(plan, inputs={"x": "override"}) - assert r.success - assert r.steps["a"].output == "override" - - async def test_mixed_input_and_step_refs(self, ts_registry): - plan = PlanSpec( - name="mixed", - steps=( - StepSpec(id="a", tool="echo", params={"message": "${inputs.prefix}"}), - StepSpec( - id="b", - tool="echo", - params={"message": "${a.output}-${inputs.suffix}"}, - depends_on=("a",), - ), - ), - inputs={"prefix": "hello", "suffix": "world"}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert r.steps["b"].output == "hello-world" - - async def test_bad_input_ref_fails_step_not_plan(self, ts_registry): - plan = PlanSpec( - name="bad_ref", - steps=( - StepSpec(id="good", tool="echo", params={"message": "ok"}), - StepSpec(id="bad", tool="echo", params={"message": "${inputs.nope}"}), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - # The bad step fails, good step succeeds - assert r.steps["good"].status == StepStatus.SUCCESS - assert r.steps["bad"].status == StepStatus.FAILED - assert "param resolution failed" in r.steps["bad"].error - - async def test_complex_input_object(self, ts_registry): - plan = PlanSpec( - name="complex", - steps=( - StepSpec(id="a", tool="echo", params={"message": "${inputs.cfg.db.host}"}), - ), - inputs={"cfg": {"db": {"host": "localhost", "port": 5432}}}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert r.steps["a"].output == "localhost" - - async def test_input_as_full_object_passthrough(self, ts_registry): - data = {"servers": ["a", "b"], "count": 2} - plan = PlanSpec( - name="passthrough", - steps=(StepSpec(id="a", tool="echo", params={"message": "${inputs.data}"}),), - inputs={"data": data}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert r.steps["a"].output == data - - -# --------------------------------------------------------------------------- -# Conditional step execution — ``when`` clause -# --------------------------------------------------------------------------- - -class TestConditionalExecution: - async def test_when_true_runs_step(self, ts_registry): - plan = PlanSpec( - name="cond", - steps=( - StepSpec(id="a", tool="echo", params={"message": "ok"}, when="${inputs.run}"), - ), - inputs={"run": True}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert r.steps["a"].status == StepStatus.SUCCESS - - async def test_when_false_skips_step(self, ts_registry): - plan = PlanSpec( - name="cond", - steps=( - StepSpec(id="a", tool="echo", params={"message": "ok"}, when="${inputs.run}"), - ), - inputs={"run": False}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success # skip is not a failure - assert r.steps["a"].status == StepStatus.SKIPPED - assert r.steps["a"].error == "condition not met" - - async def test_when_none_always_runs(self, ts_registry): - """Steps without a when clause always execute (backwards compatible).""" - plan = PlanSpec( - name="no_when", - steps=(StepSpec(id="a", tool="echo", params={"message": "hi"}),), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert r.steps["a"].status == StepStatus.SUCCESS - - async def test_when_string_equality(self, ts_registry): - plan = PlanSpec( - name="eq", - steps=( - StepSpec(id="a", tool="echo", params={"message": "deploy"}, - when="${inputs.env} == prod"), - ), - inputs={"env": "prod"}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["a"].status == StepStatus.SUCCESS - - async def test_when_string_equality_false(self, ts_registry): - plan = PlanSpec( - name="eq", - steps=( - StepSpec(id="a", tool="echo", params={"message": "deploy"}, - when="${inputs.env} == prod"), - ), - inputs={"env": "staging"}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["a"].status == StepStatus.SKIPPED - - async def test_when_inequality(self, ts_registry): - plan = PlanSpec( - name="neq", - steps=( - StepSpec(id="a", tool="echo", params={"message": "not prod"}, - when="${inputs.env} != prod"), - ), - inputs={"env": "staging"}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["a"].status == StepStatus.SUCCESS - - async def test_when_references_step_output(self, ts_registry): - plan = PlanSpec( - name="step_ref", - steps=( - StepSpec(id="check", tool="echo", params={"message": "go"}), - StepSpec(id="act", tool="echo", params={"message": "acted"}, - depends_on=("check",), when="${check.output} == go"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["act"].status == StepStatus.SUCCESS - - async def test_when_skip_does_not_cascade(self, ts_registry): - """A when-skipped step should NOT cascade-skip its dependents.""" - plan = PlanSpec( - name="no_cascade", - steps=( - StepSpec(id="optional", tool="echo", params={"message": "x"}, - when="${inputs.run_optional}"), - StepSpec(id="always", tool="echo", params={"message": "yes"}, - depends_on=("optional",)), - ), - inputs={"run_optional": False}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["optional"].status == StepStatus.SKIPPED - assert r.steps["always"].status == StepStatus.SUCCESS - - async def test_when_missing_ref_skips(self, ts_registry): - """Bad interpolation in when expression skips the step (doesn't crash).""" - plan = PlanSpec( - name="bad_when", - steps=( - StepSpec(id="a", tool="echo", params={"message": "ok"}, - when="${inputs.nonexistent}"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["a"].status == StepStatus.SKIPPED - - def test_when_loaded_from_dict(self): - from src.odin.plan_loader import load_plan - plan = load_plan({ - "name": "t", - "steps": [{"id": "a", "tool": "echo", "when": "${inputs.go}"}], - "inputs": {"go": True}, - }) - assert plan.steps[0].when == "${inputs.go}" - - async def test_when_with_runtime_input_override(self, ts_registry): - """Runtime inputs can toggle conditional steps.""" - plan = PlanSpec( - name="toggle", - steps=( - StepSpec(id="a", tool="echo", params={"message": "ran"}, - when="${inputs.enabled}"), - ), - inputs={"enabled": False}, - ) - p = Planner(ts_registry) - # Default: disabled - r = await p.execute(plan) - assert r.steps["a"].status == StepStatus.SKIPPED - # Override: enabled - r = await p.execute(plan, inputs={"enabled": True}) - assert r.steps["a"].status == StepStatus.SUCCESS - - # -- branching on prior step outputs ------------------------------------ - - async def test_when_step_output_nested_path(self, ts_registry): - """Condition can reference nested dict paths in prior step output.""" - plan = PlanSpec( - name="nested_branch", - steps=( - StepSpec(id="check", tool="echo", - params={"message": {"env": "prod", "ready": True}}), - StepSpec(id="deploy", tool="echo", - params={"message": "deployed"}, - depends_on=("check",), - when="${check.output.env} == prod"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["deploy"].status == StepStatus.SUCCESS - - async def test_when_step_output_nested_mismatch_skips(self, ts_registry): - plan = PlanSpec( - name="nested_skip", - steps=( - StepSpec(id="check", tool="echo", - params={"message": {"env": "staging"}}), - StepSpec(id="deploy", tool="echo", - params={"message": "deployed"}, - depends_on=("check",), - when="${check.output.env} == prod"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["deploy"].status == StepStatus.SKIPPED - - async def test_when_step_output_truthiness(self, ts_registry): - """Condition on step output truthy value (non-string).""" - plan = PlanSpec( - name="truthy_branch", - steps=( - StepSpec(id="gate", tool="echo", - params={"message": {"items": [1, 2, 3]}}), - StepSpec(id="process", tool="echo", - params={"message": "processing"}, - depends_on=("gate",), - when="${gate.output.items}"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["process"].status == StepStatus.SUCCESS - - async def test_when_step_output_falsy_skips(self, ts_registry): - """Empty list output should cause condition to skip.""" - plan = PlanSpec( - name="falsy_branch", - steps=( - StepSpec(id="gate", tool="echo", - params={"message": {"items": []}}), - StepSpec(id="process", tool="echo", - params={"message": "processing"}, - depends_on=("gate",), - when="${gate.output.items}"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["process"].status == StepStatus.SKIPPED - - async def test_when_dollar_brace_steps_prefix(self, ts_registry): - """${steps.X.output} form works in when conditions.""" - plan = PlanSpec( - name="steps_prefix", - steps=( - StepSpec(id="check", tool="echo", params={"message": "go"}), - StepSpec(id="act", tool="echo", params={"message": "acted"}, - depends_on=("check",), - when="${steps.check.output} == go"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["act"].status == StepStatus.SUCCESS - - async def test_when_bare_brace_steps_prefix(self, ts_registry): - """{steps.X.output} form works in when conditions.""" - plan = PlanSpec( - name="bare_steps", - steps=( - StepSpec(id="check", tool="echo", params={"message": "go"}), - StepSpec(id="act", tool="echo", params={"message": "acted"}, - depends_on=("check",), - when="{steps.check.output} == go"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["act"].status == StepStatus.SUCCESS - - async def test_when_multi_step_chain_branching(self, ts_registry): - """Three-step chain: A produces data, B branches on A, C branches on B.""" - plan = PlanSpec( - name="chain_branch", - steps=( - StepSpec(id="a", tool="echo", params={"message": "ready"}), - StepSpec(id="b", tool="echo", params={"message": "processed"}, - depends_on=("a",), - when="${a.output} == ready"), - StepSpec(id="c", tool="echo", params={"message": "final"}, - depends_on=("b",), - when="${b.output} == processed"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["a"].status == StepStatus.SUCCESS - assert r.steps["b"].status == StepStatus.SUCCESS - assert r.steps["c"].status == StepStatus.SUCCESS - - async def test_when_step_output_inequality(self, ts_registry): - plan = PlanSpec( - name="neq_step", - steps=( - StepSpec(id="check", tool="echo", params={"message": "staging"}), - StepSpec(id="warn", tool="echo", params={"message": "not prod!"}, - depends_on=("check",), - when="${check.output} != prod"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["warn"].status == StepStatus.SUCCESS - - async def test_when_numeric_gt_runs_step(self, ts_registry): - plan = PlanSpec( - name="numeric_gt", - steps=( - StepSpec(id="count", tool="echo", params={"message": 15}), - StepSpec(id="alert", tool="echo", params={"message": "threshold exceeded"}, - depends_on=("count",), - when="${count.output} > 10"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["alert"].status == StepStatus.SUCCESS - - async def test_when_numeric_gt_skips_step(self, ts_registry): - plan = PlanSpec( - name="numeric_gt_skip", - steps=( - StepSpec(id="count", tool="echo", params={"message": 3}), - StepSpec(id="alert", tool="echo", params={"message": "threshold exceeded"}, - depends_on=("count",), - when="${count.output} > 10"), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["alert"].status == StepStatus.SKIPPED - - async def test_when_numeric_lte_from_input(self, ts_registry): - plan = PlanSpec( - name="numeric_lte", - steps=( - StepSpec(id="a", tool="echo", params={"message": "go"}, - when="${inputs.retries} <= 3"), - ), - inputs={"retries": 2}, - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.steps["a"].status == StepStatus.SUCCESS - - -# --------------------------------------------------------------------------- -# Parallel execution — independent steps run concurrently -# --------------------------------------------------------------------------- - -class TestParallelExecution: - async def test_diamond_parallel_speedup(self, ts_registry, parallel_barrier): - """Both siblings enter before either completes; D follows both.""" - parallel_barrier({"b", "c"}) - plan = PlanSpec( - name="diamond", - steps=( - StepSpec(id="a", tool="ts", params={"participant": "a"}), - StepSpec(id="b", tool="ts", params={"participant": "b"}, depends_on=("a",)), - StepSpec(id="c", tool="ts", params={"participant": "c"}, depends_on=("a",)), - StepSpec(id="d", tool="ts", params={"participant": "d"}, depends_on=("b", "c")), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert set(r.steps) == {"a", "b", "c", "d"} - - # B and C should have overlapping time windows - b_start = r.steps["b"].output["start"] - b_end = r.steps["b"].output["end"] - c_start = r.steps["c"].output["start"] - c_end = r.steps["c"].output["end"] - assert b_start < c_end and c_start < b_end, "B and C should overlap in time" - - async def test_wide_fan_out_parallel(self, ts_registry, parallel_barrier): - """Many independent steps all run concurrently from a single root.""" - n = 5 - participants = {f"fan_{i}" for i in range(n)} - entered, completed = parallel_barrier(participants) - steps = [StepSpec(id="root", tool="ts", params={"participant": "root"})] - for i in range(n): - steps.append( - StepSpec(id=f"fan_{i}", tool="ts", params={"participant": f"fan_{i}"}, - depends_on=("root",)) - ) - plan = PlanSpec(name="fan_out", steps=tuple(steps)) - p = Planner(ts_registry) - - r = await p.execute(plan) - - assert r.success - assert entered == completed == participants - - async def test_independent_roots_parallel(self, ts_registry, parallel_barrier): - """Steps with no dependencies at all run concurrently.""" - entered, completed = parallel_barrier({"a", "b", "c"}) - plan = PlanSpec( - name="roots", - steps=( - StepSpec(id="a", tool="ts", params={"participant": "a"}), - StepSpec(id="b", tool="ts", params={"participant": "b"}), - StepSpec(id="c", tool="ts", params={"participant": "c"}), - ), - ) - p = Planner(ts_registry) - - r = await p.execute(plan) - - assert r.success - assert entered == completed == {"a", "b", "c"} - - async def test_parallel_failure_still_cascades(self, ts_registry): - """When one parallel branch fails, its dependents are still skipped.""" - plan = PlanSpec( - name="parallel_fail", - steps=( - StepSpec(id="root", tool="echo", params={"message": "ok"}), - StepSpec(id="ok_branch", tool="ts", params={"sleep": 0.01}, depends_on=("root",)), - StepSpec( - id="bad_branch", - tool="fail", - params={"message": "boom"}, - depends_on=("root",), - ), - StepSpec( - id="after_ok", - tool="echo", - params={"message": "yes"}, - depends_on=("ok_branch",), - ), - StepSpec( - id="after_bad", - tool="echo", - params={"message": "no"}, - depends_on=("bad_branch",), - ), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert not r.success - assert r.steps["ok_branch"].status == StepStatus.SUCCESS - assert r.steps["bad_branch"].status == StepStatus.FAILED - assert r.steps["after_ok"].status == StepStatus.SUCCESS - assert r.steps["after_bad"].status == StepStatus.SKIPPED - - async def test_serial_chain_still_works(self, ts_registry): - """A purely serial chain executes correctly with the async planner.""" - plan = PlanSpec( - name="serial", - steps=( - StepSpec(id="a", tool="echo", params={"message": "1"}), - StepSpec( - id="b", - tool="echo", - params={"message": "got ${a.output}"}, - depends_on=("a",), - ), - StepSpec( - id="c", - tool="echo", - params={"message": "got ${b.output}"}, - depends_on=("b",), - ), - ), - ) - p = Planner(ts_registry) - r = await p.execute(plan) - assert r.success - assert r.steps["c"].output == "got got 1" diff --git a/tests/test_registry.py b/tests/test_registry.py deleted file mode 100644 index fd705e5c6..000000000 --- a/tests/test_registry.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for the ToolRegistry.""" - -from __future__ import annotations - -import pytest - -from src.odin.registry import ToolRegistry -from src.odin.tools.base import BaseTool - - -class DummyTool(BaseTool): - async def execute(self, params, ctx): - return "dummy" - - -def test_register_and_get(): - reg = ToolRegistry() - reg.register("dummy", DummyTool) - assert reg.get("dummy") is DummyTool - - -def test_has(): - reg = ToolRegistry() - reg.register("dummy", DummyTool) - assert reg.has("dummy") - assert not reg.has("nope") - - -def test_get_unknown_raises(): - reg = ToolRegistry() - with pytest.raises(KeyError, match="Unknown tool"): - reg.get("nope") - - -def test_list_tools(): - reg = ToolRegistry() - reg.register("b", DummyTool) - reg.register("a", DummyTool) - assert reg.list_tools() == ["a", "b"] - - -def test_with_defaults(): - reg = ToolRegistry.with_defaults() - assert reg.has("shell") - assert reg.has("read_file") - assert not reg.has("apply_patch") # dormant planner registry does not expose live tools - assert reg.has("list_dir") - assert reg.has("http_request") diff --git a/tests/test_reporter.py b/tests/test_reporter.py deleted file mode 100644 index fa9f5b33d..000000000 --- a/tests/test_reporter.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Tests for the Reporter formatting utilities.""" - -from __future__ import annotations - -import json - -from src.odin.reporter import Reporter -from src.odin.types import PlanResult, StepResult, StepStatus - - -def _make_result() -> PlanResult: - r = PlanResult(name="test-plan", success=False) - r.steps["a"] = StepResult( - status=StepStatus.SUCCESS, output="ok", duration=0.2, attempts=1, - ) - r.steps["b"] = StepResult( - status=StepStatus.FAILED, error="boom", duration=0.3, attempts=2, - ) - return r - - -def test_to_dict(): - d = Reporter.to_dict(_make_result()) - assert d["name"] == "test-plan" - assert d["success"] is False - assert "a" in d["steps"] - assert d["steps"]["a"]["status"] == "success" - assert d["steps"]["b"]["status"] == "failed" - - -def test_to_json(): - j = Reporter.to_json(_make_result()) - parsed = json.loads(j) - assert parsed["name"] == "test-plan" - - -def test_to_summary(): - s = Reporter.to_summary(_make_result()) - assert "test-plan" in s - assert "FAILED" in s - assert "[OK]" in s - assert "[FAIL]" in s - assert "boom" in s diff --git a/tests/test_slack_notifier.py b/tests/test_slack_notifier.py deleted file mode 100644 index 27a48ecf8..000000000 --- a/tests/test_slack_notifier.py +++ /dev/null @@ -1,1143 +0,0 @@ -"""Tests for Slack webhook notifier (Round 17). - -Tests the SlackNotifier module: payload building, message formatting, -rate limiting, secret scrubbing, health server integration, monitoring -watcher integration, config schema, and REST API endpoints. -""" - -from __future__ import annotations - -import asyncio -import time -from unittest.mock import AsyncMock, MagicMock - -from aiohttp.test_utils import TestClient, TestServer - -from src.config.schema import Config, SlackConfig -from src.notifications.slack import ( - DEFAULT_COLOR, - DEFAULT_RATE_LIMIT, - MAX_TEXT_LEN, - SEVERITY_COLORS, - SlackNotifier, - _discord_to_slack_markdown, - _truncate, - build_formatted_payload, - build_plain_payload, -) - -# --------------------------------------------------------------------------- -# SlackConfig schema -# --------------------------------------------------------------------------- - - -class TestSlackConfigDefaults: - def test_defaults(self): - cfg = SlackConfig() - assert cfg.enabled is False - assert cfg.webhook_urls == {} - assert cfg.default_webhook_url == "" - assert cfg.scrub_secrets is True - assert cfg.rate_limit_seconds == 1 - assert cfg.forward_alerts is True - assert cfg.forward_webhooks is False - - def test_custom_values(self): - cfg = SlackConfig( - enabled=True, - webhook_urls={"alerts": "https://hooks.slack.com/test"}, - default_webhook_url="https://hooks.slack.com/default", - scrub_secrets=False, - rate_limit_seconds=5, - forward_alerts=False, - forward_webhooks=True, - ) - assert cfg.enabled is True - assert cfg.webhook_urls == {"alerts": "https://hooks.slack.com/test"} - assert cfg.default_webhook_url == "https://hooks.slack.com/default" - assert cfg.scrub_secrets is False - assert cfg.rate_limit_seconds == 5 - assert cfg.forward_alerts is False - assert cfg.forward_webhooks is True - - def test_config_includes_slack(self): - cfg = Config(discord={"token": "test"}) - assert hasattr(cfg, "slack") - assert isinstance(cfg.slack, SlackConfig) - assert cfg.slack.enabled is False - - def test_config_with_slack(self): - cfg = Config( - discord={"token": "test"}, - slack={"enabled": True, "default_webhook_url": "https://hooks.slack.com/x"}, - ) - assert cfg.slack.enabled is True - assert cfg.slack.default_webhook_url == "https://hooks.slack.com/x" - - def test_empty_webhook_urls(self): - cfg = SlackConfig(webhook_urls={}) - assert cfg.webhook_urls == {} - - def test_multiple_webhook_urls(self): - urls = { - "alerts": "https://hooks.slack.com/1", - "monitoring": "https://hooks.slack.com/2", - "builds": "https://hooks.slack.com/3", - } - cfg = SlackConfig(webhook_urls=urls) - assert len(cfg.webhook_urls) == 3 - - -# --------------------------------------------------------------------------- -# Text helpers -# --------------------------------------------------------------------------- - - -class TestTruncate: - def test_short_text(self): - assert _truncate("hello") == "hello" - - def test_exact_limit(self): - text = "a" * MAX_TEXT_LEN - assert _truncate(text) == text - - def test_over_limit(self): - text = "a" * (MAX_TEXT_LEN + 100) - result = _truncate(text) - assert len(result) <= MAX_TEXT_LEN - assert result.endswith("…(truncated)") - - def test_custom_limit(self): - result = _truncate("a" * 200, limit=100) - assert len(result) <= 100 - assert result.endswith("…(truncated)") - - def test_empty(self): - assert _truncate("") == "" - - -class TestDiscordToSlackMarkdown: - def test_bold(self): - assert _discord_to_slack_markdown("**bold**") == "*bold*" - - def test_underline_to_italic(self): - assert _discord_to_slack_markdown("__text__") == "_text_" - - def test_mixed(self): - result = _discord_to_slack_markdown("**Disk Alert** on `host`") - assert result == "*Disk Alert* on `host`" - - def test_no_change(self): - text = "plain text" - assert _discord_to_slack_markdown(text) == text - - def test_code_preserved(self): - text = "`code block`" - assert _discord_to_slack_markdown(text) == "`code block`" - - def test_multiple_bold(self): - result = _discord_to_slack_markdown("**a** and **b**") - assert result == "*a* and *b*" - - -# --------------------------------------------------------------------------- -# Payload building -# --------------------------------------------------------------------------- - - -class TestBuildPlainPayload: - def test_basic(self): - payload = build_plain_payload("hello") - assert payload == {"text": "hello"} - - def test_truncated(self): - payload = build_plain_payload("a" * (MAX_TEXT_LEN + 100)) - assert len(payload["text"]) <= MAX_TEXT_LEN - - def test_empty(self): - payload = build_plain_payload("") - assert payload == {"text": ""} - - -class TestBuildFormattedPayload: - def test_basic(self): - payload = build_formatted_payload("Title", "Message") - assert payload["text"] == "Title" - assert len(payload["attachments"]) == 1 - att = payload["attachments"][0] - assert att["title"] == "Title" - assert att["text"] == "Message" - assert att["color"] == SEVERITY_COLORS["info"] - - def test_severity_colors(self): - for sev, color in SEVERITY_COLORS.items(): - payload = build_formatted_payload("T", "M", severity=sev) - assert payload["attachments"][0]["color"] == color - - def test_unknown_severity(self): - payload = build_formatted_payload("T", "M", severity="custom") - assert payload["attachments"][0]["color"] == DEFAULT_COLOR - - def test_fields(self): - payload = build_formatted_payload("T", "M", source="test", severity="error") - fields = payload["attachments"][0]["fields"] - sources = [f for f in fields if f["title"] == "Source"] - assert sources[0]["value"] == "test" - severities = [f for f in fields if f["title"] == "Severity"] - assert severities[0]["value"] == "ERROR" - - def test_mrkdwn_in(self): - payload = build_formatted_payload("T", "M") - assert "text" in payload["attachments"][0]["mrkdwn_in"] - - def test_truncated_title(self): - long_title = "a" * 200 - payload = build_formatted_payload(long_title, "M") - assert len(payload["attachments"][0]["title"]) <= 150 - - def test_no_source(self): - payload = build_formatted_payload("T", "M", source="") - fields = payload["attachments"][0].get("fields", []) - sources = [f for f in fields if f["title"] == "Source"] - assert len(sources) == 0 - - def test_no_severity_field(self): - payload = build_formatted_payload("T", "M", severity="") - fields = payload["attachments"][0].get("fields", []) - severities = [f for f in fields if f["title"] == "Severity"] - assert len(severities) == 0 - - -# --------------------------------------------------------------------------- -# SlackNotifier init -# --------------------------------------------------------------------------- - - -class TestSlackNotifierInit: - def test_defaults(self): - n = SlackNotifier() - assert n.send_count == 0 - assert n.error_count == 0 - assert n.configured_channels == [] - - def test_with_urls(self): - n = SlackNotifier(webhook_urls={"a": "url_a", "b": "url_b"}) - assert sorted(n.configured_channels) == ["a", "b"] - - def test_default_url(self): - n = SlackNotifier(default_webhook_url="https://hooks.slack.com/x") - url = n.resolve_url() - assert url == "https://hooks.slack.com/x" - - def test_scrub_default(self): - n = SlackNotifier() - assert n._scrub is True - - def test_scrub_off(self): - n = SlackNotifier(scrub_secrets=False) - assert n._scrub is False - - def test_rate_limit(self): - n = SlackNotifier(rate_limit_seconds=5) - assert n._rate_limit == 5 - - def test_negative_rate_limit(self): - n = SlackNotifier(rate_limit_seconds=-1) - assert n._rate_limit == 0 - - -# --------------------------------------------------------------------------- -# URL resolution -# --------------------------------------------------------------------------- - - -class TestResolveUrl: - def test_named_channel(self): - n = SlackNotifier(webhook_urls={"alerts": "https://hooks/alerts"}) - assert n.resolve_url("alerts") == "https://hooks/alerts" - - def test_unknown_channel_fallback(self): - n = SlackNotifier( - webhook_urls={"alerts": "https://hooks/alerts"}, - default_webhook_url="https://hooks/default", - ) - assert n.resolve_url("unknown") == "https://hooks/default" - - def test_no_url(self): - n = SlackNotifier() - assert n.resolve_url("any") is None - - def test_none_channel(self): - n = SlackNotifier(default_webhook_url="https://hooks/default") - assert n.resolve_url(None) == "https://hooks/default" - - def test_https_url_passthrough(self): - n = SlackNotifier() - assert n.resolve_url("https://hooks.slack.com/custom") == "https://hooks.slack.com/custom" - - def test_named_overrides_default(self): - n = SlackNotifier( - webhook_urls={"alerts": "https://specific"}, - default_webhook_url="https://default", - ) - assert n.resolve_url("alerts") == "https://specific" - - -# --------------------------------------------------------------------------- -# Rate limiting -# --------------------------------------------------------------------------- - - -class TestRateLimiting: - def test_first_send_allowed(self): - n = SlackNotifier(rate_limit_seconds=10) - assert n._check_rate_limit("url") is True - - def test_second_send_blocked(self): - n = SlackNotifier(rate_limit_seconds=10) - n._mark_sent("url") - assert n._check_rate_limit("url") is False - - def test_send_allowed_after_cooldown(self): - n = SlackNotifier(rate_limit_seconds=1) - n._last_sent["url"] = time.monotonic() - 2 - assert n._check_rate_limit("url") is True - - def test_zero_rate_limit(self): - n = SlackNotifier(rate_limit_seconds=0) - n._mark_sent("url") - assert n._check_rate_limit("url") is True - - def test_different_urls(self): - n = SlackNotifier(rate_limit_seconds=10) - n._mark_sent("url_a") - assert n._check_rate_limit("url_b") is True - - -# --------------------------------------------------------------------------- -# Secret scrubbing -# --------------------------------------------------------------------------- - - -class TestSecretScrubbing: - async def test_scrubs_text(self): - n = SlackNotifier( - default_webhook_url="https://hooks/test", - scrub_secrets=True, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - mock_session = AsyncMock() - mock_session.post = MagicMock(return_value=AsyncMock( - __aenter__=AsyncMock(return_value=mock_resp), - __aexit__=AsyncMock(return_value=False), - )) - mock_session.closed = False - n._session = mock_session - - await n.send("password=supersecret123") - call_args = mock_session.post.call_args - payload = call_args.kwargs.get("json") or call_args[1].get("json") - assert "supersecret" not in payload["text"] - assert "[REDACTED]" in payload["text"] - - async def test_no_scrub_when_disabled(self): - n = SlackNotifier( - default_webhook_url="https://hooks/test", - scrub_secrets=False, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - mock_session = AsyncMock() - mock_session.post = MagicMock(return_value=AsyncMock( - __aenter__=AsyncMock(return_value=mock_resp), - __aexit__=AsyncMock(return_value=False), - )) - mock_session.closed = False - n._session = mock_session - - await n.send("password=supersecret123") - call_args = mock_session.post.call_args - payload = call_args.kwargs.get("json") or call_args[1].get("json") - assert "password=supersecret123" in payload["text"] - - -# --------------------------------------------------------------------------- -# Send (mocked HTTP) -# --------------------------------------------------------------------------- - - -class TestSend: - def _make_notifier(self, **kwargs): - defaults = { - "default_webhook_url": "https://hooks.slack.com/test", - "rate_limit_seconds": 0, - "scrub_secrets": False, - } - defaults.update(kwargs) - return SlackNotifier(**defaults) - - def _mock_session(self, status=200, body="ok"): - mock_resp = AsyncMock() - mock_resp.status = status - mock_resp.text = AsyncMock(return_value=body) - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - return session - - async def test_success(self): - n = self._make_notifier() - n._session = self._mock_session() - result = await n.send("hello") - assert result is True - assert n.send_count == 1 - assert n.error_count == 0 - - async def test_no_url(self): - n = SlackNotifier(rate_limit_seconds=0) - result = await n.send("hello") - assert result is False - - async def test_error_status(self): - n = self._make_notifier() - n._session = self._mock_session(status=400, body="invalid_payload") - result = await n.send("hello") - assert result is False - assert n.error_count == 1 - - async def test_timeout(self): - n = self._make_notifier() - session = AsyncMock() - session.closed = False - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(side_effect=asyncio.TimeoutError) - ctx.__aexit__ = AsyncMock(return_value=False) - session.post = MagicMock(return_value=ctx) - n._session = session - result = await n.send("hello") - assert result is False - assert n.error_count == 1 - - async def test_connection_error(self): - n = self._make_notifier() - session = AsyncMock() - session.closed = False - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(side_effect=ConnectionError("refused")) - ctx.__aexit__ = AsyncMock(return_value=False) - session.post = MagicMock(return_value=ctx) - n._session = session - result = await n.send("hello") - assert result is False - assert n.error_count == 1 - - async def test_rate_limited(self): - n = self._make_notifier(rate_limit_seconds=60) - n._session = self._mock_session() - await n.send("first") - result = await n.send("second") - assert result is False - assert n.send_count == 1 - - async def test_named_channel(self): - n = SlackNotifier( - webhook_urls={"alerts": "https://hooks/alerts"}, - rate_limit_seconds=0, - scrub_secrets=False, - ) - n._session = self._mock_session() - result = await n.send("hello", channel="alerts") - assert result is True - n._session.post.assert_called_once() - call_url = n._session.post.call_args[0][0] - assert call_url == "https://hooks/alerts" - - async def test_custom_payload(self): - n = self._make_notifier() - n._session = self._mock_session() - custom = {"text": "custom", "blocks": []} - result = await n.send("ignored", payload=custom) - assert result is True - call_args = n._session.post.call_args - sent_payload = call_args.kwargs.get("json") or call_args[1].get("json") - assert sent_payload == custom - - async def test_send_count_increments(self): - n = self._make_notifier() - n._session = self._mock_session() - await n.send("a") - await n.send("b") - assert n.send_count == 2 - - async def test_discord_markdown_converted(self): - n = self._make_notifier() - n._session = self._mock_session() - await n.send("**bold** text") - call_args = n._session.post.call_args - payload = call_args.kwargs.get("json") or call_args[1].get("json") - assert "*bold*" in payload["text"] - assert "**" not in payload["text"] - - -# --------------------------------------------------------------------------- -# Send formatted -# --------------------------------------------------------------------------- - - -class TestSendFormatted: - def _make_notifier(self): - n = SlackNotifier( - default_webhook_url="https://hooks/test", - rate_limit_seconds=0, - scrub_secrets=False, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - n._session = session - return n - - async def test_formatted_send(self): - n = self._make_notifier() - result = await n.send_formatted("Alert", "Disk full", severity="error") - assert result is True - - async def test_formatted_payload_structure(self): - n = self._make_notifier() - await n.send_formatted("Alert", "Message", severity="warning", source="test") - call_args = n._session.post.call_args - payload = call_args.kwargs.get("json") or call_args[1].get("json") - assert "attachments" in payload - att = payload["attachments"][0] - assert att["color"] == SEVERITY_COLORS["warning"] - - async def test_formatted_scrubs_secrets(self): - n = SlackNotifier( - default_webhook_url="https://hooks/test", - rate_limit_seconds=0, - scrub_secrets=True, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - n._session = session - - await n.send_formatted("Alert", "password=secret123", severity="error") - call_args = n._session.post.call_args - payload = call_args.kwargs.get("json") or call_args[1].get("json") - assert "secret123" not in str(payload) - - -# --------------------------------------------------------------------------- -# Broadcast -# --------------------------------------------------------------------------- - - -class TestBroadcast: - async def test_broadcast_all_channels(self): - n = SlackNotifier( - webhook_urls={"a": "https://a", "b": "https://b"}, - rate_limit_seconds=0, - scrub_secrets=False, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - n._session = session - - results = await n.broadcast("hello") - assert len(results) == 2 - assert all(v is True for v in results.values()) - - async def test_broadcast_specific_channels(self): - n = SlackNotifier( - webhook_urls={"a": "https://a", "b": "https://b", "c": "https://c"}, - rate_limit_seconds=0, - scrub_secrets=False, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - n._session = session - - results = await n.broadcast("hello", channels=["a", "c"]) - assert len(results) == 2 - assert "a" in results - assert "c" in results - assert "b" not in results - - async def test_broadcast_no_channels_default(self): - n = SlackNotifier( - default_webhook_url="https://default", - rate_limit_seconds=0, - scrub_secrets=False, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - n._session = session - - results = await n.broadcast("hello") - assert results == {"default": True} - - async def test_broadcast_empty(self): - n = SlackNotifier(rate_limit_seconds=0) - results = await n.broadcast("hello") - assert results == {} - - -# --------------------------------------------------------------------------- -# Status -# --------------------------------------------------------------------------- - - -class TestGetStatus: - def test_status(self): - n = SlackNotifier( - webhook_urls={"alerts": "https://hooks/a"}, - default_webhook_url="https://hooks/d", - rate_limit_seconds=3, - ) - status = n.get_status() - assert status["configured_channels"] == ["alerts"] - assert status["has_default_url"] is True - assert status["scrub_secrets"] is True - assert status["rate_limit_seconds"] == 3 - assert status["send_count"] == 0 - assert status["error_count"] == 0 - - def test_status_no_urls(self): - n = SlackNotifier() - status = n.get_status() - assert status["configured_channels"] == [] - assert status["has_default_url"] is False - - -# --------------------------------------------------------------------------- -# Close -# --------------------------------------------------------------------------- - - -class TestClose: - async def test_close_session(self): - n = SlackNotifier() - mock_session = AsyncMock() - mock_session.closed = False - n._session = mock_session - await n.close() - mock_session.close.assert_called_once() - assert n._session is None - - async def test_close_no_session(self): - n = SlackNotifier() - await n.close() # should not raise - - async def test_close_already_closed(self): - n = SlackNotifier() - mock_session = AsyncMock() - mock_session.closed = True - n._session = mock_session - await n.close() - mock_session.close.assert_not_called() - - -# --------------------------------------------------------------------------- -# Health server integration -# --------------------------------------------------------------------------- - - -class TestHealthServerSlackIntegration: - def test_slack_disabled_by_default(self): - from src.config.schema import WebhookConfig - from src.health.server import HealthServer - server = HealthServer(port=0, webhook_config=WebhookConfig(enabled=False)) - assert server.slack_notifier is None - - def test_slack_enabled(self): - from src.config.schema import WebhookConfig - from src.health.server import HealthServer - slack_cfg = SlackConfig( - enabled=True, - default_webhook_url="https://hooks.slack.com/test", - ) - server = HealthServer( - port=0, - webhook_config=WebhookConfig(enabled=False), - slack_config=slack_cfg, - ) - assert server.slack_notifier is not None - assert server.slack_notifier.resolve_url() == "https://hooks.slack.com/test" - - def test_slack_notifier_property(self): - from src.config.schema import WebhookConfig - from src.health.server import HealthServer - slack_cfg = SlackConfig( - enabled=True, - webhook_urls={"alerts": "https://hooks/a"}, - ) - server = HealthServer( - port=0, - webhook_config=WebhookConfig(enabled=False), - slack_config=slack_cfg, - ) - notifier = server.slack_notifier - assert notifier is not None - assert "alerts" in notifier.configured_channels - - async def test_send_forwards_to_slack_when_enabled(self): - from src.config.schema import WebhookConfig - from src.health.server import HealthServer - slack_cfg = SlackConfig( - enabled=True, - forward_webhooks=True, - default_webhook_url="https://hooks/test", - ) - server = HealthServer( - port=0, - webhook_config=WebhookConfig( - enabled=True, - channel_id="123", - ), - slack_config=slack_cfg, - ) - discord_send = AsyncMock() - server.set_send_message(discord_send) - - mock_notifier = AsyncMock(spec=SlackNotifier) - server._slack_notifier = mock_notifier - - resp = await server._send("gitea", "test message") - assert resp.status == 200 - discord_send.assert_called_once_with("123", "test message") - mock_notifier.send_formatted.assert_called_once() - - async def test_send_no_slack_forward_when_disabled(self): - from src.config.schema import WebhookConfig - from src.health.server import HealthServer - slack_cfg = SlackConfig( - enabled=True, - forward_webhooks=False, - default_webhook_url="https://hooks/test", - ) - server = HealthServer( - port=0, - webhook_config=WebhookConfig( - enabled=True, - channel_id="123", - ), - slack_config=slack_cfg, - ) - discord_send = AsyncMock() - server.set_send_message(discord_send) - - mock_notifier = AsyncMock(spec=SlackNotifier) - server._slack_notifier = mock_notifier - - resp = await server._send("gitea", "test message") - assert resp.status == 200 - discord_send.assert_called_once() - mock_notifier.send_formatted.assert_not_called() - - async def test_slack_error_does_not_block_discord(self): - from src.config.schema import WebhookConfig - from src.health.server import HealthServer - slack_cfg = SlackConfig( - enabled=True, - forward_webhooks=True, - default_webhook_url="https://hooks/test", - ) - server = HealthServer( - port=0, - webhook_config=WebhookConfig( - enabled=True, - channel_id="123", - ), - slack_config=slack_cfg, - ) - discord_send = AsyncMock() - server.set_send_message(discord_send) - - mock_notifier = AsyncMock(spec=SlackNotifier) - mock_notifier.send_formatted.side_effect = Exception("slack down") - server._slack_notifier = mock_notifier - - resp = await server._send("gitea", "test message") - assert resp.status == 200 - discord_send.assert_called_once() - - async def test_stop_closes_slack(self): - from src.config.schema import WebhookConfig - from src.health.server import HealthServer - slack_cfg = SlackConfig( - enabled=True, - default_webhook_url="https://hooks/test", - ) - server = HealthServer( - port=0, - webhook_config=WebhookConfig(enabled=False), - slack_config=slack_cfg, - ) - mock_notifier = AsyncMock(spec=SlackNotifier) - server._slack_notifier = mock_notifier - server._runner = None - await server.stop() - mock_notifier.close.assert_called_once() - - -class TestSlackAPIEndpoints: - def _make_bot(self, slack_notifier=None): - bot = MagicMock() - hs = MagicMock() - hs.slack_notifier = slack_notifier - bot.health_server = hs - bot.config = Config( - discord={"token": "test"}, - slack=SlackConfig(enabled=slack_notifier is not None), - ) - return bot - - def _make_app(self, bot): - from aiohttp import web - - from src.web.api import create_api_routes - app = web.Application() - routes = create_api_routes(bot) - app.router.add_routes(routes) - return app - - async def test_status_disabled(self): - bot = self._make_bot() - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/slack/status") - assert resp.status == 200 - data = await resp.json() - assert data["enabled"] is False - - async def test_status_enabled(self): - notifier = SlackNotifier( - webhook_urls={"alerts": "https://hooks/a"}, - default_webhook_url="https://hooks/d", - ) - bot = self._make_bot(slack_notifier=notifier) - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.get("/api/slack/status") - assert resp.status == 200 - data = await resp.json() - assert data["enabled"] is True - assert "alerts" in data["configured_channels"] - - async def test_test_disabled(self): - bot = self._make_bot() - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.post("/api/slack/test", json={}) - assert resp.status == 503 - - async def test_test_enabled(self): - notifier = AsyncMock(spec=SlackNotifier) - notifier.send = AsyncMock(return_value=True) - bot = self._make_bot(slack_notifier=notifier) - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.post("/api/slack/test", json={"message": "hi"}) - assert resp.status == 200 - data = await resp.json() - assert data["sent"] is True - - async def test_send_disabled(self): - bot = self._make_bot() - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.post("/api/slack/send", json={"text": "hello"}) - assert resp.status == 503 - - async def test_send_plain(self): - notifier = AsyncMock(spec=SlackNotifier) - notifier.send = AsyncMock(return_value=True) - bot = self._make_bot(slack_notifier=notifier) - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.post("/api/slack/send", json={"text": "hello"}) - assert resp.status == 200 - data = await resp.json() - assert data["sent"] is True - notifier.send.assert_called_once() - - async def test_send_formatted(self): - notifier = AsyncMock(spec=SlackNotifier) - notifier.send_formatted = AsyncMock(return_value=True) - bot = self._make_bot(slack_notifier=notifier) - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.post("/api/slack/send", json={ - "text": "disk full", - "severity": "error", - "title": "Alert", - "source": "monitor", - }) - assert resp.status == 200 - data = await resp.json() - assert data["sent"] is True - notifier.send_formatted.assert_called_once() - - async def test_send_no_text(self): - notifier = AsyncMock(spec=SlackNotifier) - bot = self._make_bot(slack_notifier=notifier) - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.post("/api/slack/send", json={"severity": "info"}) - assert resp.status == 400 - - async def test_send_invalid_json(self): - notifier = AsyncMock(spec=SlackNotifier) - bot = self._make_bot(slack_notifier=notifier) - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.post( - "/api/slack/send", - data=b"not json", - headers={"Content-Type": "application/json"}, - ) - assert resp.status == 400 - - async def test_test_with_channel(self): - notifier = AsyncMock(spec=SlackNotifier) - notifier.send = AsyncMock(return_value=True) - bot = self._make_bot(slack_notifier=notifier) - app = self._make_app(bot) - async with TestClient(TestServer(app)) as client: - resp = await client.post("/api/slack/test", json={"channel": "alerts"}) - assert resp.status == 200 - notifier.send.assert_called_once() - call_kwargs = notifier.send.call_args - assert call_kwargs.kwargs.get("channel") == "alerts" - - -# --------------------------------------------------------------------------- -# Edge cases -# --------------------------------------------------------------------------- - - -class TestEdgeCases: - def test_severity_colors_complete(self): - expected = {"info", "warning", "error", "success"} - assert set(SEVERITY_COLORS.keys()) == expected - - def test_default_color_not_in_severity(self): - assert DEFAULT_COLOR not in SEVERITY_COLORS.values() - - async def test_send_empty_text(self): - n = SlackNotifier( - default_webhook_url="https://hooks/test", - rate_limit_seconds=0, - scrub_secrets=False, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - n._session = session - result = await n.send("") - assert result is True - - def test_max_text_len_is_reasonable(self): - assert 1000 <= MAX_TEXT_LEN <= 10000 - - def test_default_rate_limit(self): - assert DEFAULT_RATE_LIMIT == 1 - - def test_webhook_url_dict_not_shared(self): - urls = {"a": "url"} - n = SlackNotifier(webhook_urls=urls) - urls["b"] = "url2" - assert "b" not in n._webhook_urls - - async def test_multiple_errors_tracked(self): - n = SlackNotifier( - default_webhook_url="https://hooks/test", - rate_limit_seconds=0, - ) - session = AsyncMock() - session.closed = False - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(side_effect=ConnectionError("refused")) - ctx.__aexit__ = AsyncMock(return_value=False) - session.post = MagicMock(return_value=ctx) - n._session = session - await n.send("a") - await n.send("b") - assert n.error_count == 2 - - def test_truncate_marker(self): - result = _truncate("a" * 5000) - assert "…(truncated)" in result - - async def test_get_session_creates_new(self): - n = SlackNotifier() - assert n._session is None - session = await n._get_session() - assert session is not None - await n.close() - - async def test_formatted_converts_discord_markdown(self): - n = SlackNotifier( - default_webhook_url="https://hooks/test", - rate_limit_seconds=0, - scrub_secrets=False, - ) - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.text = AsyncMock(return_value="ok") - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - n._session = session - - await n.send_formatted("**Alert**", "**msg**") - call_args = n._session.post.call_args - payload = call_args.kwargs.get("json") or call_args[1].get("json") - att = payload["attachments"][0] - assert "**" not in att["title"] - assert "**" not in att["text"] - - def test_plain_payload_structure(self): - p = build_plain_payload("test") - assert set(p.keys()) == {"text"} - - def test_formatted_payload_keys(self): - p = build_formatted_payload("T", "M", severity="info", source="src") - assert "text" in p - assert "attachments" in p - att = p["attachments"][0] - assert "color" in att - assert "title" in att - assert "text" in att - assert "fields" in att - assert "mrkdwn_in" in att - - -# --------------------------------------------------------------------------- -# Module imports -# --------------------------------------------------------------------------- - - -class TestRound20RateLimitFix: - """Round 20 REVIEWER: verify rate limit only set on successful send.""" - - def _make_notifier(self, **kwargs): - defaults = { - "default_webhook_url": "https://hooks.slack.com/test", - "rate_limit_seconds": 10, - "scrub_secrets": False, - } - defaults.update(kwargs) - return SlackNotifier(**defaults) - - def _mock_session(self, status=200, body="ok"): - mock_resp = AsyncMock() - mock_resp.status = status - mock_resp.text = AsyncMock(return_value=body) - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session = AsyncMock() - session.post = MagicMock(return_value=ctx) - session.closed = False - return session - - async def test_failed_send_does_not_rate_limit(self): - n = self._make_notifier() - n._session = self._mock_session(status=500, body="error") - result = await n.send("first try") - assert result is False - assert n._check_rate_limit(n.resolve_url(None)) - - async def test_successful_send_sets_rate_limit(self): - n = self._make_notifier() - n._session = self._mock_session(status=200) - result = await n.send("message") - assert result is True - assert not n._check_rate_limit(n.resolve_url(None)) - - async def test_retry_allowed_after_failure(self): - n = self._make_notifier() - fail_session = self._mock_session(status=502, body="bad gateway") - n._session = fail_session - r1 = await n.send("attempt 1") - assert r1 is False - ok_session = self._mock_session(status=200) - n._session = ok_session - r2 = await n.send("attempt 2") - assert r2 is True - assert n.send_count == 1 - - async def test_timeout_does_not_rate_limit(self): - n = self._make_notifier() - session = AsyncMock() - session.closed = False - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(side_effect=asyncio.TimeoutError) - ctx.__aexit__ = AsyncMock(return_value=False) - session.post = MagicMock(return_value=ctx) - n._session = session - await n.send("timeout msg") - assert n._check_rate_limit(n.resolve_url(None)) - - -class TestModuleImports: - def test_notifications_package(self): - from src.notifications import SlackNotifier as SN # noqa: N817 — established import alias - assert SN is SlackNotifier - - def test_slack_notifier_class(self): - assert hasattr(SlackNotifier, "send") - assert hasattr(SlackNotifier, "send_formatted") - assert hasattr(SlackNotifier, "broadcast") - assert hasattr(SlackNotifier, "close") - assert hasattr(SlackNotifier, "get_status") diff --git a/tests/test_slash_commands.py b/tests/test_slash_commands.py index f36a38af2..3c4598dca 100644 --- a/tests/test_slash_commands.py +++ b/tests/test_slash_commands.py @@ -177,8 +177,12 @@ def test_render_quota_current_then_others_never_raw_identity(): def test_render_quota_normalizes_and_escapes_operator_labels(): tracker = CodexQuotaTracker(clock=lambda: NOW) - tracker.record_headers("cur", {"x-codex-primary-used-percent": "1"}) - tracker.record_headers("oth", {"x-codex-primary-used-percent": "2"}) + tracker.record_headers("cur", { + "x-codex-primary-used-percent": "1", "x-codex-primary-window-minutes": "300", + }) + tracker.record_headers("oth", { + "x-codex-primary-used-percent": "2", "x-codex-primary-window-minutes": "300", + }) view = tracker.view(current_key="cur") lines = render_quota( view, diff --git a/tests/test_slash_dm_commands.py b/tests/test_slash_dm_commands.py index e61ac3fca..1f6c3b860 100644 --- a/tests/test_slash_dm_commands.py +++ b/tests/test_slash_dm_commands.py @@ -30,6 +30,7 @@ def interaction(uid=7): user=SimpleNamespace(id=uid, bot=False), response=SimpleNamespace(send_message=AsyncMock(), defer=AsyncMock()), followup=SimpleNamespace(send=AsyncMock()), + delete_original_response=AsyncMock(), ) @@ -80,4 +81,5 @@ def request_stop(channel): dm = interaction() await tree.get_command("stop").callback(dm) dm.response.defer.assert_awaited_once_with(ephemeral=True) - dm.followup.send.assert_awaited_once_with("Stopped safely.", ephemeral=True) + dm.followup.send.assert_awaited_once_with("Stopped safely.", ephemeral=False) + dm.delete_original_response.assert_not_awaited() diff --git a/tests/test_smoke_run_bot.py b/tests/test_smoke_run_bot.py index a87ec13a3..85a24847b 100644 --- a/tests/test_smoke_run_bot.py +++ b/tests/test_smoke_run_bot.py @@ -106,7 +106,6 @@ def test_config_has_all_round_fields(self): assert hasattr(cfg.tools, "tool_timeouts") # Phase 4 assert hasattr(cfg, "mcp") - assert hasattr(cfg, "slack") # Phase 6 assert hasattr(cfg, "audit") assert hasattr(cfg, "permissions") @@ -188,11 +187,6 @@ def test_mcp_client(self): assert MCPServerConnection is not None - def test_slack_notifier(self): - from src.notifications.slack import SlackNotifier - sn = SlackNotifier(default_webhook_url="https://hooks.slack.com/test") - assert sn is not None - def test_grafana_alerts(self): from src.health.grafana_alerts import GrafanaAlertHandler assert GrafanaAlertHandler is not None @@ -482,7 +476,6 @@ class TestImportSweep: "src.health.startup", "src.health.subsystem_guard", "src.health.grafana_alerts", - "src.notifications.slack", "src.notifications.outbound_webhooks", "src.permissions.manager", "src.monitoring.resource_usage", diff --git a/tests/test_stop_command.py b/tests/test_stop_command.py index 3992eab88..2122261ca 100644 --- a/tests/test_stop_command.py +++ b/tests/test_stop_command.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock +import src.discord.slash_commands as slash_commands from src.discord.channel_state import ChannelStateRegistry from src.discord.slash_commands import register_commands @@ -27,6 +28,7 @@ def __init__(self, channel_id=42): self.user = object() self.response = SimpleNamespace(send_message=AsyncMock(), defer=AsyncMock()) self.followup = SimpleNamespace(send=AsyncMock()) + self.delete_original_response = AsyncMock() def _bot(): @@ -62,9 +64,61 @@ async def defer(*_args, **_kwargs): release_defer.set() await asyncio.wait_for(task, timeout=1) interaction.response.defer.assert_awaited_once_with(ephemeral=True) + interaction.delete_original_response.assert_awaited_once_with() + interaction.followup.send.assert_not_awaited() + + +async def test_stop_keeps_public_reply_when_task_already_finished(): + bot = _bot() + bot.channel_state.set_active_request("42", "req") + interaction = _Interaction() + deferred = asyncio.Event() + + async def defer(*_args, **_kwargs): + deferred.set() + + interaction.response.defer.side_effect = defer + + task = asyncio.create_task(bot.tree.commands["stop"](interaction)) + await asyncio.wait_for(deferred.wait(), timeout=1) + bot.channel_state.clear_active_request("42", "req") + await asyncio.wait_for(task, timeout=1) + + interaction.followup.send.assert_awaited_once_with( + "Task had already finished before /stop took effect.", ephemeral=False + ) + interaction.delete_original_response.assert_not_awaited() + + +async def test_stop_denial_is_private_without_deferring(): + bot = _bot() + bot.intake.is_allowed_user = lambda _user: False + interaction = _Interaction() + + await bot.tree.commands["stop"](interaction) + + interaction.response.send_message.assert_awaited_once_with( + "Access denied.", ephemeral=True + ) + interaction.response.defer.assert_not_awaited() + + +async def test_stop_timeout_keeps_public_reply(monkeypatch): + bot = _bot() + bot.channel_state.set_active_request("42", "req") + interaction = _Interaction() + + async def timeout(*_args, **_kwargs): + raise TimeoutError + + monkeypatch.setattr(slash_commands.asyncio, "wait_for", timeout) + await bot.tree.commands["stop"](interaction) + interaction.followup.send.assert_awaited_once_with( - "Task stopped by user.", ephemeral=True + "Stop requested, but the in-flight operation could not be safely interrupted yet.", + ephemeral=False, ) + interaction.delete_original_response.assert_not_awaited() async def test_expire_stop_waiter_removes_request_owned_alias(): @@ -106,6 +160,7 @@ async def test_clear_active_ignores_stale_request_owner(): state.finish_stop("42", "old", "old durably cancelled") assert old_waiter.result() == "old durably cancelled" + assert old_waiter.result().confirmed state.clear_active_request("42", "old") assert state.active_requests["42"] == "new" @@ -129,9 +184,11 @@ async def test_replacing_owner_does_not_revoke_old_stop_or_misdirect_new_stop(): state.finish_stop("42", "old", "old settled") assert old_waiter.result() == "old settled" + assert old_waiter.result().confirmed assert not new_waiter.done() state.finish_stop("42", "new", "new settled") assert new_waiter.result() == "new settled" + assert new_waiter.result().confirmed async def test_request_stop_does_not_set_stale_event_without_owner(): @@ -158,6 +215,7 @@ async def test_finish_stop_ignores_stale_owner_and_completed_waiter(): state.finish_stop("42", "req", "done") state.finish_stop("42", "req", "later") assert waiter.result() == "done" + assert waiter.result().confirmed async def test_cleanup_releases_stale_active_and_orphan_waiters(): @@ -219,4 +277,5 @@ async def test_cleanup_resolves_request_owned_waiter_orphaned_by_a_successor(): assert waiter.done() assert waiter.result() == "No active task in this channel." + assert not waiter.result().confirmed assert reg._stop_waiters == {} diff --git a/tests/test_token_auth_snapshot_security.py b/tests/test_token_auth_snapshot_security.py index c697d269a..d7c8e236c 100644 --- a/tests/test_token_auth_snapshot_security.py +++ b/tests/test_token_auth_snapshot_security.py @@ -1,4 +1,5 @@ """Independent F12 reproductions: store publication and live auth integrity.""" + from __future__ import annotations import json @@ -11,8 +12,12 @@ def entry(**overrides): - return {"user_id": "owner", "token_hash": _hash_token("known-secret"), - "allowed_hosts": ["localhost"], **overrides} + return { + "user_id": "owner", + "token_hash": _hash_token("known-secret"), + "allowed_hosts": ["localhost"], + **overrides, + } def manager_at(tmp_path, data=None): @@ -50,12 +55,143 @@ def test_initial_empty_store_is_still_bootstrap(tmp_path): assert not manager.dynamic_auth_required +@pytest.mark.asyncio +async def test_duplicate_user_id_delete_removes_all_rows_and_keeps_old_token_dead(tmp_path): + old = entry(token_hash=_hash_token("old-secret")) + other = entry(user_id="other", token_hash=_hash_token("other-secret")) + new = entry(token_hash=_hash_token("new-secret")) + manager, path = manager_at(tmp_path, [old, other, new]) + assert manager.resolve("new-secret") is not None + assert manager.resolve("old-secret") is None + assert any( + item["reason"] == "duplicate user_id (shadowed)" for item in manager.invalid_entries() + ) + assert await manager.delete_token("owner") + rows = json.loads(path.read_text()) + assert [row["user_id"] for row in rows] == ["other"] + reloaded = ApiTokenManager(str(path)) + assert reloaded.resolve("old-secret") is None + assert reloaded.resolve("new-secret") is None + + +@pytest.mark.asyncio +async def test_last_valid_token_delete_refuses_when_unusable_entry_remains(tmp_path): + manager, path = manager_at(tmp_path, [entry(), {"user_id": "broken"}]) + before = path.read_text() + with pytest.raises(ValueError, match="remove or repair the 1 unusable entry first"): + await manager.delete_token("owner") + assert path.read_text() == before + assert manager.resolve("known-secret") is not None + + +@pytest.mark.asyncio +async def test_unusable_token_row_can_be_explicitly_removed(tmp_path): + manager, path = manager_at(tmp_path, [entry(), {"user_id": "broken"}]) + assert await manager.remove_unusable_entry(1, "missing user_id or token_hash", "broken") + assert json.loads(path.read_text()) == [entry()] + + +@pytest.mark.asyncio +async def test_unusable_row_removal_rejects_stale_and_wrong_indices(tmp_path, monkeypatch): + manager, path = manager_at(tmp_path, [entry(), {"user_id": "broken"}]) + assert ( + await manager.remove_unusable_entry(-1, "missing user_id or token_hash", "broken") + is False + ) + with pytest.raises(ValueError, match="changed"): + await manager.remove_unusable_entry(9, "missing user_id or token_hash", "broken") + with pytest.raises(ValueError, match="changed"): + await manager.remove_unusable_entry(0, "missing user_id or token_hash", "broken") + before = path.read_text() + previous_signature = manager._stat_signature + calls = 0 + + def changed_after_preflight(): + nonlocal calls + calls += 1 + return (0,) if calls > 1 else previous_signature() + + monkeypatch.setattr(manager, "_stat_signature", changed_after_preflight) + with pytest.raises(RuntimeError, match="changed before credential publication"): + await manager.remove_unusable_entry(1, "missing user_id or token_hash", "broken") + assert path.read_text() == before + monkeypatch.setattr(manager, "_stat_signature", previous_signature) + + +@pytest.mark.asyncio +async def test_unusable_row_removal_checks_write_readback(tmp_path, monkeypatch): + manager, _ = manager_at(tmp_path, [entry(), {"user_id": "broken"}]) + original_refresh = manager._refresh_store + + def stale_refresh(*, force=False): + if not force: + original_refresh(force=False) + + monkeypatch.setattr(manager, "_refresh_store", stale_refresh) + with pytest.raises(RuntimeError, match="changed during credential publication"): + await manager.remove_unusable_entry(1, "missing user_id or token_hash", "broken") + + +@pytest.mark.asyncio +async def test_shadowed_duplicates_can_be_deleted_to_empty_store(tmp_path): + manager, path = manager_at(tmp_path, [ + entry(token_hash=_hash_token("old")), entry(token_hash=_hash_token("new")), + ]) + assert await manager.delete_token("owner") + assert json.loads(path.read_text()) == [] + assert manager.resolve("old") is None and manager.resolve("new") is None + + +@pytest.mark.asyncio +async def test_stale_diagnosis_cannot_remove_shifted_row(tmp_path): + manager, path = manager_at(tmp_path, [ + entry(), {"user_id": "first"}, {"user_id": "second"}, + ]) + original = manager.invalid_entries()[0] + assert await manager.remove_unusable_entry(1, "missing user_id or token_hash", "first") + before = path.read_text() + with pytest.raises(ValueError, match="changed"): + await manager.remove_unusable_entry( + original["index"], original["reason"], original["user_id"] + ) + assert path.read_text() == before + + +def test_token_parser_retains_invalid_identity_diagnostics(tmp_path): + manager, _ = manager_at(tmp_path, [ + entry(), + entry(user_id=["broken"]), + ]) + # Pydantic may accept null username by coercion in older installs; use a + # genuinely invalid user-id type to exercise the conservative fallback. + assert manager.resolve("known-secret") is not None + assert manager.invalid_entries() + + +def test_token_parser_keeps_nonstring_user_id_private(tmp_path): + manager, _ = manager_at(tmp_path, [entry(), entry(user_id=["broken"])]) + invalid = manager.invalid_entries() + assert len(invalid) == 1 + assert invalid[0]["reason"] == "invalid token identity fields" + assert "user_id" not in invalid[0] + + +def test_token_parser_invalid_hashless_row_is_diagnosed(tmp_path): + manager, _ = manager_at(tmp_path, [entry(), {"user_id": "hashless"}]) + assert manager.unusable_entry_count() == 1 + assert manager.invalid_entries()[0]["reason"] == "missing user_id or token_hash" + + @pytest.mark.asyncio @pytest.mark.parametrize( - "guarded,allowed,blocked", [(True, True, False), (True, False, True), (False, True, True)], + "guarded,allowed,blocked", + [(True, True, False), (True, False, True), (False, True, True)], ) async def test_last_removal_requires_guard_to_relax_live_protection( - tmp_path, guarded, allowed, blocked, + tmp_path, + guarded, + allowed, + blocked, ): manager, path = manager_at(tmp_path) if guarded: @@ -73,9 +209,11 @@ async def test_last_removal_requires_guard_to_relax_live_protection( @pytest.mark.asyncio async def test_async_guard_and_nonboolean_guard(tmp_path): manager, _ = manager_at(tmp_path) + async def allow(inventory): assert inventory.dynamic_usable == 0 return True + manager.set_last_credential_guard(allow) assert await manager.delete_token("owner") await manager.create_token("new") @@ -130,10 +268,12 @@ def test_symlinked_store_remains_v398_compatible(tmp_path): def test_open_replacement_race_rejects_mismatched_descriptor(tmp_path, monkeypatch): manager, path = manager_at(tmp_path) real_open = os.open + def replace_before_open(name, flags, *args, **kwargs): path.unlink() path.write_text("[]") return real_open(name, flags, *args, **kwargs) + monkeypatch.setattr(os, "open", replace_before_open) path.write_text(json.dumps([entry(label="changed")])) assert manager.credential_store_status == "unreadable" @@ -143,10 +283,12 @@ def replace_before_open(name, flags, *args, **kwargs): def test_changed_during_read_and_after_read_are_rejected(tmp_path, monkeypatch): manager, path = manager_at(tmp_path) read = manager._read_store + def mutate_after(signature): text = read(signature) path.write_text("[]") return text + monkeypatch.setattr(manager, "_read_store", mutate_after) path.write_text(json.dumps([entry(label="changed")])) assert manager.credential_store_status == "unreadable" @@ -190,8 +332,10 @@ def read(self): def test_stat_failure_is_failclosed(tmp_path, monkeypatch): manager, _ = manager_at(tmp_path) + def denied(): raise PermissionError("denied") + monkeypatch.setattr(manager, "_stat_signature", denied) assert manager.credential_store_auth_required assert manager.resolve("known-secret") is None @@ -223,14 +367,18 @@ def replace_after_fstat(fd): @pytest.mark.asyncio @pytest.mark.parametrize("operation", ["create", "update", "regenerate", "delete"]) async def test_save_does_not_pin_own_candidate_to_external_signature( - tmp_path, monkeypatch, operation, + tmp_path, + monkeypatch, + operation, ): manager, path = manager_at(tmp_path) writer = module.write_private_atomic + def competing_write(destination, contents): result = writer(destination, contents) writer(destination, "[]") return result + monkeypatch.setattr(module, "write_private_atomic", competing_write) if operation == "delete": # Preserve one candidate credential to distinguish our deletion from [] @@ -286,13 +434,16 @@ async def guard(_): @pytest.mark.asyncio async def test_degraded_directory_durability_still_publishes_verified_credentials( - tmp_path, monkeypatch, + tmp_path, + monkeypatch, ): manager, _ = manager_at(tmp_path) writer = module.write_private_atomic + def degraded(destination, contents): writer(destination, contents) return False + monkeypatch.setattr(module, "write_private_atomic", degraded) identity = await manager.create_token("new") assert manager.durability_degraded @@ -339,8 +490,10 @@ def test_detached_identity_requires_exact_manager_issuance(tmp_path): @pytest.mark.asyncio -@pytest.mark.parametrize("change", ["rotate", "update", "delete", "recreate", "reload", - "empty", "corrupt", "missing", "unsafe"]) +@pytest.mark.parametrize( + "change", + ["rotate", "update", "delete", "recreate", "reload", "empty", "corrupt", "missing", "unsafe"], +) async def test_issued_identity_and_old_snapshot_cannot_cross_store_era(tmp_path, change): manager, path = manager_at(tmp_path) snapshot = manager.auth_snapshot() diff --git a/tests/test_token_manager_fail_closed_campaign.py b/tests/test_token_manager_fail_closed_campaign.py index 1106005d2..e4ec3eea5 100644 --- a/tests/test_token_manager_fail_closed_campaign.py +++ b/tests/test_token_manager_fail_closed_campaign.py @@ -73,6 +73,17 @@ def test_invalid_entry_does_not_revoke_valid_sibling(tmp_path: Path, invalid: ob assert manager.resolve("known-secret").user_id == "owner" +def test_invalid_identity_fields_are_isolated_from_valid_sibling(tmp_path: Path) -> None: + malformed_identity = {**_entry("broken"), "user_id": ["not-a-string"]} + manager, _path = _manager(tmp_path, [_entry(), malformed_identity]) + + assert manager.credential_store_status == "valid" + assert manager.resolve("known-secret").user_id == "owner" + assert manager.invalid_entries() == [ + {"index": 1, "reason": "invalid token identity fields"} + ] + + def test_runtime_corruption_invalidates_a_previously_valid_cache(tmp_path: Path) -> None: manager, path = _manager(tmp_path, [_entry()]) assert manager.resolve("known-secret") is not None diff --git a/tests/test_tool_lifecycle_correlation.py b/tests/test_tool_lifecycle_correlation.py index 2c8a27733..9d2aea177 100644 --- a/tests/test_tool_lifecycle_correlation.py +++ b/tests/test_tool_lifecycle_correlation.py @@ -391,3 +391,27 @@ async def test_rejected_post_action_image_keeps_single_failed_receipt(tmp_path, assert records[0]["type"] == "tool_end" assert records[0]["error"] == "computer_observation_rejected" assert await runner._audit.count_by_tool() == {"computer_act": 1} + + +@pytest.mark.parametrize("delivery_ok", [False, True]) +async def test_post_action_image_preserves_private_audit_metadata(tmp_path, delivery_ok): + runner, st, _ = harness(tmp_path, native=True) + receipt = {"status": "not_satisfied", "verification": { + "reason": "target_changed_observe_again"}, "execution": {"released": True}} + image = {"__computer_frame__": {}, "__image_block__": {"type": "image"}, + "__prompt__": "Inspect the changed target", "__computer_action_receipt__": receipt, + "__computer_audit_metadata__": {"computer_reason_code": "target_changed_observe_again", + "computer_input_outcome": "released_verified"}} + runner._native_tools.dispatch.return_value = ( + image, SimpleNamespace(rebuild_system_prompt=False)) + runner._computer_service = lambda: SimpleNamespace( + validate_delivery=AsyncMock(return_value=None if delivery_ok else None, + side_effect=None if delivery_ok else ValueError("expired")), + reserves_tool=lambda _: False) + await runner._run_one_tool(st, block(tool="computer_act")) + rows = await runner._audit.search() + terminal = next(row for row in rows if row.get("type") == "tool_end") + assert terminal["audit_metadata"] == image["__computer_audit_metadata__"] + assert terminal["error"] == ("computer_not_satisfied" if delivery_ok + else "computer_observation_rejected") + assert "__computer_audit_metadata__" not in terminal["result_summary"] diff --git a/tests/test_tools/__init__.py b/tests/test_tools/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/test_tools/test_file_ops.py b/tests/test_tools/test_file_ops.py deleted file mode 100644 index 2fadf5785..000000000 --- a/tests/test_tools/test_file_ops.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Tests for file operation tools.""" - -import pytest - -from src.odin.context import ExecutionContext -from src.odin.tools.file_ops import ListDirTool, ReadFileTool - - -@pytest.mark.asyncio -async def test_read_file(tmp_path): - f = tmp_path / "test.txt" - f.write_text("hello odin") - tool = ReadFileTool() - content = await tool.execute({"path": str(f)}, ExecutionContext()) - assert content == "hello odin" - - -@pytest.mark.asyncio -async def test_read_nonexistent_raises(tmp_path): - tool = ReadFileTool() - with pytest.raises(FileNotFoundError): - await tool.execute({"path": str(tmp_path / "nope.txt")}, ExecutionContext()) - - -@pytest.mark.asyncio -async def test_list_dir(tmp_path): - (tmp_path / "a.txt").write_text("a") - (tmp_path / "b.py").write_text("b") - tool = ListDirTool() - result = await tool.execute({"path": str(tmp_path)}, ExecutionContext()) - assert len(result) == 2 - - -@pytest.mark.asyncio -async def test_list_dir_glob(tmp_path): - (tmp_path / "a.txt").write_text("a") - (tmp_path / "b.py").write_text("b") - tool = ListDirTool() - result = await tool.execute( - {"path": str(tmp_path), "pattern": "*.py"}, ExecutionContext() - ) - assert len(result) == 1 - assert "b.py" in result[0] - - -@pytest.mark.asyncio -async def test_list_dir_recursive(tmp_path): - sub = tmp_path / "sub" - sub.mkdir() - (tmp_path / "top.txt").write_text("t") - (sub / "deep.txt").write_text("d") - tool = ListDirTool() - result = await tool.execute( - {"path": str(tmp_path), "pattern": "*.txt", "recursive": True}, - ExecutionContext(), - ) - assert len(result) == 2 diff --git a/tests/test_tools/test_http.py b/tests/test_tools/test_http.py deleted file mode 100644 index c64a83450..000000000 --- a/tests/test_tools/test_http.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Tests for HttpRequestTool.""" - -import aiohttp -import pytest - -_aiohttp_major_minor = tuple(int(x) for x in aiohttp.__version__.split(".")[:2]) - -pytestmark = pytest.mark.skipif( - _aiohttp_major_minor >= (3, 11), - reason="aioresponses 0.7.x incompatible with aiohttp >= 3.11 (stream_writer kwarg)", -) - -from aioresponses import ( # noqa: E402 — deliberate mid-file import (see block comment) - aioresponses, -) - -from src.odin.context import ( # noqa: E402 — deliberate mid-file import (see block comment) - ExecutionContext, -) -from src.odin.tools.http import ( # noqa: E402 — deliberate mid-file import (see block comment) - HttpRequestTool, -) - - -@pytest.mark.asyncio -async def test_get_json(): - with aioresponses() as m: - m.get("http://example.com/api", payload={"ok": True}) - tool = HttpRequestTool() - result = await tool.execute( - {"url": "http://example.com/api"}, ExecutionContext() - ) - assert result["status"] == 200 - assert result["body"] == {"ok": True} - - -@pytest.mark.asyncio -async def test_post_with_body(): - with aioresponses() as m: - m.post("http://example.com/api", payload={"created": True}) - tool = HttpRequestTool() - result = await tool.execute( - { - "url": "http://example.com/api", - "method": "POST", - "body": {"name": "odin"}, - }, - ExecutionContext(), - ) - assert result["status"] == 200 - assert result["body"]["created"] is True - - -@pytest.mark.asyncio -async def test_text_response(): - with aioresponses() as m: - m.get("http://example.com/page", body="hello") - tool = HttpRequestTool() - result = await tool.execute( - {"url": "http://example.com/page", "json_response": False}, - ExecutionContext(), - ) - assert result["body"] == "hello" - - -@pytest.mark.asyncio -async def test_non_200_status(): - with aioresponses() as m: - m.get("http://example.com/err", status=404, payload={"error": "not found"}) - tool = HttpRequestTool() - result = await tool.execute( - {"url": "http://example.com/err"}, ExecutionContext() - ) - assert result["status"] == 404 diff --git a/tests/test_tools/test_process.py b/tests/test_tools/test_process.py deleted file mode 100644 index c886c6530..000000000 --- a/tests/test_tools/test_process.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Tests for process management tools.""" - - -import pytest - -from src.odin.context import ExecutionContext -from src.odin.tools.process import ProcessKillTool, ProcessRunTool - - -@pytest.mark.asyncio -async def test_run_short_process(): - tool = ProcessRunTool() - result = await tool.execute({"command": "echo odin"}, ExecutionContext()) - assert result["returncode"] == 0 - assert "odin" in result["stdout"] - assert result["pid"] > 0 - - -@pytest.mark.asyncio -async def test_run_detached(): - tool = ProcessRunTool() - result = await tool.execute( - {"command": "sleep 60", "detach": True}, ExecutionContext() - ) - assert result["returncode"] is None - assert result["pid"] > 0 - - # Clean up the detached process. - kill = ProcessKillTool() - kill_result = await kill.execute({"pid": result["pid"]}, ExecutionContext()) - assert kill_result["killed"] is True - - -@pytest.mark.asyncio -async def test_kill_nonexistent(): - tool = ProcessKillTool() - result = await tool.execute({"pid": 999999999}, ExecutionContext()) - assert result["killed"] is False - - -@pytest.mark.asyncio -@pytest.mark.parametrize("pid", [0, -1, -1000]) -async def test_kill_rejects_broadcast_pids(pid): - # os.kill(0, sig) hits the caller's whole process group; os.kill(-1, sig) - # every process the user owns. This tool kills ONE process — a pid <= 0 is - # never a single target and must be refused, not signalled. - tool = ProcessKillTool() - result = await tool.execute({"pid": pid}, ExecutionContext()) - assert result["killed"] is False diff --git a/tests/test_tools/test_shell.py b/tests/test_tools/test_shell.py deleted file mode 100644 index cc0cfeba2..000000000 --- a/tests/test_tools/test_shell.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Tests for ShellTool.""" - -import pytest - -from src.odin.context import ExecutionContext -from src.odin.tools.shell import ShellTool - - -@pytest.mark.asyncio -async def test_echo(): - tool = ShellTool() - result = await tool.execute({"command": "echo hello"}, ExecutionContext()) - assert result["returncode"] == 0 - assert result["stdout"].strip() == "hello" - - -@pytest.mark.asyncio -async def test_failing_command_raises(): - tool = ShellTool() - with pytest.raises(RuntimeError, match="failed"): - await tool.execute({"command": "false", "check": True}, ExecutionContext()) - - -@pytest.mark.asyncio -async def test_failing_command_no_check(): - tool = ShellTool() - result = await tool.execute( - {"command": "false", "check": False}, ExecutionContext() - ) - assert result["returncode"] != 0 - - -@pytest.mark.asyncio -async def test_cwd(tmp_path): - tool = ShellTool() - result = await tool.execute( - {"command": "pwd", "cwd": str(tmp_path)}, ExecutionContext() - ) - assert tmp_path.name in result["stdout"] - - -@pytest.mark.asyncio -async def test_env(): - tool = ShellTool() - result = await tool.execute( - {"command": "echo $MY_VAR", "env": {"MY_VAR": "odin"}}, - ExecutionContext(), - ) - # env replaces the full env, so this might not work on all systems. - # Instead, test with printenv which is more reliable: - result = await tool.execute( - { - "command": "printenv MY_VAR || echo ''", - "env": {"MY_VAR": "odin", "PATH": "/usr/bin:/bin"}, - "check": False, - }, - ExecutionContext(), - ) - assert "odin" in result["stdout"] diff --git a/tests/test_ts_ledger_fixes.py b/tests/test_ts_ledger_fixes.py index b8fd337b2..01935a6d0 100644 --- a/tests/test_ts_ledger_fixes.py +++ b/tests/test_ts_ledger_fixes.py @@ -9,7 +9,6 @@ import pytest -from src.odin.planner import PlanValidationError from src.tools.skill_context import SkillContext @@ -66,35 +65,6 @@ async def test_unknown_host_returns_denial_string(self): assert result == "Unknown or disallowed host: nope" -class TestTS0003PlanValidationError: - """TS-0003: `odin run` crashed inside its own error reporter — the - exception never had the .errors the CLI iterates.""" - - def test_carries_error_list(self): - exc = PlanValidationError(["duplicate step id: a", "unknown tool: x"]) - assert exc.errors == ["duplicate step id: a", "unknown tool: x"] - assert "duplicate step id: a; unknown tool: x" == str(exc) - - @pytest.mark.asyncio - async def test_planner_execute_raises_with_errors(self): - # validate() returns the error list; execute() is the raise site the - # CLI catches — drive the real path with an invalid plan. - from src.odin.planner import Planner - from src.odin.registry import ToolRegistry - from src.odin.types import PlanSpec, StepSpec - - plan = PlanSpec( - name="bad", steps=[ - StepSpec(id="a", tool="run_command", params={}), - StepSpec(id="a", tool="run_command", params={}), - ], - ) - planner = Planner(ToolRegistry.with_defaults()) - with pytest.raises(PlanValidationError) as excinfo: - await planner.execute(plan) - assert any("duplicate" in e.lower() for e in excinfo.value.errors) - - class TestTS0005HttpPostJsonShadowing: """TS-0005: http_post's `json=` parameter shadowed the stdlib module, so any non-JSON-content-type response crashed on `json.loads`. diff --git a/tests/test_types.py b/tests/test_types.py deleted file mode 100644 index 04df59200..000000000 --- a/tests/test_types.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Tests for core data types.""" - -from src.odin.types import PlanResult, StepResult, StepStatus - - -def test_step_result_defaults(): - r = StepResult(status=StepStatus.SUCCESS) - assert r.duration == 0.0 - assert r.attempts == 1 - assert r.output is None - assert r.error is None - - -def test_step_result_with_duration(): - r = StepResult(status=StepStatus.SUCCESS, duration=2.5, attempts=3) - assert r.duration == 2.5 - assert r.attempts == 3 - - -def test_step_spec_frozen(): - from src.odin.types import StepSpec - s = StepSpec(id="x", tool="shell") - try: - s.id = "y" # type: ignore[misc] - assert False, "Should be frozen" - except AttributeError: - pass - - -def test_plan_result_success_all_pass(): - pr = PlanResult(name="t", success=True) - pr.steps["a"] = StepResult(status=StepStatus.SUCCESS) - pr.steps["b"] = StepResult(status=StepStatus.SUCCESS) - assert pr.success is True - - -def test_plan_result_explicit_failure(): - pr = PlanResult(name="t", success=False) - pr.steps["a"] = StepResult(status=StepStatus.SUCCESS) - pr.steps["b"] = StepResult(status=StepStatus.FAILED, error="boom") - assert pr.success is False - - -def test_plan_result_empty(): - pr = PlanResult(name="t", success=True) - assert len(pr.steps) == 0 - - -def test_plan_result_steps_dict(): - pr = PlanResult(name="t", success=True) - pr.steps["a"] = StepResult(status=StepStatus.SUCCESS, duration=5.0) - assert pr.steps["a"].duration == 5.0 diff --git a/tests/test_typing_resilience.py b/tests/test_typing_resilience.py index 222c7aa5d..832e50845 100644 --- a/tests/test_typing_resilience.py +++ b/tests/test_typing_resilience.py @@ -518,6 +518,29 @@ def _msg(): class TestRunInnerErrorPresentation: """The incident path end-to-end: what actually reaches chat on failure.""" + @pytest.mark.parametrize("tool_error", [False, True]) + async def test_session_save_failure_still_delivers_response(self, caplog, tool_error): + """Pin the real exception path instead of incidental thread coverage. + + Local and CI coverage differed on the except header without either + covering its body. Exercise an actual save failure deterministically. + """ + from unittest.mock import Mock + + sessions = _FakeSessions() + sessions.save = Mock(side_effect=OSError("test session disk failure")) + pipeline, _, delivery = _make_pipeline( + sessions=sessions, tool_loop_exc=TimeoutError() if tool_error else None + ) + with caplog.at_level(logging.WARNING, logger="odin.discord.intake_pipeline"): + await pipeline._run_inner(_msg(), "do the thing", "c1") + sessions.save.assert_called_once_with() + assert "Session save failed: test session disk failure" in caplog.text + assert delivery.chunked == [ + "Tool execution timed out: TimeoutError" if tool_error else "ok-response" + ] + assert sessions.added[-1][1] == "assistant" + async def test_tool_loop_discord_500_sends_sanitized_error(self): pipeline, sessions, delivery = _make_pipeline(tool_loop_exc=_http_500()) await pipeline._run_inner(_msg(), "do the thing", "c1") diff --git a/tests/test_ui_templates.py b/tests/test_ui_templates.py index 8656827ad..8f70dc706 100644 --- a/tests/test_ui_templates.py +++ b/tests/test_ui_templates.py @@ -113,3 +113,22 @@ def test_llm_provider_credentials_resist_browser_autofill(): assert page.count('class="hm-input credential-input') == 2 assert ".credential-input:-webkit-autofill" in css assert "-webkit-text-fill-color: var(--hm-text)" in css + + +def test_codex_quota_column_renders_reported_windows_and_remaining_bar(): + page = (REPO_ROOT / "ui" / "js" / "pages" / "llm-config.js").read_text() + css = (REPO_ROOT / "ui" / "css" / "style.css").read_text() + assert 'Quota' in page + assert 'quotaBlocks(a, formatQuotaDate)' in page + assert "{{ block.remaining }}% remaining" in page + assert "block.remaining + '%'" in page + quota_math = (REPO_ROOT / "ui" / "js" / "codex-quota.js").read_text() + assert "quotaFailureVisible(a)" in page + assert "statusLabel" in page + assert "100 - used" in quota_math + assert "['primary', 'secondary']" in quota_math + assert "Weekly usage limit" in quota_math and "5-hour usage limit" in quota_math + assert "checked {{ quotaAge(a.quota.observed_at) }} ago" in page + assert "Quota check failed" in page and "a.quota_check_failed" in page + assert ".codex-quota-track" in css and ".codex-quota-fill" in css + assert "width: block.remaining + '%'" in page diff --git a/tests/test_usage_rollup.py b/tests/test_usage_rollup.py index d19eaa34b..2a4cacd6d 100644 --- a/tests/test_usage_rollup.py +++ b/tests/test_usage_rollup.py @@ -1,4 +1,5 @@ """Load-bearing coverage for the persistent Usage & Activity rollup.""" + from __future__ import annotations import asyncio @@ -10,6 +11,7 @@ import pytest from src.trajectories.saver import TrajectorySaver, TrajectoryTurn +from src.usage import rollup as rollup_module from src.usage.provenance import accepted_usage_fields from src.usage.rollup import UsageRollup @@ -78,9 +80,7 @@ def test_provider_reported_input_wins_and_output_stays_separate(self): output_token_provenance="estimated_text_v1", ) snapshot = SimpleNamespace(density_milli=2500) - result = accepted_usage_fields( - response, chars_sent=2500, images_sent=2, snapshot=snapshot - ) + result = accepted_usage_fields(response, chars_sent=2500, images_sent=2, snapshot=snapshot) assert result["input_tokens"] == 812 assert result["input_token_provenance"] == "provider_reported" assert result["estimated_input_tokens"] == 48000 @@ -90,9 +90,7 @@ def test_provider_reported_input_wins_and_output_stays_separate(self): def test_fallback_is_frozen_image_aware_estimator_not_four_char(self): response = SimpleNamespace(server_input_tokens=None, output_tokens=1) snapshot = SimpleNamespace(density_milli=1000) - result = accepted_usage_fields( - response, chars_sent=1000, images_sent=3, snapshot=snapshot - ) + result = accepted_usage_fields(response, chars_sent=1000, images_sent=3, snapshot=snapshot) assert result["input_tokens"] == 50500 assert result["input_token_provenance"] == "estimated_context_v1" assert result["input_tokens"] != 250 # CostTracker's deferred 4-char estimate @@ -159,18 +157,20 @@ async def test_absent_store_self_creates_and_survives_restart(self, tmp_path): rollup = make_rollup(tmp_path) assert rollup.available and rollup.db_path.exists() record = turn_record( - iterations=[{ - "iteration": 1, - "provider": "codex", - "model": "sol", - "input_tokens": 999, - "server_input_tokens": 123, - "estimated_input_tokens": 456, - "input_token_provenance": "provider_reported", - "output_tokens": 7, - "output_token_provenance": "estimated_text_v1", - "duration_ms": 50, - }] + iterations=[ + { + "iteration": 1, + "provider": "codex", + "model": "sol", + "input_tokens": 999, + "server_input_tokens": 123, + "estimated_input_tokens": 456, + "input_token_provenance": "provider_reported", + "output_tokens": 7, + "output_token_provenance": "estimated_text_v1", + "duration_ms": 50, + } + ] ) await rollup.observe_trajectory(record, "turn") first = await rollup.summary("all") @@ -223,22 +223,22 @@ async def test_empty_store_is_honest_empty_not_unavailable(self, tmp_path): class TestDurationTruthfulness: - async def test_nonpositive_duration_is_unavailable_without_rewriting_facts( - self, tmp_path - ): + async def test_nonpositive_duration_is_unavailable_without_rewriting_facts(self, tmp_path): rollup = make_rollup(tmp_path) record = turn_record( "duration-unknown", - iterations=[{ - "iteration": 1, - "provider": "codex", - "model": "sol", - "input_token_provenance": "provider_reported", - "server_input_tokens": 12, - "output_token_provenance": "provider_reported", - "server_output_tokens": 3, - "duration_ms": 0, - }], + iterations=[ + { + "iteration": 1, + "provider": "codex", + "model": "sol", + "input_token_provenance": "provider_reported", + "server_input_tokens": 12, + "output_token_provenance": "provider_reported", + "server_output_tokens": 3, + "duration_ms": 0, + } + ], ) record["total_duration_ms"] = 0 await rollup.observe_trajectory(record, "turn") @@ -253,35 +253,33 @@ async def test_nonpositive_duration_is_unavailable_without_rewriting_facts( # The append-only evidence remains exactly what the source supplied. with rollup._connect() as conn: - assert conn.execute( - "SELECT duration_ms FROM turn_facts" - ).fetchone()[0] == 0 - assert conn.execute( - "SELECT duration_ms FROM generation_facts" - ).fetchone()[0] == 0 - - async def test_mixed_recorded_and_unknown_durations_exclude_unknown_samples( - self, tmp_path - ): + assert conn.execute("SELECT duration_ms FROM turn_facts").fetchone()[0] == 0 + assert conn.execute("SELECT duration_ms FROM generation_facts").fetchone()[0] == 0 + + async def test_mixed_recorded_and_unknown_durations_exclude_unknown_samples(self, tmp_path): rollup = make_rollup(tmp_path) unknown = turn_record( "duration-missing", - iterations=[{ - "iteration": 1, - "provider": "codex", - "model": "sol", - "duration_ms": 0, - }], + iterations=[ + { + "iteration": 1, + "provider": "codex", + "model": "sol", + "duration_ms": 0, + } + ], ) unknown["total_duration_ms"] = 0 measured = turn_record( "duration-recorded", - iterations=[{ - "iteration": 1, - "provider": "codex", - "model": "sol", - "duration_ms": 600, - }], + iterations=[ + { + "iteration": 1, + "provider": "codex", + "model": "sol", + "duration_ms": 600, + } + ], ) measured["total_duration_ms"] = 900 await rollup.observe_trajectory(unknown, "turn") @@ -289,18 +287,22 @@ async def test_mixed_recorded_and_unknown_durations_exclude_unknown_samples( with rollup._connect() as conn: for raw in ( - json.dumps({ - "timestamp": datetime.now(UTC).isoformat(), - "tool_name": "read_file", - "execution_time_ms": 0, - "_hmac": "duration-unknown", - }).encode(), - json.dumps({ - "timestamp": datetime.now(UTC).isoformat(), - "tool_name": "read_file", - "execution_time_ms": 80, - "_hmac": "duration-recorded", - }).encode(), + json.dumps( + { + "timestamp": datetime.now(UTC).isoformat(), + "tool_name": "read_file", + "execution_time_ms": 0, + "_hmac": "duration-unknown", + } + ).encode(), + json.dumps( + { + "timestamp": datetime.now(UTC).isoformat(), + "tool_name": "read_file", + "execution_time_ms": 80, + "_hmac": "duration-recorded", + } + ).encode(), ): fact = rollup._tool_fact(raw) assert fact is not None @@ -342,12 +344,8 @@ async def test_other_tool_bucket_averages_only_recorded_samples(self, tmp_path): assert other["executions"] == 4 assert other["duration_samples"] == 2 assert other["duration_ms"] > 0 - assert other["avg_duration_ms"] == round( - other["duration_ms"] / other["duration_samples"] - ) - assert other["avg_duration_ms"] != round( - other["duration_ms"] / other["executions"] - ) + assert other["avg_duration_ms"] == round(other["duration_ms"] / other["duration_samples"]) + assert other["avg_duration_ms"] != round(other["duration_ms"] / other["executions"]) class TestObserverIsolation: @@ -365,6 +363,7 @@ def schedule_trajectory(self, *_args): async def test_saver_does_not_await_statistics_writer(self, tmp_path): class Observer: called = False + def schedule_trajectory(self, *_args): self.called = True @@ -417,20 +416,30 @@ async def test_interrupted_backfill_resumes_without_duplication(self, tmp_path): async def test_audit_rotation_identity_dedupe_and_tail(self, tmp_path): rollup = make_rollup(tmp_path) path = rollup.audit.path - first = json.dumps({ - "timestamp": datetime.now(UTC).isoformat(), - "tool_name": "run_command", - "execution_time_ms": 11, - }) + "\n" + first = ( + json.dumps( + { + "timestamp": datetime.now(UTC).isoformat(), + "tool_name": "run_command", + "execution_time_ms": 11, + } + ) + + "\n" + ) path.write_text(first) await rollup._one_backfill_pass() path.rename(path.with_name(path.name + ".1")) - path.write_text(json.dumps({ - "timestamp": datetime.now(UTC).isoformat(), - "tool_name": "read_file", - "execution_time_ms": 3, - "error": "failed", - }) + "\n") + path.write_text( + json.dumps( + { + "timestamp": datetime.now(UTC).isoformat(), + "tool_name": "read_file", + "execution_time_ms": 3, + "error": "failed", + } + ) + + "\n" + ) for _ in range(5): await rollup._one_backfill_pass() with sqlite3.connect(rollup.db_path) as conn: @@ -496,15 +505,42 @@ def test_timestamp_parser_is_total(value, expected): ("row", "input_tokens", "input_prov", "output_tokens", "output_prov"), [ ({"input_tokens": 10, "output_tokens": 4}, 10, "legacy_estimated", 4, "legacy_estimated"), - ({"input_token_provenance": "provider_reported", "server_input_tokens": 8, - "output_token_provenance": "provider_reported", "server_output_tokens": 3}, - 8, "provider_reported", 3, "provider_reported"), - ({"input_token_provenance": "estimated_context_v1", "estimated_input_tokens": 7, - "output_token_provenance": "estimated_text_v1", "output_tokens": 2}, - 7, "estimated_context_v1", 2, "estimated_text_v1"), - ({"input_token_provenance": "provider_reported", "server_input_tokens": -1, - "output_token_provenance": "bogus", "output_tokens": 2}, - None, "unknown", None, "unknown"), + ( + { + "input_token_provenance": "provider_reported", + "server_input_tokens": 8, + "output_token_provenance": "provider_reported", + "server_output_tokens": 3, + }, + 8, + "provider_reported", + 3, + "provider_reported", + ), + ( + { + "input_token_provenance": "estimated_context_v1", + "estimated_input_tokens": 7, + "output_token_provenance": "estimated_text_v1", + "output_tokens": 2, + }, + 7, + "estimated_context_v1", + 2, + "estimated_text_v1", + ), + ( + { + "input_token_provenance": "provider_reported", + "server_input_tokens": -1, + "output_token_provenance": "bogus", + "output_tokens": 2, + }, + None, + "unknown", + None, + "unknown", + ), ], ) def test_token_provenance_classification(row, input_tokens, input_prov, output_tokens, output_prov): @@ -604,9 +640,7 @@ def test_apply_raw_rows_handles_empty_malformed_non_tool_and_wrong_trajectory(tm trajectory_kind=None, ) assert (accepted, malformed) == (0, 1) - accepted, malformed = rollup._apply_raw_rows( - conn, [b"[]"], trajectory_kind="turn" - ) + accepted, malformed = rollup._apply_raw_rows(conn, [b"[]"], trajectory_kind="turn") assert (accepted, malformed) == (0, 1) finally: conn.close() @@ -672,9 +706,7 @@ async def test_summary_other_bucket_and_totals(tmp_path): with rollup._lock, rollup._connect() as conn: fact = rollup._tool_fact(raw) assert fact is not None - conn.execute( - "INSERT INTO tool_facts VALUES(?,?,?,?,?,?)", fact - ) + conn.execute("INSERT INTO tool_facts VALUES(?,?,?,?,?,?)", fact) conn.commit() data = await rollup.summary("all") assert data["tools"][-1]["tool_name"] == "Other" @@ -823,3 +855,246 @@ def test_oversized_row_is_bounded_skipped_and_does_not_starve_tail(tmp_path, mon with rollup._connect() as conn: assert conn.execute("SELECT COUNT(*) FROM turn_facts").fetchone()[0] == 1 assert conn.execute("SELECT SUM(malformed_rows) FROM ingestion_cursors").fetchone()[0] >= 1 + + +def test_tail_indexes_complete_rows_before_unterminated_oversized_row(tmp_path, monkeypatch): + import os + + import src.usage.rollup as module + + monkeypatch.setattr(module, "_BACKFILL_BYTES", 1024) + monkeypatch.setattr( + UsageRollup, "_last_complete_offset", staticmethod(lambda _handle, _size: 0) + ) + rollup = make_rollup(tmp_path) + path = rollup.trajectory_directory / "complete-before-pending.jsonl" + good = json.dumps(turn_record("before-pending", iterations=[])).encode() + b"\n" + path.write_bytes(good + b'{"message_id":"' + b"x" * 2000) + with path.open("rb") as handle: + rollup._consume_tail( + handle=handle, + stat=os.fstat(handle.fileno()), + kind="trajectory", + display_path=str(path), + trajectory_kind="turn", + ) + with rollup._connect() as conn: + assert conn.execute("SELECT COUNT(*) FROM turn_facts").fetchone()[0] == 1 + + +def test_oversized_unterminated_tail_advances_when_newline_arrives(tmp_path, monkeypatch): + import src.usage.rollup as module + + monkeypatch.setattr(module, "_BACKFILL_BYTES", 512) + rollup = make_rollup(tmp_path) + path = rollup.trajectory_directory / "large-tail.jsonl" + oversized = b'{"message_id":"' + b"x" * 700 + with path.open("wb") as handle: + handle.write(oversized) + + # An unterminated over-limit row remains pending, not falsely consumed. + with path.open("rb") as handle: + rollup._consume_tail( + handle=handle, + stat=__import__("os").fstat(handle.fileno()), + kind="trajectory", + display_path=str(path), + trajectory_kind="turn", + ) + with rollup._connect() as conn: + assert conn.execute("SELECT high_offset FROM ingestion_cursors").fetchone()[0] == 0 + + good = json.dumps(turn_record("after-oversized-tail", iterations=[])) + with path.open("ab") as handle: + handle.write(b'"}\n' + good.encode() + b"\n") + with path.open("rb") as handle: + rollup._consume_tail( + handle=handle, + stat=__import__("os").fstat(handle.fileno()), + kind="trajectory", + display_path=str(path), + trajectory_kind="turn", + ) + # The bounded oversized-row pass advances to the next record; consume that + # valid record on the next tail pass. + with path.open("rb") as handle: + rollup._consume_tail( + handle=handle, + stat=__import__("os").fstat(handle.fileno()), + kind="trajectory", + display_path=str(path), + trajectory_kind="turn", + ) + with rollup._connect() as conn: + assert conn.execute("SELECT COUNT(*) FROM turn_facts").fetchone()[0] == 1 + assert ( + conn.execute("SELECT high_offset FROM ingestion_cursors").fetchone()[0] + == path.stat().st_size + ) + assert conn.execute("SELECT malformed_rows FROM ingestion_cursors").fetchone()[0] == 1 + + +def test_tail_row_straddling_buffer_end_is_not_misclassified_as_oversized(tmp_path, monkeypatch): + import os + + monkeypatch.setattr(rollup_module, "_BACKFILL_BYTES", 1024) + monkeypatch.setattr( + UsageRollup, "_last_complete_offset", staticmethod(lambda _handle, _size: 0) + ) + rollup = make_rollup(tmp_path) + path = rollup.trajectory_directory / "straddling.jsonl" + # A complete small prefix puts the next normal row 64 bytes before the + # buffer boundary. Its newline lies beyond the buffer, with more data after. + records = [turn_record(f"row-{i}", iterations=[]) for i in range(3)] + encoded = [json.dumps(row).encode() for row in records] + prefix_record = json.dumps(turn_record("prefix", iterations=[])).encode() + prefix = prefix_record + b" " * (1023 - len(prefix_record)) + b"\n" + row = encoded[0] + b"\n" + path.write_bytes(prefix + row + encoded[1] + b"\n" + encoded[2] + b"\n") + for _ in range(3): + with path.open("rb") as handle: + rollup._consume_tail( + handle=handle, + stat=os.fstat(handle.fileno()), + kind="trajectory", + display_path=str(path), + trajectory_kind="turn", + ) + with rollup._connect() as conn: + assert conn.execute("SELECT COUNT(*) FROM turn_facts").fetchone()[0] == 4 + assert conn.execute("SELECT SUM(malformed_rows) FROM ingestion_cursors").fetchone()[0] == 0 + + +def test_tail_rows_are_views_over_single_batch_buffer(tmp_path, monkeypatch): + import os + + monkeypatch.setattr(rollup_module, "_BACKFILL_BYTES", 1024) + monkeypatch.setattr( + UsageRollup, "_last_complete_offset", staticmethod(lambda _handle, _size: 0) + ) + rollup = make_rollup(tmp_path) + path = rollup.trajectory_directory / "views.jsonl" + rows = [json.dumps(turn_record(f"view-{i}", iterations=[])).encode() for i in range(4)] + path.write_bytes(b"\n".join(rows) + b"\n") + captured = {} + original = rollup._apply_raw_rows + + def inspect(conn, raws, *, trajectory_kind): + captured["raws"] = raws + return original(conn, raws, trajectory_kind=trajectory_kind) + + monkeypatch.setattr(rollup, "_apply_raw_rows", inspect) + with path.open("rb") as handle: + rollup._consume_tail( + handle=handle, + stat=os.fstat(handle.fileno()), + kind="trajectory", + display_path=str(path), + trajectory_kind="turn", + ) + assert len(captured["raws"]) == len(rows) + assert all(isinstance(raw, memoryview) for raw in captured["raws"]) + assert len({id(raw.obj) for raw in captured["raws"]}) == 1 + assert all(isinstance(raw.obj, bytes) for raw in captured["raws"]) + + + + + +@pytest.mark.asyncio +async def test_source_scan_errors_are_per_backfill_pass(tmp_path, monkeypatch): + rollup = make_rollup(tmp_path) + original = rollup._trajectory_snapshots + calls = 0 + + def first_scan_fails(): + nonlocal calls + calls += 1 + if calls == 1: + rollup._source_scan_errors += 1 + return [] + return original() + + monkeypatch.setattr(rollup, "_trajectory_snapshots", first_scan_fails) + assert await rollup._one_backfill_pass() is False + assert rollup._source_scan_errors == 1 + assert await rollup._one_backfill_pass() is True + assert rollup._source_scan_errors == 0 + assert (await rollup.summary("all"))["coverage"]["backfill_complete"] is True + + +@pytest.mark.asyncio +async def test_scan_error_without_cursor_work_uses_tail_cadence(tmp_path, monkeypatch): + rollup = make_rollup(tmp_path) + waits = [] + calls = 0 + + async def one_pass(): + nonlocal calls + calls += 1 + rollup._source_scan_errors = 1 + return False + + async def record_wait(_awaitable, timeout): + waits.append(timeout) + _awaitable.close() + rollup._stop.set() + raise TimeoutError + + monkeypatch.setattr(rollup, "_one_backfill_pass", one_pass) + monkeypatch.setattr("src.usage.rollup.asyncio.wait_for", record_wait) + try: + await rollup._backfill_loop() + assert calls == 1 + assert waits == [rollup_module._TAIL_INTERVAL_SECONDS] + finally: + await rollup.stop() + + +@pytest.mark.asyncio +async def test_incomplete_cursor_work_uses_backfill_pause(tmp_path, monkeypatch): + rollup = make_rollup(tmp_path) + waits = [] + + async def one_pass(): + rollup._source_scan_errors = 1 + return False + + async def record_wait(_awaitable, timeout): + waits.append(timeout) + _awaitable.close() + rollup._stop.set() + raise TimeoutError + + monkeypatch.setattr(rollup, "_one_backfill_pass", one_pass) + monkeypatch.setattr(rollup, "_has_cursor_work", lambda: True) + monkeypatch.setattr("src.usage.rollup.asyncio.wait_for", record_wait) + try: + await rollup._backfill_loop() + assert waits == [rollup_module._BACKFILL_PAUSE_SECONDS] + finally: + await rollup.stop() + + +@pytest.mark.asyncio +async def test_coverage_waits_for_tail_offset_not_only_initial_backfill(tmp_path): + rollup = make_rollup(tmp_path) + path = rollup.trajectory_directory / "appended.jsonl" + path.write_text(json.dumps(turn_record("initial", iterations=[])) + "\n") + for _ in range(4): + if await rollup._one_backfill_pass(): + break + else: + pytest.fail("initial source scans did not complete") + + rows = "".join(json.dumps(turn_record(f"append-{i}", iterations=[])) + "\n" for i in range(300)) + with path.open("a") as handle: + handle.write(rows) + # Tail work is deliberately bounded; the historical initial_complete bit + # alone must not make coverage claim these appended rows are indexed. + assert await rollup._one_backfill_pass() is False + assert (await rollup.summary("all"))["coverage"]["backfill_complete"] is False + for _ in range(3): + if await rollup._one_backfill_pass(): + break + assert (await rollup.summary("all"))["coverage"]["backfill_complete"] is True diff --git a/tests/test_web_api_codex_admin.py b/tests/test_web_api_codex_admin.py index 7bd25893a..88d43da33 100644 --- a/tests/test_web_api_codex_admin.py +++ b/tests/test_web_api_codex_admin.py @@ -59,6 +59,105 @@ async def test_configured_lists_accounts(self, tmp_path): assert body["accounts"][0]["email"] == "u0@x.co" assert body["accounts"][0]["is_current"] is True + @pytest.mark.asyncio + async def test_status_includes_per_account_quota_and_check_failure(self, tmp_path, monkeypatch): + from src.llm.account_key import opaque_account_key + bot, _ = _make_bot(tmp_path, accounts=2) + pool = bot.llm_gateway.codex_client.auth + monkeypatch.setattr("src.llm.account_key.DEFAULT_KEY_PATH", tmp_path / "account-key") + first_key = opaque_account_key("0") + second_key = opaque_account_key("1") + pool.quota.record_headers(first_key, { + "x-codex-primary-used-percent": "59", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-after-seconds": "3600", + "x-codex-rate-limit-reached-type": "primary", + "x-codex-secondary-used-percent": "12", + "x-codex-secondary-window-minutes": "10080", + }) + pool.quota.record_headers(second_key, { + "x-codex-secondary-used-percent": "73", + "x-codex-secondary-window-minutes": "10080", + }) + monkeypatch.setattr( + pool, + "quota_check_failure", + lambda index: "timeout" if index == 1 else None, + raising=False, + ) + + async with TestClient(TestServer(_app(bot))) as c: + accounts = (await (await c.get("/api/codex/status")).json())["accounts"] + assert accounts[0]["quota"]["primary"] == { + "used_percent": 59.0, + "window_minutes": 300, + "resets_at": pytest.approx(accounts[0]["quota"]["observed_at"] + 3600, abs=1), + } + assert accounts[0]["quota"]["secondary"]["used_percent"] == 12.0 + assert accounts[0]["quota"]["observed_at"] > 0 + assert accounts[0]["quota"]["limit_reached_type"] == "primary" + assert accounts[0]["limit_reached"] is False + assert accounts[0]["quota_check_failed"] is None + assert accounts[1]["quota"]["primary"] is None + assert accounts[1]["quota"]["secondary"]["used_percent"] == 73.0 + assert accounts[1]["quota_check_failed"] == "timeout" + assert accounts[1]["limit_reached"] is False + + @pytest.mark.asyncio + async def test_status_empty_quota_before_observation(self, tmp_path): + bot, _ = _make_bot(tmp_path, accounts=1) + async with TestClient(TestServer(_app(bot))) as c: + account = (await (await c.get("/api/codex/status")).json())["accounts"][0] + assert account["quota"] is None + assert account["quota_check_failed"] is None + + @pytest.mark.asyncio + async def test_status_omits_zero_minute_placeholder_window(self, tmp_path, monkeypatch): + from src.llm.account_key import opaque_account_key + bot, _ = _make_bot(tmp_path, accounts=1) + pool = bot.llm_gateway.codex_client.auth + monkeypatch.setattr("src.llm.account_key.DEFAULT_KEY_PATH", tmp_path / "account-key") + pool.quota.record_headers(opaque_account_key("0"), { + "x-codex-secondary-window-minutes": "0", + "x-codex-secondary-used-percent": "0", + "x-codex-secondary-reset-after-seconds": "0", + }) + async with TestClient(TestServer(_app(bot))) as c: + account = (await (await c.get("/api/codex/status")).json())["accounts"][0] + assert account["quota"] is None + assert account["limit_reached"] is False + + @pytest.mark.asyncio + async def test_limit_reached_from_full_usage_without_header(self, tmp_path, monkeypatch): + from src.llm.account_key import opaque_account_key + + bot, _ = _make_bot(tmp_path, accounts=1) + pool = bot.llm_gateway.codex_client.auth + monkeypatch.setattr("src.llm.account_key.DEFAULT_KEY_PATH", tmp_path / "account-key") + pool.quota.record_headers(opaque_account_key("0"), { + "x-codex-primary-used-percent": "100.1", + "x-codex-primary-window-minutes": "300", + }) + async with TestClient(TestServer(_app(bot))) as c: + account = (await (await c.get("/api/codex/status")).json())["accounts"][0] + assert account["quota"]["limit_reached_type"] is None + assert account["limit_reached"] is True + + @pytest.mark.asyncio + async def test_status_limit_signal_without_percentages(self, tmp_path, monkeypatch): + from src.llm.account_key import opaque_account_key + + bot, _ = _make_bot(tmp_path, accounts=1) + pool = bot.llm_gateway.codex_client.auth + monkeypatch.setattr("src.llm.account_key.DEFAULT_KEY_PATH", tmp_path / "account-key") + pool.quota.record_headers(opaque_account_key("0"), { + "x-codex-rate-limit-reached-type": "primary", + }) + async with TestClient(TestServer(_app(bot))) as c: + account = (await (await c.get("/api/codex/status")).json())["accounts"][0] + assert account["quota"]["limit_reached_type"] == "primary" + assert account["limit_reached"] is True + @pytest.mark.asyncio async def test_unconfigured(self, tmp_path): bot, _ = _make_bot(tmp_path, configured=False) diff --git a/tests/test_web_api_config_admin.py b/tests/test_web_api_config_admin.py index ef412ea79..6c78aecf3 100644 --- a/tests/test_web_api_config_admin.py +++ b/tests/test_web_api_config_admin.py @@ -12,7 +12,7 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiohttp import web @@ -387,6 +387,57 @@ async def test_members_deduped_sorted(self): members = await (await c.get("/api/discord/members")).json() assert [m["id"] for m in members] == ["2", "3", "1"] # by display_name + +class TestDiscordIdentityLookup: + @pytest.mark.asyncio + async def test_failed_lookups_are_cached_and_bounded(self, monkeypatch): + from src.web.api import discord_identity + + monkeypatch.setattr(discord_identity, "_CACHE_MAX", 2) + app, bot = _app(discord_identity.register_discord_identity) + bot.api_token_manager = None + bot.fetch_user = AsyncMock(side_effect=LookupError("unknown user")) + ids = ["12345678901234567", "12345678901234568", "12345678901234569"] + async with TestClient(TestServer(app)) as client: + for user_id in ids: + response = await client.get(f"/api/discord/users/{user_id}") + assert (await response.json())["user"] is None + await client.get(f"/api/discord/users/{ids[2]}") + assert bot.fetch_user.await_count == 3 # negative cache hit + await client.get(f"/api/discord/users/{ids[0]}") + assert bot.fetch_user.await_count == 4 # oldest entry evicted + + @pytest.mark.asyncio + async def test_lookup_cached_and_invalid_ids_rejected(self): + from src.web.api.discord_identity import register_discord_identity + + app, bot = _app(register_discord_identity) + bot.api_token_manager = None + avatar = SimpleNamespace(url="https://cdn.example/avatar.png") + bot.fetch_user = AsyncMock(return_value=SimpleNamespace( + id=12345678901234567, name="ada", global_name="Ada", display_avatar=avatar, bot=False, + )) + async with TestClient(TestServer(app)) as client: + first = await client.get("/api/discord/users/12345678901234567") + second = await client.get("/api/discord/users/12345678901234567") + assert (await first.json())["user"]["display_name"] == "Ada" + assert (await second.json())["user"]["avatar_url"].endswith("avatar.png") + assert bot.fetch_user.await_count == 1 + assert (await client.get("/api/discord/users/not-a-snowflake")).status == 400 + + @pytest.mark.asyncio + async def test_lookup_requires_admin_authentication(self): + from src.web.api.discord_identity import register_discord_identity + + app, bot = _app(register_discord_identity) + bot.api_token_manager = None + bot.config.web.api_token = "secret" + bot.fetch_user = AsyncMock() + async with TestClient(TestServer(app)) as client: + response = await client.get("/api/discord/users/12345678901234567") + assert response.status in (401, 403) + bot.fetch_user.assert_not_awaited() + @pytest.mark.asyncio async def test_guild_and_channel_config_put(self): app, bot = _app(register_discord_config) @@ -1464,13 +1515,24 @@ async def test_live_apply_fields_name_their_handler(self): ) @pytest.mark.asyncio - async def test_activation_required_fields_say_what_activation_means(self): + async def test_activation_required_fields_say_what_activation_means(self, monkeypatch): app, _bot = _app(register_discord_config) async with TestClient(TestServer(app)) as c: body = await (await c.get("/api/config/meta")).json() dormant = [r for r in body["fields"] if r["apply_mode"] == "activation_required"] - assert dormant, "no dormant fields — the vocabulary would be untested" + from src.config.apply_registry import FIELDS, FieldSpec, build_field_record + + # Exercise the contract even on configurations with no gated leaves. + monkeypatch.setitem( + FIELDS, + "usage.synthetic_activation", + FieldSpec( + apply_mode="activation_required", + activation_policy="Operator activation is required.", + ), + ) + dormant.append(build_field_record("usage.synthetic_activation", True)) for record in dormant: assert record["activation_policy"], f"{record['path']}" assert record["apply_state"] == "dormant" @@ -1528,7 +1590,6 @@ async def test_no_secret_value_appears_anywhere_in_the_payload(self): bot.config.discord.token = "tok-discord-leak" bot.config.web.api_token = "tok-web-leak" bot.config.audit.hmac_key = "tok-audit-leak" - bot.config.slack.default_webhook_url = "tok-slack-webhook-url-leak" raw_config = bot.config.model_dump() raw_config["web"]["api_tokens"] = [{"name": "ops", "token": "tok-in-a-list-leak"}] raw_config["outbound_webhooks"]["targets"] = [ @@ -1543,7 +1604,6 @@ async def test_no_secret_value_appears_anywhere_in_the_payload(self): "tok-discord-leak", "tok-web-leak", "tok-audit-leak", - "tok-slack-webhook-url-leak", "tok-in-a-list-leak", "tok-target-leak", ): @@ -1693,6 +1753,24 @@ async def test_generic_config_rejects_mcp_without_splitting_any_truth(_active_co assert invalidations == [] +@pytest.mark.asyncio +async def test_generic_config_cannot_rewrite_webhook_targets_or_resolved_secrets(_active_config): + bot = _bot() + app, bot = _app(register_discord_config, bot=bot) + before = _active_config.read_bytes() + submitted = "https://user:password@example.test/hook" + async with TestClient(TestServer(app)) as c: + response = await c.put("/api/config", json={ + "outbound_webhooks": {"targets": [{"url": submitted, "secret": "resolved"}]} + }) + text = await response.text() + assert response.status == 409 + assert "outbound_webhooks.targets" in text + assert submitted not in text and "resolved" not in text + assert bot.config.outbound_webhooks.targets == [] + assert _active_config.read_bytes() == before + + @pytest.mark.asyncio async def test_generic_config_rejects_disabled_tools_leaf(_active_config): """tools.disabled_tools has a transactional owner (the Tools API); diff --git a/tests/test_web_api_integrations.py b/tests/test_web_api_integrations.py index d90e324c5..26c415eee 100644 --- a/tests/test_web_api_integrations.py +++ b/tests/test_web_api_integrations.py @@ -1,7 +1,7 @@ """Route coverage for web/api/integrations.py (RFC-006 P4-continuation, CONT-1). Per Odin's advisory: fake the remote services hard. These tests validate request -parsing, validation, and delegation/response shaping for MCP / Slack / Grafana +parsing, validation, and delegation/response shaping for MCP / Grafana alerts / outbound webhooks — never the network. Each service is a faked object; the "disabled" path is simply the attribute being absent. """ @@ -11,14 +11,17 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +import pytest +import yaml from aiohttp import web from aiohttp.test_utils import TestClient, TestServer +from src.config.schema import load_config, set_active_config_path +from src.notifications.outbound_webhooks import OutboundWebhookDispatcher from src.web.api.integrations import ( register_grafana_alerts, register_mcp_servers, register_outbound_webhooks, - register_slack, ) @@ -103,50 +106,6 @@ async def test_enabled_requires_boolean(self): assert (await c.post("/api/mcp/enabled", json={"enabled": "yes"})).status == 400 -class TestSlack: - def _notifier(self): - n = MagicMock() - n.get_status.return_value = {"channel": "#ops"} - n.send = AsyncMock(return_value=True) - n.send_formatted = AsyncMock(return_value=True) - return n - - async def test_status_disabled_and_enabled(self): - async with TestClient(TestServer(_app(register_slack, bot=_bot()))) as c: - assert (await (await c.get("/api/slack/status")).json())["enabled"] is False - bot = _bot(health_server=SimpleNamespace(slack_notifier=self._notifier())) - async with TestClient(TestServer(_app(register_slack, bot=bot))) as c: - body = await (await c.get("/api/slack/status")).json() - assert body["enabled"] is True and body["channel"] == "#ops" - - async def test_test_endpoint(self): - async with TestClient(TestServer(_app(register_slack, bot=_bot()))) as c: - assert (await c.post("/api/slack/test", json={})).status == 503 - bot = _bot(health_server=SimpleNamespace(slack_notifier=self._notifier())) - async with TestClient(TestServer(_app(register_slack, bot=bot))) as c: - r = await c.post("/api/slack/test", json={"message": "hi"}) - assert r.status == 200 and (await r.json())["sent"] is True - # tolerates a non-JSON body (defaults to {}) - assert (await c.post("/api/slack/test", data="not json")).status == 200 - - async def test_send_plain_and_formatted(self): - notifier = self._notifier() - bot = _bot(health_server=SimpleNamespace(slack_notifier=notifier)) - async with TestClient(TestServer(_app(register_slack, bot=bot))) as c: - assert (await c.post("/api/slack/send", data="bad")).status == 400 - assert (await c.post("/api/slack/send", json={})).status == 400 # no text - assert (await c.post("/api/slack/send", json={"text": "hello"})).status == 200 - notifier.send.assert_awaited() - assert ( - await c.post("/api/slack/send", json={"text": "warn", "severity": "critical"}) - ).status == 200 - notifier.send_formatted.assert_awaited() - - async def test_send_disabled(self): - async with TestClient(TestServer(_app(register_slack, bot=_bot()))) as c: - assert (await c.post("/api/slack/send", json={"text": "x"})).status == 503 - - # --------------------------------------------------------------------------- # # Grafana alerts # --------------------------------------------------------------------------- # @@ -214,6 +173,19 @@ async def test_delete_rule(self): # Outbound webhooks # --------------------------------------------------------------------------- # class TestOutboundWebhooks: + @pytest.fixture + def durable_bot(self, tmp_path): + path = tmp_path / "config.yml" + path.write_text( + "discord:\n token: placeholder\noutbound_webhooks:\n enabled: true\n targets: []\n" + ) + bot = _bot( + config=load_config(path), + outbound_webhook_dispatcher=OutboundWebhookDispatcher(), + ) + yield bot, path + set_active_config_path(None) + def _dispatcher(self): d = MagicMock() d.get_status.return_value = {"count": 1} @@ -226,6 +198,574 @@ def _dispatcher(self): d.stats.as_dict.return_value = {"sent": 5} return d, target + async def test_failed_persistence_returns_503_without_swapping_state( + self, durable_bot, monkeypatch + ): + bot, _ = durable_bot + original = bot.outbound_webhook_dispatcher._webhooks + + async def fail_persist(_targets, **_kwargs): + return OSError("disk full"), False + + monkeypatch.setattr("src.config.persistence.persist_webhook_targets_locked", fail_persist) + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as c: + response = await c.post( + "/api/outbound-webhooks", + json={"name": "hook", "url": "https://example.test/hook"}, + ) + assert response.status == 503 + assert (await response.json())["error"] == "could not save outbound webhook targets" + assert bot.outbound_webhook_dispatcher._webhooks is original + + async def test_webhook_round_trip_preserves_config_owned_yaml(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOOK_URL_A", "http://127.0.0.1/alpha") + monkeypatch.setenv("HOOK_SECRET_A", "resolved-alpha-secret") + monkeypatch.setenv("HOOK_SECRET_B", "resolved-beta-secret") + path = tmp_path / "config.yml" + path.write_text( + "# top-level config comment\n" + "discord:\n token: placeholder\n" + "outbound_webhooks:\n" + " enabled: true\n" + " # targets section comment\n" + " targets:\n" + " # alpha row comment\n" + " - name: Alpha\n" + " url: ${HOOK_URL_A}\n" + " secret: ${HOOK_SECRET_A}\n" + " events: [health, alert] # flow list comment\n" + " enabled: true\n" + " scrub_secrets: true\n" + " verify_ssl: true\n" + " # beta row comment\n" + " - name: Beta\n" + " url: http://127.0.0.1/beta\n" + " secret: ${HOOK_SECRET_B}\n" + " # section trailer\n" + "# # MCP server settings remain here\n" + "mcp:\n enabled: false\n" + ) + # Stable alphabetic IDs avoid YAML's numeric-scalar quoting ambiguity. + path.write_text( + path.read_text() + .replace(" - name: Alpha\n", " - id: alpha-id\n name: Alpha\n") + .replace(" - name: Beta\n", " - id: beta-id\n name: Beta\n") + ) + config = load_config(path) + dispatcher = OutboundWebhookDispatcher() + import uuid + + for index, configured in enumerate(config.outbound_webhooks.targets): + dispatcher.register( + name=configured.name, + url=configured.url, + secret=configured.secret, + events=configured.events or None, + enabled=configured.enabled, + scrub_secrets=configured.scrub_secrets, + verify_ssl=configured.verify_ssl, + webhook_id=configured.id or uuid.uuid5( + uuid.NAMESPACE_URL, + f"outbound-webhook:{index}:{configured.url}", + ).hex[:12], + created_at=configured.created_at, + ) + bot = _bot(config=config, outbound_webhook_dispatcher=dispatcher) + + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + initial = dispatcher.list_webhooks() + alpha_id, beta_id = initial[0].id, initial[1].id + created_response = await client.post( + "/api/outbound-webhooks", + json={ + "name": "Created", + "url": "http://127.0.0.1/created", + "secret": "submitted-created-secret", + }, + ) + assert created_response.status == 201 + created = await created_response.json() + after_create = path.read_text() + assert "${HOOK_URL_A}" in after_create + assert "${HOOK_SECRET_A}" in after_create + assert "${HOOK_SECRET_B}" in after_create + assert "resolved-alpha-secret" not in after_create + assert "resolved-beta-secret" not in after_create + assert after_create.count(f"id: {created['id']}") == 1 + assert "&id" not in after_create and "*id" not in after_create + assert "events: [health, alert]" in after_create + assert "# flow list comment" in after_create + assert "# alpha row comment" in after_create + assert "# beta row comment" in after_create + assert "# # MCP server settings remain here" in after_create + + # Update the legacy row without writing its resolved URL or secret, + # then rename its URL explicitly and keep the same stable identity. + renamed = await client.put( + f"/api/outbound-webhooks/{alpha_id}", json={"name": "Renamed Alpha"} + ) + assert renamed.status == 200 + assert "${HOOK_URL_A}" in path.read_text() + assert "${HOOK_SECRET_A}" in path.read_text() + moved = await client.put( + f"/api/outbound-webhooks/{alpha_id}", + json={"url": "http://127.0.0.1/alpha-renamed"}, + ) + assert moved.status == 200 + assert (await moved.json())["id"] == alpha_id + assert "resolved-alpha-secret" not in path.read_text() + + assert (await client.delete(f"/api/outbound-webhooks/{beta_id}")).status == 200 + assert (await client.delete(f"/api/outbound-webhooks/{alpha_id}")).status == 200 + assert (await client.delete(f"/api/outbound-webhooks/{created['id']}")).status == 200 + + final = path.read_text() + assert "targets: []" in final + assert "resolved-alpha-secret" not in final + assert "resolved-beta-secret" not in final + assert "# # MCP server settings remain here" in final + restarted = load_config(path) + assert restarted.outbound_webhooks.targets == [] + set_active_config_path(None) + + async def test_create_attaches_missing_targets_key(self, tmp_path): + path = tmp_path / "config.yml" + path.write_text("discord:\n token: placeholder\noutbound_webhooks:\n enabled: true\n") + bot = _bot( + config=load_config(path), + outbound_webhook_dispatcher=OutboundWebhookDispatcher(), + ) + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + response = await client.post( + "/api/outbound-webhooks", + json={"name": "hook", "url": "http://127.0.0.1/hook"}, + ) + assert response.status == 201 + ident = (await response.json())["id"] + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"][0]["id"] == ident + assert any(target.id == ident for target in load_config(path).outbound_webhooks.targets) + set_active_config_path(None) + + @pytest.mark.parametrize( + "layout", ["empty", "missing", "scalar", "block", "flow", "row-flow", "template"] + ) + @pytest.mark.parametrize("operations", ["update-delete", "delete-both"]) + async def test_webhook_crud_preserves_exact_trailing_template( + self, tmp_path, monkeypatch, layout, operations + ): + """Round-5 reproducer: verify the bytes and restart parity at EVERY save.""" + import uuid + from dataclasses import asdict + from pathlib import Path + + head = "discord:\n token: x\noutbound_webhooks:\n enabled: true\n" + row = " targets:\n - name: h\n url: https://h.example.test/x\n" + layouts = { + "empty": " targets: []\n", + "missing": "", + "scalar": row, + "block": row + " events:\n - alert\n", + "flow": row + " events: [alert]\n", + "row-flow": ' targets:\n - {name: h, url: "https://h.example.test/x"}\n', + } + tail = ( + "\n# # MCP (Model Context Protocol) servers.\n# mcp:\n# enabled: false\n\n" + "# # Graceful degradation thresholds\n# graceful_degradation:\n" + "# degraded_threshold: 3\n" + ) + if layout == "template": + monkeypatch.setenv("DISCORD_TOKEN", "test-only-unresolved-on-disk") + monkeypatch.setenv("MCP_API_KEY", "test-only-unresolved-on-disk") + monkeypatch.setenv("MCP_HTTP_TOKEN", "test-only-unresolved-on-disk") + template = (Path(__file__).resolve().parents[1] / "config.yml").read_text() + prefix, section = template.split("# outbound_webhooks:\n", 1) + section = "# outbound_webhooks:\n" + section + block, tail = section.split("\n# # MCP", 1) + tail = "\n# # MCP" + tail + # Activate the actual repository template, not a shortened imitation. + original = prefix + "\n".join( + line.removeprefix("# ") for line in block.split("\n") + ) + tail + assert sum(line.startswith("#") for line in tail.splitlines()) == 31 + else: + original = head + layouts[layout] + tail + path = tmp_path / "config.yml" + path.write_text(original) + + def restarted_dispatcher(): + config = load_config(path) + dispatcher = OutboundWebhookDispatcher() + for index, target in enumerate(config.outbound_webhooks.targets): + fields = target.model_dump() + ident = fields.pop("id") or uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:{index}:{target.url}" + ).hex[:12] + dispatcher.register(**fields, webhook_id=ident) + return config, dispatcher + + config, dispatcher = restarted_dispatcher() + bot = _bot(config=config, outbound_webhook_dispatcher=dispatcher) + untouched_row = ( + layouts[layout].removeprefix(" targets:\n") + if layout in {"scalar", "block", "flow", "row-flow"} else None + ) + + def verify(): + text = path.read_text() + assert text.endswith(tail) + assert text.count("# # MCP") == 1 + assert "test-only-unresolved-on-disk" not in text + assert text.startswith(original.split("outbound_webhooks:\n", 1)[0]) + assert yaml.safe_load(text) + assert "&id" not in text and "*id" not in text + if untouched_row: + assert untouched_row in text + _, restarted = restarted_dispatcher() + live_rows = [asdict(target) for target in dispatcher.list_webhooks()] + restarted_rows = [asdict(target) for target in restarted.list_webhooks()] + # Legacy rows without created_at get a fresh timestamp on startup. + for target in live_rows + restarted_rows: + target.pop("created_at", None) + assert live_rows == restarted_rows + + try: + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + ids = [] + for number in (1, 2): + response = await client.post("/api/outbound-webhooks", json={ + "name": f"c{number}", "url": f"http://10.0.0.{number}/h", + **({"events": ["alert"]} if number == 2 else {}), + }) + assert response.status == 201, await response.text() + ids.append((await response.json())["id"]) + verify() + if operations == "update-delete": + response = await client.put( + f"/api/outbound-webhooks/{ids[-1]}", json={"enabled": False} + ) + assert response.status == 200, await response.text() + verify() + ids = ids[-1:] + for ident in reversed(ids): + response = await client.delete(f"/api/outbound-webhooks/{ident}") + assert response.status == 200, await response.text() + verify() + finally: + set_active_config_path(None) + + async def test_delete_idless_duplicate_url_keeps_remaining_runtime_identity(self, tmp_path): + path = tmp_path / "config.yml" + path.write_text( + "discord:\n token: placeholder\noutbound_webhooks:\n enabled: true\n targets:\n" + " - name: first\n url: https://same.invalid/hook\n" + " - name: second\n url: https://same.invalid/hook\n" + ) + config = load_config(path) + dispatcher = OutboundWebhookDispatcher() + import uuid + + for index, target in enumerate(config.outbound_webhooks.targets): + dispatcher.register( + name=target.name, url=target.url, + webhook_id=uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:{index}:{target.url}" + ).hex[:12], + ) + bot = _bot(config=config, outbound_webhook_dispatcher=dispatcher) + original_text = path.read_text() + original_rows = yaml.safe_load(original_text)["outbound_webhooks"]["targets"] + first_id, second_id = [target.id for target in dispatcher.list_webhooks()] + + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + removed = await client.delete(f"/api/outbound-webhooks/{first_id}") + assert removed.status == 200 + after_delete = path.read_text() + rows_after_delete = yaml.safe_load(after_delete)["outbound_webhooks"]["targets"] + assert rows_after_delete == [original_rows[1]] + assert "id:" not in after_delete + + # The surviving target's index-derived ID changes after deletion. + # Runtime lookup and disk identity must agree immediately, without + # writing an ID into the untouched legacy row. + remaining_id = dispatcher.list_webhooks()[0].id + expected_after_restart = uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:0:{original_rows[1]['url']}" + ).hex[:12] + assert remaining_id == expected_after_restart + updated = await client.put( + f"/api/outbound-webhooks/{remaining_id}", json={"enabled": False} + ) + assert updated.status == 200 + final_text = path.read_text() + final_rows = yaml.safe_load(final_text)["outbound_webhooks"]["targets"] + assert len(final_rows) == 1 + assert final_rows[0]["name"] == "second" + assert final_rows[0]["enabled"] is False + assert "id:" not in final_text + set_active_config_path(None) + + @pytest.mark.parametrize("layout", ["block", "flow", "basic-auth"]) + async def test_edited_idless_row_rebinds_after_earlier_delete(self, tmp_path, layout): + """The runtime identity must match a restart after EACH scoped write.""" + import uuid + + url = ( + "https://user:password@second.example.test/h" + if layout == "basic-auth" else "https://second.example.test/h" + ) + first = " - name: first\n url: https://first.example.test/h\n" + second = ( + f' - {{name: second, url: "{url}"}}\n' + if layout == "flow" else + f" - name: second\n url: {url}\n" + ) + path = tmp_path / "config.yml" + path.write_text( + "discord:\n token: placeholder\noutbound_webhooks:\n" + " enabled: true\n targets:\n" + first + second + + " # preserved trailer\n" + ) + config = load_config(path) + dispatcher = OutboundWebhookDispatcher() + for index, target in enumerate(config.outbound_webhooks.targets): + dispatcher.register( + name=target.name, url=target.url, + webhook_id=uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:{index}:{target.url}" + ).hex[:12], + ) + bot = _bot(config=config, outbound_webhook_dispatcher=dispatcher) + + def assert_restart_ids(): + on_disk = load_config(path).outbound_webhooks.targets + expected = [ + row.id or uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:{index}:{row.url}" + ).hex[:12] + for index, row in enumerate(on_disk) + ] + assert [row.id for row in dispatcher.list_webhooks()] == expected + assert [row.id for row in bot.config.outbound_webhooks.targets] == [ + row.id for row in on_disk + ] + + try: + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + first_id, second_id = [row.id for row in dispatcher.list_webhooks()] + response = await client.put( + f"/api/outbound-webhooks/{second_id}", json={"name": "second-renamed"} + ) + assert response.status == 200, await response.text() + assert_restart_ids() + before_delete = path.read_text() + assert first in before_delete and "id:" not in before_delete + + response = await client.delete(f"/api/outbound-webhooks/{first_id}") + assert response.status == 200, await response.text() + assert_restart_ids() + after_delete = path.read_text() + assert first not in after_delete + assert after_delete == before_delete.replace(first, "") + + rebound_id = dispatcher.list_webhooks()[0].id + response = await client.put( + f"/api/outbound-webhooks/{rebound_id}", json={"enabled": False} + ) + assert response.status == 200, await response.text() + assert_restart_ids() + final = path.read_text() + assert "id:" not in final + assert "# preserved trailer" in final + assert yaml.safe_load(final)["outbound_webhooks"]["targets"][0]["enabled"] is False + + # A URL change *does* persist a stable ID. Unlike a rename, + # it must not remap the runtime row to a fresh URL-derived ID. + response = await client.put( + f"/api/outbound-webhooks/{rebound_id}", + json={"url": url + "?changed=1"}, + ) + assert response.status == 200, await response.text() + assert_restart_ids() + assert dispatcher.list_webhooks()[0].id == rebound_id + saved = yaml.safe_load(path.read_text()) + assert saved["outbound_webhooks"]["targets"][0]["id"] == rebound_id + finally: + set_active_config_path(None) + + @pytest.mark.parametrize("field", ["enabled", "scrub_secrets", "verify_ssl"]) + async def test_boolean_fields_reject_non_booleans_without_echoing_values( + self, durable_bot, field + ): + bot, _ = durable_bot + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + response = await client.post( + "/api/outbound-webhooks", + json={"name": "hook", "url": "http://127.0.0.1/hook", field: "sensitive-value"}, + ) + text = await response.text() + assert response.status == 400 + assert "sensitive-value" not in text + assert not bot.outbound_webhook_dispatcher.list_webhooks() + + async def test_basic_auth_url_password_is_masked_in_all_crud_responses(self, durable_bot): + bot, _path = durable_bot + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + response = await client.post("/api/outbound-webhooks", json={ + "url": "https://user:sensitive%3Apass@example.test/hook", "name": "private"}) + assert response.status == 201 + created = await response.json() + assert "sensitive" not in str(created) + assert "[REDACTED]" in created["url"] + listing = await (await client.get("/api/outbound-webhooks")).text() + assert "sensitive" not in listing + response = await client.put(f"/api/outbound-webhooks/{created['id']}", + json={"name": "renamed"}) + assert response.status == 200 and "sensitive" not in await response.text() + assert "sensitive%3Apass" in _path.read_text() + + @pytest.mark.parametrize("field", ["enabled", "scrub_secrets", "verify_ssl"]) + async def test_invalid_boolean_error_names_field_without_echo(self, durable_bot, field): + bot, _path = durable_bot + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + response = await client.post("/api/outbound-webhooks", json={ + "url": "https://example.test/", field: "secret-looking-invalid-value"}) + assert response.status == 400 + assert (await response.json())["error"] == f"{field} must be a boolean" + + @pytest.mark.parametrize("field,value", [("url", []), ("name", 4)]) + async def test_non_string_update_is_bad_request(self, durable_bot, field, value): + bot, path = durable_bot + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + response = await client.post("/api/outbound-webhooks", json={ + "url": "https://example.test/hook"}) + ident = (await response.json())["id"] + before = path.read_bytes() + response = await client.put(f"/api/outbound-webhooks/{ident}", json={field: value}) + assert response.status == 400 + assert (await response.json())["error"] == f"{field} must be a string" + assert path.read_bytes() == before + + @pytest.mark.parametrize("mutation", ["put", "delete"]) + async def test_hand_deleted_target_conflicts_without_resurrecting_it( + self, durable_bot, mutation + ): + bot, path = durable_bot + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + created = await (await client.post("/api/outbound-webhooks", json={ + "url": "https://example.test/hook"})).json() + path.write_text( + "discord:\n token: placeholder\n" + "outbound_webhooks:\n enabled: true\n targets: []\n" + ) + before = path.read_bytes() + if mutation == "put": + response = await client.put(f"/api/outbound-webhooks/{created['id']}", + json={"name": "new"}) + else: + response = await client.delete(f"/api/outbound-webhooks/{created['id']}") + assert response.status == 409 + assert path.read_bytes() == before + assert bot.outbound_webhook_dispatcher.get(created["id"]) is not None + + async def test_hand_edited_legacy_url_conflicts_without_reappending_old_url( + self, tmp_path + ): + import uuid + + path = tmp_path / "config.yml" + path.write_text("discord:\n token: placeholder\n" + "outbound_webhooks:\n enabled: true\n targets:\n" + " - url: https://old.example.test/hook\n name: old\n") + config = load_config(path) + dispatcher = OutboundWebhookDispatcher() + ident = uuid.uuid5( + uuid.NAMESPACE_URL, "outbound-webhook:0:https://old.example.test/hook" + ).hex[:12] + dispatcher.register(name="old", url="https://old.example.test/hook", webhook_id=ident) + bot = _bot(config=config, outbound_webhook_dispatcher=dispatcher) + try: + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as client: + path.write_text(path.read_text().replace("old.example", "edited.example")) + before = path.read_bytes() + response = await client.put(f"/api/outbound-webhooks/{ident}", + json={"name": "renamed"}) + assert response.status == 409 + assert path.read_bytes() == before + assert dispatcher.get(ident).name == "old" + finally: + set_active_config_path(None) + + async def test_cancelled_persistence_commit_propagates_after_swap( + self, durable_bot, monkeypatch + ): + bot, _ = durable_bot + + async def cancelled_commit(_targets, **_kwargs): + return None, True + + monkeypatch.setattr( + "src.config.persistence.persist_webhook_targets_locked", cancelled_commit + ) + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as c: + with pytest.raises(Exception, match="Server disconnected"): + await c.post( + "/api/outbound-webhooks", + json={"name": "hook", "url": "https://example.test/hook"}, + ) + assert len(bot.outbound_webhook_dispatcher.list_webhooks()) == 1 + + async def test_unexpected_mutation_error_returns_503(self, durable_bot, monkeypatch): + bot, _ = durable_bot + + def fail_register(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(OutboundWebhookDispatcher, "register", fail_register) + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as c: + response = await c.post( + "/api/outbound-webhooks", + json={"name": "hook", "url": "https://example.test/hook"}, + ) + assert response.status == 503 + + async def test_update_and_delete_unexpected_errors_return_503(self, durable_bot, monkeypatch): + bot, _ = durable_bot + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as c: + created = await ( + await c.post( + "/api/outbound-webhooks", + json={"name": "hook", "url": "https://example.test/hook"}, + ) + ).json() + + def fail(*_args, **_kwargs): + raise RuntimeError("storage backend failed") + + monkeypatch.setattr(OutboundWebhookDispatcher, "update", fail) + assert (await c.put(f"/api/outbound-webhooks/{created['id']}", json={})).status == 503 + monkeypatch.setattr(OutboundWebhookDispatcher, "unregister", fail) + assert (await c.delete(f"/api/outbound-webhooks/{created['id']}")).status == 503 + + async def test_test_event_success_response(self, durable_bot, monkeypatch): + bot, _ = durable_bot + from src.notifications.outbound_webhooks import DeliveryResult + + bot.outbound_webhook_dispatcher.register( + name="hook", url="https://example.test/hook", webhook_id="wh1" + ) + + async def send_test_event(_self, webhook_id): + assert webhook_id == "wh1" + return DeliveryResult( + webhook_id="wh1", webhook_name="hook", event_type="custom", success=True + ) + + monkeypatch.setattr(OutboundWebhookDispatcher, "send_test_event", send_test_event) + async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as c: + result = await c.post("/api/outbound-webhooks/wh1/test") + assert result.status == 200 + assert (await result.json())["success"] is True + async def test_disabled_503(self): async with TestClient(TestServer(_app(register_outbound_webhooks, bot=_bot()))) as c: assert (await c.get("/api/outbound-webhooks")).status == 503 @@ -242,9 +782,8 @@ async def test_list_and_stats(self): assert (await (await c.get("/api/outbound-webhooks")).json())["count"] == 1 assert (await (await c.get("/api/outbound-webhooks/stats")).json())["sent"] == 5 - async def test_create(self): - d, _ = self._dispatcher() - bot = _bot(outbound_webhook_dispatcher=d) + async def test_create(self, durable_bot): + bot, path = durable_bot async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as c: assert (await c.post("/api/outbound-webhooks", data="bad")).status == 400 # name is length-validated (max 128); an over-long name is rejected @@ -252,29 +791,80 @@ async def test_create(self): r = await c.post( "/api/outbound-webhooks", json={"name": "hook", "url": "https://example.test/x"} ) - assert r.status == 201 and (await r.json())["id"] == "wh1" - # dispatcher rejecting the target (e.g. bad url) surfaces as 400 - d.register.side_effect = ValueError("bad url") + assert r.status == 201 + created = await r.json() + assert created["id"] and "secret" not in created + assert ( + yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"][0]["id"] + == created["id"] + ) assert (await c.post("/api/outbound-webhooks", json={"name": "hook2"})).status == 400 - async def test_update(self): - d, _ = self._dispatcher() - bot = _bot(outbound_webhook_dispatcher=d) + async def test_update(self, durable_bot): + bot, path = durable_bot async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as c: - assert (await c.put("/api/outbound-webhooks/wh1", data="bad")).status == 400 - assert (await c.put("/api/outbound-webhooks/wh1", json={"name": "new"})).status == 200 - d.update.return_value = None + created = await ( + await c.post( + "/api/outbound-webhooks", + json={ + "name": "test", + "url": "http://127.0.0.1/hook", + "secret": "top-secret", + }, + ) + ).json() + ident = created["id"] + assert (await c.put(f"/api/outbound-webhooks/{ident}", data="bad")).status == 400 + response = await c.put( + f"/api/outbound-webhooks/{ident}", + json={ + "name": "new", + "verify_ssl": False, + "scrub_secrets": False, + }, + ) + assert response.status == 200 + assert "top-secret" not in await response.text() + stored = yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"][0] + assert stored["id"] == ident and stored["secret"] == "top-secret" + assert stored["verify_ssl"] is False and stored["scrub_secrets"] is False + restarted = load_config(path) + boot = OutboundWebhookDispatcher() + for target in restarted.outbound_webhooks.targets: + boot.register( + name=target.name, + url=target.url, + secret=target.secret, + events=target.events, + enabled=target.enabled, + scrub_secrets=target.scrub_secrets, + verify_ssl=target.verify_ssl, + webhook_id=target.id, + created_at=target.created_at, + ) + assert boot.get(ident).name == "new" + assert boot.get(ident).verify_ssl is False + assert boot.get(ident).created_at == created["created_at"] assert (await c.put("/api/outbound-webhooks/ghost", json={})).status == 404 - d.update.side_effect = ValueError("bad") - assert (await c.put("/api/outbound-webhooks/wh1", json={})).status == 400 + assert ( + await c.put( + f"/api/outbound-webhooks/{ident}", json={"url": "http://169.254.169.254/"} + ) + ).status == 400 - async def test_delete_and_test(self): - d, _ = self._dispatcher() - bot = _bot(outbound_webhook_dispatcher=d) + async def test_delete_and_test(self, durable_bot): + bot, path = durable_bot async with TestClient(TestServer(_app(register_outbound_webhooks, bot=bot))) as c: - assert (await c.delete("/api/outbound-webhooks/wh1")).status == 200 - d.unregister.return_value = False + created = await ( + await c.post( + "/api/outbound-webhooks", + json={ + "name": "hook", + "url": "https://example.test/hook", + }, + ) + ).json() + assert (await c.delete("/api/outbound-webhooks/" + created["id"])).status == 200 + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [] assert (await c.delete("/api/outbound-webhooks/ghost")).status == 404 - assert (await c.post("/api/outbound-webhooks/wh1/test")).status == 200 - d.send_test_event = AsyncMock(return_value=None) assert (await c.post("/api/outbound-webhooks/ghost/test")).status == 404 diff --git a/tests/test_web_api_integrations_validation_coverage.py b/tests/test_web_api_integrations_validation_coverage.py new file mode 100644 index 000000000..1fe350faa --- /dev/null +++ b/tests/test_web_api_integrations_validation_coverage.py @@ -0,0 +1,88 @@ +"""Focused malformed-body branches for outbound-webhook API validation.""" + +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.web.api.integrations import register_outbound_webhooks + + +@pytest.mark.asyncio +async def test_outbound_webhook_create_and_update_reject_non_object_payloads(): + routes = web.RouteTableDef() + register_outbound_webhooks( + routes, SimpleNamespace(outbound_webhook_dispatcher=object()) + ) + app = web.Application() + app.router.add_routes(routes) + + async with TestClient(TestServer(app)) as client: + create = await client.post("/api/outbound-webhooks", json=[]) + update = await client.put("/api/outbound-webhooks/example", json=[]) + invalid_flag = await client.put( + "/api/outbound-webhooks/example", json={"enabled": "yes"} + ) + + assert create.status == 400 + assert update.status == 400 + assert invalid_flag.status == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field,value", [("name", []), ("url", {"bad": "type"})]) +async def test_outbound_webhook_create_rejects_non_string_name_and_url(field, value): + routes = web.RouteTableDef() + register_outbound_webhooks( + routes, SimpleNamespace(outbound_webhook_dispatcher=object()) + ) + app = web.Application() + app.router.add_routes(routes) + + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/api/outbound-webhooks", + json={"name": "safe", "url": "https://example.test/hook", field: value}, + ) + body = await response.json() + + assert response.status == 400 + assert body == {"error": f"{field} must be a string"} + + +@pytest.mark.asyncio +async def test_outbound_webhook_routes_report_disabled_dispatcher(): + """The endpoint reports unavailable when optional webhook wiring is absent.""" + routes = web.RouteTableDef() + register_outbound_webhooks(routes, SimpleNamespace()) + app = web.Application() + app.router.add_routes(routes) + + async with TestClient(TestServer(app)) as client: + response = await client.get("/api/outbound-webhooks/stats") + body = await response.json() + + assert response.status == 503 + assert body == {"error": "outbound webhooks not available"} + + +@pytest.mark.asyncio +async def test_outbound_webhook_create_rejects_overlong_url(): + """Reject an over-limit URL before any dispatcher mutation is attempted.""" + routes = web.RouteTableDef() + register_outbound_webhooks( + routes, SimpleNamespace(outbound_webhook_dispatcher=object()) + ) + app = web.Application() + app.router.add_routes(routes) + + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/api/outbound-webhooks", + json={"name": "valid", "url": "https://example.test/" + "x" * 2048}, + ) + body = await response.json() + + assert response.status == 400 + assert body == {"error": "url exceeds maximum length (2048 chars)"} diff --git a/tests/test_web_api_security_helpers_coverage.py b/tests/test_web_api_security_helpers_coverage.py new file mode 100644 index 000000000..5f8608d8b --- /dev/null +++ b/tests/test_web_api_security_helpers_coverage.py @@ -0,0 +1,47 @@ +"""Direct edge coverage for API security snapshot compatibility helpers.""" + +from types import SimpleNamespace + +from src.web.api.security import _auth_snapshot, _dynamic_auth_required + + +def test_auth_snapshot_supports_none_legacy_and_classmethod_manager(): + assert _auth_snapshot(None) is None + legacy = SimpleNamespace() + assert _auth_snapshot(legacy) is legacy + + class Manager: + def auth_snapshot(self): + return "snapshot" + + assert _auth_snapshot(Manager()) == "snapshot" + + +def test_dynamic_auth_required_supports_snapshot_shapes(): + assert _dynamic_auth_required(None) is False + assert _dynamic_auth_required(SimpleNamespace(dynamic_auth_required=True)) is True + assert _dynamic_auth_required(SimpleNamespace(dynamic_auth_required=False)) is False + assert _dynamic_auth_required( + SimpleNamespace( + dynamic_auth_required=None, + credential_inventory=SimpleNamespace(has_usable_auth=True), + ) + ) is True + assert _dynamic_auth_required( + SimpleNamespace( + dynamic_auth_required=None, + credential_inventory=SimpleNamespace(has_usable_auth=False), + ) + ) is False + + class TokenSnapshot: + dynamic_auth_required = None + credential_inventory = None + + def list_tokens(self): + return ["token"] + + assert _dynamic_auth_required(TokenSnapshot()) is True + TokenSnapshot.list_tokens = lambda self: [] + assert _dynamic_auth_required(TokenSnapshot()) is False + assert _dynamic_auth_required(SimpleNamespace()) is False diff --git a/tests/test_web_api_security_routes.py b/tests/test_web_api_security_routes.py index 4381e88e1..d88a83a72 100644 --- a/tests/test_web_api_security_routes.py +++ b/tests/test_web_api_security_routes.py @@ -73,9 +73,151 @@ async def _inject(request, handler): return app +@pytest.mark.asyncio +async def test_permission_audit_uses_identity_not_replayable_session_credential(tmp_path): + bot = _make_bot(tmp_path) + routes = web.RouteTableDef() + register_permissions_rbac(routes, bot) + + @web.middleware + async def authenticated_session(request, handler): + request.__dict__["_api_identity"] = SimpleNamespace(tier="admin", user_id="admin-user") + request.__dict__["_session_id"] = "session-bearer-never-log-me" + return await handler(request) + + app = web.Application(middlewares=[authenticated_session]) + app.router.add_routes(routes) + async with TestClient(TestServer(app)) as client: + response = await client.put("/api/permissions/user/visitor", json={"tier": "guest"}) + assert response.status == 200 + kwargs = bot.audit.log_event.await_args.kwargs + assert kwargs["actor"] == "web:admin-user" + assert "session-bearer-never-log-me" not in str(kwargs) + + +@pytest.mark.asyncio +async def test_host_access_audit_uses_identity_not_replayable_session_credential(tmp_path): + bot = _make_bot(tmp_path) + + @web.middleware + async def authenticated_session(request, handler): + request.__dict__["_api_identity"] = SimpleNamespace(tier="admin", user_id="admin-user") + request.__dict__["_session_id"] = "session-bearer-never-log-me" + return await handler(request) + + routes = web.RouteTableDef() + register_host_access(routes, bot) + app = web.Application(middlewares=[authenticated_session]) + app.router.add_routes(routes) + async with TestClient(TestServer(app)) as client: + response = await client.put( + "/api/host-access/user/visitor", json={"allowed_hosts": ["alpha"]} + ) + assert response.status == 200 + kwargs = bot.audit.log_event.await_args.kwargs + assert kwargs["actor"] == "web:admin-user" + assert "session-bearer-never-log-me" not in str(kwargs) + + # ── RBAC ───────────────────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_unusable_removal_reports_missing_row(tmp_path): + bot = _make_bot(tmp_path) + bot.api_token_manager.remove_unusable_entry = AsyncMock(return_value=False) + async with TestClient(TestServer(_app(bot))) as client: + response = await client.delete( + "/api/tokens/unusable/0", json={"reason": "invalid row"} + ) + assert response.status == 404 + assert await response.json() == {"error": "unusable token entry not found"} + bot.audit.log_event.assert_not_awaited() + class TestRbacRoutes: + @pytest.mark.asyncio + async def test_repair_and_remove_invalid_permission_overrides(self, tmp_path): + path = tmp_path / "perms.json" + path.write_text('{"broken": "wizard"}', encoding="utf-8") + bot = _make_bot(tmp_path) + bot.permissions = PermissionManager({}, "admin", str(path)) + async with TestClient(TestServer(_app(bot))) as client: + repaired = await client.post( + "/api/permissions/user/broken/repair", json={"tier": "guest"} + ) + assert repaired.status == 200 + assert await repaired.json() == { + "user_id": "broken", "tier": "guest", "status": "repaired" + } + bot.audit.log_event.assert_awaited_with( + event_type="permission_change", action="set_tier", actor="web:admin-user", + detail="Set user broken to tier guest", + ) + # Reintroduce an unknown legacy tier to exercise the delete route too. + path.write_text('{"broken": "wizard"}', encoding="utf-8") + bot.permissions = PermissionManager({}, "admin", str(path)) + removed = await client.delete("/api/permissions/user/broken/repair") + assert removed.status == 200 + assert await removed.json() == {"user_id": "broken", "status": "removed"} + bot.audit.log_event.assert_awaited_with( + event_type="permission_change", action="delete_tier", actor="web:admin-user", + detail="Removed tier override for user broken", + ) + assert bot.permissions.invalid_overrides == {} + + @pytest.mark.asyncio + async def test_repair_routes_validate_and_refuse_corrupt_store(self, tmp_path): + path = tmp_path / "perms.json" + path.write_text('{"broken": "wizard"}', encoding="utf-8") + bot = _make_bot(tmp_path) + bot.permissions = PermissionManager({}, "admin", str(path)) + async with TestClient(TestServer(_app(bot))) as client: + assert (await client.post( + "/api/permissions/user/broken/repair", json={"tier": "nope"} + )).status == 400 + assert (await client.post( + "/api/permissions/user/missing/repair", json={"tier": "guest"} + )).status == 404 + assert (await client.delete("/api/permissions/user/missing/repair")).status == 404 + assert (await client.post( + "/api/permissions/user/broken/repair", data="{" + )).status == 400 + path.write_text('{"broken": "wizard",', encoding="utf-8") + corrupt = await client.post( + "/api/permissions/user/broken/repair", json={"tier": "guest"} + ) + assert corrupt.status == 409 + bot.audit.log_event.assert_not_awaited() + + @pytest.mark.asyncio + async def test_repair_routes_admin_gate_manager_absence_and_corrupt_delete(self, tmp_path): + bot = _make_bot(tmp_path, with_managers=False) + async with TestClient(TestServer(_app(bot))) as client: + assert (await client.post( + "/api/permissions/user/u/repair", json={"tier": "guest"} + )).status == 503 + assert (await client.delete("/api/permissions/user/u/repair")).status == 503 + + path = tmp_path / "perms.json" + path.write_text('{"broken": "wizard"}', encoding="utf-8") + bot = _make_bot(tmp_path) + bot.permissions = PermissionManager({}, "admin", str(path)) + denied = SimpleNamespace(tier="user", user_id="ordinary") + async with TestClient(TestServer(_app(bot, identity=denied))) as client: + assert (await client.post( + "/api/permissions/user/broken/repair", json={"tier": "guest"} + )).status == 403 + assert (await client.delete( + "/api/permissions/user/broken/repair" + )).status == 403 + + path.write_text('{"broken": "wizard"}', encoding="utf-8") + bot.permissions = PermissionManager({}, "admin", str(path)) + path.write_text('{"broken": "wizard",', encoding="utf-8") + async with TestClient(TestServer(_app(bot))) as client: + assert (await client.delete( + "/api/permissions/user/broken/repair" + )).status == 409 + @pytest.mark.asyncio async def test_list_tiers(self, tmp_path): bot = _make_bot(tmp_path) @@ -249,6 +391,124 @@ async def test_default_policy_validation_and_503(self, tmp_path): # ── API tokens (admin-gated) ───────────────────────────────────────── class TestApiTokenRoutes: + @pytest.mark.asyncio + async def test_remove_unusable_token_entry_route(self, tmp_path): + import json + + from src.permissions.token_manager import _hash_token + + path = tmp_path / "tokens.json" + valid = { + "user_id": "owner", "username": "Owner", + "token_hash": _hash_token("known-secret"), "token_prefix": "known-se", + "tier": "admin", "allowed_tools": [], "allowed_hosts": None, + "default_host": "", "label": "", + } + path.write_text(json.dumps([valid, "broken-row"]), encoding="utf-8") + bot = _make_bot(tmp_path) + bot.api_token_manager = ApiTokenManager(path=str(path)) + async with TestClient(TestServer(_app(bot))) as client: + diagnosis = {"reason": "entry is not an object"} + removed = await client.delete("/api/tokens/unusable/1", json=diagnosis) + assert removed.status == 200 + assert await removed.json() == {"status": "removed", "index": 1} + assert bot.api_token_manager.resolve("known-secret").user_id == "owner" + bot.audit.log_event.assert_awaited_with( + event_type="token_change", action="delete_token", actor="web:admin-user", + detail=("Removed unusable token entry 1: reason=entry is not an object, " + "user_id=None"), + ) + assert (await client.delete("/api/tokens/unusable/1", json=diagnosis)).status == 409 + bad = await client.delete("/api/tokens/unusable/not-an-index", json=diagnosis) + assert bad.status == 400 + assert await bad.json() == {"error": "index must be an integer"} + assert (await client.delete("/api/tokens/unusable/1")).status == 400 + + @pytest.mark.asyncio + async def test_stale_token_diagnosis_conflicts_without_removing_other_row(self, tmp_path): + import json + + from src.permissions.token_manager import _hash_token + + path = tmp_path / "tokens.json" + path.write_text(json.dumps([ + {"user_id": "owner", "token_hash": _hash_token("secret")}, + {"user_id": "first"}, {"user_id": "second"}, + ])) + bot = _make_bot(tmp_path) + async with TestClient(TestServer(_app(bot))) as client: + listing = await client.get("/api/tokens") + entries = (await listing.json())["invalid_entries"] + assert [item.get("user_id") for item in entries] == ["first", "second"] + assert (await client.delete("/api/tokens/unusable/1", json=entries[0])).status == 200 + remaining = path.read_text() + stale = await client.delete("/api/tokens/unusable/1", json=entries[0]) + assert stale.status == 409 + assert "changed" in (await stale.json())["error"] + assert path.read_text() == remaining + assert bot.audit.log_event.await_count == 1 + bot.audit.log_event.assert_awaited_with( + event_type="token_change", action="delete_token", actor="web:admin-user", + detail=(f"Removed unusable token entry 1: reason={entries[0]['reason']}, " + "user_id='first'"), + ) + for malformed in ({}, {"reason": 42}, {"reason": "invalid tier", "user_id": []}): + assert (await client.delete("/api/tokens/unusable/1", json=malformed)).status == 400 + + @pytest.mark.asyncio + async def test_unusable_row_removal_survives_audit_sink_failure(self, tmp_path): + import json + + from src.permissions.token_manager import _hash_token + + path = tmp_path / "tokens.json" + path.write_text(json.dumps([ + {"user_id": "owner", "token_hash": _hash_token("secret")}, "broken-row", + ])) + bot = _make_bot(tmp_path) + bot.audit.log_event.side_effect = RuntimeError("audit unavailable") + async with TestClient(TestServer(_app(bot))) as client: + response = await client.delete( + "/api/tokens/unusable/1", json={"reason": "entry is not an object"} + ) + assert response.status == 200 + assert len(json.loads(path.read_text())) == 1 + + @pytest.mark.asyncio + async def test_remove_unusable_token_rejects_missing_manager_and_last_credential( + self, tmp_path, + ): + import json + + from src.permissions.token_manager import _hash_token + + bot = _make_bot(tmp_path, with_managers=False) + async with TestClient(TestServer(_app(bot))) as client: + assert (await client.delete("/api/tokens/unusable/0")).status == 503 + + path = tmp_path / "tokens.json" + valid = { + "user_id": "owner", "username": "Owner", + "token_hash": _hash_token("known-secret"), "token_prefix": "known-se", + "tier": "admin", "allowed_tools": [], "allowed_hosts": None, + "default_host": "", "label": "", + } + path.write_text(json.dumps([valid, "broken-row"]), encoding="utf-8") + bot = _make_bot(tmp_path) + bot.api_token_manager = ApiTokenManager(path=str(path)) + bot.api_token_manager.set_last_credential_guard(lambda _inventory: False) + async with TestClient(TestServer(_app(bot))) as client: + response = await client.delete( + "/api/tokens/unusable/1", json={"reason": "entry is not an object"} + ) + assert response.status == 409 + assert json.loads(path.read_text(encoding="utf-8")) == [valid, "broken-row"] + + bot.api_token_manager.set_last_credential_guard(lambda _inventory: True) + denied = SimpleNamespace(tier="user", user_id="ordinary") + async with TestClient(TestServer(_app(bot, identity=denied))) as client: + assert (await client.delete("/api/tokens/unusable/1")).status == 403 + @pytest.mark.asyncio @pytest.mark.parametrize("with_websocket_manager", [False, True]) async def test_delete_last_credential_conflict_preserves_auth( diff --git a/tests/test_web_security_reliability.py b/tests/test_web_security_reliability.py index 31b2d3b05..4e1684524 100644 --- a/tests/test_web_security_reliability.py +++ b/tests/test_web_security_reliability.py @@ -65,7 +65,9 @@ def test_admin_prefix_exact_and_subpath(): # --------------------------------------------------------------------------- def _req(remote, xff=None): - headers = {} + from multidict import CIMultiDict + + headers = CIMultiDict() if xff is not None: headers["X-Forwarded-For"] = xff return SimpleNamespace(remote=remote, headers=headers) @@ -82,11 +84,58 @@ def test_client_ip_uses_xff_from_trusted_proxy(): assert _client_ip(req, trusted_proxies=("192.168.1.1",)) == "198.51.100.7" +def test_client_ip_joins_repeated_xff_header_lines(): + from multidict import CIMultiDict + + req = SimpleNamespace( + remote="10.0.0.12", + headers=CIMultiDict([ + ("X-Forwarded-For", "198.51.100.7"), + ("X-Forwarded-For", "10.0.0.8"), + ]), + ) + assert _client_ip(req, trusted_proxies=("10.0.0.0/24",)) == "198.51.100.7" + + def test_client_ip_falls_back_when_no_xff(): req = _req("192.168.1.1") assert _client_ip(req, trusted_proxies=("192.168.1.1",)) == "192.168.1.1" +def test_client_ip_supports_trusted_proxy_cidrs(): + req = _req("10.0.0.12", xff="198.51.100.7, 10.0.0.8") + assert _client_ip(req, trusted_proxies=("10.0.0.0/24",)) == "198.51.100.7" + + +def test_client_ip_walks_forwarded_chain_right_to_left(): + req = _req("10.0.0.12", xff="198.51.100.7, 192.168.1.20, 10.0.0.8") + assert _client_ip( + req, trusted_proxies=("10.0.0.0/24", "192.168.1.0/24") + ) == "198.51.100.7" + + +@pytest.mark.parametrize("xff", ["not-an-ip", "198.51.100.7, garbage", ""]) +def test_client_ip_never_returns_invalid_forwarded_value(xff): + req = _req("10.0.0.12", xff=xff) + assert _client_ip(req, trusted_proxies=("10.0.0.0/24",)) == "10.0.0.12" + + +def test_client_ip_stops_at_first_untrusted_hop_and_ignores_leftward_spoof(): + req = _req("10.0.0.12", xff="203.0.113.99, 198.51.100.7, 10.0.0.8") + assert _client_ip(req, trusted_proxies=("10.0.0.0/24",)) == "198.51.100.7" + + +def test_client_ip_handles_ipv6_and_invalid_trust_entries(): + req = _req("2001:db8:1::2", xff="2001:db8:2::7, 2001:db8:1::1") + assert _client_ip(req, trusted_proxies=("not-a-network", "2001:db8:1::/64")) == "2001:db8:2::7" + + +def test_client_ip_rejects_non_ip_peer_and_walks_all_trusted_hops(): + assert _client_ip(_req("unknown", "198.51.100.9"), ("10.0.0.0/8",)) == "unknown" + req = _req("10.0.0.12", "10.0.0.8, 10.0.0.9") + assert _client_ip(req, ("10.0.0.0/24",)) == "10.0.0.8" + + # --------------------------------------------------------------------------- # Config redaction # --------------------------------------------------------------------------- @@ -94,7 +143,6 @@ def test_client_ip_falls_back_when_no_xff(): def test_redaction_covers_hmac_and_webhook_and_secret(): cfg = { "audit": {"hmac_key": "supersecretkey"}, - "slack": {"webhook_url": "https://hooks.slack.com/T/abc"}, "some_secret": "s3cr3t", "app_password": "pw", "nested": {"api_token": "tok"}, @@ -102,7 +150,6 @@ def test_redaction_covers_hmac_and_webhook_and_secret(): } red = _redact_config(cfg) assert red["audit"]["hmac_key"] == "••••••••" - assert red["slack"]["webhook_url"] == "••••••••" assert red["some_secret"] == "••••••••" assert red["app_password"] == "••••••••" assert red["nested"]["api_token"] == "••••••••" @@ -138,12 +185,10 @@ def test_operator_named_container_children_are_masked(): "headers": {"Authorization": "Bearer REAL"}, "env": {"MY_PASSPHRASE": "REAL-ENV"}, }}}, - "slack": {"webhook_urls": {"ops": "https://hooks.slack.com/REAL"}}, }) servers = red["mcp"]["servers"]["ops"] assert servers["headers"]["Authorization"] == "••••••••" assert servers["env"]["MY_PASSPHRASE"] == "••••••••" - assert red["slack"]["webhook_urls"]["ops"] == "••••••••" def test_container_masking_keeps_shape(): @@ -260,7 +305,6 @@ async def handler(request): ("/api/mcp/servers", {"headers": {"Authorization": "MASK"}}), ("/api/outbound-webhooks", {"secret": "MASK"}), ("/api/llm/kimi/config", {"api_key": "MASK"}), - ("/api/config", {"slack": {"default_webhook_url": "MASK"}}), ("/api/skills", {"nested": [{"deep": {"token": "MASK"}}]}), ], ) @@ -388,11 +432,9 @@ def test_the_redaction_mask_is_refused_as_input(): mask back on save. Accepting it writes eight bullets over the credential.""" from src.web.api_common import contains_redaction_mask - assert contains_redaction_mask({"slack": {"default_webhook_url": "••••••••"}}) assert contains_redaction_mask({"web": {"api_tokens": [{"token": "••••••••"}]}}) assert contains_redaction_mask({"mcp": {"servers": {"a": {"headers": {"A": "••••••••"}}}}}) assert not contains_redaction_mask({"discord": {"require_mention": True}}) - assert not contains_redaction_mask({"slack": {"default_webhook_url": "https://real"}}) # --------------------------------------------------------------------------- diff --git a/tests/test_webhook_campaign.py b/tests/test_webhook_campaign.py new file mode 100644 index 000000000..3aa60bfcc --- /dev/null +++ b/tests/test_webhook_campaign.py @@ -0,0 +1,508 @@ +"""Outbound webhook durability and delivery-origin security regressions.""" + +import ipaddress +import socket +from types import SimpleNamespace +from urllib.parse import urlparse + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from src.config.persistence import patch_config_paths +from src.config.schema import OutboundWebhooksConfig, set_active_config_path +from src.notifications import outbound_webhooks as hooks +from src.web.api.integrations import register_outbound_webhooks + + +@pytest.fixture +def no_retry(monkeypatch): + monkeypatch.setattr(hooks, "_MAX_RETRIES", 0) + + +@pytest.fixture +def loopback_connections_only(monkeypatch): + """A webhook regression must never open a socket to an external address.""" + connect = socket.socket.connect + + def guarded_connect(sock, address): + if sock.family in (socket.AF_INET, socket.AF_INET6): + assert ipaddress.ip_address(address[0]).is_loopback, address[0] + return connect(sock, address) + + monkeypatch.setattr(socket.socket, "connect", guarded_connect) + + +async def test_private_redirect_same_origin_signed_then_other_origin_unsigned(no_retry): + seen = [] + + async def landing(request): + seen.append( + (request.path, request.headers.get("X-Webhook-Signature"), await request.read()) + ) + return web.Response(text="ok") + + target_app = web.Application() + target_app.router.add_post("/landing", landing) + async with TestClient(TestServer(target_app)) as target: + + async def start(request): + seen.append( + (request.path, request.headers.get("X-Webhook-Signature"), await request.read()) + ) + return web.Response(status=307, headers={"Location": f"{target.make_url('/landing')}"}) + + origin_app = web.Application() + origin_app.router.add_post("/start", start) + async with TestClient(TestServer(origin_app)) as origin: + dispatcher = hooks.OutboundWebhookDispatcher(rate_limit_seconds=0) + target_row = dispatcher.register( + name="private", url=str(origin.make_url("/start")), secret="signing-secret" + ) + try: + result = await dispatcher.send_test_event(target_row.id) + assert result.success + assert seen[0][1].startswith("sha256=") + assert seen[1][1] is None + assert seen[0][2] == seen[1][2] + finally: + await dispatcher.close() + + +async def test_cross_origin_redirect_strips_basic_authorization( + no_retry, loopback_connections_only +): + seen = [] + async def landing(request): + seen.append(request.headers.get("Authorization")) + return web.Response(text="ok") + + target_app = web.Application() + target_app.router.add_post("/landing", landing) + async with TestClient(TestServer(target_app)) as target: + async def start(request): + seen.append(request.headers.get("Authorization")) + return web.Response(status=307, headers={"Location": str(target.make_url("/landing"))}) + + origin_app = web.Application() + origin_app.router.add_post("/start", start) + async with TestClient(TestServer(origin_app)) as origin: + parsed = urlparse(str(origin.make_url("/start"))) + basic_url = parsed._replace(netloc="user:pass@" + parsed.netloc).geturl() + dispatcher = hooks.OutboundWebhookDispatcher(rate_limit_seconds=0) + row = dispatcher.register(name="basic", url=basic_url) + try: + assert (await dispatcher.send_test_event(row.id)).success + assert seen == ["Basic dXNlcjpwYXNz", None] + finally: + await dispatcher.close() + + +async def test_same_origin_307_keeps_signature_and_302_drops_post_body(no_retry): + seen = [] + + async def start(request): + return web.Response(status=307, headers={"Location": "/same"}) + + async def same(request): + seen.append(("same", request.headers.get("X-Webhook-Signature"), await request.read())) + return web.Response(status=302, headers={"Location": "/finish"}) + + async def finish(request): + seen.append( + (request.method, request.headers.get("X-Webhook-Signature"), await request.read()) + ) + return web.Response(text="ok") + + app = web.Application() + app.router.add_post("/start", start) + app.router.add_post("/same", same) + app.router.add_get("/finish", finish) + async with TestClient(TestServer(app)) as server: + dispatcher = hooks.OutboundWebhookDispatcher() + target = dispatcher.register( + name="signed", url=str(server.make_url("/start")), secret="secret" + ) + try: + result = await dispatcher.send_test_event(target.id) + assert result.success + assert seen[0][1].startswith("sha256=") and seen[0][2] + assert seen[1] == ("GET", None, b"") + finally: + await dispatcher.close() + + +async def test_redirect_to_metadata_never_sent(no_retry, loopback_connections_only): + async def redirect(_request): + return web.Response( + status=307, headers={"Location": "http://169.254.169.254/latest/meta-data/"} + ) + + app = web.Application() + app.router.add_post("/start", redirect) + async with TestClient(TestServer(app)) as server: + dispatcher = hooks.OutboundWebhookDispatcher() + target = dispatcher.register(name="origin", url=str(server.make_url("/start"))) + try: + result = await dispatcher.send_test_event(target.id) + assert not result.success + assert "metadata" in result.error.lower() + finally: + await dispatcher.close() + + +async def test_dns_rebind_is_checked_by_the_connecting_resolver( + monkeypatch, + no_retry, + loopback_connections_only, +): + """The address validated is the resolver's socket destination, not prior DNS.""" + + async def malicious(_self, host, port, family): + return [ + { + "hostname": host, + "host": "169.254.169.254", + "port": port, + "family": socket.AF_INET, + "proto": 0, + "flags": socket.AI_NUMERICHOST, + } + ] + + monkeypatch.setattr(hooks, "is_metadata_url", lambda url, **kwargs: False) + monkeypatch.setattr(hooks._WebhookResolver, "_inner", None, raising=False) + from aiohttp.resolver import DefaultResolver + + monkeypatch.setattr(DefaultResolver, "resolve", malicious) + dispatcher = hooks.OutboundWebhookDispatcher() + target = dispatcher.register(name="dns", url="http://rebind.example.test/hook") + try: + result = await dispatcher.send_test_event(target.id) + assert not result.success + assert "metadata" in result.error.lower() + finally: + await dispatcher.close() + + +async def test_persistence_failure_leaves_runtime_unchanged(tmp_path): + path = tmp_path / "missing.yml" + set_active_config_path(path) + dispatcher = hooks.OutboundWebhookDispatcher() + bot = SimpleNamespace( + outbound_webhook_dispatcher=dispatcher, + config=SimpleNamespace(outbound_webhooks=OutboundWebhooksConfig(enabled=True)), + ) + routes = web.RouteTableDef() + register_outbound_webhooks(routes, bot) + app = web.Application() + app.router.add_routes(routes) + try: + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/api/outbound-webhooks", + json={ + "name": "not saved", + "url": "https://example.test/hook", + "secret": "private-secret", + }, + ) + assert response.status == 503 + assert "private-secret" not in await response.text() + assert dispatcher.list_webhooks() == [] + assert bot.config.outbound_webhooks.targets == [] + finally: + set_active_config_path(None) + + +async def test_crud_keeps_unregistered_configured_target(tmp_path): + path = tmp_path / "config.yml" + path.write_text( + "outbound_webhooks:\n enabled: true\n targets:\n" + " - id: bad\n name: typo\n url: https://example.test/hook\n" + " events: [all]\n" + " - id: good\n name: valid\n url: https://good.example.test/hook\n" + ) + from src.config.schema import OutboundWebhookTarget + + config = SimpleNamespace( + outbound_webhooks=OutboundWebhooksConfig( + enabled=True, + targets=[ + OutboundWebhookTarget(id="bad", name="bad", url="http://169.254.169.254/"), + OutboundWebhookTarget( + id="good", name="valid", url="https://good.example.test/hook" + ), + ], + ) + ) + dispatcher = hooks.OutboundWebhookDispatcher() + dispatcher.register(name="valid", url="https://good.example.test/hook", webhook_id="good") + bot = SimpleNamespace(outbound_webhook_dispatcher=dispatcher, config=config) + set_active_config_path(path) + routes = web.RouteTableDef() + register_outbound_webhooks(routes, bot) + app = web.Application() + app.add_routes(routes) + try: + async with TestClient(TestServer(app)) as client: + response = await client.put("/api/outbound-webhooks/good", json={"name": "renamed"}) + assert response.status == 200 + saved = __import__("yaml").safe_load(path.read_text()) + assert saved["outbound_webhooks"]["targets"][0]["id"] == "bad" + # The file is desired state, including edits made since startup. + assert saved["outbound_webhooks"]["targets"][0]["url"] == "https://example.test/hook" + finally: + set_active_config_path(None) + + +async def test_delete_legacy_target_reindexes_runtime_ids_for_immediate_update(tmp_path): + import uuid + + path = tmp_path / "config.yml" + path.write_text( + "outbound_webhooks:\n enabled: true\n targets:\n" + " - name: first\n url: https://first.example.test/hook\n" + " - name: second\n url: https://second.example.test/hook\n" + " - name: third\n url: https://third.example.test/hook\n" + ) + from src.config.schema import OutboundWebhookTarget + + rows = [ + OutboundWebhookTarget(name=name, url=f"https://{name}.example.test/hook") + for name in ("first", "second", "third") + ] + ids = [uuid.uuid5(uuid.NAMESPACE_URL, f"outbound-webhook:{i}:{row.url}").hex[:12] + for i, row in enumerate(rows)] + dispatcher = hooks.OutboundWebhookDispatcher() + dispatcher.register(name="first", url=rows[0].url, webhook_id=ids[0]) + dispatcher.register(name="second", url=rows[1].url, webhook_id=ids[1]) + dispatcher.register(name="third", url=rows[2].url, webhook_id=ids[2]) + bot = SimpleNamespace( + outbound_webhook_dispatcher=dispatcher, + config=SimpleNamespace( + outbound_webhooks=OutboundWebhooksConfig(enabled=True, targets=rows) + ), + ) + set_active_config_path(path) + routes = web.RouteTableDef() + register_outbound_webhooks(routes, bot) + app = web.Application() + app.add_routes(routes) + try: + async with TestClient(TestServer(app)) as client: + response = await client.delete(f"/api/outbound-webhooks/{ids[0]}") + assert response.status == 200 + shifted_second = uuid.uuid5( + uuid.NAMESPACE_URL, f"outbound-webhook:0:{rows[1].url}" + ).hex[:12] + response = await client.put( + f"/api/outbound-webhooks/{shifted_second}", json={"enabled": False} + ) + assert response.status == 200, await response.text() + import yaml + saved = yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] + assert len(saved) == 2 + assert saved[0]["url"] == rows[1].url and saved[0]["enabled"] is False + assert "id" not in saved[0] and "id" not in saved[1] + expected = uuid.uuid5(uuid.NAMESPACE_URL, + f"outbound-webhook:0:{rows[1].url}").hex[:12] + assert expected in {target.id for target in dispatcher.list_webhooks()} + finally: + set_active_config_path(None) + + +async def test_webhook_persistence_preserves_yaml_comments_and_env_leaves(tmp_path, monkeypatch): + monkeypatch.setenv("CAMPAIGN_WEBHOOK_SECRET", "resolved-value") + path = tmp_path / "config.yml" + path.write_text( + "# outside targets stays\noutbound_webhooks:\n targets:\n" + " - id: keep # entry comment\n name: before\n" + " url: https://example.test/hook\n" + " secret: ${CAMPAIGN_WEBHOOK_SECRET} # don't expose\n" + " events: [all] # flow style\n# next section note\nnext: true\n" + ) + from src.config.persistence import patch_config_paths + + patch_config_paths( + [ + ( + ("outbound_webhooks", "targets"), + [ + { + "id": "keep", + "name": "after", + "url": "https://example.test/hook", + "secret": "resolved-value", + "events": ["all"], + "enabled": True, + "scrub_secrets": True, + "verify_ssl": True, + "created_at": "now", + } + ], + ) + ], + path=path, + ) + text = path.read_text() + assert "# outside targets stays" in text and "# next section note" in text + assert "# entry comment" in text and "# don't expose" in text and "# flow style" in text + assert "${CAMPAIGN_WEBHOOK_SECRET}" in text and "resolved-value" not in text + + +def test_metadata_literals_and_private_targets(): + dispatcher = hooks.OutboundWebhookDispatcher() + for url in ("http://127.0.0.1/", "http://192.168.1.13/", "http://100.64.1.2/"): + dispatcher.register(name="internal", url=url) + for url in ( + "http://169.254.169.254/", + "http://169.254.169.254./", + "http://[fe80::a9fe:a9fe%25eth0]/", + "http://[fd20:ce::254%25eth0]/", + "http://169.254.169.254%25eth0/", + "http://[::ffff:169.254.169.254]/", + "http://169.254.170.2/", + "http://100.100.100.200/", + "http://[fd20:ce::254]/", + "http://169.254.10.20/", + "http://[fe80::a9fe:a9fe]/", + "http://[::ffff:100.100.100.200]/", + "http://[::ffff:6464:64c8]/", + "http://[fd20:00ce:0000:0000:0000:0000:0000:0254]/", + "http://2852039166/", + "http://169.16689662/", + "http://metadata.google.internal/", + ): + with pytest.raises(ValueError, match="metadata"): + dispatcher.register(name="blocked", url=url) + + +def test_basic_auth_is_allowed_for_configured_target_and_redirect_auth_is_rejected(): + target = hooks.OutboundWebhookDispatcher().register( + name="basic", url="https://user:pass@example.test/hook" + ) + assert target.url == "https://user:pass@example.test/hook" + with pytest.raises(ValueError): + hooks._validate_webhook_url("https://user:pass@example.test/hook", redirect=True) + + +@pytest.mark.parametrize( + "address", + ["::ffff:100.100.100.200", "::ffff:6464:64c8", "fd20:00ce:0:0:0:0:0:0254"], +) +async def test_webhook_resolver_rejects_metadata_ipv6_spellings(address): + class Inner: + async def resolve(self, host, port, family): + return [{"host": address}] + + resolver = hooks._WebhookResolver() + resolver._inner = Inner() + with pytest.raises(hooks.BlockedAddressError): + await resolver.resolve("attacker.test") + + +@pytest.mark.parametrize("location", ["/next", "http://user:pass@127.0.0.1/next"]) +async def test_basic_auth_same_origin_redirect_preserves_auth_and_rejects_header_userinfo( + location, no_retry, loopback_connections_only +): + seen = [] + + async def start(request): + seen.append(request.headers.get("Authorization")) + return web.Response(status=307, headers={"Location": location}) + + async def next_page(request): + seen.append(request.headers.get("Authorization")) + return web.Response(text="ok") + + app = web.Application() + app.router.add_post("/start", start) + app.router.add_post("/next", next_page) + async with TestClient(TestServer(app)) as server: + base = str(server.make_url("/start")) + parsed = hooks.urlparse(base) + target_url = parsed._replace(netloc="user:pass@" + parsed.netloc).geturl() + dispatcher = hooks.OutboundWebhookDispatcher() + target = dispatcher.register(name="basic", url=target_url) + try: + result = await dispatcher.send_test_event(target.id) + if location.startswith("http://user"): + assert not result.success + assert seen[0] is not None and len(seen) == 1 + else: + assert result.success + assert seen == ["Basic dXNlcjpwYXNz", "Basic dXNlcjpwYXNz"] + finally: + await dispatcher.close() + + +def test_webhook_send_policy_skips_synchronous_dns(monkeypatch): + def fail(*args, **kwargs): + raise AssertionError("send-time validation must not resolve DNS synchronously") + + monkeypatch.setattr(hooks.socket, "getaddrinfo", fail) + target = hooks.OutboundWebhookDispatcher().register( + name="dns-free", url="https://example.test/hook" + ) + assert target.url + + +def test_whole_list_persistence_keeps_existing_env_signing_key(tmp_path, monkeypatch): + monkeypatch.setenv("WEBHOOK_CAMPAIGN_SECRET", "resolved-secret") + path = tmp_path / "config.yml" + path.write_text( + "outbound_webhooks:\n targets:\n - id: a\n" + " name: existing\n url: https://example.test/hook\n" + " secret: ${WEBHOOK_CAMPAIGN_SECRET}\n" + ) + patch_config_paths( + [ + ( + ("outbound_webhooks", "targets"), + [ + { + "id": "a", + "name": "renamed", + "url": "https://example.test/hook", + "secret": "resolved-secret", + "events": [], + "enabled": True, + } + ], + ) + ], + path=path, + ) + assert "${WEBHOOK_CAMPAIGN_SECRET}" in path.read_text() + assert "resolved-secret" not in path.read_text() + + +def test_legacy_target_id_preserves_placeholder_during_url_edit(tmp_path, monkeypatch): + from uuid import NAMESPACE_URL, uuid5 + + monkeypatch.setenv("WEBHOOK_CAMPAIGN_SECRET", "resolved-secret") + path = tmp_path / "config.yml" + path.write_text( + "outbound_webhooks:\n targets:\n - name: existing\n" + " url: https://example.test/hook\n" + " secret: ${WEBHOOK_CAMPAIGN_SECRET}\n" + ) + ident = uuid5(NAMESPACE_URL, "outbound-webhook:0:https://example.test/hook").hex[:12] + patch_config_paths( + [ + ( + ("outbound_webhooks", "targets"), + [ + { + "id": ident, + "url": "https://new.example.test/hook", + "secret": "resolved-secret", + } + ], + ) + ], + path=path, + ) + assert "${WEBHOOK_CAMPAIGN_SECRET}" in path.read_text() + assert "resolved-secret" not in path.read_text() diff --git a/tests/test_webhook_persistence.py b/tests/test_webhook_persistence.py new file mode 100644 index 000000000..095e033a2 --- /dev/null +++ b/tests/test_webhook_persistence.py @@ -0,0 +1,908 @@ +"""Focused coverage for leaf-scoped outbound webhook persistence.""" + +from __future__ import annotations + +import asyncio + +import pytest +import yaml + +from src.config.persistence import ( + DELETE_CONFIG_PATH, + ConfigPersistError, + _assert_not_shared, + _dump_atomic, + _field_lookup, + _patch_config_paths, + _placeholder_still_accurate, + patch_webhook_targets, + persist_webhook_targets_locked, + submitted_leaves, +) + + +def _path(tmp_path, text="discord: {}\n"): + path = tmp_path / "config.yml" + path.write_text(text) + return path + + +def test_create_section_and_targets_from_empty_document(tmp_path): + path = _path(tmp_path) + patch_webhook_targets( + [{"id": "one", "name": "One", "url": "https://one.invalid"}], + changed_fields={"one": {"name", "url"}}, + path=path, + ) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"][0]["id"] == "one" + + +def test_create_missing_targets_precedes_trailing_section_template(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n enabled: true\n" + "# template one\n# template two\ngraceful_degradation: true\n", + ) + patch_webhook_targets( + [{"id": "one", "url": "https://one.invalid"}], + changed_fields={"one": {"url"}}, path=path, + ) + text = path.read_text() + assert text.index("targets:") < text.index("# template one") + assert text.index("id: one") < text.index("# template one") + assert yaml.safe_load(text)["outbound_webhooks"]["targets"][0]["id"] == "one" + + +def test_create_after_scalar_row_keeps_trailing_comments_separate(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: first\n verify_ssl: true\n" + " # template one\n # template two\n", + ) + patch_webhook_targets( + [{"id": "second", "url": "https://second.invalid"}], + changed_fields={"second": {"url"}}, path=path, + ) + text = path.read_text() + assert "verify_ssl: true #" not in text + assert text.count("# template") == 2 + assert text.index("id: second") < text.index("# template one") + + +def test_create_missing_targets_preserves_six_line_eof_comment_block(tmp_path): + comments = "".join( + f"# template {word}\n" + for word in ("one", "two", "three", "four", "five", "six") + ) + path = _path(tmp_path, "outbound_webhooks:\n enabled: true\n" + comments) + patch_webhook_targets( + [{"id": "one", "url": "https://one.invalid"}], + changed_fields={"one": {"url"}}, path=path, + ) + text = path.read_text() + assert text.count("# template") == 6 + assert text.index("targets:") < text.index("# template one") + assert text.index("id: one") < text.index("# template one") + assert yaml.safe_load(text)["outbound_webhooks"]["targets"][0]["id"] == "one" + + +def test_update_refuses_missing_requested_target_without_writing(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n - id: one\n url: https://one.invalid\n", + ) + original = path.read_bytes() + with pytest.raises(ConfigPersistError, match="update lacks target"): + patch_webhook_targets([], changed_fields={"one": {"name"}}, path=path) + assert path.read_bytes() == original + + +def test_patch_existing_fields_preserves_unmentioned_and_resolved_placeholder( + tmp_path, monkeypatch +): + monkeypatch.setenv("HOOK_SECRET", "secret-value") + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: one\n name: Old\n url: https://one.invalid\n" + " secret: ${HOOK_SECRET}\n enabled: true\n", + ) + patch_webhook_targets( + [ + { + "id": "one", + "name": "New", + "url": "https://one.invalid", + "secret": "secret-value", + "enabled": False, + } + ], + changed_fields={"one": {"name", "secret", "enabled"}}, + path=path, + ) + text = path.read_text() + assert "name: New" in text and "enabled: false" in text + assert "${HOOK_SECRET}" in text and "secret-value" not in text + + +def test_force_field_overwrites_placeholder(tmp_path, monkeypatch): + monkeypatch.setenv("HOOK_SECRET", "same-secret") + path = _path( + tmp_path, "outbound_webhooks:\n targets:\n - id: one\n secret: ${HOOK_SECRET}\n" + ) + patch_webhook_targets( + [{"id": "one", "secret": "same-secret"}], + changed_fields={"one": ({"secret"}, {"secret"})}, + path=path, + ) + assert "secret: same-secret" in path.read_text() + + +def test_delete_legacy_row_does_not_edit_shifted_legacy_row(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: remove\n url: https://remove.invalid\n" + " - name: Keep\n url: https://keep.invalid\n", + ) + patch_webhook_targets( + [{"id": "new", "name": "New", "url": "https://new.invalid"}], + changed_fields={"new": {"name", "url"}}, + delete_ids=["remove"], + path=path, + ) + rows = yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] + assert rows == [ + {"name": "Keep", "url": "https://keep.invalid"}, + {"id": "new", "name": "New", "url": "https://new.invalid"}, + ] + + +def test_delete_last_row_emits_empty_targets_and_noop_does_not_rewrite(tmp_path): + path = _path( + tmp_path, "outbound_webhooks:\n targets:\n - id: one\n url: https://one.invalid\n" + ) + patch_webhook_targets([], changed_fields={}, delete_ids=["one"], path=path) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [] + before = path.stat().st_mtime_ns + patch_webhook_targets([], changed_fields={}, path=path) + assert path.stat().st_mtime_ns == before + + +def test_delete_preserves_trailing_row_comments(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: one\n url: https://one.invalid\n name: One\n" + " enabled: true\n # section-tail\n", + ) + patch_webhook_targets([], changed_fields={}, delete_ids=["one"], path=path) + text = path.read_text() + assert "# section-tail" in text + assert yaml.safe_load(text)["outbound_webhooks"]["targets"] == [] + + +@pytest.mark.parametrize( + ("text", "message"), + [ + ("outbound_webhooks: nope\n", "must be a mapping"), + ("outbound_webhooks:\n targets: nope\n", "must be a list"), + ("outbound_webhooks:\n targets:\n - id: [broken\n", "unreadable or malformed"), + ], +) +def test_invalid_disk_state_fails_clearly(tmp_path, text, message): + path = _path(tmp_path, text) + target = {"id": "one", "name": "One", "url": "https://x.invalid"} + with pytest.raises(ConfigPersistError, match=message): + patch_webhook_targets([target], changed_fields={"one": {"name"}}, path=path) + + +def test_missing_config_path_fails(tmp_path): + with pytest.raises(ConfigPersistError, match="does not exist"): + patch_webhook_targets([], changed_fields={}, path=tmp_path / "missing.yml") + + +def test_legacy_identity_resolves_environment_url_without_writing_it(tmp_path, monkeypatch): + monkeypatch.setenv("HOOK_URL", "https://resolved.invalid") + path = _path( + tmp_path, "outbound_webhooks:\n targets:\n - url: ${HOOK_URL}\n name: Before\n" + ) + import uuid + + ident = uuid.uuid5(uuid.NAMESPACE_URL, "outbound-webhook:0:https://resolved.invalid").hex[:12] + patch_webhook_targets( + [{"id": ident, "name": "After", "url": "https://resolved.invalid"}], + changed_fields={ident: {"name"}}, + path=path, + ) + assert "name: After" in path.read_text() + assert "${HOOK_URL}" in path.read_text() + + +@pytest.mark.parametrize("row", ["- scalar\n", " - name: no-url\n"]) +def test_rows_without_mapping_identity_can_remain_while_new_target_is_added(tmp_path, row): + path = _path(tmp_path, "outbound_webhooks:\n targets:\n " + row) + patch_webhook_targets( + [{"id": "fresh", "name": "Fresh", "url": "https://fresh.invalid"}], + changed_fields={"fresh": {"name", "url"}}, + path=path, + ) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"][-1]["id"] == "fresh" + + +def test_unresolvable_environment_url_uses_raw_value_for_legacy_identity(tmp_path, monkeypatch): + monkeypatch.delenv("MISSING_HOOK_URL", raising=False) + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n - url: ${MISSING_HOOK_URL}\n name: Before\n", + ) + import uuid + + ident = uuid.uuid5(uuid.NAMESPACE_URL, "outbound-webhook:0:${MISSING_HOOK_URL}").hex[:12] + patch_webhook_targets( + [{"id": ident, "name": "After"}], + changed_fields={ident: {"name"}}, + path=path, + ) + assert "name: After" in path.read_text() + + +def test_changed_field_missing_from_target_is_ignored(tmp_path): + path = _path(tmp_path, "outbound_webhooks:\n targets:\n - id: one\n name: Existing\n") + patch_webhook_targets( + [{"id": "one", "name": "Existing"}], + changed_fields={"one": {"secret"}}, + path=path, + ) + assert "secret:" not in path.read_text() + + +def test_delete_comment_detachment_variants(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: first\n name: First\n url: https://first.invalid\n" + " enabled: true # inline\n" + " - id: last\n name: Last\n url: https://last.invalid\n" + " enabled: true # last-inline\n # section-tail\n", + ) + patch_webhook_targets([], changed_fields={}, delete_ids=["first", "last"], path=path) + text = path.read_text() + assert "# section-tail" in text + assert yaml.safe_load(text)["outbound_webhooks"]["targets"] == [] + + +def test_delete_non_mapping_rows_with_resolved_ids_rejects_malformed_entry(tmp_path, monkeypatch): + import src.config.persistence as persistence + + path = _path(tmp_path, "outbound_webhooks:\n targets:\n - scalar\n") + monkeypatch.setattr(persistence, "_webhook_identity", lambda _row, _index: "bad-row") + with pytest.raises(ConfigPersistError, match="entry must be a mapping"): + patch_webhook_targets( + [{"id": "bad-row", "name": "Bad"}], + changed_fields={"bad-row": {"name"}}, + path=path, + ) + + +def test_delete_malformed_row_takes_comment_preservation_guard(tmp_path, monkeypatch): + import src.config.persistence as persistence + + path = _path(tmp_path, "outbound_webhooks:\n targets:\n - scalar\n - id: valid\n") + original = persistence._webhook_identity + + def identify(row, index): + if index == 0: + return "remove" + return original(row, index) + + monkeypatch.setattr(persistence, "_webhook_identity", identify) + patch_webhook_targets([], changed_fields={}, delete_ids=["remove"], path=path) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [{"id": "valid"}] + + +def test_deleted_last_row_transfers_comment_block_before_section(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: first\n name: First\n" + " - id: last\n name: Last\n # trailing section comment\n", + ) + patch_webhook_targets([], changed_fields={}, delete_ids=["first", "last"], path=path) + assert "# trailing section comment" in path.read_text() + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [] + + +def test_deleting_earlier_row_transfers_trailing_section_comment(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: remove\n name: Remove\n" + " - id: keep\n name: Keep\n # section comment\n", + ) + patch_webhook_targets( + [{"id": "keep", "name": "Keep"}], + changed_fields={}, + delete_ids=["remove"], + path=path, + ) + result = yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] + assert result == [{"id": "keep", "name": "Keep"}] + assert "# section comment" in path.read_text() + + +def test_create_after_template_empty_targets_keeps_trailing_comment_block(tmp_path): + trailer = "".join(f"# # template line {i}\n" for i in range(31)) + path = _path(tmp_path, "outbound_webhooks:\n targets: []\n" + trailer + "mcp: {}\n") + for ident in ("first", "second"): + patch_webhook_targets( + [{"id": ident, "url": f"https://{ident}.invalid"}], + changed_fields={ident: {"url"}}, path=path, + ) + text = path.read_text() + assert text.index("id: second") < text.index("# # template line 0") + assert text.count("# # template line") == 31 + assert yaml.safe_load(text)["mcp"] == {} + + +def test_create_between_rows_and_trailer_preserves_all_comments(tmp_path): + path = _path(tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: first\n # between\n" + " - id: second\n # section-tail\n# # MCP\nmcp: {}\n", + ) + patch_webhook_targets([{"id": "third", "url": "https://third.invalid"}], + changed_fields={"third": {"url"}}, path=path) + text = path.read_text() + assert text.index("# between") < text.index("id: second") + assert text.index("id: third") < text.index("# section-tail") + assert "# # MCP" in text and yaml.safe_load(text)["mcp"] == {} + + +def test_delete_first_row_keeps_between_and_last_row_trailer(tmp_path): + path = _path(tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: first\n # between\n" + " - id: second\n # section-tail\n# # MCP\nmcp: {}\n", + ) + patch_webhook_targets([], changed_fields={}, delete_ids=["first"], path=path) + text = path.read_text() + assert text.index("# between") < text.index("id: second") + assert "# section-tail" in text and "# # MCP" in text + assert yaml.safe_load(text)["mcp"] == {} + + +def test_delete_last_row_keeps_both_between_and_trailing_blocks(tmp_path): + path = _path(tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: first\n # between\n" + " - id: last\n # section-tail\n# # MCP\nmcp: {}\n", + ) + patch_webhook_targets([], changed_fields={}, delete_ids=["last"], path=path) + text = path.read_text() + assert "# between" in text and "# section-tail" in text and "# # MCP" in text + assert yaml.safe_load(text)["mcp"] == {} + + +def test_delete_last_row_then_create_keeps_trailer_after_new_row(tmp_path): + path = _path(tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: old\n # section-tail\n# # MCP\nmcp: {}\n", + ) + patch_webhook_targets([], changed_fields={}, delete_ids=["old"], path=path) + patch_webhook_targets([{"id": "new", "url": "https://new.invalid"}], + changed_fields={"new": {"url"}}, path=path) + text = path.read_text() + assert text.index("id: new") < text.index("# section-tail") + assert yaml.safe_load(text)["mcp"] == {} + + +def test_delete_last_row_preserves_leading_and_trailing_comments(tmp_path): + path = _path(tmp_path, + "outbound_webhooks:\n targets:\n" + " # before-first\n - id: old\n # after-last\n" + "# # MCP\nmcp: {}\n", + ) + patch_webhook_targets([], changed_fields={}, delete_ids=["old"], path=path) + text = path.read_text() + assert "# before-first" in text and "# after-last" in text + assert "# # MCP" in text and yaml.safe_load(text)["mcp"] == {} + + +def test_hand_edited_rows_are_not_reintroduced_or_overwritten(tmp_path): + path = _path(tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: edited\n url: https://operator.invalid\n" + " - id: unrelated\n url: https://operator2.invalid\n", + ) + patch_webhook_targets([ + {"id": "edited", "url": "https://stale.invalid"}, + {"id": "unrelated", "url": "https://stale2.invalid"}, + {"id": "new", "url": "https://new.invalid"}, + ], changed_fields={"new": {"url"}}, path=path) + assert [r["url"] for r in yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"]] == [ + "https://operator.invalid", "https://operator2.invalid", "https://new.invalid"] + + +@pytest.mark.parametrize("mutation", ["update", "delete"]) +def test_missing_requested_row_conflicts_without_writing(tmp_path, mutation): + path = _path(tmp_path, "outbound_webhooks:\n targets: []\n") + original = path.read_bytes() + with pytest.raises(ConfigPersistError, match="changed on disk"): + patch_webhook_targets( + [{"id": "missing", "name": "new"}] if mutation == "update" else [], + changed_fields={"missing": ({"name"}, {"name"})} if mutation == "update" else {}, + delete_ids=["missing"] if mutation == "delete" else (), path=path, + ) + assert path.read_bytes() == original + + +def test_create_conflicts_with_operator_added_same_id(tmp_path): + path = _path(tmp_path, "outbound_webhooks:\n targets:\n" + " - id: new\n url: https://operator.invalid\n") + original = path.read_bytes() + with pytest.raises(ConfigPersistError, match="changed on disk"): + patch_webhook_targets([{"id": "new", "url": "https://stale.invalid"}], + changed_fields={"new": {"url"}}, create_ids=["new"], path=path) + assert path.read_bytes() == original + + +def test_delete_last_row_comment_transfer_ignores_non_comment_trailer(tmp_path): + from types import SimpleNamespace + + from src.config.persistence import _load_document + + path = _path(tmp_path, "outbound_webhooks:\n targets:\n - id: one\n name: One\n") + document, _ = _load_document(path) + document["outbound_webhooks"]["targets"][0].ca.items["name"] = [ + None, + None, + SimpleNamespace(value="\nplain trailer"), + ] + patch_webhook_targets([], changed_fields={}, delete_ids=["one"], path=path) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [] + + +def test_delete_mapping_with_short_comment_slot_and_non_mapping_tail(tmp_path): + from src.config.persistence import _load_document + + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n - id: remove\n name: Remove\n - scalar\n", + ) + document, _ = _load_document(path) + document["outbound_webhooks"]["targets"][0].ca.items["name"] = [None, None] + patch_webhook_targets([], changed_fields={}, delete_ids=["remove"], path=path) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == ["scalar"] + + +def test_delete_preserves_list_valued_row_comment_token(tmp_path): + from types import SimpleNamespace + + from src.config.persistence import _load_document + + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: remove\n name: Remove\n" + " - id: keep\n name: Keep\n", + ) + document, _ = _load_document(path) + document["outbound_webhooks"]["targets"][1].ca.items["name"] = [ + None, + None, + [SimpleNamespace(value="\n# list-token-trailer\n")], + ] + patch_webhook_targets( + [{"id": "keep", "name": "Keep"}], + changed_fields={}, + delete_ids=["remove"], + path=path, + ) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [ + {"id": "keep", "name": "Keep"} + ] + + +def test_delete_preserves_non_comment_trailing_row_token(tmp_path): + from types import SimpleNamespace + + from src.config.persistence import _load_document + + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: remove\n name: Remove\n" + " - id: keep\n name: Keep\n", + ) + document, _ = _load_document(path) + document["outbound_webhooks"]["targets"][1].ca.items["name"] = [ + None, + None, + SimpleNamespace(value="\nplain attachment\n"), + ] + patch_webhook_targets( + [{"id": "keep", "name": "Keep"}], + changed_fields={}, + delete_ids=["remove"], + path=path, + ) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [ + {"id": "keep", "name": "Keep"} + ] + + +def test_placeholder_guard_bad_numeric_and_boolean_spellings(monkeypatch): + monkeypatch.setenv("VALUE", "not-a-number") + assert not _placeholder_still_accurate("${VALUE}", 2) + monkeypatch.setenv("VALUE", "perhaps") + assert not _placeholder_still_accurate("${VALUE}", True) + assert not _placeholder_still_accurate(123, "123") + + +def test_schema_alias_submission_tracks_validated_canonical_leaf(): + from src.config.schema import SearchConfig + + assert _field_lookup(SearchConfig) + leaves = submitted_leaves( + {"chromadb_path": "/new/db"}, + {"search_db_path": "/new/db"}, + SearchConfig, + ) + assert leaves == [(("search_db_path",), "/new/db", ("chromadb_path",))] + + +def test_schema_owned_mapping_is_single_validated_leaf_and_unknown_is_dropped(): + from src.config.schema import Config + + leaves = submitted_leaves( + {"context_budget": {"short": 120}, "removed": 1}, + {"context_budget": {"short": 120}}, + Config, + ) + assert leaves == [(("context_budget", "short"), 120)] + + +def test_patch_config_paths_deletes_leaf_and_creates_nested_mappings(tmp_path): + path = _path(tmp_path, "server:\n host: old\n") + _patch_config_paths( + [(("server", "host"), DELETE_CONFIG_PATH), (("server", "port", "http"), 8000)], + path=path, + ) + doc = yaml.safe_load(path.read_text()) + assert doc["server"] == {"port": {"http": 8000}} + + +def test_atomic_dump_preserves_requested_mode(tmp_path): + import os + import stat + + path = _path(tmp_path, "value: old\n") + os.chmod(path, 0o640) + _dump_atomic({"value": "new"}, path, 0o640) + assert stat.S_IMODE(path.stat().st_mode) == 0o640 + assert yaml.safe_load(path.read_text())["value"] == "new" + + +def test_anchor_shared_mapping_is_refused(): + from ruamel.yaml import YAML + + doc = YAML().load("defaults: &shared\n key: value\nalias: *shared\n") + with pytest.raises(ConfigPersistError, match="anchor shared"): + _assert_not_shared(doc["defaults"], ("defaults",)) + + +def test_roundtrip_writer_does_not_create_cwd_fallback(tmp_path, monkeypatch): + from src.config.schema import set_active_config_path + + monkeypatch.chdir(tmp_path) + set_active_config_path(None) + with pytest.raises(ConfigPersistError, match="not loaded from disk"): + _patch_config_paths([(("value",), 1)]) + + +def test_file_lock_rejects_unsafe_existing_lock_path(tmp_path, monkeypatch): + import os + import tempfile + + import src.config.persistence as persistence + + lockdir = tmp_path / f"odin-config-locks-{os.geteuid()}" + lockdir.mkdir(mode=0o700) + lockfile = lockdir / "lockid" + lockfile.write_text("unsafe") + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + monkeypatch.setattr(persistence, "_config_file_lock", persistence._config_file_lock) + # Path identity is a SHA-like filename; create a symlink with the actual key + # after asking the same migration identity function used by the lock. + from src.config.migrations import _config_identity + + key = _config_identity(tmp_path / "target.yml") + (lockdir / key).unlink(missing_ok=True) + (lockdir / key).symlink_to(lockfile) + with pytest.raises((OSError, ConfigPersistError)): + persistence.patch_config_paths([(("x",), 1)], path=tmp_path / "target.yml") + + +def test_invalid_image_model_intent_is_rejected(tmp_path): + path = _path(tmp_path) + with pytest.raises(ConfigPersistError, match="invalid image model intent"): + _patch_config_paths([], path=path, image_model_intent={"not-real": "follow"}) + + +def test_image_model_pin_requires_explicit_value(tmp_path): + path = _path(tmp_path) + with pytest.raises(ConfigPersistError, match="pin requires"): + _patch_config_paths([], path=path, image_model_intent={"image_model": "pin"}) + + +def test_image_model_default_noop_and_explicit_follow(tmp_path): + from src.config.image_defaults import IMAGE_MODEL_DEFAULTS, IMAGE_MODEL_PREFIX + + leaf, default = next(iter(IMAGE_MODEL_DEFAULTS.items())) + path = _path(tmp_path, "image:\n openai:\n image_model: gpt-image-2.5-flare\n") + _patch_config_paths( + [(tuple((*IMAGE_MODEL_PREFIX, leaf)), default)], + path=path, + image_model_intent={leaf: "follow"}, + ) + assert yaml.safe_load(path.read_text()) == {"image": {"openai": {}}} + + +def test_image_model_pin_writes_explicit_value(tmp_path): + from src.config.image_defaults import IMAGE_MODEL_DEFAULTS, IMAGE_MODEL_PREFIX + + leaf, _default = next(iter(IMAGE_MODEL_DEFAULTS.items())) + path = _path(tmp_path, "image:\n openai: {}\n") + value = "explicit-test-model" + _patch_config_paths( + [(tuple((*IMAGE_MODEL_PREFIX, leaf)), value)], + path=path, + image_model_intent={leaf: "pin"}, + ) + assert yaml.safe_load(path.read_text())["image"]["openai"][leaf] == value + + +def test_persist_locked_empty_and_failed_write_outcomes(tmp_path, monkeypatch): + from src.config.persistence import persist_config_paths_locked + + assert asyncio.run(persist_config_paths_locked([], path=tmp_path / "unused")) == (None, False) + monkeypatch.setattr( + "src.config.persistence.patch_config_paths", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk failure")), + ) + error, cancelled = asyncio.run( + persist_config_paths_locked([(("value",), 1)], path=tmp_path / "config.yml") + ) + assert isinstance(error, OSError) and not cancelled + + +def test_persist_locked_forwards_image_intents_and_captures_error(tmp_path, monkeypatch): + from src.config.persistence import persist_config_paths_locked + + seen = {} + + def fail(changes, **kwargs): + seen["changes"] = list(changes) + seen.update(kwargs) + raise OSError("disk failure") + + monkeypatch.setattr("src.config.persistence.patch_config_paths", fail) + error, cancelled = asyncio.run( + persist_config_paths_locked( + [(("image", "openai", "image_model"), "test")], + path=tmp_path / "config.yml", + image_model_intent={"image_model": "pin"}, + ) + ) + assert isinstance(error, OSError) and not cancelled + assert seen["image_model_intent"] == {"image_model": "pin"} + + +def test_patch_paths_skips_same_value_and_deletes_absent_path(tmp_path): + path = _path(tmp_path, "server:\n host: localhost\n") + before = path.stat().st_mtime_ns + _patch_config_paths( + [(("server", "host"), "localhost"), (("server", "missing"), DELETE_CONFIG_PATH)], + path=path, + ) + assert path.stat().st_mtime_ns == before + + +def test_image_model_follow_creates_missing_branch_and_removes_default(tmp_path): + from src.config.image_defaults import IMAGE_MODEL_DEFAULTS, IMAGE_MODEL_PREFIX + + leaf, default = next(iter(IMAGE_MODEL_DEFAULTS.items())) + path = _path(tmp_path) + _patch_config_paths( + [(tuple((*IMAGE_MODEL_PREFIX, leaf)), default)], + path=path, + image_model_intent={leaf: "follow"}, + ) + assert yaml.safe_load(path.read_text()) == {"discord": {}} + + +def test_webhook_whole_list_roundtrip_reconciles_and_preserves_unknown_secret( + tmp_path, monkeypatch +): + monkeypatch.setenv("HOOK_SECRET", "resolved-secret") + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: one\n url: https://one.invalid\n secret: ${HOOK_SECRET}\n", + ) + _patch_config_paths( + [ + ( + ("outbound_webhooks", "targets"), + [ + {"id": "one", "url": "https://one.invalid", "secret": "resolved-secret"}, + {"id": "two", "url": "https://two.invalid"}, + ], + ) + ], + path=path, + ) + text = path.read_text() + assert "${HOOK_SECRET}" in text and "resolved-secret" not in text + assert yaml.safe_load(text)["outbound_webhooks"]["targets"][-1]["id"] == "two" + + +def test_field_lookup_empty_model_and_raw_dump_path(tmp_path): + class Empty: + model_fields = {} + + assert _field_lookup(Empty) == {} + path = _path(tmp_path, "before\n") + _dump_atomic({}, path, 0o644, raw_text="raw: preserved\n") + assert path.read_text() == "raw: preserved\n" + + +def test_file_lock_rejects_unsafe_directory_mode(tmp_path, monkeypatch): + import os + import tempfile + + import src.config.persistence as persistence + + unsafe_dir = tmp_path / f"odin-config-locks-{os.geteuid()}" + unsafe_dir.mkdir(mode=0o755) + os.chmod(unsafe_dir, 0o755) + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + with pytest.raises(ConfigPersistError, match="unsafe config lock directory"): + persistence.patch_config_paths([(("x",), 1)], path=tmp_path / "target.yml") + + +def test_file_lock_rejects_wrong_lock_file_permissions(tmp_path, monkeypatch): + import os + import tempfile + + import src.config.persistence as persistence + from src.config.migrations import _config_identity + + lockdir = tmp_path / f"odin-config-locks-{os.geteuid()}" + lockdir.mkdir(mode=0o700) + os.chmod(lockdir, 0o700) + (lockdir / _config_identity(tmp_path / "target.yml")).write_text("unsafe") + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + with pytest.raises(ConfigPersistError, match="unsafe config lock file"): + persistence.patch_config_paths([(("x",), 1)], path=tmp_path / "target.yml") + + +def test_delete_first_row_preserves_last_row_section_comment(tmp_path): + from types import SimpleNamespace + + from src.config.persistence import _load_document + + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: remove\n name: Remove\n" + " - id: keep\n name: Keep\n", + ) + doc, _ = _load_document(path) + doc["outbound_webhooks"]["targets"][1].ca.items["name"] = [ + None, + None, + SimpleNamespace(value="\n# trailing section comment\n"), + ] + patch_webhook_targets( + [{"id": "keep", "name": "Keep"}], + changed_fields={}, + delete_ids=["remove"], + path=path, + ) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [ + {"id": "keep", "name": "Keep"} + ] + + +def test_webhook_comment_helpers_skip_empty_slots_and_transfer_comment_list(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n" + " - id: remove\n name: Remove # inline\n" + " - id: keep\n name: Keep # inline\n # trailing section comment\n", + ) + patch_webhook_targets( + [{"id": "keep", "name": "Keep"}], + changed_fields={}, + delete_ids=["remove"], + path=path, + ) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [ + {"id": "keep", "name": "Keep"} + ] + + +def test_patch_paths_empty_change_list_is_noop(tmp_path): + _patch_config_paths([], path=tmp_path / "does-not-exist.yml") + + +def test_persist_config_paths_empty_change_list_is_noop(): + from src.config.persistence import persist_config_paths + + asyncio.run(persist_config_paths([])) + + +def test_webhook_list_merge_handles_scalar_legacy_entry(tmp_path): + path = _path(tmp_path, "outbound_webhooks:\n targets:\n - scalar\n") + _patch_config_paths( + [(("outbound_webhooks", "targets"), [{"id": "new", "url": "https://new.invalid"}])], + path=path, + ) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"] == [ + {"id": "new", "url": "https://new.invalid"} + ] + + +def test_webhook_list_merge_recovers_legacy_row_by_url(tmp_path): + path = _path( + tmp_path, + "outbound_webhooks:\n targets:\n - url: https://legacy.invalid\n name: Old\n", + ) + _patch_config_paths( + [ + ( + ("outbound_webhooks", "targets"), + [{"id": "assigned", "url": "https://legacy.invalid", "name": "New"}], + ) + ], + path=path, + ) + row = yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"][0] + assert row == {"url": "https://legacy.invalid", "name": "New", "id": "assigned"} + + +def test_locked_async_wrapper_normalizes_model_dump_and_settles(tmp_path): + class Row: + def model_dump(self): + return {"id": "one", "name": "One", "url": "https://one.invalid"} + + path = _path(tmp_path) + outcome = asyncio.run( + persist_webhook_targets_locked( + [Row()], changed_fields={"one": (frozenset({"name"}), set())}, path=path + ) + ) + assert outcome == (None, False) + assert yaml.safe_load(path.read_text())["outbound_webhooks"]["targets"][0]["name"] == "One" + + +def test_locked_async_wrapper_normalizes_plain_changed_field_iterable(tmp_path): + path = _path(tmp_path, "outbound_webhooks:\n targets:\n - id: one\n name: Old\n") + outcome = asyncio.run( + persist_webhook_targets_locked( + [{"id": "one", "name": "New"}], changed_fields={"one": ["name"]}, path=path + ) + ) + assert outcome == (None, False) + assert "name: New" in path.read_text() + + +def test_locked_async_wrapper_empty_is_noop(tmp_path): + path = _path(tmp_path) + assert asyncio.run(persist_webhook_targets_locked([], changed_fields={}, path=path)) == ( + None, + False, + ) diff --git a/tests/test_webhook_text.py b/tests/test_webhook_text.py new file mode 100644 index 000000000..b09221deb --- /dev/null +++ b/tests/test_webhook_text.py @@ -0,0 +1,189 @@ +"""Source-span contract: edit only selected row syntax, never its neighbours.""" + +from __future__ import annotations + +import pytest +import yaml + +from src.config.persistence import ConfigPersistError, patch_webhook_targets +from src.config.webhook_text import WebhookTextPatch + + +def _save(tmp_path, text, *, rows=(), fields=None, deletes=()): + path = tmp_path / "config.yml" + path.write_bytes(text.encode()) + patch_webhook_targets(rows, changed_fields=fields or {}, delete_ids=deletes, path=path) + return path.read_bytes().decode() + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +@pytest.mark.parametrize("style", ["block", "flow"]) +def test_update_changes_only_value_tokens_and_preserves_every_other_byte(tmp_path, newline, style): + if style == "block": + row = ( + " - id: one # id\n" + " name: 'Old' # name\n" + ' url: "https://old.invalid/h" # url\n' + " events: [health, alert] # events\n" + " secret: ${SECRET}\n" + ) + else: + row = ( + " - {id: one, name: 'Old', url: \"https://old.invalid/h\", " + "events: [health, alert], secret: '${SECRET}'} # inline\n" + ) + text = ( + "# header\nother: { x: 1, y: 'two' } # untouched\n" + "outbound_webhooks:\n targets:\n" + row + + "\n# tail\nsecond: [a,b]\n" + ).replace("\n", newline) + result = _save(tmp_path, text, rows=[{ + "id": "one", "name": "New", "url": "https://new.invalid/h", + "secret": "must-not-write", "events": ["alert"], + }], fields={"one": {"name", "url", "events"}}) + assert result == text.replace("'Old'", "'New'").replace( + '"https://old.invalid/h"', '"https://new.invalid/h"' + ).replace("[health, alert]", "[alert]") + + +@pytest.mark.parametrize("layout", [ + "outbound_webhooks: {}\n", + "outbound_webhooks:\n", + "outbound_webhooks: null\n", + "outbound_webhooks: {targets: []}\n", + "outbound_webhooks:\n targets: # inline\n", + "outbound_webhooks:\n targets: null # inline\n", + "outbound_webhooks:\n targets: [] # inline\n", + "outbound_webhooks:\n targets: [{id: old, url: https://old.invalid}]\n", + "outbound_webhooks:\n targets:\n - id: old\n url: https://old.invalid\n", + "outbound_webhooks:\n targets:\n - id: old\n events:\n", + "outbound_webhooks:\n targets:\n - id: old\n name: |-\n long name\n", +]) +def test_append_boundaries_with_null_flow_indentless_and_block_scalar(tmp_path, layout): + tail = "\n# trailer\n# another\nother: yes\n" + text = layout + tail + result = _save(tmp_path, text, rows=[{"id": "new", "name": "New"}], fields={"new": {"name"}}) + assert result.endswith(tail) + assert result.index("id: new") < result.index("# trailer") + assert yaml.safe_load(result)["outbound_webhooks"]["targets"][-1] == { + "id": "new", "name": "New", + } + if "# inline" in text: + assert "# inline\n" in result + + +def test_append_to_no_final_newline(tmp_path): + result = _save(tmp_path, "discord: {}", rows=[{"id": "new"}], fields={"new": {"id"}}) + assert result.startswith("discord: {}\noutbound_webhooks:\n") + + +def test_legacy_url_edit_adds_id_without_rewriting_neighbour(tmp_path): + from src.config.persistence import _webhook_identity + + row = {"url": "https://old.invalid", "name": "Old"} + ident = _webhook_identity(row, 0) + text = ( + "outbound_webhooks:\n targets:\n" + " - url: https://old.invalid # inline\n name: 'Old'\n" + " # between\n - {url: 'https://keep.invalid', name: Keep}\n\n# tail\n" + ) + result = _save(tmp_path, text, rows=[{"id": ident, "url": "https://new.invalid"}], + fields={ident: {"url"}}) + assert " name: 'Old'\n" in result + assert result.endswith(text[text.index(" # between"):]) + assert yaml.safe_load(result)["outbound_webhooks"]["targets"][0] == { + "id": ident, "url": "https://new.invalid", "name": "Old", + } + + +def test_atomic_dumper_explicit_sequence_indent_remains_supported(tmp_path): + from src.config.persistence import _dump_atomic + + path = tmp_path / "config.yml" + path.write_text("before: true\n") + _dump_atomic({"rows": [{"id": "one"}]}, path, 0o600, sequence_indent=4) + assert path.read_text() == "rows:\n - id: one\n" + + +@pytest.mark.parametrize("ids", [["one"], ["two"], ["three"], ["one", "two", "three"]]) +def test_delete_flow_sequence_preserves_other_row_bytes(tmp_path, ids): + row_text = { + "one": "{id: one, name: 'One'}", + "two": '{id: two, name: "Two"}', + "three": "{id: three, name: Three}", + } + text = ( + "outbound_webhooks:\n targets: [" + ", ".join(row_text.values()) + "] # inline\n\n# tail\n" + ) + result = _save(tmp_path, text, deletes=ids) + assert result.endswith("] # inline\n\n# tail\n") + assert [row["id"] for row in yaml.safe_load(result)["outbound_webhooks"]["targets"]] == [ + ident for ident in row_text if ident not in ids + ] + for ident, original in row_text.items(): + if ident not in ids: + assert original in result + + +@pytest.mark.parametrize("ident", ["one", "two"]) +def test_flow_delete_uses_separator_token_not_comma_inside_comment(tmp_path, ident): + text = ( + "outbound_webhooks:\n targets: [\n" + " {id: one} # note, not a separator\n" + " , {id: two}\n ]\n\n# tail\n" + ) + result = _save(tmp_path, text, deletes=[ident]) + assert "# note, not a separator\n" in result + assert result.endswith("\n# tail\n") + assert yaml.safe_load(result)["outbound_webhooks"]["targets"] == [ + {"id": "two" if ident == "one" else "one"} + ] + + +@pytest.mark.parametrize("row", [" - {id: one}\n", " - id: one\n"]) +def test_add_missing_field_only_within_selected_row(tmp_path, row): + text = "outbound_webhooks:\n targets:\n" + row + "\n# tail\n" + result = _save(tmp_path, text, rows=[{"id": "one", "name": "New"}], fields={"one": {"name"}}) + assert result.endswith("\n# tail\n") + assert yaml.safe_load(result)["outbound_webhooks"]["targets"] == [{"id": "one", "name": "New"}] + + +@pytest.mark.parametrize("value", ["", "null", "|-\n Old\n", ">-\n Old\n"]) +def test_replace_null_and_block_scalar_does_not_consume_trailer(tmp_path, value): + text = "outbound_webhooks:\n targets:\n - id: one\n name: " + value + "\n# tail\n" + result = _save( + tmp_path, text, rows=[{"id": "one", "name": "Line 1\nLine 2"}], + fields={"one": {"name"}}, + ) + assert result.endswith("\n# tail\n") + assert yaml.safe_load(result)["outbound_webhooks"]["targets"][0]["name"] == "Line 1\nLine 2" + + +@pytest.mark.parametrize("text", [ + "outbound_webhooks: §ion\n targets: []\ncopy: *section\n", + "outbound_webhooks:\n targets: &rows []\ncopy: *rows\n", + "outbound_webhooks:\n targets:\n - &row {id: one}\n - *row\n", + "defaults: &defaults {targets: []}\noutbound_webhooks: *defaults\n", + "defaults: &defaults {targets: []}\noutbound_webhooks:\n <<: *defaults\n", +]) +def test_shared_source_marks_fail_closed_without_writing(tmp_path, text): + path = tmp_path / "config.yml" + path.write_text(text) + with pytest.raises(ConfigPersistError, match="anchored"): + patch_webhook_targets([{"id": "new"}], changed_fields={"new": {"id"}}, path=path) + assert path.read_text() == text + + +@pytest.mark.parametrize("corrupt", ["outbound_webhooks: [", "outbound_webhooks: {targets: []}\n"]) +def test_reparse_mismatch_or_failure_prevents_commit(tmp_path, monkeypatch, corrupt): + text = "outbound_webhooks:\n targets: []\n# tail\n" + path = tmp_path / "config.yml" + path.write_text(text) + + def corrupt_edit(self, _row): + self.text = corrupt + + monkeypatch.setattr(WebhookTextPatch, "append", corrupt_edit) + with pytest.raises(ConfigPersistError, match="source edit"): + patch_webhook_targets([{"id": "new"}], changed_fields={"new": {"id"}}, path=path) + assert path.read_text() == text diff --git a/tests/test_write_invariant_integration.py b/tests/test_write_invariant_integration.py index 532d9f254..fb25f5c9d 100644 --- a/tests/test_write_invariant_integration.py +++ b/tests/test_write_invariant_integration.py @@ -382,6 +382,7 @@ def delayed_finish(lease, status=TurnStatus.TERMINAL_COMPLETED): text, *_ = await asyncio.wait_for(task, timeout=1) assert text.startswith("Task stopped by user.") assert waiter.result().startswith("Task stopped by user.") + assert waiter.result().confirmed is True (status,) = store._conn.execute("SELECT status FROM turns").fetchone() assert status == TurnStatus.TERMINAL_CANCELLED finally: diff --git a/ui/css/style.css b/ui/css/style.css index 8af42e013..d38929087 100644 --- a/ui/css/style.css +++ b/ui/css/style.css @@ -69,6 +69,17 @@ body { /* ================================================================ STATUS INDICATORS ================================================================ */ +.codex-quota-cell { min-width: 190px; max-width: 260px; } +.codex-quota-list { display: grid; gap: 0.45rem; min-width: 175px; } +.codex-quota-block { display: grid; gap: 0.2rem; } +.codex-quota-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; font-size: 0.7rem; } +.codex-quota-heading strong { color: #d1d5db; white-space: nowrap; } +.codex-quota-label { color: #9ca3af; } +.codex-quota-track { height: 5px; overflow: hidden; background: #e5e7eb; border-radius: 9999px; } +.codex-quota-fill { height: 100%; background: #34d399; border-radius: inherit; transition: width 0.2s ease; } +.codex-quota-reset, .codex-quota-observed { color: #6b7280; font-size: 0.65rem; white-space: nowrap; } +.codex-quota-limited, .codex-quota-failure { color: #fbbf24; } +.codex-quota-observed { display: block; margin-top: 0.15rem; } .status-dot { width: 8px; height: 8px; @@ -118,6 +129,28 @@ body { .hm-table tr:hover td { background: rgba(31, 41, 55, 0.3); } +.host-access-table { table-layout: fixed; min-width: 700px; } +.host-access-table th:nth-child(1) { width: 28%; } +.host-access-table th:nth-child(2) { width: 22%; } +.host-access-table th:nth-child(3) { width: 25%; } +.host-access-table th:nth-child(4) { width: 17%; } +.host-access-table th:nth-child(5) { width: 8%; } +.host-chip-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; max-width: 100%; text-align: left; } +.host-default-summary { overflow-wrap: anywhere; } +.host-access-modal { position: fixed; inset: 0; margin: auto; width: min(36rem, calc(100vw - 2rem)); max-width: 100%; max-height: calc(100dvh - 2rem); padding: 0; border: 1px solid var(--hm-border); border-radius: var(--hm-radius-xl); background: var(--hm-surface); color: var(--hm-text); } +.host-access-modal::backdrop { background: rgba(0, 0, 0, 0.65); } +.host-access-modal-form { display: flex; flex-direction: column; max-height: calc(100dvh - 2rem); } +.host-access-modal header, .host-access-modal footer { padding: 1rem 1.5rem; flex-shrink: 0; } +.host-access-modal-body { display: flex; flex-direction: column; gap: 0.75rem; padding: 0 1.5rem 1rem; min-height: 0; overflow-y: auto; } +.host-access-checklist { display: flex; flex-direction: column; gap: 0.65rem; min-height: 5rem; max-height: 35dvh; overflow-y: auto; flex-shrink: 0; overflow-wrap: anywhere; } +.host-access-checklist input { flex-shrink: 0; } +.host-access-checklist input:disabled + span { opacity: 0.55; } +@media (max-width: 640px) { + .host-access-modal { width: 100%; height: 100dvh; max-height: 100dvh; border-radius: 0; border: 0; } + .host-access-modal-form { height: 100%; max-height: 100dvh; } + .host-access-modal-body { flex: 1; } +} +.host-chip { display: inline-block; max-width: 9rem; padding: 0.12rem 0.4rem; border: 1px solid var(--hm-border); border-radius: 4px; font-size: var(--hm-text-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ================================================================ BUTTONS diff --git a/ui/dist/assets/index-0C-lSjnx.js b/ui/dist/assets/index-BoZFf8ax.js similarity index 64% rename from ui/dist/assets/index-0C-lSjnx.js rename to ui/dist/assets/index-BoZFf8ax.js index 02848bf42..270b3a25d 100644 --- a/ui/dist/assets/index-0C-lSjnx.js +++ b/ui/dist/assets/index-BoZFf8ax.js @@ -1,40 +1,40 @@ -var Ob=Object.defineProperty;var Lb=(e,t,s)=>t in e?Ob(e,t,{enumerable:!0,configurable:!0,writable:!0,value:s}):e[t]=s;var Tt=(e,t,s)=>Lb(e,typeof t!="symbol"?t+"":t,s);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))a(n);new MutationObserver(n=>{for(const i of n)if(i.type==="childList")for(const l of i.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&a(l)}).observe(document,{childList:!0,subtree:!0});function s(n){const i={};return n.integrity&&(i.integrity=n.integrity),n.referrerPolicy&&(i.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?i.credentials="include":n.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(n){if(n.ep)return;n.ep=!0;const i=s(n);fetch(n.href,i)}})();class Nb{constructor(){this._persist=localStorage.getItem("odin_persist")==="1",this._token=this._persist?localStorage.getItem("odin_token")||"":sessionStorage.getItem("odin_token")||"";const t=this._persist?localStorage:sessionStorage;this._sessionTimeout=parseInt(t.getItem("odin_session_timeout")||"0",10),this._lastActivity=Date.now(),this._activityTimer=null,this.onSessionExpired=null,this._token&&this._sessionTimeout>0&&this._startActivityMonitor()}get token(){return this._token}get sessionTimeout(){return this._sessionTimeout}setToken(t,s=0){if(this._token=t,this._sessionTimeout=s,this._lastActivity=Date.now(),t){const a=this._persist?localStorage:sessionStorage;a.setItem("odin_token",t),this._persist&&localStorage.setItem("odin_persist","1"),s>0?a.setItem("odin_session_timeout",String(s)):a.removeItem("odin_session_timeout"),this._startActivityMonitor()}else sessionStorage.removeItem("odin_token"),sessionStorage.removeItem("odin_session_timeout"),localStorage.removeItem("odin_token"),localStorage.removeItem("odin_persist"),localStorage.removeItem("odin_session_timeout"),this._stopActivityMonitor()}setPersist(t){this._persist=t}_startActivityMonitor(){this._stopActivityMonitor(),!(this._sessionTimeout<=0)&&(this._activityTimer=setInterval(()=>{(Date.now()-this._lastActivity)/1e3>=this._sessionTimeout&&(this._stopActivityMonitor(),this.onSessionExpired&&this.onSessionExpired())},1e4))}_stopActivityMonitor(){this._activityTimer&&(clearInterval(this._activityTimer),this._activityTimer=null)}_headers(t={}){const s={"Content-Type":"application/json",...t};return this._token&&(s.Authorization=`Bearer ${this._token}`),s}async _request(t,s,a=null,{signal:n}={}){this._lastActivity=Date.now();const i={method:t,headers:this._headers(),signal:n};a!==null&&(i.body=JSON.stringify(a));const l=await fetch(s,i);if(l.status===401)throw new go("Unauthorized");const o=await l.json().catch(()=>null);if(!l.ok){const r=(o==null?void 0:o.error)||`HTTP ${l.status}`;throw new mc(r,l.status,o)}return o}get(t,s={}){return this._request("GET",t,null,s)}async getBlob(t){this._lastActivity=Date.now();const s=await fetch(t,{method:"GET",headers:this._headers()});if(s.status===401)throw new go("Unauthorized");if(!s.ok){const a=await s.json().catch(()=>null);throw new mc((a==null?void 0:a.error)||`HTTP ${s.status}`,s.status,a)}return s.blob()}post(t,s){return this._request("POST",t,s)}async setListenerExposure(t,s){const a=await fetch("/api/setup/listener",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify({expose_beyond_loopback:s})}),n=await a.json().catch(()=>null);if(!a.ok)throw new mc((n==null?void 0:n.error)||"Listener reauthentication failed",a.status,n);return n}put(t,s){return this._request("PUT",t,s)}del(t){return this._request("DELETE",t)}async login(t){const s=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:t})}),a=await s.json().catch(()=>null);if(!s.ok)throw new go((a==null?void 0:a.error)||"Login failed");return this.setToken(a.session_id,a.timeout_seconds||0),a}async logout(){const t=this.post("/api/auth/logout",{});this.setToken("");try{await t}catch{}}async check(){try{return await this.get("/api/status"),{ok:!0,needsAuth:!1}}catch(t){return t instanceof go?{ok:!1,needsAuth:!0}:{ok:!1,needsAuth:!1,error:t.message}}}}class go extends Error{constructor(t){super(t),this.name="AuthError"}}class mc extends Error{constructor(t,s,a){super(t),this.name="ApiError",this.status=s,this.data=a}}class Mb{constructor(t){this._api=t,this._ws=null,this._handlers={logs:[],events:[],chat:[]},this._reconnectDelay=1e3,this._maxReconnectDelay=3e4,this._shouldConnect=!1,this._subscriptions=new Set,this._reconnectAttempt=0,this._reconnectTimer=null,this._lastPongTime=0,this._pingInterval=null,this._forcedRetireTimer=null,this._subscriptionAckTimer=null,this._pendingReconnect=null,this._latency=-1,this._chatPending=!1,this._state="disconnected",this._lifecycle={status:new Set,state:new Set,latency:new Set,reconnected:new Set},this._everConnected=!1,this._reconnectEpoch=0}onStatus(t){return this._addLifecycle("status",t)}onState(t){return this._addLifecycle("state",t)}onLatencyChange(t){return this._addLifecycle("latency",t)}onReconnected(t){return this._addLifecycle("reconnected",t)}_addLifecycle(t,s){return this._lifecycle[t].add(s),()=>{this._lifecycle[t].delete(s)}}_emitLifecycle(t,...s){for(const a of[...this._lifecycle[t]])try{a(...s)}catch{}}get connected(){var t;return((t=this._ws)==null?void 0:t.readyState)===WebSocket.OPEN}get state(){return this._state}get reconnectAttempt(){return this._reconnectAttempt}get latency(){return this._latency}get reconnectEpoch(){return this._reconnectEpoch}_resetLatency(){this._latency=-1,this._emitLifecycle("latency",-1)}connect(){this._shouldConnect=!0,this._setState("connecting"),this._open()}disconnect(){this._shouldConnect=!1,this._everConnected=!1,this._reconnectTimer&&(clearTimeout(this._reconnectTimer),this._reconnectTimer=null),this._forcedRetireTimer&&(clearTimeout(this._forcedRetireTimer),this._forcedRetireTimer=null),this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._pendingReconnect=null,this._reconnectAttempt=0,this._resetLatency(),this._stopPing(),this._ws&&(this._ws.close(),this._ws=null),this._setState("disconnected")}_setState(t){this._state!==t&&(this._state=t,this._emitLifecycle("state",t,{attempt:this._reconnectAttempt,latency:this._latency}))}_startPing(t){this._stopPing(),this._lastPongTime=Date.now(),this._pingInterval=setInterval(()=>{if(!(this._ws!==t||t.readyState!==WebSocket.OPEN)){if(this._lastPongTime&&Date.now()-this._lastPongTime>47e3){this._beginForcedRetirement(t,"pong timeout");return}try{t.send(JSON.stringify({type:"ping",ts:Date.now()}))}catch{}}},15e3)}_beginForcedRetirement(t,s){if(!(this._ws!==t||this._forcedRetireTimer)){this._stopPing(),this._reconnectAttempt++,this._setState("reconnecting"),this._emitLifecycle("status",!1),this._forcedRetireTimer=setTimeout(()=>{this._forcedRetireTimer=null,this._retireSocket(t,!0,!0)},1e3);try{t.close(4e3,s)}catch{}}}_scheduleReconnect(t=!0){!this._shouldConnect||this._reconnectTimer||(t&&this._reconnectAttempt++,this._setState("reconnecting"),this._reconnectTimer=setTimeout(()=>{this._reconnectTimer=null,this._open()},this._reconnectDelay),this._reconnectDelay=Math.min(this._reconnectDelay*2,this._maxReconnectDelay))}_retireSocket(t,s=!1,a=!1){if(this._ws===t){if(this._forcedRetireTimer&&(clearTimeout(this._forcedRetireTimer),this._forcedRetireTimer=null),this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._pendingReconnect=null,this._ws=null,this._stopPing(),this._resetLatency(),this._chatPending){this._chatPending=!1;const n={type:"chat_error",error:"Connection lost — the response may still complete; check session history."};for(const i of this._handlers.chat||[])i(n)}s||this._emitLifecycle("status",!1),this._shouldConnect?this._scheduleReconnect(!a):this._setState("disconnected")}}_beginReconnectBarrier(t,s){if(!s)return;const a=new Set(this._subscriptions);if(a.size===0){this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch);return}this._pendingReconnect={socket:t,channels:a},this._subscriptionAckTimer=setTimeout(()=>{var n;((n=this._pendingReconnect)==null?void 0:n.socket)===t&&this._beginForcedRetirement(t,"subscription acknowledgement timeout")},5e3)}_ackSubscription(t,s){const a=this._pendingReconnect;!a||a.socket!==t||!a.channels.has(s)||(a.channels.delete(s),!(a.channels.size>0)&&(this._pendingReconnect=null,this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch)))}_stopPing(){this._pingInterval&&(clearInterval(this._pingInterval),this._pingInterval=null)}subscribe(t,s){var a;if(this._handlers[t]||(this._handlers[t]=[]),this._handlers[t].push(s),t!=="chat"&&(this._subscriptions.add(t),this.connected)){const n=this._ws;((a=this._pendingReconnect)==null?void 0:a.socket)===n&&this._pendingReconnect.channels.add(t),n.send(JSON.stringify({subscribe:t}))}}unsubscribe(t,s){const a=this._handlers[t];if(a){const n=a.indexOf(s);if(n>=0&&a.splice(n,1),a.length===0&&t!=="chat"&&(this._subscriptions.delete(t),this.connected)){const i=this._ws;i.send(JSON.stringify({unsubscribe:t})),this._ackSubscription(i,t)}}}on(t,s){return this.subscribe(t,s)}off(t,s){return this.unsubscribe(t,s)}sendChat(t,{channelId:s,userId:a,username:n}={}){return this.connected?(this._ws.send(JSON.stringify({type:"chat",content:t,channel_id:s||"web-default",user_id:a||void 0,username:n||void 0})),this._chatPending=!0,!0):!1}_open(){if(this._ws||!this._shouldConnect)return;const s=`${location.protocol==="https:"?"wss:":"ws:"}//${location.host}/api/ws`,a=this._api.token?["odin.bearer."+btoa(this._api.token).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")]:void 0,n=a?new WebSocket(s,a):new WebSocket(s);this._ws=n;const i=()=>this._ws===n;n.onopen=()=>{if(!i())return;const l=this._everConnected;this._everConnected=!0,this._reconnectDelay=1e3,this._reconnectAttempt=0;for(const o of this._subscriptions)n.send(JSON.stringify({subscribe:o}));this._startPing(n),this._setState("connected"),this._emitLifecycle("status",!0),this._beginReconnectBarrier(n,l)},n.onmessage=l=>{if(!i())return;let o;try{o=JSON.parse(l.data)}catch{return}const r=o.type;if(r==="pong"){o.ts&&(this._latency=Date.now()-o.ts,this._lastPongTime=Date.now(),this._emitLifecycle("latency",this._latency));return}if(r==="subscribed"){this._ackSubscription(n,o.channel);return}if(r==="log")for(const c of this._handlers.logs||[])c(o);else if(r==="event")for(const c of this._handlers.events||[])c(o);else if(r==="chat_response"||r==="chat_error"){this._chatPending=!1;for(const c of this._handlers.chat||[])c(o)}},n.onclose=()=>{const l=!!this._forcedRetireTimer;this._retireSocket(n,l,l)},n.onerror=()=>{}}}const z=new Nb,ut=new Mb(z);/** +var Bb=Object.defineProperty;var Ub=(e,t,s)=>t in e?Bb(e,t,{enumerable:!0,configurable:!0,writable:!0,value:s}):e[t]=s;var Tt=(e,t,s)=>Ub(e,typeof t!="symbol"?t+"":t,s);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))a(n);new MutationObserver(n=>{for(const i of n)if(i.type==="childList")for(const l of i.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&a(l)}).observe(document,{childList:!0,subtree:!0});function s(n){const i={};return n.integrity&&(i.integrity=n.integrity),n.referrerPolicy&&(i.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?i.credentials="include":n.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(n){if(n.ep)return;n.ep=!0;const i=s(n);fetch(n.href,i)}})();class zb{constructor(){this._persist=localStorage.getItem("odin_persist")==="1",this._token=this._persist?localStorage.getItem("odin_token")||"":sessionStorage.getItem("odin_token")||"";const t=this._persist?localStorage:sessionStorage;this._sessionTimeout=parseInt(t.getItem("odin_session_timeout")||"0",10),this._lastActivity=Date.now(),this._activityTimer=null,this.onSessionExpired=null,this._token&&this._sessionTimeout>0&&this._startActivityMonitor()}get token(){return this._token}get sessionTimeout(){return this._sessionTimeout}setToken(t,s=0){if(this._token=t,this._sessionTimeout=s,this._lastActivity=Date.now(),t){const a=this._persist?localStorage:sessionStorage;a.setItem("odin_token",t),this._persist&&localStorage.setItem("odin_persist","1"),s>0?a.setItem("odin_session_timeout",String(s)):a.removeItem("odin_session_timeout"),this._startActivityMonitor()}else sessionStorage.removeItem("odin_token"),sessionStorage.removeItem("odin_session_timeout"),localStorage.removeItem("odin_token"),localStorage.removeItem("odin_persist"),localStorage.removeItem("odin_session_timeout"),this._stopActivityMonitor()}setPersist(t){this._persist=t}_startActivityMonitor(){this._stopActivityMonitor(),!(this._sessionTimeout<=0)&&(this._activityTimer=setInterval(()=>{(Date.now()-this._lastActivity)/1e3>=this._sessionTimeout&&(this._stopActivityMonitor(),this.onSessionExpired&&this.onSessionExpired())},1e4))}_stopActivityMonitor(){this._activityTimer&&(clearInterval(this._activityTimer),this._activityTimer=null)}_headers(t={}){const s={"Content-Type":"application/json",...t};return this._token&&(s.Authorization=`Bearer ${this._token}`),s}async _request(t,s,a=null,{signal:n}={}){this._lastActivity=Date.now();const i={method:t,headers:this._headers(),signal:n};a!==null&&(i.body=JSON.stringify(a));const l=await fetch(s,i);if(l.status===401)throw new bo("Unauthorized");const o=await l.json().catch(()=>null);if(!l.ok){const r=(o==null?void 0:o.error)||`HTTP ${l.status}`;throw new yc(r,l.status,o)}return o}get(t,s={}){return this._request("GET",t,null,s)}async getBlob(t){this._lastActivity=Date.now();const s=await fetch(t,{method:"GET",headers:this._headers()});if(s.status===401)throw new bo("Unauthorized");if(!s.ok){const a=await s.json().catch(()=>null);throw new yc((a==null?void 0:a.error)||`HTTP ${s.status}`,s.status,a)}return s.blob()}post(t,s){return this._request("POST",t,s)}async setListenerExposure(t,s){const a=await fetch("/api/setup/listener",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify({expose_beyond_loopback:s})}),n=await a.json().catch(()=>null);if(!a.ok)throw new yc((n==null?void 0:n.error)||"Listener reauthentication failed",a.status,n);return n}put(t,s){return this._request("PUT",t,s)}del(t){return this._request("DELETE",t)}async login(t){const s=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:t})}),a=await s.json().catch(()=>null);if(!s.ok)throw new bo((a==null?void 0:a.error)||"Login failed");return this.setToken(a.session_id,a.timeout_seconds||0),a}async logout(){const t=this.post("/api/auth/logout",{});this.setToken("");try{await t}catch{}}async check(){try{return await this.get("/api/status"),{ok:!0,needsAuth:!1}}catch(t){return t instanceof bo?{ok:!1,needsAuth:!0}:{ok:!1,needsAuth:!1,error:t.message}}}}class bo extends Error{constructor(t){super(t),this.name="AuthError"}}class yc extends Error{constructor(t,s,a){super(t),this.name="ApiError",this.status=s,this.data=a}}class Hb{constructor(t){this._api=t,this._ws=null,this._handlers={logs:[],events:[],chat:[]},this._reconnectDelay=1e3,this._maxReconnectDelay=3e4,this._shouldConnect=!1,this._subscriptions=new Set,this._reconnectAttempt=0,this._reconnectTimer=null,this._lastPongTime=0,this._pingInterval=null,this._forcedRetireTimer=null,this._subscriptionAckTimer=null,this._pendingReconnect=null,this._latency=-1,this._chatPending=!1,this._state="disconnected",this._lifecycle={status:new Set,state:new Set,latency:new Set,reconnected:new Set},this._everConnected=!1,this._reconnectEpoch=0}onStatus(t){return this._addLifecycle("status",t)}onState(t){return this._addLifecycle("state",t)}onLatencyChange(t){return this._addLifecycle("latency",t)}onReconnected(t){return this._addLifecycle("reconnected",t)}_addLifecycle(t,s){return this._lifecycle[t].add(s),()=>{this._lifecycle[t].delete(s)}}_emitLifecycle(t,...s){for(const a of[...this._lifecycle[t]])try{a(...s)}catch{}}get connected(){var t;return((t=this._ws)==null?void 0:t.readyState)===WebSocket.OPEN}get state(){return this._state}get reconnectAttempt(){return this._reconnectAttempt}get latency(){return this._latency}get reconnectEpoch(){return this._reconnectEpoch}_resetLatency(){this._latency=-1,this._emitLifecycle("latency",-1)}connect(){this._shouldConnect=!0,this._setState("connecting"),this._open()}disconnect(){this._shouldConnect=!1,this._everConnected=!1,this._reconnectTimer&&(clearTimeout(this._reconnectTimer),this._reconnectTimer=null),this._forcedRetireTimer&&(clearTimeout(this._forcedRetireTimer),this._forcedRetireTimer=null),this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._pendingReconnect=null,this._reconnectAttempt=0,this._resetLatency(),this._stopPing(),this._ws&&(this._ws.close(),this._ws=null),this._setState("disconnected")}_setState(t){this._state!==t&&(this._state=t,this._emitLifecycle("state",t,{attempt:this._reconnectAttempt,latency:this._latency}))}_startPing(t){this._stopPing(),this._lastPongTime=Date.now(),this._pingInterval=setInterval(()=>{if(!(this._ws!==t||t.readyState!==WebSocket.OPEN)){if(this._lastPongTime&&Date.now()-this._lastPongTime>47e3){this._beginForcedRetirement(t,"pong timeout");return}try{t.send(JSON.stringify({type:"ping",ts:Date.now()}))}catch{}}},15e3)}_beginForcedRetirement(t,s){if(!(this._ws!==t||this._forcedRetireTimer)){this._stopPing(),this._reconnectAttempt++,this._setState("reconnecting"),this._emitLifecycle("status",!1),this._forcedRetireTimer=setTimeout(()=>{this._forcedRetireTimer=null,this._retireSocket(t,!0,!0)},1e3);try{t.close(4e3,s)}catch{}}}_scheduleReconnect(t=!0){!this._shouldConnect||this._reconnectTimer||(t&&this._reconnectAttempt++,this._setState("reconnecting"),this._reconnectTimer=setTimeout(()=>{this._reconnectTimer=null,this._open()},this._reconnectDelay),this._reconnectDelay=Math.min(this._reconnectDelay*2,this._maxReconnectDelay))}_retireSocket(t,s=!1,a=!1){if(this._ws===t){if(this._forcedRetireTimer&&(clearTimeout(this._forcedRetireTimer),this._forcedRetireTimer=null),this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._pendingReconnect=null,this._ws=null,this._stopPing(),this._resetLatency(),this._chatPending){this._chatPending=!1;const n={type:"chat_error",error:"Connection lost — the response may still complete; check session history."};for(const i of this._handlers.chat||[])i(n)}s||this._emitLifecycle("status",!1),this._shouldConnect?this._scheduleReconnect(!a):this._setState("disconnected")}}_beginReconnectBarrier(t,s){if(!s)return;const a=new Set(this._subscriptions);if(a.size===0){this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch);return}this._pendingReconnect={socket:t,channels:a},this._subscriptionAckTimer=setTimeout(()=>{var n;((n=this._pendingReconnect)==null?void 0:n.socket)===t&&this._beginForcedRetirement(t,"subscription acknowledgement timeout")},5e3)}_ackSubscription(t,s){const a=this._pendingReconnect;!a||a.socket!==t||!a.channels.has(s)||(a.channels.delete(s),!(a.channels.size>0)&&(this._pendingReconnect=null,this._subscriptionAckTimer&&(clearTimeout(this._subscriptionAckTimer),this._subscriptionAckTimer=null),this._reconnectEpoch+=1,this._emitLifecycle("reconnected",this._reconnectEpoch)))}_stopPing(){this._pingInterval&&(clearInterval(this._pingInterval),this._pingInterval=null)}subscribe(t,s){var a;if(this._handlers[t]||(this._handlers[t]=[]),this._handlers[t].push(s),t!=="chat"&&(this._subscriptions.add(t),this.connected)){const n=this._ws;((a=this._pendingReconnect)==null?void 0:a.socket)===n&&this._pendingReconnect.channels.add(t),n.send(JSON.stringify({subscribe:t}))}}unsubscribe(t,s){const a=this._handlers[t];if(a){const n=a.indexOf(s);if(n>=0&&a.splice(n,1),a.length===0&&t!=="chat"&&(this._subscriptions.delete(t),this.connected)){const i=this._ws;i.send(JSON.stringify({unsubscribe:t})),this._ackSubscription(i,t)}}}on(t,s){return this.subscribe(t,s)}off(t,s){return this.unsubscribe(t,s)}sendChat(t,{channelId:s,userId:a,username:n}={}){return this.connected?(this._ws.send(JSON.stringify({type:"chat",content:t,channel_id:s||"web-default",user_id:a||void 0,username:n||void 0})),this._chatPending=!0,!0):!1}_open(){if(this._ws||!this._shouldConnect)return;const s=`${location.protocol==="https:"?"wss:":"ws:"}//${location.host}/api/ws`,a=this._api.token?["odin.bearer."+btoa(this._api.token).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")]:void 0,n=a?new WebSocket(s,a):new WebSocket(s);this._ws=n;const i=()=>this._ws===n;n.onopen=()=>{if(!i())return;const l=this._everConnected;this._everConnected=!0,this._reconnectDelay=1e3,this._reconnectAttempt=0;for(const o of this._subscriptions)n.send(JSON.stringify({subscribe:o}));this._startPing(n),this._setState("connected"),this._emitLifecycle("status",!0),this._beginReconnectBarrier(n,l)},n.onmessage=l=>{if(!i())return;let o;try{o=JSON.parse(l.data)}catch{return}const r=o.type;if(r==="pong"){o.ts&&(this._latency=Date.now()-o.ts,this._lastPongTime=Date.now(),this._emitLifecycle("latency",this._latency));return}if(r==="subscribed"){this._ackSubscription(n,o.channel);return}if(r==="log")for(const c of this._handlers.logs||[])c(o);else if(r==="event")for(const c of this._handlers.events||[])c(o);else if(r==="chat_response"||r==="chat_error"){this._chatPending=!1;for(const c of this._handlers.chat||[])c(o)}},n.onclose=()=>{const l=!!this._forcedRetireTimer;this._retireSocket(n,l,l)},n.onerror=()=>{}}}const z=new zb,ut=new Hb(z);/** * @vue/shared v3.5.38 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT -**/function Ys(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const it={},Ai=[],hs=()=>{},Ci=()=>!1,ei=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Er=e=>e.startsWith("onUpdate:"),nt=Object.assign,Ld=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},Db=Object.prototype.hasOwnProperty,ht=(e,t)=>Db.call(e,t),Ie=Array.isArray,Ri=e=>Zi(e)==="[object Map]",ti=e=>Zi(e)==="[object Set]",Xu=e=>Zi(e)==="[object Date]",Pb=e=>Zi(e)==="[object RegExp]",je=e=>typeof e=="function",Ye=e=>typeof e=="string",Cs=e=>typeof e=="symbol",mt=e=>e!==null&&typeof e=="object",Nd=e=>(mt(e)||je(e))&&je(e.then)&&je(e.catch),um=Object.prototype.toString,Zi=e=>um.call(e),$b=e=>Zi(e).slice(8,-1),Ar=e=>Zi(e)==="[object Object]",Rr=e=>Ye(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Za=Ys(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Fb=Ys("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),Ir=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},Bb=/-\w/g,wt=Ir(e=>e.replace(Bb,t=>t.slice(1).toUpperCase())),Ub=/\B([A-Z])/g,Us=Ir(e=>e.replace(Ub,"-$1").toLowerCase()),si=Ir(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ii=Ir(e=>e?`on${si(e)}`:""),is=(e,t)=>!Object.is(e,t),Oi=(e,...t)=>{for(let s=0;s{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:a,value:s})},Or=e=>{const t=parseFloat(e);return isNaN(t)?e:t},Zo=e=>{const t=Ye(e)?Number(e):NaN;return isNaN(t)?e:t};let ep;const Lr=()=>ep||(ep=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function zb(e,t){return e+JSON.stringify(t,(s,a)=>typeof a=="function"?a.toString():a)}const Hb="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol",jb=Ys(Hb);function to(e){if(Ie(e)){const t={};for(let s=0;s{if(s){const a=s.split(qb);a.length>1&&(t[a[0].trim()]=a[1].trim())}}),t}function so(e){let t="";if(Ye(e))t=e;else if(Ie(e))for(let s=0;sen(s,t))}const hm=e=>!!(e&&e.__v_isRef===!0),vm=e=>Ye(e)?e:e==null?"":Ie(e)||mt(e)&&(e.toString===um||!je(e.toString))?hm(e)?vm(e.value):JSON.stringify(e,gm,2):String(e),gm=(e,t)=>hm(t)?gm(e,t.value):Ri(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[a,n],i)=>(s[hc(a,i)+" =>"]=n,s),{})}:ti(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>hc(s))}:Cs(t)?hc(t):mt(t)&&!Ie(t)&&!Ar(t)?String(t):t,hc=(e,t="")=>{var s;return Cs(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};function iy(e){return e==null?"initial":typeof e=="string"?e===""?" ":e:String(e)}/** +**/function Qs(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const it={},Ri=[],hs=()=>{},Ti=()=>!1,ti=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Lr=e=>e.startsWith("onUpdate:"),nt=Object.assign,Fd=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},jb=Object.prototype.hasOwnProperty,ht=(e,t)=>jb.call(e,t),Le=Array.isArray,Ii=e=>Qi(e)==="[object Map]",si=e=>Qi(e)==="[object Set]",ip=e=>Qi(e)==="[object Date]",qb=e=>Qi(e)==="[object RegExp]",je=e=>typeof e=="function",Qe=e=>typeof e=="string",Cs=e=>typeof e=="symbol",mt=e=>e!==null&&typeof e=="object",Bd=e=>(mt(e)||je(e))&&je(e.then)&&je(e.catch),gm=Object.prototype.toString,Qi=e=>gm.call(e),Vb=e=>Qi(e).slice(8,-1),Nr=e=>Qi(e)==="[object Object]",Mr=e=>Qe(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Za=Qs(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Gb=Qs("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),Dr=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},Wb=/-\w/g,wt=Dr(e=>e.replace(Wb,t=>t.slice(1).toUpperCase())),Kb=/\B([A-Z])/g,Us=Dr(e=>e.replace(Kb,"-$1").toLowerCase()),ai=Dr(e=>e.charAt(0).toUpperCase()+e.slice(1)),Oi=Dr(e=>e?`on${ai(e)}`:""),is=(e,t)=>!Object.is(e,t),Li=(e,...t)=>{for(let s=0;s{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:a,value:s})},Pr=e=>{const t=parseFloat(e);return isNaN(t)?e:t},tr=e=>{const t=Qe(e)?Number(e):NaN;return isNaN(t)?e:t};let lp;const $r=()=>lp||(lp=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Jb(e,t){return e+JSON.stringify(t,(s,a)=>typeof a=="function"?a.toString():a)}const Zb="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol",Qb=Qs(Zb);function so(e){if(Le(e)){const t={};for(let s=0;s{if(s){const a=s.split(Xb);a.length>1&&(t[a[0].trim()]=a[1].trim())}}),t}function ao(e){let t="";if(Qe(e))t=e;else if(Le(e))for(let s=0;sen(s,t))}const _m=e=>!!(e&&e.__v_isRef===!0),wm=e=>Qe(e)?e:e==null?"":Le(e)||mt(e)&&(e.toString===gm||!je(e.toString))?_m(e)?wm(e.value):JSON.stringify(e,km,2):String(e),km=(e,t)=>_m(t)?km(e,t.value):Ii(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[a,n],i)=>(s[xc(a,i)+" =>"]=n,s),{})}:si(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>xc(s))}:Cs(t)?xc(t):mt(t)&&!Le(t)&&!Nr(t)?String(t):t,xc=(e,t="")=>{var s;return Cs(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};function fy(e){return e==null?"initial":typeof e=="string"?e===""?" ":e:String(e)}/** * @vue/reactivity v3.5.38 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT -**/let ts;class Md{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&ts&&(ts.active?(this.parent=ts,this.index=(ts.scopes||(ts.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t0&&--this._on===0){if(ts===this)ts=this.prevScope;else{let t=ts;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,a;for(s=0,a=this.effects.length;s0)return;if(Cl){let t=Cl;for(Cl=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Sl;){let t=Sl;for(Sl=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(a){e||(e=a)}t=s}}if(e)throw e}function _m(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function wm(e){let t,s=e.depsTail,a=s;for(;a;){const n=a.prevDep;a.version===-1?(a===s&&(s=n),$d(a),ry(a)):t=a,a.dep.activeLink=a.prevActiveLink,a.prevActiveLink=void 0,a=n}e.deps=t,e.depsTail=s}function Gc(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(km(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function km(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Dl)||(e.globalVersion=Dl,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Gc(e))))return;e.flags|=2;const t=e.dep,s=Rt,a=ma;Rt=e,ma=!0;try{_m(e);const n=e.fn(e._value);(t.version===0||is(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(n){throw t.version++,n}finally{Rt=s,ma=a,wm(e),e.flags&=-3}}function $d(e,t=!1){const{dep:s,prevSub:a,nextSub:n}=e;if(a&&(a.nextSub=n,e.prevSub=void 0),n&&(n.prevSub=a,e.nextSub=void 0),s.subs===e&&(s.subs=a,!a&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)$d(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function ry(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}function cy(e,t){e.effect instanceof Ml&&(e=e.effect.fn);const s=new Ml(e);t&&nt(s,t);try{s.run()}catch(n){throw s.stop(),n}const a=s.run.bind(s);return a.effect=s,a}function dy(e){e.effect.stop()}let ma=!0;const Sm=[];function tn(){Sm.push(ma),ma=!1}function sn(){const e=Sm.pop();ma=e===void 0?!0:e}function tp(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=Rt;Rt=void 0;try{t()}finally{Rt=s}}}let Dl=0;class uy{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Mr{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!Rt||!ma||Rt===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==Rt)s=this.activeLink=new uy(Rt,this),Rt.deps?(s.prevDep=Rt.depsTail,Rt.depsTail.nextDep=s,Rt.depsTail=s):Rt.deps=Rt.depsTail=s,Cm(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const a=s.nextDep;a.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=a),s.prevDep=Rt.depsTail,s.nextDep=void 0,Rt.depsTail.nextDep=s,Rt.depsTail=s,Rt.deps===s&&(Rt.deps=a)}return s}trigger(t){this.version++,Dl++,this.notify(t)}notify(t){Dd();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{Pd()}}}function Cm(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let a=t.deps;a;a=a.nextDep)Cm(a)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const Yo=new WeakMap,qn=Symbol(""),Wc=Symbol(""),Pl=Symbol("");function ws(e,t,s){if(ma&&Rt){let a=Yo.get(e);a||Yo.set(e,a=new Map);let n=a.get(s);n||(a.set(s,n=new Mr),n.map=a,n.key=s),n.track()}}function qa(e,t,s,a,n,i){const l=Yo.get(e);if(!l){Dl++;return}const o=r=>{r&&r.trigger()};if(Dd(),t==="clear")l.forEach(o);else{const r=Ie(e),c=r&&Rr(s);if(r&&s==="length"){const d=Number(a);l.forEach((u,f)=>{(f==="length"||f===Pl||!Cs(f)&&f>=d)&&o(u)})}else switch((s!==void 0||l.has(void 0))&&o(l.get(s)),c&&o(l.get(Pl)),t){case"add":r?c&&o(l.get("length")):(o(l.get(qn)),Ri(e)&&o(l.get(Wc)));break;case"delete":r||(o(l.get(qn)),Ri(e)&&o(l.get(Wc)));break;case"set":Ri(e)&&o(l.get(qn));break}}Pd()}function py(e,t){const s=Yo.get(e);return s&&s.get(t)}function hi(e){const t=dt(e);return t===e?t:(ws(t,"iterate",Pl),Hs(e)?t:t.map(va))}function Dr(e){return ws(e=dt(e),"iterate",Pl),e}function Aa(e,t){return Ia(e)?Fi(Ya(e)?va(t):t):va(t)}const fy={__proto__:null,[Symbol.iterator](){return gc(this,Symbol.iterator,e=>Aa(this,e))},concat(...e){return hi(this).concat(...e.map(t=>Ie(t)?hi(t):t))},entries(){return gc(this,"entries",e=>(e[1]=Aa(this,e[1]),e))},every(e,t){return $a(this,"every",e,t,void 0,arguments)},filter(e,t){return $a(this,"filter",e,t,s=>s.map(a=>Aa(this,a)),arguments)},find(e,t){return $a(this,"find",e,t,s=>Aa(this,s),arguments)},findIndex(e,t){return $a(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return $a(this,"findLast",e,t,s=>Aa(this,s),arguments)},findLastIndex(e,t){return $a(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return $a(this,"forEach",e,t,void 0,arguments)},includes(...e){return bc(this,"includes",e)},indexOf(...e){return bc(this,"indexOf",e)},join(e){return hi(this).join(e)},lastIndexOf(...e){return bc(this,"lastIndexOf",e)},map(e,t){return $a(this,"map",e,t,void 0,arguments)},pop(){return rl(this,"pop")},push(...e){return rl(this,"push",e)},reduce(e,...t){return sp(this,"reduce",e,t)},reduceRight(e,...t){return sp(this,"reduceRight",e,t)},shift(){return rl(this,"shift")},some(e,t){return $a(this,"some",e,t,void 0,arguments)},splice(...e){return rl(this,"splice",e)},toReversed(){return hi(this).toReversed()},toSorted(e){return hi(this).toSorted(e)},toSpliced(...e){return hi(this).toSpliced(...e)},unshift(...e){return rl(this,"unshift",e)},values(){return gc(this,"values",e=>Aa(this,e))}};function gc(e,t,s){const a=Dr(e),n=a[t]();return a!==e&&!Hs(e)&&(n._next=n.next,n.next=()=>{const i=n._next();return i.done||(i.value=s(i.value)),i}),n}const my=Array.prototype;function $a(e,t,s,a,n,i){const l=Dr(e),o=l!==e&&!Hs(e),r=l[t];if(r!==my[t]){const u=r.apply(e,i);return o?va(u):u}let c=s;l!==e&&(o?c=function(u,f){return s.call(this,Aa(e,u),f,e)}:s.length>2&&(c=function(u,f){return s.call(this,u,f,e)}));const d=r.call(l,c,a);return o&&n?n(d):d}function sp(e,t,s,a){const n=Dr(e),i=n!==e&&!Hs(e);let l=s,o=!1;n!==e&&(i?(o=a.length===0,l=function(c,d,u){return o&&(o=!1,c=Aa(e,c)),s.call(this,c,Aa(e,d),u,e)}):s.length>3&&(l=function(c,d,u){return s.call(this,c,d,u,e)}));const r=n[t](l,...a);return o?Aa(e,r):r}function bc(e,t,s){const a=dt(e);ws(a,"iterate",Pl);const n=a[t](...s);return(n===-1||n===!1)&&ao(s[0])?(s[0]=dt(s[0]),a[t](...s)):n}function rl(e,t,s=[]){tn(),Dd();const a=dt(e)[t].apply(e,s);return Pd(),sn(),a}const hy=Ys("__proto__,__v_isRef,__isVue"),Tm=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Cs));function vy(e){Cs(e)||(e=String(e));const t=dt(this);return ws(t,"has",e),t.hasOwnProperty(e)}class Em{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,a){if(s==="__v_skip")return t.__v_skip;const n=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!n;if(s==="__v_isReadonly")return n;if(s==="__v_isShallow")return i;if(s==="__v_raw")return a===(n?i?Nm:Lm:i?Om:Im).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(a)?t:void 0;const l=Ie(t);if(!n){let r;if(l&&(r=fy[s]))return r;if(s==="hasOwnProperty")return vy}const o=Reflect.get(t,s,Gt(t)?t:a);if((Cs(s)?Tm.has(s):hy(s))||(n||ws(t,"get",s),i))return o;if(Gt(o)){const r=l&&Rr(s)?o:o.value;return n&&mt(r)?Qo(r):r}return mt(o)?n?Qo(o):Tn(o):o}}class Am extends Em{constructor(t=!1){super(!1,t)}set(t,s,a,n){let i=t[s];const l=Ie(t)&&Rr(s);if(!this._isShallow){const c=Ia(i);if(!Hs(a)&&!Ia(a)&&(i=dt(i),a=dt(a)),!l&&Gt(i)&&!Gt(a))return c||(i.value=a),!0}const o=l?Number(s)e,bo=e=>Reflect.getPrototypeOf(e);function _y(e,t,s){return function(...a){const n=this.__v_raw,i=dt(n),l=Ri(i),o=e==="entries"||e===Symbol.iterator&&l,r=e==="keys"&&l,c=n[e](...a),d=s?Kc:t?Fi:va;return!t&&ws(i,"iterate",r?Wc:qn),nt(Object.create(c),{next(){const{value:u,done:f}=c.next();return f?{value:u,done:f}:{value:o?[d(u[0]),d(u[1])]:d(u),done:f}}})}}function yo(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function wy(e,t){const s={get(n){const i=this.__v_raw,l=dt(i),o=dt(n);e||(is(n,o)&&ws(l,"get",n),ws(l,"get",o));const{has:r}=bo(l),c=t?Kc:e?Fi:va;if(r.call(l,n))return c(i.get(n));if(r.call(l,o))return c(i.get(o));i!==l&&i.get(n)},get size(){const n=this.__v_raw;return!e&&ws(dt(n),"iterate",qn),n.size},has(n){const i=this.__v_raw,l=dt(i),o=dt(n);return e||(is(n,o)&&ws(l,"has",n),ws(l,"has",o)),n===o?i.has(n):i.has(n)||i.has(o)},forEach(n,i){const l=this,o=l.__v_raw,r=dt(o),c=t?Kc:e?Fi:va;return!e&&ws(r,"iterate",qn),o.forEach((d,u)=>n.call(i,c(d),c(u),l))}};return nt(s,e?{add:yo("add"),set:yo("set"),delete:yo("delete"),clear:yo("clear")}:{add(n){const i=dt(this),l=bo(i),o=dt(n),r=!t&&!Hs(n)&&!Ia(n)?o:n;return l.has.call(i,r)||is(n,r)&&l.has.call(i,n)||is(o,r)&&l.has.call(i,o)||(i.add(r),qa(i,"add",r,r)),this},set(n,i){!t&&!Hs(i)&&!Ia(i)&&(i=dt(i));const l=dt(this),{has:o,get:r}=bo(l);let c=o.call(l,n);c||(n=dt(n),c=o.call(l,n));const d=r.call(l,n);return l.set(n,i),c?is(i,d)&&qa(l,"set",n,i):qa(l,"add",n,i),this},delete(n){const i=dt(this),{has:l,get:o}=bo(i);let r=l.call(i,n);r||(n=dt(n),r=l.call(i,n)),o&&o.call(i,n);const c=i.delete(n);return r&&qa(i,"delete",n,void 0),c},clear(){const n=dt(this),i=n.size!==0,l=n.clear();return i&&qa(n,"clear",void 0,void 0),l}}),["keys","values","entries",Symbol.iterator].forEach(n=>{s[n]=_y(n,e,t)}),s}function Pr(e,t){const s=wy(e,t);return(a,n,i)=>n==="__v_isReactive"?!e:n==="__v_isReadonly"?e:n==="__v_raw"?a:Reflect.get(ht(s,n)&&n in a?s:a,n,i)}const ky={get:Pr(!1,!1)},Sy={get:Pr(!1,!0)},Cy={get:Pr(!0,!1)},Ty={get:Pr(!0,!0)},Im=new WeakMap,Om=new WeakMap,Lm=new WeakMap,Nm=new WeakMap;function Ey(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Tn(e){return Ia(e)?e:$r(e,!1,gy,ky,Im)}function Fd(e){return $r(e,!1,yy,Sy,Om)}function Qo(e){return $r(e,!0,by,Cy,Lm)}function Ay(e){return $r(e,!0,xy,Ty,Nm)}function $r(e,t,s,a,n){if(!mt(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=n.get(e);if(i)return i;const l=Ey($b(e));if(l===0)return e;const o=new Proxy(e,l===2?a:s);return n.set(e,o),o}function Ya(e){return Ia(e)?Ya(e.__v_raw):!!(e&&e.__v_isReactive)}function Ia(e){return!!(e&&e.__v_isReadonly)}function Hs(e){return!!(e&&e.__v_isShallow)}function ao(e){return e?!!e.__v_raw:!1}function dt(e){const t=e&&e.__v_raw;return t?dt(t):e}function Mm(e){return!ht(e,"__v_skip")&&Object.isExtensible(e)&&pm(e,"__v_skip",!0),e}const va=e=>mt(e)?Tn(e):e,Fi=e=>mt(e)?Qo(e):e;function Gt(e){return e?e.__v_isRef===!0:!1}function p(e){return Dm(e,!1)}function Bd(e){return Dm(e,!0)}function Dm(e,t){return Gt(e)?e:new Ry(e,t)}class Ry{constructor(t,s){this.dep=new Mr,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:dt(t),this._value=s?t:va(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,a=this.__v_isShallow||Hs(t)||Ia(t);t=a?t:dt(t),is(t,s)&&(this._rawValue=t,this._value=a?t:va(t),this.dep.trigger())}}function Iy(e){e.dep&&e.dep.trigger()}function Ra(e){return Gt(e)?e.value:e}function Oy(e){return je(e)?e():Ra(e)}const Ly={get:(e,t,s)=>t==="__v_raw"?e:Ra(Reflect.get(e,t,s)),set:(e,t,s,a)=>{const n=e[t];return Gt(n)&&!Gt(s)?(n.value=s,!0):Reflect.set(e,t,s,a)}};function Ud(e){return Ya(e)?e:new Proxy(e,Ly)}class Ny{constructor(t){this.__v_isRef=!0,this._value=void 0;const s=this.dep=new Mr,{get:a,set:n}=t(s.track.bind(s),s.trigger.bind(s));this._get=a,this._set=n}get value(){return this._value=this._get()}set value(t){this._set(t)}}function Pm(e){return new Ny(e)}function My(e){const t=Ie(e)?new Array(e.length):{};for(const s in e)t[s]=$m(e,s);return t}class Dy{constructor(t,s,a){this._object=t,this._defaultValue=a,this.__v_isRef=!0,this._value=void 0,this._key=Cs(s)?s:String(s),this._raw=dt(t);let n=!0,i=t;if(!Ie(t)||Cs(this._key)||!Rr(this._key))do n=!ao(i)||Hs(i);while(n&&(i=i.__v_raw));this._shallow=n}get value(){let t=this._object[this._key];return this._shallow&&(t=Ra(t)),this._value=t===void 0?this._defaultValue:t}set value(t){if(this._shallow&&Gt(this._raw[this._key])){const s=this._object[this._key];if(Gt(s)){s.value=t;return}}this._object[this._key]=t}get dep(){return py(this._raw,this._key)}}class Py{constructor(t){this._getter=t,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}function $y(e,t,s){return Gt(e)?e:je(e)?new Py(e):mt(e)&&arguments.length>1?$m(e,t,s):p(e)}function $m(e,t,s){return new Dy(e,t,s)}class Fy{constructor(t,s,a){this.fn=t,this.setter=s,this._value=void 0,this.dep=new Mr(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Dl-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=a}notify(){if(this.flags|=16,!(this.flags&8)&&Rt!==this)return xm(this,!0),!0}get value(){const t=this.dep.track();return km(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function By(e,t,s=!1){let a,n;return je(e)?a=e:(a=e.get,n=e.set),new Fy(a,n,s)}const Uy={GET:"get",HAS:"has",ITERATE:"iterate"},zy={SET:"set",ADD:"add",DELETE:"delete",CLEAR:"clear"},xo={},Xo=new WeakMap;let bn;function Hy(){return bn}function Fm(e,t=!1,s=bn){if(s){let a=Xo.get(s);a||Xo.set(s,a=[]),a.push(e)}}function jy(e,t,s=it){const{immediate:a,deep:n,once:i,scheduler:l,augmentJob:o,call:r}=s,c=x=>n?x:Hs(x)||n===!1||n===0?Ga(x,1):Ga(x);let d,u,f,m,h=!1,b=!1;if(Gt(e)?(u=()=>e.value,h=Hs(e)):Ya(e)?(u=()=>c(e),h=!0):Ie(e)?(b=!0,h=e.some(x=>Ya(x)||Hs(x)),u=()=>e.map(x=>{if(Gt(x))return x.value;if(Ya(x))return c(x);if(je(x))return r?r(x,2):x()})):je(e)?t?u=r?()=>r(e,2):e:u=()=>{if(f){tn();try{f()}finally{sn()}}const x=bn;bn=d;try{return r?r(e,3,[m]):e(m)}finally{bn=x}}:u=hs,t&&n){const x=u,S=n===!0?1/0:n;u=()=>Ga(x(),S)}const I=bm(),k=()=>{d.stop(),I&&I.active&&Ld(I.effects,d)};if(i&&t){const x=t;t=(...S)=>{const _=x(...S);return k(),_}}let y=b?new Array(e.length).fill(xo):xo;const g=x=>{if(!(!(d.flags&1)||!d.dirty&&!x))if(t){const S=d.run();if(x||n||h||(b?S.some((_,A)=>is(_,y[A])):is(S,y))){f&&f();const _=bn;bn=d;try{const A=[S,y===xo?void 0:b&&y[0]===xo?[]:y,m];y=S,r?r(t,3,A):t(...A)}finally{bn=_}}}else d.run()};return o&&o(g),d=new Ml(u),d.scheduler=l?()=>l(g,!1):g,m=x=>Fm(x,!1,d),f=d.onStop=()=>{const x=Xo.get(d);if(x){if(r)r(x,4);else for(const S of x)S();Xo.delete(d)}},t?a?g(!0):y=d.run():l?l(g.bind(null,!0),!0):d.run(),k.pause=d.pause.bind(d),k.resume=d.resume.bind(d),k.stop=k,k}function Ga(e,t=1/0,s){if(t<=0||!mt(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,Gt(e))Ga(e.value,t,s);else if(Ie(e))for(let a=0;a{Ga(a,t,s)});else if(Ar(e)){for(const a in e)Ga(e[a],t,s);for(const a of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,a)&&Ga(e[a],t,s)}return e}/** +**/let ts;class Ud{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&ts&&(ts.active?(this.parent=ts,this.index=(ts.scopes||(ts.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t0&&--this._on===0){if(ts===this)ts=this.prevScope;else{let t=ts;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,a;for(s=0,a=this.effects.length;s0)return;if(Tl){let t=Tl;for(Tl=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Cl;){let t=Cl;for(Cl=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(a){e||(e=a)}t=s}}if(e)throw e}function Em(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Am(e){let t,s=e.depsTail,a=s;for(;a;){const n=a.prevDep;a.version===-1?(a===s&&(s=n),jd(a),vy(a)):t=a,a.dep.activeLink=a.prevActiveLink,a.prevActiveLink=void 0,a=n}e.deps=t,e.depsTail=s}function Qc(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Rm(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Rm(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Pl)||(e.globalVersion=Pl,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Qc(e))))return;e.flags|=2;const t=e.dep,s=Rt,a=ma;Rt=e,ma=!0;try{Em(e);const n=e.fn(e._value);(t.version===0||is(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(n){throw t.version++,n}finally{Rt=s,ma=a,Am(e),e.flags&=-3}}function jd(e,t=!1){const{dep:s,prevSub:a,nextSub:n}=e;if(a&&(a.nextSub=n,e.prevSub=void 0),n&&(n.prevSub=a,e.nextSub=void 0),s.subs===e&&(s.subs=a,!a&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)jd(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function vy(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}function gy(e,t){e.effect instanceof Dl&&(e=e.effect.fn);const s=new Dl(e);t&&nt(s,t);try{s.run()}catch(n){throw s.stop(),n}const a=s.run.bind(s);return a.effect=s,a}function by(e){e.effect.stop()}let ma=!0;const Im=[];function tn(){Im.push(ma),ma=!1}function sn(){const e=Im.pop();ma=e===void 0?!0:e}function op(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=Rt;Rt=void 0;try{t()}finally{Rt=s}}}let Pl=0;class yy{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Br{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!Rt||!ma||Rt===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==Rt)s=this.activeLink=new yy(Rt,this),Rt.deps?(s.prevDep=Rt.depsTail,Rt.depsTail.nextDep=s,Rt.depsTail=s):Rt.deps=Rt.depsTail=s,Om(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const a=s.nextDep;a.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=a),s.prevDep=Rt.depsTail,s.nextDep=void 0,Rt.depsTail.nextDep=s,Rt.depsTail=s,Rt.deps===s&&(Rt.deps=a)}return s}trigger(t){this.version++,Pl++,this.notify(t)}notify(t){zd();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{Hd()}}}function Om(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let a=t.deps;a;a=a.nextDep)Om(a)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const sr=new WeakMap,Gn=Symbol(""),Yc=Symbol(""),$l=Symbol("");function ws(e,t,s){if(ma&&Rt){let a=sr.get(e);a||sr.set(e,a=new Map);let n=a.get(s);n||(a.set(s,n=new Br),n.map=a,n.key=s),n.track()}}function Va(e,t,s,a,n,i){const l=sr.get(e);if(!l){Pl++;return}const o=r=>{r&&r.trigger()};if(zd(),t==="clear")l.forEach(o);else{const r=Le(e),c=r&&Mr(s);if(r&&s==="length"){const d=Number(a);l.forEach((u,f)=>{(f==="length"||f===$l||!Cs(f)&&f>=d)&&o(u)})}else switch((s!==void 0||l.has(void 0))&&o(l.get(s)),c&&o(l.get($l)),t){case"add":r?c&&o(l.get("length")):(o(l.get(Gn)),Ii(e)&&o(l.get(Yc)));break;case"delete":r||(o(l.get(Gn)),Ii(e)&&o(l.get(Yc)));break;case"set":Ii(e)&&o(l.get(Gn));break}}Hd()}function xy(e,t){const s=sr.get(e);return s&&s.get(t)}function vi(e){const t=dt(e);return t===e?t:(ws(t,"iterate",$l),Hs(e)?t:t.map(va))}function Ur(e){return ws(e=dt(e),"iterate",$l),e}function Aa(e,t){return Ia(e)?Bi(Qa(e)?va(t):t):va(t)}const _y={__proto__:null,[Symbol.iterator](){return wc(this,Symbol.iterator,e=>Aa(this,e))},concat(...e){return vi(this).concat(...e.map(t=>Le(t)?vi(t):t))},entries(){return wc(this,"entries",e=>(e[1]=Aa(this,e[1]),e))},every(e,t){return $a(this,"every",e,t,void 0,arguments)},filter(e,t){return $a(this,"filter",e,t,s=>s.map(a=>Aa(this,a)),arguments)},find(e,t){return $a(this,"find",e,t,s=>Aa(this,s),arguments)},findIndex(e,t){return $a(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return $a(this,"findLast",e,t,s=>Aa(this,s),arguments)},findLastIndex(e,t){return $a(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return $a(this,"forEach",e,t,void 0,arguments)},includes(...e){return kc(this,"includes",e)},indexOf(...e){return kc(this,"indexOf",e)},join(e){return vi(this).join(e)},lastIndexOf(...e){return kc(this,"lastIndexOf",e)},map(e,t){return $a(this,"map",e,t,void 0,arguments)},pop(){return cl(this,"pop")},push(...e){return cl(this,"push",e)},reduce(e,...t){return rp(this,"reduce",e,t)},reduceRight(e,...t){return rp(this,"reduceRight",e,t)},shift(){return cl(this,"shift")},some(e,t){return $a(this,"some",e,t,void 0,arguments)},splice(...e){return cl(this,"splice",e)},toReversed(){return vi(this).toReversed()},toSorted(e){return vi(this).toSorted(e)},toSpliced(...e){return vi(this).toSpliced(...e)},unshift(...e){return cl(this,"unshift",e)},values(){return wc(this,"values",e=>Aa(this,e))}};function wc(e,t,s){const a=Ur(e),n=a[t]();return a!==e&&!Hs(e)&&(n._next=n.next,n.next=()=>{const i=n._next();return i.done||(i.value=s(i.value)),i}),n}const wy=Array.prototype;function $a(e,t,s,a,n,i){const l=Ur(e),o=l!==e&&!Hs(e),r=l[t];if(r!==wy[t]){const u=r.apply(e,i);return o?va(u):u}let c=s;l!==e&&(o?c=function(u,f){return s.call(this,Aa(e,u),f,e)}:s.length>2&&(c=function(u,f){return s.call(this,u,f,e)}));const d=r.call(l,c,a);return o&&n?n(d):d}function rp(e,t,s,a){const n=Ur(e),i=n!==e&&!Hs(e);let l=s,o=!1;n!==e&&(i?(o=a.length===0,l=function(c,d,u){return o&&(o=!1,c=Aa(e,c)),s.call(this,c,Aa(e,d),u,e)}):s.length>3&&(l=function(c,d,u){return s.call(this,c,d,u,e)}));const r=n[t](l,...a);return o?Aa(e,r):r}function kc(e,t,s){const a=dt(e);ws(a,"iterate",$l);const n=a[t](...s);return(n===-1||n===!1)&&no(s[0])?(s[0]=dt(s[0]),a[t](...s)):n}function cl(e,t,s=[]){tn(),zd();const a=dt(e)[t].apply(e,s);return Hd(),sn(),a}const ky=Qs("__proto__,__v_isRef,__isVue"),Lm=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Cs));function Sy(e){Cs(e)||(e=String(e));const t=dt(this);return ws(t,"has",e),t.hasOwnProperty(e)}class Nm{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,a){if(s==="__v_skip")return t.__v_skip;const n=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!n;if(s==="__v_isReadonly")return n;if(s==="__v_isShallow")return i;if(s==="__v_raw")return a===(n?i?Bm:Fm:i?$m:Pm).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(a)?t:void 0;const l=Le(t);if(!n){let r;if(l&&(r=_y[s]))return r;if(s==="hasOwnProperty")return Sy}const o=Reflect.get(t,s,Wt(t)?t:a);if((Cs(s)?Lm.has(s):ky(s))||(n||ws(t,"get",s),i))return o;if(Wt(o)){const r=l&&Mr(s)?o:o.value;return n&&mt(r)?ar(r):r}return mt(o)?n?ar(o):Tn(o):o}}class Mm extends Nm{constructor(t=!1){super(!1,t)}set(t,s,a,n){let i=t[s];const l=Le(t)&&Mr(s);if(!this._isShallow){const c=Ia(i);if(!Hs(a)&&!Ia(a)&&(i=dt(i),a=dt(a)),!l&&Wt(i)&&!Wt(a))return c||(i.value=a),!0}const o=l?Number(s)e,yo=e=>Reflect.getPrototypeOf(e);function Ry(e,t,s){return function(...a){const n=this.__v_raw,i=dt(n),l=Ii(i),o=e==="entries"||e===Symbol.iterator&&l,r=e==="keys"&&l,c=n[e](...a),d=s?Xc:t?Bi:va;return!t&&ws(i,"iterate",r?Yc:Gn),nt(Object.create(c),{next(){const{value:u,done:f}=c.next();return f?{value:u,done:f}:{value:o?[d(u[0]),d(u[1])]:d(u),done:f}}})}}function xo(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Iy(e,t){const s={get(n){const i=this.__v_raw,l=dt(i),o=dt(n);e||(is(n,o)&&ws(l,"get",n),ws(l,"get",o));const{has:r}=yo(l),c=t?Xc:e?Bi:va;if(r.call(l,n))return c(i.get(n));if(r.call(l,o))return c(i.get(o));i!==l&&i.get(n)},get size(){const n=this.__v_raw;return!e&&ws(dt(n),"iterate",Gn),n.size},has(n){const i=this.__v_raw,l=dt(i),o=dt(n);return e||(is(n,o)&&ws(l,"has",n),ws(l,"has",o)),n===o?i.has(n):i.has(n)||i.has(o)},forEach(n,i){const l=this,o=l.__v_raw,r=dt(o),c=t?Xc:e?Bi:va;return!e&&ws(r,"iterate",Gn),o.forEach((d,u)=>n.call(i,c(d),c(u),l))}};return nt(s,e?{add:xo("add"),set:xo("set"),delete:xo("delete"),clear:xo("clear")}:{add(n){const i=dt(this),l=yo(i),o=dt(n),r=!t&&!Hs(n)&&!Ia(n)?o:n;return l.has.call(i,r)||is(n,r)&&l.has.call(i,n)||is(o,r)&&l.has.call(i,o)||(i.add(r),Va(i,"add",r,r)),this},set(n,i){!t&&!Hs(i)&&!Ia(i)&&(i=dt(i));const l=dt(this),{has:o,get:r}=yo(l);let c=o.call(l,n);c||(n=dt(n),c=o.call(l,n));const d=r.call(l,n);return l.set(n,i),c?is(i,d)&&Va(l,"set",n,i):Va(l,"add",n,i),this},delete(n){const i=dt(this),{has:l,get:o}=yo(i);let r=l.call(i,n);r||(n=dt(n),r=l.call(i,n)),o&&o.call(i,n);const c=i.delete(n);return r&&Va(i,"delete",n,void 0),c},clear(){const n=dt(this),i=n.size!==0,l=n.clear();return i&&Va(n,"clear",void 0,void 0),l}}),["keys","values","entries",Symbol.iterator].forEach(n=>{s[n]=Ry(n,e,t)}),s}function zr(e,t){const s=Iy(e,t);return(a,n,i)=>n==="__v_isReactive"?!e:n==="__v_isReadonly"?e:n==="__v_raw"?a:Reflect.get(ht(s,n)&&n in a?s:a,n,i)}const Oy={get:zr(!1,!1)},Ly={get:zr(!1,!0)},Ny={get:zr(!0,!1)},My={get:zr(!0,!0)},Pm=new WeakMap,$m=new WeakMap,Fm=new WeakMap,Bm=new WeakMap;function Dy(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Tn(e){return Ia(e)?e:Hr(e,!1,Cy,Oy,Pm)}function qd(e){return Hr(e,!1,Ey,Ly,$m)}function ar(e){return Hr(e,!0,Ty,Ny,Fm)}function Py(e){return Hr(e,!0,Ay,My,Bm)}function Hr(e,t,s,a,n){if(!mt(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=n.get(e);if(i)return i;const l=Dy(Vb(e));if(l===0)return e;const o=new Proxy(e,l===2?a:s);return n.set(e,o),o}function Qa(e){return Ia(e)?Qa(e.__v_raw):!!(e&&e.__v_isReactive)}function Ia(e){return!!(e&&e.__v_isReadonly)}function Hs(e){return!!(e&&e.__v_isShallow)}function no(e){return e?!!e.__v_raw:!1}function dt(e){const t=e&&e.__v_raw;return t?dt(t):e}function Um(e){return!ht(e,"__v_skip")&&Object.isExtensible(e)&&bm(e,"__v_skip",!0),e}const va=e=>mt(e)?Tn(e):e,Bi=e=>mt(e)?ar(e):e;function Wt(e){return e?e.__v_isRef===!0:!1}function p(e){return zm(e,!1)}function Vd(e){return zm(e,!0)}function zm(e,t){return Wt(e)?e:new $y(e,t)}class $y{constructor(t,s){this.dep=new Br,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:dt(t),this._value=s?t:va(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,a=this.__v_isShallow||Hs(t)||Ia(t);t=a?t:dt(t),is(t,s)&&(this._rawValue=t,this._value=a?t:va(t),this.dep.trigger())}}function Fy(e){e.dep&&e.dep.trigger()}function Ra(e){return Wt(e)?e.value:e}function By(e){return je(e)?e():Ra(e)}const Uy={get:(e,t,s)=>t==="__v_raw"?e:Ra(Reflect.get(e,t,s)),set:(e,t,s,a)=>{const n=e[t];return Wt(n)&&!Wt(s)?(n.value=s,!0):Reflect.set(e,t,s,a)}};function Gd(e){return Qa(e)?e:new Proxy(e,Uy)}class zy{constructor(t){this.__v_isRef=!0,this._value=void 0;const s=this.dep=new Br,{get:a,set:n}=t(s.track.bind(s),s.trigger.bind(s));this._get=a,this._set=n}get value(){return this._value=this._get()}set value(t){this._set(t)}}function Hm(e){return new zy(e)}function Hy(e){const t=Le(e)?new Array(e.length):{};for(const s in e)t[s]=jm(e,s);return t}class jy{constructor(t,s,a){this._object=t,this._defaultValue=a,this.__v_isRef=!0,this._value=void 0,this._key=Cs(s)?s:String(s),this._raw=dt(t);let n=!0,i=t;if(!Le(t)||Cs(this._key)||!Mr(this._key))do n=!no(i)||Hs(i);while(n&&(i=i.__v_raw));this._shallow=n}get value(){let t=this._object[this._key];return this._shallow&&(t=Ra(t)),this._value=t===void 0?this._defaultValue:t}set value(t){if(this._shallow&&Wt(this._raw[this._key])){const s=this._object[this._key];if(Wt(s)){s.value=t;return}}this._object[this._key]=t}get dep(){return xy(this._raw,this._key)}}class qy{constructor(t){this._getter=t,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}}function Vy(e,t,s){return Wt(e)?e:je(e)?new qy(e):mt(e)&&arguments.length>1?jm(e,t,s):p(e)}function jm(e,t,s){return new jy(e,t,s)}class Gy{constructor(t,s,a){this.fn=t,this.setter=s,this._value=void 0,this.dep=new Br(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Pl-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=a}notify(){if(this.flags|=16,!(this.flags&8)&&Rt!==this)return Tm(this,!0),!0}get value(){const t=this.dep.track();return Rm(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Wy(e,t,s=!1){let a,n;return je(e)?a=e:(a=e.get,n=e.set),new Gy(a,n,s)}const Ky={GET:"get",HAS:"has",ITERATE:"iterate"},Jy={SET:"set",ADD:"add",DELETE:"delete",CLEAR:"clear"},_o={},nr=new WeakMap;let bn;function Zy(){return bn}function qm(e,t=!1,s=bn){if(s){let a=nr.get(s);a||nr.set(s,a=[]),a.push(e)}}function Qy(e,t,s=it){const{immediate:a,deep:n,once:i,scheduler:l,augmentJob:o,call:r}=s,c=x=>n?x:Hs(x)||n===!1||n===0?Ga(x,1):Ga(x);let d,u,f,m,h=!1,g=!1;if(Wt(e)?(u=()=>e.value,h=Hs(e)):Qa(e)?(u=()=>c(e),h=!0):Le(e)?(g=!0,h=e.some(x=>Qa(x)||Hs(x)),u=()=>e.map(x=>{if(Wt(x))return x.value;if(Qa(x))return c(x);if(je(x))return r?r(x,2):x()})):je(e)?t?u=r?()=>r(e,2):e:u=()=>{if(f){tn();try{f()}finally{sn()}}const x=bn;bn=d;try{return r?r(e,3,[m]):e(m)}finally{bn=x}}:u=hs,t&&n){const x=u,S=n===!0?1/0:n;u=()=>Ga(x(),S)}const A=Sm(),w=()=>{d.stop(),A&&A.active&&Fd(A.effects,d)};if(i&&t){const x=t;t=(...S)=>{const _=x(...S);return w(),_}}let y=g?new Array(e.length).fill(_o):_o;const b=x=>{if(!(!(d.flags&1)||!d.dirty&&!x))if(t){const S=d.run();if(x||n||h||(g?S.some((_,T)=>is(_,y[T])):is(S,y))){f&&f();const _=bn;bn=d;try{const T=[S,y===_o?void 0:g&&y[0]===_o?[]:y,m];y=S,r?r(t,3,T):t(...T)}finally{bn=_}}}else d.run()};return o&&o(b),d=new Dl(u),d.scheduler=l?()=>l(b,!1):b,m=x=>qm(x,!1,d),f=d.onStop=()=>{const x=nr.get(d);if(x){if(r)r(x,4);else for(const S of x)S();nr.delete(d)}},t?a?b(!0):y=d.run():l?l(b.bind(null,!0),!0):d.run(),w.pause=d.pause.bind(d),w.resume=d.resume.bind(d),w.stop=w,w}function Ga(e,t=1/0,s){if(t<=0||!mt(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,Wt(e))Ga(e.value,t,s);else if(Le(e))for(let a=0;a{Ga(a,t,s)});else if(Nr(e)){for(const a in e)Ga(e[a],t,s);for(const a of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,a)&&Ga(e[a],t,s)}return e}/** * @vue/runtime-core v3.5.38 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT -**/const Bm=[];function Vy(e){Bm.push(e)}function qy(){Bm.pop()}function Gy(e,t){}const Wy={SETUP_FUNCTION:0,0:"SETUP_FUNCTION",RENDER_FUNCTION:1,1:"RENDER_FUNCTION",NATIVE_EVENT_HANDLER:5,5:"NATIVE_EVENT_HANDLER",COMPONENT_EVENT_HANDLER:6,6:"COMPONENT_EVENT_HANDLER",VNODE_HOOK:7,7:"VNODE_HOOK",DIRECTIVE_HOOK:8,8:"DIRECTIVE_HOOK",TRANSITION_HOOK:9,9:"TRANSITION_HOOK",APP_ERROR_HANDLER:10,10:"APP_ERROR_HANDLER",APP_WARN_HANDLER:11,11:"APP_WARN_HANDLER",FUNCTION_REF:12,12:"FUNCTION_REF",ASYNC_COMPONENT_LOADER:13,13:"ASYNC_COMPONENT_LOADER",SCHEDULER:14,14:"SCHEDULER",COMPONENT_UPDATE:15,15:"COMPONENT_UPDATE",APP_UNMOUNT_CLEANUP:16,16:"APP_UNMOUNT_CLEANUP"},Ky={sp:"serverPrefetch hook",bc:"beforeCreate hook",c:"created hook",bm:"beforeMount hook",m:"mounted hook",bu:"beforeUpdate hook",u:"updated",bum:"beforeUnmount hook",um:"unmounted hook",a:"activated hook",da:"deactivated hook",ec:"errorCaptured hook",rtc:"renderTracked hook",rtg:"renderTriggered hook",0:"setup function",1:"render function",2:"watcher getter",3:"watcher callback",4:"watcher cleanup function",5:"native event handler",6:"component event handler",7:"vnode hook",8:"directive hook",9:"transition hook",10:"app errorHandler",11:"app warnHandler",12:"ref function",13:"async component loader",14:"scheduler flush",15:"component update",16:"app unmount cleanup function"};function Yi(e,t,s,a){try{return a?e(...a):e()}catch(n){ai(n,t,s)}}function Zs(e,t,s,a){if(je(e)){const n=Yi(e,t,s,a);return n&&Nd(n)&&n.catch(i=>{ai(i,t,s)}),n}if(Ie(e)){const n=[];for(let i=0;i>>1,n=Is[a],i=Fl(n);i=Fl(s)?Is.push(e):Is.splice(Zy(t),0,e),e.flags|=1,zm()}}function zm(){er||(er=Um.then(Hm))}function $l(e){Ie(e)?Li.push(...e):yn&&e.id===-1?yn.splice(_i+1,0,e):e.flags&1||(Li.push(e),e.flags|=1),zm()}function ap(e,t,s=Ta+1){for(;sFl(s)-Fl(a));if(Li.length=0,yn){yn.push(...t);return}for(yn=t,_i=0;_ie.id==null?e.flags&2?-1:1/0:e.id;function Hm(e){try{for(Ta=0;Tawi.emit(n,...i)),_o=[]):typeof window<"u"&&window.HTMLElement&&!((a=(s=window.navigator)==null?void 0:s.userAgent)!=null&&a.includes("jsdom"))?((t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(i=>{jm(i,t)}),setTimeout(()=>{wi||(t.__VUE_DEVTOOLS_HOOK_REPLAY__=null,_o=[])},3e3)):_o=[]}let ms=null,Fr=null;function Bl(e){const t=ms;return ms=e,Fr=e&&e.type.__scopeId||null,t}function Yy(e){Fr=e}function Qy(){Fr=null}const Xy=e=>Hd;function Hd(e,t=ms,s){if(!t||e._n)return e;const a=(...n)=>{a._d&&jl(-1);const i=Bl(t);let l;try{l=e(...n)}finally{Bl(i),a._d&&jl(1)}return l};return a._n=!0,a._c=!0,a._d=!0,a}function ex(e,t){if(ms===null)return e;const s=oo(ms),a=e.dirs||(e.dirs=[]);for(let n=0;n1)return s&&je(t)?t.call(a&&a.proxy):t}}function tx(){return!!(Ls()||Gn)}const Vm=Symbol.for("v-scx"),qm=()=>na(Vm);function sx(e,t){return no(e,null,t)}function ax(e,t){return no(e,null,{flush:"post"})}function Gm(e,t){return no(e,null,{flush:"sync"})}function Wt(e,t,s){return no(e,t,s)}function no(e,t,s=it){const{immediate:a,deep:n,flush:i,once:l}=s,o=nt({},s),r=t&&a||!t&&i!=="post";let c;if(Yn){if(i==="sync"){const m=qm();c=m.__watcherHandles||(m.__watcherHandles=[])}else if(!r){const m=()=>{};return m.stop=hs,m.resume=hs,m.pause=hs,m}}const d=fs;o.call=(m,h,b)=>Zs(m,d,h,b);let u=!1;i==="post"?o.scheduler=m=>{Vt(m,d&&d.suspense)}:i!=="sync"&&(u=!0,o.scheduler=(m,h)=>{h?m():zd(m)}),o.augmentJob=m=>{t&&(m.flags|=4),u&&(m.flags|=2,d&&(m.id=d.uid,m.i=d))};const f=jy(e,t,o);return Yn&&(c?c.push(f):r&&f()),f}function nx(e,t,s){const a=this.proxy,n=Ye(e)?e.includes(".")?Wm(a,e):()=>a[e]:e.bind(a,a);let i;je(t)?i=t:(i=t.handler,s=t);const l=Qi(this),o=no(n,i.bind(a),s);return l(),o}function Wm(e,t){const s=t.split(".");return()=>{let a=e;for(let n=0;ne.__isTeleport,zn=e=>e&&(e.disabled||e.disabled===""),ix=e=>e&&(e.defer||e.defer===""),np=e=>typeof SVGElement<"u"&&e instanceof SVGElement,ip=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,Jc=(e,t)=>{const s=e&&e.to;return Ye(s)?t?t(s):null:s},lx={name:"Teleport",__isTeleport:!0,process(e,t,s,a,n,i,l,o,r,c){const{mc:d,pc:u,pbc:f,o:{insert:m,querySelector:h,createText:b,createComment:I,parentNode:k}}=c,y=zn(t.props);let{dynamicChildren:g}=t;const x=(A,R,w)=>{A.shapeFlag&16&&d(A.children,R,w,n,i,l,o,r)},S=(A=t)=>{const R=zn(A.props),w=A.target=Jc(A.props,h),O=Zc(w,A,b,m);w&&(l!=="svg"&&np(w)?l="svg":l!=="mathml"&&ip(w)&&(l="mathml"),n&&n.isCE&&(n.ce._teleportTargets||(n.ce._teleportTargets=new Set)).add(w),R||(x(A,w,O),yl(A,!1)))},_=A=>{const R=()=>{if(vn.get(A)===R){if(vn.delete(A),zn(A.props)){const w=k(A.el)||s;x(A,w,A.anchor),yl(A,!0)}S(A)}};vn.set(A,R),Vt(R,i)};if(e==null){const A=t.el=b(""),R=t.anchor=b("");if(m(A,s,a),m(R,s,a),ix(t.props)||i&&i.pendingBranch){_(t);return}y&&(x(t,s,R),yl(t,!0)),S()}else{t.el=e.el;const A=t.anchor=e.anchor,R=vn.get(e);if(R){R.flags|=8,vn.delete(e),_(t);return}t.targetStart=e.targetStart;const w=t.target=e.target,O=t.targetAnchor=e.targetAnchor,B=zn(e.props),T=B?s:w,$=B?A:O;if(l==="svg"||np(w)?l="svg":(l==="mathml"||ip(w))&&(l="mathml"),g?(f(e.dynamicChildren,g,T,n,i,l,o),Xd(e,t,!0)):r||u(e,t,T,$,n,i,l,o,!1),y)B?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):wo(t,s,A,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const Q=t.target=Jc(t.props,h);Q&&wo(t,Q,null,c,0)}else B&&wo(t,w,O,c,1);yl(t,y)}},remove(e,t,s,{um:a,o:{remove:n}},i){const{shapeFlag:l,children:o,anchor:r,targetStart:c,targetAnchor:d,target:u,props:f}=e,m=i||!zn(f),h=vn.get(e);if(h&&(h.flags|=8,vn.delete(e)),u&&(n(c),n(d)),i&&n(r),!h&&l&16)for(let b=0;b{e.isMounted=!0}),Hr(()=>{e.isUnmounting=!0}),e}const Xs=[Function,Array],Vd={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:Xs,onEnter:Xs,onAfterEnter:Xs,onEnterCancelled:Xs,onBeforeLeave:Xs,onLeave:Xs,onAfterLeave:Xs,onLeaveCancelled:Xs,onBeforeAppear:Xs,onAppear:Xs,onAfterAppear:Xs,onAppearCancelled:Xs},Zm=e=>{const t=e.subTree;return t.component?Zm(t.component):t},cx={name:"BaseTransition",props:Vd,setup(e,{slots:t}){const s=Ls(),a=jd();return()=>{const n=t.default&&Br(t.default(),!0),i=n&&n.length?Ym(n):s.subTree?Nh():void 0;if(!i)return;const l=dt(e),{mode:o}=l;if(a.isLeaving)return yc(i);const r=lp(i);if(!r)return yc(i);let c=Bi(r,l,a,s,u=>c=u);r.type!==Ht&&an(r,c);let d=s.subTree&&lp(s.subTree);if(d&&d.type!==Ht&&!fa(d,r)&&Zm(s).type!==Ht){let u=Bi(d,l,a,s);if(an(d,u),o==="out-in"&&r.type!==Ht)return a.isLeaving=!0,u.afterLeave=()=>{a.isLeaving=!1,s.job.flags&8||s.update(),delete u.afterLeave,d=void 0},yc(i);o==="in-out"&&r.type!==Ht?u.delayLeave=(f,m,h)=>{const b=Xm(a,d);b[String(d.key)]=d,f[ea]=()=>{m(),f[ea]=void 0,delete c.delayedLeave,d=void 0},c.delayedLeave=()=>{h(),delete c.delayedLeave,d=void 0}}:d=void 0}else d&&(d=void 0);return i}}};function Ym(e){let t=e[0];if(e.length>1){for(const s of e)if(s.type!==Ht){t=s;break}}return t}const Qm=cx;function Xm(e,t){const{leavingVNodes:s}=e;let a=s.get(t.type);return a||(a=Object.create(null),s.set(t.type,a)),a}function Bi(e,t,s,a,n){const{appear:i,mode:l,persisted:o=!1,onBeforeEnter:r,onEnter:c,onAfterEnter:d,onEnterCancelled:u,onBeforeLeave:f,onLeave:m,onAfterLeave:h,onLeaveCancelled:b,onBeforeAppear:I,onAppear:k,onAfterAppear:y,onAppearCancelled:g}=t,x=String(e.key),S=Xm(s,e),_=(w,O)=>{w&&Zs(w,a,9,O)},A=(w,O)=>{const B=O[1];_(w,O),Ie(w)?w.every(T=>T.length<=1)&&B():w.length<=1&&B()},R={mode:l,persisted:o,beforeEnter(w){let O=r;if(!s.isMounted)if(i)O=I||r;else return;w[ea]&&w[ea](!0);const B=S[x];B&&fa(e,B)&&B.el[ea]&&B.el[ea](),_(O,[w])},enter(w){if(S[x]===e)return;let O=c,B=d,T=u;if(!s.isMounted)if(i)O=k||c,B=y||d,T=g||u;else return;let $=!1;w[cl]=W=>{$||($=!0,W?_(T,[w]):_(B,[w]),R.delayedLeave&&R.delayedLeave(),w[cl]=void 0)};const Q=w[cl].bind(null,!1);O?A(O,[w,Q]):Q()},leave(w,O){const B=String(e.key);if(w[cl]&&w[cl](!0),s.isUnmounting)return O();_(f,[w]);let T=!1;w[ea]=Q=>{T||(T=!0,O(),Q?_(b,[w]):_(h,[w]),w[ea]=void 0,S[B]===e&&delete S[B])};const $=w[ea].bind(null,!1);S[B]=e,m?A(m,[w,$]):$()},clone(w){const O=Bi(w,t,s,a,n);return n&&n(O),O}};return R}function yc(e){if(lo(e))return e=Oa(e),e.children=null,e}function lp(e){if(!lo(e))return Jm(e.type)&&e.children?Ym(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:s}=e;if(s){if(t&16)return s[0];if(t&32&&je(s.default))return s.default()}}function an(e,t){e.shapeFlag&6&&e.component?(e.transition=t,an(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Br(e,t=!1,s){let a=[],n=0;for(let i=0;i1)for(let i=0;is.value,set:i=>s.value=i})}return s}function op(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const sr=new WeakMap;function Ni(e,t,s,a,n=!1){if(Ie(e)){e.forEach((b,I)=>Ni(b,t&&(Ie(t)?t[I]:t),s,a,n));return}if(Qa(a)&&!n){a.shapeFlag&512&&a.type.__asyncResolved&&a.component.subTree.component&&Ni(e,t,s,a.component.subTree);return}const i=a.shapeFlag&4?oo(a.component):a.el,l=n?null:i,{i:o,r}=e,c=t&&t.r,d=o.refs===it?o.refs={}:o.refs,u=o.setupState,f=dt(u),m=u===it?Ci:b=>op(d,b)?!1:ht(f,b),h=(b,I)=>!(I&&op(d,I));if(c!=null&&c!==r){if(rp(t),Ye(c))d[c]=null,m(c)&&(u[c]=null);else if(Gt(c)){const b=t;h(c,b.k)&&(c.value=null),b.k&&(d[b.k]=null)}}if(je(r))Yi(r,o,12,[l,d]);else{const b=Ye(r),I=Gt(r);if(b||I){const k=()=>{if(e.f){const y=b?m(r)?u[r]:d[r]:h()||!e.k?r.value:d[e.k];if(n)Ie(y)&&Ld(y,i);else if(Ie(y))y.includes(i)||y.push(i);else if(b)d[r]=[i],m(r)&&(u[r]=d[r]);else{const g=[i];h(r,e.k)&&(r.value=g),e.k&&(d[e.k]=g)}}else b?(d[r]=l,m(r)&&(u[r]=l)):I&&(h(r,e.k)&&(r.value=l),e.k&&(d[e.k]=l))};if(l){const y=()=>{k(),sr.delete(e)};y.id=-1,sr.set(e,y),Vt(y,s)}else rp(e),k()}}}function rp(e){const t=sr.get(e);t&&(t.flags|=8,sr.delete(e))}let cp=!1;const vi=()=>{cp||(console.error("Hydration completed but contains mismatches."),cp=!0)},px=e=>e.namespaceURI.includes("svg")&&e.tagName!=="foreignObject",fx=e=>e.namespaceURI.includes("MathML"),ko=e=>{if(e.nodeType===1){if(px(e))return"svg";if(fx(e))return"mathml"}},Ti=e=>e.nodeType===8;function mx(e){const{mt:t,p:s,o:{patchProp:a,createText:n,nextSibling:i,parentNode:l,remove:o,insert:r,createComment:c}}=e,d=(g,x)=>{if(!x.hasChildNodes()){s(null,g,x),tr(),x._vnode=g;return}u(x.firstChild,g,null,null,null),tr(),x._vnode=g},u=(g,x,S,_,A,R=!1)=>{R=R||!!x.dynamicChildren;const w=Ti(g)&&g.data==="[",O=()=>b(g,x,S,_,A,w),{type:B,ref:T,shapeFlag:$,patchFlag:Q}=x;let W=g.nodeType;x.el=g,Q===-2&&(R=!1,x.dynamicChildren=null);let N=null;switch(B){case kn:W!==3?x.children===""?(r(x.el=n(""),l(g),g),N=g):N=O():(g.data!==x.children&&(vi(),g.data=x.children),N=i(g));break;case Ht:y(g)?(N=i(g),k(x.el=g.content.firstChild,g,S)):W!==8||w?N=O():N=i(g);break;case Wn:if(w&&(g=i(g),W=g.nodeType),W===1||W===3){N=g;const L=!x.children.length;for(let D=0;D{R=R||!!x.dynamicChildren;const{type:w,props:O,patchFlag:B,shapeFlag:T,dirs:$,transition:Q}=x,W=w==="input"||w==="option";if(W||B!==-1){$&&Ea(x,null,S,"created");let N=!1;if(y(g)){N=Sh(null,Q)&&S&&S.vnode.props&&S.vnode.props.appear;const D=g.content.firstChild;if(N){const re=D.getAttribute("class");re&&(D.$cls=re),Q.beforeEnter(D)}k(D,g,S),x.el=g=D}if(T&16&&!(O&&(O.innerHTML||O.textContent))){let D=m(g.firstChild,x,g,S,_,A,R);for(D&&!So(g,1)&&vi();D;){const re=D;D=D.nextSibling,o(re)}}else if(T&8){let D=x.children;D[0]===` -`&&(g.tagName==="PRE"||g.tagName==="TEXTAREA")&&(D=D.slice(1));const{textContent:re}=g;re!==D&&re!==D.replace(/\r\n|\r/g,` -`)&&(So(g,0)||vi(),g.textContent=x.children)}if(O){if(W||!R||B&48){const D=g.tagName.includes("-");for(const re in O)(W&&(re.endsWith("value")||re==="indeterminate")||ei(re)&&!Za(re)||re[0]==="."||D&&!Za(re))&&a(g,re,null,O[re],void 0,S)}else if(O.onClick)a(g,"onClick",null,O.onClick,void 0,S);else if(B&4&&Ya(O.style))for(const D in O.style)O.style[D]}let L;(L=O&&O.onVnodeBeforeMount)&&$s(L,S,x),$&&Ea(x,null,S,"beforeMount"),((L=O&&O.onVnodeMounted)||$||N)&&Ah(()=>{L&&$s(L,S,x),N&&Q.enter(g),$&&Ea(x,null,S,"mounted")},_)}return g.nextSibling},m=(g,x,S,_,A,R,w)=>{w=w||!!x.dynamicChildren;const O=x.children,B=O.length;let T=!1;for(let $=0;${const{slotScopeIds:w}=x;w&&(A=A?A.concat(w):w);const O=l(g),B=m(i(g),x,O,S,_,A,R);return B&&Ti(B)&&B.data==="]"?i(x.anchor=B):(vi(),r(x.anchor=c("]"),O,B),B)},b=(g,x,S,_,A,R)=>{if(So(g.parentElement,1)||vi(),x.el=null,R){const B=I(g);for(;;){const T=i(g);if(T&&T!==B)o(T);else break}}const w=i(g),O=l(g);return o(g),s(null,x,O,w,S,_,ko(O),A),S&&(S.vnode.el=x.el,Vr(S,x.el)),w},I=(g,x="[",S="]")=>{let _=0;for(;g;)if(g=i(g),g&&Ti(g)&&(g.data===x&&_++,g.data===S)){if(_===0)return i(g);_--}return g},k=(g,x,S)=>{const _=x.parentNode;_&&_.replaceChild(g,x);let A=S;for(;A;)A.vnode.el===x&&(A.vnode.el=A.subTree.el=g),A=A.parent},y=g=>g.nodeType===1&&g.tagName==="TEMPLATE";return[d,u]}const dp="data-allow-mismatch",hx={0:"text",1:"children",2:"class",3:"style",4:"attribute"};function So(e,t){if(t===0||t===1)for(;e&&!e.hasAttribute(dp);)e=e.parentElement;const s=e&&e.getAttribute(dp);if(s==null)return!1;if(s==="")return!0;{const a=s.split(",");return t===0&&a.includes("children")?!0:a.includes(hx[t])}}const vx=Lr().requestIdleCallback||(e=>setTimeout(e,1)),gx=Lr().cancelIdleCallback||(e=>clearTimeout(e)),bx=(e=1e4)=>t=>{const s=vx(t,{timeout:e});return()=>gx(s)};function yx(e){const{top:t,left:s,bottom:a,right:n}=e.getBoundingClientRect(),{innerHeight:i,innerWidth:l}=window;return(t>0&&t0&&a0&&s0&&n(t,s)=>{const a=new IntersectionObserver(n=>{for(const i of n)if(i.isIntersecting){a.disconnect(),t();break}},e);return s(n=>{if(n instanceof Element){if(yx(n))return t(),a.disconnect(),!1;a.observe(n)}}),()=>a.disconnect()},_x=e=>t=>{if(e){const s=matchMedia(e);if(s.matches)t();else return s.addEventListener("change",t,{once:!0}),()=>s.removeEventListener("change",t)}},wx=(e=[])=>(t,s)=>{Ye(e)&&(e=[e]);let a=!1;const n=l=>{a||(a=!0,i(),t(),l.target.dispatchEvent(new l.constructor(l.type,l)))},i=()=>{s(l=>{for(const o of e)l.removeEventListener(o,n)})};return s(l=>{for(const o of e)l.addEventListener(o,n,{once:!0})}),i};function kx(e,t){if(Ti(e)&&e.data==="["){let s=1,a=e.nextSibling;for(;a;){if(a.nodeType===1){if(t(a)===!1)break}else if(Ti(a))if(a.data==="]"){if(--s===0)break}else a.data==="["&&s++;a=a.nextSibling}}else t(e)}const Qa=e=>!!e.type.__asyncLoader;function Sx(e){je(e)&&(e={loader:e});const{loader:t,loadingComponent:s,errorComponent:a,delay:n=200,hydrate:i,timeout:l,suspensible:o=!0,onError:r}=e;let c=null,d,u=0;const f=()=>(u++,c=null,m()),m=()=>{let h;return c||(h=c=t().catch(b=>{if(b=b instanceof Error?b:new Error(String(b)),r)return new Promise((I,k)=>{r(b,()=>I(f()),()=>k(b),u+1)});throw b}).then(b=>h!==c&&c?c:(b&&(b.__esModule||b[Symbol.toStringTag]==="Module")&&(b=b.default),d=b,b)))};return io({name:"AsyncComponentWrapper",__asyncLoader:m,__asyncHydrate(h,b,I){let k=!1;(b.bu||(b.bu=[])).push(()=>k=!0);const y=()=>{k||I()},g=i?()=>{const x=i(y,S=>kx(h,S));x&&(b.bum||(b.bum=[])).push(x)}:y;d?g():m().then(()=>!b.isUnmounted&&g())},get __asyncResolved(){return d},setup(){const h=fs;if(qd(h),d)return()=>Co(d,h);const b=S=>{c=null,ai(S,h,13,!a)};if(o&&h.suspense||Yn)return m().then(S=>()=>Co(S,h)).catch(S=>(b(S),()=>a?Lt(a,{error:S}):null));const I=p(!1),k=p(),y=p(!!n);let g,x;return xt(()=>{g!=null&&clearTimeout(g),x!=null&&clearTimeout(x)}),n&&(x=setTimeout(()=>{h.isUnmounted||(y.value=!1)},n)),l!=null&&(g=setTimeout(()=>{if(!h.isUnmounted&&!I.value&&!k.value){const S=new Error(`Async component timed out after ${l}ms.`);b(S),k.value=S}},l)),m().then(()=>{h.isUnmounted||(I.value=!0,h.parent&&lo(h.parent.vnode)&&h.parent.update())}).catch(S=>{if(h.isUnmounted){c=null;return}b(S),k.value=S}),()=>{if(I.value&&d)return Co(d,h);if(k.value&&a)return Lt(a,{error:k.value});if(s&&!y.value)return Co(s,h)}}})}function Co(e,t){const{ref:s,props:a,children:n,ce:i}=t.vnode,l=Lt(e,a,n);return l.ref=s,l.ce=i,delete t.vnode.ce,l}const lo=e=>e.type.__isKeepAlive,Cx={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){const s=Ls(),a=s.ctx;if(!a.renderer)return()=>{const y=t.default&&t.default();return y&&y.length===1?y[0]:y};const n=new Map,i=new Set;let l=null;const o=s.suspense,{renderer:{p:r,m:c,um:d,o:{createElement:u}}}=a,f=u("div");a.activate=(y,g,x,S,_)=>{const A=y.component;c(y,g,x,0,o),r(A.vnode,y,g,x,A,o,S,y.slotScopeIds,_),Vt(()=>{A.isDeactivated=!1,A.a&&Oi(A.a);const R=y.props&&y.props.onVnodeMounted;R&&$s(R,A.parent,y)},o)},a.deactivate=y=>{const g=y.component;nr(g.m),nr(g.a),c(y,f,null,1,o),Vt(()=>{g.da&&Oi(g.da);const x=y.props&&y.props.onVnodeUnmounted;x&&$s(x,g.parent,y),g.isDeactivated=!0},o)};function m(y){xc(y),d(y,s,o,!0)}function h(y){n.forEach((g,x)=>{const S=id(Qa(g)?g.type.__asyncResolved||{}:g.type);S&&!y(S)&&b(x)})}function b(y){const g=n.get(y);g&&(!l||!fa(g,l))?m(g):l&&xc(l),n.delete(y),i.delete(y)}Wt(()=>[e.include,e.exclude],([y,g])=>{y&&h(x=>xl(y,x)),g&&h(x=>!xl(g,x))},{flush:"post",deep:!0});let I=null;const k=()=>{I!=null&&(ir(s.subTree.type)?Vt(()=>{n.set(I,To(s.subTree))},s.subTree.suspense):n.set(I,To(s.subTree)))};return st(k),zr(k),Hr(()=>{n.forEach(y=>{const{subTree:g,suspense:x}=s,S=To(g);if(y.type===S.type&&y.key===S.key){xc(S);const _=S.component.da;_&&Vt(_,x);return}m(y)})}),()=>{if(I=null,!t.default)return l=null;const y=t.default(),g=y[0];if(y.length>1)return l=null,y;if(!nn(g)||!(g.shapeFlag&4)&&!(g.shapeFlag&128))return l=null,g;let x=To(g);if(x.type===Ht)return l=null,x;const S=x.type,_=id(Qa(x)?x.type.__asyncResolved||{}:S),{include:A,exclude:R,max:w}=e;if(A&&(!_||!xl(A,_))||R&&_&&xl(R,_))return x.shapeFlag&=-257,l=x,g;const O=x.key==null?S:x.key,B=n.get(O);return x.el&&(x=Oa(x),g.shapeFlag&128&&(g.ssContent=x)),I=O,B?(x.el=B.el,x.component=B.component,x.transition&&an(x,x.transition),x.shapeFlag|=512,i.delete(O),i.add(O)):(i.add(O),w&&i.size>parseInt(w,10)&&b(i.values().next().value)),x.shapeFlag|=256,l=x,ir(g.type)?g:x}}},Tx=Cx;function xl(e,t){return Ie(e)?e.some(s=>xl(s,t)):Ye(e)?e.split(",").includes(t):Pb(e)?(e.lastIndex=0,e.test(t)):!1}function rs(e,t){eh(e,"a",t)}function Kt(e,t){eh(e,"da",t)}function eh(e,t,s=fs){const a=e.__wdc||(e.__wdc=()=>{let n=s;for(;n;){if(n.isDeactivated)return;n=n.parent}return e()});if(Ur(t,a,s),s){let n=s.parent;for(;n&&n.parent;)lo(n.parent.vnode)&&Ex(a,t,s,n),n=n.parent}}function Ex(e,t,s,a){const n=Ur(t,e,a,!0);xt(()=>{Ld(a[t],n)},s)}function xc(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function To(e){return e.shapeFlag&128?e.ssContent:e}function Ur(e,t,s=fs,a=!1){if(s){const n=s[e]||(s[e]=[]),i=t.__weh||(t.__weh=(...l)=>{tn();const o=Qi(s),r=Zs(t,s,e,l);return o(),sn(),r});return a?n.unshift(i):n.push(i),i}}const ln=e=>(t,s=fs)=>{(!Yn||e==="sp")&&Ur(e,(...a)=>t(...a),s)},th=ln("bm"),st=ln("m"),Gd=ln("bu"),zr=ln("u"),Hr=ln("bum"),xt=ln("um"),sh=ln("sp"),ah=ln("rtg"),nh=ln("rtc");function ih(e,t=fs){Ur("ec",e,t)}const Wd="components",Ax="directives";function Rx(e,t){return Kd(Wd,e,!0,t)||e}const lh=Symbol.for("v-ndc");function Ix(e){return Ye(e)?Kd(Wd,e,!1)||e:e||lh}function Ox(e){return Kd(Ax,e)}function Kd(e,t,s=!0,a=!1){const n=ms||fs;if(n){const i=n.type;if(e===Wd){const o=id(i,!1);if(o&&(o===t||o===wt(t)||o===si(wt(t))))return i}const l=up(n[e]||i[e],t)||up(n.appContext[e],t);return!l&&a?i:l}}function up(e,t){return e&&(e[t]||e[wt(t)]||e[si(wt(t))])}function Lx(e,t,s,a){let n;const i=s&&s[a],l=Ie(e);if(l||Ye(e)){const o=l&&Ya(e);let r=!1,c=!1;o&&(r=!Hs(e),c=Ia(e),e=Dr(e)),n=new Array(e.length);for(let d=0,u=e.length;dt(o,r,void 0,i&&i[r]));else{const o=Object.keys(e);n=new Array(o.length);for(let r=0,c=o.length;r{const i=a.fn(...n);return i&&(i.key=a.key),i}:a.fn)}return e}function Mx(e,t,s={},a,n){if(ms.ce||ms.parent&&Qa(ms.parent)&&ms.parent.ce){const c=Object.keys(s).length>0;return t!=="default"&&(s.name=t),Hl(),lr(ls,null,[Lt("slot",s,a&&a())],c?-2:64)}let i=e[t];i&&i._c&&(i._d=!1),Hl();const l=i&&Jd(i(s)),o=s.key||l&&l.key,r=lr(ls,{key:(o&&!Cs(o)?o:`_${t}`)+(!l&&a?"_fb":"")},l||(a?a():[]),l&&e._===1?64:-2);return!n&&r.scopeId&&(r.slotScopeIds=[r.scopeId+"-s"]),i&&i._c&&(i._d=!0),r}function Jd(e){return e.some(t=>nn(t)?!(t.type===Ht||t.type===ls&&!Jd(t.children)):!0)?e:null}function Dx(e,t){const s={};for(const a in e)s[t&&/[A-Z]/.test(a)?`on:${a}`:Ii(a)]=e[a];return s}const Yc=e=>e?Ph(e)?oo(e):Yc(e.parent):null,El=nt(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Yc(e.parent),$root:e=>Yc(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Zd(e),$forceUpdate:e=>e.f||(e.f=()=>{zd(e.update)}),$nextTick:e=>e.n||(e.n=zt.bind(e.proxy)),$watch:e=>nx.bind(e)}),_c=(e,t)=>e!==it&&!e.__isScriptSetup&&ht(e,t),Qc={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:a,data:n,props:i,accessCache:l,type:o,appContext:r}=e;if(t[0]!=="$"){const f=l[t];if(f!==void 0)switch(f){case 1:return a[t];case 2:return n[t];case 4:return s[t];case 3:return i[t]}else{if(_c(a,t))return l[t]=1,a[t];if(n!==it&&ht(n,t))return l[t]=2,n[t];if(ht(i,t))return l[t]=3,i[t];if(s!==it&&ht(s,t))return l[t]=4,s[t];Xc&&(l[t]=0)}}const c=El[t];let d,u;if(c)return t==="$attrs"&&ws(e.attrs,"get",""),c(e);if((d=o.__cssModules)&&(d=d[t]))return d;if(s!==it&&ht(s,t))return l[t]=4,s[t];if(u=r.config.globalProperties,ht(u,t))return u[t]},set({_:e},t,s){const{data:a,setupState:n,ctx:i}=e;return _c(n,t)?(n[t]=s,!0):a!==it&&ht(a,t)?(a[t]=s,!0):ht(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:a,appContext:n,props:i,type:l}},o){let r;return!!(s[o]||e!==it&&o[0]!=="$"&&ht(e,o)||_c(t,o)||ht(i,o)||ht(a,o)||ht(El,o)||ht(n.config.globalProperties,o)||(r=l.__cssModules)&&r[o])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:ht(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}},Px=nt({},Qc,{get(e,t){if(t!==Symbol.unscopables)return Qc.get(e,t,e)},has(e,t){return t[0]!=="_"&&!jb(t)}});function $x(){return null}function Fx(){return null}function Bx(e){}function Ux(e){}function zx(){return null}function Hx(){}function jx(e,t){return null}function Vx(){return oh().slots}function qx(){return oh().attrs}function oh(e){const t=Ls();return t.setupContext||(t.setupContext=Uh(t))}function Ul(e){return Ie(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}function Gx(e,t){const s=Ul(e);for(const a in t){if(a.startsWith("__skip"))continue;let n=s[a];n?Ie(n)||je(n)?n=s[a]={type:n,default:t[a]}:n.default=t[a]:n===null&&(n=s[a]={default:t[a]}),n&&t[`__skip_${a}`]&&(n.skipFactory=!0)}return s}function Wx(e,t){return!e||!t?e||t:Ie(e)&&Ie(t)?e.concat(t):nt({},Ul(e),Ul(t))}function Kx(e,t){const s={};for(const a in e)t.includes(a)||Object.defineProperty(s,a,{enumerable:!0,get:()=>e[a]});return s}function Jx(e){const t=Ls(),s=Yn;let a=e();Vl(),s&&Di(!1);const n=()=>{Qi(t),s&&Di(!0)},i=()=>{Ls()!==t&&t.scope.off(),Vl(),s&&Di(!1)};return Nd(a)&&(a=a.catch(l=>{throw n(),Promise.resolve().then(()=>Promise.resolve().then(i)),l})),[a,()=>{n(),Promise.resolve().then(i)}]}let Xc=!0;function Zx(e){const t=Zd(e),s=e.proxy,a=e.ctx;Xc=!1,t.beforeCreate&&pp(t.beforeCreate,e,"bc");const{data:n,computed:i,methods:l,watch:o,provide:r,inject:c,created:d,beforeMount:u,mounted:f,beforeUpdate:m,updated:h,activated:b,deactivated:I,beforeDestroy:k,beforeUnmount:y,destroyed:g,unmounted:x,render:S,renderTracked:_,renderTriggered:A,errorCaptured:R,serverPrefetch:w,expose:O,inheritAttrs:B,components:T,directives:$,filters:Q}=t;if(c&&Yx(c,a,null),l)for(const L in l){const D=l[L];je(D)&&(a[L]=D.bind(s))}if(n){const L=n.call(s,s);mt(L)&&(e.data=Tn(L))}if(Xc=!0,i)for(const L in i){const D=i[L],re=je(D)?D.bind(s,s):je(D.get)?D.get.bind(s,s):hs,ce=!je(D)&&je(D.set)?D.set.bind(s):hs,J=H({get:re,set:ce});Object.defineProperty(a,L,{enumerable:!0,configurable:!0,get:()=>J.value,set:K=>J.value=K})}if(o)for(const L in o)rh(o[L],a,s,L);if(r){const L=je(r)?r.call(s):r;Reflect.ownKeys(L).forEach(D=>{Tl(D,L[D])})}d&&pp(d,e,"c");function N(L,D){Ie(D)?D.forEach(re=>L(re.bind(s))):D&&L(D.bind(s))}if(N(th,u),N(st,f),N(Gd,m),N(zr,h),N(rs,b),N(Kt,I),N(ih,R),N(nh,_),N(ah,A),N(Hr,y),N(xt,x),N(sh,w),Ie(O))if(O.length){const L=e.exposed||(e.exposed={});O.forEach(D=>{Object.defineProperty(L,D,{get:()=>s[D],set:re=>s[D]=re,enumerable:!0})})}else e.exposed||(e.exposed={});S&&e.render===hs&&(e.render=S),B!=null&&(e.inheritAttrs=B),T&&(e.components=T),$&&(e.directives=$),w&&qd(e)}function Yx(e,t,s=hs){Ie(e)&&(e=ed(e));for(const a in e){const n=e[a];let i;mt(n)?"default"in n?i=na(n.from||a,n.default,!0):i=na(n.from||a):i=na(n),Gt(i)?Object.defineProperty(t,a,{enumerable:!0,configurable:!0,get:()=>i.value,set:l=>i.value=l}):t[a]=i}}function pp(e,t,s){Zs(Ie(e)?e.map(a=>a.bind(t.proxy)):e.bind(t.proxy),t,s)}function rh(e,t,s,a){let n=a.includes(".")?Wm(s,a):()=>s[a];if(Ye(e)){const i=t[e];je(i)&&Wt(n,i)}else if(je(e))Wt(n,e.bind(s));else if(mt(e))if(Ie(e))e.forEach(i=>rh(i,t,s,a));else{const i=je(e.handler)?e.handler.bind(s):t[e.handler];je(i)&&Wt(n,i,e)}}function Zd(e){const t=e.type,{mixins:s,extends:a}=t,{mixins:n,optionsCache:i,config:{optionMergeStrategies:l}}=e.appContext,o=i.get(t);let r;return o?r=o:!n.length&&!s&&!a?r=t:(r={},n.length&&n.forEach(c=>ar(r,c,l,!0)),ar(r,t,l)),mt(t)&&i.set(t,r),r}function ar(e,t,s,a=!1){const{mixins:n,extends:i}=t;i&&ar(e,i,s,!0),n&&n.forEach(l=>ar(e,l,s,!0));for(const l in t)if(!(a&&l==="expose")){const o=Qx[l]||s&&s[l];e[l]=o?o(e[l],t[l]):t[l]}return e}const Qx={data:fp,props:mp,emits:mp,methods:_l,computed:_l,beforeCreate:Es,created:Es,beforeMount:Es,mounted:Es,beforeUpdate:Es,updated:Es,beforeDestroy:Es,beforeUnmount:Es,destroyed:Es,unmounted:Es,activated:Es,deactivated:Es,errorCaptured:Es,serverPrefetch:Es,components:_l,directives:_l,watch:e0,provide:fp,inject:Xx};function fp(e,t){return t?e?function(){return nt(je(e)?e.call(this,this):e,je(t)?t.call(this,this):t)}:t:e}function Xx(e,t){return _l(ed(e),ed(t))}function ed(e){if(Ie(e)){const t={};for(let s=0;s{let d,u=it,f;return Gm(()=>{const m=e[n];is(d,m)&&(d=m,c())}),{get(){return r(),s.get?s.get(d):d},set(m){const h=s.set?s.set(m):m;if(!is(h,d)&&!(u!==it&&is(m,u)))return;const b=a.vnode.props,I=!!(b&&(t in b||n in b||i in b)&&(`onUpdate:${t}`in b||`onUpdate:${n}`in b||`onUpdate:${i}`in b));I||(d=m,c()),a.emit(`update:${t}`,h),is(m,u)&&(is(m,h)&&!is(h,f)||I&&u!==it&&!is(h,d))&&c(),u=m,f=h}}});return o[Symbol.iterator]=()=>{let r=0;return{next(){return r<2?{value:r++?l||it:o,done:!1}:{done:!0}}}},o}const dh=(e,t)=>t==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${wt(t)}Modifiers`]||e[`${Us(t)}Modifiers`];function n0(e,t,...s){if(e.isUnmounted)return;const a=e.vnode.props||it;let n=s;const i=t.startsWith("update:"),l=i&&dh(a,t.slice(7));l&&(l.trim&&(n=s.map(d=>Ye(d)?d.trim():d)),l.number&&(n=s.map(Or)));let o,r=a[o=Ii(t)]||a[o=Ii(wt(t))];!r&&i&&(r=a[o=Ii(Us(t))]),r&&Zs(r,e,6,n);const c=a[o+"Once"];if(c){if(!e.emitted)e.emitted={};else if(e.emitted[o])return;e.emitted[o]=!0,Zs(c,e,6,n)}}const i0=new WeakMap;function uh(e,t,s=!1){const a=s?i0:t.emitsCache,n=a.get(e);if(n!==void 0)return n;const i=e.emits;let l={},o=!1;if(!je(e)){const r=c=>{const d=uh(c,t,!0);d&&(o=!0,nt(l,d))};!s&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}return!i&&!o?(mt(e)&&a.set(e,null),null):(Ie(i)?i.forEach(r=>l[r]=null):nt(l,i),mt(e)&&a.set(e,l),l)}function jr(e,t){return!e||!ei(t)?!1:(t=t.slice(2).replace(/Once$/,""),ht(e,t[0].toLowerCase()+t.slice(1))||ht(e,Us(t))||ht(e,t))}function Ho(e){const{type:t,vnode:s,proxy:a,withProxy:n,propsOptions:[i],slots:l,attrs:o,emit:r,render:c,renderCache:d,props:u,data:f,setupState:m,ctx:h,inheritAttrs:b}=e,I=Bl(e);let k,y;try{if(s.shapeFlag&4){const x=n||a,S=x;k=Bs(c.call(S,x,d,u,m,f,h)),y=o}else{const x=t;k=Bs(x.length>1?x(u,{attrs:o,slots:l,emit:r}):x(u,null)),y=t.props?o:o0(o)}}catch(x){Al.length=0,ai(x,e,1),k=Lt(Ht)}let g=k;if(y&&b!==!1){const x=Object.keys(y),{shapeFlag:S}=g;x.length&&S&7&&(i&&x.some(Er)&&(y=r0(y,i)),g=Oa(g,y,!1,!0))}return s.dirs&&(g=Oa(g,null,!1,!0),g.dirs=g.dirs?g.dirs.concat(s.dirs):s.dirs),s.transition&&an(g,s.transition),k=g,Bl(I),k}function l0(e,t=!0){let s;for(let a=0;a{let t;for(const s in e)(s==="class"||s==="style"||ei(s))&&((t||(t={}))[s]=e[s]);return t},r0=(e,t)=>{const s={};for(const a in e)(!Er(a)||!(a.slice(9)in t))&&(s[a]=e[a]);return s};function c0(e,t,s){const{props:a,children:n,component:i}=e,{props:l,children:o,patchFlag:r}=t,c=i.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&r>=0){if(r&1024)return!0;if(r&16)return a?hp(a,l,c):!!l;if(r&8){const d=t.dynamicProps;for(let u=0;uObject.create(fh),hh=e=>Object.getPrototypeOf(e)===fh;function d0(e,t,s,a=!1){const n={},i=mh();e.propsDefaults=Object.create(null),vh(e,t,n,i);for(const l in e.propsOptions[0])l in n||(n[l]=void 0);s?e.props=a?n:Fd(n):e.type.props?e.props=n:e.props=i,e.attrs=i}function u0(e,t,s,a){const{props:n,attrs:i,vnode:{patchFlag:l}}=e,o=dt(n),[r]=e.propsOptions;let c=!1;if((a||l>0)&&!(l&16)){if(l&8){const d=e.vnode.dynamicProps;for(let u=0;u{r=!0;const[f,m]=gh(u,t,!0);nt(l,f),m&&o.push(...m)};!s&&t.mixins.length&&t.mixins.forEach(d),e.extends&&d(e.extends),e.mixins&&e.mixins.forEach(d)}if(!i&&!r)return mt(e)&&a.set(e,Ai),Ai;if(Ie(i))for(let d=0;de==="_"||e==="_ctx"||e==="$stable",Qd=e=>Ie(e)?e.map(Bs):[Bs(e)],f0=(e,t,s)=>{if(t._n)return t;const a=Hd((...n)=>Qd(t(...n)),s);return a._c=!1,a},bh=(e,t,s)=>{const a=e._ctx;for(const n in e){if(Yd(n))continue;const i=e[n];if(je(i))t[n]=f0(n,i,a);else if(i!=null){const l=Qd(i);t[n]=()=>l}}},yh=(e,t)=>{const s=Qd(t);e.slots.default=()=>s},xh=(e,t,s)=>{for(const a in t)(s||!Yd(a))&&(e[a]=t[a])},m0=(e,t,s)=>{const a=e.slots=mh();if(e.vnode.shapeFlag&32){const n=t._;n?(xh(a,t,s),s&&pm(a,"_",n,!0)):bh(t,a)}else t&&yh(e,t)},h0=(e,t,s)=>{const{vnode:a,slots:n}=e;let i=!0,l=it;if(a.shapeFlag&32){const o=t._;o?s&&o===1?i=!1:xh(n,t,s):(i=!t.$stable,bh(t,n)),l=t}else t&&(yh(e,t),l={default:1});if(i)for(const o in n)!Yd(o)&&l[o]==null&&delete n[o]},Vt=Ah;function _h(e){return kh(e)}function wh(e){return kh(e,mx)}function kh(e,t){const s=Lr();s.__VUE__=!0;const{insert:a,remove:n,patchProp:i,createElement:l,createText:o,createComment:r,setText:c,setElementText:d,parentNode:u,nextSibling:f,setScopeId:m=hs,insertStaticContent:h}=e,b=(E,P,G,me=null,de=null,fe=null,_e=void 0,q=null,te=!!P.dynamicChildren)=>{if(E===P)return;E&&!fa(E,P)&&(me=ne(E),K(E,de,fe,!0),E=null),P.patchFlag===-2&&(te=!1,P.dynamicChildren=null);const{type:X,ref:xe,shapeFlag:U}=P;switch(X){case kn:I(E,P,G,me);break;case Ht:k(E,P,G,me);break;case Wn:E==null&&y(P,G,me,_e);break;case ls:T(E,P,G,me,de,fe,_e,q,te);break;default:U&1?S(E,P,G,me,de,fe,_e,q,te):U&6?$(E,P,G,me,de,fe,_e,q,te):(U&64||U&128)&&X.process(E,P,G,me,de,fe,_e,q,te,be)}xe!=null&&de?Ni(xe,E&&E.ref,fe,P||E,!P):xe==null&&E&&E.ref!=null&&Ni(E.ref,null,fe,E,!0)},I=(E,P,G,me)=>{if(E==null)a(P.el=o(P.children),G,me);else{const de=P.el=E.el;P.children!==E.children&&c(de,P.children)}},k=(E,P,G,me)=>{E==null?a(P.el=r(P.children||""),G,me):P.el=E.el},y=(E,P,G,me)=>{[E.el,E.anchor]=h(E.children,P,G,me,E.el,E.anchor)},g=({el:E,anchor:P},G,me)=>{let de;for(;E&&E!==P;)de=f(E),a(E,G,me),E=de;a(P,G,me)},x=({el:E,anchor:P})=>{let G;for(;E&&E!==P;)G=f(E),n(E),E=G;n(P)},S=(E,P,G,me,de,fe,_e,q,te)=>{if(P.type==="svg"?_e="svg":P.type==="math"&&(_e="mathml"),E==null)_(P,G,me,de,fe,_e,q,te);else{const X=E.el&&E.el._isVueCE?E.el:null;try{X&&X._beginPatch(),w(E,P,de,fe,_e,q,te)}finally{X&&X._endPatch()}}},_=(E,P,G,me,de,fe,_e,q)=>{let te,X;const{props:xe,shapeFlag:U,transition:ue,dirs:oe}=E;if(te=E.el=l(E.type,fe,xe&&xe.is,xe),U&8?d(te,E.children):U&16&&R(E.children,te,null,me,de,wc(E,fe),_e,q),oe&&Ea(E,null,me,"created"),A(te,E,E.scopeId,_e,me),xe){for(const Fe in xe)Fe!=="value"&&!Za(Fe)&&i(te,Fe,null,xe[Fe],fe,me);"value"in xe&&i(te,"value",null,xe.value,fe),(X=xe.onVnodeBeforeMount)&&$s(X,me,E)}oe&&Ea(E,null,me,"beforeMount");const he=Sh(de,ue);he&&ue.beforeEnter(te),a(te,P,G),((X=xe&&xe.onVnodeMounted)||he||oe)&&Vt(()=>{try{X&&$s(X,me,E),he&&ue.enter(te),oe&&Ea(E,null,me,"mounted")}finally{}},de)},A=(E,P,G,me,de)=>{if(G&&m(E,G),me)for(let fe=0;fe{for(let X=te;X{const q=P.el=E.el;let{patchFlag:te,dynamicChildren:X,dirs:xe}=P;te|=E.patchFlag&16;const U=E.props||it,ue=P.props||it;let oe;if(G&&Pn(G,!1),(oe=ue.onVnodeBeforeUpdate)&&$s(oe,G,P,E),xe&&Ea(P,E,G,"beforeUpdate"),G&&Pn(G,!0),(U.innerHTML&&ue.innerHTML==null||U.textContent&&ue.textContent==null)&&d(q,""),X?O(E.dynamicChildren,X,q,G,me,wc(P,de),fe):_e||D(E,P,q,null,G,me,wc(P,de),fe,!1),te>0){if(te&16)B(q,U,ue,G,de);else if(te&2&&U.class!==ue.class&&i(q,"class",null,ue.class,de),te&4&&i(q,"style",U.style,ue.style,de),te&8){const he=P.dynamicProps;for(let Fe=0;Fe{oe&&$s(oe,G,P,E),xe&&Ea(P,E,G,"updated")},me)},O=(E,P,G,me,de,fe,_e)=>{for(let q=0;q{if(P!==G){if(P!==it)for(const fe in P)!Za(fe)&&!(fe in G)&&i(E,fe,P[fe],null,de,me);for(const fe in G){if(Za(fe))continue;const _e=G[fe],q=P[fe];_e!==q&&fe!=="value"&&i(E,fe,q,_e,de,me)}"value"in G&&i(E,"value",P.value,G.value,de)}},T=(E,P,G,me,de,fe,_e,q,te)=>{const X=P.el=E?E.el:o(""),xe=P.anchor=E?E.anchor:o("");let{patchFlag:U,dynamicChildren:ue,slotScopeIds:oe}=P;oe&&(q=q?q.concat(oe):oe),E==null?(a(X,G,me),a(xe,G,me),R(P.children||[],G,xe,de,fe,_e,q,te)):U>0&&U&64&&ue&&E.dynamicChildren&&E.dynamicChildren.length===ue.length?(O(E.dynamicChildren,ue,G,de,fe,_e,q),(P.key!=null||de&&P===de.subTree)&&Xd(E,P,!0)):D(E,P,G,xe,de,fe,_e,q,te)},$=(E,P,G,me,de,fe,_e,q,te)=>{P.slotScopeIds=q,E==null?P.shapeFlag&512?de.ctx.activate(P,G,me,_e,te):Q(P,G,me,de,fe,_e,te):W(E,P,te)},Q=(E,P,G,me,de,fe,_e)=>{const q=E.component=Dh(E,me,de);if(lo(E)&&(q.ctx.renderer=be),$h(q,!1,_e),q.asyncDep){if(de&&de.registerDep(q,N,_e),!E.el){const te=q.subTree=Lt(Ht);k(null,te,P,G),E.placeholder=te.el}}else N(q,E,P,G,de,fe,_e)},W=(E,P,G)=>{const me=P.component=E.component;if(c0(E,P,G))if(me.asyncDep&&!me.asyncResolved){L(me,P,G);return}else me.next=P,me.update();else P.el=E.el,me.vnode=P},N=(E,P,G,me,de,fe,_e)=>{const q=()=>{if(E.isMounted){let{next:U,bu:ue,u:oe,parent:he,vnode:Fe}=E;{const Xe=Ch(E);if(Xe){U&&(U.el=Fe.el,L(E,U,_e)),Xe.asyncDep.then(()=>{Vt(()=>{E.isUnmounted||X()},de)});return}}let Ve=U,$e;Pn(E,!1),U?(U.el=Fe.el,L(E,U,_e)):U=Fe,ue&&Oi(ue),($e=U.props&&U.props.onVnodeBeforeUpdate)&&$s($e,he,U,Fe),Pn(E,!0);const Ke=Ho(E),at=E.subTree;E.subTree=Ke,b(at,Ke,u(at.el),ne(at),E,de,fe),U.el=Ke.el,Ve===null&&Vr(E,Ke.el),oe&&Vt(oe,de),($e=U.props&&U.props.onVnodeUpdated)&&Vt(()=>$s($e,he,U,Fe),de)}else{let U;const{el:ue,props:oe}=P,{bm:he,m:Fe,parent:Ve,root:$e,type:Ke}=E,at=Qa(P);if(Pn(E,!1),he&&Oi(he),!at&&(U=oe&&oe.onVnodeBeforeMount)&&$s(U,Ve,P),Pn(E,!0),ue&&Ne){const Xe=()=>{E.subTree=Ho(E),Ne(ue,E.subTree,E,de,null)};at&&Ke.__asyncHydrate?Ke.__asyncHydrate(ue,E,Xe):Xe()}else{$e.ce&&$e.ce._hasShadowRoot()&&$e.ce._injectChildStyle(Ke,E.parent?E.parent.type:void 0);const Xe=E.subTree=Ho(E);b(null,Xe,G,me,E,de,fe),P.el=Xe.el}if(Fe&&Vt(Fe,de),!at&&(U=oe&&oe.onVnodeMounted)){const Xe=P;Vt(()=>$s(U,Ve,Xe),de)}(P.shapeFlag&256||Ve&&Qa(Ve.vnode)&&Ve.vnode.shapeFlag&256)&&E.a&&Vt(E.a,de),E.isMounted=!0,P=G=me=null}};E.scope.on();const te=E.effect=new Ml(q);E.scope.off();const X=E.update=te.run.bind(te),xe=E.job=te.runIfDirty.bind(te);xe.i=E,xe.id=E.uid,te.scheduler=()=>zd(xe),Pn(E,!0),X()},L=(E,P,G)=>{P.component=E;const me=E.vnode.props;E.vnode=P,E.next=null,u0(E,P.props,me,G),h0(E,P.children,G),tn(),ap(E),sn()},D=(E,P,G,me,de,fe,_e,q,te=!1)=>{const X=E&&E.children,xe=E?E.shapeFlag:0,U=P.children,{patchFlag:ue,shapeFlag:oe}=P;if(ue>0){if(ue&128){ce(X,U,G,me,de,fe,_e,q,te);return}else if(ue&256){re(X,U,G,me,de,fe,_e,q,te);return}}oe&8?(xe&16&&Oe(X,de,fe),U!==X&&d(G,U)):xe&16?oe&16?ce(X,U,G,me,de,fe,_e,q,te):Oe(X,de,fe,!0):(xe&8&&d(G,""),oe&16&&R(U,G,me,de,fe,_e,q,te))},re=(E,P,G,me,de,fe,_e,q,te)=>{E=E||Ai,P=P||Ai;const X=E.length,xe=P.length,U=Math.min(X,xe);let ue;for(ue=0;uexe?Oe(E,de,fe,!0,!1,U):R(P,G,me,de,fe,_e,q,te,U)},ce=(E,P,G,me,de,fe,_e,q,te)=>{let X=0;const xe=P.length;let U=E.length-1,ue=xe-1;for(;X<=U&&X<=ue;){const oe=E[X],he=P[X]=te?ja(P[X]):Bs(P[X]);if(fa(oe,he))b(oe,he,G,null,de,fe,_e,q,te);else break;X++}for(;X<=U&&X<=ue;){const oe=E[U],he=P[ue]=te?ja(P[ue]):Bs(P[ue]);if(fa(oe,he))b(oe,he,G,null,de,fe,_e,q,te);else break;U--,ue--}if(X>U){if(X<=ue){const oe=ue+1,he=oeue)for(;X<=U;)K(E[X],de,fe,!0),X++;else{const oe=X,he=X,Fe=new Map;for(X=he;X<=ue;X++){const Ae=P[X]=te?ja(P[X]):Bs(P[X]);Ae.key!=null&&Fe.set(Ae.key,X)}let Ve,$e=0;const Ke=ue-he+1;let at=!1,Xe=0;const Z=new Array(Ke);for(X=0;X=Ke){K(Ae,de,fe,!0);continue}let Me;if(Ae.key!=null)Me=Fe.get(Ae.key);else for(Ve=he;Ve<=ue;Ve++)if(Z[Ve-he]===0&&fa(Ae,P[Ve])){Me=Ve;break}Me===void 0?K(Ae,de,fe,!0):(Z[Me-he]=X+1,Me>=Xe?Xe=Me:at=!0,b(Ae,P[Me],G,null,de,fe,_e,q,te),$e++)}const Ee=at?v0(Z):Ai;for(Ve=Ee.length-1,X=Ke-1;X>=0;X--){const Ae=he+X,Me=P[Ae],ee=P[Ae+1],Le=Ae+1{const{el:fe,type:_e,transition:q,children:te,shapeFlag:X}=E;if(X&6){J(E.component.subTree,P,G,me);return}if(X&128){E.suspense.move(P,G,me);return}if(X&64){_e.move(E,P,G,be);return}if(_e===ls){a(fe,P,G);for(let U=0;Uq.enter(fe),de));else{const{leave:U,delayLeave:ue,afterLeave:oe}=q,he=()=>{E.ctx.isUnmounted?n(fe):a(fe,P,G)},Fe=()=>{const Ve=fe._isLeaving||!!fe[ea];fe._isLeaving&&fe[ea](!0),q.persisted&&!Ve?he():U(fe,()=>{he(),oe&&oe()})};ue?ue(fe,he,Fe):Fe()}else a(fe,P,G)},K=(E,P,G,me=!1,de=!1)=>{const{type:fe,props:_e,ref:q,children:te,dynamicChildren:X,shapeFlag:xe,patchFlag:U,dirs:ue,cacheIndex:oe,memo:he}=E;if(U===-2&&(de=!1),q!=null&&(tn(),Ni(q,null,G,E,!0),sn()),oe!=null&&(P.renderCache[oe]=void 0),xe&256){P.ctx.deactivate(E);return}const Fe=xe&1&&ue,Ve=!Qa(E);let $e;if(Ve&&($e=_e&&_e.onVnodeBeforeUnmount)&&$s($e,P,E),xe&6)ye(E.component,G,me);else{if(xe&128){E.suspense.unmount(G,me);return}Fe&&Ea(E,null,P,"beforeUnmount"),xe&64?E.type.remove(E,P,G,be,me):X&&!X.hasOnce&&(fe!==ls||U>0&&U&64)?Oe(X,P,G,!1,!0):(fe===ls&&U&384||!de&&xe&16)&&Oe(te,P,G),me&&se(E)}const Ke=he!=null&&oe==null;(Ve&&($e=_e&&_e.onVnodeUnmounted)||Fe||Ke)&&Vt(()=>{$e&&$s($e,P,E),Fe&&Ea(E,null,P,"unmounted"),Ke&&(E.el=null)},G)},se=E=>{const{type:P,el:G,anchor:me,transition:de}=E;if(P===ls){j(G,me);return}if(P===Wn){x(E);return}const fe=()=>{n(G),de&&!de.persisted&&de.afterLeave&&de.afterLeave()};if(E.shapeFlag&1&&de&&!de.persisted){const{leave:_e,delayLeave:q}=de,te=()=>_e(G,fe);q?q(E.el,fe,te):te()}else fe()},j=(E,P)=>{let G;for(;E!==P;)G=f(E),n(E),E=G;n(P)},ye=(E,P,G)=>{const{bum:me,scope:de,job:fe,subTree:_e,um:q,m:te,a:X}=E;nr(te),nr(X),me&&Oi(me),de.stop(),fe&&(fe.flags|=8,K(_e,E,P,G)),q&&Vt(q,P),Vt(()=>{E.isUnmounted=!0},P)},Oe=(E,P,G,me=!1,de=!1,fe=0)=>{for(let _e=fe;_e{if(E.shapeFlag&6)return ne(E.component.subTree);if(E.shapeFlag&128)return E.suspense.next();const P=f(E.anchor||E.el),G=P&&P[Km];return G?f(G):P};let ie=!1;const ve=(E,P,G)=>{let me;E==null?P._vnode&&(K(P._vnode,null,null,!0),me=P._vnode.component):b(P._vnode||null,E,P,null,null,null,G),P._vnode=E,ie||(ie=!0,ap(me),tr(),ie=!1)},be={p:b,um:K,m:J,r:se,mt:Q,mc:R,pc:D,pbc:O,n:ne,o:e};let ge,Ne;return t&&([ge,Ne]=t(be)),{render:ve,hydrate:ge,createApp:s0(ve,ge)}}function wc({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function Pn({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Sh(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function Xd(e,t,s=!1){const a=e.children,n=t.children;if(Ie(a)&&Ie(n))for(let i=0;i>1,e[s[o]]0&&(t[a]=s[i-1]),s[i]=a)}}for(i=s.length,l=s[i-1];i-- >0;)s[i]=l,l=t[l];return s}function Ch(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Ch(t)}function nr(e){if(e)for(let t=0;te.__isSuspense;let sd=0;const g0={name:"Suspense",__isSuspense:!0,process(e,t,s,a,n,i,l,o,r,c){if(e==null)y0(t,s,a,n,i,l,o,r,c);else{if(i&&i.deps>0&&!e.suspense.isInFallback){t.suspense=e.suspense,t.suspense.vnode=t,t.el=e.el;return}x0(e,t,s,a,n,l,o,r,c)}},hydrate:_0,normalize:w0},b0=g0;function zl(e,t){const s=e.props&&e.props[t];je(s)&&s()}function y0(e,t,s,a,n,i,l,o,r){const{p:c,o:{createElement:d}}=r,u=d("div"),f=e.suspense=Eh(e,n,a,t,u,s,i,l,o,r);c(null,f.pendingBranch=e.ssContent,u,null,a,f,i,l),f.deps>0?(zl(e,"onPending"),zl(e,"onFallback"),c(null,e.ssFallback,t,s,a,null,i,l),Mi(f,e.ssFallback)):f.resolve(!1,!0)}function x0(e,t,s,a,n,i,l,o,{p:r,um:c,o:{createElement:d}}){const u=t.suspense=e.suspense;u.vnode=t,t.el=e.el;const f=t.ssContent,m=t.ssFallback,{activeBranch:h,pendingBranch:b,isInFallback:I,isHydrating:k}=u;if(b)u.pendingBranch=f,fa(b,f)?(r(b,f,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0?u.resolve():I&&(k||(r(h,m,s,a,n,null,i,l,o),Mi(u,m)))):(u.pendingId=sd++,k?(u.isHydrating=!1,u.activeBranch=b):c(b,n,u),u.deps=0,u.effects.length=0,u.hiddenContainer=d("div"),I?(r(null,f,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0?u.resolve():(r(h,m,s,a,n,null,i,l,o),Mi(u,m))):h&&fa(h,f)?(r(h,f,s,a,n,u,i,l,o),u.resolve(!0)):(r(null,f,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0&&u.resolve()));else if(h&&fa(h,f))r(h,f,s,a,n,u,i,l,o),Mi(u,f);else if(zl(t,"onPending"),u.pendingBranch=f,f.shapeFlag&512?u.pendingId=f.component.suspenseId:u.pendingId=sd++,r(null,f,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0)u.resolve();else{const{timeout:y,pendingId:g}=u;y>0?setTimeout(()=>{u.pendingId===g&&u.fallback(m)},y):y===0&&u.fallback(m)}}function Eh(e,t,s,a,n,i,l,o,r,c,d=!1){const{p:u,m:f,um:m,n:h,o:{parentNode:b,remove:I}}=c;let k;const y=k0(e);y&&t&&t.pendingBranch&&(k=t.pendingId,t.deps++);const g=e.props?Zo(e.props.timeout):void 0,x=i,S={vnode:e,parent:t,parentComponent:s,namespace:l,container:a,hiddenContainer:n,deps:0,pendingId:sd++,timeout:typeof g=="number"?g:-1,activeBranch:null,isFallbackMountPending:!1,pendingBranch:null,isInFallback:!d,isHydrating:d,isUnmounted:!1,effects:[],resolve(_=!1,A=!1){const{vnode:R,activeBranch:w,pendingBranch:O,pendingId:B,effects:T,parentComponent:$,container:Q,isInFallback:W}=S;let N=!1;if(S.isHydrating)S.isHydrating=!1;else if(!_){N=w&&O.transition&&O.transition.mode==="out-in";let re=!1;N&&(w.transition.afterLeave=()=>{B===S.pendingId&&(f(O,Q,i===x&&!re?h(w):i,0),$l(T),W&&R.ssFallback&&(R.ssFallback.el=null))}),w&&!S.isFallbackMountPending&&(b(w.el)===Q&&(i=h(w),re=!0),m(w,$,S,!0),!N&&W&&R.ssFallback&&Vt(()=>R.ssFallback.el=null,S)),N||f(O,Q,i,0)}S.isFallbackMountPending=!1,Mi(S,O),S.pendingBranch=null,S.isInFallback=!1;let L=S.parent,D=!1;for(;L;){if(L.pendingBranch){L.effects.push(...T),D=!0;break}L=L.parent}!D&&!N&&$l(T),S.effects=[],y&&t&&t.pendingBranch&&k===t.pendingId&&(t.deps--,t.deps===0&&!A&&t.resolve()),zl(R,"onResolve")},fallback(_){if(!S.pendingBranch)return;const{vnode:A,activeBranch:R,parentComponent:w,container:O,namespace:B}=S;zl(A,"onFallback");const T=h(R),$=()=>{S.isFallbackMountPending=!1,S.isInFallback&&(u(null,_,O,T,w,null,B,o,r),Mi(S,_))},Q=_.transition&&_.transition.mode==="out-in";Q&&(S.isFallbackMountPending=!0,R.transition.afterLeave=$),S.isInFallback=!0,m(R,w,null,!0),Q||$()},move(_,A,R){S.activeBranch&&f(S.activeBranch,_,A,R),S.container=_},next(){return S.activeBranch&&h(S.activeBranch)},registerDep(_,A,R){const w=!!S.pendingBranch;w&&S.deps++;const O=_.vnode.el;_.asyncDep.catch(B=>{ai(B,_,0)}).then(B=>{if(_.isUnmounted||S.isUnmounted||S.pendingId!==_.suspenseId)return;Vl(),_.asyncResolved=!0;const{vnode:T}=_;ad(_,B,!1),O&&(T.el=O);const $=!O&&_.subTree.el;A(_,T,b(O||_.subTree.el),O?null:h(_.subTree),S,l,R),$&&(T.placeholder=null,I($)),Vr(_,T.el),w&&--S.deps===0&&S.resolve()})},unmount(_,A){S.isUnmounted=!0,S.activeBranch&&m(S.activeBranch,s,_,A),S.pendingBranch&&m(S.pendingBranch,s,_,A)}};return S}function _0(e,t,s,a,n,i,l,o,r){const c=t.suspense=Eh(t,a,s,e.parentNode,document.createElement("div"),null,n,i,l,o,!0),d=r(e,c.pendingBranch=t.ssContent,s,c,i,l);return c.deps===0&&c.resolve(!1,!0),d}function w0(e){const{shapeFlag:t,children:s}=e,a=t&32;e.ssContent=gp(a?s.default:s),e.ssFallback=a?gp(s.fallback):Lt(Ht)}function gp(e){let t;if(je(e)){const s=Zn&&e._c;s&&(e._d=!1,Hl()),e=e(),s&&(e._d=!0,t=ks,Rh())}return Ie(e)&&(e=l0(e)),e=Bs(e),t&&!e.dynamicChildren&&(e.dynamicChildren=t.filter(s=>s!==e)),e}function Ah(e,t){t&&t.pendingBranch?Ie(e)?t.effects.push(...e):t.effects.push(e):$l(e)}function Mi(e,t){e.activeBranch=t;const{vnode:s,parentComponent:a}=e;let n=t.el;for(;!n&&t.component;)t=t.component.subTree,n=t.el;s.el=n,a&&a.subTree===s&&(a.vnode.el=n,Vr(a,n))}function k0(e){const t=e.props&&e.props.suspensible;return t!=null&&t!==!1}const ls=Symbol.for("v-fgt"),kn=Symbol.for("v-txt"),Ht=Symbol.for("v-cmt"),Wn=Symbol.for("v-stc"),Al=[];let ks=null;function Hl(e=!1){Al.push(ks=e?null:[])}function Rh(){Al.pop(),ks=Al[Al.length-1]||null}let Zn=1;function jl(e,t=!1){Zn+=e,e<0&&ks&&t&&(ks.hasOnce=!0)}function Ih(e){return e.dynamicChildren=Zn>0?ks||Ai:null,Rh(),Zn>0&&ks&&ks.push(e),e}function S0(e,t,s,a,n,i){return Ih(eu(e,t,s,a,n,i,!0))}function lr(e,t,s,a,n){return Ih(Lt(e,t,s,a,n,!0))}function nn(e){return e?e.__v_isVNode===!0:!1}function fa(e,t){return e.type===t.type&&e.key===t.key}function C0(e){}const Oh=({key:e})=>e??null,jo=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?Ye(e)||Gt(e)||je(e)?{i:ms,r:e,k:t,f:!!s}:e:null);function eu(e,t=null,s=null,a=0,n=null,i=e===ls?0:1,l=!1,o=!1){const r={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Oh(t),ref:t&&jo(t),scopeId:Fr,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:a,dynamicProps:n,dynamicChildren:null,appContext:null,ctx:ms};return o?(su(r,s),i&128&&e.normalize(r)):s&&(r.shapeFlag|=Ye(s)?8:16),Zn>0&&!l&&ks&&(r.patchFlag>0||i&6)&&r.patchFlag!==32&&ks.push(r),r}const Lt=T0;function T0(e,t=null,s=null,a=0,n=null,i=!1){if((!e||e===lh)&&(e=Ht),nn(e)){const o=Oa(e,t,!0);return s&&su(o,s),Zn>0&&!i&&ks&&(o.shapeFlag&6?ks[ks.indexOf(e)]=o:ks.push(o)),o.patchFlag=-2,o}if(N0(e)&&(e=e.__vccOpts),t){t=Lh(t);let{class:o,style:r}=t;o&&!Ye(o)&&(t.class=so(o)),mt(r)&&(ao(r)&&!Ie(r)&&(r=nt({},r)),t.style=to(r))}const l=Ye(e)?1:ir(e)?128:Jm(e)?64:mt(e)?4:je(e)?2:0;return eu(e,t,s,a,n,l,i,!0)}function Lh(e){return e?ao(e)||hh(e)?nt({},e):e:null}function Oa(e,t,s=!1,a=!1){const{props:n,ref:i,patchFlag:l,children:o,transition:r}=e,c=t?Mh(n||{},t):n,d={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&Oh(c),ref:t&&t.ref?s&&i?Ie(i)?i.concat(jo(t)):[i,jo(t)]:jo(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:o,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==ls?l===-1?16:l|16:l,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:r,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Oa(e.ssContent),ssFallback:e.ssFallback&&Oa(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return r&&a&&an(d,r.clone(d)),d}function tu(e=" ",t=0){return Lt(kn,null,e,t)}function E0(e,t){const s=Lt(Wn,null,e);return s.staticCount=t,s}function Nh(e="",t=!1){return t?(Hl(),lr(Ht,null,e)):Lt(Ht,null,e)}function Bs(e){return e==null||typeof e=="boolean"?Lt(Ht):Ie(e)?Lt(ls,null,e.slice()):nn(e)?ja(e):Lt(kn,null,String(e))}function ja(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Oa(e)}function su(e,t){let s=0;const{shapeFlag:a}=e;if(t==null)t=null;else if(Ie(t))s=16;else if(typeof t=="object")if(a&65){const n=t.default;n&&(n._c&&(n._d=!1),su(e,n()),n._c&&(n._d=!0));return}else{s=32;const n=t._;!n&&!hh(t)?t._ctx=ms:n===3&&ms&&(ms.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else je(t)?(t={default:t,_ctx:ms},s=32):(t=String(t),a&64?(s=16,t=[tu(t)]):s=8);e.children=t,e.shapeFlag|=s}function Mh(...e){const t={};for(let s=0;sfs||ms;let or,Di;{const e=Lr(),t=(s,a)=>{let n;return(n=e[s])||(n=e[s]=[]),n.push(a),i=>{n.length>1?n.forEach(l=>l(i)):n[0](i)}};or=t("__VUE_INSTANCE_SETTERS__",s=>fs=s),Di=t("__VUE_SSR_SETTERS__",s=>Yn=s)}const Qi=e=>{const t=fs;return or(e),e.scope.on(),()=>{e.scope.off(),or(t)}},Vl=()=>{fs&&fs.scope.off(),or(null)};function Ph(e){return e.vnode.shapeFlag&4}let Yn=!1;function $h(e,t=!1,s=!1){t&&Di(t);const{props:a,children:n}=e.vnode,i=Ph(e);d0(e,a,i,t),m0(e,n,s||t);const l=i?I0(e,t):void 0;return t&&Di(!1),l}function I0(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Qc);const{setup:a}=s;if(a){tn();const n=e.setupContext=a.length>1?Uh(e):null,i=Qi(e),l=Yi(a,e,0,[e.props,n]),o=Nd(l);if(sn(),i(),(o||e.sp)&&!Qa(e)&&qd(e),o){if(l.then(Vl,Vl),t)return l.then(r=>{ad(e,r,t)}).catch(r=>{ai(r,e,0)});e.asyncDep=l}else ad(e,l,t)}else Bh(e,t)}function ad(e,t,s){je(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:mt(t)&&(e.setupState=Ud(t)),Bh(e,s)}let rr,nd;function Fh(e){rr=e,nd=t=>{t.render._rc&&(t.withProxy=new Proxy(t.ctx,Px))}}const O0=()=>!rr;function Bh(e,t,s){const a=e.type;if(!e.render){if(!t&&rr&&!a.render){const n=a.template||Zd(e).template;if(n){const{isCustomElement:i,compilerOptions:l}=e.appContext.config,{delimiters:o,compilerOptions:r}=a,c=nt(nt({isCustomElement:i,delimiters:o},l),r);a.render=rr(n,c)}}e.render=a.render||hs,nd&&nd(e)}{const n=Qi(e);tn();try{Zx(e)}finally{sn(),n()}}}const L0={get(e,t){return ws(e,"get",""),e[t]}};function Uh(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,L0),slots:e.slots,emit:e.emit,expose:t}}function oo(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Ud(Mm(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in El)return El[s](e)},has(t,s){return s in t||s in El}})):e.proxy}function id(e,t=!0){return je(e)?e.displayName||e.name:e.name||t&&e.__name}function N0(e){return je(e)&&"__vccOpts"in e}const H=(e,t)=>By(e,t,Yn);function Ui(e,t,s){try{jl(-1);const a=arguments.length;return a===2?mt(t)&&!Ie(t)?nn(t)?Lt(e,null,[t]):Lt(e,t):Lt(e,null,t):(a>3?s=Array.prototype.slice.call(arguments,2):a===3&&nn(s)&&(s=[s]),Lt(e,t,s))}finally{jl(1)}}function M0(){}function D0(e,t,s,a){const n=s[a];if(n&&zh(n,e))return n;const i=t();return i.memo=e.slice(),i.cacheIndex=a,s[a]=i}function zh(e,t){const s=e.memo;if(s.length!=t.length)return!1;for(let a=0;a0&&ks&&ks.push(e),!0}const Hh="3.5.38",P0=hs,$0=Ky,F0=wi,B0=jm,U0={createComponentInstance:Dh,setupComponent:$h,renderComponentRoot:Ho,setCurrentRenderingInstance:Bl,isVNode:nn,normalizeVNode:Bs,getComponentPublicInstance:oo,ensureValidVNode:Jd,pushWarningContext:Vy,popWarningContext:qy},z0=U0,H0=null,j0=null,V0=null;/** +**/const Vm=[];function Yy(e){Vm.push(e)}function Xy(){Vm.pop()}function ex(e,t){}const tx={SETUP_FUNCTION:0,0:"SETUP_FUNCTION",RENDER_FUNCTION:1,1:"RENDER_FUNCTION",NATIVE_EVENT_HANDLER:5,5:"NATIVE_EVENT_HANDLER",COMPONENT_EVENT_HANDLER:6,6:"COMPONENT_EVENT_HANDLER",VNODE_HOOK:7,7:"VNODE_HOOK",DIRECTIVE_HOOK:8,8:"DIRECTIVE_HOOK",TRANSITION_HOOK:9,9:"TRANSITION_HOOK",APP_ERROR_HANDLER:10,10:"APP_ERROR_HANDLER",APP_WARN_HANDLER:11,11:"APP_WARN_HANDLER",FUNCTION_REF:12,12:"FUNCTION_REF",ASYNC_COMPONENT_LOADER:13,13:"ASYNC_COMPONENT_LOADER",SCHEDULER:14,14:"SCHEDULER",COMPONENT_UPDATE:15,15:"COMPONENT_UPDATE",APP_UNMOUNT_CLEANUP:16,16:"APP_UNMOUNT_CLEANUP"},sx={sp:"serverPrefetch hook",bc:"beforeCreate hook",c:"created hook",bm:"beforeMount hook",m:"mounted hook",bu:"beforeUpdate hook",u:"updated",bum:"beforeUnmount hook",um:"unmounted hook",a:"activated hook",da:"deactivated hook",ec:"errorCaptured hook",rtc:"renderTracked hook",rtg:"renderTriggered hook",0:"setup function",1:"render function",2:"watcher getter",3:"watcher callback",4:"watcher cleanup function",5:"native event handler",6:"component event handler",7:"vnode hook",8:"directive hook",9:"transition hook",10:"app errorHandler",11:"app warnHandler",12:"ref function",13:"async component loader",14:"scheduler flush",15:"component update",16:"app unmount cleanup function"};function Yi(e,t,s,a){try{return a?e(...a):e()}catch(n){ni(n,t,s)}}function Zs(e,t,s,a){if(je(e)){const n=Yi(e,t,s,a);return n&&Bd(n)&&n.catch(i=>{ni(i,t,s)}),n}if(Le(e)){const n=[];for(let i=0;i>>1,n=Is[a],i=Bl(n);i=Bl(s)?Is.push(e):Is.splice(nx(t),0,e),e.flags|=1,Wm()}}function Wm(){ir||(ir=Gm.then(Km))}function Fl(e){Le(e)?Ni.push(...e):yn&&e.id===-1?yn.splice(wi+1,0,e):e.flags&1||(Ni.push(e),e.flags|=1),Wm()}function cp(e,t,s=Ta+1){for(;sBl(s)-Bl(a));if(Ni.length=0,yn){yn.push(...t);return}for(yn=t,wi=0;wie.id==null?e.flags&2?-1:1/0:e.id;function Km(e){try{for(Ta=0;Taki.emit(n,...i)),wo=[]):typeof window<"u"&&window.HTMLElement&&!((a=(s=window.navigator)==null?void 0:s.userAgent)!=null&&a.includes("jsdom"))?((t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(i=>{Jm(i,t)}),setTimeout(()=>{ki||(t.__VUE_DEVTOOLS_HOOK_REPLAY__=null,wo=[])},3e3)):wo=[]}let ms=null,jr=null;function Ul(e){const t=ms;return ms=e,jr=e&&e.type.__scopeId||null,t}function ix(e){jr=e}function lx(){jr=null}const ox=e=>Kd;function Kd(e,t=ms,s){if(!t||e._n)return e;const a=(...n)=>{a._d&&ql(-1);const i=Ul(t);let l;try{l=e(...n)}finally{Ul(i),a._d&&ql(1)}return l};return a._n=!0,a._c=!0,a._d=!0,a}function rx(e,t){if(ms===null)return e;const s=ro(ms),a=e.dirs||(e.dirs=[]);for(let n=0;n1)return s&&je(t)?t.call(a&&a.proxy):t}}function cx(){return!!(Ls()||Wn)}const Zm=Symbol.for("v-scx"),Qm=()=>na(Zm);function dx(e,t){return io(e,null,t)}function ux(e,t){return io(e,null,{flush:"post"})}function Ym(e,t){return io(e,null,{flush:"sync"})}function jt(e,t,s){return io(e,t,s)}function io(e,t,s=it){const{immediate:a,deep:n,flush:i,once:l}=s,o=nt({},s),r=t&&a||!t&&i!=="post";let c;if(Yn){if(i==="sync"){const m=Qm();c=m.__watcherHandles||(m.__watcherHandles=[])}else if(!r){const m=()=>{};return m.stop=hs,m.resume=hs,m.pause=hs,m}}const d=fs;o.call=(m,h,g)=>Zs(m,d,h,g);let u=!1;i==="post"?o.scheduler=m=>{Vt(m,d&&d.suspense)}:i!=="sync"&&(u=!0,o.scheduler=(m,h)=>{h?m():Wd(m)}),o.augmentJob=m=>{t&&(m.flags|=4),u&&(m.flags|=2,d&&(m.id=d.uid,m.i=d))};const f=Qy(e,t,o);return Yn&&(c?c.push(f):r&&f()),f}function px(e,t,s){const a=this.proxy,n=Qe(e)?e.includes(".")?Xm(a,e):()=>a[e]:e.bind(a,a);let i;je(t)?i=t:(i=t.handler,s=t);const l=Xi(this),o=io(n,i.bind(a),s);return l(),o}function Xm(e,t){const s=t.split(".");return()=>{let a=e;for(let n=0;ne.__isTeleport,Hn=e=>e&&(e.disabled||e.disabled===""),fx=e=>e&&(e.defer||e.defer===""),dp=e=>typeof SVGElement<"u"&&e instanceof SVGElement,up=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,ed=(e,t)=>{const s=e&&e.to;return Qe(s)?t?t(s):null:s},mx={name:"Teleport",__isTeleport:!0,process(e,t,s,a,n,i,l,o,r,c){const{mc:d,pc:u,pbc:f,o:{insert:m,querySelector:h,createText:g,createComment:A,parentNode:w}}=c,y=Hn(t.props);let{dynamicChildren:b}=t;const x=(T,L,E)=>{T.shapeFlag&16&&d(T.children,L,E,n,i,l,o,r)},S=(T=t)=>{const L=Hn(T.props),E=T.target=ed(T.props,h),P=td(E,T,g,m);E&&(l!=="svg"&&dp(E)?l="svg":l!=="mathml"&&up(E)&&(l="mathml"),n&&n.isCE&&(n.ce._teleportTargets||(n.ce._teleportTargets=new Set)).add(E),L||(x(T,E,P),xl(T,!1)))},_=T=>{const L=()=>{if(vn.get(T)===L){if(vn.delete(T),Hn(T.props)){const E=w(T.el)||s;x(T,E,T.anchor),xl(T,!0)}S(T)}};vn.set(T,L),Vt(L,i)};if(e==null){const T=t.el=g(""),L=t.anchor=g("");if(m(T,s,a),m(L,s,a),fx(t.props)||i&&i.pendingBranch){_(t);return}y&&(x(t,s,L),xl(t,!0)),S()}else{t.el=e.el;const T=t.anchor=e.anchor,L=vn.get(e);if(L){L.flags|=8,vn.delete(e),_(t);return}t.targetStart=e.targetStart;const E=t.target=e.target,P=t.targetAnchor=e.targetAnchor,R=Hn(e.props),I=R?s:E,H=R?T:P;if(l==="svg"||dp(E)?l="svg":(l==="mathml"||up(E))&&(l="mathml"),b?(f(e.dynamicChildren,b,I,n,i,l,o),iu(e,t,!0)):r||u(e,t,I,H,n,i,l,o,!1),y)R?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):ko(t,s,T,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const X=t.target=ed(t.props,h);X&&ko(t,X,null,c,0)}else R&&ko(t,E,P,c,1);xl(t,y)}},remove(e,t,s,{um:a,o:{remove:n}},i){const{shapeFlag:l,children:o,anchor:r,targetStart:c,targetAnchor:d,target:u,props:f}=e,m=i||!Hn(f),h=vn.get(e);if(h&&(h.flags|=8,vn.delete(e)),u&&(n(c),n(d)),i&&n(r),!h&&l&16)for(let g=0;g{e.isMounted=!0}),Wr(()=>{e.isUnmounting=!0}),e}const Xs=[Function,Array],Zd={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:Xs,onEnter:Xs,onAfterEnter:Xs,onEnterCancelled:Xs,onBeforeLeave:Xs,onLeave:Xs,onAfterLeave:Xs,onLeaveCancelled:Xs,onBeforeAppear:Xs,onAppear:Xs,onAfterAppear:Xs,onAppearCancelled:Xs},sh=e=>{const t=e.subTree;return t.component?sh(t.component):t},gx={name:"BaseTransition",props:Zd,setup(e,{slots:t}){const s=Ls(),a=Jd();return()=>{const n=t.default&&qr(t.default(),!0),i=n&&n.length?ah(n):s.subTree?Bh():void 0;if(!i)return;const l=dt(e),{mode:o}=l;if(a.isLeaving)return Sc(i);const r=pp(i);if(!r)return Sc(i);let c=Ui(r,l,a,s,u=>c=u);r.type!==Ht&&an(r,c);let d=s.subTree&&pp(s.subTree);if(d&&d.type!==Ht&&!fa(d,r)&&sh(s).type!==Ht){let u=Ui(d,l,a,s);if(an(d,u),o==="out-in"&&r.type!==Ht)return a.isLeaving=!0,u.afterLeave=()=>{a.isLeaving=!1,s.job.flags&8||s.update(),delete u.afterLeave,d=void 0},Sc(i);o==="in-out"&&r.type!==Ht?u.delayLeave=(f,m,h)=>{const g=ih(a,d);g[String(d.key)]=d,f[ea]=()=>{m(),f[ea]=void 0,delete c.delayedLeave,d=void 0},c.delayedLeave=()=>{h(),delete c.delayedLeave,d=void 0}}:d=void 0}else d&&(d=void 0);return i}}};function ah(e){let t=e[0];if(e.length>1){for(const s of e)if(s.type!==Ht){t=s;break}}return t}const nh=gx;function ih(e,t){const{leavingVNodes:s}=e;let a=s.get(t.type);return a||(a=Object.create(null),s.set(t.type,a)),a}function Ui(e,t,s,a,n){const{appear:i,mode:l,persisted:o=!1,onBeforeEnter:r,onEnter:c,onAfterEnter:d,onEnterCancelled:u,onBeforeLeave:f,onLeave:m,onAfterLeave:h,onLeaveCancelled:g,onBeforeAppear:A,onAppear:w,onAfterAppear:y,onAppearCancelled:b}=t,x=String(e.key),S=ih(s,e),_=(E,P)=>{E&&Zs(E,a,9,P)},T=(E,P)=>{const R=P[1];_(E,P),Le(E)?E.every(I=>I.length<=1)&&R():E.length<=1&&R()},L={mode:l,persisted:o,beforeEnter(E){let P=r;if(!s.isMounted)if(i)P=A||r;else return;E[ea]&&E[ea](!0);const R=S[x];R&&fa(e,R)&&R.el[ea]&&R.el[ea](),_(P,[E])},enter(E){if(S[x]===e)return;let P=c,R=d,I=u;if(!s.isMounted)if(i)P=w||c,R=y||d,I=b||u;else return;let H=!1;E[dl]=Q=>{H||(H=!0,Q?_(I,[E]):_(R,[E]),L.delayedLeave&&L.delayedLeave(),E[dl]=void 0)};const X=E[dl].bind(null,!1);P?T(P,[E,X]):X()},leave(E,P){const R=String(e.key);if(E[dl]&&E[dl](!0),s.isUnmounting)return P();_(f,[E]);let I=!1;E[ea]=X=>{I||(I=!0,P(),X?_(g,[E]):_(h,[E]),E[ea]=void 0,S[R]===e&&delete S[R])};const H=E[ea].bind(null,!1);S[R]=e,m?T(m,[E,H]):H()},clone(E){const P=Ui(E,t,s,a,n);return n&&n(P),P}};return L}function Sc(e){if(oo(e))return e=Oa(e),e.children=null,e}function pp(e){if(!oo(e))return th(e.type)&&e.children?ah(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:s}=e;if(s){if(t&16)return s[0];if(t&32&&je(s.default))return s.default()}}function an(e,t){e.shapeFlag&6&&e.component?(e.transition=t,an(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function qr(e,t=!1,s){let a=[],n=0;for(let i=0;i1)for(let i=0;is.value,set:i=>s.value=i})}return s}function fp(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const or=new WeakMap;function Mi(e,t,s,a,n=!1){if(Le(e)){e.forEach((g,A)=>Mi(g,t&&(Le(t)?t[A]:t),s,a,n));return}if(Ya(a)&&!n){a.shapeFlag&512&&a.type.__asyncResolved&&a.component.subTree.component&&Mi(e,t,s,a.component.subTree);return}const i=a.shapeFlag&4?ro(a.component):a.el,l=n?null:i,{i:o,r}=e,c=t&&t.r,d=o.refs===it?o.refs={}:o.refs,u=o.setupState,f=dt(u),m=u===it?Ti:g=>fp(d,g)?!1:ht(f,g),h=(g,A)=>!(A&&fp(d,A));if(c!=null&&c!==r){if(mp(t),Qe(c))d[c]=null,m(c)&&(u[c]=null);else if(Wt(c)){const g=t;h(c,g.k)&&(c.value=null),g.k&&(d[g.k]=null)}}if(je(r))Yi(r,o,12,[l,d]);else{const g=Qe(r),A=Wt(r);if(g||A){const w=()=>{if(e.f){const y=g?m(r)?u[r]:d[r]:h()||!e.k?r.value:d[e.k];if(n)Le(y)&&Fd(y,i);else if(Le(y))y.includes(i)||y.push(i);else if(g)d[r]=[i],m(r)&&(u[r]=d[r]);else{const b=[i];h(r,e.k)&&(r.value=b),e.k&&(d[e.k]=b)}}else g?(d[r]=l,m(r)&&(u[r]=l)):A&&(h(r,e.k)&&(r.value=l),e.k&&(d[e.k]=l))};if(l){const y=()=>{w(),or.delete(e)};y.id=-1,or.set(e,y),Vt(y,s)}else mp(e),w()}}}function mp(e){const t=or.get(e);t&&(t.flags|=8,or.delete(e))}let hp=!1;const gi=()=>{hp||(console.error("Hydration completed but contains mismatches."),hp=!0)},xx=e=>e.namespaceURI.includes("svg")&&e.tagName!=="foreignObject",_x=e=>e.namespaceURI.includes("MathML"),So=e=>{if(e.nodeType===1){if(xx(e))return"svg";if(_x(e))return"mathml"}},Ei=e=>e.nodeType===8;function wx(e){const{mt:t,p:s,o:{patchProp:a,createText:n,nextSibling:i,parentNode:l,remove:o,insert:r,createComment:c}}=e,d=(b,x)=>{if(!x.hasChildNodes()){s(null,b,x),lr(),x._vnode=b;return}u(x.firstChild,b,null,null,null),lr(),x._vnode=b},u=(b,x,S,_,T,L=!1)=>{L=L||!!x.dynamicChildren;const E=Ei(b)&&b.data==="[",P=()=>g(b,x,S,_,T,E),{type:R,ref:I,shapeFlag:H,patchFlag:X}=x;let Q=b.nodeType;x.el=b,X===-2&&(L=!1,x.dynamicChildren=null);let N=null;switch(R){case kn:Q!==3?x.children===""?(r(x.el=n(""),l(b),b),N=b):N=P():(b.data!==x.children&&(gi(),b.data=x.children),N=i(b));break;case Ht:y(b)?(N=i(b),w(x.el=b.content.firstChild,b,S)):Q!==8||E?N=P():N=i(b);break;case Kn:if(E&&(b=i(b),Q=b.nodeType),Q===1||Q===3){N=b;const O=!x.children.length;for(let $=0;${L=L||!!x.dynamicChildren;const{type:E,props:P,patchFlag:R,shapeFlag:I,dirs:H,transition:X}=x,Q=E==="input"||E==="option";if(Q||R!==-1){H&&Ea(x,null,S,"created");let N=!1;if(y(b)){N=Ih(null,X)&&S&&S.vnode.props&&S.vnode.props.appear;const $=b.content.firstChild;if(N){const ce=$.getAttribute("class");ce&&($.$cls=ce),X.beforeEnter($)}w($,b,S),x.el=b=$}if(I&16&&!(P&&(P.innerHTML||P.textContent))){let $=m(b.firstChild,x,b,S,_,T,L);for($&&!Co(b,1)&&gi();$;){const ce=$;$=$.nextSibling,o(ce)}}else if(I&8){let $=x.children;$[0]===` +`&&(b.tagName==="PRE"||b.tagName==="TEXTAREA")&&($=$.slice(1));const{textContent:ce}=b;ce!==$&&ce!==$.replace(/\r\n|\r/g,` +`)&&(Co(b,0)||gi(),b.textContent=x.children)}if(P){if(Q||!L||R&48){const $=b.tagName.includes("-");for(const ce in P)(Q&&(ce.endsWith("value")||ce==="indeterminate")||ti(ce)&&!Za(ce)||ce[0]==="."||$&&!Za(ce))&&a(b,ce,null,P[ce],void 0,S)}else if(P.onClick)a(b,"onClick",null,P.onClick,void 0,S);else if(R&4&&Qa(P.style))for(const $ in P.style)P.style[$]}let O;(O=P&&P.onVnodeBeforeMount)&&$s(O,S,x),H&&Ea(x,null,S,"beforeMount"),((O=P&&P.onVnodeMounted)||H||N)&&Mh(()=>{O&&$s(O,S,x),N&&X.enter(b),H&&Ea(x,null,S,"mounted")},_)}return b.nextSibling},m=(b,x,S,_,T,L,E)=>{E=E||!!x.dynamicChildren;const P=x.children,R=P.length;let I=!1;for(let H=0;H{const{slotScopeIds:E}=x;E&&(T=T?T.concat(E):E);const P=l(b),R=m(i(b),x,P,S,_,T,L);return R&&Ei(R)&&R.data==="]"?i(x.anchor=R):(gi(),r(x.anchor=c("]"),P,R),R)},g=(b,x,S,_,T,L)=>{if(Co(b.parentElement,1)||gi(),x.el=null,L){const R=A(b);for(;;){const I=i(b);if(I&&I!==R)o(I);else break}}const E=i(b),P=l(b);return o(b),s(null,x,P,E,S,_,So(P),T),S&&(S.vnode.el=x.el,Jr(S,x.el)),E},A=(b,x="[",S="]")=>{let _=0;for(;b;)if(b=i(b),b&&Ei(b)&&(b.data===x&&_++,b.data===S)){if(_===0)return i(b);_--}return b},w=(b,x,S)=>{const _=x.parentNode;_&&_.replaceChild(b,x);let T=S;for(;T;)T.vnode.el===x&&(T.vnode.el=T.subTree.el=b),T=T.parent},y=b=>b.nodeType===1&&b.tagName==="TEMPLATE";return[d,u]}const vp="data-allow-mismatch",kx={0:"text",1:"children",2:"class",3:"style",4:"attribute"};function Co(e,t){if(t===0||t===1)for(;e&&!e.hasAttribute(vp);)e=e.parentElement;const s=e&&e.getAttribute(vp);if(s==null)return!1;if(s==="")return!0;{const a=s.split(",");return t===0&&a.includes("children")?!0:a.includes(kx[t])}}const Sx=$r().requestIdleCallback||(e=>setTimeout(e,1)),Cx=$r().cancelIdleCallback||(e=>clearTimeout(e)),Tx=(e=1e4)=>t=>{const s=Sx(t,{timeout:e});return()=>Cx(s)};function Ex(e){const{top:t,left:s,bottom:a,right:n}=e.getBoundingClientRect(),{innerHeight:i,innerWidth:l}=window;return(t>0&&t0&&a0&&s0&&n(t,s)=>{const a=new IntersectionObserver(n=>{for(const i of n)if(i.isIntersecting){a.disconnect(),t();break}},e);return s(n=>{if(n instanceof Element){if(Ex(n))return t(),a.disconnect(),!1;a.observe(n)}}),()=>a.disconnect()},Rx=e=>t=>{if(e){const s=matchMedia(e);if(s.matches)t();else return s.addEventListener("change",t,{once:!0}),()=>s.removeEventListener("change",t)}},Ix=(e=[])=>(t,s)=>{Qe(e)&&(e=[e]);let a=!1;const n=l=>{a||(a=!0,i(),t(),l.target.dispatchEvent(new l.constructor(l.type,l)))},i=()=>{s(l=>{for(const o of e)l.removeEventListener(o,n)})};return s(l=>{for(const o of e)l.addEventListener(o,n,{once:!0})}),i};function Ox(e,t){if(Ei(e)&&e.data==="["){let s=1,a=e.nextSibling;for(;a;){if(a.nodeType===1){if(t(a)===!1)break}else if(Ei(a))if(a.data==="]"){if(--s===0)break}else a.data==="["&&s++;a=a.nextSibling}}else t(e)}const Ya=e=>!!e.type.__asyncLoader;function Lx(e){je(e)&&(e={loader:e});const{loader:t,loadingComponent:s,errorComponent:a,delay:n=200,hydrate:i,timeout:l,suspensible:o=!0,onError:r}=e;let c=null,d,u=0;const f=()=>(u++,c=null,m()),m=()=>{let h;return c||(h=c=t().catch(g=>{if(g=g instanceof Error?g:new Error(String(g)),r)return new Promise((A,w)=>{r(g,()=>A(f()),()=>w(g),u+1)});throw g}).then(g=>h!==c&&c?c:(g&&(g.__esModule||g[Symbol.toStringTag]==="Module")&&(g=g.default),d=g,g)))};return lo({name:"AsyncComponentWrapper",__asyncLoader:m,__asyncHydrate(h,g,A){let w=!1;(g.bu||(g.bu=[])).push(()=>w=!0);const y=()=>{w||A()},b=i?()=>{const x=i(y,S=>Ox(h,S));x&&(g.bum||(g.bum=[])).push(x)}:y;d?b():m().then(()=>!g.isUnmounted&&b())},get __asyncResolved(){return d},setup(){const h=fs;if(Qd(h),d)return()=>To(d,h);const g=S=>{c=null,ni(S,h,13,!a)};if(o&&h.suspense||Yn)return m().then(S=>()=>To(S,h)).catch(S=>(g(S),()=>a?Lt(a,{error:S}):null));const A=p(!1),w=p(),y=p(!!n);let b,x;return xt(()=>{b!=null&&clearTimeout(b),x!=null&&clearTimeout(x)}),n&&(x=setTimeout(()=>{h.isUnmounted||(y.value=!1)},n)),l!=null&&(b=setTimeout(()=>{if(!h.isUnmounted&&!A.value&&!w.value){const S=new Error(`Async component timed out after ${l}ms.`);g(S),w.value=S}},l)),m().then(()=>{h.isUnmounted||(A.value=!0,h.parent&&oo(h.parent.vnode)&&h.parent.update())}).catch(S=>{if(h.isUnmounted){c=null;return}g(S),w.value=S}),()=>{if(A.value&&d)return To(d,h);if(w.value&&a)return Lt(a,{error:w.value});if(s&&!y.value)return To(s,h)}}})}function To(e,t){const{ref:s,props:a,children:n,ce:i}=t.vnode,l=Lt(e,a,n);return l.ref=s,l.ce=i,delete t.vnode.ce,l}const oo=e=>e.type.__isKeepAlive,Nx={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){const s=Ls(),a=s.ctx;if(!a.renderer)return()=>{const y=t.default&&t.default();return y&&y.length===1?y[0]:y};const n=new Map,i=new Set;let l=null;const o=s.suspense,{renderer:{p:r,m:c,um:d,o:{createElement:u}}}=a,f=u("div");a.activate=(y,b,x,S,_)=>{const T=y.component;c(y,b,x,0,o),r(T.vnode,y,b,x,T,o,S,y.slotScopeIds,_),Vt(()=>{T.isDeactivated=!1,T.a&&Li(T.a);const L=y.props&&y.props.onVnodeMounted;L&&$s(L,T.parent,y)},o)},a.deactivate=y=>{const b=y.component;cr(b.m),cr(b.a),c(y,f,null,1,o),Vt(()=>{b.da&&Li(b.da);const x=y.props&&y.props.onVnodeUnmounted;x&&$s(x,b.parent,y),b.isDeactivated=!0},o)};function m(y){Cc(y),d(y,s,o,!0)}function h(y){n.forEach((b,x)=>{const S=dd(Ya(b)?b.type.__asyncResolved||{}:b.type);S&&!y(S)&&g(x)})}function g(y){const b=n.get(y);b&&(!l||!fa(b,l))?m(b):l&&Cc(l),n.delete(y),i.delete(y)}jt(()=>[e.include,e.exclude],([y,b])=>{y&&h(x=>_l(y,x)),b&&h(x=>!_l(b,x))},{flush:"post",deep:!0});let A=null;const w=()=>{A!=null&&(dr(s.subTree.type)?Vt(()=>{n.set(A,Eo(s.subTree))},s.subTree.suspense):n.set(A,Eo(s.subTree)))};return tt(w),Gr(w),Wr(()=>{n.forEach(y=>{const{subTree:b,suspense:x}=s,S=Eo(b);if(y.type===S.type&&y.key===S.key){Cc(S);const _=S.component.da;_&&Vt(_,x);return}m(y)})}),()=>{if(A=null,!t.default)return l=null;const y=t.default(),b=y[0];if(y.length>1)return l=null,y;if(!nn(b)||!(b.shapeFlag&4)&&!(b.shapeFlag&128))return l=null,b;let x=Eo(b);if(x.type===Ht)return l=null,x;const S=x.type,_=dd(Ya(x)?x.type.__asyncResolved||{}:S),{include:T,exclude:L,max:E}=e;if(T&&(!_||!_l(T,_))||L&&_&&_l(L,_))return x.shapeFlag&=-257,l=x,b;const P=x.key==null?S:x.key,R=n.get(P);return x.el&&(x=Oa(x),b.shapeFlag&128&&(b.ssContent=x)),A=P,R?(x.el=R.el,x.component=R.component,x.transition&&an(x,x.transition),x.shapeFlag|=512,i.delete(P),i.add(P)):(i.add(P),E&&i.size>parseInt(E,10)&&g(i.values().next().value)),x.shapeFlag|=256,l=x,dr(b.type)?b:x}}},Mx=Nx;function _l(e,t){return Le(e)?e.some(s=>_l(s,t)):Qe(e)?e.split(",").includes(t):qb(e)?(e.lastIndex=0,e.test(t)):!1}function rs(e,t){lh(e,"a",t)}function Kt(e,t){lh(e,"da",t)}function lh(e,t,s=fs){const a=e.__wdc||(e.__wdc=()=>{let n=s;for(;n;){if(n.isDeactivated)return;n=n.parent}return e()});if(Vr(t,a,s),s){let n=s.parent;for(;n&&n.parent;)oo(n.parent.vnode)&&Dx(a,t,s,n),n=n.parent}}function Dx(e,t,s,a){const n=Vr(t,e,a,!0);xt(()=>{Fd(a[t],n)},s)}function Cc(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function Eo(e){return e.shapeFlag&128?e.ssContent:e}function Vr(e,t,s=fs,a=!1){if(s){const n=s[e]||(s[e]=[]),i=t.__weh||(t.__weh=(...l)=>{tn();const o=Xi(s),r=Zs(t,s,e,l);return o(),sn(),r});return a?n.unshift(i):n.push(i),i}}const ln=e=>(t,s=fs)=>{(!Yn||e==="sp")&&Vr(e,(...a)=>t(...a),s)},oh=ln("bm"),tt=ln("m"),Yd=ln("bu"),Gr=ln("u"),Wr=ln("bum"),xt=ln("um"),rh=ln("sp"),ch=ln("rtg"),dh=ln("rtc");function uh(e,t=fs){Vr("ec",e,t)}const Xd="components",Px="directives";function $x(e,t){return eu(Xd,e,!0,t)||e}const ph=Symbol.for("v-ndc");function Fx(e){return Qe(e)?eu(Xd,e,!1)||e:e||ph}function Bx(e){return eu(Px,e)}function eu(e,t,s=!0,a=!1){const n=ms||fs;if(n){const i=n.type;if(e===Xd){const o=dd(i,!1);if(o&&(o===t||o===wt(t)||o===ai(wt(t))))return i}const l=gp(n[e]||i[e],t)||gp(n.appContext[e],t);return!l&&a?i:l}}function gp(e,t){return e&&(e[t]||e[wt(t)]||e[ai(wt(t))])}function Ux(e,t,s,a){let n;const i=s&&s[a],l=Le(e);if(l||Qe(e)){const o=l&&Qa(e);let r=!1,c=!1;o&&(r=!Hs(e),c=Ia(e),e=Ur(e)),n=new Array(e.length);for(let d=0,u=e.length;dt(o,r,void 0,i&&i[r]));else{const o=Object.keys(e);n=new Array(o.length);for(let r=0,c=o.length;r{const i=a.fn(...n);return i&&(i.key=a.key),i}:a.fn)}return e}function Hx(e,t,s={},a,n){if(ms.ce||ms.parent&&Ya(ms.parent)&&ms.parent.ce){const c=Object.keys(s).length>0;return t!=="default"&&(s.name=t),jl(),ur(ls,null,[Lt("slot",s,a&&a())],c?-2:64)}let i=e[t];i&&i._c&&(i._d=!1),jl();const l=i&&tu(i(s)),o=s.key||l&&l.key,r=ur(ls,{key:(o&&!Cs(o)?o:`_${t}`)+(!l&&a?"_fb":"")},l||(a?a():[]),l&&e._===1?64:-2);return!n&&r.scopeId&&(r.slotScopeIds=[r.scopeId+"-s"]),i&&i._c&&(i._d=!0),r}function tu(e){return e.some(t=>nn(t)?!(t.type===Ht||t.type===ls&&!tu(t.children)):!0)?e:null}function jx(e,t){const s={};for(const a in e)s[t&&/[A-Z]/.test(a)?`on:${a}`:Oi(a)]=e[a];return s}const sd=e=>e?Hh(e)?ro(e):sd(e.parent):null,Al=nt(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>sd(e.parent),$root:e=>sd(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>su(e),$forceUpdate:e=>e.f||(e.f=()=>{Wd(e.update)}),$nextTick:e=>e.n||(e.n=Mt.bind(e.proxy)),$watch:e=>px.bind(e)}),Tc=(e,t)=>e!==it&&!e.__isScriptSetup&&ht(e,t),ad={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:a,data:n,props:i,accessCache:l,type:o,appContext:r}=e;if(t[0]!=="$"){const f=l[t];if(f!==void 0)switch(f){case 1:return a[t];case 2:return n[t];case 4:return s[t];case 3:return i[t]}else{if(Tc(a,t))return l[t]=1,a[t];if(n!==it&&ht(n,t))return l[t]=2,n[t];if(ht(i,t))return l[t]=3,i[t];if(s!==it&&ht(s,t))return l[t]=4,s[t];nd&&(l[t]=0)}}const c=Al[t];let d,u;if(c)return t==="$attrs"&&ws(e.attrs,"get",""),c(e);if((d=o.__cssModules)&&(d=d[t]))return d;if(s!==it&&ht(s,t))return l[t]=4,s[t];if(u=r.config.globalProperties,ht(u,t))return u[t]},set({_:e},t,s){const{data:a,setupState:n,ctx:i}=e;return Tc(n,t)?(n[t]=s,!0):a!==it&&ht(a,t)?(a[t]=s,!0):ht(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:a,appContext:n,props:i,type:l}},o){let r;return!!(s[o]||e!==it&&o[0]!=="$"&&ht(e,o)||Tc(t,o)||ht(i,o)||ht(a,o)||ht(Al,o)||ht(n.config.globalProperties,o)||(r=l.__cssModules)&&r[o])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:ht(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}},qx=nt({},ad,{get(e,t){if(t!==Symbol.unscopables)return ad.get(e,t,e)},has(e,t){return t[0]!=="_"&&!Qb(t)}});function Vx(){return null}function Gx(){return null}function Wx(e){}function Kx(e){}function Jx(){return null}function Zx(){}function Qx(e,t){return null}function Yx(){return fh().slots}function Xx(){return fh().attrs}function fh(e){const t=Ls();return t.setupContext||(t.setupContext=Gh(t))}function zl(e){return Le(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}function e0(e,t){const s=zl(e);for(const a in t){if(a.startsWith("__skip"))continue;let n=s[a];n?Le(n)||je(n)?n=s[a]={type:n,default:t[a]}:n.default=t[a]:n===null&&(n=s[a]={default:t[a]}),n&&t[`__skip_${a}`]&&(n.skipFactory=!0)}return s}function t0(e,t){return!e||!t?e||t:Le(e)&&Le(t)?e.concat(t):nt({},zl(e),zl(t))}function s0(e,t){const s={};for(const a in e)t.includes(a)||Object.defineProperty(s,a,{enumerable:!0,get:()=>e[a]});return s}function a0(e){const t=Ls(),s=Yn;let a=e();Vl(),s&&Pi(!1);const n=()=>{Xi(t),s&&Pi(!0)},i=()=>{Ls()!==t&&t.scope.off(),Vl(),s&&Pi(!1)};return Bd(a)&&(a=a.catch(l=>{throw n(),Promise.resolve().then(()=>Promise.resolve().then(i)),l})),[a,()=>{n(),Promise.resolve().then(i)}]}let nd=!0;function n0(e){const t=su(e),s=e.proxy,a=e.ctx;nd=!1,t.beforeCreate&&bp(t.beforeCreate,e,"bc");const{data:n,computed:i,methods:l,watch:o,provide:r,inject:c,created:d,beforeMount:u,mounted:f,beforeUpdate:m,updated:h,activated:g,deactivated:A,beforeDestroy:w,beforeUnmount:y,destroyed:b,unmounted:x,render:S,renderTracked:_,renderTriggered:T,errorCaptured:L,serverPrefetch:E,expose:P,inheritAttrs:R,components:I,directives:H,filters:X}=t;if(c&&i0(c,a,null),l)for(const O in l){const $=l[O];je($)&&(a[O]=$.bind(s))}if(n){const O=n.call(s,s);mt(O)&&(e.data=Tn(O))}if(nd=!0,i)for(const O in i){const $=i[O],ce=je($)?$.bind(s,s):je($.get)?$.get.bind(s,s):hs,ue=!je($)&&je($.set)?$.set.bind(s):hs,Z=U({get:ce,set:ue});Object.defineProperty(a,O,{enumerable:!0,configurable:!0,get:()=>Z.value,set:J=>Z.value=J})}if(o)for(const O in o)mh(o[O],a,s,O);if(r){const O=je(r)?r.call(s):r;Reflect.ownKeys(O).forEach($=>{El($,O[$])})}d&&bp(d,e,"c");function N(O,$){Le($)?$.forEach(ce=>O(ce.bind(s))):$&&O($.bind(s))}if(N(oh,u),N(tt,f),N(Yd,m),N(Gr,h),N(rs,g),N(Kt,A),N(uh,L),N(dh,_),N(ch,T),N(Wr,y),N(xt,x),N(rh,E),Le(P))if(P.length){const O=e.exposed||(e.exposed={});P.forEach($=>{Object.defineProperty(O,$,{get:()=>s[$],set:ce=>s[$]=ce,enumerable:!0})})}else e.exposed||(e.exposed={});S&&e.render===hs&&(e.render=S),R!=null&&(e.inheritAttrs=R),I&&(e.components=I),H&&(e.directives=H),E&&Qd(e)}function i0(e,t,s=hs){Le(e)&&(e=id(e));for(const a in e){const n=e[a];let i;mt(n)?"default"in n?i=na(n.from||a,n.default,!0):i=na(n.from||a):i=na(n),Wt(i)?Object.defineProperty(t,a,{enumerable:!0,configurable:!0,get:()=>i.value,set:l=>i.value=l}):t[a]=i}}function bp(e,t,s){Zs(Le(e)?e.map(a=>a.bind(t.proxy)):e.bind(t.proxy),t,s)}function mh(e,t,s,a){let n=a.includes(".")?Xm(s,a):()=>s[a];if(Qe(e)){const i=t[e];je(i)&&jt(n,i)}else if(je(e))jt(n,e.bind(s));else if(mt(e))if(Le(e))e.forEach(i=>mh(i,t,s,a));else{const i=je(e.handler)?e.handler.bind(s):t[e.handler];je(i)&&jt(n,i,e)}}function su(e){const t=e.type,{mixins:s,extends:a}=t,{mixins:n,optionsCache:i,config:{optionMergeStrategies:l}}=e.appContext,o=i.get(t);let r;return o?r=o:!n.length&&!s&&!a?r=t:(r={},n.length&&n.forEach(c=>rr(r,c,l,!0)),rr(r,t,l)),mt(t)&&i.set(t,r),r}function rr(e,t,s,a=!1){const{mixins:n,extends:i}=t;i&&rr(e,i,s,!0),n&&n.forEach(l=>rr(e,l,s,!0));for(const l in t)if(!(a&&l==="expose")){const o=l0[l]||s&&s[l];e[l]=o?o(e[l],t[l]):t[l]}return e}const l0={data:yp,props:xp,emits:xp,methods:wl,computed:wl,beforeCreate:Es,created:Es,beforeMount:Es,mounted:Es,beforeUpdate:Es,updated:Es,beforeDestroy:Es,beforeUnmount:Es,destroyed:Es,unmounted:Es,activated:Es,deactivated:Es,errorCaptured:Es,serverPrefetch:Es,components:wl,directives:wl,watch:r0,provide:yp,inject:o0};function yp(e,t){return t?e?function(){return nt(je(e)?e.call(this,this):e,je(t)?t.call(this,this):t)}:t:e}function o0(e,t){return wl(id(e),id(t))}function id(e){if(Le(e)){const t={};for(let s=0;s{let d,u=it,f;return Ym(()=>{const m=e[n];is(d,m)&&(d=m,c())}),{get(){return r(),s.get?s.get(d):d},set(m){const h=s.set?s.set(m):m;if(!is(h,d)&&!(u!==it&&is(m,u)))return;const g=a.vnode.props,A=!!(g&&(t in g||n in g||i in g)&&(`onUpdate:${t}`in g||`onUpdate:${n}`in g||`onUpdate:${i}`in g));A||(d=m,c()),a.emit(`update:${t}`,h),is(m,u)&&(is(m,h)&&!is(h,f)||A&&u!==it&&!is(h,d))&&c(),u=m,f=h}}});return o[Symbol.iterator]=()=>{let r=0;return{next(){return r<2?{value:r++?l||it:o,done:!1}:{done:!0}}}},o}const vh=(e,t)=>t==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${wt(t)}Modifiers`]||e[`${Us(t)}Modifiers`];function p0(e,t,...s){if(e.isUnmounted)return;const a=e.vnode.props||it;let n=s;const i=t.startsWith("update:"),l=i&&vh(a,t.slice(7));l&&(l.trim&&(n=s.map(d=>Qe(d)?d.trim():d)),l.number&&(n=s.map(Pr)));let o,r=a[o=Oi(t)]||a[o=Oi(wt(t))];!r&&i&&(r=a[o=Oi(Us(t))]),r&&Zs(r,e,6,n);const c=a[o+"Once"];if(c){if(!e.emitted)e.emitted={};else if(e.emitted[o])return;e.emitted[o]=!0,Zs(c,e,6,n)}}const f0=new WeakMap;function gh(e,t,s=!1){const a=s?f0:t.emitsCache,n=a.get(e);if(n!==void 0)return n;const i=e.emits;let l={},o=!1;if(!je(e)){const r=c=>{const d=gh(c,t,!0);d&&(o=!0,nt(l,d))};!s&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}return!i&&!o?(mt(e)&&a.set(e,null),null):(Le(i)?i.forEach(r=>l[r]=null):nt(l,i),mt(e)&&a.set(e,l),l)}function Kr(e,t){return!e||!ti(t)?!1:(t=t.slice(2).replace(/Once$/,""),ht(e,t[0].toLowerCase()+t.slice(1))||ht(e,Us(t))||ht(e,t))}function Wo(e){const{type:t,vnode:s,proxy:a,withProxy:n,propsOptions:[i],slots:l,attrs:o,emit:r,render:c,renderCache:d,props:u,data:f,setupState:m,ctx:h,inheritAttrs:g}=e,A=Ul(e);let w,y;try{if(s.shapeFlag&4){const x=n||a,S=x;w=Bs(c.call(S,x,d,u,m,f,h)),y=o}else{const x=t;w=Bs(x.length>1?x(u,{attrs:o,slots:l,emit:r}):x(u,null)),y=t.props?o:h0(o)}}catch(x){Rl.length=0,ni(x,e,1),w=Lt(Ht)}let b=w;if(y&&g!==!1){const x=Object.keys(y),{shapeFlag:S}=b;x.length&&S&7&&(i&&x.some(Lr)&&(y=v0(y,i)),b=Oa(b,y,!1,!0))}return s.dirs&&(b=Oa(b,null,!1,!0),b.dirs=b.dirs?b.dirs.concat(s.dirs):s.dirs),s.transition&&an(b,s.transition),w=b,Ul(A),w}function m0(e,t=!0){let s;for(let a=0;a{let t;for(const s in e)(s==="class"||s==="style"||ti(s))&&((t||(t={}))[s]=e[s]);return t},v0=(e,t)=>{const s={};for(const a in e)(!Lr(a)||!(a.slice(9)in t))&&(s[a]=e[a]);return s};function g0(e,t,s){const{props:a,children:n,component:i}=e,{props:l,children:o,patchFlag:r}=t,c=i.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&r>=0){if(r&1024)return!0;if(r&16)return a?_p(a,l,c):!!l;if(r&8){const d=t.dynamicProps;for(let u=0;uObject.create(yh),_h=e=>Object.getPrototypeOf(e)===yh;function b0(e,t,s,a=!1){const n={},i=xh();e.propsDefaults=Object.create(null),wh(e,t,n,i);for(const l in e.propsOptions[0])l in n||(n[l]=void 0);s?e.props=a?n:qd(n):e.type.props?e.props=n:e.props=i,e.attrs=i}function y0(e,t,s,a){const{props:n,attrs:i,vnode:{patchFlag:l}}=e,o=dt(n),[r]=e.propsOptions;let c=!1;if((a||l>0)&&!(l&16)){if(l&8){const d=e.vnode.dynamicProps;for(let u=0;u{r=!0;const[f,m]=kh(u,t,!0);nt(l,f),m&&o.push(...m)};!s&&t.mixins.length&&t.mixins.forEach(d),e.extends&&d(e.extends),e.mixins&&e.mixins.forEach(d)}if(!i&&!r)return mt(e)&&a.set(e,Ri),Ri;if(Le(i))for(let d=0;de==="_"||e==="_ctx"||e==="$stable",nu=e=>Le(e)?e.map(Bs):[Bs(e)],_0=(e,t,s)=>{if(t._n)return t;const a=Kd((...n)=>nu(t(...n)),s);return a._c=!1,a},Sh=(e,t,s)=>{const a=e._ctx;for(const n in e){if(au(n))continue;const i=e[n];if(je(i))t[n]=_0(n,i,a);else if(i!=null){const l=nu(i);t[n]=()=>l}}},Ch=(e,t)=>{const s=nu(t);e.slots.default=()=>s},Th=(e,t,s)=>{for(const a in t)(s||!au(a))&&(e[a]=t[a])},w0=(e,t,s)=>{const a=e.slots=xh();if(e.vnode.shapeFlag&32){const n=t._;n?(Th(a,t,s),s&&bm(a,"_",n,!0)):Sh(t,a)}else t&&Ch(e,t)},k0=(e,t,s)=>{const{vnode:a,slots:n}=e;let i=!0,l=it;if(a.shapeFlag&32){const o=t._;o?s&&o===1?i=!1:Th(n,t,s):(i=!t.$stable,Sh(t,n)),l=t}else t&&(Ch(e,t),l={default:1});if(i)for(const o in n)!au(o)&&l[o]==null&&delete n[o]},Vt=Mh;function Eh(e){return Rh(e)}function Ah(e){return Rh(e,wx)}function Rh(e,t){const s=$r();s.__VUE__=!0;const{insert:a,remove:n,patchProp:i,createElement:l,createText:o,createComment:r,setText:c,setElementText:d,parentNode:u,nextSibling:f,setScopeId:m=hs,insertStaticContent:h}=e,g=(k,D,W,ge=null,pe=null,he=null,we=void 0,K=null,ne=!!D.dynamicChildren)=>{if(k===D)return;k&&!fa(k,D)&&(ge=le(k),J(k,pe,he,!0),k=null),D.patchFlag===-2&&(ne=!1,D.dynamicChildren=null);const{type:te,ref:_e,shapeFlag:B}=D;switch(te){case kn:A(k,D,W,ge);break;case Ht:w(k,D,W,ge);break;case Kn:k==null&&y(D,W,ge,we);break;case ls:I(k,D,W,ge,pe,he,we,K,ne);break;default:B&1?S(k,D,W,ge,pe,he,we,K,ne):B&6?H(k,D,W,ge,pe,he,we,K,ne):(B&64||B&128)&&te.process(k,D,W,ge,pe,he,we,K,ne,j)}_e!=null&&pe?Mi(_e,k&&k.ref,he,D||k,!D):_e==null&&k&&k.ref!=null&&Mi(k.ref,null,he,k,!0)},A=(k,D,W,ge)=>{if(k==null)a(D.el=o(D.children),W,ge);else{const pe=D.el=k.el;D.children!==k.children&&c(pe,D.children)}},w=(k,D,W,ge)=>{k==null?a(D.el=r(D.children||""),W,ge):D.el=k.el},y=(k,D,W,ge)=>{[k.el,k.anchor]=h(k.children,D,W,ge,k.el,k.anchor)},b=({el:k,anchor:D},W,ge)=>{let pe;for(;k&&k!==D;)pe=f(k),a(k,W,ge),k=pe;a(D,W,ge)},x=({el:k,anchor:D})=>{let W;for(;k&&k!==D;)W=f(k),n(k),k=W;n(D)},S=(k,D,W,ge,pe,he,we,K,ne)=>{if(D.type==="svg"?we="svg":D.type==="math"&&(we="mathml"),k==null)_(D,W,ge,pe,he,we,K,ne);else{const te=k.el&&k.el._isVueCE?k.el:null;try{te&&te._beginPatch(),E(k,D,pe,he,we,K,ne)}finally{te&&te._endPatch()}}},_=(k,D,W,ge,pe,he,we,K)=>{let ne,te;const{props:_e,shapeFlag:B,transition:fe,dirs:de}=k;if(ne=k.el=l(k.type,he,_e&&_e.is,_e),B&8?d(ne,k.children):B&16&&L(k.children,ne,null,ge,pe,Ec(k,he),we,K),de&&Ea(k,null,ge,"created"),T(ne,k,k.scopeId,we,ge),_e){for(const Fe in _e)Fe!=="value"&&!Za(Fe)&&i(ne,Fe,null,_e[Fe],he,ge);"value"in _e&&i(ne,"value",null,_e.value,he),(te=_e.onVnodeBeforeMount)&&$s(te,ge,k)}de&&Ea(k,null,ge,"beforeMount");const be=Ih(pe,fe);be&&fe.beforeEnter(ne),a(ne,D,W),((te=_e&&_e.onVnodeMounted)||be||de)&&Vt(()=>{try{te&&$s(te,ge,k),be&&fe.enter(ne),de&&Ea(k,null,ge,"mounted")}finally{}},pe)},T=(k,D,W,ge,pe)=>{if(W&&m(k,W),ge)for(let he=0;he{for(let te=ne;te{const K=D.el=k.el;let{patchFlag:ne,dynamicChildren:te,dirs:_e}=D;ne|=k.patchFlag&16;const B=k.props||it,fe=D.props||it;let de;if(W&&$n(W,!1),(de=fe.onVnodeBeforeUpdate)&&$s(de,W,D,k),_e&&Ea(D,k,W,"beforeUpdate"),W&&$n(W,!0),(B.innerHTML&&fe.innerHTML==null||B.textContent&&fe.textContent==null)&&d(K,""),te?P(k.dynamicChildren,te,K,W,ge,Ec(D,pe),he):we||$(k,D,K,null,W,ge,Ec(D,pe),he,!1),ne>0){if(ne&16)R(K,B,fe,W,pe);else if(ne&2&&B.class!==fe.class&&i(K,"class",null,fe.class,pe),ne&4&&i(K,"style",B.style,fe.style,pe),ne&8){const be=D.dynamicProps;for(let Fe=0;Fe{de&&$s(de,W,D,k),_e&&Ea(D,k,W,"updated")},ge)},P=(k,D,W,ge,pe,he,we)=>{for(let K=0;K{if(D!==W){if(D!==it)for(const he in D)!Za(he)&&!(he in W)&&i(k,he,D[he],null,pe,ge);for(const he in W){if(Za(he))continue;const we=W[he],K=D[he];we!==K&&he!=="value"&&i(k,he,K,we,pe,ge)}"value"in W&&i(k,"value",D.value,W.value,pe)}},I=(k,D,W,ge,pe,he,we,K,ne)=>{const te=D.el=k?k.el:o(""),_e=D.anchor=k?k.anchor:o("");let{patchFlag:B,dynamicChildren:fe,slotScopeIds:de}=D;de&&(K=K?K.concat(de):de),k==null?(a(te,W,ge),a(_e,W,ge),L(D.children||[],W,_e,pe,he,we,K,ne)):B>0&&B&64&&fe&&k.dynamicChildren&&k.dynamicChildren.length===fe.length?(P(k.dynamicChildren,fe,W,pe,he,we,K),(D.key!=null||pe&&D===pe.subTree)&&iu(k,D,!0)):$(k,D,W,_e,pe,he,we,K,ne)},H=(k,D,W,ge,pe,he,we,K,ne)=>{D.slotScopeIds=K,k==null?D.shapeFlag&512?pe.ctx.activate(D,W,ge,we,ne):X(D,W,ge,pe,he,we,ne):Q(k,D,ne)},X=(k,D,W,ge,pe,he,we)=>{const K=k.component=zh(k,ge,pe);if(oo(k)&&(K.ctx.renderer=j),jh(K,!1,we),K.asyncDep){if(pe&&pe.registerDep(K,N,we),!k.el){const ne=K.subTree=Lt(Ht);w(null,ne,D,W),k.placeholder=ne.el}}else N(K,k,D,W,pe,he,we)},Q=(k,D,W)=>{const ge=D.component=k.component;if(g0(k,D,W))if(ge.asyncDep&&!ge.asyncResolved){O(ge,D,W);return}else ge.next=D,ge.update();else D.el=k.el,ge.vnode=D},N=(k,D,W,ge,pe,he,we)=>{const K=()=>{if(k.isMounted){let{next:B,bu:fe,u:de,parent:be,vnode:Fe}=k;{const Xe=Oh(k);if(Xe){B&&(B.el=Fe.el,O(k,B,we)),Xe.asyncDep.then(()=>{Vt(()=>{k.isUnmounted||te()},pe)});return}}let qe=B,$e;$n(k,!1),B?(B.el=Fe.el,O(k,B,we)):B=Fe,fe&&Li(fe),($e=B.props&&B.props.onVnodeBeforeUpdate)&&$s($e,be,B,Fe),$n(k,!0);const Ke=Wo(k),at=k.subTree;k.subTree=Ke,g(at,Ke,u(at.el),le(at),k,pe,he),B.el=Ke.el,qe===null&&Jr(k,Ke.el),de&&Vt(de,pe),($e=B.props&&B.props.onVnodeUpdated)&&Vt(()=>$s($e,be,B,Fe),pe)}else{let B;const{el:fe,props:de}=D,{bm:be,m:Fe,parent:qe,root:$e,type:Ke}=k,at=Ya(D);if($n(k,!1),be&&Li(be),!at&&(B=de&&de.onVnodeBeforeMount)&&$s(B,qe,D),$n(k,!0),fe&&xe){const Xe=()=>{k.subTree=Wo(k),xe(fe,k.subTree,k,pe,null)};at&&Ke.__asyncHydrate?Ke.__asyncHydrate(fe,k,Xe):Xe()}else{$e.ce&&$e.ce._hasShadowRoot()&&$e.ce._injectChildStyle(Ke,k.parent?k.parent.type:void 0);const Xe=k.subTree=Wo(k);g(null,Xe,W,ge,k,pe,he),D.el=Xe.el}if(Fe&&Vt(Fe,pe),!at&&(B=de&&de.onVnodeMounted)){const Xe=D;Vt(()=>$s(B,qe,Xe),pe)}(D.shapeFlag&256||qe&&Ya(qe.vnode)&&qe.vnode.shapeFlag&256)&&k.a&&Vt(k.a,pe),k.isMounted=!0,D=W=ge=null}};k.scope.on();const ne=k.effect=new Dl(K);k.scope.off();const te=k.update=ne.run.bind(ne),_e=k.job=ne.runIfDirty.bind(ne);_e.i=k,_e.id=k.uid,ne.scheduler=()=>Wd(_e),$n(k,!0),te()},O=(k,D,W)=>{D.component=k;const ge=k.vnode.props;k.vnode=D,k.next=null,y0(k,D.props,ge,W),k0(k,D.children,W),tn(),cp(k),sn()},$=(k,D,W,ge,pe,he,we,K,ne=!1)=>{const te=k&&k.children,_e=k?k.shapeFlag:0,B=D.children,{patchFlag:fe,shapeFlag:de}=D;if(fe>0){if(fe&128){ue(te,B,W,ge,pe,he,we,K,ne);return}else if(fe&256){ce(te,B,W,ge,pe,he,we,K,ne);return}}de&8?(_e&16&&Oe(te,pe,he),B!==te&&d(W,B)):_e&16?de&16?ue(te,B,W,ge,pe,he,we,K,ne):Oe(te,pe,he,!0):(_e&8&&d(W,""),de&16&&L(B,W,ge,pe,he,we,K,ne))},ce=(k,D,W,ge,pe,he,we,K,ne)=>{k=k||Ri,D=D||Ri;const te=k.length,_e=D.length,B=Math.min(te,_e);let fe;for(fe=0;fe_e?Oe(k,pe,he,!0,!1,B):L(D,W,ge,pe,he,we,K,ne,B)},ue=(k,D,W,ge,pe,he,we,K,ne)=>{let te=0;const _e=D.length;let B=k.length-1,fe=_e-1;for(;te<=B&&te<=fe;){const de=k[te],be=D[te]=ne?ja(D[te]):Bs(D[te]);if(fa(de,be))g(de,be,W,null,pe,he,we,K,ne);else break;te++}for(;te<=B&&te<=fe;){const de=k[B],be=D[fe]=ne?ja(D[fe]):Bs(D[fe]);if(fa(de,be))g(de,be,W,null,pe,he,we,K,ne);else break;B--,fe--}if(te>B){if(te<=fe){const de=fe+1,be=de<_e?D[de].el:ge;for(;te<=fe;)g(null,D[te]=ne?ja(D[te]):Bs(D[te]),W,be,pe,he,we,K,ne),te++}}else if(te>fe)for(;te<=B;)J(k[te],pe,he,!0),te++;else{const de=te,be=te,Fe=new Map;for(te=be;te<=fe;te++){const Re=D[te]=ne?ja(D[te]):Bs(D[te]);Re.key!=null&&Fe.set(Re.key,te)}let qe,$e=0;const Ke=fe-be+1;let at=!1,Xe=0;const Y=new Array(Ke);for(te=0;te=Ke){J(Re,pe,he,!0);continue}let Me;if(Re.key!=null)Me=Fe.get(Re.key);else for(qe=be;qe<=fe;qe++)if(Y[qe-be]===0&&fa(Re,D[qe])){Me=qe;break}Me===void 0?J(Re,pe,he,!0):(Y[Me-be]=te+1,Me>=Xe?Xe=Me:at=!0,g(Re,D[Me],W,null,pe,he,we,K,ne),$e++)}const Ae=at?S0(Y):Ri;for(qe=Ae.length-1,te=Ke-1;te>=0;te--){const Re=be+te,Me=D[Re],ae=D[Re+1],Ne=Re+1<_e?ae.el||Lh(ae):ge;Y[te]===0?g(null,Me,W,Ne,pe,he,we,K,ne):at&&(qe<0||te!==Ae[qe]?Z(Me,W,Ne,2):qe--)}}},Z=(k,D,W,ge,pe=null)=>{const{el:he,type:we,transition:K,children:ne,shapeFlag:te}=k;if(te&6){Z(k.component.subTree,D,W,ge);return}if(te&128){k.suspense.move(D,W,ge);return}if(te&64){we.move(k,D,W,j);return}if(we===ls){a(he,D,W);for(let B=0;BK.enter(he),pe));else{const{leave:B,delayLeave:fe,afterLeave:de}=K,be=()=>{k.ctx.isUnmounted?n(he):a(he,D,W)},Fe=()=>{const qe=he._isLeaving||!!he[ea];he._isLeaving&&he[ea](!0),K.persisted&&!qe?be():B(he,()=>{be(),de&&de()})};fe?fe(he,be,Fe):Fe()}else a(he,D,W)},J=(k,D,W,ge=!1,pe=!1)=>{const{type:he,props:we,ref:K,children:ne,dynamicChildren:te,shapeFlag:_e,patchFlag:B,dirs:fe,cacheIndex:de,memo:be}=k;if(B===-2&&(pe=!1),K!=null&&(tn(),Mi(K,null,W,k,!0),sn()),de!=null&&(D.renderCache[de]=void 0),_e&256){D.ctx.deactivate(k);return}const Fe=_e&1&&fe,qe=!Ya(k);let $e;if(qe&&($e=we&&we.onVnodeBeforeUnmount)&&$s($e,D,k),_e&6)ye(k.component,W,ge);else{if(_e&128){k.suspense.unmount(W,ge);return}Fe&&Ea(k,null,D,"beforeUnmount"),_e&64?k.type.remove(k,D,W,j,ge):te&&!te.hasOnce&&(he!==ls||B>0&&B&64)?Oe(te,D,W,!1,!0):(he===ls&&B&384||!pe&&_e&16)&&Oe(ne,D,W),ge&&se(k)}const Ke=be!=null&&de==null;(qe&&($e=we&&we.onVnodeUnmounted)||Fe||Ke)&&Vt(()=>{$e&&$s($e,D,k),Fe&&Ea(k,null,D,"unmounted"),Ke&&(k.el=null)},W)},se=k=>{const{type:D,el:W,anchor:ge,transition:pe}=k;if(D===ls){q(W,ge);return}if(D===Kn){x(k);return}const he=()=>{n(W),pe&&!pe.persisted&&pe.afterLeave&&pe.afterLeave()};if(k.shapeFlag&1&&pe&&!pe.persisted){const{leave:we,delayLeave:K}=pe,ne=()=>we(W,he);K?K(k.el,he,ne):ne()}else he()},q=(k,D)=>{let W;for(;k!==D;)W=f(k),n(k),k=W;n(D)},ye=(k,D,W)=>{const{bum:ge,scope:pe,job:he,subTree:we,um:K,m:ne,a:te}=k;cr(ne),cr(te),ge&&Li(ge),pe.stop(),he&&(he.flags|=8,J(we,k,D,W)),K&&Vt(K,D),Vt(()=>{k.isUnmounted=!0},D)},Oe=(k,D,W,ge=!1,pe=!1,he=0)=>{for(let we=he;we{if(k.shapeFlag&6)return le(k.component.subTree);if(k.shapeFlag&128)return k.suspense.next();const D=f(k.anchor||k.el),W=D&&D[eh];return W?f(W):D};let oe=!1;const ve=(k,D,W)=>{let ge;k==null?D._vnode&&(J(D._vnode,null,null,!0),ge=D._vnode.component):g(D._vnode||null,k,D,null,null,null,W),D._vnode=k,oe||(oe=!0,cp(ge),lr(),oe=!1)},j={p:g,um:J,m:Z,r:se,mt:X,mc:L,pc:$,pbc:P,n:le,o:e};let V,xe;return t&&([V,xe]=t(j)),{render:ve,hydrate:V,createApp:d0(ve,V)}}function Ec({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function $n({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Ih(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function iu(e,t,s=!1){const a=e.children,n=t.children;if(Le(a)&&Le(n))for(let i=0;i>1,e[s[o]]0&&(t[a]=s[i-1]),s[i]=a)}}for(i=s.length,l=s[i-1];i-- >0;)s[i]=l,l=t[l];return s}function Oh(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Oh(t)}function cr(e){if(e)for(let t=0;te.__isSuspense;let od=0;const C0={name:"Suspense",__isSuspense:!0,process(e,t,s,a,n,i,l,o,r,c){if(e==null)E0(t,s,a,n,i,l,o,r,c);else{if(i&&i.deps>0&&!e.suspense.isInFallback){t.suspense=e.suspense,t.suspense.vnode=t,t.el=e.el;return}A0(e,t,s,a,n,l,o,r,c)}},hydrate:R0,normalize:I0},T0=C0;function Hl(e,t){const s=e.props&&e.props[t];je(s)&&s()}function E0(e,t,s,a,n,i,l,o,r){const{p:c,o:{createElement:d}}=r,u=d("div"),f=e.suspense=Nh(e,n,a,t,u,s,i,l,o,r);c(null,f.pendingBranch=e.ssContent,u,null,a,f,i,l),f.deps>0?(Hl(e,"onPending"),Hl(e,"onFallback"),c(null,e.ssFallback,t,s,a,null,i,l),Di(f,e.ssFallback)):f.resolve(!1,!0)}function A0(e,t,s,a,n,i,l,o,{p:r,um:c,o:{createElement:d}}){const u=t.suspense=e.suspense;u.vnode=t,t.el=e.el;const f=t.ssContent,m=t.ssFallback,{activeBranch:h,pendingBranch:g,isInFallback:A,isHydrating:w}=u;if(g)u.pendingBranch=f,fa(g,f)?(r(g,f,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0?u.resolve():A&&(w||(r(h,m,s,a,n,null,i,l,o),Di(u,m)))):(u.pendingId=od++,w?(u.isHydrating=!1,u.activeBranch=g):c(g,n,u),u.deps=0,u.effects.length=0,u.hiddenContainer=d("div"),A?(r(null,f,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0?u.resolve():(r(h,m,s,a,n,null,i,l,o),Di(u,m))):h&&fa(h,f)?(r(h,f,s,a,n,u,i,l,o),u.resolve(!0)):(r(null,f,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0&&u.resolve()));else if(h&&fa(h,f))r(h,f,s,a,n,u,i,l,o),Di(u,f);else if(Hl(t,"onPending"),u.pendingBranch=f,f.shapeFlag&512?u.pendingId=f.component.suspenseId:u.pendingId=od++,r(null,f,u.hiddenContainer,null,n,u,i,l,o),u.deps<=0)u.resolve();else{const{timeout:y,pendingId:b}=u;y>0?setTimeout(()=>{u.pendingId===b&&u.fallback(m)},y):y===0&&u.fallback(m)}}function Nh(e,t,s,a,n,i,l,o,r,c,d=!1){const{p:u,m:f,um:m,n:h,o:{parentNode:g,remove:A}}=c;let w;const y=O0(e);y&&t&&t.pendingBranch&&(w=t.pendingId,t.deps++);const b=e.props?tr(e.props.timeout):void 0,x=i,S={vnode:e,parent:t,parentComponent:s,namespace:l,container:a,hiddenContainer:n,deps:0,pendingId:od++,timeout:typeof b=="number"?b:-1,activeBranch:null,isFallbackMountPending:!1,pendingBranch:null,isInFallback:!d,isHydrating:d,isUnmounted:!1,effects:[],resolve(_=!1,T=!1){const{vnode:L,activeBranch:E,pendingBranch:P,pendingId:R,effects:I,parentComponent:H,container:X,isInFallback:Q}=S;let N=!1;if(S.isHydrating)S.isHydrating=!1;else if(!_){N=E&&P.transition&&P.transition.mode==="out-in";let ce=!1;N&&(E.transition.afterLeave=()=>{R===S.pendingId&&(f(P,X,i===x&&!ce?h(E):i,0),Fl(I),Q&&L.ssFallback&&(L.ssFallback.el=null))}),E&&!S.isFallbackMountPending&&(g(E.el)===X&&(i=h(E),ce=!0),m(E,H,S,!0),!N&&Q&&L.ssFallback&&Vt(()=>L.ssFallback.el=null,S)),N||f(P,X,i,0)}S.isFallbackMountPending=!1,Di(S,P),S.pendingBranch=null,S.isInFallback=!1;let O=S.parent,$=!1;for(;O;){if(O.pendingBranch){O.effects.push(...I),$=!0;break}O=O.parent}!$&&!N&&Fl(I),S.effects=[],y&&t&&t.pendingBranch&&w===t.pendingId&&(t.deps--,t.deps===0&&!T&&t.resolve()),Hl(L,"onResolve")},fallback(_){if(!S.pendingBranch)return;const{vnode:T,activeBranch:L,parentComponent:E,container:P,namespace:R}=S;Hl(T,"onFallback");const I=h(L),H=()=>{S.isFallbackMountPending=!1,S.isInFallback&&(u(null,_,P,I,E,null,R,o,r),Di(S,_))},X=_.transition&&_.transition.mode==="out-in";X&&(S.isFallbackMountPending=!0,L.transition.afterLeave=H),S.isInFallback=!0,m(L,E,null,!0),X||H()},move(_,T,L){S.activeBranch&&f(S.activeBranch,_,T,L),S.container=_},next(){return S.activeBranch&&h(S.activeBranch)},registerDep(_,T,L){const E=!!S.pendingBranch;E&&S.deps++;const P=_.vnode.el;_.asyncDep.catch(R=>{ni(R,_,0)}).then(R=>{if(_.isUnmounted||S.isUnmounted||S.pendingId!==_.suspenseId)return;Vl(),_.asyncResolved=!0;const{vnode:I}=_;rd(_,R,!1),P&&(I.el=P);const H=!P&&_.subTree.el;T(_,I,g(P||_.subTree.el),P?null:h(_.subTree),S,l,L),H&&(I.placeholder=null,A(H)),Jr(_,I.el),E&&--S.deps===0&&S.resolve()})},unmount(_,T){S.isUnmounted=!0,S.activeBranch&&m(S.activeBranch,s,_,T),S.pendingBranch&&m(S.pendingBranch,s,_,T)}};return S}function R0(e,t,s,a,n,i,l,o,r){const c=t.suspense=Nh(t,a,s,e.parentNode,document.createElement("div"),null,n,i,l,o,!0),d=r(e,c.pendingBranch=t.ssContent,s,c,i,l);return c.deps===0&&c.resolve(!1,!0),d}function I0(e){const{shapeFlag:t,children:s}=e,a=t&32;e.ssContent=kp(a?s.default:s),e.ssFallback=a?kp(s.fallback):Lt(Ht)}function kp(e){let t;if(je(e)){const s=Qn&&e._c;s&&(e._d=!1,jl()),e=e(),s&&(e._d=!0,t=ks,Dh())}return Le(e)&&(e=m0(e)),e=Bs(e),t&&!e.dynamicChildren&&(e.dynamicChildren=t.filter(s=>s!==e)),e}function Mh(e,t){t&&t.pendingBranch?Le(e)?t.effects.push(...e):t.effects.push(e):Fl(e)}function Di(e,t){e.activeBranch=t;const{vnode:s,parentComponent:a}=e;let n=t.el;for(;!n&&t.component;)t=t.component.subTree,n=t.el;s.el=n,a&&a.subTree===s&&(a.vnode.el=n,Jr(a,n))}function O0(e){const t=e.props&&e.props.suspensible;return t!=null&&t!==!1}const ls=Symbol.for("v-fgt"),kn=Symbol.for("v-txt"),Ht=Symbol.for("v-cmt"),Kn=Symbol.for("v-stc"),Rl=[];let ks=null;function jl(e=!1){Rl.push(ks=e?null:[])}function Dh(){Rl.pop(),ks=Rl[Rl.length-1]||null}let Qn=1;function ql(e,t=!1){Qn+=e,e<0&&ks&&t&&(ks.hasOnce=!0)}function Ph(e){return e.dynamicChildren=Qn>0?ks||Ri:null,Dh(),Qn>0&&ks&&ks.push(e),e}function L0(e,t,s,a,n,i){return Ph(lu(e,t,s,a,n,i,!0))}function ur(e,t,s,a,n){return Ph(Lt(e,t,s,a,n,!0))}function nn(e){return e?e.__v_isVNode===!0:!1}function fa(e,t){return e.type===t.type&&e.key===t.key}function N0(e){}const $h=({key:e})=>e??null,Ko=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?Qe(e)||Wt(e)||je(e)?{i:ms,r:e,k:t,f:!!s}:e:null);function lu(e,t=null,s=null,a=0,n=null,i=e===ls?0:1,l=!1,o=!1){const r={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&$h(t),ref:t&&Ko(t),scopeId:jr,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:a,dynamicProps:n,dynamicChildren:null,appContext:null,ctx:ms};return o?(ru(r,s),i&128&&e.normalize(r)):s&&(r.shapeFlag|=Qe(s)?8:16),Qn>0&&!l&&ks&&(r.patchFlag>0||i&6)&&r.patchFlag!==32&&ks.push(r),r}const Lt=M0;function M0(e,t=null,s=null,a=0,n=null,i=!1){if((!e||e===ph)&&(e=Ht),nn(e)){const o=Oa(e,t,!0);return s&&ru(o,s),Qn>0&&!i&&ks&&(o.shapeFlag&6?ks[ks.indexOf(e)]=o:ks.push(o)),o.patchFlag=-2,o}if(z0(e)&&(e=e.__vccOpts),t){t=Fh(t);let{class:o,style:r}=t;o&&!Qe(o)&&(t.class=ao(o)),mt(r)&&(no(r)&&!Le(r)&&(r=nt({},r)),t.style=so(r))}const l=Qe(e)?1:dr(e)?128:th(e)?64:mt(e)?4:je(e)?2:0;return lu(e,t,s,a,n,l,i,!0)}function Fh(e){return e?no(e)||_h(e)?nt({},e):e:null}function Oa(e,t,s=!1,a=!1){const{props:n,ref:i,patchFlag:l,children:o,transition:r}=e,c=t?Uh(n||{},t):n,d={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&$h(c),ref:t&&t.ref?s&&i?Le(i)?i.concat(Ko(t)):[i,Ko(t)]:Ko(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:o,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==ls?l===-1?16:l|16:l,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:r,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Oa(e.ssContent),ssFallback:e.ssFallback&&Oa(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return r&&a&&an(d,r.clone(d)),d}function ou(e=" ",t=0){return Lt(kn,null,e,t)}function D0(e,t){const s=Lt(Kn,null,e);return s.staticCount=t,s}function Bh(e="",t=!1){return t?(jl(),ur(Ht,null,e)):Lt(Ht,null,e)}function Bs(e){return e==null||typeof e=="boolean"?Lt(Ht):Le(e)?Lt(ls,null,e.slice()):nn(e)?ja(e):Lt(kn,null,String(e))}function ja(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Oa(e)}function ru(e,t){let s=0;const{shapeFlag:a}=e;if(t==null)t=null;else if(Le(t))s=16;else if(typeof t=="object")if(a&65){const n=t.default;n&&(n._c&&(n._d=!1),ru(e,n()),n._c&&(n._d=!0));return}else{s=32;const n=t._;!n&&!_h(t)?t._ctx=ms:n===3&&ms&&(ms.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else je(t)?(t={default:t,_ctx:ms},s=32):(t=String(t),a&64?(s=16,t=[ou(t)]):s=8);e.children=t,e.shapeFlag|=s}function Uh(...e){const t={};for(let s=0;sfs||ms;let pr,Pi;{const e=$r(),t=(s,a)=>{let n;return(n=e[s])||(n=e[s]=[]),n.push(a),i=>{n.length>1?n.forEach(l=>l(i)):n[0](i)}};pr=t("__VUE_INSTANCE_SETTERS__",s=>fs=s),Pi=t("__VUE_SSR_SETTERS__",s=>Yn=s)}const Xi=e=>{const t=fs;return pr(e),e.scope.on(),()=>{e.scope.off(),pr(t)}},Vl=()=>{fs&&fs.scope.off(),pr(null)};function Hh(e){return e.vnode.shapeFlag&4}let Yn=!1;function jh(e,t=!1,s=!1){t&&Pi(t);const{props:a,children:n}=e.vnode,i=Hh(e);b0(e,a,i,t),w0(e,n,s||t);const l=i?F0(e,t):void 0;return t&&Pi(!1),l}function F0(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,ad);const{setup:a}=s;if(a){tn();const n=e.setupContext=a.length>1?Gh(e):null,i=Xi(e),l=Yi(a,e,0,[e.props,n]),o=Bd(l);if(sn(),i(),(o||e.sp)&&!Ya(e)&&Qd(e),o){if(l.then(Vl,Vl),t)return l.then(r=>{rd(e,r,t)}).catch(r=>{ni(r,e,0)});e.asyncDep=l}else rd(e,l,t)}else Vh(e,t)}function rd(e,t,s){je(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:mt(t)&&(e.setupState=Gd(t)),Vh(e,s)}let fr,cd;function qh(e){fr=e,cd=t=>{t.render._rc&&(t.withProxy=new Proxy(t.ctx,qx))}}const B0=()=>!fr;function Vh(e,t,s){const a=e.type;if(!e.render){if(!t&&fr&&!a.render){const n=a.template||su(e).template;if(n){const{isCustomElement:i,compilerOptions:l}=e.appContext.config,{delimiters:o,compilerOptions:r}=a,c=nt(nt({isCustomElement:i,delimiters:o},l),r);a.render=fr(n,c)}}e.render=a.render||hs,cd&&cd(e)}{const n=Xi(e);tn();try{n0(e)}finally{sn(),n()}}}const U0={get(e,t){return ws(e,"get",""),e[t]}};function Gh(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,U0),slots:e.slots,emit:e.emit,expose:t}}function ro(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Gd(Um(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in Al)return Al[s](e)},has(t,s){return s in t||s in Al}})):e.proxy}function dd(e,t=!0){return je(e)?e.displayName||e.name:e.name||t&&e.__name}function z0(e){return je(e)&&"__vccOpts"in e}const U=(e,t)=>Wy(e,t,Yn);function zi(e,t,s){try{ql(-1);const a=arguments.length;return a===2?mt(t)&&!Le(t)?nn(t)?Lt(e,null,[t]):Lt(e,t):Lt(e,null,t):(a>3?s=Array.prototype.slice.call(arguments,2):a===3&&nn(s)&&(s=[s]),Lt(e,t,s))}finally{ql(1)}}function H0(){}function j0(e,t,s,a){const n=s[a];if(n&&Wh(n,e))return n;const i=t();return i.memo=e.slice(),i.cacheIndex=a,s[a]=i}function Wh(e,t){const s=e.memo;if(s.length!=t.length)return!1;for(let a=0;a0&&ks&&ks.push(e),!0}const Kh="3.5.38",q0=hs,V0=sx,G0=ki,W0=Jm,K0={createComponentInstance:zh,setupComponent:jh,renderComponentRoot:Wo,setCurrentRenderingInstance:Ul,isVNode:nn,normalizeVNode:Bs,getComponentPublicInstance:ro,ensureValidVNode:tu,pushWarningContext:Yy,popWarningContext:Xy},J0=K0,Z0=null,Q0=null,Y0=null;/** * @vue/runtime-dom v3.5.38 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT -**/let ld;const bp=typeof window<"u"&&window.trustedTypes;if(bp)try{ld=bp.createPolicy("vue",{createHTML:e=>e})}catch{}const jh=ld?e=>ld.createHTML(e):e=>e,q0="http://www.w3.org/2000/svg",G0="http://www.w3.org/1998/Math/MathML",Ha=typeof document<"u"?document:null,yp=Ha&&Ha.createElement("template"),Vh={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,a)=>{const n=t==="svg"?Ha.createElementNS(q0,e):t==="mathml"?Ha.createElementNS(G0,e):s?Ha.createElement(e,{is:s}):Ha.createElement(e);return e==="select"&&a&&a.multiple!=null&&n.setAttribute("multiple",a.multiple),n},createText:e=>Ha.createTextNode(e),createComment:e=>Ha.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ha.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,a,n,i){const l=s?s.previousSibling:t.lastChild;if(n&&(n===i||n.nextSibling))for(;t.insertBefore(n.cloneNode(!0),s),!(n===i||!(n=n.nextSibling)););else{yp.innerHTML=jh(a==="svg"?`${e}`:a==="mathml"?`${e}`:e);const o=yp.content;if(a==="svg"||a==="mathml"){const r=o.firstChild;for(;r.firstChild;)o.appendChild(r.firstChild);o.removeChild(r)}t.insertBefore(o,s)}return[l?l.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},fn="transition",dl="animation",zi=Symbol("_vtc"),qh={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},Gh=nt({},Vd,qh),W0=e=>(e.displayName="Transition",e.props=Gh,e),K0=W0((e,{slots:t})=>Ui(Qm,Wh(e),t)),$n=(e,t=[])=>{Ie(e)?e.forEach(s=>s(...t)):e&&e(...t)},xp=e=>e?Ie(e)?e.some(t=>t.length>1):e.length>1:!1;function Wh(e){const t={};for(const T in e)T in qh||(t[T]=e[T]);if(e.css===!1)return t;const{name:s="v",type:a,duration:n,enterFromClass:i=`${s}-enter-from`,enterActiveClass:l=`${s}-enter-active`,enterToClass:o=`${s}-enter-to`,appearFromClass:r=i,appearActiveClass:c=l,appearToClass:d=o,leaveFromClass:u=`${s}-leave-from`,leaveActiveClass:f=`${s}-leave-active`,leaveToClass:m=`${s}-leave-to`}=e,h=J0(n),b=h&&h[0],I=h&&h[1],{onBeforeEnter:k,onEnter:y,onEnterCancelled:g,onLeave:x,onLeaveCancelled:S,onBeforeAppear:_=k,onAppear:A=y,onAppearCancelled:R=g}=t,w=(T,$,Q,W)=>{T._enterCancelled=W,gn(T,$?d:o),gn(T,$?c:l),Q&&Q()},O=(T,$)=>{T._isLeaving=!1,gn(T,u),gn(T,m),gn(T,f),$&&$()},B=T=>($,Q)=>{const W=T?A:y,N=()=>w($,T,Q);$n(W,[$,N]),_p(()=>{gn($,T?r:i),ka($,T?d:o),xp(W)||wp($,a,b,N)})};return nt(t,{onBeforeEnter(T){$n(k,[T]),ka(T,i),ka(T,l)},onBeforeAppear(T){$n(_,[T]),ka(T,r),ka(T,c)},onEnter:B(!1),onAppear:B(!0),onLeave(T,$){T._isLeaving=!0;const Q=()=>O(T,$);ka(T,u),T._enterCancelled?(ka(T,f),od(T)):(od(T),ka(T,f)),_p(()=>{T._isLeaving&&(gn(T,u),ka(T,m),xp(x)||wp(T,a,I,Q))}),$n(x,[T,Q])},onEnterCancelled(T){w(T,!1,void 0,!0),$n(g,[T])},onAppearCancelled(T){w(T,!0,void 0,!0),$n(R,[T])},onLeaveCancelled(T){O(T),$n(S,[T])}})}function J0(e){if(e==null)return null;if(mt(e))return[kc(e.enter),kc(e.leave)];{const t=kc(e);return[t,t]}}function kc(e){return Zo(e)}function ka(e,t){t.split(/\s+/).forEach(s=>s&&e.classList.add(s)),(e[zi]||(e[zi]=new Set)).add(t)}function gn(e,t){t.split(/\s+/).forEach(a=>a&&e.classList.remove(a));const s=e[zi];s&&(s.delete(t),s.size||(e[zi]=void 0))}function _p(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let Z0=0;function wp(e,t,s,a){const n=e._endId=++Z0,i=()=>{n===e._endId&&a()};if(s!=null)return setTimeout(i,s);const{type:l,timeout:o,propCount:r}=Kh(e,t);if(!l)return a();const c=l+"end";let d=0;const u=()=>{e.removeEventListener(c,f),i()},f=m=>{m.target===e&&++d>=r&&u()};setTimeout(()=>{d(s[h]||"").split(", "),n=a(`${fn}Delay`),i=a(`${fn}Duration`),l=kp(n,i),o=a(`${dl}Delay`),r=a(`${dl}Duration`),c=kp(o,r);let d=null,u=0,f=0;t===fn?l>0&&(d=fn,u=l,f=i.length):t===dl?c>0&&(d=dl,u=c,f=r.length):(u=Math.max(l,c),d=u>0?l>c?fn:dl:null,f=d?d===fn?i.length:r.length:0);const m=d===fn&&/\b(?:transform|all)(?:,|$)/.test(a(`${fn}Property`).toString());return{type:d,timeout:u,propCount:f,hasTransform:m}}function kp(e,t){for(;e.lengthSp(s)+Sp(e[a])))}function Sp(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function od(e){return(e?e.ownerDocument:document).body.offsetHeight}function Y0(e,t,s){const a=e[zi];a&&(t=(t?[t,...a]:[...a]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const cr=Symbol("_vod"),au=Symbol("_vsh"),Jh={name:"show",beforeMount(e,{value:t},{transition:s}){e[cr]=e.style.display==="none"?"":e.style.display,s&&t?s.beforeEnter(e):ul(e,t)},mounted(e,{value:t},{transition:s}){s&&t&&s.enter(e)},updated(e,{value:t,oldValue:s},{transition:a}){!t!=!s&&(a?t?(a.beforeEnter(e),ul(e,!0),a.enter(e)):a.leave(e,()=>{ul(e,!1)}):ul(e,t))},beforeUnmount(e,{value:t}){ul(e,t)}};function ul(e,t){e.style.display=t?e[cr]:"none",e[au]=!t}function Q0(){Jh.getSSRProps=({value:e})=>{if(!e)return{style:{display:"none"}}}}const Zh=Symbol("");function X0(e){const t=Ls();if(!t)return;const s=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(i=>dr(i,n))},a=()=>{const n=e(t.proxy);t.ce?dr(t.ce,n):rd(t.subTree,n),s(n)};Gd(()=>{$l(a)}),st(()=>{Wt(a,hs,{flush:"post"});const n=new MutationObserver(a);n.observe(t.subTree.el.parentNode,{childList:!0}),xt(()=>n.disconnect())})}function rd(e,t){if(e.shapeFlag&128){const s=e.suspense;e=s.activeBranch,s.pendingBranch&&!s.isHydrating&&s.effects.push(()=>{rd(s.activeBranch,t)})}for(;e.component;)e=e.component.subTree;if(e.shapeFlag&1&&e.el)dr(e.el,t);else if(e.type===ls)e.children.forEach(s=>rd(s,t));else if(e.type===Wn){let{el:s,anchor:a}=e;for(;s&&(dr(s,t),s!==a);)s=s.nextSibling}}function dr(e,t){if(e.nodeType===1){const s=e.style;let a="";for(const n in t){const i=iy(t[n]);s.setProperty(`--${n}`,i),a+=`--${n}: ${i};`}s[Zh]=a}}const e_=/(?:^|;)\s*display\s*:/;function t_(e,t,s){const a=e.style,n=Ye(s);let i=!1;if(s&&!n){if(t)if(Ye(t))for(const l of t.split(";")){const o=l.slice(0,l.indexOf(":")).trim();s[o]==null&&wl(a,o,"")}else for(const l in t)s[l]==null&&wl(a,l,"");for(const l in s){l==="display"&&(i=!0);const o=s[l];o!=null?a_(e,l,!Ye(t)&&t?t[l]:void 0,o)||wl(a,l,o):wl(a,l,"")}}else if(n){if(t!==s){const l=a[Zh];l&&(s+=";"+l),a.cssText=s,i=e_.test(s)}}else t&&e.removeAttribute("style");cr in e&&(e[cr]=i?a.display:"",e[au]&&(a.display="none"))}const Cp=/\s*!important$/;function wl(e,t,s){if(Ie(s))s.forEach(a=>wl(e,t,a));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const a=s_(e,t);Cp.test(s)?e.setProperty(Us(a),s.replace(Cp,""),"important"):e[a]=s}}const Tp=["Webkit","Moz","ms"],Sc={};function s_(e,t){const s=Sc[t];if(s)return s;let a=wt(t);if(a!=="filter"&&a in e)return Sc[t]=a;a=si(a);for(let n=0;nCc||(o_.then(()=>Cc=0),Cc=Date.now());function c_(e,t){const s=a=>{if(!a._vts)a._vts=Date.now();else if(a._vts<=s.attached)return;const n=s.value;if(Ie(n)){const i=a.stopImmediatePropagation;a.stopImmediatePropagation=()=>{i.call(a),a._stopped=!0};const l=n.slice(),o=[a];for(let r=0;re.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Yh=(e,t,s,a,n,i)=>{const l=n==="svg";t==="class"?Y0(e,a,l):t==="style"?t_(e,s,a):ei(t)?Er(t)||i_(e,t,s,a,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):d_(e,t,a,l))?(Rp(e,t,a),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Ap(e,t,a,l,i,t!=="value")):e._isVueCE&&(u_(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!Ye(a)))?Rp(e,wt(t),a,i,t):(t==="true-value"?e._trueValue=a:t==="false-value"&&(e._falseValue=a),Ap(e,t,a,l))};function d_(e,t,s,a){if(a)return!!(t==="innerHTML"||t==="textContent"||t in e&&Lp(t)&&je(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const n=e.tagName;if(n==="IMG"||n==="VIDEO"||n==="CANVAS"||n==="SOURCE")return!1}return Lp(t)&&Ye(s)?!1:t in e}function u_(e,t){const s=e._def.props;if(!s)return!1;const a=wt(t);return Array.isArray(s)?s.some(n=>wt(n)===a):Object.keys(s).some(n=>wt(n)===a)}const Np={};function Qh(e,t,s){let a=io(e,t);Ar(a)&&(a=nt({},a,t));class n extends qr{constructor(l){super(a,l,s)}}return n.def=a,n}const p_=((e,t)=>Qh(e,t,uv)),f_=typeof HTMLElement<"u"?HTMLElement:class{};class qr extends f_{constructor(t,s={},a=fr){super(),this._def=t,this._props=s,this._createApp=a,this._isVueCE=!0,this._instance=null,this._app=null,this._nonce=this._def.nonce,this._connected=!1,this._resolved=!1,this._patching=!1,this._dirty=!1,this._numberProps=null,this._styleChildren=new WeakSet,this._styleAnchors=new WeakMap,this._ob=null,this.shadowRoot&&a!==fr?this._root=this.shadowRoot:t.shadowRoot!==!1?(this.attachShadow(nt({},t.shadowRootOptions,{mode:"open"})),this._root=this.shadowRoot):this._root=this}connectedCallback(){if(!this.isConnected)return;!this.shadowRoot&&!this._resolved&&this._parseSlots(),this._connected=!0;let t=this;for(;t=t&&(t.assignedSlot||t.parentNode||t.host);)if(t instanceof qr){this._parent=t;break}this._instance||(this._resolved?this._mount(this._def):t&&t._pendingResolve?this._pendingResolve=t._pendingResolve.then(()=>{this._pendingResolve=void 0,this._resolveDef()}):this._resolveDef())}_setParent(t=this._parent){t&&(this._instance.parent=t._instance,this._inheritParentContext(t))}_inheritParentContext(t=this._parent){t&&this._app&&Object.setPrototypeOf(this._app._context.provides,t._instance.provides)}disconnectedCallback(){this._connected=!1,zt(()=>{this._connected||(this._ob&&(this._ob.disconnect(),this._ob=null),this._app&&this._app.unmount(),this._instance&&(this._instance.ce=void 0),this._app=this._instance=null,this._teleportTargets&&(this._teleportTargets.clear(),this._teleportTargets=void 0))})}_processMutations(t){for(const s of t)this._setAttr(s.attributeName)}_resolveDef(){if(this._pendingResolve)return;for(let a=0;a{this._resolved=!0,this._pendingResolve=void 0;const{props:i,styles:l}=a;let o;if(i&&!Ie(i))for(const r in i){const c=i[r];(c===Number||c&&c.type===Number)&&(r in this._props&&(this._props[r]=Zo(this._props[r])),(o||(o=Object.create(null)))[wt(r)]=!0)}this._numberProps=o,this._resolveProps(a),this.shadowRoot&&this._applyStyles(l),this._mount(a)},s=this._def.__asyncLoader;s?this._pendingResolve=s().then(a=>{a.configureApp=this._def.configureApp,t(this._def=a,!0)}):t(this._def)}_mount(t){this._app=this._createApp(t),this._inheritParentContext(),t.configureApp&&t.configureApp(this._app),this._app._ceVNode=this._createVNode(),this._app.mount(this._root);const s=this._instance&&this._instance.exposed;if(s)for(const a in s)ht(this,a)||Object.defineProperty(this,a,{get:()=>Ra(s[a])})}_resolveProps(t){const{props:s}=t,a=Ie(s)?s:Object.keys(s||{});for(const n of Object.keys(this))n[0]!=="_"&&a.includes(n)&&this._setProp(n,this[n]);for(const n of a.map(wt))Object.defineProperty(this,n,{get(){return this._getProp(n)},set(i){this._setProp(n,i,!0,!this._patching)}})}_setAttr(t){if(t.startsWith("data-v-"))return;const s=this.hasAttribute(t);let a=s?this.getAttribute(t):Np;const n=wt(t);s&&this._numberProps&&this._numberProps[n]&&(a=Zo(a)),this._setProp(n,a,!1,!0)}_getProp(t){return this._props[t]}_setProp(t,s,a=!0,n=!1){if(s!==this._props[t]&&(this._dirty=!0,s===Np?delete this._props[t]:(this._props[t]=s,t==="key"&&this._app&&(this._app._ceVNode.key=s)),n&&this._instance&&this._update(),a)){const i=this._ob;i&&(this._processMutations(i.takeRecords()),i.disconnect()),s===!0?this.setAttribute(Us(t),""):typeof s=="string"||typeof s=="number"?this.setAttribute(Us(t),s+""):s||this.removeAttribute(Us(t)),i&&i.observe(this,{attributes:!0})}}_update(){const t=this._createVNode();this._app&&(t.appContext=this._app._context),dv(t,this._root)}_createVNode(){const t={};this.shadowRoot||(t.onVnodeMounted=t.onVnodeUpdated=this._renderSlots.bind(this));const s=Lt(this._def,nt(t,this._props));return this._instance||(s.ce=a=>{this._instance=a,a.ce=this,a.isCE=!0;const n=(i,l)=>{this.dispatchEvent(new CustomEvent(i,Ar(l[0])?nt({detail:l},l[0]):{detail:l}))};a.emit=(i,...l)=>{n(i,l),Us(i)!==i&&n(Us(i),l)},this._setParent()}),s}_applyStyles(t,s,a){if(!t)return;if(s){if(s===this._def||this._styleChildren.has(s))return;this._styleChildren.add(s)}const n=this._nonce,i=this.shadowRoot,l=a?this._getStyleAnchor(a)||this._getStyleAnchor(this._def):this._getRootStyleInsertionAnchor(i);let o=null;for(let r=t.length-1;r>=0;r--){const c=document.createElement("style");n&&c.setAttribute("nonce",n),c.textContent=t[r],i.insertBefore(c,o||l),o=c,r===0&&(a||this._styleAnchors.set(this._def,c),s&&this._styleAnchors.set(s,c))}}_getStyleAnchor(t){if(!t)return null;const s=this._styleAnchors.get(t);return s&&s.parentNode===this.shadowRoot?s:(s&&this._styleAnchors.delete(t),null)}_getRootStyleInsertionAnchor(t){for(let s=0;s(delete e.props.mode,e),g_=v_({name:"TransitionGroup",props:nt({},Gh,{tag:String,moveClass:String}),setup(e,{slots:t}){const s=Ls(),a=jd();let n,i;return zr(()=>{if(!n.length)return;const l=e.moveClass||`${e.name||"v"}-move`;if(!w_(n[0].el,s.vnode.el,l)){n=[];return}n.forEach(y_),n.forEach(x_);const o=n.filter(__);od(s.vnode.el),o.forEach(r=>{const c=r.el,d=c.style;ka(c,l),d.transform=d.webkitTransform=d.transitionDuration="";const u=c[ur]=f=>{f&&f.target!==c||(!f||f.propertyName.endsWith("transform"))&&(c.removeEventListener("transitionend",u),c[ur]=null,gn(c,l))};c.addEventListener("transitionend",u)}),n=[]}),()=>{const l=dt(e),o=Wh(l);let r=l.tag||ls;if(n=[],i)for(let c=0;c{o.split(/\s+/).forEach(r=>r&&a.classList.remove(r))}),s.split(/\s+/).forEach(o=>o&&a.classList.add(o)),a.style.display="none";const i=t.nodeType===1?t:t.parentNode;i.appendChild(a);const{hasTransform:l}=Kh(a);return i.removeChild(a),l}const Cn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return Ie(t)?s=>Oi(t,s):t};function k_(e){e.target.composing=!0}function Dp(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const ia=Symbol("_assign");function Pp(e,t,s){return t&&(e=e.trim()),s&&(e=Or(e)),e}const pr={created(e,{modifiers:{lazy:t,trim:s,number:a}},n){e[ia]=Cn(n);const i=a||n.props&&n.props.type==="number";Wa(e,t?"change":"input",l=>{l.target.composing||e[ia](Pp(e.value,s,i))}),(s||i)&&Wa(e,"change",()=>{e.value=Pp(e.value,s,i)}),t||(Wa(e,"compositionstart",k_),Wa(e,"compositionend",Dp),Wa(e,"change",Dp))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:a,trim:n,number:i}},l){if(e[ia]=Cn(l),e.composing)return;const o=(i||e.type==="number")&&!/^0\d/.test(e.value)?Or(e.value):e.value,r=t??"";if(o===r)return;const c=e.getRootNode();(c instanceof Document||c instanceof ShadowRoot)&&c.activeElement===e&&e.type!=="range"&&(a&&t===s||n&&e.value.trim()===r)||(e.value=r)}},nu={deep:!0,created(e,t,s){e[ia]=Cn(s),Wa(e,"change",()=>{const a=e._modelValue,n=Hi(e),i=e.checked,l=e[ia];if(Ie(a)){const o=Nr(a,n),r=o!==-1;if(i&&!r)l(a.concat(n));else if(!i&&r){const c=[...a];c.splice(o,1),l(c)}}else if(ti(a)){const o=new Set(a);i?o.add(n):o.delete(n),l(o)}else l(nv(e,i))})},mounted:$p,beforeUpdate(e,t,s){e[ia]=Cn(s),$p(e,t,s)}};function $p(e,{value:t,oldValue:s},a){e._modelValue=t;let n;if(Ie(t))n=Nr(t,a.props.value)>-1;else if(ti(t))n=t.has(a.props.value);else{if(t===s)return;n=en(t,nv(e,!0))}e.checked!==n&&(e.checked=n)}const iu={created(e,{value:t},s){e.checked=en(t,s.props.value),e[ia]=Cn(s),Wa(e,"change",()=>{e[ia](Hi(e))})},beforeUpdate(e,{value:t,oldValue:s},a){e[ia]=Cn(a),t!==s&&(e.checked=en(t,a.props.value))}},av={deep:!0,created(e,{value:t,modifiers:{number:s}},a){const n=ti(t);Wa(e,"change",()=>{const i=Array.prototype.filter.call(e.options,l=>l.selected).map(l=>s?Or(Hi(l)):Hi(l));e[ia](e.multiple?n?new Set(i):i:i[0]),e._assigning=!0,zt(()=>{e._assigning=!1})}),e[ia]=Cn(a)},mounted(e,{value:t}){Fp(e,t)},beforeUpdate(e,t,s){e[ia]=Cn(s)},updated(e,{value:t}){e._assigning||Fp(e,t)}};function Fp(e,t){const s=e.multiple,a=Ie(t);if(!(s&&!a&&!ti(t))){for(let n=0,i=e.options.length;nString(c)===String(o)):l.selected=Nr(t,o)>-1}else l.selected=t.has(o);else if(en(Hi(l),t)){e.selectedIndex!==n&&(e.selectedIndex=n);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Hi(e){return"_value"in e?e._value:e.value}function nv(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const iv={created(e,t,s){Eo(e,t,s,null,"created")},mounted(e,t,s){Eo(e,t,s,null,"mounted")},beforeUpdate(e,t,s,a){Eo(e,t,s,a,"beforeUpdate")},updated(e,t,s,a){Eo(e,t,s,a,"updated")}};function lv(e,t){switch(e){case"SELECT":return av;case"TEXTAREA":return pr;default:switch(t){case"checkbox":return nu;case"radio":return iu;default:return pr}}}function Eo(e,t,s,a,n){const l=lv(e.tagName,s.props&&s.props.type)[n];l&&l(e,t,s,a)}function S_(){pr.getSSRProps=({value:e})=>({value:e}),iu.getSSRProps=({value:e},t)=>{if(t.props&&en(t.props.value,e))return{checked:!0}},nu.getSSRProps=({value:e},t)=>{if(Ie(e)){if(t.props&&Nr(e,t.props.value)>-1)return{checked:!0}}else if(ti(e)){if(t.props&&e.has(t.props.value))return{checked:!0}}else if(e)return{checked:!0}},iv.getSSRProps=(e,t)=>{if(typeof t.type!="string")return;const s=lv(t.type.toUpperCase(),t.props&&t.props.type);if(s.getSSRProps)return s.getSSRProps(e,t)}}const C_=["ctrl","shift","alt","meta"],T_={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>C_.some(s=>e[`${s}Key`]&&!t.includes(s))},E_=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),a=t.join(".");return s[a]||(s[a]=((n,...i)=>{for(let l=0;l{const s=e._withKeys||(e._withKeys={}),a=t.join(".");return s[a]||(s[a]=(n=>{if(!("key"in n))return;const i=Us(n.key);if(t.some(l=>l===i||A_[l]===i))return e(n)}))},ov=nt({patchProp:Yh},Vh);let Rl,Bp=!1;function rv(){return Rl||(Rl=_h(ov))}function cv(){return Rl=Bp?Rl:wh(ov),Bp=!0,Rl}const dv=((...e)=>{rv().render(...e)}),I_=((...e)=>{cv().hydrate(...e)}),fr=((...e)=>{const t=rv().createApp(...e),{mount:s}=t;return t.mount=a=>{const n=fv(a);if(!n)return;const i=t._component;!je(i)&&!i.render&&!i.template&&(i.template=n.innerHTML),n.nodeType===1&&(n.textContent="");const l=s(n,!1,pv(n));return n instanceof Element&&(n.removeAttribute("v-cloak"),n.setAttribute("data-v-app","")),l},t}),uv=((...e)=>{const t=cv().createApp(...e),{mount:s}=t;return t.mount=a=>{const n=fv(a);if(n)return s(n,!0,pv(n))},t});function pv(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function fv(e){return Ye(e)?document.querySelector(e):e}let Up=!1;const O_=()=>{Up||(Up=!0,S_(),Q0())},L_=Object.freeze(Object.defineProperty({__proto__:null,BaseTransition:Qm,BaseTransitionPropsValidators:Vd,Comment:Ht,DeprecationTypes:V0,EffectScope:Md,ErrorCodes:Wy,ErrorTypeStrings:$0,Fragment:ls,KeepAlive:Tx,ReactiveEffect:Ml,Static:Wn,Suspense:b0,Teleport:rx,Text:kn,TrackOpTypes:Uy,Transition:K0,TransitionGroup:b_,TriggerOpTypes:zy,VueElement:qr,assertNumber:Gy,callWithAsyncErrorHandling:Zs,callWithErrorHandling:Yi,camelize:wt,capitalize:si,cloneVNode:Oa,compatUtils:j0,computed:H,createApp:fr,createBlock:lr,createCommentVNode:Nh,createElementBlock:S0,createElementVNode:eu,createHydrationRenderer:wh,createPropsRestProxy:Kx,createRenderer:_h,createSSRApp:uv,createSlots:Nx,createStaticVNode:E0,createTextVNode:tu,createVNode:Lt,customRef:Pm,defineAsyncComponent:Sx,defineComponent:io,defineCustomElement:Qh,defineEmits:Fx,defineExpose:Bx,defineModel:Hx,defineOptions:Ux,defineProps:$x,defineSSRCustomElement:p_,defineSlots:zx,devtools:F0,effect:cy,effectScope:ly,getCurrentInstance:Ls,getCurrentScope:bm,getCurrentWatcher:Hy,getTransitionRawChildren:Br,guardReactiveProps:Lh,h:Ui,handleError:ai,hasInjectionContext:tx,hydrate:I_,hydrateOnIdle:bx,hydrateOnInteraction:wx,hydrateOnMediaQuery:_x,hydrateOnVisible:xx,initCustomFormatter:M0,initDirectivesForSSR:O_,inject:na,isMemoSame:zh,isProxy:ao,isReactive:Ya,isReadonly:Ia,isRef:Gt,isRuntimeOnly:O0,isShallow:Hs,isVNode:nn,markRaw:Mm,mergeDefaults:Gx,mergeModels:Wx,mergeProps:Mh,nextTick:zt,nodeOps:Vh,normalizeClass:so,normalizeProps:Wb,normalizeStyle:to,onActivated:rs,onBeforeMount:th,onBeforeUnmount:Hr,onBeforeUpdate:Gd,onDeactivated:Kt,onErrorCaptured:ih,onMounted:st,onRenderTracked:nh,onRenderTriggered:ah,onScopeDispose:oy,onServerPrefetch:sh,onUnmounted:xt,onUpdated:zr,onWatcherCleanup:Fm,openBlock:Hl,patchProp:Yh,popScopeId:Qy,provide:Tl,proxyRefs:Ud,pushScopeId:Yy,queuePostFlushCb:$l,reactive:Tn,readonly:Qo,ref:p,registerRuntimeCompiler:Fh,render:dv,renderList:Lx,renderSlot:Mx,resolveComponent:Rx,resolveDirective:Ox,resolveDynamicComponent:Ix,resolveFilter:H0,resolveTransitionHooks:Bi,setBlockTracking:jl,setDevtoolsHook:B0,setTransitionHooks:an,shallowReactive:Fd,shallowReadonly:Ay,shallowRef:Bd,ssrContextKey:Vm,ssrUtils:z0,stop:dy,toDisplayString:vm,toHandlerKey:Ii,toHandlers:Dx,toRaw:dt,toRef:$y,toRefs:My,toValue:Oy,transformVNodeArgs:C0,triggerRef:Iy,unref:Ra,useAttrs:qx,useCssModule:h_,useCssVars:X0,useHost:Xh,useId:dx,useModel:a0,useSSRContext:qm,useShadowRoot:m_,useSlots:Vx,useTemplateRef:ux,useTransitionState:jd,vModelCheckbox:nu,vModelDynamic:iv,vModelRadio:iu,vModelSelect:av,vModelText:pr,vShow:Jh,version:Hh,warn:P0,watch:Wt,watchEffect:sx,watchPostEffect:ax,watchSyncEffect:Gm,withAsyncContext:Jx,withCtx:Hd,withDefaults:jx,withDirectives:ex,withKeys:R_,withMemo:D0,withModifiers:E_,withScopeId:Xy},Symbol.toStringTag,{value:"Module"}));/** +**/let ud;const Sp=typeof window<"u"&&window.trustedTypes;if(Sp)try{ud=Sp.createPolicy("vue",{createHTML:e=>e})}catch{}const Jh=ud?e=>ud.createHTML(e):e=>e,X0="http://www.w3.org/2000/svg",e_="http://www.w3.org/1998/Math/MathML",Ha=typeof document<"u"?document:null,Cp=Ha&&Ha.createElement("template"),Zh={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,a)=>{const n=t==="svg"?Ha.createElementNS(X0,e):t==="mathml"?Ha.createElementNS(e_,e):s?Ha.createElement(e,{is:s}):Ha.createElement(e);return e==="select"&&a&&a.multiple!=null&&n.setAttribute("multiple",a.multiple),n},createText:e=>Ha.createTextNode(e),createComment:e=>Ha.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ha.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,a,n,i){const l=s?s.previousSibling:t.lastChild;if(n&&(n===i||n.nextSibling))for(;t.insertBefore(n.cloneNode(!0),s),!(n===i||!(n=n.nextSibling)););else{Cp.innerHTML=Jh(a==="svg"?`${e}`:a==="mathml"?`${e}`:e);const o=Cp.content;if(a==="svg"||a==="mathml"){const r=o.firstChild;for(;r.firstChild;)o.appendChild(r.firstChild);o.removeChild(r)}t.insertBefore(o,s)}return[l?l.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},fn="transition",ul="animation",Hi=Symbol("_vtc"),Qh={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},Yh=nt({},Zd,Qh),t_=e=>(e.displayName="Transition",e.props=Yh,e),s_=t_((e,{slots:t})=>zi(nh,Xh(e),t)),Fn=(e,t=[])=>{Le(e)?e.forEach(s=>s(...t)):e&&e(...t)},Tp=e=>e?Le(e)?e.some(t=>t.length>1):e.length>1:!1;function Xh(e){const t={};for(const I in e)I in Qh||(t[I]=e[I]);if(e.css===!1)return t;const{name:s="v",type:a,duration:n,enterFromClass:i=`${s}-enter-from`,enterActiveClass:l=`${s}-enter-active`,enterToClass:o=`${s}-enter-to`,appearFromClass:r=i,appearActiveClass:c=l,appearToClass:d=o,leaveFromClass:u=`${s}-leave-from`,leaveActiveClass:f=`${s}-leave-active`,leaveToClass:m=`${s}-leave-to`}=e,h=a_(n),g=h&&h[0],A=h&&h[1],{onBeforeEnter:w,onEnter:y,onEnterCancelled:b,onLeave:x,onLeaveCancelled:S,onBeforeAppear:_=w,onAppear:T=y,onAppearCancelled:L=b}=t,E=(I,H,X,Q)=>{I._enterCancelled=Q,gn(I,H?d:o),gn(I,H?c:l),X&&X()},P=(I,H)=>{I._isLeaving=!1,gn(I,u),gn(I,m),gn(I,f),H&&H()},R=I=>(H,X)=>{const Q=I?T:y,N=()=>E(H,I,X);Fn(Q,[H,N]),Ep(()=>{gn(H,I?r:i),ka(H,I?d:o),Tp(Q)||Ap(H,a,g,N)})};return nt(t,{onBeforeEnter(I){Fn(w,[I]),ka(I,i),ka(I,l)},onBeforeAppear(I){Fn(_,[I]),ka(I,r),ka(I,c)},onEnter:R(!1),onAppear:R(!0),onLeave(I,H){I._isLeaving=!0;const X=()=>P(I,H);ka(I,u),I._enterCancelled?(ka(I,f),pd(I)):(pd(I),ka(I,f)),Ep(()=>{I._isLeaving&&(gn(I,u),ka(I,m),Tp(x)||Ap(I,a,A,X))}),Fn(x,[I,X])},onEnterCancelled(I){E(I,!1,void 0,!0),Fn(b,[I])},onAppearCancelled(I){E(I,!0,void 0,!0),Fn(L,[I])},onLeaveCancelled(I){P(I),Fn(S,[I])}})}function a_(e){if(e==null)return null;if(mt(e))return[Ac(e.enter),Ac(e.leave)];{const t=Ac(e);return[t,t]}}function Ac(e){return tr(e)}function ka(e,t){t.split(/\s+/).forEach(s=>s&&e.classList.add(s)),(e[Hi]||(e[Hi]=new Set)).add(t)}function gn(e,t){t.split(/\s+/).forEach(a=>a&&e.classList.remove(a));const s=e[Hi];s&&(s.delete(t),s.size||(e[Hi]=void 0))}function Ep(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let n_=0;function Ap(e,t,s,a){const n=e._endId=++n_,i=()=>{n===e._endId&&a()};if(s!=null)return setTimeout(i,s);const{type:l,timeout:o,propCount:r}=ev(e,t);if(!l)return a();const c=l+"end";let d=0;const u=()=>{e.removeEventListener(c,f),i()},f=m=>{m.target===e&&++d>=r&&u()};setTimeout(()=>{d(s[h]||"").split(", "),n=a(`${fn}Delay`),i=a(`${fn}Duration`),l=Rp(n,i),o=a(`${ul}Delay`),r=a(`${ul}Duration`),c=Rp(o,r);let d=null,u=0,f=0;t===fn?l>0&&(d=fn,u=l,f=i.length):t===ul?c>0&&(d=ul,u=c,f=r.length):(u=Math.max(l,c),d=u>0?l>c?fn:ul:null,f=d?d===fn?i.length:r.length:0);const m=d===fn&&/\b(?:transform|all)(?:,|$)/.test(a(`${fn}Property`).toString());return{type:d,timeout:u,propCount:f,hasTransform:m}}function Rp(e,t){for(;e.lengthIp(s)+Ip(e[a])))}function Ip(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function pd(e){return(e?e.ownerDocument:document).body.offsetHeight}function i_(e,t,s){const a=e[Hi];a&&(t=(t?[t,...a]:[...a]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const mr=Symbol("_vod"),cu=Symbol("_vsh"),tv={name:"show",beforeMount(e,{value:t},{transition:s}){e[mr]=e.style.display==="none"?"":e.style.display,s&&t?s.beforeEnter(e):pl(e,t)},mounted(e,{value:t},{transition:s}){s&&t&&s.enter(e)},updated(e,{value:t,oldValue:s},{transition:a}){!t!=!s&&(a?t?(a.beforeEnter(e),pl(e,!0),a.enter(e)):a.leave(e,()=>{pl(e,!1)}):pl(e,t))},beforeUnmount(e,{value:t}){pl(e,t)}};function pl(e,t){e.style.display=t?e[mr]:"none",e[cu]=!t}function l_(){tv.getSSRProps=({value:e})=>{if(!e)return{style:{display:"none"}}}}const sv=Symbol("");function o_(e){const t=Ls();if(!t)return;const s=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(i=>hr(i,n))},a=()=>{const n=e(t.proxy);t.ce?hr(t.ce,n):fd(t.subTree,n),s(n)};Yd(()=>{Fl(a)}),tt(()=>{jt(a,hs,{flush:"post"});const n=new MutationObserver(a);n.observe(t.subTree.el.parentNode,{childList:!0}),xt(()=>n.disconnect())})}function fd(e,t){if(e.shapeFlag&128){const s=e.suspense;e=s.activeBranch,s.pendingBranch&&!s.isHydrating&&s.effects.push(()=>{fd(s.activeBranch,t)})}for(;e.component;)e=e.component.subTree;if(e.shapeFlag&1&&e.el)hr(e.el,t);else if(e.type===ls)e.children.forEach(s=>fd(s,t));else if(e.type===Kn){let{el:s,anchor:a}=e;for(;s&&(hr(s,t),s!==a);)s=s.nextSibling}}function hr(e,t){if(e.nodeType===1){const s=e.style;let a="";for(const n in t){const i=fy(t[n]);s.setProperty(`--${n}`,i),a+=`--${n}: ${i};`}s[sv]=a}}const r_=/(?:^|;)\s*display\s*:/;function c_(e,t,s){const a=e.style,n=Qe(s);let i=!1;if(s&&!n){if(t)if(Qe(t))for(const l of t.split(";")){const o=l.slice(0,l.indexOf(":")).trim();s[o]==null&&kl(a,o,"")}else for(const l in t)s[l]==null&&kl(a,l,"");for(const l in s){l==="display"&&(i=!0);const o=s[l];o!=null?u_(e,l,!Qe(t)&&t?t[l]:void 0,o)||kl(a,l,o):kl(a,l,"")}}else if(n){if(t!==s){const l=a[sv];l&&(s+=";"+l),a.cssText=s,i=r_.test(s)}}else t&&e.removeAttribute("style");mr in e&&(e[mr]=i?a.display:"",e[cu]&&(a.display="none"))}const Op=/\s*!important$/;function kl(e,t,s){if(Le(s))s.forEach(a=>kl(e,t,a));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const a=d_(e,t);Op.test(s)?e.setProperty(Us(a),s.replace(Op,""),"important"):e[a]=s}}const Lp=["Webkit","Moz","ms"],Rc={};function d_(e,t){const s=Rc[t];if(s)return s;let a=wt(t);if(a!=="filter"&&a in e)return Rc[t]=a;a=ai(a);for(let n=0;nIc||(h_.then(()=>Ic=0),Ic=Date.now());function g_(e,t){const s=a=>{if(!a._vts)a._vts=Date.now();else if(a._vts<=s.attached)return;const n=s.value;if(Le(n)){const i=a.stopImmediatePropagation;a.stopImmediatePropagation=()=>{i.call(a),a._stopped=!0};const l=n.slice(),o=[a];for(let r=0;re.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,av=(e,t,s,a,n,i)=>{const l=n==="svg";t==="class"?i_(e,a,l):t==="style"?c_(e,s,a):ti(t)?Lr(t)||f_(e,t,s,a,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):b_(e,t,a,l))?(Dp(e,t,a),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Mp(e,t,a,l,i,t!=="value")):e._isVueCE&&(y_(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!Qe(a)))?Dp(e,wt(t),a,i,t):(t==="true-value"?e._trueValue=a:t==="false-value"&&(e._falseValue=a),Mp(e,t,a,l))};function b_(e,t,s,a){if(a)return!!(t==="innerHTML"||t==="textContent"||t in e&&Fp(t)&&je(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const n=e.tagName;if(n==="IMG"||n==="VIDEO"||n==="CANVAS"||n==="SOURCE")return!1}return Fp(t)&&Qe(s)?!1:t in e}function y_(e,t){const s=e._def.props;if(!s)return!1;const a=wt(t);return Array.isArray(s)?s.some(n=>wt(n)===a):Object.keys(s).some(n=>wt(n)===a)}const Bp={};function nv(e,t,s){let a=lo(e,t);Nr(a)&&(a=nt({},a,t));class n extends Zr{constructor(l){super(a,l,s)}}return n.def=a,n}const x_=((e,t)=>nv(e,t,gv)),__=typeof HTMLElement<"u"?HTMLElement:class{};class Zr extends __{constructor(t,s={},a=br){super(),this._def=t,this._props=s,this._createApp=a,this._isVueCE=!0,this._instance=null,this._app=null,this._nonce=this._def.nonce,this._connected=!1,this._resolved=!1,this._patching=!1,this._dirty=!1,this._numberProps=null,this._styleChildren=new WeakSet,this._styleAnchors=new WeakMap,this._ob=null,this.shadowRoot&&a!==br?this._root=this.shadowRoot:t.shadowRoot!==!1?(this.attachShadow(nt({},t.shadowRootOptions,{mode:"open"})),this._root=this.shadowRoot):this._root=this}connectedCallback(){if(!this.isConnected)return;!this.shadowRoot&&!this._resolved&&this._parseSlots(),this._connected=!0;let t=this;for(;t=t&&(t.assignedSlot||t.parentNode||t.host);)if(t instanceof Zr){this._parent=t;break}this._instance||(this._resolved?this._mount(this._def):t&&t._pendingResolve?this._pendingResolve=t._pendingResolve.then(()=>{this._pendingResolve=void 0,this._resolveDef()}):this._resolveDef())}_setParent(t=this._parent){t&&(this._instance.parent=t._instance,this._inheritParentContext(t))}_inheritParentContext(t=this._parent){t&&this._app&&Object.setPrototypeOf(this._app._context.provides,t._instance.provides)}disconnectedCallback(){this._connected=!1,Mt(()=>{this._connected||(this._ob&&(this._ob.disconnect(),this._ob=null),this._app&&this._app.unmount(),this._instance&&(this._instance.ce=void 0),this._app=this._instance=null,this._teleportTargets&&(this._teleportTargets.clear(),this._teleportTargets=void 0))})}_processMutations(t){for(const s of t)this._setAttr(s.attributeName)}_resolveDef(){if(this._pendingResolve)return;for(let a=0;a{this._resolved=!0,this._pendingResolve=void 0;const{props:i,styles:l}=a;let o;if(i&&!Le(i))for(const r in i){const c=i[r];(c===Number||c&&c.type===Number)&&(r in this._props&&(this._props[r]=tr(this._props[r])),(o||(o=Object.create(null)))[wt(r)]=!0)}this._numberProps=o,this._resolveProps(a),this.shadowRoot&&this._applyStyles(l),this._mount(a)},s=this._def.__asyncLoader;s?this._pendingResolve=s().then(a=>{a.configureApp=this._def.configureApp,t(this._def=a,!0)}):t(this._def)}_mount(t){this._app=this._createApp(t),this._inheritParentContext(),t.configureApp&&t.configureApp(this._app),this._app._ceVNode=this._createVNode(),this._app.mount(this._root);const s=this._instance&&this._instance.exposed;if(s)for(const a in s)ht(this,a)||Object.defineProperty(this,a,{get:()=>Ra(s[a])})}_resolveProps(t){const{props:s}=t,a=Le(s)?s:Object.keys(s||{});for(const n of Object.keys(this))n[0]!=="_"&&a.includes(n)&&this._setProp(n,this[n]);for(const n of a.map(wt))Object.defineProperty(this,n,{get(){return this._getProp(n)},set(i){this._setProp(n,i,!0,!this._patching)}})}_setAttr(t){if(t.startsWith("data-v-"))return;const s=this.hasAttribute(t);let a=s?this.getAttribute(t):Bp;const n=wt(t);s&&this._numberProps&&this._numberProps[n]&&(a=tr(a)),this._setProp(n,a,!1,!0)}_getProp(t){return this._props[t]}_setProp(t,s,a=!0,n=!1){if(s!==this._props[t]&&(this._dirty=!0,s===Bp?delete this._props[t]:(this._props[t]=s,t==="key"&&this._app&&(this._app._ceVNode.key=s)),n&&this._instance&&this._update(),a)){const i=this._ob;i&&(this._processMutations(i.takeRecords()),i.disconnect()),s===!0?this.setAttribute(Us(t),""):typeof s=="string"||typeof s=="number"?this.setAttribute(Us(t),s+""):s||this.removeAttribute(Us(t)),i&&i.observe(this,{attributes:!0})}}_update(){const t=this._createVNode();this._app&&(t.appContext=this._app._context),vv(t,this._root)}_createVNode(){const t={};this.shadowRoot||(t.onVnodeMounted=t.onVnodeUpdated=this._renderSlots.bind(this));const s=Lt(this._def,nt(t,this._props));return this._instance||(s.ce=a=>{this._instance=a,a.ce=this,a.isCE=!0;const n=(i,l)=>{this.dispatchEvent(new CustomEvent(i,Nr(l[0])?nt({detail:l},l[0]):{detail:l}))};a.emit=(i,...l)=>{n(i,l),Us(i)!==i&&n(Us(i),l)},this._setParent()}),s}_applyStyles(t,s,a){if(!t)return;if(s){if(s===this._def||this._styleChildren.has(s))return;this._styleChildren.add(s)}const n=this._nonce,i=this.shadowRoot,l=a?this._getStyleAnchor(a)||this._getStyleAnchor(this._def):this._getRootStyleInsertionAnchor(i);let o=null;for(let r=t.length-1;r>=0;r--){const c=document.createElement("style");n&&c.setAttribute("nonce",n),c.textContent=t[r],i.insertBefore(c,o||l),o=c,r===0&&(a||this._styleAnchors.set(this._def,c),s&&this._styleAnchors.set(s,c))}}_getStyleAnchor(t){if(!t)return null;const s=this._styleAnchors.get(t);return s&&s.parentNode===this.shadowRoot?s:(s&&this._styleAnchors.delete(t),null)}_getRootStyleInsertionAnchor(t){for(let s=0;s(delete e.props.mode,e),C_=S_({name:"TransitionGroup",props:nt({},Yh,{tag:String,moveClass:String}),setup(e,{slots:t}){const s=Ls(),a=Jd();let n,i;return Gr(()=>{if(!n.length)return;const l=e.moveClass||`${e.name||"v"}-move`;if(!I_(n[0].el,s.vnode.el,l)){n=[];return}n.forEach(E_),n.forEach(A_);const o=n.filter(R_);pd(s.vnode.el),o.forEach(r=>{const c=r.el,d=c.style;ka(c,l),d.transform=d.webkitTransform=d.transitionDuration="";const u=c[vr]=f=>{f&&f.target!==c||(!f||f.propertyName.endsWith("transform"))&&(c.removeEventListener("transitionend",u),c[vr]=null,gn(c,l))};c.addEventListener("transitionend",u)}),n=[]}),()=>{const l=dt(e),o=Xh(l);let r=l.tag||ls;if(n=[],i)for(let c=0;c{o.split(/\s+/).forEach(r=>r&&a.classList.remove(r))}),s.split(/\s+/).forEach(o=>o&&a.classList.add(o)),a.style.display="none";const i=t.nodeType===1?t:t.parentNode;i.appendChild(a);const{hasTransform:l}=ev(a);return i.removeChild(a),l}const Cn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return Le(t)?s=>Li(t,s):t};function O_(e){e.target.composing=!0}function zp(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const ia=Symbol("_assign");function Hp(e,t,s){return t&&(e=e.trim()),s&&(e=Pr(e)),e}const gr={created(e,{modifiers:{lazy:t,trim:s,number:a}},n){e[ia]=Cn(n);const i=a||n.props&&n.props.type==="number";Wa(e,t?"change":"input",l=>{l.target.composing||e[ia](Hp(e.value,s,i))}),(s||i)&&Wa(e,"change",()=>{e.value=Hp(e.value,s,i)}),t||(Wa(e,"compositionstart",O_),Wa(e,"compositionend",zp),Wa(e,"change",zp))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:a,trim:n,number:i}},l){if(e[ia]=Cn(l),e.composing)return;const o=(i||e.type==="number")&&!/^0\d/.test(e.value)?Pr(e.value):e.value,r=t??"";if(o===r)return;const c=e.getRootNode();(c instanceof Document||c instanceof ShadowRoot)&&c.activeElement===e&&e.type!=="range"&&(a&&t===s||n&&e.value.trim()===r)||(e.value=r)}},du={deep:!0,created(e,t,s){e[ia]=Cn(s),Wa(e,"change",()=>{const a=e._modelValue,n=ji(e),i=e.checked,l=e[ia];if(Le(a)){const o=Fr(a,n),r=o!==-1;if(i&&!r)l(a.concat(n));else if(!i&&r){const c=[...a];c.splice(o,1),l(c)}}else if(si(a)){const o=new Set(a);i?o.add(n):o.delete(n),l(o)}else l(dv(e,i))})},mounted:jp,beforeUpdate(e,t,s){e[ia]=Cn(s),jp(e,t,s)}};function jp(e,{value:t,oldValue:s},a){e._modelValue=t;let n;if(Le(t))n=Fr(t,a.props.value)>-1;else if(si(t))n=t.has(a.props.value);else{if(t===s)return;n=en(t,dv(e,!0))}e.checked!==n&&(e.checked=n)}const uu={created(e,{value:t},s){e.checked=en(t,s.props.value),e[ia]=Cn(s),Wa(e,"change",()=>{e[ia](ji(e))})},beforeUpdate(e,{value:t,oldValue:s},a){e[ia]=Cn(a),t!==s&&(e.checked=en(t,a.props.value))}},cv={deep:!0,created(e,{value:t,modifiers:{number:s}},a){const n=si(t);Wa(e,"change",()=>{const i=Array.prototype.filter.call(e.options,l=>l.selected).map(l=>s?Pr(ji(l)):ji(l));e[ia](e.multiple?n?new Set(i):i:i[0]),e._assigning=!0,Mt(()=>{e._assigning=!1})}),e[ia]=Cn(a)},mounted(e,{value:t}){qp(e,t)},beforeUpdate(e,t,s){e[ia]=Cn(s)},updated(e,{value:t}){e._assigning||qp(e,t)}};function qp(e,t){const s=e.multiple,a=Le(t);if(!(s&&!a&&!si(t))){for(let n=0,i=e.options.length;nString(c)===String(o)):l.selected=Fr(t,o)>-1}else l.selected=t.has(o);else if(en(ji(l),t)){e.selectedIndex!==n&&(e.selectedIndex=n);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function ji(e){return"_value"in e?e._value:e.value}function dv(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const uv={created(e,t,s){Ao(e,t,s,null,"created")},mounted(e,t,s){Ao(e,t,s,null,"mounted")},beforeUpdate(e,t,s,a){Ao(e,t,s,a,"beforeUpdate")},updated(e,t,s,a){Ao(e,t,s,a,"updated")}};function pv(e,t){switch(e){case"SELECT":return cv;case"TEXTAREA":return gr;default:switch(t){case"checkbox":return du;case"radio":return uu;default:return gr}}}function Ao(e,t,s,a,n){const l=pv(e.tagName,s.props&&s.props.type)[n];l&&l(e,t,s,a)}function L_(){gr.getSSRProps=({value:e})=>({value:e}),uu.getSSRProps=({value:e},t)=>{if(t.props&&en(t.props.value,e))return{checked:!0}},du.getSSRProps=({value:e},t)=>{if(Le(e)){if(t.props&&Fr(e,t.props.value)>-1)return{checked:!0}}else if(si(e)){if(t.props&&e.has(t.props.value))return{checked:!0}}else if(e)return{checked:!0}},uv.getSSRProps=(e,t)=>{if(typeof t.type!="string")return;const s=pv(t.type.toUpperCase(),t.props&&t.props.type);if(s.getSSRProps)return s.getSSRProps(e,t)}}const N_=["ctrl","shift","alt","meta"],M_={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>N_.some(s=>e[`${s}Key`]&&!t.includes(s))},D_=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),a=t.join(".");return s[a]||(s[a]=((n,...i)=>{for(let l=0;l{const s=e._withKeys||(e._withKeys={}),a=t.join(".");return s[a]||(s[a]=(n=>{if(!("key"in n))return;const i=Us(n.key);if(t.some(l=>l===i||P_[l]===i))return e(n)}))},fv=nt({patchProp:av},Zh);let Il,Vp=!1;function mv(){return Il||(Il=Eh(fv))}function hv(){return Il=Vp?Il:Ah(fv),Vp=!0,Il}const vv=((...e)=>{mv().render(...e)}),F_=((...e)=>{hv().hydrate(...e)}),br=((...e)=>{const t=mv().createApp(...e),{mount:s}=t;return t.mount=a=>{const n=yv(a);if(!n)return;const i=t._component;!je(i)&&!i.render&&!i.template&&(i.template=n.innerHTML),n.nodeType===1&&(n.textContent="");const l=s(n,!1,bv(n));return n instanceof Element&&(n.removeAttribute("v-cloak"),n.setAttribute("data-v-app","")),l},t}),gv=((...e)=>{const t=hv().createApp(...e),{mount:s}=t;return t.mount=a=>{const n=yv(a);if(n)return s(n,!0,bv(n))},t});function bv(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function yv(e){return Qe(e)?document.querySelector(e):e}let Gp=!1;const B_=()=>{Gp||(Gp=!0,L_(),l_())},U_=Object.freeze(Object.defineProperty({__proto__:null,BaseTransition:nh,BaseTransitionPropsValidators:Zd,Comment:Ht,DeprecationTypes:Y0,EffectScope:Ud,ErrorCodes:tx,ErrorTypeStrings:V0,Fragment:ls,KeepAlive:Mx,ReactiveEffect:Dl,Static:Kn,Suspense:T0,Teleport:vx,Text:kn,TrackOpTypes:Ky,Transition:s_,TransitionGroup:T_,TriggerOpTypes:Jy,VueElement:Zr,assertNumber:ex,callWithAsyncErrorHandling:Zs,callWithErrorHandling:Yi,camelize:wt,capitalize:ai,cloneVNode:Oa,compatUtils:Q0,computed:U,createApp:br,createBlock:ur,createCommentVNode:Bh,createElementBlock:L0,createElementVNode:lu,createHydrationRenderer:Ah,createPropsRestProxy:s0,createRenderer:Eh,createSSRApp:gv,createSlots:zx,createStaticVNode:D0,createTextVNode:ou,createVNode:Lt,customRef:Hm,defineAsyncComponent:Lx,defineComponent:lo,defineCustomElement:nv,defineEmits:Gx,defineExpose:Wx,defineModel:Zx,defineOptions:Kx,defineProps:Vx,defineSSRCustomElement:x_,defineSlots:Jx,devtools:G0,effect:gy,effectScope:my,getCurrentInstance:Ls,getCurrentScope:Sm,getCurrentWatcher:Zy,getTransitionRawChildren:qr,guardReactiveProps:Fh,h:zi,handleError:ni,hasInjectionContext:cx,hydrate:F_,hydrateOnIdle:Tx,hydrateOnInteraction:Ix,hydrateOnMediaQuery:Rx,hydrateOnVisible:Ax,initCustomFormatter:H0,initDirectivesForSSR:B_,inject:na,isMemoSame:Wh,isProxy:no,isReactive:Qa,isReadonly:Ia,isRef:Wt,isRuntimeOnly:B0,isShallow:Hs,isVNode:nn,markRaw:Um,mergeDefaults:e0,mergeModels:t0,mergeProps:Uh,nextTick:Mt,nodeOps:Zh,normalizeClass:ao,normalizeProps:ty,normalizeStyle:so,onActivated:rs,onBeforeMount:oh,onBeforeUnmount:Wr,onBeforeUpdate:Yd,onDeactivated:Kt,onErrorCaptured:uh,onMounted:tt,onRenderTracked:dh,onRenderTriggered:ch,onScopeDispose:hy,onServerPrefetch:rh,onUnmounted:xt,onUpdated:Gr,onWatcherCleanup:qm,openBlock:jl,patchProp:av,popScopeId:lx,provide:El,proxyRefs:Gd,pushScopeId:ix,queuePostFlushCb:Fl,reactive:Tn,readonly:ar,ref:p,registerRuntimeCompiler:qh,render:vv,renderList:Ux,renderSlot:Hx,resolveComponent:$x,resolveDirective:Bx,resolveDynamicComponent:Fx,resolveFilter:Z0,resolveTransitionHooks:Ui,setBlockTracking:ql,setDevtoolsHook:W0,setTransitionHooks:an,shallowReactive:qd,shallowReadonly:Py,shallowRef:Vd,ssrContextKey:Zm,ssrUtils:J0,stop:by,toDisplayString:wm,toHandlerKey:Oi,toHandlers:jx,toRaw:dt,toRef:Vy,toRefs:Hy,toValue:By,transformVNodeArgs:N0,triggerRef:Fy,unref:Ra,useAttrs:Xx,useCssModule:k_,useCssVars:o_,useHost:iv,useId:bx,useModel:u0,useSSRContext:Qm,useShadowRoot:w_,useSlots:Yx,useTemplateRef:yx,useTransitionState:Jd,vModelCheckbox:du,vModelDynamic:uv,vModelRadio:uu,vModelSelect:cv,vModelText:gr,vShow:tv,version:Kh,warn:q0,watch:jt,watchEffect:dx,watchPostEffect:ux,watchSyncEffect:Ym,withAsyncContext:a0,withCtx:Kd,withDefaults:Qx,withDirectives:rx,withKeys:$_,withMemo:j0,withModifiers:D_,withScopeId:ox},Symbol.toStringTag,{value:"Module"}));/** * @vue/compiler-core v3.5.38 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT -**/const ql=Symbol(""),Il=Symbol(""),lu=Symbol(""),mr=Symbol(""),mv=Symbol(""),Qn=Symbol(""),hv=Symbol(""),vv=Symbol(""),ou=Symbol(""),ru=Symbol(""),ro=Symbol(""),cu=Symbol(""),gv=Symbol(""),du=Symbol(""),uu=Symbol(""),pu=Symbol(""),fu=Symbol(""),mu=Symbol(""),hu=Symbol(""),bv=Symbol(""),yv=Symbol(""),Gr=Symbol(""),hr=Symbol(""),vu=Symbol(""),gu=Symbol(""),Gl=Symbol(""),co=Symbol(""),bu=Symbol(""),cd=Symbol(""),N_=Symbol(""),dd=Symbol(""),vr=Symbol(""),M_=Symbol(""),D_=Symbol(""),yu=Symbol(""),P_=Symbol(""),$_=Symbol(""),xu=Symbol(""),xv=Symbol(""),ji={[ql]:"Fragment",[Il]:"Teleport",[lu]:"Suspense",[mr]:"KeepAlive",[mv]:"BaseTransition",[Qn]:"openBlock",[hv]:"createBlock",[vv]:"createElementBlock",[ou]:"createVNode",[ru]:"createElementVNode",[ro]:"createCommentVNode",[cu]:"createTextVNode",[gv]:"createStaticVNode",[du]:"resolveComponent",[uu]:"resolveDynamicComponent",[pu]:"resolveDirective",[fu]:"resolveFilter",[mu]:"withDirectives",[hu]:"renderList",[bv]:"renderSlot",[yv]:"createSlots",[Gr]:"toDisplayString",[hr]:"mergeProps",[vu]:"normalizeClass",[gu]:"normalizeStyle",[Gl]:"normalizeProps",[co]:"guardReactiveProps",[bu]:"toHandlers",[cd]:"camelize",[N_]:"capitalize",[dd]:"toHandlerKey",[vr]:"setBlockTracking",[M_]:"pushScopeId",[D_]:"popScopeId",[yu]:"withCtx",[P_]:"unref",[$_]:"isRef",[xu]:"withMemo",[xv]:"isMemoSame"};function F_(e){Object.getOwnPropertySymbols(e).forEach(t=>{ji[t]=e[t]})}const Qs={start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0},source:""};function B_(e,t=""){return{type:0,source:t,children:e,helpers:new Set,components:[],directives:[],hoists:[],imports:[],cached:[],temps:0,codegenNode:void 0,loc:Qs}}function Wl(e,t,s,a,n,i,l,o=!1,r=!1,c=!1,d=Qs){return e&&(o?(e.helper(Qn),e.helper(Gi(e.inSSR,c))):e.helper(qi(e.inSSR,c)),l&&e.helper(mu)),{type:13,tag:t,props:s,children:a,patchFlag:n,dynamicProps:i,directives:l,isBlock:o,disableTracking:r,isComponent:c,loc:d}}function Kn(e,t=Qs){return{type:17,loc:t,elements:e}}function aa(e,t=Qs){return{type:15,loc:t,properties:e}}function qt(e,t){return{type:16,loc:Qs,key:Ye(e)?et(e,!0):e,value:t}}function et(e,t=!1,s=Qs,a=0){return{type:4,loc:s,content:e,isStatic:t,constType:t?3:a}}function ha(e,t=Qs){return{type:8,loc:t,children:e}}function ss(e,t=[],s=Qs){return{type:14,loc:s,callee:e,arguments:t}}function Vi(e,t=void 0,s=!1,a=!1,n=Qs){return{type:18,params:e,returns:t,newline:s,isSlot:a,loc:n}}function ud(e,t,s,a=!0){return{type:19,test:e,consequent:t,alternate:s,newline:a,loc:Qs}}function U_(e,t,s=!1,a=!1){return{type:20,index:e,value:t,needPauseTracking:s,inVOnce:a,needArraySpread:!1,loc:Qs}}function z_(e){return{type:21,body:e,loc:Qs}}function qi(e,t){return e||t?ou:ru}function Gi(e,t){return e||t?hv:vv}function _u(e,{helper:t,removeHelper:s,inSSR:a}){e.isBlock||(e.isBlock=!0,s(qi(a,e.isComponent)),t(Qn),t(Gi(a,e.isComponent)))}const zp=new Uint8Array([123,123]),Hp=new Uint8Array([125,125]);function jp(e){return e>=97&&e<=122||e>=65&&e<=90}function Ks(e){return e===32||e===10||e===9||e===12||e===13}function mn(e){return e===47||e===62||Ks(e)}function gr(e){const t=new Uint8Array(e.length);for(let s=0;s100){let l=-1,o=n;for(;l+1>>1;this.newlines[r]=0;l--)if(t>this.newlines[l]){i=l;break}return i>=0&&(s=i+2,a=t-this.newlines[i]),{column:a,line:s,offset:t}}peek(){return this.buffer.charCodeAt(this.index+1)}stateText(t){t===60?(this.index>this.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=5,this.sectionStart=this.index):!this.inVPre&&t===this.delimiterOpen[0]&&(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(t))}stateInterpolationOpen(t){if(t===this.delimiterOpen[this.delimiterIndex])if(this.delimiterIndex===this.delimiterOpen.length-1){const s=this.index+1-this.delimiterOpen.length;s>this.sectionStart&&this.cbs.ontext(this.sectionStart,s),this.state=3,this.sectionStart=s}else this.delimiterIndex++;else this.inRCDATA?(this.state=32,this.stateInRCDATA(t)):(this.state=1,this.stateText(t))}stateInterpolation(t){t===this.delimiterClose[0]&&(this.state=4,this.delimiterIndex=0,this.stateInterpolationClose(t))}stateInterpolationClose(t){t===this.delimiterClose[this.delimiterIndex]?this.delimiterIndex===this.delimiterClose.length-1?(this.cbs.oninterpolation(this.sectionStart,this.index+1),this.inRCDATA?this.state=32:this.state=1,this.sectionStart=this.index+1):this.delimiterIndex++:(this.state=3,this.stateInterpolation(t))}stateSpecialStartSequence(t){const s=this.sequenceIndex===this.currentSequence.length;if(!(s?mn(t):(t|32)===this.currentSequence[this.sequenceIndex]))this.inRCDATA=!1;else if(!s){this.sequenceIndex++;return}this.sequenceIndex=0,this.state=6,this.stateInTagName(t)}stateInRCDATA(t){if(this.sequenceIndex===this.currentSequence.length){if(t===62||Ks(t)){const s=this.index-this.currentSequence.length;if(this.sectionStart=t||(this.state===28?this.currentSequence===ys.CdataEnd?this.cbs.oncdata(this.sectionStart,t):this.cbs.oncomment(this.sectionStart,t):this.state===6||this.state===11||this.state===18||this.state===17||this.state===12||this.state===13||this.state===14||this.state===15||this.state===16||this.state===20||this.state===19||this.state===21||this.state===9||this.cbs.ontext(this.sectionStart,t))}emitCodePoint(t,s){}}function Vp(e,{compatConfig:t}){const s=t&&t[e];return e==="MODE"?s||3:s}function Jn(e,t){const s=Vp("MODE",t),a=Vp(e,t);return s===3?a===!0:a!==!1}function Kl(e,t,s,...a){return Jn(e,t)}function wu(e){throw e}function _v(e){}function Ot(e,t,s,a){const n=`https://vuejs.org/error-reference/#compiler-${e}`,i=new SyntaxError(String(n));return i.code=e,i.loc=t,i}const zs=e=>e.type===4&&e.isStatic;function wv(e){switch(e){case"Teleport":case"teleport":return Il;case"Suspense":case"suspense":return lu;case"KeepAlive":case"keep-alive":return mr;case"BaseTransition":case"base-transition":return mv}}const j_=/^$|^\d|[^\$\w\xA0-\uFFFF]/,ku=e=>!j_.test(e),kv=/[A-Za-z_$\xA0-\uFFFF]/,V_=/[\.\?\w$\xA0-\uFFFF]/,q_=/\s+[.[]\s*|\s*[.[]\s+/g,Sv=e=>e.type===4?e.content:e.loc.source,G_=e=>{const t=Sv(e).trim().replace(q_,o=>o.trim());let s=0,a=[],n=0,i=0,l=null;for(let o=0;o|^\s*(?:async\s+)?function(?:\s+[\w$]+)?\s*\(/,K_=e=>W_.test(Sv(e)),J_=K_;function sa(e,t,s=!1){for(let a=0;at.type===7&&t.name==="bind"&&(!t.arg||t.arg.type!==4||!t.arg.isStatic))}function Tc(e){return e.type===5||e.type===2}function qp(e){return e.type===7&&e.name==="pre"}function Y_(e){return e.type===7&&e.name==="slot"}function br(e){return e.type===1&&e.tagType===3}function yr(e){return e.type===1&&e.tagType===2}const Q_=new Set([Gl,co]);function Tv(e,t=[]){if(e&&!Ye(e)&&e.type===14){const s=e.callee;if(!Ye(s)&&Q_.has(s))return Tv(e.arguments[0],t.concat(e))}return[e,t]}function xr(e,t,s){let a,n=e.type===13?e.props:e.arguments[2],i=[],l;if(n&&!Ye(n)&&n.type===14){const o=Tv(n);n=o[0],i=o[1],l=i[i.length-1]}if(n==null||Ye(n))a=aa([t]);else if(n.type===14){const o=n.arguments[0];!Ye(o)&&o.type===15?Gp(t,o)||o.properties.unshift(t):n.callee===bu?a=ss(s.helper(hr),[aa([t]),n]):n.arguments.unshift(aa([t])),!a&&(a=n)}else n.type===15?(Gp(t,n)||n.properties.unshift(t),a=n):(a=ss(s.helper(hr),[aa([t]),n]),l&&l.callee===co&&(l=i[i.length-2]));e.type===13?l?l.arguments[0]=a:e.props=a:l?l.arguments[0]=a:e.arguments[2]=a}function Gp(e,t){let s=!1;if(e.key.type===4){const a=e.key.content;s=t.properties.some(n=>n.key.type===4&&n.key.content===a)}return s}function Jl(e,t){return`_${t}_${e.replace(/[^\w]/g,(s,a)=>s==="-"?"_":e.charCodeAt(a).toString())}`}function X_(e){return e.type===14&&e.callee===xu?e.arguments[1].returns:e}const ew=/([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;function Ev(e){for(let t=0;t0,isVoidTag:Ci,isPreTag:Ci,isIgnoreNewlineTag:Ci,isCustomElement:Ci,onError:wu,onWarn:_v,comments:!1,prefixIdentifiers:!1};let ft=Rv,Zl=null,Xa="",_s=null,ot=null,Ps="",za=-1,Bn=-1,Cu=0,xn=!1,pd=null;const It=[],$t=new H_(It,{onerr:Fa,ontext(e,t){Ao(ps(e,t),e,t)},ontextentity(e,t,s){Ao(e,t,s)},oninterpolation(e,t){if(xn)return Ao(ps(e,t),e,t);let s=e+$t.delimiterOpen.length,a=t-$t.delimiterClose.length;for(;Ks(Xa.charCodeAt(s));)s++;for(;Ks(Xa.charCodeAt(a-1));)a--;let n=ps(s,a);n.includes("&")&&(n=ft.decodeEntities(n,!1)),fd({type:5,content:qo(n,!1,Ut(s,a)),loc:Ut(e,t)})},onopentagname(e,t){const s=ps(e,t);_s={type:1,tag:s,ns:ft.getNamespace(s,It[0],ft.ns),tagType:0,props:[],children:[],loc:Ut(e-1,t),codegenNode:void 0}},onopentagend(e){Kp(e)},onclosetag(e,t){const s=ps(e,t);if(!ft.isVoidTag(s)){let a=!1;for(let n=0;n0&&Fa(24,It[0].loc.start.offset);for(let l=0;l<=n;l++){const o=It.shift();Vo(o,t,l(a.type===7?a.rawName:a.name)===s)&&Fa(2,t)},onattribend(e,t){if(_s&&ot){if(jn(ot.loc,t),e!==0)if(Ps.includes("&")&&(Ps=ft.decodeEntities(Ps,!0)),ot.type===6)ot.name==="class"&&(Ps=Lv(Ps).trim()),e===1&&!Ps&&Fa(13,t),ot.value={type:2,content:Ps,loc:e===1?Ut(za,Bn):Ut(za-1,Bn+1)},$t.inSFCRoot&&_s.tag==="template"&&ot.name==="lang"&&Ps&&Ps!=="html"&&$t.enterRCDATA(gr("n.content==="sync"))>-1&&Kl("COMPILER_V_BIND_SYNC",ft,ot.loc,ot.arg.loc.source)&&(ot.name="model",ot.modifiers.splice(a,1))}(ot.type!==7||ot.name!=="pre")&&_s.props.push(ot)}Ps="",za=Bn=-1},oncomment(e,t){ft.comments&&fd({type:3,content:ps(e,t),loc:Ut(e-4,t+3)})},onend(){const e=Xa.length;for(let t=0;t{const h=t.start.offset+f,b=h+u.length;return qo(u,!1,Ut(h,b),0,m?1:0)},o={source:l(i.trim(),s.indexOf(i,n.length)),value:void 0,key:void 0,index:void 0,finalized:!1};let r=n.trim().replace(tw,"").trim();const c=n.indexOf(r),d=r.match(Wp);if(d){r=r.replace(Wp,"").trim();const u=d[1].trim();let f;if(u&&(f=s.indexOf(u,c+r.length),o.key=l(u,f,!0)),d[2]){const m=d[2].trim();m&&(o.index=l(m,s.indexOf(m,o.key?f+u.length:c+r.length),!0))}}return r&&(o.value=l(r,c,!0)),o}function ps(e,t){return Xa.slice(e,t)}function Kp(e){$t.inSFCRoot&&(_s.innerLoc=Ut(e+1,e+1)),fd(_s);const{tag:t,ns:s}=_s;s===0&&ft.isPreTag(t)&&Cu++,ft.isVoidTag(t)?Vo(_s,e):(It.unshift(_s),(s===1||s===2)&&($t.inXML=!0)),_s=null}function Ao(e,t,s){{const i=It[0]&&It[0].tag;i!=="script"&&i!=="style"&&e.includes("&")&&(e=ft.decodeEntities(e,!1))}const a=It[0]||Zl,n=a.children[a.children.length-1];n&&n.type===2?(n.content+=e,jn(n.loc,s)):a.children.push({type:2,content:e,loc:Ut(t,s)})}function Vo(e,t,s=!1){s?jn(e.loc,Iv(t,60)):jn(e.loc,aw(t,62)+1),$t.inSFCRoot&&(e.children.length?e.innerLoc.end=nt({},e.children[e.children.length-1].loc.end):e.innerLoc.end=nt({},e.innerLoc.start),e.innerLoc.source=ps(e.innerLoc.start.offset,e.innerLoc.end.offset));const{tag:a,ns:n,children:i}=e;if(xn||(a==="slot"?e.tagType=2:Jp(e)?e.tagType=3:iw(e)&&(e.tagType=1)),$t.inRCDATA||(e.children=Ov(i)),n===0&&ft.isIgnoreNewlineTag(a)){const l=i[0];l&&l.type===2&&(l.content=l.content.replace(/^\r?\n/,""))}n===0&&ft.isPreTag(a)&&Cu--,pd===e&&(xn=$t.inVPre=!1,pd=null),$t.inXML&&(It[0]?It[0].ns:ft.ns)===0&&($t.inXML=!1);{const l=e.props;if(!$t.inSFCRoot&&Jn("COMPILER_NATIVE_TEMPLATE",ft)&&e.tag==="template"&&!Jp(e)){const r=It[0]||Zl,c=r.children.indexOf(e);r.children.splice(c,1,...e.children)}const o=l.find(r=>r.type===6&&r.name==="inline-template");o&&Kl("COMPILER_INLINE_TEMPLATE",ft,o.loc)&&e.children.length&&(o.value={type:2,content:ps(e.children[0].loc.start.offset,e.children[e.children.length-1].loc.end.offset),loc:o.loc})}}function aw(e,t){let s=e;for(;Xa.charCodeAt(s)!==t&&s=0;)s--;return s}const nw=new Set(["if","else","else-if","for","slot"]);function Jp({tag:e,props:t}){if(e==="template"){for(let s=0;s64&&e<91}const ow=/\r\n/g;function Ov(e){const t=ft.whitespace!=="preserve";let s=!1;for(let a=0;as.type!==3);return t.length===1&&t[0].type===1&&!yr(t[0])?t[0]:null}function Go(e,t,s,a=!1,n=!1){const{children:i}=e,l=[];for(let d=0;d0){if(f>=2){u.codegenNode.patchFlag=-1,l.push(u);continue}}else{const m=u.codegenNode;if(m.type===13){const h=m.patchFlag;if((h===void 0||h===512||h===1)&&Dv(u,s)>=2){const b=Pv(u);b&&(m.props=s.hoist(b))}m.dynamicProps&&(m.dynamicProps=s.hoist(m.dynamicProps))}}}else if(u.type===12&&(a?0:Js(u,s))>=2){u.codegenNode.type===14&&u.codegenNode.arguments.length>0&&u.codegenNode.arguments.push("-1"),l.push(u);continue}if(u.type===1){const f=u.tagType===1;f&&s.scopes.vSlot++,Go(u,e,s,!1,n),f&&s.scopes.vSlot--}else if(u.type===11)Go(u,e,s,u.children.length===1,!0);else if(u.type===9)for(let f=0;fm.key===u||m.key.content===u);return f&&f.value}}l.length&&s.transformHoist&&s.transformHoist(i,s,e)}function Js(e,t){const{constantCache:s}=t;switch(e.type){case 1:if(e.tagType!==0)return 0;const a=s.get(e);if(a!==void 0)return a;const n=e.codegenNode;if(n.type!==13||n.isBlock&&e.tag!=="svg"&&e.tag!=="foreignObject"&&e.tag!=="math")return 0;if(n.patchFlag===void 0){let l=3;const o=Dv(e,t);if(o===0)return s.set(e,0),0;o1)for(let r=0;rB&&(R.childIndex--,R.onNodeRemoved()),R.parent.children.splice(B,1)},onNodeRemoved:hs,addIdentifiers(w){},removeIdentifiers(w){},hoist(w){Ye(w)&&(w=et(w)),R.hoists.push(w);const O=et(`_hoisted_${R.hoists.length}`,!1,w.loc,2);return O.hoisted=w,O},cache(w,O=!1,B=!1){const T=U_(R.cached.length,w,O,B);return R.cached.push(T),T}};return R.filters=new Set,R}function vw(e,t){const s=hw(e,t);Kr(e,s),t.hoistStatic&&fw(e,s),t.ssr||gw(e,s),e.helpers=new Set([...s.helpers.keys()]),e.components=[...s.components],e.directives=[...s.directives],e.imports=s.imports,e.hoists=s.hoists,e.temps=s.temps,e.cached=s.cached,e.transformed=!0,e.filters=[...s.filters]}function gw(e,t){const{helper:s}=t,{children:a}=e;if(a.length===1){const n=Nv(e);if(n&&n.codegenNode){const i=n.codegenNode;i.type===13&&_u(i,t),e.codegenNode=i}else e.codegenNode=a[0]}else if(a.length>1){let n=64;e.codegenNode=Wl(t,s(ql),void 0,e.children,n,void 0,void 0,!0,void 0,!1)}}function bw(e,t){let s=0;const a=()=>{s--};for(;sa===e:a=>e.test(a);return(a,n)=>{if(a.type===1){const{props:i}=a;if(a.tagType===3&&i.some(Y_))return;const l=[];for(let o=0;o`${ji[e]}: _${ji[e]}`;function yw(e,{mode:t="function",prefixIdentifiers:s=t==="module",sourceMap:a=!1,filename:n="template.vue.html",scopeId:i=null,optimizeImports:l=!1,runtimeGlobalName:o="Vue",runtimeModuleName:r="vue",ssrRuntimeModuleName:c="vue/server-renderer",ssr:d=!1,isTS:u=!1,inSSR:f=!1}){const m={mode:t,prefixIdentifiers:s,sourceMap:a,filename:n,scopeId:i,optimizeImports:l,runtimeGlobalName:o,runtimeModuleName:r,ssrRuntimeModuleName:c,ssr:d,isTS:u,inSSR:f,source:e.source,code:"",column:1,line:1,offset:0,indentLevel:0,pure:!1,map:void 0,helper(b){return`_${ji[b]}`},push(b,I=-2,k){m.code+=b},indent(){h(++m.indentLevel)},deindent(b=!1){b?--m.indentLevel:h(--m.indentLevel)},newline(){h(m.indentLevel)}};function h(b){m.push(` -`+" ".repeat(b),0)}return m}function xw(e,t={}){const s=yw(e,t);t.onContextCreated&&t.onContextCreated(s);const{mode:a,push:n,prefixIdentifiers:i,indent:l,deindent:o,newline:r,scopeId:c,ssr:d}=s,u=Array.from(e.helpers),f=u.length>0,m=!i&&a!=="module";_w(e,s);const b=d?"ssrRender":"render",k=(d?["_ctx","_push","_parent","_attrs"]:["_ctx","_cache"]).join(", ");if(n(`function ${b}(${k}) {`),l(),m&&(n("with (_ctx) {"),l(),f&&(n(`const { ${u.map(Fv).join(", ")} } = _Vue -`,-1),r())),e.components.length&&(Ec(e.components,"component",s),(e.directives.length||e.temps>0)&&r()),e.directives.length&&(Ec(e.directives,"directive",s),e.temps>0&&r()),e.filters&&e.filters.length&&(r(),Ec(e.filters,"filter",s),r()),e.temps>0){n("let ");for(let y=0;y0?", ":""}_temp${y}`)}return(e.components.length||e.directives.length||e.temps)&&(n(` -`,0),r()),d||n("return "),e.codegenNode?Ss(e.codegenNode,s):n("null"),m&&(o(),n("}")),o(),n("}"),{ast:e,code:s.code,preamble:"",map:s.map?s.map.toJSON():void 0}}function _w(e,t){const{ssr:s,prefixIdentifiers:a,push:n,newline:i,runtimeModuleName:l,runtimeGlobalName:o,ssrRuntimeModuleName:r}=t,c=o,d=Array.from(e.helpers);if(d.length>0&&(n(`const _Vue = ${c} -`,-1),e.hoists.length)){const u=[ou,ru,ro,cu,gv].filter(f=>d.includes(f)).map(Fv).join(", ");n(`const { ${u} } = _Vue -`,-1)}ww(e.hoists,t),i(),n("return ")}function Ec(e,t,{helper:s,push:a,newline:n,isTS:i}){const l=s(t==="filter"?fu:t==="component"?du:pu);for(let o=0;o3||!1;t.push("["),s&&t.indent(),uo(e,t,s),s&&t.deindent(),t.push("]")}function uo(e,t,s=!1,a=!0){const{push:n,newline:i}=t;for(let l=0;ls||"null")}function Rw(e,t){const{push:s,helper:a,pure:n}=t,i=Ye(e.callee)?e.callee:a(e.callee);n&&s(Jr),s(i+"(",-2,e),uo(e.arguments,t),s(")")}function Iw(e,t){const{push:s,indent:a,deindent:n,newline:i}=t,{properties:l}=e;if(!l.length){s("{}",-2,e);return}const o=l.length>1||!1;s(o?"{":"{ "),o&&a();for(let r=0;r "),(r||o)&&(s("{"),a()),l?(r&&s("return "),Ie(l)?Tu(l,t):Ss(l,t)):o&&Ss(o,t),(r||o)&&(n(),s("}")),c&&(e.isNonScopedSlot&&s(", undefined, true"),s(")"))}function Nw(e,t){const{test:s,consequent:a,alternate:n,newline:i}=e,{push:l,indent:o,deindent:r,newline:c}=t;if(s.type===4){const u=!ku(s.content);u&&l("("),Bv(s,t),u&&l(")")}else l("("),Ss(s,t),l(")");i&&o(),t.indentLevel++,i||l(" "),l("? "),Ss(a,t),t.indentLevel--,i&&c(),i||l(" "),l(": ");const d=n.type===19;d||t.indentLevel++,Ss(n,t),d||t.indentLevel--,i&&r(!0)}function Mw(e,t){const{push:s,helper:a,indent:n,deindent:i,newline:l}=t,{needPauseTracking:o,needArraySpread:r}=e;r&&s("[...("),s(`_cache[${e.index}] || (`),o&&(n(),s(`${a(vr)}(-1`),e.inVOnce&&s(", true"),s("),"),l(),s("(")),s(`_cache[${e.index}] = `),Ss(e.value,t),o&&(s(`).cacheIndex = ${e.index},`),l(),s(`${a(vr)}(1),`),l(),s(`_cache[${e.index}]`),i()),s(")"),r&&s(")]")}new RegExp("\\b"+"arguments,await,break,case,catch,class,const,continue,debugger,default,delete,do,else,export,extends,finally,for,function,if,import,let,new,return,super,switch,throw,try,var,void,while,with,yield".split(",").join("\\b|\\b")+"\\b");const Dw=$v(/^(?:if|else|else-if)$/,(e,t,s)=>Pw(e,t,s,(a,n,i)=>{const l=s.parent.children;let o=l.indexOf(a),r=0;for(;o-->=0;){const c=l[o];c&&c.type===9&&(r+=c.branches.length)}return()=>{if(i)a.codegenNode=Yp(n,r,s);else{const c=$w(a.codegenNode);c.alternate=Yp(n,r+a.branches.length-1,s)}}}));function Pw(e,t,s,a){if(t.name!=="else"&&(!t.exp||!t.exp.content.trim())){const n=t.exp?t.exp.loc:e.loc;s.onError(Ot(28,t.loc)),t.exp=et("true",!1,n)}if(t.name==="if"){const n=Zp(e,t),i={type:9,loc:cw(e.loc),branches:[n]};if(s.replaceNode(i),a)return a(i,n,!0)}else{const n=s.parent.children;let i=n.indexOf(e);for(;i-->=-1;){const l=n[i];if(l&&Av(l)){s.removeNode(l);continue}if(l&&l.type===9){(t.name==="else-if"||t.name==="else")&&l.branches[l.branches.length-1].condition===void 0&&s.onError(Ot(30,e.loc)),s.removeNode();const o=Zp(e,t);l.branches.push(o);const r=a&&a(l,o,!1);Kr(o,s),r&&r(),s.currentNode=null}else s.onError(Ot(30,e.loc));break}}}function Zp(e,t){const s=e.tagType===3;return{type:10,loc:e.loc,condition:t.name==="else"?void 0:t.exp,children:s&&!sa(e,"for")?e.children:[e],userKey:Wr(e,"key"),isTemplateIf:s}}function Yp(e,t,s){return e.condition?ud(e.condition,Qp(e,t,s),ss(s.helper(ro),['""',"true"])):Qp(e,t,s)}function Qp(e,t,s){const{helper:a}=s,n=qt("key",et(`${t}`,!1,Qs,2)),{children:i}=e,l=i[0];if(i.length!==1||l.type!==1)if(i.length===1&&l.type===11){const r=l.codegenNode;return xr(r,n,s),r}else return Wl(s,a(ql),aa([n]),i,64,void 0,void 0,!0,!1,!1,e.loc);else{const r=l.codegenNode,c=X_(r);return c.type===13&&_u(c,s),xr(c,n,s),r}}function $w(e){for(;;)if(e.type===19)if(e.alternate.type===19)e=e.alternate;else return e;else e.type===20&&(e=e.value)}const Fw=$v("for",(e,t,s)=>{const{helper:a,removeHelper:n}=s;return Bw(e,t,s,i=>{const l=ss(a(hu),[i.source]),o=br(e),r=sa(e,"memo"),c=Wr(e,"key",!1,!0);c&&c.type;let d=c&&(c.type===6?c.value?et(c.value.content,!0):void 0:c.exp);const u=d?qt("key",d):null,f=i.source.type===4&&i.source.constType>0,m=f?64:c?128:256;return i.codegenNode=Wl(s,a(ql),void 0,l,m,void 0,void 0,!0,!f,!1,e.loc),()=>{let h;const{children:b}=i,I=b.length!==1||b[0].type!==1,k=yr(e)?e:o&&e.children.length===1&&yr(e.children[0])?e.children[0]:null;if(k?(h=k.codegenNode,o&&u&&xr(h,u,s)):I?h=Wl(s,a(ql),u?aa([u]):void 0,e.children,64,void 0,void 0,!0,void 0,!1):(h=b[0].codegenNode,o&&u&&xr(h,u,s),h.isBlock!==!f&&(h.isBlock?(n(Qn),n(Gi(s.inSSR,h.isComponent))):n(qi(s.inSSR,h.isComponent))),h.isBlock=!f,h.isBlock?(a(Qn),a(Gi(s.inSSR,h.isComponent))):a(qi(s.inSSR,h.isComponent))),r){const y=Vi(md(i.parseResult,[et("_cached")]));y.body=z_([ha(["const _memo = (",r.exp,")"]),ha(["if (_cached && _cached.el",...d?[" && _cached.key === ",d]:[],` && ${s.helperString(xv)}(_cached, _memo)) return _cached`]),ha(["const _item = ",h]),et("_item.memo = _memo"),et("return _item")]),l.arguments.push(y,et("_cache"),et(String(s.cached.length))),s.cached.push(null)}else l.arguments.push(Vi(md(i.parseResult),h,!0))}})});function Bw(e,t,s,a){if(!t.exp){s.onError(Ot(31,t.loc));return}const n=t.forParseResult;if(!n){s.onError(Ot(32,t.loc));return}zv(n);const{addIdentifiers:i,removeIdentifiers:l,scopes:o}=s,{source:r,value:c,key:d,index:u}=n,f={type:11,loc:t.loc,source:r,valueAlias:c,keyAlias:d,objectIndexAlias:u,parseResult:n,children:br(e)?e.children:[e]};s.replaceNode(f),o.vFor++;const m=a&&a(f);return()=>{o.vFor--,m&&m()}}function zv(e,t){e.finalized||(e.finalized=!0)}function md({value:e,key:t,index:s},a=[]){return Uw([e,t,s,...a])}function Uw(e){let t=e.length;for(;t--&&!e[t];);return e.slice(0,t+1).map((s,a)=>s||et("_".repeat(a+1),!1))}const Xp=et("undefined",!1),zw=(e,t)=>{if(e.type===1&&(e.tagType===1||e.tagType===3)){const s=sa(e,"slot");if(s)return s.exp,t.scopes.vSlot++,()=>{t.scopes.vSlot--}}},Hw=(e,t,s,a)=>Vi(e,s,!1,!0,s.length?s[0].loc:a);function jw(e,t,s=Hw){t.helper(yu);const{children:a,loc:n}=e,i=[],l=[];let o=t.scopes.vSlot>0||t.scopes.vFor>0;const r=sa(e,"slot",!0);if(r){const{arg:I,exp:k}=r;I&&!zs(I)&&(o=!0),i.push(qt(I||et("default",!0),s(k,void 0,a,n)))}let c=!1,d=!1;const u=[],f=new Set;let m=0;for(let I=0;I{const g=s(k,void 0,y,n);return t.compatConfig&&(g.isNonScopedSlot=!0),qt("default",g)};c?u.length&&!u.every(Su)&&(d?t.onError(Ot(39,u[0].loc)):i.push(I(void 0,u))):i.push(I(void 0,a))}const h=o?2:Wo(e.children)?3:1;let b=aa(i.concat(qt("_",et(h+"",!1))),n);return l.length&&(b=ss(t.helper(yv),[b,Kn(l)])),{slots:b,hasDynamicSlots:o}}function Ro(e,t,s){const a=[qt("name",e),qt("fn",t)];return s!=null&&a.push(qt("key",et(String(s),!0))),aa(a)}function Wo(e){for(let t=0;tfunction(){if(e=t.currentNode,!(e.type===1&&(e.tagType===0||e.tagType===1)))return;const{tag:a,props:n}=e,i=e.tagType===1;let l=i?qw(e,t):`"${a}"`;const o=mt(l)&&l.callee===uu;let r,c,d=0,u,f,m,h=o||l===Il||l===lu||!i&&(a==="svg"||a==="foreignObject"||a==="math");if(n.length>0){const b=jv(e,t,void 0,i,o);r=b.props,d=b.patchFlag,f=b.dynamicPropNames;const I=b.directives;m=I&&I.length?Kn(I.map(k=>Ww(k,t))):void 0,b.shouldUseBlock&&(h=!0)}if(e.children.length>0)if(l===mr&&(h=!0,d|=1024),i&&l!==Il&&l!==mr){const{slots:I,hasDynamicSlots:k}=jw(e,t);c=I,k&&(d|=1024)}else if(e.children.length===1&&l!==Il){const I=e.children[0],k=I.type,y=k===5||k===8;y&&Js(I,t)===0&&(d|=1),y||k===2?c=I:c=e.children}else c=e.children;f&&f.length&&(u=Kw(f)),e.codegenNode=Wl(t,l,r,c,d===0?void 0:d,u,m,!!h,!1,i,e.loc)};function qw(e,t,s=!1){let{tag:a}=e;const n=hd(a),i=Wr(e,"is",!1,!0);if(i)if(n||Jn("COMPILER_IS_ON_ELEMENT",t)){let o;if(i.type===6?o=i.value&&et(i.value.content,!0):(o=i.exp,o||(o=et("is",!1,i.arg.loc))),o)return ss(t.helper(uu),[o])}else i.type===6&&i.value.content.startsWith("vue:")&&(a=i.value.content.slice(4));const l=wv(a)||t.isBuiltInComponent(a);return l?(s||t.helper(l),l):(t.helper(du),t.components.add(a),Jl(a,"component"))}function jv(e,t,s=e.props,a,n,i=!1){const{tag:l,loc:o,children:r}=e;let c=[];const d=[],u=[],f=r.length>0;let m=!1,h=0,b=!1,I=!1,k=!1,y=!1,g=!1,x=!1;const S=[],_=O=>{c.length&&(d.push(aa(ef(c),o)),c=[]),O&&d.push(O)},A=()=>{t.scopes.vFor>0&&c.push(qt(et("ref_for",!0),et("true")))},R=({key:O,value:B})=>{if(zs(O)){const T=O.content,$=ei(T);if($&&(!a||n)&&T.toLowerCase()!=="onclick"&&T!=="onUpdate:modelValue"&&!Za(T)&&(y=!0),$&&Za(T)&&(x=!0),$&&B.type===14&&(B=B.arguments[0]),B.type===20||(B.type===4||B.type===8)&&Js(B,t)>0)return;T==="ref"?b=!0:T==="class"?I=!0:T==="style"?k=!0:T!=="key"&&!S.includes(T)&&S.push(T),a&&(T==="class"||T==="style")&&!S.includes(T)&&S.push(T)}else g=!0};for(let O=0;Oce.content==="prop")&&(h|=32);const re=t.directiveTransforms[T];if(re){const{props:ce,needRuntime:J}=re(B,e,t);!i&&ce.forEach(R),D&&$&&!zs($)?_(aa(ce,o)):c.push(...ce),J&&(u.push(B),Cs(J)&&Hv.set(B,J))}else Fb(T)||(u.push(B),f&&(m=!0))}}let w;if(d.length?(_(),d.length>1?w=ss(t.helper(hr),d,o):w=d[0]):c.length&&(w=aa(ef(c),o)),g?h|=16:(I&&!a&&(h|=2),k&&!a&&(h|=4),S.length&&(h|=8),y&&(h|=32)),!m&&(h===0||h===32)&&(b||x||u.length>0)&&(h|=512),!t.inSSR&&w)switch(w.type){case 15:let O=-1,B=-1,T=!1;for(let W=0;Wqt(l,i)),n))}return Kn(s,e.loc)}function Kw(e){let t="[";for(let s=0,a=e.length;s{if(yr(e)){const{children:s,loc:a}=e,{slotName:n,slotProps:i}=Zw(e,t),l=[t.prefixIdentifiers?"_ctx.$slots":"$slots",n,"{}","undefined","true"];let o=2;i&&(l[2]=i,o=3),s.length&&(l[3]=Vi([],s,!1,!1,a),o=4),t.scopeId&&!t.slotted&&(o=5),l.splice(o),e.codegenNode=ss(t.helper(bv),l,a)}};function Zw(e,t){let s='"default"',a;const n=[];for(let i=0;i0){const{props:i,directives:l}=jv(e,t,n,!1,!1);a=i,l.length&&t.onError(Ot(36,l[0].loc))}return{slotName:s,slotProps:a}}const Vv=(e,t,s,a)=>{const{loc:n,modifiers:i,arg:l}=e;!e.exp&&!i.length&&s.onError(Ot(35,n));let o;if(l.type===4)if(l.isStatic){let u=l.content;u.startsWith("vue:")&&(u=`vnode-${u.slice(4)}`);const f=t.tagType!==0||u.startsWith("vnode")||!/[A-Z]/.test(u)?Ii(wt(u)):`on:${u}`;o=et(f,!0,l.loc)}else o=ha([`${s.helperString(dd)}(`,l,")"]);else o=l,o.children.unshift(`${s.helperString(dd)}(`),o.children.push(")");let r=e.exp;r&&!r.content.trim()&&(r=void 0);let c=s.cacheHandlers&&!r&&!s.inVOnce;if(r){const u=Cv(r),f=!(u||J_(r)),m=r.content.includes(";");(f||c&&u)&&(r=ha([`${f?"$event":"(...args)"} => ${m?"{":"("}`,r,m?"}":")"]))}let d={props:[qt(o,r||et("() => {}",!1,n))]};return a&&(d=a(d)),c&&(d.props[0].value=s.cache(d.props[0].value)),d.props.forEach(u=>u.key.isHandlerKey=!0),d},Yw=(e,t,s)=>{const{modifiers:a,loc:n}=e,i=e.arg;let{exp:l}=e;return l&&l.type===4&&!l.content.trim()&&(l=void 0),i.type!==4?(i.children.unshift("("),i.children.push(') || ""')):i.isStatic||(i.content=i.content?`${i.content} || ""`:'""'),a.some(o=>o.content==="camel")&&(i.type===4?i.isStatic?i.content=wt(i.content):i.content=`${s.helperString(cd)}(${i.content})`:(i.children.unshift(`${s.helperString(cd)}(`),i.children.push(")"))),s.inSSR||(a.some(o=>o.content==="prop")&&tf(i,"."),a.some(o=>o.content==="attr")&&tf(i,"^")),{props:[qt(i,l)]}},tf=(e,t)=>{e.type===4?e.isStatic?e.content=t+e.content:e.content=`\`${t}\${${e.content}}\``:(e.children.unshift(`'${t}' + (`),e.children.push(")"))},Qw=(e,t)=>{if(e.type===0||e.type===1||e.type===11||e.type===10)return()=>{const s=e.children;let a,n=!1;for(let i=0;ii.type===7&&!t.directiveTransforms[i.name])&&e.tag!=="template")))for(let i=0;i{if(e.type===1&&sa(e,"once",!0))return sf.has(e)||t.inVOnce||t.inSSR?void 0:(sf.add(e),t.inVOnce=!0,t.helper(vr),()=>{t.inVOnce=!1;const s=t.currentNode;s.codegenNode&&(s.codegenNode=t.cache(s.codegenNode,!0,!0))})},qv=(e,t,s)=>{const{exp:a,arg:n}=e;if(!a)return s.onError(Ot(41,e.loc)),pl();const i=a.loc.source.trim(),l=a.type===4?a.content:i,o=s.bindingMetadata[i];if(o==="props"||o==="props-aliased")return s.onError(Ot(44,a.loc)),pl();if(o==="literal-const"||o==="setup-const")return s.onError(Ot(45,a.loc)),pl();if(!l.trim()||!Cv(a))return s.onError(Ot(42,a.loc)),pl();const r=n||et("modelValue",!0),c=n?zs(n)?`onUpdate:${wt(n.content)}`:ha(['"onUpdate:" + ',n]):"onUpdate:modelValue";let d;const u=s.isTS?"($event: any)":"$event";d=ha([`${u} => ((`,a,") = $event)"]);const f=[qt(r,e.exp),qt(c,d)];if(e.modifiers.length&&t.tagType===1){const m=e.modifiers.map(b=>b.content).map(b=>(ku(b)?b:JSON.stringify(b))+": true").join(", "),h=n?zs(n)?`${n.content}Modifiers`:ha([n,' + "Modifiers"']):"modelModifiers";f.push(qt(h,et(`{ ${m} }`,!1,e.loc,2)))}return pl(f)};function pl(e=[]){return{props:e}}const ek=/[\w).+\-_$\]]/,tk=(e,t)=>{Jn("COMPILER_FILTERS",t)&&(e.type===5?_r(e.content,t):e.type===1&&e.props.forEach(s=>{s.type===7&&s.name!=="for"&&s.exp&&_r(s.exp,t)}))};function _r(e,t){if(e.type===4)af(e,t);else for(let s=0;s=0&&(y=s.charAt(k),y===" ");k--);(!y||!ek.test(y))&&(l=!0)}}h===void 0?h=s.slice(0,m).trim():d!==0&&I();function I(){b.push(s.slice(d,m).trim()),d=m+1}if(b.length){for(m=0;m{if(e.type===1){const s=sa(e,"memo");return!s||nf.has(e)||t.inSSR?void 0:(nf.add(e),()=>{const a=e.codegenNode||t.currentNode.codegenNode;a&&a.type===13&&(e.tagType!==1&&_u(a,t),e.codegenNode=ss(t.helper(xu),[s.exp,Vi(void 0,a),"_cache",String(t.cached.length)]),t.cached.push(null))})}},nk=(e,t)=>{if(e.type===1){for(const s of e.props)if(s.type===7&&s.name==="bind"&&(!s.exp||s.exp.type===4&&!s.exp.content.trim())&&s.arg){const a=s.arg;if(a.type!==4||!a.isStatic)t.onError(Ot(53,a.loc)),s.exp=et("",!0,a.loc);else{const n=wt(a.content);(kv.test(n[0])||n[0]==="-")&&(s.exp=et(n,!1,a.loc))}}}};function ik(e){return[[nk,Xw,Dw,ak,Fw,tk,Jw,Vw,zw,Qw],{on:Vv,bind:Yw,model:qv}]}function lk(e,t={}){const s=t.onError||wu,a=t.mode==="module";t.prefixIdentifiers===!0?s(Ot(48)):a&&s(Ot(49));const n=!1;t.cacheHandlers&&s(Ot(50)),t.scopeId&&!a&&s(Ot(51));const i=nt({},t,{prefixIdentifiers:n}),l=Ye(e)?pw(e,i):e,[o,r]=ik();return vw(l,nt({},i,{nodeTransforms:[...o,...t.nodeTransforms||[]],directiveTransforms:nt({},r,t.directiveTransforms||{})})),xw(l,i)}const ok=()=>({props:[]});/** +**/const Gl=Symbol(""),Ol=Symbol(""),pu=Symbol(""),yr=Symbol(""),xv=Symbol(""),Xn=Symbol(""),_v=Symbol(""),wv=Symbol(""),fu=Symbol(""),mu=Symbol(""),co=Symbol(""),hu=Symbol(""),kv=Symbol(""),vu=Symbol(""),gu=Symbol(""),bu=Symbol(""),yu=Symbol(""),xu=Symbol(""),_u=Symbol(""),Sv=Symbol(""),Cv=Symbol(""),Qr=Symbol(""),xr=Symbol(""),wu=Symbol(""),ku=Symbol(""),Wl=Symbol(""),uo=Symbol(""),Su=Symbol(""),md=Symbol(""),z_=Symbol(""),hd=Symbol(""),_r=Symbol(""),H_=Symbol(""),j_=Symbol(""),Cu=Symbol(""),q_=Symbol(""),V_=Symbol(""),Tu=Symbol(""),Tv=Symbol(""),qi={[Gl]:"Fragment",[Ol]:"Teleport",[pu]:"Suspense",[yr]:"KeepAlive",[xv]:"BaseTransition",[Xn]:"openBlock",[_v]:"createBlock",[wv]:"createElementBlock",[fu]:"createVNode",[mu]:"createElementVNode",[co]:"createCommentVNode",[hu]:"createTextVNode",[kv]:"createStaticVNode",[vu]:"resolveComponent",[gu]:"resolveDynamicComponent",[bu]:"resolveDirective",[yu]:"resolveFilter",[xu]:"withDirectives",[_u]:"renderList",[Sv]:"renderSlot",[Cv]:"createSlots",[Qr]:"toDisplayString",[xr]:"mergeProps",[wu]:"normalizeClass",[ku]:"normalizeStyle",[Wl]:"normalizeProps",[uo]:"guardReactiveProps",[Su]:"toHandlers",[md]:"camelize",[z_]:"capitalize",[hd]:"toHandlerKey",[_r]:"setBlockTracking",[H_]:"pushScopeId",[j_]:"popScopeId",[Cu]:"withCtx",[q_]:"unref",[V_]:"isRef",[Tu]:"withMemo",[Tv]:"isMemoSame"};function G_(e){Object.getOwnPropertySymbols(e).forEach(t=>{qi[t]=e[t]})}const Ys={start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0},source:""};function W_(e,t=""){return{type:0,source:t,children:e,helpers:new Set,components:[],directives:[],hoists:[],imports:[],cached:[],temps:0,codegenNode:void 0,loc:Ys}}function Kl(e,t,s,a,n,i,l,o=!1,r=!1,c=!1,d=Ys){return e&&(o?(e.helper(Xn),e.helper(Wi(e.inSSR,c))):e.helper(Gi(e.inSSR,c)),l&&e.helper(xu)),{type:13,tag:t,props:s,children:a,patchFlag:n,dynamicProps:i,directives:l,isBlock:o,disableTracking:r,isComponent:c,loc:d}}function Jn(e,t=Ys){return{type:17,loc:t,elements:e}}function aa(e,t=Ys){return{type:15,loc:t,properties:e}}function Gt(e,t){return{type:16,loc:Ys,key:Qe(e)?et(e,!0):e,value:t}}function et(e,t=!1,s=Ys,a=0){return{type:4,loc:s,content:e,isStatic:t,constType:t?3:a}}function ha(e,t=Ys){return{type:8,loc:t,children:e}}function ss(e,t=[],s=Ys){return{type:14,loc:s,callee:e,arguments:t}}function Vi(e,t=void 0,s=!1,a=!1,n=Ys){return{type:18,params:e,returns:t,newline:s,isSlot:a,loc:n}}function vd(e,t,s,a=!0){return{type:19,test:e,consequent:t,alternate:s,newline:a,loc:Ys}}function K_(e,t,s=!1,a=!1){return{type:20,index:e,value:t,needPauseTracking:s,inVOnce:a,needArraySpread:!1,loc:Ys}}function J_(e){return{type:21,body:e,loc:Ys}}function Gi(e,t){return e||t?fu:mu}function Wi(e,t){return e||t?_v:wv}function Eu(e,{helper:t,removeHelper:s,inSSR:a}){e.isBlock||(e.isBlock=!0,s(Gi(a,e.isComponent)),t(Xn),t(Wi(a,e.isComponent)))}const Wp=new Uint8Array([123,123]),Kp=new Uint8Array([125,125]);function Jp(e){return e>=97&&e<=122||e>=65&&e<=90}function Ks(e){return e===32||e===10||e===9||e===12||e===13}function mn(e){return e===47||e===62||Ks(e)}function wr(e){const t=new Uint8Array(e.length);for(let s=0;s100){let l=-1,o=n;for(;l+1>>1;this.newlines[r]=0;l--)if(t>this.newlines[l]){i=l;break}return i>=0&&(s=i+2,a=t-this.newlines[i]),{column:a,line:s,offset:t}}peek(){return this.buffer.charCodeAt(this.index+1)}stateText(t){t===60?(this.index>this.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=5,this.sectionStart=this.index):!this.inVPre&&t===this.delimiterOpen[0]&&(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(t))}stateInterpolationOpen(t){if(t===this.delimiterOpen[this.delimiterIndex])if(this.delimiterIndex===this.delimiterOpen.length-1){const s=this.index+1-this.delimiterOpen.length;s>this.sectionStart&&this.cbs.ontext(this.sectionStart,s),this.state=3,this.sectionStart=s}else this.delimiterIndex++;else this.inRCDATA?(this.state=32,this.stateInRCDATA(t)):(this.state=1,this.stateText(t))}stateInterpolation(t){t===this.delimiterClose[0]&&(this.state=4,this.delimiterIndex=0,this.stateInterpolationClose(t))}stateInterpolationClose(t){t===this.delimiterClose[this.delimiterIndex]?this.delimiterIndex===this.delimiterClose.length-1?(this.cbs.oninterpolation(this.sectionStart,this.index+1),this.inRCDATA?this.state=32:this.state=1,this.sectionStart=this.index+1):this.delimiterIndex++:(this.state=3,this.stateInterpolation(t))}stateSpecialStartSequence(t){const s=this.sequenceIndex===this.currentSequence.length;if(!(s?mn(t):(t|32)===this.currentSequence[this.sequenceIndex]))this.inRCDATA=!1;else if(!s){this.sequenceIndex++;return}this.sequenceIndex=0,this.state=6,this.stateInTagName(t)}stateInRCDATA(t){if(this.sequenceIndex===this.currentSequence.length){if(t===62||Ks(t)){const s=this.index-this.currentSequence.length;if(this.sectionStart=t||(this.state===28?this.currentSequence===ys.CdataEnd?this.cbs.oncdata(this.sectionStart,t):this.cbs.oncomment(this.sectionStart,t):this.state===6||this.state===11||this.state===18||this.state===17||this.state===12||this.state===13||this.state===14||this.state===15||this.state===16||this.state===20||this.state===19||this.state===21||this.state===9||this.cbs.ontext(this.sectionStart,t))}emitCodePoint(t,s){}}function Zp(e,{compatConfig:t}){const s=t&&t[e];return e==="MODE"?s||3:s}function Zn(e,t){const s=Zp("MODE",t),a=Zp(e,t);return s===3?a===!0:a!==!1}function Jl(e,t,s,...a){return Zn(e,t)}function Au(e){throw e}function Ev(e){}function Ot(e,t,s,a){const n=`https://vuejs.org/error-reference/#compiler-${e}`,i=new SyntaxError(String(n));return i.code=e,i.loc=t,i}const zs=e=>e.type===4&&e.isStatic;function Av(e){switch(e){case"Teleport":case"teleport":return Ol;case"Suspense":case"suspense":return pu;case"KeepAlive":case"keep-alive":return yr;case"BaseTransition":case"base-transition":return xv}}const Q_=/^$|^\d|[^\$\w\xA0-\uFFFF]/,Ru=e=>!Q_.test(e),Rv=/[A-Za-z_$\xA0-\uFFFF]/,Y_=/[\.\?\w$\xA0-\uFFFF]/,X_=/\s+[.[]\s*|\s*[.[]\s+/g,Iv=e=>e.type===4?e.content:e.loc.source,ew=e=>{const t=Iv(e).trim().replace(X_,o=>o.trim());let s=0,a=[],n=0,i=0,l=null;for(let o=0;o|^\s*(?:async\s+)?function(?:\s+[\w$]+)?\s*\(/,sw=e=>tw.test(Iv(e)),aw=sw;function sa(e,t,s=!1){for(let a=0;at.type===7&&t.name==="bind"&&(!t.arg||t.arg.type!==4||!t.arg.isStatic))}function Oc(e){return e.type===5||e.type===2}function Qp(e){return e.type===7&&e.name==="pre"}function iw(e){return e.type===7&&e.name==="slot"}function kr(e){return e.type===1&&e.tagType===3}function Sr(e){return e.type===1&&e.tagType===2}const lw=new Set([Wl,uo]);function Lv(e,t=[]){if(e&&!Qe(e)&&e.type===14){const s=e.callee;if(!Qe(s)&&lw.has(s))return Lv(e.arguments[0],t.concat(e))}return[e,t]}function Cr(e,t,s){let a,n=e.type===13?e.props:e.arguments[2],i=[],l;if(n&&!Qe(n)&&n.type===14){const o=Lv(n);n=o[0],i=o[1],l=i[i.length-1]}if(n==null||Qe(n))a=aa([t]);else if(n.type===14){const o=n.arguments[0];!Qe(o)&&o.type===15?Yp(t,o)||o.properties.unshift(t):n.callee===Su?a=ss(s.helper(xr),[aa([t]),n]):n.arguments.unshift(aa([t])),!a&&(a=n)}else n.type===15?(Yp(t,n)||n.properties.unshift(t),a=n):(a=ss(s.helper(xr),[aa([t]),n]),l&&l.callee===uo&&(l=i[i.length-2]));e.type===13?l?l.arguments[0]=a:e.props=a:l?l.arguments[0]=a:e.arguments[2]=a}function Yp(e,t){let s=!1;if(e.key.type===4){const a=e.key.content;s=t.properties.some(n=>n.key.type===4&&n.key.content===a)}return s}function Zl(e,t){return`_${t}_${e.replace(/[^\w]/g,(s,a)=>s==="-"?"_":e.charCodeAt(a).toString())}`}function ow(e){return e.type===14&&e.callee===Tu?e.arguments[1].returns:e}const rw=/([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;function Nv(e){for(let t=0;t0,isVoidTag:Ti,isPreTag:Ti,isIgnoreNewlineTag:Ti,isCustomElement:Ti,onError:Au,onWarn:Ev,comments:!1,prefixIdentifiers:!1};let ft=Dv,Ql=null,Xa="",_s=null,ot=null,Ps="",za=-1,Un=-1,Ou=0,xn=!1,gd=null;const It=[],Ft=new Z_(It,{onerr:Fa,ontext(e,t){Ro(ps(e,t),e,t)},ontextentity(e,t,s){Ro(e,t,s)},oninterpolation(e,t){if(xn)return Ro(ps(e,t),e,t);let s=e+Ft.delimiterOpen.length,a=t-Ft.delimiterClose.length;for(;Ks(Xa.charCodeAt(s));)s++;for(;Ks(Xa.charCodeAt(a-1));)a--;let n=ps(s,a);n.includes("&")&&(n=ft.decodeEntities(n,!1)),bd({type:5,content:Zo(n,!1,zt(s,a)),loc:zt(e,t)})},onopentagname(e,t){const s=ps(e,t);_s={type:1,tag:s,ns:ft.getNamespace(s,It[0],ft.ns),tagType:0,props:[],children:[],loc:zt(e-1,t),codegenNode:void 0}},onopentagend(e){ef(e)},onclosetag(e,t){const s=ps(e,t);if(!ft.isVoidTag(s)){let a=!1;for(let n=0;n0&&Fa(24,It[0].loc.start.offset);for(let l=0;l<=n;l++){const o=It.shift();Jo(o,t,l(a.type===7?a.rawName:a.name)===s)&&Fa(2,t)},onattribend(e,t){if(_s&&ot){if(qn(ot.loc,t),e!==0)if(Ps.includes("&")&&(Ps=ft.decodeEntities(Ps,!0)),ot.type===6)ot.name==="class"&&(Ps=Fv(Ps).trim()),e===1&&!Ps&&Fa(13,t),ot.value={type:2,content:Ps,loc:e===1?zt(za,Un):zt(za-1,Un+1)},Ft.inSFCRoot&&_s.tag==="template"&&ot.name==="lang"&&Ps&&Ps!=="html"&&Ft.enterRCDATA(wr("n.content==="sync"))>-1&&Jl("COMPILER_V_BIND_SYNC",ft,ot.loc,ot.arg.loc.source)&&(ot.name="model",ot.modifiers.splice(a,1))}(ot.type!==7||ot.name!=="pre")&&_s.props.push(ot)}Ps="",za=Un=-1},oncomment(e,t){ft.comments&&bd({type:3,content:ps(e,t),loc:zt(e-4,t+3)})},onend(){const e=Xa.length;for(let t=0;t{const h=t.start.offset+f,g=h+u.length;return Zo(u,!1,zt(h,g),0,m?1:0)},o={source:l(i.trim(),s.indexOf(i,n.length)),value:void 0,key:void 0,index:void 0,finalized:!1};let r=n.trim().replace(cw,"").trim();const c=n.indexOf(r),d=r.match(Xp);if(d){r=r.replace(Xp,"").trim();const u=d[1].trim();let f;if(u&&(f=s.indexOf(u,c+r.length),o.key=l(u,f,!0)),d[2]){const m=d[2].trim();m&&(o.index=l(m,s.indexOf(m,o.key?f+u.length:c+r.length),!0))}}return r&&(o.value=l(r,c,!0)),o}function ps(e,t){return Xa.slice(e,t)}function ef(e){Ft.inSFCRoot&&(_s.innerLoc=zt(e+1,e+1)),bd(_s);const{tag:t,ns:s}=_s;s===0&&ft.isPreTag(t)&&Ou++,ft.isVoidTag(t)?Jo(_s,e):(It.unshift(_s),(s===1||s===2)&&(Ft.inXML=!0)),_s=null}function Ro(e,t,s){{const i=It[0]&&It[0].tag;i!=="script"&&i!=="style"&&e.includes("&")&&(e=ft.decodeEntities(e,!1))}const a=It[0]||Ql,n=a.children[a.children.length-1];n&&n.type===2?(n.content+=e,qn(n.loc,s)):a.children.push({type:2,content:e,loc:zt(t,s)})}function Jo(e,t,s=!1){s?qn(e.loc,Pv(t,60)):qn(e.loc,uw(t,62)+1),Ft.inSFCRoot&&(e.children.length?e.innerLoc.end=nt({},e.children[e.children.length-1].loc.end):e.innerLoc.end=nt({},e.innerLoc.start),e.innerLoc.source=ps(e.innerLoc.start.offset,e.innerLoc.end.offset));const{tag:a,ns:n,children:i}=e;if(xn||(a==="slot"?e.tagType=2:tf(e)?e.tagType=3:fw(e)&&(e.tagType=1)),Ft.inRCDATA||(e.children=$v(i)),n===0&&ft.isIgnoreNewlineTag(a)){const l=i[0];l&&l.type===2&&(l.content=l.content.replace(/^\r?\n/,""))}n===0&&ft.isPreTag(a)&&Ou--,gd===e&&(xn=Ft.inVPre=!1,gd=null),Ft.inXML&&(It[0]?It[0].ns:ft.ns)===0&&(Ft.inXML=!1);{const l=e.props;if(!Ft.inSFCRoot&&Zn("COMPILER_NATIVE_TEMPLATE",ft)&&e.tag==="template"&&!tf(e)){const r=It[0]||Ql,c=r.children.indexOf(e);r.children.splice(c,1,...e.children)}const o=l.find(r=>r.type===6&&r.name==="inline-template");o&&Jl("COMPILER_INLINE_TEMPLATE",ft,o.loc)&&e.children.length&&(o.value={type:2,content:ps(e.children[0].loc.start.offset,e.children[e.children.length-1].loc.end.offset),loc:o.loc})}}function uw(e,t){let s=e;for(;Xa.charCodeAt(s)!==t&&s=0;)s--;return s}const pw=new Set(["if","else","else-if","for","slot"]);function tf({tag:e,props:t}){if(e==="template"){for(let s=0;s64&&e<91}const hw=/\r\n/g;function $v(e){const t=ft.whitespace!=="preserve";let s=!1;for(let a=0;as.type!==3);return t.length===1&&t[0].type===1&&!Sr(t[0])?t[0]:null}function Qo(e,t,s,a=!1,n=!1){const{children:i}=e,l=[];for(let d=0;d0){if(f>=2){u.codegenNode.patchFlag=-1,l.push(u);continue}}else{const m=u.codegenNode;if(m.type===13){const h=m.patchFlag;if((h===void 0||h===512||h===1)&&zv(u,s)>=2){const g=Hv(u);g&&(m.props=s.hoist(g))}m.dynamicProps&&(m.dynamicProps=s.hoist(m.dynamicProps))}}}else if(u.type===12&&(a?0:Js(u,s))>=2){u.codegenNode.type===14&&u.codegenNode.arguments.length>0&&u.codegenNode.arguments.push("-1"),l.push(u);continue}if(u.type===1){const f=u.tagType===1;f&&s.scopes.vSlot++,Qo(u,e,s,!1,n),f&&s.scopes.vSlot--}else if(u.type===11)Qo(u,e,s,u.children.length===1,!0);else if(u.type===9)for(let f=0;fm.key===u||m.key.content===u);return f&&f.value}}l.length&&s.transformHoist&&s.transformHoist(i,s,e)}function Js(e,t){const{constantCache:s}=t;switch(e.type){case 1:if(e.tagType!==0)return 0;const a=s.get(e);if(a!==void 0)return a;const n=e.codegenNode;if(n.type!==13||n.isBlock&&e.tag!=="svg"&&e.tag!=="foreignObject"&&e.tag!=="math")return 0;if(n.patchFlag===void 0){let l=3;const o=zv(e,t);if(o===0)return s.set(e,0),0;o1)for(let r=0;rR&&(L.childIndex--,L.onNodeRemoved()),L.parent.children.splice(R,1)},onNodeRemoved:hs,addIdentifiers(E){},removeIdentifiers(E){},hoist(E){Qe(E)&&(E=et(E)),L.hoists.push(E);const P=et(`_hoisted_${L.hoists.length}`,!1,E.loc,2);return P.hoisted=E,P},cache(E,P=!1,R=!1){const I=K_(L.cached.length,E,P,R);return L.cached.push(I),I}};return L.filters=new Set,L}function Sw(e,t){const s=kw(e,t);Xr(e,s),t.hoistStatic&&_w(e,s),t.ssr||Cw(e,s),e.helpers=new Set([...s.helpers.keys()]),e.components=[...s.components],e.directives=[...s.directives],e.imports=s.imports,e.hoists=s.hoists,e.temps=s.temps,e.cached=s.cached,e.transformed=!0,e.filters=[...s.filters]}function Cw(e,t){const{helper:s}=t,{children:a}=e;if(a.length===1){const n=Bv(e);if(n&&n.codegenNode){const i=n.codegenNode;i.type===13&&Eu(i,t),e.codegenNode=i}else e.codegenNode=a[0]}else if(a.length>1){let n=64;e.codegenNode=Kl(t,s(Gl),void 0,e.children,n,void 0,void 0,!0,void 0,!1)}}function Tw(e,t){let s=0;const a=()=>{s--};for(;sa===e:a=>e.test(a);return(a,n)=>{if(a.type===1){const{props:i}=a;if(a.tagType===3&&i.some(iw))return;const l=[];for(let o=0;o`${qi[e]}: _${qi[e]}`;function Ew(e,{mode:t="function",prefixIdentifiers:s=t==="module",sourceMap:a=!1,filename:n="template.vue.html",scopeId:i=null,optimizeImports:l=!1,runtimeGlobalName:o="Vue",runtimeModuleName:r="vue",ssrRuntimeModuleName:c="vue/server-renderer",ssr:d=!1,isTS:u=!1,inSSR:f=!1}){const m={mode:t,prefixIdentifiers:s,sourceMap:a,filename:n,scopeId:i,optimizeImports:l,runtimeGlobalName:o,runtimeModuleName:r,ssrRuntimeModuleName:c,ssr:d,isTS:u,inSSR:f,source:e.source,code:"",column:1,line:1,offset:0,indentLevel:0,pure:!1,map:void 0,helper(g){return`_${qi[g]}`},push(g,A=-2,w){m.code+=g},indent(){h(++m.indentLevel)},deindent(g=!1){g?--m.indentLevel:h(--m.indentLevel)},newline(){h(m.indentLevel)}};function h(g){m.push(` +`+" ".repeat(g),0)}return m}function Aw(e,t={}){const s=Ew(e,t);t.onContextCreated&&t.onContextCreated(s);const{mode:a,push:n,prefixIdentifiers:i,indent:l,deindent:o,newline:r,scopeId:c,ssr:d}=s,u=Array.from(e.helpers),f=u.length>0,m=!i&&a!=="module";Rw(e,s);const g=d?"ssrRender":"render",w=(d?["_ctx","_push","_parent","_attrs"]:["_ctx","_cache"]).join(", ");if(n(`function ${g}(${w}) {`),l(),m&&(n("with (_ctx) {"),l(),f&&(n(`const { ${u.map(qv).join(", ")} } = _Vue +`,-1),r())),e.components.length&&(Lc(e.components,"component",s),(e.directives.length||e.temps>0)&&r()),e.directives.length&&(Lc(e.directives,"directive",s),e.temps>0&&r()),e.filters&&e.filters.length&&(r(),Lc(e.filters,"filter",s),r()),e.temps>0){n("let ");for(let y=0;y0?", ":""}_temp${y}`)}return(e.components.length||e.directives.length||e.temps)&&(n(` +`,0),r()),d||n("return "),e.codegenNode?Ss(e.codegenNode,s):n("null"),m&&(o(),n("}")),o(),n("}"),{ast:e,code:s.code,preamble:"",map:s.map?s.map.toJSON():void 0}}function Rw(e,t){const{ssr:s,prefixIdentifiers:a,push:n,newline:i,runtimeModuleName:l,runtimeGlobalName:o,ssrRuntimeModuleName:r}=t,c=o,d=Array.from(e.helpers);if(d.length>0&&(n(`const _Vue = ${c} +`,-1),e.hoists.length)){const u=[fu,mu,co,hu,kv].filter(f=>d.includes(f)).map(qv).join(", ");n(`const { ${u} } = _Vue +`,-1)}Iw(e.hoists,t),i(),n("return ")}function Lc(e,t,{helper:s,push:a,newline:n,isTS:i}){const l=s(t==="filter"?yu:t==="component"?vu:bu);for(let o=0;o3||!1;t.push("["),s&&t.indent(),po(e,t,s),s&&t.deindent(),t.push("]")}function po(e,t,s=!1,a=!0){const{push:n,newline:i}=t;for(let l=0;ls||"null")}function $w(e,t){const{push:s,helper:a,pure:n}=t,i=Qe(e.callee)?e.callee:a(e.callee);n&&s(ec),s(i+"(",-2,e),po(e.arguments,t),s(")")}function Fw(e,t){const{push:s,indent:a,deindent:n,newline:i}=t,{properties:l}=e;if(!l.length){s("{}",-2,e);return}const o=l.length>1||!1;s(o?"{":"{ "),o&&a();for(let r=0;r "),(r||o)&&(s("{"),a()),l?(r&&s("return "),Le(l)?Lu(l,t):Ss(l,t)):o&&Ss(o,t),(r||o)&&(n(),s("}")),c&&(e.isNonScopedSlot&&s(", undefined, true"),s(")"))}function zw(e,t){const{test:s,consequent:a,alternate:n,newline:i}=e,{push:l,indent:o,deindent:r,newline:c}=t;if(s.type===4){const u=!Ru(s.content);u&&l("("),Vv(s,t),u&&l(")")}else l("("),Ss(s,t),l(")");i&&o(),t.indentLevel++,i||l(" "),l("? "),Ss(a,t),t.indentLevel--,i&&c(),i||l(" "),l(": ");const d=n.type===19;d||t.indentLevel++,Ss(n,t),d||t.indentLevel--,i&&r(!0)}function Hw(e,t){const{push:s,helper:a,indent:n,deindent:i,newline:l}=t,{needPauseTracking:o,needArraySpread:r}=e;r&&s("[...("),s(`_cache[${e.index}] || (`),o&&(n(),s(`${a(_r)}(-1`),e.inVOnce&&s(", true"),s("),"),l(),s("(")),s(`_cache[${e.index}] = `),Ss(e.value,t),o&&(s(`).cacheIndex = ${e.index},`),l(),s(`${a(_r)}(1),`),l(),s(`_cache[${e.index}]`),i()),s(")"),r&&s(")]")}new RegExp("\\b"+"arguments,await,break,case,catch,class,const,continue,debugger,default,delete,do,else,export,extends,finally,for,function,if,import,let,new,return,super,switch,throw,try,var,void,while,with,yield".split(",").join("\\b|\\b")+"\\b");const jw=jv(/^(?:if|else|else-if)$/,(e,t,s)=>qw(e,t,s,(a,n,i)=>{const l=s.parent.children;let o=l.indexOf(a),r=0;for(;o-->=0;){const c=l[o];c&&c.type===9&&(r+=c.branches.length)}return()=>{if(i)a.codegenNode=af(n,r,s);else{const c=Vw(a.codegenNode);c.alternate=af(n,r+a.branches.length-1,s)}}}));function qw(e,t,s,a){if(t.name!=="else"&&(!t.exp||!t.exp.content.trim())){const n=t.exp?t.exp.loc:e.loc;s.onError(Ot(28,t.loc)),t.exp=et("true",!1,n)}if(t.name==="if"){const n=sf(e,t),i={type:9,loc:gw(e.loc),branches:[n]};if(s.replaceNode(i),a)return a(i,n,!0)}else{const n=s.parent.children;let i=n.indexOf(e);for(;i-->=-1;){const l=n[i];if(l&&Mv(l)){s.removeNode(l);continue}if(l&&l.type===9){(t.name==="else-if"||t.name==="else")&&l.branches[l.branches.length-1].condition===void 0&&s.onError(Ot(30,e.loc)),s.removeNode();const o=sf(e,t);l.branches.push(o);const r=a&&a(l,o,!1);Xr(o,s),r&&r(),s.currentNode=null}else s.onError(Ot(30,e.loc));break}}}function sf(e,t){const s=e.tagType===3;return{type:10,loc:e.loc,condition:t.name==="else"?void 0:t.exp,children:s&&!sa(e,"for")?e.children:[e],userKey:Yr(e,"key"),isTemplateIf:s}}function af(e,t,s){return e.condition?vd(e.condition,nf(e,t,s),ss(s.helper(co),['""',"true"])):nf(e,t,s)}function nf(e,t,s){const{helper:a}=s,n=Gt("key",et(`${t}`,!1,Ys,2)),{children:i}=e,l=i[0];if(i.length!==1||l.type!==1)if(i.length===1&&l.type===11){const r=l.codegenNode;return Cr(r,n,s),r}else return Kl(s,a(Gl),aa([n]),i,64,void 0,void 0,!0,!1,!1,e.loc);else{const r=l.codegenNode,c=ow(r);return c.type===13&&Eu(c,s),Cr(c,n,s),r}}function Vw(e){for(;;)if(e.type===19)if(e.alternate.type===19)e=e.alternate;else return e;else e.type===20&&(e=e.value)}const Gw=jv("for",(e,t,s)=>{const{helper:a,removeHelper:n}=s;return Ww(e,t,s,i=>{const l=ss(a(_u),[i.source]),o=kr(e),r=sa(e,"memo"),c=Yr(e,"key",!1,!0);c&&c.type;let d=c&&(c.type===6?c.value?et(c.value.content,!0):void 0:c.exp);const u=d?Gt("key",d):null,f=i.source.type===4&&i.source.constType>0,m=f?64:c?128:256;return i.codegenNode=Kl(s,a(Gl),void 0,l,m,void 0,void 0,!0,!f,!1,e.loc),()=>{let h;const{children:g}=i,A=g.length!==1||g[0].type!==1,w=Sr(e)?e:o&&e.children.length===1&&Sr(e.children[0])?e.children[0]:null;if(w?(h=w.codegenNode,o&&u&&Cr(h,u,s)):A?h=Kl(s,a(Gl),u?aa([u]):void 0,e.children,64,void 0,void 0,!0,void 0,!1):(h=g[0].codegenNode,o&&u&&Cr(h,u,s),h.isBlock!==!f&&(h.isBlock?(n(Xn),n(Wi(s.inSSR,h.isComponent))):n(Gi(s.inSSR,h.isComponent))),h.isBlock=!f,h.isBlock?(a(Xn),a(Wi(s.inSSR,h.isComponent))):a(Gi(s.inSSR,h.isComponent))),r){const y=Vi(yd(i.parseResult,[et("_cached")]));y.body=J_([ha(["const _memo = (",r.exp,")"]),ha(["if (_cached && _cached.el",...d?[" && _cached.key === ",d]:[],` && ${s.helperString(Tv)}(_cached, _memo)) return _cached`]),ha(["const _item = ",h]),et("_item.memo = _memo"),et("return _item")]),l.arguments.push(y,et("_cache"),et(String(s.cached.length))),s.cached.push(null)}else l.arguments.push(Vi(yd(i.parseResult),h,!0))}})});function Ww(e,t,s,a){if(!t.exp){s.onError(Ot(31,t.loc));return}const n=t.forParseResult;if(!n){s.onError(Ot(32,t.loc));return}Wv(n);const{addIdentifiers:i,removeIdentifiers:l,scopes:o}=s,{source:r,value:c,key:d,index:u}=n,f={type:11,loc:t.loc,source:r,valueAlias:c,keyAlias:d,objectIndexAlias:u,parseResult:n,children:kr(e)?e.children:[e]};s.replaceNode(f),o.vFor++;const m=a&&a(f);return()=>{o.vFor--,m&&m()}}function Wv(e,t){e.finalized||(e.finalized=!0)}function yd({value:e,key:t,index:s},a=[]){return Kw([e,t,s,...a])}function Kw(e){let t=e.length;for(;t--&&!e[t];);return e.slice(0,t+1).map((s,a)=>s||et("_".repeat(a+1),!1))}const lf=et("undefined",!1),Jw=(e,t)=>{if(e.type===1&&(e.tagType===1||e.tagType===3)){const s=sa(e,"slot");if(s)return s.exp,t.scopes.vSlot++,()=>{t.scopes.vSlot--}}},Zw=(e,t,s,a)=>Vi(e,s,!1,!0,s.length?s[0].loc:a);function Qw(e,t,s=Zw){t.helper(Cu);const{children:a,loc:n}=e,i=[],l=[];let o=t.scopes.vSlot>0||t.scopes.vFor>0;const r=sa(e,"slot",!0);if(r){const{arg:A,exp:w}=r;A&&!zs(A)&&(o=!0),i.push(Gt(A||et("default",!0),s(w,void 0,a,n)))}let c=!1,d=!1;const u=[],f=new Set;let m=0;for(let A=0;A{const b=s(w,void 0,y,n);return t.compatConfig&&(b.isNonScopedSlot=!0),Gt("default",b)};c?u.length&&!u.every(Iu)&&(d?t.onError(Ot(39,u[0].loc)):i.push(A(void 0,u))):i.push(A(void 0,a))}const h=o?2:Yo(e.children)?3:1;let g=aa(i.concat(Gt("_",et(h+"",!1))),n);return l.length&&(g=ss(t.helper(Cv),[g,Jn(l)])),{slots:g,hasDynamicSlots:o}}function Io(e,t,s){const a=[Gt("name",e),Gt("fn",t)];return s!=null&&a.push(Gt("key",et(String(s),!0))),aa(a)}function Yo(e){for(let t=0;tfunction(){if(e=t.currentNode,!(e.type===1&&(e.tagType===0||e.tagType===1)))return;const{tag:a,props:n}=e,i=e.tagType===1;let l=i?Xw(e,t):`"${a}"`;const o=mt(l)&&l.callee===gu;let r,c,d=0,u,f,m,h=o||l===Ol||l===pu||!i&&(a==="svg"||a==="foreignObject"||a==="math");if(n.length>0){const g=Jv(e,t,void 0,i,o);r=g.props,d=g.patchFlag,f=g.dynamicPropNames;const A=g.directives;m=A&&A.length?Jn(A.map(w=>tk(w,t))):void 0,g.shouldUseBlock&&(h=!0)}if(e.children.length>0)if(l===yr&&(h=!0,d|=1024),i&&l!==Ol&&l!==yr){const{slots:A,hasDynamicSlots:w}=Qw(e,t);c=A,w&&(d|=1024)}else if(e.children.length===1&&l!==Ol){const A=e.children[0],w=A.type,y=w===5||w===8;y&&Js(A,t)===0&&(d|=1),y||w===2?c=A:c=e.children}else c=e.children;f&&f.length&&(u=sk(f)),e.codegenNode=Kl(t,l,r,c,d===0?void 0:d,u,m,!!h,!1,i,e.loc)};function Xw(e,t,s=!1){let{tag:a}=e;const n=xd(a),i=Yr(e,"is",!1,!0);if(i)if(n||Zn("COMPILER_IS_ON_ELEMENT",t)){let o;if(i.type===6?o=i.value&&et(i.value.content,!0):(o=i.exp,o||(o=et("is",!1,i.arg.loc))),o)return ss(t.helper(gu),[o])}else i.type===6&&i.value.content.startsWith("vue:")&&(a=i.value.content.slice(4));const l=Av(a)||t.isBuiltInComponent(a);return l?(s||t.helper(l),l):(t.helper(vu),t.components.add(a),Zl(a,"component"))}function Jv(e,t,s=e.props,a,n,i=!1){const{tag:l,loc:o,children:r}=e;let c=[];const d=[],u=[],f=r.length>0;let m=!1,h=0,g=!1,A=!1,w=!1,y=!1,b=!1,x=!1;const S=[],_=P=>{c.length&&(d.push(aa(of(c),o)),c=[]),P&&d.push(P)},T=()=>{t.scopes.vFor>0&&c.push(Gt(et("ref_for",!0),et("true")))},L=({key:P,value:R})=>{if(zs(P)){const I=P.content,H=ti(I);if(H&&(!a||n)&&I.toLowerCase()!=="onclick"&&I!=="onUpdate:modelValue"&&!Za(I)&&(y=!0),H&&Za(I)&&(x=!0),H&&R.type===14&&(R=R.arguments[0]),R.type===20||(R.type===4||R.type===8)&&Js(R,t)>0)return;I==="ref"?g=!0:I==="class"?A=!0:I==="style"?w=!0:I!=="key"&&!S.includes(I)&&S.push(I),a&&(I==="class"||I==="style")&&!S.includes(I)&&S.push(I)}else b=!0};for(let P=0;Pue.content==="prop")&&(h|=32);const ce=t.directiveTransforms[I];if(ce){const{props:ue,needRuntime:Z}=ce(R,e,t);!i&&ue.forEach(L),$&&H&&!zs(H)?_(aa(ue,o)):c.push(...ue),Z&&(u.push(R),Cs(Z)&&Kv.set(R,Z))}else Gb(I)||(u.push(R),f&&(m=!0))}}let E;if(d.length?(_(),d.length>1?E=ss(t.helper(xr),d,o):E=d[0]):c.length&&(E=aa(of(c),o)),b?h|=16:(A&&!a&&(h|=2),w&&!a&&(h|=4),S.length&&(h|=8),y&&(h|=32)),!m&&(h===0||h===32)&&(g||x||u.length>0)&&(h|=512),!t.inSSR&&E)switch(E.type){case 15:let P=-1,R=-1,I=!1;for(let Q=0;QGt(l,i)),n))}return Jn(s,e.loc)}function sk(e){let t="[";for(let s=0,a=e.length;s{if(Sr(e)){const{children:s,loc:a}=e,{slotName:n,slotProps:i}=nk(e,t),l=[t.prefixIdentifiers?"_ctx.$slots":"$slots",n,"{}","undefined","true"];let o=2;i&&(l[2]=i,o=3),s.length&&(l[3]=Vi([],s,!1,!1,a),o=4),t.scopeId&&!t.slotted&&(o=5),l.splice(o),e.codegenNode=ss(t.helper(Sv),l,a)}};function nk(e,t){let s='"default"',a;const n=[];for(let i=0;i0){const{props:i,directives:l}=Jv(e,t,n,!1,!1);a=i,l.length&&t.onError(Ot(36,l[0].loc))}return{slotName:s,slotProps:a}}const Zv=(e,t,s,a)=>{const{loc:n,modifiers:i,arg:l}=e;!e.exp&&!i.length&&s.onError(Ot(35,n));let o;if(l.type===4)if(l.isStatic){let u=l.content;u.startsWith("vue:")&&(u=`vnode-${u.slice(4)}`);const f=t.tagType!==0||u.startsWith("vnode")||!/[A-Z]/.test(u)?Oi(wt(u)):`on:${u}`;o=et(f,!0,l.loc)}else o=ha([`${s.helperString(hd)}(`,l,")"]);else o=l,o.children.unshift(`${s.helperString(hd)}(`),o.children.push(")");let r=e.exp;r&&!r.content.trim()&&(r=void 0);let c=s.cacheHandlers&&!r&&!s.inVOnce;if(r){const u=Ov(r),f=!(u||aw(r)),m=r.content.includes(";");(f||c&&u)&&(r=ha([`${f?"$event":"(...args)"} => ${m?"{":"("}`,r,m?"}":")"]))}let d={props:[Gt(o,r||et("() => {}",!1,n))]};return a&&(d=a(d)),c&&(d.props[0].value=s.cache(d.props[0].value)),d.props.forEach(u=>u.key.isHandlerKey=!0),d},ik=(e,t,s)=>{const{modifiers:a,loc:n}=e,i=e.arg;let{exp:l}=e;return l&&l.type===4&&!l.content.trim()&&(l=void 0),i.type!==4?(i.children.unshift("("),i.children.push(') || ""')):i.isStatic||(i.content=i.content?`${i.content} || ""`:'""'),a.some(o=>o.content==="camel")&&(i.type===4?i.isStatic?i.content=wt(i.content):i.content=`${s.helperString(md)}(${i.content})`:(i.children.unshift(`${s.helperString(md)}(`),i.children.push(")"))),s.inSSR||(a.some(o=>o.content==="prop")&&rf(i,"."),a.some(o=>o.content==="attr")&&rf(i,"^")),{props:[Gt(i,l)]}},rf=(e,t)=>{e.type===4?e.isStatic?e.content=t+e.content:e.content=`\`${t}\${${e.content}}\``:(e.children.unshift(`'${t}' + (`),e.children.push(")"))},lk=(e,t)=>{if(e.type===0||e.type===1||e.type===11||e.type===10)return()=>{const s=e.children;let a,n=!1;for(let i=0;ii.type===7&&!t.directiveTransforms[i.name])&&e.tag!=="template")))for(let i=0;i{if(e.type===1&&sa(e,"once",!0))return cf.has(e)||t.inVOnce||t.inSSR?void 0:(cf.add(e),t.inVOnce=!0,t.helper(_r),()=>{t.inVOnce=!1;const s=t.currentNode;s.codegenNode&&(s.codegenNode=t.cache(s.codegenNode,!0,!0))})},Qv=(e,t,s)=>{const{exp:a,arg:n}=e;if(!a)return s.onError(Ot(41,e.loc)),fl();const i=a.loc.source.trim(),l=a.type===4?a.content:i,o=s.bindingMetadata[i];if(o==="props"||o==="props-aliased")return s.onError(Ot(44,a.loc)),fl();if(o==="literal-const"||o==="setup-const")return s.onError(Ot(45,a.loc)),fl();if(!l.trim()||!Ov(a))return s.onError(Ot(42,a.loc)),fl();const r=n||et("modelValue",!0),c=n?zs(n)?`onUpdate:${wt(n.content)}`:ha(['"onUpdate:" + ',n]):"onUpdate:modelValue";let d;const u=s.isTS?"($event: any)":"$event";d=ha([`${u} => ((`,a,") = $event)"]);const f=[Gt(r,e.exp),Gt(c,d)];if(e.modifiers.length&&t.tagType===1){const m=e.modifiers.map(g=>g.content).map(g=>(Ru(g)?g:JSON.stringify(g))+": true").join(", "),h=n?zs(n)?`${n.content}Modifiers`:ha([n,' + "Modifiers"']):"modelModifiers";f.push(Gt(h,et(`{ ${m} }`,!1,e.loc,2)))}return fl(f)};function fl(e=[]){return{props:e}}const rk=/[\w).+\-_$\]]/,ck=(e,t)=>{Zn("COMPILER_FILTERS",t)&&(e.type===5?Tr(e.content,t):e.type===1&&e.props.forEach(s=>{s.type===7&&s.name!=="for"&&s.exp&&Tr(s.exp,t)}))};function Tr(e,t){if(e.type===4)df(e,t);else for(let s=0;s=0&&(y=s.charAt(w),y===" ");w--);(!y||!rk.test(y))&&(l=!0)}}h===void 0?h=s.slice(0,m).trim():d!==0&&A();function A(){g.push(s.slice(d,m).trim()),d=m+1}if(g.length){for(m=0;m{if(e.type===1){const s=sa(e,"memo");return!s||uf.has(e)||t.inSSR?void 0:(uf.add(e),()=>{const a=e.codegenNode||t.currentNode.codegenNode;a&&a.type===13&&(e.tagType!==1&&Eu(a,t),e.codegenNode=ss(t.helper(Tu),[s.exp,Vi(void 0,a),"_cache",String(t.cached.length)]),t.cached.push(null))})}},pk=(e,t)=>{if(e.type===1){for(const s of e.props)if(s.type===7&&s.name==="bind"&&(!s.exp||s.exp.type===4&&!s.exp.content.trim())&&s.arg){const a=s.arg;if(a.type!==4||!a.isStatic)t.onError(Ot(53,a.loc)),s.exp=et("",!0,a.loc);else{const n=wt(a.content);(Rv.test(n[0])||n[0]==="-")&&(s.exp=et(n,!1,a.loc))}}}};function fk(e){return[[pk,ok,jw,uk,Gw,ck,ak,Yw,Jw,lk],{on:Zv,bind:ik,model:Qv}]}function mk(e,t={}){const s=t.onError||Au,a=t.mode==="module";t.prefixIdentifiers===!0?s(Ot(48)):a&&s(Ot(49));const n=!1;t.cacheHandlers&&s(Ot(50)),t.scopeId&&!a&&s(Ot(51));const i=nt({},t,{prefixIdentifiers:n}),l=Qe(e)?xw(e,i):e,[o,r]=fk();return Sw(l,nt({},i,{nodeTransforms:[...o,...t.nodeTransforms||[]],directiveTransforms:nt({},r,t.directiveTransforms||{})})),Aw(l,i)}const hk=()=>({props:[]});/** * @vue/compiler-dom v3.5.38 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT -**/const Gv=Symbol(""),Wv=Symbol(""),Kv=Symbol(""),Jv=Symbol(""),vd=Symbol(""),Zv=Symbol(""),Yv=Symbol(""),Qv=Symbol(""),Xv=Symbol(""),eg=Symbol("");F_({[Gv]:"vModelRadio",[Wv]:"vModelCheckbox",[Kv]:"vModelText",[Jv]:"vModelSelect",[vd]:"vModelDynamic",[Zv]:"withModifiers",[Yv]:"withKeys",[Qv]:"vShow",[Xv]:"Transition",[eg]:"TransitionGroup"});let gi;function rk(e,t=!1){return gi||(gi=document.createElement("div")),t?(gi.innerHTML=`
`,gi.children[0].getAttribute("foo")):(gi.innerHTML=e,gi.textContent)}const ck={parseMode:"html",isVoidTag:ty,isNativeTag:e=>Qb(e)||Xb(e)||ey(e),isPreTag:e=>e==="pre",isIgnoreNewlineTag:e=>e==="pre"||e==="textarea",decodeEntities:rk,isBuiltInComponent:e=>{if(e==="Transition"||e==="transition")return Xv;if(e==="TransitionGroup"||e==="transition-group")return eg},getNamespace(e,t,s){let a=t?t.ns:s;if(t&&a===2)if(t.tag==="annotation-xml"){if(e==="svg")return 1;t.props.some(n=>n.type===6&&n.name==="encoding"&&n.value!=null&&(n.value.content==="text/html"||n.value.content==="application/xhtml+xml"))&&(a=0)}else/^m(?:[ions]|text)$/.test(t.tag)&&e!=="mglyph"&&e!=="malignmark"&&(a=0);else t&&a===1&&(t.tag==="foreignObject"||t.tag==="desc"||t.tag==="title")&&(a=0);if(a===0){if(e==="svg")return 1;if(e==="math")return 2}return a}},dk=e=>{e.type===1&&e.props.forEach((t,s)=>{t.type===6&&t.name==="style"&&t.value&&(e.props[s]={type:7,name:"bind",arg:et("style",!0,t.loc),exp:uk(t.value.content,t.loc),modifiers:[],loc:t.loc})})},uk=(e,t)=>{const s=fm(e);return et(JSON.stringify(s),!1,t,3)};function Sn(e,t){return Ot(e,t)}const pk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Sn(54,n)),t.children.length&&(s.onError(Sn(55,n)),t.children.length=0),{props:[qt(et("innerHTML",!0,n),a||et("",!0))]}},fk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Sn(56,n)),t.children.length&&(s.onError(Sn(57,n)),t.children.length=0),{props:[qt(et("textContent",!0),a?Js(a,s)>0?a:ss(s.helperString(Gr),[a],n):et("",!0))]}},mk=(e,t,s)=>{const a=qv(e,t,s);if(!a.props.length||t.tagType===1)return a;e.arg&&s.onError(Sn(59,e.arg.loc));const{tag:n}=t,i=s.isCustomElement(n);if(n==="input"||n==="textarea"||n==="select"||i){let l=Kv,o=!1;if(n==="input"||i){const r=Wr(t,"type");if(r){if(r.type===7)l=vd;else if(r.value)switch(r.value.content){case"radio":l=Gv;break;case"checkbox":l=Wv;break;case"file":o=!0,s.onError(Sn(60,e.loc));break}}else Z_(t)&&(l=vd)}else n==="select"&&(l=Jv);o||(a.needRuntime=s.helper(l))}else s.onError(Sn(58,e.loc));return a.props=a.props.filter(l=>!(l.key.type===4&&l.key.content==="modelValue")),a},hk=Ys("passive,once,capture"),vk=Ys("stop,prevent,self,ctrl,shift,alt,meta,exact,middle"),gk=Ys("left,right"),tg=Ys("onkeyup,onkeydown,onkeypress"),bk=(e,t,s,a)=>{const n=[],i=[],l=[];for(let o=0;ozs(e)&&e.content.toLowerCase()==="onclick"?et(t,!0):e.type!==4?ha(["(",e,`) === "onClick" ? "${t}" : (`,e,")"]):e,yk=(e,t,s)=>Vv(e,t,s,a=>{const{modifiers:n}=e;if(!n.length)return a;let{key:i,value:l}=a.props[0];const{keyModifiers:o,nonKeyModifiers:r,eventOptionModifiers:c}=bk(i,n,s,e.loc);if(r.includes("right")&&(i=lf(i,"onContextmenu")),r.includes("middle")&&(i=lf(i,"onMouseup")),r.length&&(l=ss(s.helper(Zv),[l,JSON.stringify(r)])),o.length&&(!zs(i)||tg(i.content.toLowerCase()))&&(l=ss(s.helper(Yv),[l,JSON.stringify(o)])),c.length){const d=c.map(si).join("");i=zs(i)?et(`${i.content}${d}`,!0):ha(["(",i,`) + "${d}"`])}return{props:[qt(i,l)]}}),xk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Sn(62,n)),{props:[],needRuntime:s.helper(Qv)}},_k=(e,t)=>{e.type===1&&e.tagType===0&&(e.tag==="script"||e.tag==="style")&&t.removeNode()},wk=[dk],kk={cloak:ok,html:pk,text:fk,model:mk,on:yk,show:xk};function Sk(e,t={}){return lk(e,nt({},ck,t,{nodeTransforms:[_k,...wk,...t.nodeTransforms||[]],directiveTransforms:nt({},kk,t.directiveTransforms||{}),transformHoist:null}))}/** +**/const Yv=Symbol(""),Xv=Symbol(""),eg=Symbol(""),tg=Symbol(""),_d=Symbol(""),sg=Symbol(""),ag=Symbol(""),ng=Symbol(""),ig=Symbol(""),lg=Symbol("");G_({[Yv]:"vModelRadio",[Xv]:"vModelCheckbox",[eg]:"vModelText",[tg]:"vModelSelect",[_d]:"vModelDynamic",[sg]:"withModifiers",[ag]:"withKeys",[ng]:"vShow",[ig]:"Transition",[lg]:"TransitionGroup"});let bi;function vk(e,t=!1){return bi||(bi=document.createElement("div")),t?(bi.innerHTML=`
`,bi.children[0].getAttribute("foo")):(bi.innerHTML=e,bi.textContent)}const gk={parseMode:"html",isVoidTag:cy,isNativeTag:e=>ly(e)||oy(e)||ry(e),isPreTag:e=>e==="pre",isIgnoreNewlineTag:e=>e==="pre"||e==="textarea",decodeEntities:vk,isBuiltInComponent:e=>{if(e==="Transition"||e==="transition")return ig;if(e==="TransitionGroup"||e==="transition-group")return lg},getNamespace(e,t,s){let a=t?t.ns:s;if(t&&a===2)if(t.tag==="annotation-xml"){if(e==="svg")return 1;t.props.some(n=>n.type===6&&n.name==="encoding"&&n.value!=null&&(n.value.content==="text/html"||n.value.content==="application/xhtml+xml"))&&(a=0)}else/^m(?:[ions]|text)$/.test(t.tag)&&e!=="mglyph"&&e!=="malignmark"&&(a=0);else t&&a===1&&(t.tag==="foreignObject"||t.tag==="desc"||t.tag==="title")&&(a=0);if(a===0){if(e==="svg")return 1;if(e==="math")return 2}return a}},bk=e=>{e.type===1&&e.props.forEach((t,s)=>{t.type===6&&t.name==="style"&&t.value&&(e.props[s]={type:7,name:"bind",arg:et("style",!0,t.loc),exp:yk(t.value.content,t.loc),modifiers:[],loc:t.loc})})},yk=(e,t)=>{const s=ym(e);return et(JSON.stringify(s),!1,t,3)};function Sn(e,t){return Ot(e,t)}const xk=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Sn(54,n)),t.children.length&&(s.onError(Sn(55,n)),t.children.length=0),{props:[Gt(et("innerHTML",!0,n),a||et("",!0))]}},_k=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Sn(56,n)),t.children.length&&(s.onError(Sn(57,n)),t.children.length=0),{props:[Gt(et("textContent",!0),a?Js(a,s)>0?a:ss(s.helperString(Qr),[a],n):et("",!0))]}},wk=(e,t,s)=>{const a=Qv(e,t,s);if(!a.props.length||t.tagType===1)return a;e.arg&&s.onError(Sn(59,e.arg.loc));const{tag:n}=t,i=s.isCustomElement(n);if(n==="input"||n==="textarea"||n==="select"||i){let l=eg,o=!1;if(n==="input"||i){const r=Yr(t,"type");if(r){if(r.type===7)l=_d;else if(r.value)switch(r.value.content){case"radio":l=Yv;break;case"checkbox":l=Xv;break;case"file":o=!0,s.onError(Sn(60,e.loc));break}}else nw(t)&&(l=_d)}else n==="select"&&(l=tg);o||(a.needRuntime=s.helper(l))}else s.onError(Sn(58,e.loc));return a.props=a.props.filter(l=>!(l.key.type===4&&l.key.content==="modelValue")),a},kk=Qs("passive,once,capture"),Sk=Qs("stop,prevent,self,ctrl,shift,alt,meta,exact,middle"),Ck=Qs("left,right"),og=Qs("onkeyup,onkeydown,onkeypress"),Tk=(e,t,s,a)=>{const n=[],i=[],l=[];for(let o=0;ozs(e)&&e.content.toLowerCase()==="onclick"?et(t,!0):e.type!==4?ha(["(",e,`) === "onClick" ? "${t}" : (`,e,")"]):e,Ek=(e,t,s)=>Zv(e,t,s,a=>{const{modifiers:n}=e;if(!n.length)return a;let{key:i,value:l}=a.props[0];const{keyModifiers:o,nonKeyModifiers:r,eventOptionModifiers:c}=Tk(i,n,s,e.loc);if(r.includes("right")&&(i=pf(i,"onContextmenu")),r.includes("middle")&&(i=pf(i,"onMouseup")),r.length&&(l=ss(s.helper(sg),[l,JSON.stringify(r)])),o.length&&(!zs(i)||og(i.content.toLowerCase()))&&(l=ss(s.helper(ag),[l,JSON.stringify(o)])),c.length){const d=c.map(ai).join("");i=zs(i)?et(`${i.content}${d}`,!0):ha(["(",i,`) + "${d}"`])}return{props:[Gt(i,l)]}}),Ak=(e,t,s)=>{const{exp:a,loc:n}=e;return a||s.onError(Sn(62,n)),{props:[],needRuntime:s.helper(ng)}},Rk=(e,t)=>{e.type===1&&e.tagType===0&&(e.tag==="script"||e.tag==="style")&&t.removeNode()},Ik=[bk],Ok={cloak:hk,html:xk,text:_k,model:wk,on:Ek,show:Ak};function Lk(e,t={}){return mk(e,nt({},gk,t,{nodeTransforms:[Rk,...Ik,...t.nodeTransforms||[]],directiveTransforms:nt({},Ok,t.directiveTransforms||{}),transformHoist:null}))}/** * vue v3.5.38 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT -**/const of=Object.create(null);function Ck(e,t){if(!Ye(e))if(e.nodeType)e=e.innerHTML;else return hs;const s=zb(e,t),a=of[s];if(a)return a;if(e[0]==="#"){const o=document.querySelector(e);e=o?o.innerHTML:""}const n=nt({hoistStatic:!0,onError:void 0,onWarn:hs},t);!n.isCustomElement&&typeof customElements<"u"&&(n.isCustomElement=o=>!!customElements.get(o));const{code:i}=Sk(e,n),l=new Function("Vue",i)(L_);return l._rc=!0,of[s]=l}Fh(Ck);const wr=Tn({items:[]});let Tk=1;function Zr(e,t="info",s=3e3){const a=Tk++;return wr.items.push({id:a,message:String(e),type:t}),s>0&&setTimeout(()=>Eu(a),s),a}function Eu(e){const t=wr.items.findIndex(s=>s.id===e);t>=0&&wr.items.splice(t,1)}function Se(e,t="info",s=3e3){return Zr(e,t,s)}Se.success=(e,t=3e3)=>Zr(e,"success",t);Se.error=(e,t=5e3)=>Zr(e,"error",t);Se.info=(e,t=3e3)=>Zr(e,"info",t);Se.dismiss=Eu;const Ek={setup(){return{state:wr,dismiss:Eu}},template:` +**/const ff=Object.create(null);function Nk(e,t){if(!Qe(e))if(e.nodeType)e=e.innerHTML;else return hs;const s=Jb(e,t),a=ff[s];if(a)return a;if(e[0]==="#"){const o=document.querySelector(e);e=o?o.innerHTML:""}const n=nt({hoistStatic:!0,onError:void 0,onWarn:hs},t);!n.isCustomElement&&typeof customElements<"u"&&(n.isCustomElement=o=>!!customElements.get(o));const{code:i}=Lk(e,n),l=new Function("Vue",i)(U_);return l._rc=!0,ff[s]=l}qh(Nk);const Er=Tn({items:[]});let Mk=1;function tc(e,t="info",s=3e3){const a=Mk++;return Er.items.push({id:a,message:String(e),type:t}),s>0&&setTimeout(()=>Nu(a),s),a}function Nu(e){const t=Er.items.findIndex(s=>s.id===e);t>=0&&Er.items.splice(t,1)}function ke(e,t="info",s=3e3){return tc(e,t,s)}ke.success=(e,t=3e3)=>tc(e,"success",t);ke.error=(e,t=5e3)=>tc(e,"error",t);ke.info=(e,t=3e3)=>tc(e,"info",t);ke.dismiss=Nu;const Dk={setup(){return{state:Er,dismiss:Nu}},template:`
t in e?Ob(e,t,{enumerable:!0,config
- `},Va=Tn({open:!1,title:"Confirm",message:"",confirmLabel:"Confirm",cancelLabel:"Cancel",danger:!1});let Pi=null;function os({title:e="Confirm",message:t="",confirmLabel:s="Confirm",cancelLabel:a="Cancel",danger:n=!1}={}){return Pi&&Pi(!1),Va.title=e,Va.message=t,Va.confirmLabel=s,Va.cancelLabel=a,Va.danger=n,Va.open=!0,new Promise(i=>{Pi=i})}function rf(e){Va.open=!1,Pi&&(Pi(e),Pi=null)}const Ak={setup(){function e(t){Va.open&&t.key==="Escape"&&(t.stopPropagation(),rf(!1))}return st(()=>document.addEventListener("keydown",e,!0)),xt(()=>document.removeEventListener("keydown",e,!0)),{state:Va,settle:rf}},template:` + `},qa=Tn({open:!1,title:"Confirm",message:"",confirmLabel:"Confirm",cancelLabel:"Cancel",danger:!1});let $i=null;function os({title:e="Confirm",message:t="",confirmLabel:s="Confirm",cancelLabel:a="Cancel",danger:n=!1}={}){return $i&&$i(!1),qa.title=e,qa.message=t,qa.confirmLabel=s,qa.cancelLabel=a,qa.danger=n,qa.open=!0,new Promise(i=>{$i=i})}function mf(e){qa.open=!1,$i&&($i(e),$i=null)}const Pk={setup(){function e(t){qa.open&&t.key==="Escape"&&(t.stopPropagation(),mf(!1))}return tt(()=>document.addEventListener("keydown",e,!0)),xt(()=>document.removeEventListener("keydown",e,!0)),{state:qa,settle:mf}},template:` - `};function Ou(e){if(e instanceof Date)return e;if(typeof e=="string"){const t=new Date(e);return isNaN(t.getTime())?null:t}return typeof e=="number"&&isFinite(e)?new Date(e<1e12?e*1e3:e):null}function ni(e){const t=Ou(e);return t?t.toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}):"—"}function WS(e){const t=Ou(e);return t?t.toLocaleTimeString():"—"}function gg(e){const t=Ou(e);if(!t)return"—";const s=Math.max(0,Math.floor((Date.now()-t.getTime())/1e3));return s<60?`${s}s ago`:s<3600?`${Math.floor(s/60)}m ago`:s<86400?`${Math.floor(s/3600)}h ago`:`${Math.floor(s/86400)}d ago`}function KS(e){if(e==null||!isFinite(e))return"—";const t=Math.max(0,Math.floor(Number(e)));return t<60?"less than 1 min ago":t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)} hr ago`:`${Math.floor(t/86400)} day ago`}function Ji(e){if(e==null||!isFinite(e))return"—";const t=Math.max(0,Math.round(e));if(t<60)return`${t}s`;if(t<3600){const n=Math.floor(t/60),i=t%60;return i?`${n}m ${i}s`:`${n}m`}const s=Math.floor(t/3600),a=Math.floor(t%3600/60);return a?`${s}h ${a}m`:`${s}h`}function Lu(e,t=200){const s=String(e??"");return s.length>t?s.slice(0,t)+"…":s}function bg(e,t=5e3){const s=String(e??"");return s.length>t?s.slice(0,t)+` -... (truncated)`:s}function Tf(e){return String(e??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function Nu(e){return e==null||!isFinite(e)?"—":Number(e).toLocaleString()}function yg(e){return e==null||!isFinite(e)?"—":e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}const xg=Symbol("agent-detail-cancelled"),JS=15e3;function ZS(e,{timeoutMs:t,timeoutLabel:s,scheduleTimeout:a,cancelTimeout:n}){const i=typeof AbortController=="function"?new AbortController:null;let l=null,o=!1,r,c;const d=new Promise((m,h)=>{r=m,c=h});function u(m,h){o||(o=!0,l!==null&&n(l),l=null,(m?r:c)(h))}let f;try{f=e(i==null?void 0:i.signal)}catch(m){u(!1,m)}return o||Promise.resolve(f).then(m=>u(!0,m),m=>u(!1,m)),!o&&Number.isFinite(t)&&t>0&&(l=a(()=>{const m=Math.max(1,Math.round(t/1e3));u(!1,new Error(`${s} request timed out after ${m}s`)),i==null||i.abort()},t)),{promise:d,cancel(){u(!0,xg),i==null||i.abort()}}}function _g({state:e,requestDetail:t,timeoutMs:s=JS,detailLabel:a="Agent detail",scheduleTimeout:n=globalThis.setTimeout.bind(globalThis),cancelTimeout:i=globalThis.clearTimeout.bind(globalThis)}){if(!e||typeof e!="object")throw new TypeError("agent detail state is required");if(typeof t!="function")throw new TypeError("requestDetail must be a function");let l=null;function o(){const f=l;l=null,f==null||f.cancel()}function r(f,{initial:m,coalesce:h}){if(!f)return Promise.resolve();if(h&&l&&l.agentId===f&&e.detailId===f)return l.promise;o();const b={agentId:f,cancel:null,promise:null};l=b,m?(e.detail=null,e.detailError=null,e.detailLoading=!0):e.detail===null&&e.detailError===null&&(e.detailLoading=!0);const I=ZS(k=>t(f,{signal:k}),{timeoutMs:s,timeoutLabel:a,scheduleTimeout:n,cancelTimeout:i});return b.cancel=I.cancel,b.promise=(async()=>{let k=null,y=null;try{k=await I.promise}catch(g){y=g}k!==xg&&(l!==b||e.detailId!==f||(l=null,!y&&(k===null||typeof k!="object")&&(y=new Error(`${a} response was empty or invalid`)),y?e.detail===null&&(e.detailError=(y==null?void 0:y.message)||`Failed to load ${a.toLowerCase()}`):(e.detail=k,e.detailError=null),e.detailLoading=!1))})(),b.promise}function c(f){return e.detailId=f,r(f,{initial:!0,coalesce:!1})}function d(){const f=e.detailId;return f?r(f,{initial:!1,coalesce:!0}):Promise.resolve()}function u(){o(),e.detailId=null,e.detail=null,e.detailError=null,e.detailLoading=!1}return{open:c,refresh:d,close:u,hasInFlight:()=>l!==null}}function YS({isEnabled:e,refreshList:t,hasOpenDetail:s,refreshDetail:a,intervalMs:n=5e3,scheduleInterval:i=globalThis.setInterval.bind(globalThis),cancelInterval:l=globalThis.clearInterval.bind(globalThis)}){let o=null;function r(){e()&&(t(),s()&&a())}function c(){o!==null&&(l(o),o=null)}function d(){c(),e()&&(o=i(r,n))}function u(){e()?d():c()}return{start:d,stop:c,sync:u,isRunning:()=>o!==null}}const QS={components:{ToolOutput:ec},template:` + `};function $u(e){if(e instanceof Date)return e;if(typeof e=="string"){const t=new Date(e);return isNaN(t.getTime())?null:t}return typeof e=="number"&&isFinite(e)?new Date(e<1e12?e*1e3:e):null}function ii(e){const t=$u(e);return t?t.toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}):"—"}function t1(e){const t=$u(e);return t?t.toLocaleTimeString():"—"}function kg(e){const t=$u(e);if(!t)return"—";const s=Math.max(0,Math.floor((Date.now()-t.getTime())/1e3));return s<60?`${s}s ago`:s<3600?`${Math.floor(s/60)}m ago`:s<86400?`${Math.floor(s/3600)}h ago`:`${Math.floor(s/86400)}d ago`}function s1(e){if(e==null||!isFinite(e))return"—";const t=Math.max(0,Math.floor(Number(e)));return t<60?"less than 1 min ago":t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)} hr ago`:`${Math.floor(t/86400)} day ago`}function Zi(e){if(e==null||!isFinite(e))return"—";const t=Math.max(0,Math.round(e));if(t<60)return`${t}s`;if(t<3600){const n=Math.floor(t/60),i=t%60;return i?`${n}m ${i}s`:`${n}m`}const s=Math.floor(t/3600),a=Math.floor(t%3600/60);return a?`${s}h ${a}m`:`${s}h`}function Fu(e,t=200){const s=String(e??"");return s.length>t?s.slice(0,t)+"…":s}function Sg(e,t=5e3){const s=String(e??"");return s.length>t?s.slice(0,t)+` +... (truncated)`:s}function Lf(e){return String(e??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function Bu(e){return e==null||!isFinite(e)?"—":Number(e).toLocaleString()}function Cg(e){return e==null||!isFinite(e)?"—":e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}const Tg=Symbol("agent-detail-cancelled"),a1=15e3;function n1(e,{timeoutMs:t,timeoutLabel:s,scheduleTimeout:a,cancelTimeout:n}){const i=typeof AbortController=="function"?new AbortController:null;let l=null,o=!1,r,c;const d=new Promise((m,h)=>{r=m,c=h});function u(m,h){o||(o=!0,l!==null&&n(l),l=null,(m?r:c)(h))}let f;try{f=e(i==null?void 0:i.signal)}catch(m){u(!1,m)}return o||Promise.resolve(f).then(m=>u(!0,m),m=>u(!1,m)),!o&&Number.isFinite(t)&&t>0&&(l=a(()=>{const m=Math.max(1,Math.round(t/1e3));u(!1,new Error(`${s} request timed out after ${m}s`)),i==null||i.abort()},t)),{promise:d,cancel(){u(!0,Tg),i==null||i.abort()}}}function Eg({state:e,requestDetail:t,timeoutMs:s=a1,detailLabel:a="Agent detail",scheduleTimeout:n=globalThis.setTimeout.bind(globalThis),cancelTimeout:i=globalThis.clearTimeout.bind(globalThis)}){if(!e||typeof e!="object")throw new TypeError("agent detail state is required");if(typeof t!="function")throw new TypeError("requestDetail must be a function");let l=null;function o(){const f=l;l=null,f==null||f.cancel()}function r(f,{initial:m,coalesce:h}){if(!f)return Promise.resolve();if(h&&l&&l.agentId===f&&e.detailId===f)return l.promise;o();const g={agentId:f,cancel:null,promise:null};l=g,m?(e.detail=null,e.detailError=null,e.detailLoading=!0):e.detail===null&&e.detailError===null&&(e.detailLoading=!0);const A=n1(w=>t(f,{signal:w}),{timeoutMs:s,timeoutLabel:a,scheduleTimeout:n,cancelTimeout:i});return g.cancel=A.cancel,g.promise=(async()=>{let w=null,y=null;try{w=await A.promise}catch(b){y=b}w!==Tg&&(l!==g||e.detailId!==f||(l=null,!y&&(w===null||typeof w!="object")&&(y=new Error(`${a} response was empty or invalid`)),y?e.detail===null&&(e.detailError=(y==null?void 0:y.message)||`Failed to load ${a.toLowerCase()}`):(e.detail=w,e.detailError=null),e.detailLoading=!1))})(),g.promise}function c(f){return e.detailId=f,r(f,{initial:!0,coalesce:!1})}function d(){const f=e.detailId;return f?r(f,{initial:!1,coalesce:!0}):Promise.resolve()}function u(){o(),e.detailId=null,e.detail=null,e.detailError=null,e.detailLoading=!1}return{open:c,refresh:d,close:u,hasInFlight:()=>l!==null}}function i1({isEnabled:e,refreshList:t,hasOpenDetail:s,refreshDetail:a,intervalMs:n=5e3,scheduleInterval:i=globalThis.setInterval.bind(globalThis),cancelInterval:l=globalThis.clearInterval.bind(globalThis)}){let o=null;function r(){e()&&(t(),s()&&a())}function c(){o!==null&&(l(o),o=null)}function d(){c(),e()&&(o=i(r,n))}function u(){e()?d():c()}return{start:d,stop:c,sync:u,isRunning:()=>o!==null}}const l1={components:{ToolOutput:ic},template:`

Agents

@@ -521,7 +521,7 @@ ${u.text}`:u.text).join(`
-
`,setup(){const e=p([]),t=p(!0),s=p(null),a=p(null),n=p(!0),i=p("all");let l=!1;const o=H(()=>e.value.filter(D=>D.status==="running").length),r=H(()=>e.value.filter(D=>D.status==="completed").length),c=H(()=>e.value.filter(D=>["failed","timeout","killed"].includes(D.status)).length),d=H(()=>[{value:"all",label:"All",count:e.value.length},{value:"running",label:"Running",count:o.value},{value:"completed",label:"Completed",count:r.value},{value:"failed",label:"Failed",count:c.value}]),u=H(()=>i.value==="all"?e.value:i.value==="failed"?e.value.filter(D=>["failed","timeout","killed"].includes(D.status)):e.value.filter(D=>D.status===i.value));function f(D){const re=Number(D.max_iterations)||0;return re<=0?0:Math.min(100,Math.round(D.iteration_count/re*100))}function m(D){return(Number(D.max_iterations)||0)>0}function h(D,re){return D?D==="N/A"?"N/A":re==="current_inheritance"?`inherit (currently ${D})`:D:"unknown"}function b(D){return h(D.display_model,D.display_model_source||D.display_source)}function I(D){return h(D.display_reasoning_effort,D.display_reasoning_effort_source||D.display_source)}function k(D){return{last_execution:"last executed",current_inheritance:"inherited from current config — not yet executed",spawn_override_pending:"requested at spawn — not yet executed",unknown:"no execution data"}[D]||""}const y=p(null),g=p(null),x=p(!1),S=p(null),_=p(""),R=_g({state:{get detail(){return y.value},set detail(D){y.value=D},get detailId(){return g.value},set detailId(D){g.value=D},get detailLoading(){return x.value},set detailLoading(D){x.value=D},get detailError(){return S.value},set detailError(D){S.value=D}},requestDetail:(D,{signal:re})=>z.get(`/api/agents/${encodeURIComponent(D)}`,{signal:re})});async function w(D){_.value="",await R.open(D.id)}function O(){R.close(),_.value=""}async function B(){await R.refresh()}async function T(D,re){try{await navigator.clipboard.writeText(re||""),_.value=D,setTimeout(()=>{_.value===D&&(_.value="")},1500)}catch{Se.error("Copy failed")}}async function $(D=!1){D=D===!0,D||(t.value=!0);try{const re=await z.get("/api/agents");e.value=Array.isArray(re)?re:[],s.value=null}catch(re){D||(s.value=re.message)}D||(t.value=!1)}async function Q(D){const re=e.value.find(J=>J.id===D);if(await os({title:"Kill agent",message:`Kill agent "${(re==null?void 0:re.label)||D}"? Its current work will be lost.`,confirmLabel:"Kill",danger:!0})){a.value=D;try{await z.del(`/api/agents/${encodeURIComponent(D)}`),Se.success("Agent killed"),await $()}catch(J){Se.error(J.message||"Failed to kill agent")}a.value=null}}const W=YS({isEnabled:()=>n.value&&l,refreshList:()=>$(!0),hasOpenDetail:()=>!!g.value,refreshDetail:B});function N(){W.start()}function L(){W.stop()}return Wt(n,()=>W.sync()),st(()=>{l=!0,$(),N()}),rs(()=>{l=!0,$(!0),N()}),Kt(()=>{l=!1,L()}),xt(()=>{l=!1,L(),R.close()}),{agents:e,loading:t,error:s,killing:a,autoRefresh:n,statusFilter:i,runningCount:o,completedCount:r,failedCount:c,statusFilters:d,filteredAgents:u,formatTs:ni,formatDuration:Ji,progressPercent:f,hasProgress:m,displayModelText:b,displayEffortText:I,displaySourceLabel:k,detail:y,detailId:g,detailLoading:x,detailError:S,copied:_,openDetail:w,closeDetail:O,copyText:T,fetchAgents:$,killAgent:Q,startAutoRefresh:N,stopAutoRefresh:L}}},XS={template:` +
`,setup(){const e=p([]),t=p(!0),s=p(null),a=p(null),n=p(!0),i=p("all");let l=!1;const o=U(()=>e.value.filter($=>$.status==="running").length),r=U(()=>e.value.filter($=>$.status==="completed").length),c=U(()=>e.value.filter($=>["failed","timeout","killed"].includes($.status)).length),d=U(()=>[{value:"all",label:"All",count:e.value.length},{value:"running",label:"Running",count:o.value},{value:"completed",label:"Completed",count:r.value},{value:"failed",label:"Failed",count:c.value}]),u=U(()=>i.value==="all"?e.value:i.value==="failed"?e.value.filter($=>["failed","timeout","killed"].includes($.status)):e.value.filter($=>$.status===i.value));function f($){const ce=Number($.max_iterations)||0;return ce<=0?0:Math.min(100,Math.round($.iteration_count/ce*100))}function m($){return(Number($.max_iterations)||0)>0}function h($,ce){return $?$==="N/A"?"N/A":ce==="current_inheritance"?`inherit (currently ${$})`:$:"unknown"}function g($){return h($.display_model,$.display_model_source||$.display_source)}function A($){return h($.display_reasoning_effort,$.display_reasoning_effort_source||$.display_source)}function w($){return{last_execution:"last executed",current_inheritance:"inherited from current config — not yet executed",spawn_override_pending:"requested at spawn — not yet executed",unknown:"no execution data"}[$]||""}const y=p(null),b=p(null),x=p(!1),S=p(null),_=p(""),L=Eg({state:{get detail(){return y.value},set detail($){y.value=$},get detailId(){return b.value},set detailId($){b.value=$},get detailLoading(){return x.value},set detailLoading($){x.value=$},get detailError(){return S.value},set detailError($){S.value=$}},requestDetail:($,{signal:ce})=>z.get(`/api/agents/${encodeURIComponent($)}`,{signal:ce})});async function E($){_.value="",await L.open($.id)}function P(){L.close(),_.value=""}async function R(){await L.refresh()}async function I($,ce){try{await navigator.clipboard.writeText(ce||""),_.value=$,setTimeout(()=>{_.value===$&&(_.value="")},1500)}catch{ke.error("Copy failed")}}async function H($=!1){$=$===!0,$||(t.value=!0);try{const ce=await z.get("/api/agents");e.value=Array.isArray(ce)?ce:[],s.value=null}catch(ce){$||(s.value=ce.message)}$||(t.value=!1)}async function X($){const ce=e.value.find(Z=>Z.id===$);if(await os({title:"Kill agent",message:`Kill agent "${(ce==null?void 0:ce.label)||$}"? Its current work will be lost.`,confirmLabel:"Kill",danger:!0})){a.value=$;try{await z.del(`/api/agents/${encodeURIComponent($)}`),ke.success("Agent killed"),await H()}catch(Z){ke.error(Z.message||"Failed to kill agent")}a.value=null}}const Q=i1({isEnabled:()=>n.value&&l,refreshList:()=>H(!0),hasOpenDetail:()=>!!b.value,refreshDetail:R});function N(){Q.start()}function O(){Q.stop()}return jt(n,()=>Q.sync()),tt(()=>{l=!0,H(),N()}),rs(()=>{l=!0,H(!0),N()}),Kt(()=>{l=!1,O()}),xt(()=>{l=!1,O(),L.close()}),{agents:e,loading:t,error:s,killing:a,autoRefresh:n,statusFilter:i,runningCount:o,completedCount:r,failedCount:c,statusFilters:d,filteredAgents:u,formatTs:ii,formatDuration:Zi,progressPercent:f,hasProgress:m,displayModelText:g,displayEffortText:A,displaySourceLabel:w,detail:y,detailId:b,detailLoading:x,detailError:S,copied:_,openDetail:E,closeDetail:P,copyText:I,fetchAgents:H,killAgent:X,startAutoRefresh:N,stopAutoRefresh:O}}},o1={template:`

Autonomous Loops

@@ -787,7 +787,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(!1),n=p({goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""}),i=p(!1),l=p(null),o=p(null),r=p(null),c=p(null),d=p(null),u=p(!1),f=p(null),m=p("");let h=!1;const I=_g({state:{get detail(){return c.value},set detail(L){c.value=L},get detailId(){return d.value},set detailId(L){d.value=L},get detailLoading(){return u.value},set detailLoading(L){u.value=L},get detailError(){return f.value},set detailError(L){f.value=L}},detailLabel:"Loop detail",requestDetail:(L,{signal:D})=>z.get(`/api/loops/${encodeURIComponent(L)}?limit=100`,{signal:D})});async function k(L){m.value="",await I.open(L.id)}function y(){I.close(),m.value=""}async function g(L,D){try{await navigator.clipboard.writeText(D||""),m.value=L,setTimeout(()=>{m.value===L&&(m.value="")},1500)}catch{Se.error("Copy failed")}}const x=H(()=>e.value.reduce((L,D)=>L+(D.iteration_count||0),0)),S=H(()=>e.value.filter(L=>L.status==="running").length);function _(L){return L==="running"?"loop-status-running":L==="error"?"loop-status-error":"loop-status-stopped"}function A(L){return L==="running"?"badge-success":L==="error"?"badge-danger":L==="completed"?"badge-info":"badge-warning"}function R(L){return L==="act"?"badge-warning":L==="silent"?"badge-info":"badge-success"}async function w(L=!1){L=L===!0,L||(t.value=!0);try{const D=await z.get("/api/loops");e.value=Array.isArray(D)?D:[],s.value=null}catch(D){L||(s.value=D.message)}L||(t.value=!1)}async function O(){l.value=null;const L=n.value;if(!L.goal.trim()){l.value="Goal is required";return}if(!L.channel_id.trim()){l.value="Channel ID is required";return}const D={goal:L.goal.trim(),channel_id:L.channel_id.trim(),interval_seconds:L.interval_seconds||60,mode:L.mode,max_iterations:L.max_iterations||50};L.stop_condition.trim()&&(D.stop_condition=L.stop_condition.trim()),i.value=!0;try{const re=await z.post("/api/loops",D);Se.success(`Loop started: ${re.loop_id}`),n.value={goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""},a.value=!1,await w()}catch(re){l.value=re.message}i.value=!1}async function B(L){if(await os({title:"Stop loop",message:`Stop loop ${L}? The current iteration will finish before stopping.`,confirmLabel:"Stop Loop",danger:!0})){o.value=L;try{await z.del(`/api/loops/${encodeURIComponent(L)}`),Se.success("Loop stopped"),await w()}catch(re){Se.error(re.message||"Failed to stop loop")}o.value=null}}async function T(L){r.value=L;try{await z.post(`/api/loops/${encodeURIComponent(L)}/restart`),Se.success("Loop restarted"),await w()}catch(D){Se.error(D.message||"Failed to restart loop")}r.value=null}function $(L){h&&L.payload&&(L.payload.loop_id||L.payload.type==="loop")&&(w(!0),d.value&&I.refresh())}let Q=null;function W(){Q!==null&&clearInterval(Q),Q=null}function N(){W(),h&&(Q=setInterval(()=>{w(!0),d.value&&I.refresh()},5e3))}return st(()=>{h=!0,w(),ut.subscribe("events",$),N()}),rs(()=>{h=!0,w(!0),N()}),Kt(()=>{h=!1,W()}),xt(()=>{h=!1,ut.unsubscribe("events",$),W(),I.close()}),{loops:e,loading:t,error:s,showCreate:a,form:n,creating:i,createError:l,stoppingId:o,restartingId:r,detail:c,detailId:d,detailLoading:u,detailError:f,copied:m,totalIterations:x,runningCount:S,statusDotClass:_,statusBadge:A,modeBadge:R,formatAge:gg,formatDuration:Ji,formatTs:ni,formatTokens:yg,openDetail:k,closeDetail:y,copyText:g,fetchLoops:w,doCreate:O,doStop:B,doRestart:T}}},e1={template:` + `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(!1),n=p({goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""}),i=p(!1),l=p(null),o=p(null),r=p(null),c=p(null),d=p(null),u=p(!1),f=p(null),m=p("");let h=!1;const A=Eg({state:{get detail(){return c.value},set detail(O){c.value=O},get detailId(){return d.value},set detailId(O){d.value=O},get detailLoading(){return u.value},set detailLoading(O){u.value=O},get detailError(){return f.value},set detailError(O){f.value=O}},detailLabel:"Loop detail",requestDetail:(O,{signal:$})=>z.get(`/api/loops/${encodeURIComponent(O)}?limit=100`,{signal:$})});async function w(O){m.value="",await A.open(O.id)}function y(){A.close(),m.value=""}async function b(O,$){try{await navigator.clipboard.writeText($||""),m.value=O,setTimeout(()=>{m.value===O&&(m.value="")},1500)}catch{ke.error("Copy failed")}}const x=U(()=>e.value.reduce((O,$)=>O+($.iteration_count||0),0)),S=U(()=>e.value.filter(O=>O.status==="running").length);function _(O){return O==="running"?"loop-status-running":O==="error"?"loop-status-error":"loop-status-stopped"}function T(O){return O==="running"?"badge-success":O==="error"?"badge-danger":O==="completed"?"badge-info":"badge-warning"}function L(O){return O==="act"?"badge-warning":O==="silent"?"badge-info":"badge-success"}async function E(O=!1){O=O===!0,O||(t.value=!0);try{const $=await z.get("/api/loops");e.value=Array.isArray($)?$:[],s.value=null}catch($){O||(s.value=$.message)}O||(t.value=!1)}async function P(){l.value=null;const O=n.value;if(!O.goal.trim()){l.value="Goal is required";return}if(!O.channel_id.trim()){l.value="Channel ID is required";return}const $={goal:O.goal.trim(),channel_id:O.channel_id.trim(),interval_seconds:O.interval_seconds||60,mode:O.mode,max_iterations:O.max_iterations||50};O.stop_condition.trim()&&($.stop_condition=O.stop_condition.trim()),i.value=!0;try{const ce=await z.post("/api/loops",$);ke.success(`Loop started: ${ce.loop_id}`),n.value={goal:"",interval_seconds:60,mode:"notify",max_iterations:50,stop_condition:"",channel_id:""},a.value=!1,await E()}catch(ce){l.value=ce.message}i.value=!1}async function R(O){if(await os({title:"Stop loop",message:`Stop loop ${O}? The current iteration will finish before stopping.`,confirmLabel:"Stop Loop",danger:!0})){o.value=O;try{await z.del(`/api/loops/${encodeURIComponent(O)}`),ke.success("Loop stopped"),await E()}catch(ce){ke.error(ce.message||"Failed to stop loop")}o.value=null}}async function I(O){r.value=O;try{await z.post(`/api/loops/${encodeURIComponent(O)}/restart`),ke.success("Loop restarted"),await E()}catch($){ke.error($.message||"Failed to restart loop")}r.value=null}function H(O){h&&O.payload&&(O.payload.loop_id||O.payload.type==="loop")&&(E(!0),d.value&&A.refresh())}let X=null;function Q(){X!==null&&clearInterval(X),X=null}function N(){Q(),h&&(X=setInterval(()=>{E(!0),d.value&&A.refresh()},5e3))}return tt(()=>{h=!0,E(),ut.subscribe("events",H),N()}),rs(()=>{h=!0,E(!0),N()}),Kt(()=>{h=!1,Q()}),xt(()=>{h=!1,ut.unsubscribe("events",H),Q(),A.close()}),{loops:e,loading:t,error:s,showCreate:a,form:n,creating:i,createError:l,stoppingId:o,restartingId:r,detail:c,detailId:d,detailLoading:u,detailError:f,copied:m,totalIterations:x,runningCount:S,statusDotClass:_,statusBadge:T,modeBadge:L,formatAge:kg,formatDuration:Zi,formatTs:ii,formatTokens:Cg,openDetail:w,closeDetail:y,copyText:b,fetchLoops:E,doCreate:P,doStop:R,doRestart:I}}},r1={template:`
@@ -880,7 +880,7 @@ ${u.text}`:u.text).join(`
-
`,setup(){const e=p([]),t=p(!0),s=p(null),a=p(!0);let n=null;const i=p(null),l=H(()=>e.value.filter(y=>y.status==="running").length),o=H(()=>e.value.filter(y=>y.status!=="running").length);function r(y){return y==="running"?"loop-status-running":y==="failed"||y==="error"?"loop-status-error":"loop-status-stopped"}function c(y){return y==="running"?"badge-success":y==="completed"||y==="exited"?"badge-info":y==="killed"||y==="error"||y==="failed"?"badge-danger":"badge-warning"}async function d(y=!1){y=y===!0,y||(t.value=!0);try{e.value=await z.get("/api/processes"),s.value=null}catch(g){y||(s.value=g.message)}y||(t.value=!1)}function u(){f(),a.value&&(n=setInterval(()=>{t.value||d(!0)},5e3))}function f(){n&&(clearInterval(n),n=null)}Wt(a,y=>{y?u():f()});async function m(y){if(await os({title:"Kill process",message:`Kill process ${y}?`,confirmLabel:"Kill",danger:!0})){i.value=y;try{await z.del(`/api/processes/${y}`),Se.success(`Process ${y} killed`),await d()}catch(x){Se.error(x.message||"Failed to kill process")}i.value=null}}function h(y){y.payload&&(y.payload.pid||y.payload.type==="process")&&d(!0)}let b=!1;function I(){b||(b=!0,d(),ut.subscribe("events",h),u())}function k(){b&&(b=!1,ut.unsubscribe("events",h),f())}return st(I),rs(I),Kt(k),xt(k),{processes:e,loading:t,error:s,autoRefresh:a,killingPid:i,runningCount:l,completedCount:o,procStatusDot:r,statusBadge:c,formatDuration:Ji,fetchProcesses:d,doKill:m}}},t1=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;function Ef(e,t){return t==="cron"&&String(e.cron||"").trim()?e.run_at="":t==="run_at"&&String(e.run_at||"").trim()&&(e.cron=""),e}function s1(e,t=!1){const s=n=>String(n).padStart(2,"0"),a=`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())}T${s(e.getHours())}:${s(e.getMinutes())}`;return t?`${a}:${s(e.getSeconds())}`:a}function a1(e){const t=-e.getTimezoneOffset(),s=t>=0?"+":"-",a=Math.abs(t),n=Math.floor(a/60),i=a%60;return`UTC${s}${n}${i?`:${String(i).padStart(2,"0")}`:""}`}function Af(e){const t=String(e||"").trim();if(!t)return{state:"empty"};const s=t1.exec(t);if(!s)return{state:"invalid",typed:t};const[,a,n,i,l,o]=s.slice(0,6).map(Number),r=s[6]===void 0?0:Number(s[6]);if(r>59)return{state:"invalid",typed:t};const c=s[6]!==void 0,d=c?t.slice(0,19):t.slice(0,16),u=Date.UTC(a,n-1,i,l,o,r),f=new Date(u-864e5).getTimezoneOffset(),m=new Date(u+864e5).getTimezoneOffset(),h=[];for(const I of new Set([f,m])){const k=new Date(u+I*6e4);s1(k,c)===d&&(h.some(y=>y.getTime()===k.getTime())||h.push(k))}if(h.sort((I,k)=>I.getTime()-k.getTime()),h.length===0)return{state:"nonexistent",typed:t};if(h.length>1)return{state:"ambiguous",typed:t,options:h.map(I=>({instant:I,offset:a1(I),iso:I.toISOString()}))};const b=h[0];return{state:"ok",typed:t,instant:b,iso:b.toISOString()}}const n1=5e3;function Ko(e){const t=(e==null?void 0:e.available)===!0,s=typeof(e==null?void 0:e.reason)=="string"?e.reason:"provider_error";return{available:t,reason:s,epoch:Number.isInteger(e==null?void 0:e.epoch)?e.epoch:null}}function Oo(e){if(e.available)return"";switch(e.reason){case"unavailable":return"Scheduling is not configured.";case"connecting":return"Scheduling is connecting.";case"disconnected":return"Scheduling is disconnected.";case"provider_error":return"Scheduling status provider failed.";default:return"Scheduling is unavailable."}}function Rf(e){var t;return(e==null?void 0:e.status)!==503||!((t=e==null?void 0:e.data)!=null&&t.connection)?null:Ko(e.data.connection)}function i1(e){return e!=="webhook"}function If(e,t){return!i1(t)||(e==null?void 0:e.available)===!0}function l1(e,t=null){if((e==null?void 0:e.state)==="ok")return e.instant.toISOString();if((e==null?void 0:e.state)==="ambiguous"){if(t==null||t==="")throw new Error("Choose which occurrence of this repeated local time to use.");const s=e.options[Number(t)];if(!(s!=null&&s.instant))throw new Error("Choose a valid occurrence of this local time.");return s.instant.toISOString()}throw(e==null?void 0:e.state)==="nonexistent"?new Error("That local time does not exist because clocks skip it. Choose another time."):new Error("Enter a valid new one-time run time.")}const o1={template:` + `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(!0);let n=null;const i=p(null),l=U(()=>e.value.filter(y=>y.status==="running").length),o=U(()=>e.value.filter(y=>y.status!=="running").length);function r(y){return y==="running"?"loop-status-running":y==="failed"||y==="error"?"loop-status-error":"loop-status-stopped"}function c(y){return y==="running"?"badge-success":y==="completed"||y==="exited"?"badge-info":y==="killed"||y==="error"||y==="failed"?"badge-danger":"badge-warning"}async function d(y=!1){y=y===!0,y||(t.value=!0);try{e.value=await z.get("/api/processes"),s.value=null}catch(b){y||(s.value=b.message)}y||(t.value=!1)}function u(){f(),a.value&&(n=setInterval(()=>{t.value||d(!0)},5e3))}function f(){n&&(clearInterval(n),n=null)}jt(a,y=>{y?u():f()});async function m(y){if(await os({title:"Kill process",message:`Kill process ${y}?`,confirmLabel:"Kill",danger:!0})){i.value=y;try{await z.del(`/api/processes/${y}`),ke.success(`Process ${y} killed`),await d()}catch(x){ke.error(x.message||"Failed to kill process")}i.value=null}}function h(y){y.payload&&(y.payload.pid||y.payload.type==="process")&&d(!0)}let g=!1;function A(){g||(g=!0,d(),ut.subscribe("events",h),u())}function w(){g&&(g=!1,ut.unsubscribe("events",h),f())}return tt(A),rs(A),Kt(w),xt(w),{processes:e,loading:t,error:s,autoRefresh:a,killingPid:i,runningCount:l,completedCount:o,procStatusDot:r,statusBadge:c,formatDuration:Zi,fetchProcesses:d,doKill:m}}},c1=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;function Nf(e,t){return t==="cron"&&String(e.cron||"").trim()?e.run_at="":t==="run_at"&&String(e.run_at||"").trim()&&(e.cron=""),e}function d1(e,t=!1){const s=n=>String(n).padStart(2,"0"),a=`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())}T${s(e.getHours())}:${s(e.getMinutes())}`;return t?`${a}:${s(e.getSeconds())}`:a}function u1(e){const t=-e.getTimezoneOffset(),s=t>=0?"+":"-",a=Math.abs(t),n=Math.floor(a/60),i=a%60;return`UTC${s}${n}${i?`:${String(i).padStart(2,"0")}`:""}`}function Mf(e){const t=String(e||"").trim();if(!t)return{state:"empty"};const s=c1.exec(t);if(!s)return{state:"invalid",typed:t};const[,a,n,i,l,o]=s.slice(0,6).map(Number),r=s[6]===void 0?0:Number(s[6]);if(r>59)return{state:"invalid",typed:t};const c=s[6]!==void 0,d=c?t.slice(0,19):t.slice(0,16),u=Date.UTC(a,n-1,i,l,o,r),f=new Date(u-864e5).getTimezoneOffset(),m=new Date(u+864e5).getTimezoneOffset(),h=[];for(const A of new Set([f,m])){const w=new Date(u+A*6e4);d1(w,c)===d&&(h.some(y=>y.getTime()===w.getTime())||h.push(w))}if(h.sort((A,w)=>A.getTime()-w.getTime()),h.length===0)return{state:"nonexistent",typed:t};if(h.length>1)return{state:"ambiguous",typed:t,options:h.map(A=>({instant:A,offset:u1(A),iso:A.toISOString()}))};const g=h[0];return{state:"ok",typed:t,instant:g,iso:g.toISOString()}}const p1=5e3;function Xo(e){const t=(e==null?void 0:e.available)===!0,s=typeof(e==null?void 0:e.reason)=="string"?e.reason:"provider_error";return{available:t,reason:s,epoch:Number.isInteger(e==null?void 0:e.epoch)?e.epoch:null}}function Lo(e){if(e.available)return"";switch(e.reason){case"unavailable":return"Scheduling is not configured.";case"connecting":return"Scheduling is connecting.";case"disconnected":return"Scheduling is disconnected.";case"provider_error":return"Scheduling status provider failed.";default:return"Scheduling is unavailable."}}function Df(e){var t;return(e==null?void 0:e.status)!==503||!((t=e==null?void 0:e.data)!=null&&t.connection)?null:Xo(e.data.connection)}function f1(e){return e!=="webhook"}function Pf(e,t){return!f1(t)||(e==null?void 0:e.available)===!0}function m1(e,t=null){if((e==null?void 0:e.state)==="ok")return e.instant.toISOString();if((e==null?void 0:e.state)==="ambiguous"){if(t==null||t==="")throw new Error("Choose which occurrence of this repeated local time to use.");const s=e.options[Number(t)];if(!(s!=null&&s.instant))throw new Error("Choose a valid occurrence of this local time.");return s.instant.toISOString()}throw(e==null?void 0:e.state)==="nonexistent"?new Error("That local time does not exist because clocks skip it. Choose another time."):new Error("Enter a valid new one-time run time.")}const h1={template:`
@@ -1298,7 +1298,22 @@ ${u.text}`:u.text).join(`
-
`,setup(){const e=p([]),t=p(!0),s=p(null),a=p(Ko(null)),n=H(()=>a.value.available),i=H(()=>Oo(a.value));let l=null;const o=p(!1),r=p({description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""}),c=p(!1),d=p(null),u=H(()=>If(a.value,r.value.action));function f(U){return If(a.value,U)}const m=p(null),h=H(()=>Af(r.value.run_at));Wt(()=>r.value.run_at,()=>{m.value=null});const b=H(()=>{var ue;const U=h.value;return U.state==="ok"?U.instant:U.state==="ambiguous"&&m.value!==null&&((ue=U.options[m.value])==null?void 0:ue.instant)||null}),I=H(()=>{const U=b.value;return U?`${U.toLocaleString()} local — ${U.toISOString()} UTC`:""}),k=p(null),y=p(!1),g=[{label:"Every hour",expr:"0 * * * *"},{label:"Every 6h",expr:"0 */6 * * *"},{label:"Daily 9am",expr:"0 9 * * *"},{label:"Weekly Mon",expr:"0 9 * * 1"},{label:"Every 30m",expr:"*/30 * * * *"}],x=p(null),S=p(null),_=p(null),A=p(null),R=p(null),w=p(null),O=p({}),B=p({}),T=p(null),$=p([]),Q=p(!1),W=p("");let N=0;const L=H(()=>e.value.filter(U=>U.cron&&!U.one_time).length),D=H(()=>e.value.filter(U=>U.one_time).length),re=H(()=>e.value.filter(U=>U.trigger).length),ce=H(()=>e.value.filter(U=>U.paused).length),J=H(()=>e.value.filter(U=>U.consecutive_failures>0).length);function K(U){if(!U)return"-";const ue=Date.now(),he=(new Date(U).getTime()-ue)/1e3;if(he<0)return"overdue";if(he<60)return"in < 1 min";if(he<3600)return`in ${Math.floor(he/60)} min`;if(he<86400){const Ve=Math.floor(he/3600),$e=Math.floor(he%3600/60);return $e>0?`in ${Ve}h ${$e}m`:`in ${Ve}h`}const Fe=Math.floor(he/86400);return`in ${Fe} day${Fe!==1?"s":""}`}function se(U){return U==null?"-":U<1e3?`${U}ms`:U<6e4?`${(U/1e3).toFixed(1)}s`:Ji(U/1e3)}function j(U=r.value.cron){r.value.cron=U,Ef(r.value,"cron"),k.value=null}function ye(U=r.value.run_at){r.value.run_at=U,Ef(r.value,"run_at"),k.value=null}function Oe(U){return Af(O.value[U.id]||"")}function ne(U,ue){O.value[U]=ue,delete B.value[U]}function ie(U,ue){B.value[U]=ue===""?null:Number(ue)}async function ve(U){let ue;try{ue=l1(Oe(U),B.value[U.id])}catch(oe){Se.error(oe.message);return}w.value=U.id;try{const oe=await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{run_at:ue});oe!=null&&oe.inert_reason||oe!=null&&oe.paused?Se.error(`Schedule remains paused and inert: ${oe.inert_reason||"the server kept it paused"}`):(Se.success("Schedule re-armed for the new run time"),delete O.value[U.id],delete B.value[U.id]),await ge()}catch(oe){E(oe),Se.error(oe.message||"Failed to re-arm schedule")}finally{w.value=null}}async function be(){const U=r.value.cron.trim();if(U){y.value=!0;try{k.value=await z.post("/api/schedules/validate-cron",{expression:U})}catch(ue){k.value={valid:!1,error:ue.message}}y.value=!1}}async function ge(){t.value=!0,s.value=null;try{e.value=await z.get("/api/schedules")}catch(U){s.value=U.message}t.value=!1}async function Ne(){try{a.value=Ko(await z.get("/api/schedules/status"))}catch(U){a.value=Rf(U)||Ko(null)}}function E(U){const ue=Rf(U);ue&&(a.value=ue)}async function P(U){if(T.value===U){T.value=null,$.value=[];return}T.value=U,Q.value=!0,$.value=[];const ue=++N;try{const oe=await z.get(`/api/schedules/${encodeURIComponent(U)}/history?limit=10`);if(ue!==N||T.value!==U)return;$.value=oe,W.value=""}catch(oe){if(ue!==N||T.value!==U)return;$.value=[],W.value=oe.message||"Failed to load execution history"}ue===N&&(Q.value=!1)}async function G(){if(d.value=null,!f(r.value.action)){d.value=Oo(a.value);return}const U=r.value;if(!U.description.trim()){d.value="Description is required";return}if(U.action!=="webhook"&&!U.channel_id.trim()){d.value="Channel ID is required";return}if(!U.cron.trim()&&!U.run_at.trim()){d.value="Cron expression or run_at time is required";return}if(U.cron.trim()&&U.run_at.trim()){d.value="Choose either Cron or One-Time, not both";return}const ue={description:U.description.trim(),action:U.action,channel_id:U.channel_id.trim()};if(U.cron.trim()&&(ue.cron=U.cron.trim()),U.run_at.trim()){const oe=h.value;if(oe.state==="nonexistent"){d.value="That local time does not exist (daylight saving gap)";return}if(oe.state==="invalid"){d.value="One-time run time is not a valid date";return}const he=b.value;if(oe.state==="ambiguous"&&m.value===null){d.value="That local time happens twice — choose which occurrence to use";return}if(!he){d.value="One-time run time could not be resolved";return}ue.run_at=he.toISOString()}if(U.action==="reminder"&&U.message.trim()&&(ue.message=U.message.trim()),U.action==="check"&&(U.tool_name.trim()&&(ue.tool_name=U.tool_name.trim()),U.report_format&&(ue.report_format=U.report_format),U.tool_input_str.trim()))try{ue.tool_input=JSON.parse(U.tool_input_str.trim())}catch{d.value="Tool input must be valid JSON";return}if(U.action==="webhook"){if(!U.webhook_url.trim()){d.value="Webhook URL is required";return}const oe={url:U.webhook_url.trim(),method:U.webhook_method};if(U.webhook_headers_str.trim())try{const he=JSON.parse(U.webhook_headers_str.trim());if(!he||Array.isArray(he)||typeof he!="object")throw new Error("not an object");oe.headers=he}catch{d.value="Webhook headers must be a valid JSON object";return}if(U.webhook_body&&(oe.body=U.webhook_body),U.webhook_expected_status_str.trim()){const he=U.webhook_expected_status_str.split(",").map(Fe=>Number(Fe.trim()));if(he.some(Fe=>!Number.isInteger(Fe)||Fe<100||Fe>599)){d.value="Expected status codes must be comma-separated HTTP codes";return}oe.expected_status_codes=he}ue.webhook_config=oe}c.value=!0;try{await z.post("/api/schedules",ue),Se.success("Schedule created"),r.value={description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""},k.value=null,o.value=!1,await ge()}catch(oe){E(oe),d.value=oe.message}c.value=!1}async function me(U){if(!f(U.action)){Se.error(Oo(a.value));return}const ue=U.id;x.value=ue;try{const oe=await z.post(`/api/schedules/${encodeURIComponent(ue)}/run`);if(oe.status==="failure")Se.error(`Execution failed: ${oe.error||"unknown error"}`);else{const he=oe.warning?`Executed (${oe.warning})`:"Executed successfully";Se.success(he)}await ge()}catch(oe){E(oe),Se.error(oe.message||"Failed to trigger")}x.value=null}async function de(U){if(U.paused&&!f(U.action)){Se.error(Oo(a.value));return}_.value=U.id;const ue=!U.paused;try{const oe=await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{paused:ue});!ue&&(oe!=null&&oe.inert_reason)?Se.error(`Schedule remains paused and inert: ${oe.inert_reason}`):Se.success(ue?"Schedule paused":"Schedule resumed"),await ge()}catch(oe){E(oe),Se.error(oe.message||"Failed to update schedule")}_.value=null}const fe=new Map;function _e(U,ue){const oe=fe.get(U.id);oe&&clearTimeout(oe.timer);const he={run:()=>q(U,ue),timer:null};he.timer=setTimeout(()=>{fe.delete(U.id),he.run()},500),fe.set(U.id,he)}async function q(U,ue){R.value=U.id;try{await z.put(`/api/schedules/${encodeURIComponent(U.id)}`,{report_format:ue}),Se.success(ue?"Structured report enabled":"Plain-text report enabled")}catch(oe){Se.error(`Update failed: ${oe.message}`)}finally{await ge(),R.value=null}}function te(){for(const[U,ue]of[...fe])clearTimeout(ue.timer),fe.delete(U),ue.run()}async function X(U){A.value=U;try{await z.post(`/api/schedules/${encodeURIComponent(U)}/reset-failures`),Se.success("Failure counters reset"),await ge()}catch(ue){Se.error(ue.message||"Failed to reset")}A.value=null}async function xe(U){const ue=e.value.find(he=>he.id===U);if(await os({title:"Delete schedule",message:`Delete "${(ue==null?void 0:ue.description)||U}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){S.value=U;try{await z.del(`/api/schedules/${encodeURIComponent(U)}`),Se.success("Schedule deleted"),await ge()}catch(he){Se.error(he.message||"Failed to delete schedule")}S.value=null}}return st(()=>{ge(),Ne(),l=setInterval(Ne,n1)}),xt(()=>{te(),l&&clearInterval(l)}),{schedules:e,loading:t,error:s,schedulingAvailable:n,schedulingAvailabilityMessage:i,selectedActionAvailable:u,actionAvailable:f,showCreate:o,form:r,creating:c,createError:d,runAtUtcPreview:I,runAtAnalysis:h,runAtOccurrence:m,cronResult:k,validatingCron:y,cronPresets:g,runningId:x,deletingId:S,togglingId:_,resettingId:A,reportUpdatingId:R,flushReportFormatTimers:te,rearmingId:w,rearmRunAt:O,rearmOccurrences:B,rearmAnalysis:Oe,expandedId:T,history:$,historyLoading:Q,historyError:W,cronCount:L,oneTimeCount:D,webhookCount:re,pausedCount:ce,failingCount:J,formatTs:ni,formatAge:gg,formatFuture:K,formatMs:se,formatDuration:Ji,onCronInput:j,onRunAtInput:ye,validateCron:be,toggleExpand:P,fetchSchedules:ge,fetchSchedulingAvailability:Ne,doCreate:G,doRunNow:me,doTogglePause:de,doUpdateReportFormat:_e,doResetFailures:X,doDelete:xe,setRearmRunAt:ne,setRearmOccurrence:ie,doRearmSchedule:ve}}},wg=[{id:"live",label:"Live",component:GS},{id:"agents",label:"Agents",component:QS},{id:"loops",label:"Loops",component:XS},{id:"processes",label:"Processes",component:e1},{id:"schedules",label:"Schedules",component:o1}],r1={components:{TabbedPage:Xr},setup(){return{tabs:wg}},template:''},c1={template:` + `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(Xo(null)),n=U(()=>a.value.available),i=U(()=>Lo(a.value));let l=null;const o=p(!1),r=p({description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""}),c=p(!1),d=p(null),u=U(()=>Pf(a.value,r.value.action));function f(B){return Pf(a.value,B)}const m=p(null),h=U(()=>Mf(r.value.run_at));jt(()=>r.value.run_at,()=>{m.value=null});const g=U(()=>{var fe;const B=h.value;return B.state==="ok"?B.instant:B.state==="ambiguous"&&m.value!==null&&((fe=B.options[m.value])==null?void 0:fe.instant)||null}),A=U(()=>{const B=g.value;return B?`${B.toLocaleString()} local — ${B.toISOString()} UTC`:""}),w=p(null),y=p(!1),b=[{label:"Every hour",expr:"0 * * * *"},{label:"Every 6h",expr:"0 */6 * * *"},{label:"Daily 9am",expr:"0 9 * * *"},{label:"Weekly Mon",expr:"0 9 * * 1"},{label:"Every 30m",expr:"*/30 * * * *"}],x=p(null),S=p(null),_=p(null),T=p(null),L=p(null),E=p(null),P=p({}),R=p({}),I=p(null),H=p([]),X=p(!1),Q=p("");let N=0;const O=U(()=>e.value.filter(B=>B.cron&&!B.one_time).length),$=U(()=>e.value.filter(B=>B.one_time).length),ce=U(()=>e.value.filter(B=>B.trigger).length),ue=U(()=>e.value.filter(B=>B.paused).length),Z=U(()=>e.value.filter(B=>B.consecutive_failures>0).length);function J(B){if(!B)return"-";const fe=Date.now(),be=(new Date(B).getTime()-fe)/1e3;if(be<0)return"overdue";if(be<60)return"in < 1 min";if(be<3600)return`in ${Math.floor(be/60)} min`;if(be<86400){const qe=Math.floor(be/3600),$e=Math.floor(be%3600/60);return $e>0?`in ${qe}h ${$e}m`:`in ${qe}h`}const Fe=Math.floor(be/86400);return`in ${Fe} day${Fe!==1?"s":""}`}function se(B){return B==null?"-":B<1e3?`${B}ms`:B<6e4?`${(B/1e3).toFixed(1)}s`:Zi(B/1e3)}function q(B=r.value.cron){r.value.cron=B,Nf(r.value,"cron"),w.value=null}function ye(B=r.value.run_at){r.value.run_at=B,Nf(r.value,"run_at"),w.value=null}function Oe(B){return Mf(P.value[B.id]||"")}function le(B,fe){P.value[B]=fe,delete R.value[B]}function oe(B,fe){R.value[B]=fe===""?null:Number(fe)}async function ve(B){let fe;try{fe=m1(Oe(B),R.value[B.id])}catch(de){ke.error(de.message);return}E.value=B.id;try{const de=await z.put(`/api/schedules/${encodeURIComponent(B.id)}`,{run_at:fe});de!=null&&de.inert_reason||de!=null&&de.paused?ke.error(`Schedule remains paused and inert: ${de.inert_reason||"the server kept it paused"}`):(ke.success("Schedule re-armed for the new run time"),delete P.value[B.id],delete R.value[B.id]),await V()}catch(de){k(de),ke.error(de.message||"Failed to re-arm schedule")}finally{E.value=null}}async function j(){const B=r.value.cron.trim();if(B){y.value=!0;try{w.value=await z.post("/api/schedules/validate-cron",{expression:B})}catch(fe){w.value={valid:!1,error:fe.message}}y.value=!1}}async function V(){t.value=!0,s.value=null;try{e.value=await z.get("/api/schedules")}catch(B){s.value=B.message}t.value=!1}async function xe(){try{a.value=Xo(await z.get("/api/schedules/status"))}catch(B){a.value=Df(B)||Xo(null)}}function k(B){const fe=Df(B);fe&&(a.value=fe)}async function D(B){if(I.value===B){I.value=null,H.value=[];return}I.value=B,X.value=!0,H.value=[];const fe=++N;try{const de=await z.get(`/api/schedules/${encodeURIComponent(B)}/history?limit=10`);if(fe!==N||I.value!==B)return;H.value=de,Q.value=""}catch(de){if(fe!==N||I.value!==B)return;H.value=[],Q.value=de.message||"Failed to load execution history"}fe===N&&(X.value=!1)}async function W(){if(d.value=null,!f(r.value.action)){d.value=Lo(a.value);return}const B=r.value;if(!B.description.trim()){d.value="Description is required";return}if(B.action!=="webhook"&&!B.channel_id.trim()){d.value="Channel ID is required";return}if(!B.cron.trim()&&!B.run_at.trim()){d.value="Cron expression or run_at time is required";return}if(B.cron.trim()&&B.run_at.trim()){d.value="Choose either Cron or One-Time, not both";return}const fe={description:B.description.trim(),action:B.action,channel_id:B.channel_id.trim()};if(B.cron.trim()&&(fe.cron=B.cron.trim()),B.run_at.trim()){const de=h.value;if(de.state==="nonexistent"){d.value="That local time does not exist (daylight saving gap)";return}if(de.state==="invalid"){d.value="One-time run time is not a valid date";return}const be=g.value;if(de.state==="ambiguous"&&m.value===null){d.value="That local time happens twice — choose which occurrence to use";return}if(!be){d.value="One-time run time could not be resolved";return}fe.run_at=be.toISOString()}if(B.action==="reminder"&&B.message.trim()&&(fe.message=B.message.trim()),B.action==="check"&&(B.tool_name.trim()&&(fe.tool_name=B.tool_name.trim()),B.report_format&&(fe.report_format=B.report_format),B.tool_input_str.trim()))try{fe.tool_input=JSON.parse(B.tool_input_str.trim())}catch{d.value="Tool input must be valid JSON";return}if(B.action==="webhook"){if(!B.webhook_url.trim()){d.value="Webhook URL is required";return}const de={url:B.webhook_url.trim(),method:B.webhook_method};if(B.webhook_headers_str.trim())try{const be=JSON.parse(B.webhook_headers_str.trim());if(!be||Array.isArray(be)||typeof be!="object")throw new Error("not an object");de.headers=be}catch{d.value="Webhook headers must be a valid JSON object";return}if(B.webhook_body&&(de.body=B.webhook_body),B.webhook_expected_status_str.trim()){const be=B.webhook_expected_status_str.split(",").map(Fe=>Number(Fe.trim()));if(be.some(Fe=>!Number.isInteger(Fe)||Fe<100||Fe>599)){d.value="Expected status codes must be comma-separated HTTP codes";return}de.expected_status_codes=be}fe.webhook_config=de}c.value=!0;try{await z.post("/api/schedules",fe),ke.success("Schedule created"),r.value={description:"",action:"reminder",channel_id:"",cron:"",run_at:"",message:"",tool_name:"",tool_input_str:"",report_format:"",webhook_url:"",webhook_method:"POST",webhook_headers_str:"",webhook_body:"",webhook_expected_status_str:""},w.value=null,o.value=!1,await V()}catch(de){k(de),d.value=de.message}c.value=!1}async function ge(B){if(!f(B.action)){ke.error(Lo(a.value));return}const fe=B.id;x.value=fe;try{const de=await z.post(`/api/schedules/${encodeURIComponent(fe)}/run`);if(de.status==="failure")ke.error(`Execution failed: ${de.error||"unknown error"}`);else{const be=de.warning?`Executed (${de.warning})`:"Executed successfully";ke.success(be)}await V()}catch(de){k(de),ke.error(de.message||"Failed to trigger")}x.value=null}async function pe(B){if(B.paused&&!f(B.action)){ke.error(Lo(a.value));return}_.value=B.id;const fe=!B.paused;try{const de=await z.put(`/api/schedules/${encodeURIComponent(B.id)}`,{paused:fe});!fe&&(de!=null&&de.inert_reason)?ke.error(`Schedule remains paused and inert: ${de.inert_reason}`):ke.success(fe?"Schedule paused":"Schedule resumed"),await V()}catch(de){k(de),ke.error(de.message||"Failed to update schedule")}_.value=null}const he=new Map;function we(B,fe){const de=he.get(B.id);de&&clearTimeout(de.timer);const be={run:()=>K(B,fe),timer:null};be.timer=setTimeout(()=>{he.delete(B.id),be.run()},500),he.set(B.id,be)}async function K(B,fe){L.value=B.id;try{await z.put(`/api/schedules/${encodeURIComponent(B.id)}`,{report_format:fe}),ke.success(fe?"Structured report enabled":"Plain-text report enabled")}catch(de){ke.error(`Update failed: ${de.message}`)}finally{await V(),L.value=null}}function ne(){for(const[B,fe]of[...he])clearTimeout(fe.timer),he.delete(B),fe.run()}async function te(B){T.value=B;try{await z.post(`/api/schedules/${encodeURIComponent(B)}/reset-failures`),ke.success("Failure counters reset"),await V()}catch(fe){ke.error(fe.message||"Failed to reset")}T.value=null}async function _e(B){const fe=e.value.find(be=>be.id===B);if(await os({title:"Delete schedule",message:`Delete "${(fe==null?void 0:fe.description)||B}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){S.value=B;try{await z.del(`/api/schedules/${encodeURIComponent(B)}`),ke.success("Schedule deleted"),await V()}catch(be){ke.error(be.message||"Failed to delete schedule")}S.value=null}}return tt(()=>{V(),xe(),l=setInterval(xe,p1)}),xt(()=>{ne(),l&&clearInterval(l)}),{schedules:e,loading:t,error:s,schedulingAvailable:n,schedulingAvailabilityMessage:i,selectedActionAvailable:u,actionAvailable:f,showCreate:o,form:r,creating:c,createError:d,runAtUtcPreview:A,runAtAnalysis:h,runAtOccurrence:m,cronResult:w,validatingCron:y,cronPresets:b,runningId:x,deletingId:S,togglingId:_,resettingId:T,reportUpdatingId:L,flushReportFormatTimers:ne,rearmingId:E,rearmRunAt:P,rearmOccurrences:R,rearmAnalysis:Oe,expandedId:I,history:H,historyLoading:X,historyError:Q,cronCount:O,oneTimeCount:$,webhookCount:ce,pausedCount:ue,failingCount:Z,formatTs:ii,formatAge:kg,formatFuture:J,formatMs:se,formatDuration:Zi,onCronInput:q,onRunAtInput:ye,validateCron:j,toggleExpand:D,fetchSchedules:V,fetchSchedulingAvailability:xe,doCreate:W,doRunNow:ge,doTogglePause:pe,doUpdateReportFormat:we,doResetFailures:te,doDelete:_e,setRearmRunAt:le,setRearmOccurrence:oe,doRearmSchedule:ve}}},Ag=[{id:"live",label:"Live",component:e1},{id:"agents",label:"Agents",component:l1},{id:"loops",label:"Loops",component:o1},{id:"processes",label:"Processes",component:r1},{id:"schedules",label:"Schedules",component:h1}],v1={components:{TabbedPage:nc},setup(){return{tabs:Ag}},template:''},Rd=/^\d{15,25}$/,No=new Map,Mo=new Map;let Do=null,Po=null;async function g1(){return Do||(Po||(Po=z.get("/api/discord/members").then(e=>(Do=Array.isArray(e)?e:[],Do)).catch(()=>[]).finally(()=>{Po=null})),Po)}async function b1(e){if(!Rd.test(String(e)))return null;const t=(await g1()).find(s=>String(s.id)===String(e));return t?(No.set(String(e),t),t):No.has(String(e))?No.get(String(e)):(Mo.has(String(e))||Mo.set(String(e),z.get(`/api/discord/users/${encodeURIComponent(e)}`).then(s=>(s==null?void 0:s.user)||null).catch(()=>null).then(s=>(s&&No.set(String(e),s),Mo.delete(String(e)),s))),Mo.get(String(e)))}const En={props:{userId:{type:[String,Number],required:!0},members:{type:Array,default:()=>[]},showId:{type:Boolean,default:!0},fallbackLabel:{type:String,default:""}},template:` + + + {{ displayFallback }} + {{ userId }} + + `,setup(e){const t=p(null),s=U(()=>(e.members||[]).find(l=>String(l.id)===String(e.userId))||null),a=U(()=>s.value||t.value),n=U(()=>Rd.test(String(e.userId))?e.userId:e.fallbackLabel||e.userId);async function i(){t.value=null,Rd.test(String(e.userId))&&!s.value&&(t.value=await b1(e.userId))}return jt(()=>[e.userId,e.members],i,{deep:!0}),tt(i),{identity:a,displayFallback:n}}},y1={components:{DiscordIdentity:En},template:`

Audit Log

@@ -1413,7 +1428,7 @@ ${u.text}`:u.text).join(` :class="expandedIdx === i ? 'bg-gray-800/50' : ''"> {{ formatTs(e.timestamp) }} {{ e.tool || e.tool_name || '—' }} - {{ e.user || e.user_id || '—' }} + {{ !e.user_id ? (e.user || '—') : '' }} {{ e.host || '—' }} {{ e.duration ? (e.duration < 1 ? (e.duration * 1000).toFixed(0) + 'ms' : e.duration.toFixed(1) + 's') @@ -1465,7 +1480,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(null),n=p({tool:"",user:"",keyword:"",date:"",limit:50});function i(h){if(!h)return"";if(typeof h=="string")return h;try{return JSON.stringify(h,null,2)}catch{return String(h)}}function l(h){a.value=a.value===h?null:h}function o(){n.value={tool:"",user:"",keyword:"",date:"",limit:50},m()}let r=0;const c=p(!1),d=p(null),u=p(null);async function f(){c.value=!0,u.value=null;try{d.value=await z.get("/api/audit/verify")}catch(h){h.status===409&&h.data&&typeof h.data=="object"?d.value=h.data.availability==="not_enabled"?{...h.data,not_enabled:!0}:h.data:(d.value=null,u.value=h.message||"verification request failed")}c.value=!1}async function m(){const h=++r;t.value=!0,s.value=null,a.value=null;try{const b=new URLSearchParams;n.value.tool&&b.set("tool",n.value.tool),n.value.user&&b.set("user",n.value.user),n.value.keyword&&b.set("q",n.value.keyword),n.value.date&&b.set("date",n.value.date),b.set("limit",String(n.value.limit));const I=b.toString(),k=await z.get(`/api/audit${I?"?"+I:""}`);if(h!==r)return;e.value=Array.isArray(k)?k:[]}catch(b){if(h!==r)return;s.value=b.message}h===r&&(t.value=!1)}return st(()=>{m()}),{entries:e,loading:t,error:s,expandedIdx:a,filters:n,formatTs:ni,formatDetail:i,truncateBlock:bg,toggleExpand:l,clearFilters:o,fetchAudit:m,verifying:c,verifyResult:d,verifyError:u,verifyIntegrity:f}}},Of=[{id:"all",name:"All Sessions",icon:"list",filters:{}},{id:"active",name:"Recently Active",icon:"activity",filters:{minAge:0,maxAge:3600}},{id:"discord",name:"Discord Only",icon:"message",filters:{source:"discord"}},{id:"web",name:"Web Only",icon:"globe",filters:{source:"web"}},{id:"long",name:"Long Conversations",icon:"book",filters:{minMessages:10}},{id:"compacted",name:"Compacted",icon:"archive",filters:{hasCompaction:!0}}],d1=[{value:"last_active",label:"Last Active"},{value:"created_at",label:"Created"},{value:"message_count",label:"Message Count"}],u1={template:` + `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(null),n=p({tool:"",user:"",keyword:"",date:"",limit:50});function i(h){if(!h)return"";if(typeof h=="string")return h;try{return JSON.stringify(h,null,2)}catch{return String(h)}}function l(h){a.value=a.value===h?null:h}function o(){n.value={tool:"",user:"",keyword:"",date:"",limit:50},m()}let r=0;const c=p(!1),d=p(null),u=p(null);async function f(){c.value=!0,u.value=null;try{d.value=await z.get("/api/audit/verify")}catch(h){h.status===409&&h.data&&typeof h.data=="object"?d.value=h.data.availability==="not_enabled"?{...h.data,not_enabled:!0}:h.data:(d.value=null,u.value=h.message||"verification request failed")}c.value=!1}async function m(){const h=++r;t.value=!0,s.value=null,a.value=null;try{const g=new URLSearchParams;n.value.tool&&g.set("tool",n.value.tool),n.value.user&&g.set("user",n.value.user),n.value.keyword&&g.set("q",n.value.keyword),n.value.date&&g.set("date",n.value.date),g.set("limit",String(n.value.limit));const A=g.toString(),w=await z.get(`/api/audit${A?"?"+A:""}`);if(h!==r)return;e.value=Array.isArray(w)?w:[]}catch(g){if(h!==r)return;s.value=g.message}h===r&&(t.value=!1)}return tt(()=>{m()}),{entries:e,loading:t,error:s,expandedIdx:a,filters:n,formatTs:ii,formatDetail:i,truncateBlock:Sg,toggleExpand:l,clearFilters:o,fetchAudit:m,verifying:c,verifyResult:d,verifyError:u,verifyIntegrity:f}}},$f=[{id:"all",name:"All Sessions",icon:"list",filters:{}},{id:"active",name:"Recently Active",icon:"activity",filters:{minAge:0,maxAge:3600}},{id:"discord",name:"Discord Only",icon:"message",filters:{source:"discord"}},{id:"web",name:"Web Only",icon:"globe",filters:{source:"web"}},{id:"long",name:"Long Conversations",icon:"book",filters:{minMessages:10}},{id:"compacted",name:"Compacted",icon:"archive",filters:{hasCompaction:!0}}],x1=[{value:"last_active",label:"Last Active"},{value:"created_at",label:"Created"},{value:"message_count",label:"Message Count"}],_1={components:{DiscordIdentity:En},template:`
@@ -1584,7 +1599,7 @@ ${u.text}`:u.text).join(`
{{ r.type }} {{ r.channel_id }} - {{ r.user_id }} + {{ r.author }} {{ formatTimestamp(r.timestamp) }} @@ -1653,7 +1668,7 @@ ${u.text}`:u.text).join(`
Active {{ formatAge(s.last_active) }} · Created {{ formatAge(s.created_at) }} - · {{ s.last_user_id }} + ·
@@ -1730,7 +1745,7 @@ ${u.text}`:u.text).join(` :class="messageClass(m.role)">
{{ m.role }} - {{ m.user_id }} + {{ formatTimestamp(m.timestamp) }} @@ -1752,7 +1767,7 @@ ${u.text}`:u.text).join(`
{{ m.role }} - {{ m.user_id }} + {{ formatTimestamp(m.timestamp) }} @@ -1799,8 +1814,8 @@ ${u.text}`:u.text).join(`
-
`,setup(){const e=p([]),t=p(!0),s=p(null),a=p(null),n=p(null),i=p(!1);let l=0;const o=p(null),r=p(!1),c=p(new Set),d=p(!1),u=p("all"),f=p(""),m=p("last_active"),h=p(!1),b=Of,I=d1,k=p([]),y=p(!1),g=p(""),x=p("flat"),S=p(new Set),_=p(""),A=p(""),R=p(""),w=p(null),O=p(!1),B=p(""),T=p(!1);let $=0;Wt([_,A,R],()=>{$++,O.value=!1,B.value="",T.value=w.value!==null},{flush:"sync"});function Q(){try{const ee=localStorage.getItem("odin-session-presets");ee&&(k.value=JSON.parse(ee))}catch{}}function W(){try{localStorage.setItem("odin-session-presets",JSON.stringify(k.value))}catch{}}const N=H(()=>f.value.trim()!==""||u.value!=="all"),L=H(()=>{let ee=[...e.value];const Le=Of.find(lt=>lt.id===u.value),He=Le?Le.filters:{};if(He.source&&(ee=ee.filter(lt=>lt.source===He.source)),He.minMessages&&(ee=ee.filter(lt=>lt.message_count>=He.minMessages)),He.hasCompaction&&(ee=ee.filter(lt=>lt.has_summary)),He.maxAge!=null){const lt=Date.now()/1e3;ee=ee.filter(gt=>gt.last_active&<-gt.last_active<=He.maxAge)}if(f.value.trim()){const lt=f.value.toLowerCase().trim();ee=ee.filter(gt=>(gt.channel_id||"").toLowerCase().includes(lt)||(gt.last_user_id||"").toLowerCase().includes(lt)||(gt.source||"").toLowerCase().includes(lt))}const rt=m.value,Zt=h.value?1:-1;return ee.sort((lt,gt)=>{const Yt=lt[rt]||0,cs=gt[rt]||0;return(Yt-cs)*Zt}),ee}),D=H(()=>{if(!n.value||!n.value.messages)return[];const ee=n.value.messages;if(ee.length===0)return[];const Le=[];let He=[];for(const rt of ee)rt.role==="user"&&He.length>0&&(Le.push(He),He=[]),He.push(rt);return He.length>0&&Le.push(He),Le}),re=H(()=>L.value.length>0&&c.value.size===L.value.length);function ce(ee){const Le=ee.find(He=>He.role==="user");if(Le&&Le.content){const He=Le.content.slice(0,120);return He.lengthLe.id!==ee),W(),u.value===ee&&(u.value="all")}function Oe(){u.value="all",f.value="",m.value="last_active",h.value=!1}function ne(ee){if(!ee)return"—";const Le=Date.now()/1e3-ee;if(Le<60)return"just now";if(Le<3600){const rt=Math.floor(Le/60);return`${rt} minute${rt!==1?"s":""} ago`}if(Le<86400){const rt=Math.floor(Le/3600);return`${rt} hour${rt!==1?"s":""} ago`}const He=Math.floor(Le/86400);return`${He} day${He!==1?"s":""} ago`}function ie(ee){if(!ee)return"";try{return new Date(ee*1e3).toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return""}}function ve(ee){if(!ee)return"";try{return new Date(ee*1e3).toLocaleString()}catch{return""}}function be(ee){return ee==="user"?"bg-gray-900/50 border border-gray-800":ee==="assistant"?"bg-indigo-950/30 border border-indigo-900/30":"bg-gray-900/30 border border-gray-800/50"}function ge(ee){return ee==="user"?"sess-msg-user":ee==="assistant"?"sess-msg-assistant":"sess-msg-system"}function Ne(ee){return ee==="user"?"badge-info":ee==="assistant"?"badge-success":"badge-warning"}function E(ee){return ee==="user"?"sess-dot-user":ee==="assistant"?"sess-dot-assistant":"sess-dot-system"}function P(ee){return ee==="user"?"text-cyan-400":ee==="assistant"?"text-indigo-400":"text-gray-500"}function G(ee){return ee?ee.length>2e3?ee.slice(0,2e3)+` -... (truncated)`:ee:""}async function me(){const ee=_.value.trim();if(!ee)return;const Le=++$;O.value=!0,B.value="",T.value=w.value!==null;try{let He=`/api/sessions/search?q=${encodeURIComponent(ee)}&limit=50`;A.value.trim()&&(He+=`&channel_id=${encodeURIComponent(A.value.trim())}`),R.value.trim()&&(He+=`&user_id=${encodeURIComponent(R.value.trim())}`);const rt=await z.get(He);if(Le!==$)return;w.value=rt.results||[],T.value=!1}catch(He){if(Le!==$)return;B.value=He.message||"Search failed. Please retry."}finally{Le===$&&(O.value=!1)}}function de(){$++,_.value="",A.value="",R.value="",w.value=null,B.value="",T.value=!1,O.value=!1}function fe(ee){return ee?ee.replace(/&/g,"&").replace(//g,">").replace(/>>>/g,'').replace(/<<</g,""):""}function _e(ee){return ee==="user"?"fts-result-user":ee==="assistant"?"fts-result-assistant":ee==="summary"?"fts-result-summary":ee==="fts"?"fts-result-fts":ee==="channel"?"fts-result-channel":"fts-result-default"}function q(ee){return ee==="user"?"badge-info":ee==="assistant"?"badge-success":ee==="summary"?"badge-warning":ee==="fts"?"badge-success":"badge-info"}let te=0;async function X(){const ee=++te;t.value=!0,s.value=null;try{const Le=await z.get("/api/sessions");if(ee!==te)return;e.value=Le}catch(Le){if(ee!==te)return;s.value=Le.message}ee===te&&(t.value=!1)}function xe(){s.value=null,X()}async function U(ee){if(a.value===ee){a.value=null,n.value=null,S.value=new Set;return}a.value=ee,n.value=null,i.value=!0,S.value=new Set;const Le=++l;try{const He=await z.get(`/api/sessions/${encodeURIComponent(ee)}`);Le===l&&a.value===ee&&(n.value=He)}catch(He){Le===l&&a.value===ee&&(n.value={messages:[],summary:"",error:He.message||"Failed to load session"})}finally{Le===l&&(i.value=!1)}}function ue(ee){const Le=new Set(c.value);Le.has(ee)?Le.delete(ee):Le.add(ee),c.value=Le}function oe(){re.value?c.value=new Set:c.value=new Set(L.value.map(ee=>ee.channel_id))}function he(ee){o.value=ee}async function Fe(){if(o.value){r.value=!0;try{await z.del(`/api/sessions/${encodeURIComponent(o.value)}`),a.value===o.value&&(a.value=null,n.value=null),c.value.delete(o.value),await X()}catch(ee){s.value=ee.message||"Failed to clear session"}r.value=!1,o.value=null}}function Ve(){d.value=!0}async function $e(){if(c.value.size!==0){r.value=!0;try{await z.post("/api/sessions/clear-bulk",{channel_ids:[...c.value]}),c.value.has(a.value)&&(a.value=null,n.value=null),c.value=new Set,await X()}catch(ee){s.value=ee.message||"Failed to clear sessions"}r.value=!1,d.value=!1}}async function Ke(ee,Le){const He=`/api/sessions/${encodeURIComponent(ee)}/export?format=${Le}`;try{const rt=await z.getBlob(He),Zt=URL.createObjectURL(rt),lt=document.createElement("a");lt.href=Zt,lt.download=`session-${ee}.${Le==="text"?"txt":"json"}`,lt.click(),URL.revokeObjectURL(Zt)}catch(rt){s.value=rt.message||"Failed to export session"}}let at=null;function Xe(ee){ee.payload&&ee.payload.channel_id&&(clearTimeout(at),at=setTimeout(()=>{if(X(),a.value&&ee.payload.channel_id===a.value){const Le=a.value,He=l;z.get(`/api/sessions/${encodeURIComponent(Le)}`).then(rt=>{He!==l||a.value!==Le||(n.value=rt)}).catch(()=>{})}},2e3))}let Z=!1,Ee=null;function Ae(){Z||(Z=!0,X(),ut.subscribe("events",Xe),Ee=ut.onReconnected(()=>X()))}st(()=>{Q(),Ae()}),rs(()=>{Ae()});function Me(){Z&&(Z=!1,ut.unsubscribe("events",Xe),Ee&&(Ee(),Ee=null),clearTimeout(at))}return Kt(Me),xt(Me),{sessions:e,loading:t,error:s,expandedId:a,detail:n,detailLoading:i,clearTarget:o,clearing:r,selected:c,allSelected:re,bulkClearing:d,activePreset:u,searchQuery:f,sortBy:m,sortAsc:h,filterPresets:b,sortOptions:I,filteredSessions:L,hasActiveFilters:N,customPresets:k,showSavePreset:y,newPresetName:g,threadView:x,threads:D,collapsedThreads:S,ftsQuery:_,ftsChannelId:A,ftsUserId:R,ftsResults:w,ftsSearching:O,ftsError:B,ftsStale:T,formatAge:ne,formatTimestamp:ie,formatFullTimestamp:ve,messageClass:be,threadMsgClass:ge,roleBadge:Ne,roleDotClass:E,roleLabelClass:P,truncateContent:G,threadSummary:ce,fetchSessions:X,retry:xe,toggleSession:U,toggleSelect:ue,toggleSelectAll:oe,confirmClear:he,clearSession:Fe,confirmBulkClear:Ve,doBulkClear:$e,exportSession:Ke,applyPreset:K,applyCustomPreset:se,saveCustomPreset:j,removeCustomPreset:ye,resetFilters:Oe,toggleThread:J,runFtsSearch:me,clearFtsSearch:de,highlightSnippet:fe,ftsResultClass:_e,ftsTypeBadge:q}}},p1={props:["trace"],template:` + `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(null),n=p(null),i=p(!1);let l=0;const o=p(null),r=p(!1),c=p(new Set),d=p(!1),u=p("all"),f=p(""),m=p("last_active"),h=p(!1),g=$f,A=x1,w=p([]),y=p(!1),b=p(""),x=p("flat"),S=p(new Set),_=p(""),T=p(""),L=p(""),E=p(null),P=p(!1),R=p(""),I=p(!1);let H=0;jt([_,T,L],()=>{H++,P.value=!1,R.value="",I.value=E.value!==null},{flush:"sync"});function X(){try{const ae=localStorage.getItem("odin-session-presets");ae&&(w.value=JSON.parse(ae))}catch{}}function Q(){try{localStorage.setItem("odin-session-presets",JSON.stringify(w.value))}catch{}}const N=U(()=>f.value.trim()!==""||u.value!=="all"),O=U(()=>{let ae=[...e.value];const Ne=$f.find(lt=>lt.id===u.value),He=Ne?Ne.filters:{};if(He.source&&(ae=ae.filter(lt=>lt.source===He.source)),He.minMessages&&(ae=ae.filter(lt=>lt.message_count>=He.minMessages)),He.hasCompaction&&(ae=ae.filter(lt=>lt.has_summary)),He.maxAge!=null){const lt=Date.now()/1e3;ae=ae.filter(gt=>gt.last_active&<-gt.last_active<=He.maxAge)}if(f.value.trim()){const lt=f.value.toLowerCase().trim();ae=ae.filter(gt=>(gt.channel_id||"").toLowerCase().includes(lt)||(gt.last_user_id||"").toLowerCase().includes(lt)||(gt.source||"").toLowerCase().includes(lt))}const rt=m.value,Zt=h.value?1:-1;return ae.sort((lt,gt)=>{const Qt=lt[rt]||0,cs=gt[rt]||0;return(Qt-cs)*Zt}),ae}),$=U(()=>{if(!n.value||!n.value.messages)return[];const ae=n.value.messages;if(ae.length===0)return[];const Ne=[];let He=[];for(const rt of ae)rt.role==="user"&&He.length>0&&(Ne.push(He),He=[]),He.push(rt);return He.length>0&&Ne.push(He),Ne}),ce=U(()=>O.value.length>0&&c.value.size===O.value.length);function ue(ae){const Ne=ae.find(He=>He.role==="user");if(Ne&&Ne.content){const He=Ne.content.slice(0,120);return He.lengthNe.id!==ae),Q(),u.value===ae&&(u.value="all")}function Oe(){u.value="all",f.value="",m.value="last_active",h.value=!1}function le(ae){if(!ae)return"—";const Ne=Date.now()/1e3-ae;if(Ne<60)return"just now";if(Ne<3600){const rt=Math.floor(Ne/60);return`${rt} minute${rt!==1?"s":""} ago`}if(Ne<86400){const rt=Math.floor(Ne/3600);return`${rt} hour${rt!==1?"s":""} ago`}const He=Math.floor(Ne/86400);return`${He} day${He!==1?"s":""} ago`}function oe(ae){if(!ae)return"";try{return new Date(ae*1e3).toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return""}}function ve(ae){if(!ae)return"";try{return new Date(ae*1e3).toLocaleString()}catch{return""}}function j(ae){return ae==="user"?"bg-gray-900/50 border border-gray-800":ae==="assistant"?"bg-indigo-950/30 border border-indigo-900/30":"bg-gray-900/30 border border-gray-800/50"}function V(ae){return ae==="user"?"sess-msg-user":ae==="assistant"?"sess-msg-assistant":"sess-msg-system"}function xe(ae){return ae==="user"?"badge-info":ae==="assistant"?"badge-success":"badge-warning"}function k(ae){return ae==="user"?"sess-dot-user":ae==="assistant"?"sess-dot-assistant":"sess-dot-system"}function D(ae){return ae==="user"?"text-cyan-400":ae==="assistant"?"text-indigo-400":"text-gray-500"}function W(ae){return ae?ae.length>2e3?ae.slice(0,2e3)+` +... (truncated)`:ae:""}async function ge(){const ae=_.value.trim();if(!ae)return;const Ne=++H;P.value=!0,R.value="",I.value=E.value!==null;try{let He=`/api/sessions/search?q=${encodeURIComponent(ae)}&limit=50`;T.value.trim()&&(He+=`&channel_id=${encodeURIComponent(T.value.trim())}`),L.value.trim()&&(He+=`&user_id=${encodeURIComponent(L.value.trim())}`);const rt=await z.get(He);if(Ne!==H)return;E.value=rt.results||[],I.value=!1}catch(He){if(Ne!==H)return;R.value=He.message||"Search failed. Please retry."}finally{Ne===H&&(P.value=!1)}}function pe(){H++,_.value="",T.value="",L.value="",E.value=null,R.value="",I.value=!1,P.value=!1}function he(ae){return ae?ae.replace(/&/g,"&").replace(//g,">").replace(/>>>/g,'').replace(/<<</g,""):""}function we(ae){return ae==="user"?"fts-result-user":ae==="assistant"?"fts-result-assistant":ae==="summary"?"fts-result-summary":ae==="fts"?"fts-result-fts":ae==="channel"?"fts-result-channel":"fts-result-default"}function K(ae){return ae==="user"?"badge-info":ae==="assistant"?"badge-success":ae==="summary"?"badge-warning":ae==="fts"?"badge-success":"badge-info"}let ne=0;async function te(){const ae=++ne;t.value=!0,s.value=null;try{const Ne=await z.get("/api/sessions");if(ae!==ne)return;e.value=Ne}catch(Ne){if(ae!==ne)return;s.value=Ne.message}ae===ne&&(t.value=!1)}function _e(){s.value=null,te()}async function B(ae){if(a.value===ae){a.value=null,n.value=null,S.value=new Set;return}a.value=ae,n.value=null,i.value=!0,S.value=new Set;const Ne=++l;try{const He=await z.get(`/api/sessions/${encodeURIComponent(ae)}`);Ne===l&&a.value===ae&&(n.value=He)}catch(He){Ne===l&&a.value===ae&&(n.value={messages:[],summary:"",error:He.message||"Failed to load session"})}finally{Ne===l&&(i.value=!1)}}function fe(ae){const Ne=new Set(c.value);Ne.has(ae)?Ne.delete(ae):Ne.add(ae),c.value=Ne}function de(){ce.value?c.value=new Set:c.value=new Set(O.value.map(ae=>ae.channel_id))}function be(ae){o.value=ae}async function Fe(){if(o.value){r.value=!0;try{await z.del(`/api/sessions/${encodeURIComponent(o.value)}`),a.value===o.value&&(a.value=null,n.value=null),c.value.delete(o.value),await te()}catch(ae){s.value=ae.message||"Failed to clear session"}r.value=!1,o.value=null}}function qe(){d.value=!0}async function $e(){if(c.value.size!==0){r.value=!0;try{await z.post("/api/sessions/clear-bulk",{channel_ids:[...c.value]}),c.value.has(a.value)&&(a.value=null,n.value=null),c.value=new Set,await te()}catch(ae){s.value=ae.message||"Failed to clear sessions"}r.value=!1,d.value=!1}}async function Ke(ae,Ne){const He=`/api/sessions/${encodeURIComponent(ae)}/export?format=${Ne}`;try{const rt=await z.getBlob(He),Zt=URL.createObjectURL(rt),lt=document.createElement("a");lt.href=Zt,lt.download=`session-${ae}.${Ne==="text"?"txt":"json"}`,lt.click(),URL.revokeObjectURL(Zt)}catch(rt){s.value=rt.message||"Failed to export session"}}let at=null;function Xe(ae){ae.payload&&ae.payload.channel_id&&(clearTimeout(at),at=setTimeout(()=>{if(te(),a.value&&ae.payload.channel_id===a.value){const Ne=a.value,He=l;z.get(`/api/sessions/${encodeURIComponent(Ne)}`).then(rt=>{He!==l||a.value!==Ne||(n.value=rt)}).catch(()=>{})}},2e3))}let Y=!1,Ae=null;function Re(){Y||(Y=!0,te(),ut.subscribe("events",Xe),Ae=ut.onReconnected(()=>te()))}tt(()=>{X(),Re()}),rs(()=>{Re()});function Me(){Y&&(Y=!1,ut.unsubscribe("events",Xe),Ae&&(Ae(),Ae=null),clearTimeout(at))}return Kt(Me),xt(Me),{sessions:e,loading:t,error:s,expandedId:a,detail:n,detailLoading:i,clearTarget:o,clearing:r,selected:c,allSelected:ce,bulkClearing:d,activePreset:u,searchQuery:f,sortBy:m,sortAsc:h,filterPresets:g,sortOptions:A,filteredSessions:O,hasActiveFilters:N,customPresets:w,showSavePreset:y,newPresetName:b,threadView:x,threads:$,collapsedThreads:S,ftsQuery:_,ftsChannelId:T,ftsUserId:L,ftsResults:E,ftsSearching:P,ftsError:R,ftsStale:I,formatAge:le,formatTimestamp:oe,formatFullTimestamp:ve,messageClass:j,threadMsgClass:V,roleBadge:xe,roleDotClass:k,roleLabelClass:D,truncateContent:W,threadSummary:ue,fetchSessions:te,retry:_e,toggleSession:B,toggleSelect:fe,toggleSelectAll:de,confirmClear:be,clearSession:Fe,confirmBulkClear:qe,doBulkClear:$e,exportSession:Ke,applyPreset:J,applyCustomPreset:se,saveCustomPreset:q,removeCustomPreset:ye,resetFilters:Oe,toggleThread:Z,runFtsSearch:ge,clearFtsSearch:pe,highlightSnippet:he,ftsResultClass:we,ftsTypeBadge:K}}},w1={props:["trace"],template:`
Context Assembly
@@ -1845,7 +1860,7 @@ ${u.text}`:u.text).join(` trace truncated ({{ trace.truncation_reason }})
- `,setup(){return{formatTokens:yg}}},f1={components:{ContextAssemblyPanel:p1},template:` + `,setup(){return{formatTokens:Cg}}},k1={components:{ContextAssemblyPanel:w1,DiscordIdentity:En},template:`

Trace Viewer

@@ -1928,7 +1943,7 @@ ${u.text}`:u.text).join(`
User - {{ singleTrace.user_name || singleTrace.user_id || '—' }} + {{ !singleTrace.user_id ? (singleTrace.user_name || '—') : '' }}
Channel @@ -2098,7 +2113,7 @@ ${u.text}`:u.text).join(` role="button" tabindex="0" :aria-expanded="expandedIdx === i" style="cursor:pointer;" :class="expandedIdx === i ? 'bg-gray-800/50' : ''"> {{ formatTs(e.timestamp) }} - {{ e.user_name || e.user_id || '—' }} + {{ !e.user_id ? (e.user_name || '—') : '' }} {{ e.user_content.slice(0, 60) }}{{ e.user_content.length > 60 ? '...' : '' }} {{ e.source || 'api' }} @@ -2240,7 +2255,7 @@ ${u.text}`:u.text).join(`
-
`,setup(){const e=p([]),t=p([]),s=p(!0),a=p(null),n=p(null),i=p(null),l=p(""),o=p(""),r=p(0),c=p({}),d=p({channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50});function u(A){if(!A)return"—";try{const R=new Date(A);return isNaN(R.getTime())?A:R.toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return A}}function f(A){return!A&&A!==0?"—":A<1e3?A+"ms":(A/1e3).toFixed(1)+"s"}function m(A){return!A&&A!==0?"—":A>=1e3?(A/1e3).toFixed(1)+"k":String(A)}function h(A){if(!A)return"";if(typeof A=="string")return A;try{return JSON.stringify(A,null,2)}catch{return String(A)}}function b(A){n.value===A?n.value=null:(n.value=A,c.value={})}function I(A,R){const w=A+"-"+R;c.value={...c.value,[w]:!c.value[w]}}function k(A,R){return!!c.value[A+"-"+R]}function y(){d.value={channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50},o.value="",l.value="",i.value=null,S()}async function g(){try{const A=await z.get("/api/trajectories");e.value=A.files||[],r.value=A.count||0}catch{}}let x=0;async function S(){const A=++x;s.value=!0,a.value=null,n.value=null,i.value=null,c.value={};try{if(o.value){const R=await z.get(`/api/trajectories/${encodeURIComponent(o.value)}?limit=${d.value.limit}`);if(A!==x)return;let w=R.entries||[];d.value.tool_name&&(w=w.filter(O=>(O.tools_used||[]).includes(d.value.tool_name))),d.value.errors_only&&(w=w.filter(O=>O.is_error)),d.value.channel_id&&(w=w.filter(O=>O.channel_id===d.value.channel_id)),d.value.user_id&&(w=w.filter(O=>O.user_id===d.value.user_id)),t.value=w}else{const R=new URLSearchParams;d.value.channel_id&&R.set("channel_id",d.value.channel_id),d.value.user_id&&R.set("user_id",d.value.user_id),d.value.tool_name&&R.set("tool_name",d.value.tool_name),d.value.errors_only&&R.set("errors_only","true"),R.set("limit",String(d.value.limit));const w=R.toString(),O=await z.get(`/api/trajectories/search/query?${w}`);if(A!==x)return;t.value=O.results||[]}}catch(R){if(A!==x)return;a.value=R.message}A===x&&(s.value=!1)}async function _(){if(!l.value.trim())return;const A=++x;s.value=!0,a.value=null,c.value={};try{const R=await z.get(`/api/trajectories/message/${encodeURIComponent(l.value.trim())}`);if(A!==x)return;i.value=R.entry||null,i.value||(a.value="No trace found for this message ID")}catch(R){if(A!==x)return;R.status===404?(i.value=null,a.value="No trace found for message ID: "+l.value):a.value=R.message}A===x&&(s.value=!1)}return st(async()=>{await g(),await S()}),{files:e,entries:t,loading:s,error:a,expandedIdx:n,singleTrace:i,messageIdQuery:l,selectedFile:o,totalSaved:r,filters:d,expandedIterations:c,formatTs:u,formatDuration:f,formatTokens:m,formatJSON:h,truncateBlock:bg,toggleExpand:b,toggleIteration:I,isIterationExpanded:k,clearFilters:y,fetchFiles:g,fetchTraces:S,lookupMessage:_}}};function m1(e){const t=Number(e);return!Number.isFinite(t)||t<=0?"—":t<1e3?`${Math.round(t)} ms`:t<6e4?`${(t/1e3).toFixed(1)} s`:t<36e5?`${(t/6e4).toFixed(1)} min`:`${(t/36e5).toFixed(1)} h`}function h1(e){return e?`${e.approximate?"~":""}${Nu(e.total||0)}`:"0"}const v1={template:` +
`,setup(){const e=p([]),t=p([]),s=p(!0),a=p(null),n=p(null),i=p(null),l=p(""),o=p(""),r=p(0),c=p({}),d=p({channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50});function u(T){if(!T)return"—";try{const L=new Date(T);return isNaN(L.getTime())?T:L.toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return T}}function f(T){return!T&&T!==0?"—":T<1e3?T+"ms":(T/1e3).toFixed(1)+"s"}function m(T){return!T&&T!==0?"—":T>=1e3?(T/1e3).toFixed(1)+"k":String(T)}function h(T){if(!T)return"";if(typeof T=="string")return T;try{return JSON.stringify(T,null,2)}catch{return String(T)}}function g(T){n.value===T?n.value=null:(n.value=T,c.value={})}function A(T,L){const E=T+"-"+L;c.value={...c.value,[E]:!c.value[E]}}function w(T,L){return!!c.value[T+"-"+L]}function y(){d.value={channel_id:"",user_id:"",tool_name:"",errors_only:!1,limit:50},o.value="",l.value="",i.value=null,S()}async function b(){try{const T=await z.get("/api/trajectories");e.value=T.files||[],r.value=T.count||0}catch{}}let x=0;async function S(){const T=++x;s.value=!0,a.value=null,n.value=null,i.value=null,c.value={};try{if(o.value){const L=await z.get(`/api/trajectories/${encodeURIComponent(o.value)}?limit=${d.value.limit}`);if(T!==x)return;let E=L.entries||[];d.value.tool_name&&(E=E.filter(P=>(P.tools_used||[]).includes(d.value.tool_name))),d.value.errors_only&&(E=E.filter(P=>P.is_error)),d.value.channel_id&&(E=E.filter(P=>P.channel_id===d.value.channel_id)),d.value.user_id&&(E=E.filter(P=>P.user_id===d.value.user_id)),t.value=E}else{const L=new URLSearchParams;d.value.channel_id&&L.set("channel_id",d.value.channel_id),d.value.user_id&&L.set("user_id",d.value.user_id),d.value.tool_name&&L.set("tool_name",d.value.tool_name),d.value.errors_only&&L.set("errors_only","true"),L.set("limit",String(d.value.limit));const E=L.toString(),P=await z.get(`/api/trajectories/search/query?${E}`);if(T!==x)return;t.value=P.results||[]}}catch(L){if(T!==x)return;a.value=L.message}T===x&&(s.value=!1)}async function _(){if(!l.value.trim())return;const T=++x;s.value=!0,a.value=null,c.value={};try{const L=await z.get(`/api/trajectories/message/${encodeURIComponent(l.value.trim())}`);if(T!==x)return;i.value=L.entry||null,i.value||(a.value="No trace found for this message ID")}catch(L){if(T!==x)return;L.status===404?(i.value=null,a.value="No trace found for message ID: "+l.value):a.value=L.message}T===x&&(s.value=!1)}return tt(async()=>{await b(),await S()}),{files:e,entries:t,loading:s,error:a,expandedIdx:n,singleTrace:i,messageIdQuery:l,selectedFile:o,totalSaved:r,filters:d,expandedIterations:c,formatTs:u,formatDuration:f,formatTokens:m,formatJSON:h,truncateBlock:Sg,toggleExpand:g,toggleIteration:A,isIterationExpanded:w,clearFilters:y,fetchFiles:b,fetchTraces:S,lookupMessage:_}}};function S1(e){const t=Number(e);return!Number.isFinite(t)||t<=0?"—":t<1e3?`${Math.round(t)} ms`:t<6e4?`${(t/1e3).toFixed(1)} s`:t<36e5?`${(t/6e4).toFixed(1)} min`:`${(t/36e5).toFixed(1)} h`}function C1(e){return e?`${e.approximate?"~":""}${Bu(e.total||0)}`:"0"}const T1={template:`
@@ -2361,7 +2376,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p(!0),t=p(null),s=p(!1),a=p({available:!0,coverage:{},work:{},activity:[],serving:[],tools:[],automation:[]}),n=p("7d"),i=p(0),l=p(Date.now());let o=null,r=null,c=!1,d=0;const u=[{key:"24h",label:"24 hours"},{key:"7d",label:"7 days"},{key:"30d",label:"30 days"},{key:"all",label:"All time"}],f=H(()=>a.value.work||{}),m=A=>A==null?"Not reported":`$${Number(A).toFixed(6)}`,h=H(()=>Math.max(1,...(a.value.activity_over_time||[]).map(A=>Number(A.count||0)))),b=H(()=>({minWidth:`max(100%, ${(a.value.activity_over_time||[]).length*5}px)`})),I=A=>({height:`${Math.max(4,Math.round(Number(A||0)/h.value*100))}%`}),k=H(()=>s.value&&l.value-i.value>3e4);async function y(){const A=++d,R=n.value;try{const w=await z.get(`/api/usage?range=${encodeURIComponent(R)}`);if(A!==d||R!==n.value)return;a.value=w,i.value=Date.now(),l.value=i.value,s.value=!0,t.value=null}catch(w){A===d&&(t.value=w.message)}finally{A===d&&(e.value=!1)}}function g(A){n.value=A,e.value=!s.value,y()}function x(){e.value=!0,y()}function S(){c||(c=!0,y(),o=setInterval(y,15e3),r=setInterval(()=>{l.value=Date.now()},1e3))}function _(){c&&(c=!1,d+=1,o&&clearInterval(o),r&&clearInterval(r),o=null,r=null)}return st(S),rs(S),Kt(_),xt(_),{data:a,work:f,loading:e,error:t,hasData:s,range:n,ranges:u,isStale:k,fmtNum:Nu,fmtDuration:m1,tokenLabel:h1,formatActualCost:m,activityTrackStyle:b,activityBar:I,selectRange:g,retry:x}}},kg=[{id:"audit",label:"Audit",component:c1},{id:"sessions",label:"Sessions",component:u1},{id:"traces",label:"Traces",component:f1},{id:"usage",label:"Usage & Activity",component:v1}],g1={components:{TabbedPage:Xr},setup(){return{tabs:kg}},template:''},Nc=[{id:"system",label:"System & Commands",icon:"terminal",match:e=>/^(run_command|run_script|read_file|apply_patch|list_directory|search_files|manage_process|file_|post_file)/.test(e)},{id:"devops",label:"DevOps & Infrastructure",icon:"server",match:e=>/^(http_probe)/.test(e)},{id:"agents",label:"Agents & Orchestration",icon:"bot",match:e=>/^(spawn_agent|send_to_agent|wait_for_agents|get_agent_results|kill_agent|list_agents)/.test(e)},{id:"workflow",label:"Workflows & Tasks",icon:"workflow",match:e=>/^(delegate_task|cancel_task|list_tasks|schedule_|start_loop|stop_loop|list_loops|delete_schedule|list_schedules|update_schedule|parse_time)/.test(e)},{id:"network",label:"Network & Web",icon:"globe",match:e=>/^(web_|browser_|search_web|fetch_url|http_)/.test(e)},{id:"knowledge",label:"Knowledge & Search",icon:"book",match:e=>/^(search_knowledge|ingest_|knowledge_|search_history|search_audit|bulk_ingest|delete_knowledge|list_knowledge)/.test(e)},{id:"discord",label:"Discord & Admin",icon:"message",match:e=>/^(send_|add_reaction|create_poll|purge_|discord_|embed_|read_channel|set_permission)/.test(e)},{id:"skills",label:"Skills",icon:"puzzle",match:e=>/^(create_skill|edit_skill|delete_skill|enable_skill|disable_skill|install_skill|export_skill|skill_status|invoke_skill|list_skills)/.test(e)},{id:"memory",label:"Memory & State",icon:"brain",match:e=>/^(memory_manage|list_manage)/.test(e)},{id:"ai",label:"AI & Generation",icon:"sparkles",match:e=>/^(generate_|analyze_|vision_)/.test(e)},{id:"integrations",label:"Integrations",icon:"link",match:e=>/^(slack_|grafana_|mcp_)/.test(e)},{id:"other",label:"Other Tools",icon:"wrench",match:()=>!0}],b1={template:` + `,setup(){const e=p(!0),t=p(null),s=p(!1),a=p({available:!0,coverage:{},work:{},activity:[],serving:[],tools:[],automation:[]}),n=p("7d"),i=p(0),l=p(Date.now());let o=null,r=null,c=!1,d=0;const u=[{key:"24h",label:"24 hours"},{key:"7d",label:"7 days"},{key:"30d",label:"30 days"},{key:"all",label:"All time"}],f=U(()=>a.value.work||{}),m=T=>T==null?"Not reported":`$${Number(T).toFixed(6)}`,h=U(()=>Math.max(1,...(a.value.activity_over_time||[]).map(T=>Number(T.count||0)))),g=U(()=>({minWidth:`max(100%, ${(a.value.activity_over_time||[]).length*5}px)`})),A=T=>({height:`${Math.max(4,Math.round(Number(T||0)/h.value*100))}%`}),w=U(()=>s.value&&l.value-i.value>3e4);async function y(){const T=++d,L=n.value;try{const E=await z.get(`/api/usage?range=${encodeURIComponent(L)}`);if(T!==d||L!==n.value)return;a.value=E,i.value=Date.now(),l.value=i.value,s.value=!0,t.value=null}catch(E){T===d&&(t.value=E.message)}finally{T===d&&(e.value=!1)}}function b(T){n.value=T,e.value=!s.value,y()}function x(){e.value=!0,y()}function S(){c||(c=!0,y(),o=setInterval(y,15e3),r=setInterval(()=>{l.value=Date.now()},1e3))}function _(){c&&(c=!1,d+=1,o&&clearInterval(o),r&&clearInterval(r),o=null,r=null)}return tt(S),rs(S),Kt(_),xt(_),{data:a,work:f,loading:e,error:t,hasData:s,range:n,ranges:u,isStale:w,fmtNum:Bu,fmtDuration:S1,tokenLabel:C1,formatActualCost:m,activityTrackStyle:g,activityBar:A,selectRange:b,retry:x}}},Rg=[{id:"audit",label:"Audit",component:y1},{id:"sessions",label:"Sessions",component:_1},{id:"traces",label:"Traces",component:k1},{id:"usage",label:"Usage & Activity",component:T1}],E1={components:{TabbedPage:nc},setup(){return{tabs:Rg}},template:''},Fc=[{id:"system",label:"System & Commands",icon:"terminal",match:e=>/^(run_command|run_script|read_file|apply_patch|list_directory|search_files|manage_process|file_|post_file)/.test(e)},{id:"devops",label:"DevOps & Infrastructure",icon:"server",match:e=>/^(http_probe)/.test(e)},{id:"agents",label:"Agents & Orchestration",icon:"bot",match:e=>/^(spawn_agent|send_to_agent|wait_for_agents|get_agent_results|kill_agent|list_agents)/.test(e)},{id:"workflow",label:"Workflows & Tasks",icon:"workflow",match:e=>/^(delegate_task|cancel_task|list_tasks|schedule_|start_loop|stop_loop|list_loops|delete_schedule|list_schedules|update_schedule|parse_time)/.test(e)},{id:"network",label:"Network & Web",icon:"globe",match:e=>/^(web_|browser_|search_web|fetch_url|http_)/.test(e)},{id:"knowledge",label:"Knowledge & Search",icon:"book",match:e=>/^(search_knowledge|ingest_|knowledge_|search_history|search_audit|bulk_ingest|delete_knowledge|list_knowledge)/.test(e)},{id:"discord",label:"Discord & Admin",icon:"message",match:e=>/^(send_|add_reaction|create_poll|purge_|discord_|embed_|read_channel|set_permission)/.test(e)},{id:"skills",label:"Skills",icon:"puzzle",match:e=>/^(create_skill|edit_skill|delete_skill|enable_skill|disable_skill|install_skill|export_skill|skill_status|invoke_skill|list_skills)/.test(e)},{id:"memory",label:"Memory & State",icon:"brain",match:e=>/^(memory_manage|list_manage)/.test(e)},{id:"ai",label:"AI & Generation",icon:"sparkles",match:e=>/^(generate_|analyze_|vision_)/.test(e)},{id:"integrations",label:"Integrations",icon:"link",match:e=>/^(grafana_|mcp_)/.test(e)},{id:"other",label:"Other Tools",icon:"wrench",match:()=>!0}],A1={template:`
@@ -2557,9 +2572,9 @@ ${u.text}`:u.text).join(` Try a different search term
-
`,setup(){const e=p([]),t=p(!0),s=p(null),a=p(""),n=p({}),i=p({}),l=p("cards"),o=p(null),r=p(null),c=p(!1),d=p(new Set),u={disabled:"Disabled by operator",unavailable:"Unavailable — required backend is not configured",global_disabled:"Global tools disabled"};function f(w){return w.source!=="builtin"?"":u[w.state]||""}function m(w,O){const B=w&&Array.isArray(w.tools)?w.tools:null;if(c.value=!!B,r.value=B?!!w.global_enabled:null,!B){e.value=O.map(Q=>({...Q,source:"unknown",enabled:void 0,state:null}));return}const T=new Set(B.map(Q=>Q.name)),$=O.filter(Q=>!T.has(Q.name)).map(Q=>({...Q,source:Q.name.startsWith("mcp_")?"mcp":"skill",enabled:!0,state:null}));e.value=[...B.map(Q=>({...Q,source:"builtin"})),...$]}async function h(w,O){if(d.value.has(w.name))return;const B=!!O.target.checked,T=new Set(d.value);T.add(w.name),d.value=T;try{const $=await z.post(`/api/tools/builtins/${encodeURIComponent(w.name)}/enabled`,{enabled:B});m($,e.value),s.value=null;try{const Q=await z.get("/api/tools");m($,Q)}catch(Q){console.warn("Built-in toggle committed; visible catalog refresh failed",Q)}}catch($){O.target.checked=!!w.enabled,s.value=$.message||`Failed to toggle ${w.name}`}finally{const $=new Set(d.value);$.delete(w.name),d.value=$}}const b=H(()=>e.value.filter(w=>w.source==="builtin"&&w.is_core).length),I=H(()=>e.value.filter(w=>w.source==="skill").length),k=H(()=>Object.values(n.value).reduce((w,O)=>w+O,0));function y(w){for(const O of Nc)if(O.id!=="other"&&O.match(w))return O.id;return"other"}const g=H(()=>{let w=e.value;if(a.value){const O=a.value.toLowerCase();w=w.filter(B=>B.name.toLowerCase().includes(O)||(B.description||"").toLowerCase().includes(O))}return o.value&&(w=w.filter(O=>y(O.name)===o.value)),w}),x=H(()=>{const w=new Set;for(const O of e.value)w.add(y(O.name));return Nc.filter(O=>w.has(O.id))}),S=H(()=>{const w=g.value,O={};for(const T of w){const $=y(T.name);O[$]||(O[$]=[]),O[$].push(T)}const B=[];for(const T of Nc)O[T.id]&&O[T.id].length>0&&B.push({label:T.label,icon:T.icon,tools:O[T.id].sort(($,Q)=>$.name.localeCompare(Q.name))});return B});function _(w){i.value={...i.value,[w]:!i.value[w]}}async function A(){t.value=!0,s.value=null;try{const[w,O,B]=await Promise.all([z.get("/api/tools"),z.get("/api/tools/stats").catch(()=>({})),z.get("/api/tools/builtins").catch(()=>null)]);m(B,w),n.value=O||{}}catch(w){s.value=w.message}t.value=!1}function R(){A()}return st(()=>{A()}),{tools:e,loading:t,error:s,search:a,stats:n,expanded:i,viewMode:l,activeCategory:o,globalEnabled:r,inventoryAvailable:c,togglePending:d,coreCount:b,skillCount:I,totalUsage:k,filteredTools:g,groupedTools:S,usedCategories:x,stateBadge:f,applyInventory:m,toggleBuiltinTool:h,truncate:Lu,toggleExpand:_,refresh:R}}};function y1(e){if(!e)return"";let t=e.replace(/&/g,"&").replace(//g,">");t=t.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,'$1'),t=t.replace(/(#[^\n]*)/g,'$1');const s="\\b(def|class|return|if|elif|else|for|while|import|from|as|try|except|finally|raise|with|async|await|yield|pass|break|continue|and|or|not|in|is|None|True|False|self|lambda)\\b";t=t.replace(new RegExp(s,"g"),'$1');const a="\\b(print|len|range|str|int|float|list|dict|set|tuple|type|isinstance|hasattr|getattr|setattr|super|property|staticmethod|classmethod|enumerate|zip|map|filter|sorted|reversed|any|all|min|max|sum|abs|round|open|format)\\b";return t=t.replace(new RegExp(a,"g"),'$1'),t=t.replace(/(@\w+)/g,'$1'),t=t.replace(/\b(\d+\.?\d*)\b/g,'$1'),t}function x1(e){if(!e)return"1";const t=e.split(` +
`,setup(){const e=p([]),t=p(!0),s=p(null),a=p(""),n=p({}),i=p({}),l=p("cards"),o=p(null),r=p(null),c=p(!1),d=p(new Set),u={disabled:"Disabled by operator",unavailable:"Unavailable — required backend is not configured",global_disabled:"Global tools disabled"};function f(E){return E.source!=="builtin"?"":u[E.state]||""}function m(E,P){const R=E&&Array.isArray(E.tools)?E.tools:null;if(c.value=!!R,r.value=R?!!E.global_enabled:null,!R){e.value=P.map(X=>({...X,source:"unknown",enabled:void 0,state:null}));return}const I=new Set(R.map(X=>X.name)),H=P.filter(X=>!I.has(X.name)).map(X=>({...X,source:X.name.startsWith("mcp_")?"mcp":"skill",enabled:!0,state:null}));e.value=[...R.map(X=>({...X,source:"builtin"})),...H]}async function h(E,P){if(d.value.has(E.name))return;const R=!!P.target.checked,I=new Set(d.value);I.add(E.name),d.value=I;try{const H=await z.post(`/api/tools/builtins/${encodeURIComponent(E.name)}/enabled`,{enabled:R});m(H,e.value),s.value=null;try{const X=await z.get("/api/tools");m(H,X)}catch(X){console.warn("Built-in toggle committed; visible catalog refresh failed",X)}}catch(H){P.target.checked=!!E.enabled,s.value=H.message||`Failed to toggle ${E.name}`}finally{const H=new Set(d.value);H.delete(E.name),d.value=H}}const g=U(()=>e.value.filter(E=>E.source==="builtin"&&E.is_core).length),A=U(()=>e.value.filter(E=>E.source==="skill").length),w=U(()=>Object.values(n.value).reduce((E,P)=>E+P,0));function y(E){for(const P of Fc)if(P.id!=="other"&&P.match(E))return P.id;return"other"}const b=U(()=>{let E=e.value;if(a.value){const P=a.value.toLowerCase();E=E.filter(R=>R.name.toLowerCase().includes(P)||(R.description||"").toLowerCase().includes(P))}return o.value&&(E=E.filter(P=>y(P.name)===o.value)),E}),x=U(()=>{const E=new Set;for(const P of e.value)E.add(y(P.name));return Fc.filter(P=>E.has(P.id))}),S=U(()=>{const E=b.value,P={};for(const I of E){const H=y(I.name);P[H]||(P[H]=[]),P[H].push(I)}const R=[];for(const I of Fc)P[I.id]&&P[I.id].length>0&&R.push({label:I.label,icon:I.icon,tools:P[I.id].sort((H,X)=>H.name.localeCompare(X.name))});return R});function _(E){i.value={...i.value,[E]:!i.value[E]}}async function T(){t.value=!0,s.value=null;try{const[E,P,R]=await Promise.all([z.get("/api/tools"),z.get("/api/tools/stats").catch(()=>({})),z.get("/api/tools/builtins").catch(()=>null)]);m(R,E),n.value=P||{}}catch(E){s.value=E.message}t.value=!1}function L(){T()}return tt(()=>{T()}),{tools:e,loading:t,error:s,search:a,stats:n,expanded:i,viewMode:l,activeCategory:o,globalEnabled:r,inventoryAvailable:c,togglePending:d,coreCount:g,skillCount:A,totalUsage:w,filteredTools:b,groupedTools:S,usedCategories:x,stateBadge:f,applyInventory:m,toggleBuiltinTool:h,truncate:Fu,toggleExpand:_,refresh:L}}};function R1(e){if(!e)return"";let t=e.replace(/&/g,"&").replace(//g,">");t=t.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,'$1'),t=t.replace(/(#[^\n]*)/g,'$1');const s="\\b(def|class|return|if|elif|else|for|while|import|from|as|try|except|finally|raise|with|async|await|yield|pass|break|continue|and|or|not|in|is|None|True|False|self|lambda)\\b";t=t.replace(new RegExp(s,"g"),'$1');const a="\\b(print|len|range|str|int|float|list|dict|set|tuple|type|isinstance|hasattr|getattr|setattr|super|property|staticmethod|classmethod|enumerate|zip|map|filter|sorted|reversed|any|all|min|max|sum|abs|round|open|format)\\b";return t=t.replace(new RegExp(a,"g"),'$1'),t=t.replace(/(@\w+)/g,'$1'),t=t.replace(/\b(\d+\.?\d*)\b/g,'$1'),t}function I1(e){if(!e)return"1";const t=e.split(` `).length;return Array.from({length:t},(s,a)=>a+1).join(` -`)}const _1={template:` +`)}const O1={template:`

Skills

@@ -2759,10 +2774,10 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p([]),t=p(!0),s=p(null),a=p({}),n=p({}),i=p(null),l=p(""),o=p(null),r=p(!1),c=p("create"),d=p(""),u=p(""),f=p(null),m=p(null),h=p(!1),b=p(null),I=p(null),k=p(!1),y=H(()=>e.value.length),g=H(()=>e.value.reduce((j,ye)=>j+(ye.execution_count||0),0)),x=H(()=>e.value.reduce((j,ye)=>j+O(ye.code),0)),S=H(()=>{if(!l.value)return e.value;const j=l.value.toLowerCase();return e.value.filter(ye=>ye.name.toLowerCase().includes(j)||(ye.description||"").toLowerCase().includes(j))}),_=H(()=>u.value?u.value.split(` -`).length:0),A=H(()=>{const j=Math.max(_.value,1);return Array.from({length:j},(ye,Oe)=>Oe+1).join(` -`)}),R=H(()=>{const j=u.value.trim();return j?j.includes("SKILL_DEFINITION")?j.includes("async def execute")?{valid:!0,message:""}:{valid:!1,message:"Missing async def execute function"}:{valid:!1,message:"Missing SKILL_DEFINITION dict"}:null});function w(j){return y1(j)}function O(j){return j?j.split(` -`).length:0}function B(j){return x1(j)}function T(j){a.value={...a.value,[j]:!a.value[j]}}async function $(j){try{await navigator.clipboard.writeText(j);const ye=e.value.find(Oe=>Oe.code===j);ye&&(o.value=ye.name,setTimeout(()=>{o.value=null},2e3))}catch{}}function Q(j){if(j.key==="Tab"){j.preventDefault();const ye=j.target,Oe=ye.selectionStart,ne=ye.selectionEnd;u.value=u.value.substring(0,Oe)+" "+u.value.substring(ne),zt(()=>{ye.selectionStart=ye.selectionEnd=Oe+4})}}function W(j){const ye=j.target.previousElementSibling;ye&&(ye.scrollTop=j.target.scrollTop)}async function N(){t.value=!0,s.value=null;try{e.value=await z.get("/api/skills")}catch(j){s.value=j.message}t.value=!1}async function L(j){i.value=j,delete n.value[j],n.value={...n.value};try{const ye=await z.post(`/api/skills/${encodeURIComponent(j)}/test`);n.value={...n.value,[j]:ye}}catch(ye){n.value={...n.value,[j]:{result:ye.message,is_error:!0}}}i.value=null}function D(){r.value=!0,c.value="create",d.value="",u.value="",f.value=null,m.value=null}function re(j){r.value=!0,c.value="edit",d.value=j.name,u.value=j.code||"",f.value=null,m.value=null}function ce(){r.value=!1,f.value=null,m.value=null}async function J(){f.value=null,m.value=null;const j=d.value.trim(),ye=u.value.trim();if(!j){f.value="Name is required";return}if(!ye){f.value="Code is required";return}h.value=!0;try{c.value==="create"?(await z.post("/api/skills",{name:j,code:ye}),m.value="Skill created successfully"):(await z.put(`/api/skills/${encodeURIComponent(j)}`,{code:ye}),m.value="Skill updated successfully"),await N(),setTimeout(()=>{r.value=!1},800)}catch(Oe){f.value=Oe.message}h.value=!1}function K(j){I.value=j}async function se(){if(I.value){k.value=!0;try{await z.del(`/api/skills/${encodeURIComponent(I.value)}`),await N()}catch(j){Se.error(`Failed to delete skill: ${j.message||"unknown error"}`)}k.value=!1,I.value=null}}return st(()=>{N()}),{skills:e,loading:t,error:s,showCode:a,testResults:n,testing:i,search:l,copied:o,editing:r,editMode:c,editName:d,editCode:u,editError:f,editSuccess:m,saving:h,editorRef:b,deleteTarget:I,deleting:k,enabledCount:y,totalExecutions:g,totalLines:x,displayedSkills:S,editLineCount:_,editorLineNums:A,editValidation:R,highlight:w,truncate:Lu,formatTs:ni,countLines:O,getLineNumbers:B,toggleCode:T,copyCode:$,handleEditorKey:Q,syncScroll:W,fetchSkills:N,testSkill:L,showCreate:D,editSkill:re,cancelEdit:ce,saveSkill:J,confirmDelete:K,doDelete:se}}};class ta extends Error{constructor(t,s=""){super(t),this.name="MCPFormError",this.field=s}}const w1=/^[A-Za-z_][A-Za-z0-9_]*$/;function Lf(e){return String(e||"").split(/\r?\n/).map(t=>t.trim()).filter(Boolean)}function Nf(e,t,s){const a={},n=[...new Set((t||[]).map(l=>String(l)))],i=new Set(n);for(const l of e||[]){const o=String((l==null?void 0:l.key)||"").trim(),r=String((l==null?void 0:l.value)??"");if(!(!o&&!r)){if(!o)throw new ta(`${s} key is required when a value is entered.`,"authentication");if(/[\r\n\0]/.test(o))throw new ta(`${s} keys cannot contain line breaks or NUL bytes.`,"authentication");if(Object.hasOwn(a,o))throw new ta(`${s} key “${o}” appears more than once.`,"authentication");if(i.has(o))throw new ta(`${s} key “${o}” cannot be replaced and removed in the same save.`,"authentication");a[o]=r}}return{set:a,remove:n}}function k1(e){try{const t=new URL(e);return(t.protocol==="http:"||t.protocol==="https:")&&!!t.hostname}catch{return!1}}function S1(e,{mode:t="add",originalTransport:s=""}={}){const a=t==="add",n=String(e.name||"").trim();if(!n)throw new ta("Server name is required.","name");if(n.length>128||!w1.test(n))throw new ta("Use at most 128 letters, digits, or underscores, with no leading digit.","name");const i=e.transport==="http"?"http":"stdio",l=!a&&!!s&&i!==s,o={enabled:!!e.enabled,transport:i};if(a&&(o.name=n),i==="stdio"){const d=String(e.command||"").trim();if((a||l)&&!d)throw new ta("An executable path is required for a new stdio connection.","command");if(d&&(o.command=d),(a||e.replaceArgs)&&(o.args=Lf(e.argsText)),a||e.replaceCwd){const u=String(e.cwd||"").trim();if(u&&(!u.startsWith("/")||u.includes("\0")))throw new ta("Working directory must be an absolute path.","cwd");o.cwd=u}}else{const d=String(e.url||"").trim();if((a||l)&&!d)throw new ta("An HTTP endpoint is required for this connection.","url");if(d&&!k1(d))throw new ta("Endpoint must be a valid http:// or https:// URL.","url");d&&(o.url=d)}if(a||e.replaceTimeout){const d=Number(e.timeoutSeconds);if(!Number.isInteger(d)||d<1||d>3600)throw new ta("Timeout must be a whole number from 1 to 3600 seconds.","timeout");o.timeout_seconds=d}(a||e.replaceAllowlist)&&(o.tool_allowlist=Lf(e.allowlistText));const r=Nf(e.headerRows,e.headersRemove,"Header"),c=Nf(e.envRows,e.envRemove,"Environment variable");return Object.keys(r.set).length&&(o.headers_set=r.set),r.remove.length&&(o.headers_remove=r.remove),Object.keys(c.set).length&&(o.env_set=c.set),c.remove.length&&(o.env_remove=c.remove),o}function C1(e,t){return t?e.transport!==t.transport||!!e.enabled!=!!t.enabled?!0:Object.keys(e).some(s=>!["enabled","transport"].includes(s)):!1}function T1(e){const t=String(e||"").toLowerCase();return["disabled","connecting","connected","stale","error","blocked"].includes(t)?t:"error"}function E1(e,t){const s=String(t||"").trim().toLowerCase();return s?[e==null?void 0:e.original_name,e==null?void 0:e.published_name,e==null?void 0:e.description,e==null?void 0:e.exclusion_reason].filter(Boolean).some(a=>String(a).toLowerCase().includes(s)):!0}const A1=Object.freeze([{id:"identity",label:"Identity"},{id:"transport",label:"Transport"},{id:"authentication",label:"Authentication"},{id:"limits",label:"Limits"}]);function R1(e,{root:t=document,reducedMotion:s=typeof window<"u"&&(a=>(a=window.matchMedia)==null?void 0:a.call(window,"(prefers-reduced-motion: reduce)").matches)()}={}){var l;const n=t.querySelector(".mcp-editor-groups"),i=n==null?void 0:n.querySelector(`#mcp-form-${e}`);return i?(i.scrollIntoView({behavior:s?"auto":"smooth",block:"start",inline:"nearest"}),(l=i.querySelector("[data-mcp-form-heading]"))==null||l.focus({preventScroll:!0}),!0):!1}const I1=1e4,O1=Object.freeze({disabled:"Disabled",connecting:"Connecting",connected:"Connected",stale:"Stale",error:"Error",blocked:"Blocked"});function Mc(){return{name:"",enabled:!0,transport:"stdio",command:"",argsText:"",cwd:"",url:"",timeoutSeconds:120,allowlistText:"",replaceArgs:!1,replaceCwd:!1,replaceTimeout:!1,replaceAllowlist:!1,headerRows:[],envRows:[],headersRemove:[],envRemove:[]}}function L1(e){if(e==null)return"Never";const t=Math.max(0,Number(e)||0);return t<60?`${Math.round(t)}s ago`:t<3600?`${Math.round(t/60)}m ago`:t<86400?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`}const N1={template:` + `,setup(){const e=p([]),t=p(!0),s=p(null),a=p({}),n=p({}),i=p(null),l=p(""),o=p(null),r=p(!1),c=p("create"),d=p(""),u=p(""),f=p(null),m=p(null),h=p(!1),g=p(null),A=p(null),w=p(!1),y=U(()=>e.value.length),b=U(()=>e.value.reduce((q,ye)=>q+(ye.execution_count||0),0)),x=U(()=>e.value.reduce((q,ye)=>q+P(ye.code),0)),S=U(()=>{if(!l.value)return e.value;const q=l.value.toLowerCase();return e.value.filter(ye=>ye.name.toLowerCase().includes(q)||(ye.description||"").toLowerCase().includes(q))}),_=U(()=>u.value?u.value.split(` +`).length:0),T=U(()=>{const q=Math.max(_.value,1);return Array.from({length:q},(ye,Oe)=>Oe+1).join(` +`)}),L=U(()=>{const q=u.value.trim();return q?q.includes("SKILL_DEFINITION")?q.includes("async def execute")?{valid:!0,message:""}:{valid:!1,message:"Missing async def execute function"}:{valid:!1,message:"Missing SKILL_DEFINITION dict"}:null});function E(q){return R1(q)}function P(q){return q?q.split(` +`).length:0}function R(q){return I1(q)}function I(q){a.value={...a.value,[q]:!a.value[q]}}async function H(q){try{await navigator.clipboard.writeText(q);const ye=e.value.find(Oe=>Oe.code===q);ye&&(o.value=ye.name,setTimeout(()=>{o.value=null},2e3))}catch{}}function X(q){if(q.key==="Tab"){q.preventDefault();const ye=q.target,Oe=ye.selectionStart,le=ye.selectionEnd;u.value=u.value.substring(0,Oe)+" "+u.value.substring(le),Mt(()=>{ye.selectionStart=ye.selectionEnd=Oe+4})}}function Q(q){const ye=q.target.previousElementSibling;ye&&(ye.scrollTop=q.target.scrollTop)}async function N(){t.value=!0,s.value=null;try{e.value=await z.get("/api/skills")}catch(q){s.value=q.message}t.value=!1}async function O(q){i.value=q,delete n.value[q],n.value={...n.value};try{const ye=await z.post(`/api/skills/${encodeURIComponent(q)}/test`);n.value={...n.value,[q]:ye}}catch(ye){n.value={...n.value,[q]:{result:ye.message,is_error:!0}}}i.value=null}function $(){r.value=!0,c.value="create",d.value="",u.value="",f.value=null,m.value=null}function ce(q){r.value=!0,c.value="edit",d.value=q.name,u.value=q.code||"",f.value=null,m.value=null}function ue(){r.value=!1,f.value=null,m.value=null}async function Z(){f.value=null,m.value=null;const q=d.value.trim(),ye=u.value.trim();if(!q){f.value="Name is required";return}if(!ye){f.value="Code is required";return}h.value=!0;try{c.value==="create"?(await z.post("/api/skills",{name:q,code:ye}),m.value="Skill created successfully"):(await z.put(`/api/skills/${encodeURIComponent(q)}`,{code:ye}),m.value="Skill updated successfully"),await N(),setTimeout(()=>{r.value=!1},800)}catch(Oe){f.value=Oe.message}h.value=!1}function J(q){A.value=q}async function se(){if(A.value){w.value=!0;try{await z.del(`/api/skills/${encodeURIComponent(A.value)}`),await N()}catch(q){ke.error(`Failed to delete skill: ${q.message||"unknown error"}`)}w.value=!1,A.value=null}}return tt(()=>{N()}),{skills:e,loading:t,error:s,showCode:a,testResults:n,testing:i,search:l,copied:o,editing:r,editMode:c,editName:d,editCode:u,editError:f,editSuccess:m,saving:h,editorRef:g,deleteTarget:A,deleting:w,enabledCount:y,totalExecutions:b,totalLines:x,displayedSkills:S,editLineCount:_,editorLineNums:T,editValidation:L,highlight:E,truncate:Fu,formatTs:ii,countLines:P,getLineNumbers:R,toggleCode:I,copyCode:H,handleEditorKey:X,syncScroll:Q,fetchSkills:N,testSkill:O,showCreate:$,editSkill:ce,cancelEdit:ue,saveSkill:Z,confirmDelete:J,doDelete:se}}};class ta extends Error{constructor(t,s=""){super(t),this.name="MCPFormError",this.field=s}}const L1=/^[A-Za-z_][A-Za-z0-9_]*$/;function Ff(e){return String(e||"").split(/\r?\n/).map(t=>t.trim()).filter(Boolean)}function Bf(e,t,s){const a={},n=[...new Set((t||[]).map(l=>String(l)))],i=new Set(n);for(const l of e||[]){const o=String((l==null?void 0:l.key)||"").trim(),r=String((l==null?void 0:l.value)??"");if(!(!o&&!r)){if(!o)throw new ta(`${s} key is required when a value is entered.`,"authentication");if(/[\r\n\0]/.test(o))throw new ta(`${s} keys cannot contain line breaks or NUL bytes.`,"authentication");if(Object.hasOwn(a,o))throw new ta(`${s} key “${o}” appears more than once.`,"authentication");if(i.has(o))throw new ta(`${s} key “${o}” cannot be replaced and removed in the same save.`,"authentication");a[o]=r}}return{set:a,remove:n}}function N1(e){try{const t=new URL(e);return(t.protocol==="http:"||t.protocol==="https:")&&!!t.hostname}catch{return!1}}function M1(e,{mode:t="add",originalTransport:s=""}={}){const a=t==="add",n=String(e.name||"").trim();if(!n)throw new ta("Server name is required.","name");if(n.length>128||!L1.test(n))throw new ta("Use at most 128 letters, digits, or underscores, with no leading digit.","name");const i=e.transport==="http"?"http":"stdio",l=!a&&!!s&&i!==s,o={enabled:!!e.enabled,transport:i};if(a&&(o.name=n),i==="stdio"){const d=String(e.command||"").trim();if((a||l)&&!d)throw new ta("An executable path is required for a new stdio connection.","command");if(d&&(o.command=d),(a||e.replaceArgs)&&(o.args=Ff(e.argsText)),a||e.replaceCwd){const u=String(e.cwd||"").trim();if(u&&(!u.startsWith("/")||u.includes("\0")))throw new ta("Working directory must be an absolute path.","cwd");o.cwd=u}}else{const d=String(e.url||"").trim();if((a||l)&&!d)throw new ta("An HTTP endpoint is required for this connection.","url");if(d&&!N1(d))throw new ta("Endpoint must be a valid http:// or https:// URL.","url");d&&(o.url=d)}if(a||e.replaceTimeout){const d=Number(e.timeoutSeconds);if(!Number.isInteger(d)||d<1||d>3600)throw new ta("Timeout must be a whole number from 1 to 3600 seconds.","timeout");o.timeout_seconds=d}(a||e.replaceAllowlist)&&(o.tool_allowlist=Ff(e.allowlistText));const r=Bf(e.headerRows,e.headersRemove,"Header"),c=Bf(e.envRows,e.envRemove,"Environment variable");return Object.keys(r.set).length&&(o.headers_set=r.set),r.remove.length&&(o.headers_remove=r.remove),Object.keys(c.set).length&&(o.env_set=c.set),c.remove.length&&(o.env_remove=c.remove),o}function D1(e,t){return t?e.transport!==t.transport||!!e.enabled!=!!t.enabled?!0:Object.keys(e).some(s=>!["enabled","transport"].includes(s)):!1}function P1(e){const t=String(e||"").toLowerCase();return["disabled","connecting","connected","stale","error","blocked"].includes(t)?t:"error"}function $1(e,t){const s=String(t||"").trim().toLowerCase();return s?[e==null?void 0:e.original_name,e==null?void 0:e.published_name,e==null?void 0:e.description,e==null?void 0:e.exclusion_reason].filter(Boolean).some(a=>String(a).toLowerCase().includes(s)):!0}const F1=Object.freeze([{id:"identity",label:"Identity"},{id:"transport",label:"Transport"},{id:"authentication",label:"Authentication"},{id:"limits",label:"Limits"}]);function B1(e,{root:t=document,reducedMotion:s=typeof window<"u"&&(a=>(a=window.matchMedia)==null?void 0:a.call(window,"(prefers-reduced-motion: reduce)").matches)()}={}){var l;const n=t.querySelector(".mcp-editor-groups"),i=n==null?void 0:n.querySelector(`#mcp-form-${e}`);return i?(i.scrollIntoView({behavior:s?"auto":"smooth",block:"start",inline:"nearest"}),(l=i.querySelector("[data-mcp-form-heading]"))==null||l.focus({preventScroll:!0}),!0):!1}const U1=1e4,z1=Object.freeze({disabled:"Disabled",connecting:"Connecting",connected:"Connected",stale:"Stale",error:"Error",blocked:"Blocked"});function Bc(){return{name:"",enabled:!0,transport:"stdio",command:"",argsText:"",cwd:"",url:"",timeoutSeconds:120,allowlistText:"",replaceArgs:!1,replaceCwd:!1,replaceTimeout:!1,replaceAllowlist:!1,headerRows:[],envRows:[],headersRemove:[],envRemove:[]}}function H1(e){if(e==null)return"Never";const t=Math.max(0,Number(e)||0);return t<60?`${Math.round(t)}s ago`:t<3600?`${Math.round(t/60)}m ago`:t<86400?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`}const j1={template:`
@@ -3046,7 +3061,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p(null),t=p(!1),s=p(!1),a=p(!1),n=p(""),i=p({max_published_tools_per_server:"",max_published_tools_global:""}),l=p(new Set),o=H(()=>Object.keys(i.value).every(Z=>{var Ee;return Number.isInteger((Ee=e.value)==null?void 0:Ee[Z])})),r=p(""),c=p(new Set),d=p(new Set),u=p({}),f=p({}),m=p({}),h=p(new Set),b=p(!1),I=p("add"),k=p(""),y=p(null),g=p(Mc()),x=p(""),S=p(!1);let _=null,A=0,R=!1,w=!1;const O=A1,B=H(()=>{var Z;return((Z=e.value)==null?void 0:Z.servers)||[]}),T=H(()=>{var Z;return!!((Z=e.value)!=null&&Z.enabled)}),$=H(()=>{var Z,Ee,Ae,Me;return{serverCount:((Z=e.value)==null?void 0:Z.server_count)||0,enabledCount:((Ee=e.value)==null?void 0:Ee.enabled_server_count)||0,connectedCount:((Ae=e.value)==null?void 0:Ae.connected_count)||0,toolCount:((Me=e.value)==null?void 0:Me.published_tool_count)||0}}),Q=H(()=>{var Z;return((Z=y.value)==null?void 0:Z.header_keys)||[]}),W=H(()=>{var Z;return((Z=y.value)==null?void 0:Z.env_keys)||[]}),N=H(()=>{var Z;return I.value==="edit"&&((Z=y.value)==null?void 0:Z.transport)==="http"}),L=H(()=>I.value==="add"||!N.value),D=H(()=>N.value?"Replace endpoint URL":"Endpoint URL"),re=H(()=>N.value?"Leave blank to keep the saved endpoint":"https://mcp.example.com/mcp");function ce(){J(),_=window.setInterval(()=>K({quiet:!0}),I1)}function J(){_&&window.clearInterval(_),_=null}async function K({quiet:Z=!1}={}){if(a.value)return;const Ee=++A;Z||(t.value=!0);try{const Ae=await z.get("/api/mcp/status");if(Ee!==A||!R)return;e.value=Ae;for(const ee of Object.keys(i.value))!l.value.has(ee)&&Number.isInteger(Ae[ee])&&(i.value[ee]=String(Ae[ee]));r.value="";const Me=new Set((Ae.servers||[]).map(ee=>ee.name));d.value=new Set([...d.value].filter(ee=>Me.has(ee)))}catch(Ae){Ee===A&&R&&(r.value=Ae.message||"Failed to load MCP status")}finally{Ee===A&&(t.value=!1)}}function se(Z){return s.value||c.value.has(Z)}function j(Z,Ee){const Ae=new Set(c.value);Ee?Ae.add(Z):Ae.delete(Z),c.value=Ae}function ye(Z){return T1(Z.state)}function Oe(Z){if(ye(Z)==="disabled"){if(!Z.enabled)return"Disabled — server switch off";if(!T.value)return"Disabled — global MCP is off"}return O1[ye(Z)]}function ne(Z){return Z.transport==="http"?"Streamable HTTP":"stdio"}function ie(Z){return Z.negotiated_version?`${Z.era?`${String(Z.era).charAt(0).toUpperCase()}${String(Z.era).slice(1)}`:"Protocol"} · ${Z.negotiated_version}`:"Not negotiated"}function ve(Z){return Z.discovered_count?`${Z.published_count||0} published · ${Z.excluded_count||0} excluded`:"No tools discovered"}const be=p(new Set);async function ge(Z,Ee){if(be.value.has(Z.name))return;const Ae=!!Ee.target.checked,Me=new Set(be.value);Me.add(Z.name),be.value=Me;try{const ee=await z.post(`/api/mcp/servers/${encodeURIComponent(Z.name)}/enabled`,{enabled:Ae});ee&&Array.isArray(ee.servers)?e.value=ee:await K({quiet:!0})}catch(ee){Ee.target.checked=!!Z.enabled,Se.error(ee.message||`Failed to toggle ${Z.name}`)}finally{const ee=new Set(be.value);ee.delete(Z.name),be.value=ee}}function Ne(Z,Ee){var Me;i.value[Z]=Ee;const Ae=new Set(l.value);Ee===String((Me=e.value)==null?void 0:Me[Z])?Ae.delete(Z):Ae.add(Z),l.value=Ae,n.value=""}async function E(){if(s.value||!o.value||!l.value.size)return;const Z={};for(const Ee of l.value){const Ae=Number(i.value[Ee]),Me=Ee==="max_published_tools_per_server"?128:256;if(!Number.isInteger(Ae)||Ae<1||Ae>Me){n.value=`Enter a whole number between 1 and ${Me}.`;return}Z[Ee]=Ae}a.value=!0,s.value=!0,n.value="",++A,t.value=!1;try{const Ee=await z.post("/api/mcp/limits",Z);e.value=Ee;for(const Ae of Object.keys(i.value))Number.isInteger(Ee[Ae])&&(i.value[Ae]=String(Ee[Ae]));l.value=new Set,Se.success("MCP limits saved. Applied at the next publication or tools refresh.")}catch(Ee){n.value=Ee.message||"Failed to save MCP publication limits"}finally{a.value=!1,s.value=!1,await K({quiet:!0})}}async function P(Z){if(Z!==T.value&&!(!Z&&!await os({title:"Disable MCP tool publication",message:"Disable MCP globally? All MCP tools will be unpublished immediately and active transports will be stopped. Saved server configuration remains.",confirmLabel:"Disable MCP",danger:!0}))){s.value=!0;try{await z.post("/api/mcp/enabled",{enabled:Z}),Se.success(Z?"MCP enabled":"MCP disabled"),await K({quiet:!0})}catch(Ee){Se.error(Ee.message||"Failed to update MCP state"),await K({quiet:!0})}finally{s.value=!1}}}async function G(Z){j(Z.name,!0);try{await z.post(`/api/mcp/servers/${encodeURIComponent(Z.name)}/reconnect`,{}),Se.success(`Reconnected ${Z.name}`)}catch(Ee){Se.error(Ee.message||`Failed to reconnect ${Z.name}`)}finally{j(Z.name,!1),await K({quiet:!0})}}async function me(Z){j(Z.name,!0);try{await z.post(`/api/mcp/servers/${encodeURIComponent(Z.name)}/refresh-tools`,{}),Se.success(`Refreshed tools from ${Z.name}`),await _e(Z.name,!0)}catch(Ee){Se.error(Ee.message||`Failed to refresh ${Z.name}`)}finally{j(Z.name,!1),await K({quiet:!0})}}async function de(Z){if(await os({title:`Remove ${Z.name}`,message:`Remove this saved MCP server? Its ${Z.published_count||0} published tool${Z.published_count===1?"":"s"} will disappear immediately and configured authentication keys will be deleted. This cannot be undone.`,confirmLabel:"Remove server",danger:!0})){j(Z.name,!0);try{await z.del(`/api/mcp/servers/${encodeURIComponent(Z.name)}`),Se.success(`Removed ${Z.name}`),delete f.value[Z.name]}catch(Ae){Se.error(Ae.message||`Failed to remove ${Z.name}`)}finally{j(Z.name,!1),await K({quiet:!0})}}}async function fe(Z){const Ee=new Set(d.value);if(Ee.has(Z.name)){Ee.delete(Z.name),d.value=Ee;return}Ee.add(Z.name),d.value=Ee,Object.hasOwn(f.value,Z.name)||await _e(Z.name)}async function _e(Z,Ee=!1){if(!Ee&&Object.hasOwn(f.value,Z))return;const Ae=new Set(h.value);Ae.add(Z),h.value=Ae,m.value={...m.value,[Z]:""};try{const Me=await z.get(`/api/mcp/servers/${encodeURIComponent(Z)}/tools`);f.value={...f.value,[Z]:Me.tools||[]}}catch(Me){m.value={...m.value,[Z]:Me.message||"Failed to load tools"}}finally{const Me=new Set(h.value);Me.delete(Z),h.value=Me}}function q(Z){return(f.value[Z]||[]).filter(Ee=>E1(Ee,u.value[Z]))}function te(Z,Ee){u.value={...u.value,[Z]:Ee}}function X(){I.value="add",k.value="",y.value=null,g.value=Mc(),x.value="",b.value=!0}function xe(Z){I.value="edit",k.value=Z.name,y.value=Z,g.value={...Mc(),name:Z.name,enabled:!!Z.enabled,transport:Z.transport||"stdio"},x.value="",b.value=!0}function U(){S.value||(b.value=!1)}function ue(Z){b.value&&R1(Z)}function oe(Z){const Ee=Z==="headers"?"headerRows":"envRows";g.value[Ee].push({key:"",value:""})}function he(Z,Ee){const Ae=Z==="headers"?"headerRows":"envRows";g.value[Ae].splice(Ee,1)}function Fe(Z,Ee){const Ae=Z==="headers"?"headersRemove":"envRemove",Me=g.value[Ae];g.value[Ae]=Me.includes(Ee)?Me.filter(ee=>ee!==Ee):[...Me,Ee]}async function Ve(){var Ee,Ae;x.value="";let Z;try{Z=S1(g.value,{mode:I.value,originalTransport:((Ee=y.value)==null?void 0:Ee.transport)||""})}catch(Me){x.value=Me instanceof ta?Me.message:"Invalid MCP server configuration",await zt(),(Ae=document.querySelector(".mcp-editor"))==null||Ae.scrollTo({top:0,behavior:"smooth"});return}if(!(I.value==="edit"&&C1(Z,y.value)&&!await os({title:`Change ${k.value} connection`,message:"Saving this configuration replaces the server runtime. Any current connection will be retired and its tools unpublished; enabled servers reconnect after the change.",confirmLabel:"Save and reconnect",danger:!0}))){S.value=!0;try{I.value==="add"?await z.post("/api/mcp/servers",Z):await z.put(`/api/mcp/servers/${encodeURIComponent(k.value)}`,Z),Se.success(I.value==="add"?`Saved ${Z.name}`:`Updated ${k.value}`),b.value=!1,await K({quiet:!0})}catch(Me){x.value=Me.message||"Failed to save MCP server"}finally{S.value=!1}}}let $e=null;function Ke(Z){`${(Z==null?void 0:Z.event)||""} ${(Z==null?void 0:Z.type)||""} ${(Z==null?void 0:Z.tool)||""} ${(Z==null?void 0:Z.message)||""}`.toLowerCase().includes("mcp")&&($e&&window.clearTimeout($e),$e=window.setTimeout(()=>K({quiet:!0}),200))}function at(){R||(R=!0,w||(ut.subscribe("events",Ke),w=!0),K(),ce())}function Xe(){R=!1,J(),$e&&window.clearTimeout($e),$e=null,w&&(ut.unsubscribe("events",Ke),w=!1)}return st(at),rs(at),Kt(Xe),xt(Xe),{status:e,loading:t,mutating:s,pageError:r,servers:B,masterEnabled:T,aggregate:$,limitsSaving:a,limitsError:n,limitsAvailable:o,limitDraft:i,limitDirty:l,editLimit:Ne,saveLimits:E,expandedServers:d,toolQueries:u,toolErrors:m,toolsLoading:h,editorOpen:b,editorMode:I,editingName:k,editingServer:y,form:g,formError:x,saving:S,editorGroups:O,configuredHeaderKeys:Q,configuredEnvKeys:W,savedHttpEndpoint:N,endpointRequired:L,endpointFieldLabel:D,endpointPlaceholder:re,refreshAll:K,busy:se,serverState:ye,stateLabel:Oe,transportLabel:ne,protocolLabel:ie,toolSummary:ve,formatAge:L1,setMasterEnabled:P,togglePending:be,toggleServerEnabled:ge,reconnect:G,refreshTools:me,removeServer:de,toggleTools:fe,filteredTools:q,setToolQuery:te,openAdd:X,openEdit:xe,closeEditor:U,jumpToEditorGroup:ue,addSecretRow:oe,removeSecretRow:he,toggleSecretRemoval:Fe,saveServer:Ve}}};function M1(e,t){if(!e||!t)return Tf(e);const s=Tf(e),a=t.trim().split(/\s+/).filter(Boolean);if(!a.length)return s;const n=a.map(i=>i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");try{return s.replace(new RegExp(`(${n})`,"gi"),'$1')}catch{return s}}const D1={template:` + `,setup(){const e=p(null),t=p(!1),s=p(!1),a=p(!1),n=p(""),i=p({max_published_tools_per_server:"",max_published_tools_global:""}),l=p(new Set),o=U(()=>Object.keys(i.value).every(Y=>{var Ae;return Number.isInteger((Ae=e.value)==null?void 0:Ae[Y])})),r=p(""),c=p(new Set),d=p(new Set),u=p({}),f=p({}),m=p({}),h=p(new Set),g=p(!1),A=p("add"),w=p(""),y=p(null),b=p(Bc()),x=p(""),S=p(!1);let _=null,T=0,L=!1,E=!1;const P=F1,R=U(()=>{var Y;return((Y=e.value)==null?void 0:Y.servers)||[]}),I=U(()=>{var Y;return!!((Y=e.value)!=null&&Y.enabled)}),H=U(()=>{var Y,Ae,Re,Me;return{serverCount:((Y=e.value)==null?void 0:Y.server_count)||0,enabledCount:((Ae=e.value)==null?void 0:Ae.enabled_server_count)||0,connectedCount:((Re=e.value)==null?void 0:Re.connected_count)||0,toolCount:((Me=e.value)==null?void 0:Me.published_tool_count)||0}}),X=U(()=>{var Y;return((Y=y.value)==null?void 0:Y.header_keys)||[]}),Q=U(()=>{var Y;return((Y=y.value)==null?void 0:Y.env_keys)||[]}),N=U(()=>{var Y;return A.value==="edit"&&((Y=y.value)==null?void 0:Y.transport)==="http"}),O=U(()=>A.value==="add"||!N.value),$=U(()=>N.value?"Replace endpoint URL":"Endpoint URL"),ce=U(()=>N.value?"Leave blank to keep the saved endpoint":"https://mcp.example.com/mcp");function ue(){Z(),_=window.setInterval(()=>J({quiet:!0}),U1)}function Z(){_&&window.clearInterval(_),_=null}async function J({quiet:Y=!1}={}){if(a.value)return;const Ae=++T;Y||(t.value=!0);try{const Re=await z.get("/api/mcp/status");if(Ae!==T||!L)return;e.value=Re;for(const ae of Object.keys(i.value))!l.value.has(ae)&&Number.isInteger(Re[ae])&&(i.value[ae]=String(Re[ae]));r.value="";const Me=new Set((Re.servers||[]).map(ae=>ae.name));d.value=new Set([...d.value].filter(ae=>Me.has(ae)))}catch(Re){Ae===T&&L&&(r.value=Re.message||"Failed to load MCP status")}finally{Ae===T&&(t.value=!1)}}function se(Y){return s.value||c.value.has(Y)}function q(Y,Ae){const Re=new Set(c.value);Ae?Re.add(Y):Re.delete(Y),c.value=Re}function ye(Y){return P1(Y.state)}function Oe(Y){if(ye(Y)==="disabled"){if(!Y.enabled)return"Disabled — server switch off";if(!I.value)return"Disabled — global MCP is off"}return z1[ye(Y)]}function le(Y){return Y.transport==="http"?"Streamable HTTP":"stdio"}function oe(Y){return Y.negotiated_version?`${Y.era?`${String(Y.era).charAt(0).toUpperCase()}${String(Y.era).slice(1)}`:"Protocol"} · ${Y.negotiated_version}`:"Not negotiated"}function ve(Y){return Y.discovered_count?`${Y.published_count||0} published · ${Y.excluded_count||0} excluded`:"No tools discovered"}const j=p(new Set);async function V(Y,Ae){if(j.value.has(Y.name))return;const Re=!!Ae.target.checked,Me=new Set(j.value);Me.add(Y.name),j.value=Me;try{const ae=await z.post(`/api/mcp/servers/${encodeURIComponent(Y.name)}/enabled`,{enabled:Re});ae&&Array.isArray(ae.servers)?e.value=ae:await J({quiet:!0})}catch(ae){Ae.target.checked=!!Y.enabled,ke.error(ae.message||`Failed to toggle ${Y.name}`)}finally{const ae=new Set(j.value);ae.delete(Y.name),j.value=ae}}function xe(Y,Ae){var Me;i.value[Y]=Ae;const Re=new Set(l.value);Ae===String((Me=e.value)==null?void 0:Me[Y])?Re.delete(Y):Re.add(Y),l.value=Re,n.value=""}async function k(){if(s.value||!o.value||!l.value.size)return;const Y={};for(const Ae of l.value){const Re=Number(i.value[Ae]),Me=Ae==="max_published_tools_per_server"?128:256;if(!Number.isInteger(Re)||Re<1||Re>Me){n.value=`Enter a whole number between 1 and ${Me}.`;return}Y[Ae]=Re}a.value=!0,s.value=!0,n.value="",++T,t.value=!1;try{const Ae=await z.post("/api/mcp/limits",Y);e.value=Ae;for(const Re of Object.keys(i.value))Number.isInteger(Ae[Re])&&(i.value[Re]=String(Ae[Re]));l.value=new Set,ke.success("MCP limits saved. Applied at the next publication or tools refresh.")}catch(Ae){n.value=Ae.message||"Failed to save MCP publication limits"}finally{a.value=!1,s.value=!1,await J({quiet:!0})}}async function D(Y){if(Y!==I.value&&!(!Y&&!await os({title:"Disable MCP tool publication",message:"Disable MCP globally? All MCP tools will be unpublished immediately and active transports will be stopped. Saved server configuration remains.",confirmLabel:"Disable MCP",danger:!0}))){s.value=!0;try{await z.post("/api/mcp/enabled",{enabled:Y}),ke.success(Y?"MCP enabled":"MCP disabled"),await J({quiet:!0})}catch(Ae){ke.error(Ae.message||"Failed to update MCP state"),await J({quiet:!0})}finally{s.value=!1}}}async function W(Y){q(Y.name,!0);try{await z.post(`/api/mcp/servers/${encodeURIComponent(Y.name)}/reconnect`,{}),ke.success(`Reconnected ${Y.name}`)}catch(Ae){ke.error(Ae.message||`Failed to reconnect ${Y.name}`)}finally{q(Y.name,!1),await J({quiet:!0})}}async function ge(Y){q(Y.name,!0);try{await z.post(`/api/mcp/servers/${encodeURIComponent(Y.name)}/refresh-tools`,{}),ke.success(`Refreshed tools from ${Y.name}`),await we(Y.name,!0)}catch(Ae){ke.error(Ae.message||`Failed to refresh ${Y.name}`)}finally{q(Y.name,!1),await J({quiet:!0})}}async function pe(Y){if(await os({title:`Remove ${Y.name}`,message:`Remove this saved MCP server? Its ${Y.published_count||0} published tool${Y.published_count===1?"":"s"} will disappear immediately and configured authentication keys will be deleted. This cannot be undone.`,confirmLabel:"Remove server",danger:!0})){q(Y.name,!0);try{await z.del(`/api/mcp/servers/${encodeURIComponent(Y.name)}`),ke.success(`Removed ${Y.name}`),delete f.value[Y.name]}catch(Re){ke.error(Re.message||`Failed to remove ${Y.name}`)}finally{q(Y.name,!1),await J({quiet:!0})}}}async function he(Y){const Ae=new Set(d.value);if(Ae.has(Y.name)){Ae.delete(Y.name),d.value=Ae;return}Ae.add(Y.name),d.value=Ae,Object.hasOwn(f.value,Y.name)||await we(Y.name)}async function we(Y,Ae=!1){if(!Ae&&Object.hasOwn(f.value,Y))return;const Re=new Set(h.value);Re.add(Y),h.value=Re,m.value={...m.value,[Y]:""};try{const Me=await z.get(`/api/mcp/servers/${encodeURIComponent(Y)}/tools`);f.value={...f.value,[Y]:Me.tools||[]}}catch(Me){m.value={...m.value,[Y]:Me.message||"Failed to load tools"}}finally{const Me=new Set(h.value);Me.delete(Y),h.value=Me}}function K(Y){return(f.value[Y]||[]).filter(Ae=>$1(Ae,u.value[Y]))}function ne(Y,Ae){u.value={...u.value,[Y]:Ae}}function te(){A.value="add",w.value="",y.value=null,b.value=Bc(),x.value="",g.value=!0}function _e(Y){A.value="edit",w.value=Y.name,y.value=Y,b.value={...Bc(),name:Y.name,enabled:!!Y.enabled,transport:Y.transport||"stdio"},x.value="",g.value=!0}function B(){S.value||(g.value=!1)}function fe(Y){g.value&&B1(Y)}function de(Y){const Ae=Y==="headers"?"headerRows":"envRows";b.value[Ae].push({key:"",value:""})}function be(Y,Ae){const Re=Y==="headers"?"headerRows":"envRows";b.value[Re].splice(Ae,1)}function Fe(Y,Ae){const Re=Y==="headers"?"headersRemove":"envRemove",Me=b.value[Re];b.value[Re]=Me.includes(Ae)?Me.filter(ae=>ae!==Ae):[...Me,Ae]}async function qe(){var Ae,Re;x.value="";let Y;try{Y=M1(b.value,{mode:A.value,originalTransport:((Ae=y.value)==null?void 0:Ae.transport)||""})}catch(Me){x.value=Me instanceof ta?Me.message:"Invalid MCP server configuration",await Mt(),(Re=document.querySelector(".mcp-editor"))==null||Re.scrollTo({top:0,behavior:"smooth"});return}if(!(A.value==="edit"&&D1(Y,y.value)&&!await os({title:`Change ${w.value} connection`,message:"Saving this configuration replaces the server runtime. Any current connection will be retired and its tools unpublished; enabled servers reconnect after the change.",confirmLabel:"Save and reconnect",danger:!0}))){S.value=!0;try{A.value==="add"?await z.post("/api/mcp/servers",Y):await z.put(`/api/mcp/servers/${encodeURIComponent(w.value)}`,Y),ke.success(A.value==="add"?`Saved ${Y.name}`:`Updated ${w.value}`),g.value=!1,await J({quiet:!0})}catch(Me){x.value=Me.message||"Failed to save MCP server"}finally{S.value=!1}}}let $e=null;function Ke(Y){`${(Y==null?void 0:Y.event)||""} ${(Y==null?void 0:Y.type)||""} ${(Y==null?void 0:Y.tool)||""} ${(Y==null?void 0:Y.message)||""}`.toLowerCase().includes("mcp")&&($e&&window.clearTimeout($e),$e=window.setTimeout(()=>J({quiet:!0}),200))}function at(){L||(L=!0,E||(ut.subscribe("events",Ke),E=!0),J(),ue())}function Xe(){L=!1,Z(),$e&&window.clearTimeout($e),$e=null,E&&(ut.unsubscribe("events",Ke),E=!1)}return tt(at),rs(at),Kt(Xe),xt(Xe),{status:e,loading:t,mutating:s,pageError:r,servers:R,masterEnabled:I,aggregate:H,limitsSaving:a,limitsError:n,limitsAvailable:o,limitDraft:i,limitDirty:l,editLimit:xe,saveLimits:k,expandedServers:d,toolQueries:u,toolErrors:m,toolsLoading:h,editorOpen:g,editorMode:A,editingName:w,editingServer:y,form:b,formError:x,saving:S,editorGroups:P,configuredHeaderKeys:X,configuredEnvKeys:Q,savedHttpEndpoint:N,endpointRequired:O,endpointFieldLabel:$,endpointPlaceholder:ce,refreshAll:J,busy:se,serverState:ye,stateLabel:Oe,transportLabel:le,protocolLabel:oe,toolSummary:ve,formatAge:H1,setMasterEnabled:D,togglePending:j,toggleServerEnabled:V,reconnect:W,refreshTools:ge,removeServer:pe,toggleTools:he,filteredTools:K,setToolQuery:ne,openAdd:te,openEdit:_e,closeEditor:B,jumpToEditorGroup:fe,addSecretRow:de,removeSecretRow:be,toggleSecretRemoval:Fe,saveServer:qe}}};function q1(e,t){if(!e||!t)return Lf(e);const s=Lf(e),a=t.trim().split(/\s+/).filter(Boolean);if(!a.length)return s;const n=a.map(i=>i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");try{return s.replace(new RegExp(`(${n})`,"gi"),'$1')}catch{return s}}const V1={template:`

Knowledge

@@ -3241,7 +3256,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(""),n=p(null),i=p(!1),l=p(""),o=p(null),r=p(!1),c=p(""),d=p(""),u=p(null),f=p(null),m=p("success"),h=p(!1),b=p(null),I=p(null);let k=null;const y=p(null),g=p(!1),x=p({}),S=p({}),_=p({}),A=p({}),R=new Map,w=p(null),O=H(()=>e.value.reduce((K,se)=>K+(se.chunks||0),0)),B=H(()=>new Set(e.value.map(se=>se.uploader).filter(Boolean)).size);function T(K,se){const j=S.value[se];if(!j||j.length===0)return 0;const ye=Math.max(...j.map(Oe=>Oe.char_count||0));return ye===0?0:Math.round(K.char_count/ye*100)}async function $(){t.value=!0,s.value=null;try{const K=await z.get("/api/knowledge");e.value=Array.isArray(K)?K:[]}catch(K){s.value=K.message}t.value=!1}async function Q(K){if(x.value[K]){x.value[K]=!1,w.value=null;return}if(x.value[K]=!0,Object.prototype.hasOwnProperty.call(S.value,K))return;if(R.has(K))return R.get(K);const se={...A.value,[K]:!0};A.value=se;const j={..._.value};delete j[K],_.value=j;const ye=z.get(`/api/knowledge/${encodeURIComponent(K)}/chunks`).then(Oe=>{S.value={...S.value,[K]:Array.isArray(Oe)?Oe:[]}}).catch(Oe=>{_.value={..._.value,[K]:Oe.message||"load failed"}}).finally(()=>{if(R.get(K)!==ye)return;R.delete(K);const Oe={...A.value};delete Oe[K],A.value=Oe});return R.set(K,ye),ye}let W=0;async function N(){const K=a.value.trim();if(!K)return;const se=++W;i.value=!0,o.value=null,l.value=K;try{const j=await z.get(`/api/knowledge/search?q=${encodeURIComponent(K)}`);if(se!==W)return;n.value=Array.isArray(j)?j:[]}catch(j){if(se!==W)return;n.value=[],o.value=j.message||"Search failed"}se===W&&(i.value=!1)}function L(){W+=1,i.value=!1,n.value=null,a.value="",o.value=null}async function D(){u.value=null,f.value=null,m.value="success";const K=c.value.trim(),se=d.value.trim();if(!K){u.value="Source name is required";return}if(!se){u.value="Content is required";return}h.value=!0;try{const j=await z.post("/api/knowledge",{source:K,content:se}),ye=j.outcome==="duplicate"||j.outcome==="conflict";j.outcome==="unchanged"?(f.value=`Already stored unchanged: "${K}" (${j.chunks} chunks)`,c.value="",d.value=""):ye?(m.value="warning",f.value=j.message||j.status):Number.isInteger(j.chunks)&&j.chunks>0?(f.value=`Ingested ${j.chunks} chunks from "${K}"`,c.value="",d.value=""):(m.value="warning",f.value="Ingestion returned no confirmed stored content; check the source before retrying."),S.value={},await $(),!ye&&m.value==="success"&&setTimeout(()=>{r.value=!1,f.value=null},1500)}catch(j){u.value=j.message}h.value=!1}async function re(K){b.value=K,I.value=null,k&&(clearTimeout(k),k=null);try{const se=await z.post(`/api/knowledge/${encodeURIComponent(K)}/reingest`),j=se.outcome==="duplicate"||se.outcome==="conflict";I.value={source:K,error:!1,warning:j,message:j?se.message||se.status:se.outcome==="unchanged"?`Already stored unchanged (${se.chunks} chunks)`:`Re-ingested ${se.chunks||0} chunks`},j||(delete S.value[K],await $()),k=setTimeout(()=>{I.value=null,k=null},3e3)}catch(se){I.value={source:K,error:!0,message:se.message}}b.value=null}function ce(K){y.value=K}async function J(){if(y.value){g.value=!0;try{await z.del(`/api/knowledge/${encodeURIComponent(y.value)}`),delete S.value[y.value],await $()}catch(K){Se.error(`Failed to delete source: ${K.message||"unknown error"}`)}g.value=!1,y.value=null}}return st(()=>{$()}),{sources:e,loading:t,error:s,searchQuery:a,searchResults:n,searching:i,lastQuery:l,searchError:o,showIngest:r,ingestSource:c,ingestContent:d,ingestError:u,ingestSuccess:f,ingestNoticeType:m,ingesting:h,reingesting:b,reingestResult:I,deleteTarget:y,deleting:g,expanded:x,sourceChunks:S,chunkErrors:_,loadingChunks:A,selectedChunk:w,totalChunks:O,uploaderCount:B,truncate:Lu,formatTs:ni,highlightTerms:M1,chunkBarWidth:T,fetchSources:$,toggleSource:Q,doSearch:N,clearSearch:L,doIngest:D,doReingest:re,confirmDelete:ce,doDelete:J}}},P1={template:` + `,setup(){const e=p([]),t=p(!0),s=p(null),a=p(""),n=p(null),i=p(!1),l=p(""),o=p(null),r=p(!1),c=p(""),d=p(""),u=p(null),f=p(null),m=p("success"),h=p(!1),g=p(null),A=p(null);let w=null;const y=p(null),b=p(!1),x=p({}),S=p({}),_=p({}),T=p({}),L=new Map,E=p(null),P=U(()=>e.value.reduce((J,se)=>J+(se.chunks||0),0)),R=U(()=>new Set(e.value.map(se=>se.uploader).filter(Boolean)).size);function I(J,se){const q=S.value[se];if(!q||q.length===0)return 0;const ye=Math.max(...q.map(Oe=>Oe.char_count||0));return ye===0?0:Math.round(J.char_count/ye*100)}async function H(){t.value=!0,s.value=null;try{const J=await z.get("/api/knowledge");e.value=Array.isArray(J)?J:[]}catch(J){s.value=J.message}t.value=!1}async function X(J){if(x.value[J]){x.value[J]=!1,E.value=null;return}if(x.value[J]=!0,Object.prototype.hasOwnProperty.call(S.value,J))return;if(L.has(J))return L.get(J);const se={...T.value,[J]:!0};T.value=se;const q={..._.value};delete q[J],_.value=q;const ye=z.get(`/api/knowledge/${encodeURIComponent(J)}/chunks`).then(Oe=>{S.value={...S.value,[J]:Array.isArray(Oe)?Oe:[]}}).catch(Oe=>{_.value={..._.value,[J]:Oe.message||"load failed"}}).finally(()=>{if(L.get(J)!==ye)return;L.delete(J);const Oe={...T.value};delete Oe[J],T.value=Oe});return L.set(J,ye),ye}let Q=0;async function N(){const J=a.value.trim();if(!J)return;const se=++Q;i.value=!0,o.value=null,l.value=J;try{const q=await z.get(`/api/knowledge/search?q=${encodeURIComponent(J)}`);if(se!==Q)return;n.value=Array.isArray(q)?q:[]}catch(q){if(se!==Q)return;n.value=[],o.value=q.message||"Search failed"}se===Q&&(i.value=!1)}function O(){Q+=1,i.value=!1,n.value=null,a.value="",o.value=null}async function $(){u.value=null,f.value=null,m.value="success";const J=c.value.trim(),se=d.value.trim();if(!J){u.value="Source name is required";return}if(!se){u.value="Content is required";return}h.value=!0;try{const q=await z.post("/api/knowledge",{source:J,content:se}),ye=q.outcome==="duplicate"||q.outcome==="conflict";q.outcome==="unchanged"?(f.value=`Already stored unchanged: "${J}" (${q.chunks} chunks)`,c.value="",d.value=""):ye?(m.value="warning",f.value=q.message||q.status):Number.isInteger(q.chunks)&&q.chunks>0?(f.value=`Ingested ${q.chunks} chunks from "${J}"`,c.value="",d.value=""):(m.value="warning",f.value="Ingestion returned no confirmed stored content; check the source before retrying."),S.value={},await H(),!ye&&m.value==="success"&&setTimeout(()=>{r.value=!1,f.value=null},1500)}catch(q){u.value=q.message}h.value=!1}async function ce(J){g.value=J,A.value=null,w&&(clearTimeout(w),w=null);try{const se=await z.post(`/api/knowledge/${encodeURIComponent(J)}/reingest`),q=se.outcome==="duplicate"||se.outcome==="conflict";A.value={source:J,error:!1,warning:q,message:q?se.message||se.status:se.outcome==="unchanged"?`Already stored unchanged (${se.chunks} chunks)`:`Re-ingested ${se.chunks||0} chunks`},q||(delete S.value[J],await H()),w=setTimeout(()=>{A.value=null,w=null},3e3)}catch(se){A.value={source:J,error:!0,message:se.message}}g.value=null}function ue(J){y.value=J}async function Z(){if(y.value){b.value=!0;try{await z.del(`/api/knowledge/${encodeURIComponent(y.value)}`),delete S.value[y.value],await H()}catch(J){ke.error(`Failed to delete source: ${J.message||"unknown error"}`)}b.value=!1,y.value=null}}return tt(()=>{H()}),{sources:e,loading:t,error:s,searchQuery:a,searchResults:n,searching:i,lastQuery:l,searchError:o,showIngest:r,ingestSource:c,ingestContent:d,ingestError:u,ingestSuccess:f,ingestNoticeType:m,ingesting:h,reingesting:g,reingestResult:A,deleteTarget:y,deleting:b,expanded:x,sourceChunks:S,chunkErrors:_,loadingChunks:T,selectedChunk:E,totalChunks:P,uploaderCount:R,truncate:Fu,formatTs:ii,highlightTerms:q1,chunkBarWidth:I,fetchSources:H,toggleSource:X,doSearch:N,clearSearch:O,doIngest:$,doReingest:ce,confirmDelete:ue,doDelete:Z}}},G1={template:`

Memory

@@ -3433,7 +3448,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p([]),t=p({}),s=p(!0),a=p(null),n=p({}),i=p(null),l=p(""),o=p(!1),r=p({scope:"global",key:"",value:""}),c=p(!1),d=p(null),u=p(null),f=p(null),m=p(""),h=p(!1),b=p(null),I=p(null),k=p(new Set),y=p(null),g=p(!1),x=p(!1),S=H(()=>e.value.reduce((K,se)=>K+se.count,0)),_=H(()=>k.value.size);function A(K){const se=t.value[K];if(!se)return[];if(!l.value.trim())return se;const j=l.value.trim().toLowerCase();return se.filter(ye=>ye.key.toLowerCase().includes(j)||ye.value&&ye.value.toLowerCase().includes(j))}function R(K,se){return k.value.has(K+"/"+se)}function w(K,se){const j=K+"/"+se,ye=new Set(k.value);ye.has(j)?ye.delete(j):ye.add(j),k.value=ye}function O(K){const se=t.value[K];return!se||se.length===0?!1:se.every(j=>k.value.has(K+"/"+j.key))}function B(K,se){const j=t.value[K];if(!j)return;const ye=new Set(k.value);for(const Oe of j){const ne=K+"/"+Oe.key;se?ye.add(ne):ye.delete(ne)}k.value=ye}async function T(){s.value=!0,a.value=null;try{const K=await z.get("/api/memory");e.value=Object.entries(K).map(([se,j])=>({name:se,keys:j.keys||[],count:j.count||0}))}catch(K){a.value=K.message}s.value=!1}async function $(K){if(n.value[K]){n.value[K]=!1;return}n.value[K]=!0;const se=e.value.find(ye=>ye.name===K);if(!se||t.value[K]||i.value===K)return;i.value=K;let j;try{const Oe=(await z.get(`/api/memory/${encodeURIComponent(K)}`)).entries||{};j=se.keys.map(ne=>Object.prototype.hasOwnProperty.call(Oe,ne)?{key:ne,value:Oe[ne]||"",failed:!1}:{key:ne,value:"",failed:!0,error:"Not found in scope"})}catch(ye){j=se.keys.map(Oe=>({key:Oe,value:"",failed:!0,error:ye.message||"Failed to load"}))}t.value[K]=j,i.value=null}function Q(K,se,j){f.value=K+"/"+se,m.value=j}async function W(K,se){h.value=!0,b.value=null;try{await z.put(`/api/memory/${encodeURIComponent(K)}/${encodeURIComponent(se)}`,{value:m.value});const j=t.value[K];if(j){const ye=j.find(Oe=>Oe.key===se);ye&&(ye.value=m.value)}f.value=null}catch(j){b.value=`Failed to save: ${j.message||"unknown error"}`}h.value=!1}async function N(K,se){try{await navigator.clipboard.writeText(se.value),I.value=K+"/"+se.key,setTimeout(()=>{I.value=null},1500)}catch{}}async function L(){d.value=null,u.value=null;const K=r.value.scope.trim(),se=r.value.key.trim(),j=r.value.value.trim();if(!K){d.value="Scope is required";return}if(!se){d.value="Key is required";return}if(!j){d.value="Value is required";return}c.value=!0;try{await z.put(`/api/memory/${encodeURIComponent(K)}/${encodeURIComponent(se)}`,{value:j}),u.value="Entry saved",r.value={scope:"global",key:"",value:""},t.value={},await T(),setTimeout(()=>{o.value=!1,u.value=null},800)}catch(ye){d.value=ye.message}c.value=!1}function D(K,se){y.value={scope:K,key:se}}async function re(){if(!y.value)return;g.value=!0,b.value=null;const{scope:K,key:se}=y.value;try{await z.del(`/api/memory/${encodeURIComponent(K)}/${encodeURIComponent(se)}`);const j=t.value[K];j&&(t.value[K]=j.filter(ne=>ne.key!==se));const ye=e.value.find(ne=>ne.name===K);ye&&(ye.count--,ye.keys=ye.keys.filter(ne=>ne!==se));const Oe=new Set(k.value);Oe.delete(K+"/"+se),k.value=Oe}catch(j){b.value=`Failed to delete: ${j.message||"unknown error"}`}g.value=!1,y.value=null}function ce(){x.value=!0}async function J(){g.value=!0,b.value=null;const K=[];for(const se of k.value){const j=se.indexOf("/");K.push({scope:se.slice(0,j),key:se.slice(j+1)})}try{await z.post("/api/memory/bulk-delete",{entries:K}),k.value=new Set,t.value={},await T()}catch(se){b.value=`Bulk delete failed: ${se.message||"unknown error"}`}g.value=!1,x.value=!1}return st(()=>{T()}),{scopes:e,scopeEntries:t,loading:s,error:a,expanded:n,loadingScope:i,filterQuery:l,showAdd:o,addForm:r,adding:c,addError:d,addSuccess:u,editingKey:f,editValue:m,saving:h,actionError:b,copied:I,selected:k,selectedCount:_,totalEntries:S,deleteTarget:y,deleting:g,showBulkDelete:x,fetchMemory:T,toggleScope:$,startEdit:Q,doEdit:W,copyValue:N,doAdd:L,confirmDelete:D,doDelete:re,confirmBulkDelete:ce,doBulkDelete:J,isSelected:R,toggleSelect:w,isScopeAllSelected:O,toggleSelectAll:B,filteredEntries:A}}},$1={template:` + `,setup(){const e=p([]),t=p({}),s=p(!0),a=p(null),n=p({}),i=p(null),l=p(""),o=p(!1),r=p({scope:"global",key:"",value:""}),c=p(!1),d=p(null),u=p(null),f=p(null),m=p(""),h=p(!1),g=p(null),A=p(null),w=p(new Set),y=p(null),b=p(!1),x=p(!1),S=U(()=>e.value.reduce((J,se)=>J+se.count,0)),_=U(()=>w.value.size);function T(J){const se=t.value[J];if(!se)return[];if(!l.value.trim())return se;const q=l.value.trim().toLowerCase();return se.filter(ye=>ye.key.toLowerCase().includes(q)||ye.value&&ye.value.toLowerCase().includes(q))}function L(J,se){return w.value.has(J+"/"+se)}function E(J,se){const q=J+"/"+se,ye=new Set(w.value);ye.has(q)?ye.delete(q):ye.add(q),w.value=ye}function P(J){const se=t.value[J];return!se||se.length===0?!1:se.every(q=>w.value.has(J+"/"+q.key))}function R(J,se){const q=t.value[J];if(!q)return;const ye=new Set(w.value);for(const Oe of q){const le=J+"/"+Oe.key;se?ye.add(le):ye.delete(le)}w.value=ye}async function I(){s.value=!0,a.value=null;try{const J=await z.get("/api/memory");e.value=Object.entries(J).map(([se,q])=>({name:se,keys:q.keys||[],count:q.count||0}))}catch(J){a.value=J.message}s.value=!1}async function H(J){if(n.value[J]){n.value[J]=!1;return}n.value[J]=!0;const se=e.value.find(ye=>ye.name===J);if(!se||t.value[J]||i.value===J)return;i.value=J;let q;try{const Oe=(await z.get(`/api/memory/${encodeURIComponent(J)}`)).entries||{};q=se.keys.map(le=>Object.prototype.hasOwnProperty.call(Oe,le)?{key:le,value:Oe[le]||"",failed:!1}:{key:le,value:"",failed:!0,error:"Not found in scope"})}catch(ye){q=se.keys.map(Oe=>({key:Oe,value:"",failed:!0,error:ye.message||"Failed to load"}))}t.value[J]=q,i.value=null}function X(J,se,q){f.value=J+"/"+se,m.value=q}async function Q(J,se){h.value=!0,g.value=null;try{await z.put(`/api/memory/${encodeURIComponent(J)}/${encodeURIComponent(se)}`,{value:m.value});const q=t.value[J];if(q){const ye=q.find(Oe=>Oe.key===se);ye&&(ye.value=m.value)}f.value=null}catch(q){g.value=`Failed to save: ${q.message||"unknown error"}`}h.value=!1}async function N(J,se){try{await navigator.clipboard.writeText(se.value),A.value=J+"/"+se.key,setTimeout(()=>{A.value=null},1500)}catch{}}async function O(){d.value=null,u.value=null;const J=r.value.scope.trim(),se=r.value.key.trim(),q=r.value.value.trim();if(!J){d.value="Scope is required";return}if(!se){d.value="Key is required";return}if(!q){d.value="Value is required";return}c.value=!0;try{await z.put(`/api/memory/${encodeURIComponent(J)}/${encodeURIComponent(se)}`,{value:q}),u.value="Entry saved",r.value={scope:"global",key:"",value:""},t.value={},await I(),setTimeout(()=>{o.value=!1,u.value=null},800)}catch(ye){d.value=ye.message}c.value=!1}function $(J,se){y.value={scope:J,key:se}}async function ce(){if(!y.value)return;b.value=!0,g.value=null;const{scope:J,key:se}=y.value;try{await z.del(`/api/memory/${encodeURIComponent(J)}/${encodeURIComponent(se)}`);const q=t.value[J];q&&(t.value[J]=q.filter(le=>le.key!==se));const ye=e.value.find(le=>le.name===J);ye&&(ye.count--,ye.keys=ye.keys.filter(le=>le!==se));const Oe=new Set(w.value);Oe.delete(J+"/"+se),w.value=Oe}catch(q){g.value=`Failed to delete: ${q.message||"unknown error"}`}b.value=!1,y.value=null}function ue(){x.value=!0}async function Z(){b.value=!0,g.value=null;const J=[];for(const se of w.value){const q=se.indexOf("/");J.push({scope:se.slice(0,q),key:se.slice(q+1)})}try{await z.post("/api/memory/bulk-delete",{entries:J}),w.value=new Set,t.value={},await I()}catch(se){g.value=`Bulk delete failed: ${se.message||"unknown error"}`}b.value=!1,x.value=!1}return tt(()=>{I()}),{scopes:e,scopeEntries:t,loading:s,error:a,expanded:n,loadingScope:i,filterQuery:l,showAdd:o,addForm:r,adding:c,addError:d,addSuccess:u,editingKey:f,editValue:m,saving:h,actionError:g,copied:A,selected:w,selectedCount:_,totalEntries:S,deleteTarget:y,deleting:b,showBulkDelete:x,fetchMemory:I,toggleScope:H,startEdit:X,doEdit:Q,copyValue:N,doAdd:O,confirmDelete:$,doDelete:ce,confirmBulkDelete:ue,doBulkDelete:Z,isSelected:L,toggleSelect:E,isScopeAllSelected:P,toggleSelectAll:R,filteredEntries:T}}},W1={components:{DiscordIdentity:En},template:`
@@ -3498,7 +3513,7 @@ ${u.text}`:u.text).join(` {{ entry.key }} {{ entry.category }} - user: {{ entry.user_id }} + user:
@@ -3522,7 +3537,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p([]),t=p(null),s=p(!0),a=p(null),n=p(null),i=p(null),l=p(""),o=p(!1),r=p(!1),c=p(null),d=p(!1),u=H(()=>[...new Set(e.value.map(_=>_.category))].sort()),f=H(()=>{const S={};return e.value.forEach(_=>{S[_.category]=(S[_.category]||0)+1}),S}),m=H(()=>n.value?e.value.filter(S=>S.category===n.value):e.value);function h(S){return S==="correction"?"badge-warning":S==="operational"?"badge-info":S==="preference"?"badge-success":"badge-info"}function b(S){i.value=S.key,l.value=S.content}async function I(S){try{await z.put("/api/learned/"+encodeURIComponent(S),{content:l.value}),i.value=null,Se.success("Entry updated"),await y()}catch(_){Se.error(_.message||"Failed to save entry")}}async function k(S){if(await os({title:"Delete learned entry",message:`Delete "${S}"? Odin will no longer apply this learned context.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/learned/"+encodeURIComponent(S)),Se.success("Entry deleted"),await y()}catch(A){Se.error(A.message||"Failed to delete entry")}}async function y(){s.value=!0,a.value=null;try{const S=await z.get("/api/learned");e.value=S.entries||[],t.value={last_reflection:S.last_reflection,count:S.count}}catch(S){a.value=S.message}s.value=!1}async function g(){var S;r.value=!1,c.value=null;try{const _=await z.get("/api/config");o.value=((S=_.learning)==null?void 0:S.enabled)===!0,r.value=!0}catch(_){c.value=_.status===403?"Administrator access is required to change automatic learning.":_.message||"Automatic learning state is unavailable."}}async function x(S){if(!(!r.value||d.value)){d.value=!0,c.value=null;try{if(await z.put("/api/config",{learning:{enabled:S}}),await g(),!r.value)return;Se.success(`Automatic learning ${o.value?"enabled":"disabled"}`)}catch(_){r.value=!1,c.value=_.status===403?"Administrator access is required to change automatic learning.":_.message||"Failed to change automatic learning."}finally{d.value=!1}}}return st(()=>{y(),g()}),{entries:e,meta:t,loading:s,error:a,filterCat:n,editing:i,editContent:l,categories:u,catCounts:f,filtered:m,learningEnabled:o,configReady:r,configError:c,savingConfig:d,catBadge:h,formatTs:ni,startEdit:b,saveEdit:I,deleteEntry:k,fetchEntries:y,fetchLearningConfig:g,setLearningEnabled:x}}},Sg=[{id:"tools",label:"Tools",component:b1},{id:"skills",label:"Skills",component:_1},{id:"mcp-servers",label:"MCP Servers",component:N1},{id:"knowledge",label:"Knowledge",component:D1},{id:"memory",label:"Memory",component:P1},{id:"learned",label:"Learned",component:$1}],F1={components:{TabbedPage:Xr},setup(){return{tabs:Sg}},template:''},B1={ok:"text-green-400",degraded:"text-yellow-400",down:"text-red-400",unconfigured:"text-gray-500"},U1={ok:"success",degraded:"warning",down:"error",unconfigured:"minus"},z1={healthy:"text-green-400",degraded:"text-yellow-400",unhealthy:"text-red-400"},H1={template:` + `,setup(){const e=p([]),t=p(null),s=p(!0),a=p(null),n=p(null),i=p(null),l=p(""),o=p(!1),r=p(!1),c=p(null),d=p(!1),u=U(()=>[...new Set(e.value.map(_=>_.category))].sort()),f=U(()=>{const S={};return e.value.forEach(_=>{S[_.category]=(S[_.category]||0)+1}),S}),m=U(()=>n.value?e.value.filter(S=>S.category===n.value):e.value);function h(S){return S==="correction"?"badge-warning":S==="operational"?"badge-info":S==="preference"?"badge-success":"badge-info"}function g(S){i.value=S.key,l.value=S.content}async function A(S){try{await z.put("/api/learned/"+encodeURIComponent(S),{content:l.value}),i.value=null,ke.success("Entry updated"),await y()}catch(_){ke.error(_.message||"Failed to save entry")}}async function w(S){if(await os({title:"Delete learned entry",message:`Delete "${S}"? Odin will no longer apply this learned context.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/learned/"+encodeURIComponent(S)),ke.success("Entry deleted"),await y()}catch(T){ke.error(T.message||"Failed to delete entry")}}async function y(){s.value=!0,a.value=null;try{const S=await z.get("/api/learned");e.value=S.entries||[],t.value={last_reflection:S.last_reflection,count:S.count}}catch(S){a.value=S.message}s.value=!1}async function b(){var S;r.value=!1,c.value=null;try{const _=await z.get("/api/config");o.value=((S=_.learning)==null?void 0:S.enabled)===!0,r.value=!0}catch(_){c.value=_.status===403?"Administrator access is required to change automatic learning.":_.message||"Automatic learning state is unavailable."}}async function x(S){if(!(!r.value||d.value)){d.value=!0,c.value=null;try{if(await z.put("/api/config",{learning:{enabled:S}}),await b(),!r.value)return;ke.success(`Automatic learning ${o.value?"enabled":"disabled"}`)}catch(_){r.value=!1,c.value=_.status===403?"Administrator access is required to change automatic learning.":_.message||"Failed to change automatic learning."}finally{d.value=!1}}}return tt(()=>{y(),b()}),{entries:e,meta:t,loading:s,error:a,filterCat:n,editing:i,editContent:l,categories:u,catCounts:f,filtered:m,learningEnabled:o,configReady:r,configError:c,savingConfig:d,catBadge:h,formatTs:ii,startEdit:g,saveEdit:A,deleteEntry:w,fetchEntries:y,fetchLearningConfig:b,setLearningEnabled:x}}},Ig=[{id:"tools",label:"Tools",component:A1},{id:"skills",label:"Skills",component:O1},{id:"mcp-servers",label:"MCP Servers",component:j1},{id:"knowledge",label:"Knowledge",component:V1},{id:"memory",label:"Memory",component:G1},{id:"learned",label:"Learned",component:W1}],K1={components:{TabbedPage:nc},setup(){return{tabs:Ig}},template:''},J1={ok:"text-green-400",degraded:"text-yellow-400",down:"text-red-400",unconfigured:"text-gray-500"},Z1={ok:"success",degraded:"warning",down:"error",unconfigured:"minus"},Q1={healthy:"text-green-400",degraded:"text-yellow-400",unhealthy:"text-red-400"},Y1={template:`
@@ -3664,7 +3679,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p({}),t=p(!0),s=p(null),a=p(!1),n=p(!1),i=H(()=>e.value.components||[]),l=H(()=>z1[e.value.overall]||"text-gray-400"),o=H(()=>e.value.overall==="healthy"?"success":e.value.overall==="degraded"?"warning":e.value.overall==="unhealthy"?"error":"minus"),r=H(()=>{const _=e.value.overall;return _==="healthy"?"All Systems Healthy":_==="degraded"?"Some Systems Degraded":_==="unhealthy"?"System Issues Detected":"Unknown"});function c(_){return B1[_]||"text-gray-400"}function d(_){return U1[_]||"info"}function u(_){return _==="ok"?"badge-success":_==="degraded"?"badge-warning":_==="down"?"badge-danger":"badge-info"}function f(_){return _==="closed"?"text-green-400":_==="half_open"?"text-yellow-400":_==="open"?"text-red-400":"text-gray-400"}function m(_){return _.replace(/_/g," ").replace(/\b\w/g,A=>A.toUpperCase())}function h(_){if(!_)return"—";try{return new Date(_).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return _}}function b(_){return _>=1e6?(_/1e6).toFixed(1)+"M":_>=1e3?(_/1e3).toFixed(1)+"K":String(_)}async function I(){n.value=!0;try{e.value=await z.get("/api/health/components"),s.value=null,a.value=!0}catch(_){s.value=_.message}finally{t.value=!1,n.value=!1}}function k(){t.value=!0,s.value=null,I()}let y=null,g=!1;function x(){g||(g=!0,I(),y||(y=setInterval(I,3e4)))}function S(){g&&(g=!1,y&&(clearInterval(y),y=null))}return st(x),rs(x),Kt(S),xt(S),{data:e,hasData:a,loading:t,error:s,refreshing:n,components:i,overallColor:l,overallIcon:o,overallLabel:r,statusColor:c,statusIcon:d,badgeClass:u,circuitColor:f,formatName:m,formatTime:h,formatNumber:b,fetchHealth:I,retry:k}}},j1={template:` + `,setup(){const e=p({}),t=p(!0),s=p(null),a=p(!1),n=p(!1),i=U(()=>e.value.components||[]),l=U(()=>Q1[e.value.overall]||"text-gray-400"),o=U(()=>e.value.overall==="healthy"?"success":e.value.overall==="degraded"?"warning":e.value.overall==="unhealthy"?"error":"minus"),r=U(()=>{const _=e.value.overall;return _==="healthy"?"All Systems Healthy":_==="degraded"?"Some Systems Degraded":_==="unhealthy"?"System Issues Detected":"Unknown"});function c(_){return J1[_]||"text-gray-400"}function d(_){return Z1[_]||"info"}function u(_){return _==="ok"?"badge-success":_==="degraded"?"badge-warning":_==="down"?"badge-danger":"badge-info"}function f(_){return _==="closed"?"text-green-400":_==="half_open"?"text-yellow-400":_==="open"?"text-red-400":"text-gray-400"}function m(_){return _.replace(/_/g," ").replace(/\b\w/g,T=>T.toUpperCase())}function h(_){if(!_)return"—";try{return new Date(_).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return _}}function g(_){return _>=1e6?(_/1e6).toFixed(1)+"M":_>=1e3?(_/1e3).toFixed(1)+"K":String(_)}async function A(){n.value=!0;try{e.value=await z.get("/api/health/components"),s.value=null,a.value=!0}catch(_){s.value=_.message}finally{t.value=!1,n.value=!1}}function w(){t.value=!0,s.value=null,A()}let y=null,b=!1;function x(){b||(b=!0,A(),y||(y=setInterval(A,3e4)))}function S(){b&&(b=!1,y&&(clearInterval(y),y=null))}return tt(x),rs(x),Kt(S),xt(S),{data:e,hasData:a,loading:t,error:s,refreshing:n,components:i,overallColor:l,overallIcon:o,overallLabel:r,statusColor:c,statusIcon:d,badgeClass:u,circuitColor:f,formatName:m,formatTime:h,formatNumber:g,fetchHealth:A,retry:w}}},X1={template:`
@@ -3907,8 +3922,8 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p(!0),t=p(null),s=p(!1),a=p(!1),n=p("sessions"),i=p(null);let l=null;const o=[{key:"sessions",label:"Sessions"},{key:"knowledge",label:"Knowledge"},{key:"trajectories",label:"Trajectories"},{key:"storage",label:"Storage"}],r=H(()=>{if(!i.value||!i.value.collected_at)return"";try{return new Date(i.value.collected_at).toLocaleTimeString()}catch{return""}}),c=H(()=>{if(!i.value)return[];const I=i.value,k=I.storage_total_bytes||1;return[{label:"Session Persistence",mb:I.sessions.persist_dir.total_mb,bytes:I.sessions.persist_dir.total_bytes,files:I.sessions.persist_dir.file_count,pct:Math.min(100,Math.round(I.sessions.persist_dir.total_bytes/k*100)),color:"res-bar-blue"},{label:"Knowledge Database",mb:I.knowledge.db_file.total_mb,bytes:I.knowledge.db_file.total_bytes,files:I.knowledge.db_file.file_count,pct:Math.min(100,Math.round(I.knowledge.db_file.total_bytes/k*100)),color:"res-bar-purple"},{label:"Message Trajectories",mb:I.trajectories.message_dir.total_mb,bytes:I.trajectories.message_dir.total_bytes,files:I.trajectories.message_dir.file_count,pct:Math.min(100,Math.round(I.trajectories.message_dir.total_bytes/k*100)),color:"res-bar-emerald"},{label:"Agent Trajectories",mb:I.trajectories.agent_dir.total_mb,bytes:I.trajectories.agent_dir.total_bytes,files:I.trajectories.agent_dir.file_count,pct:Math.min(100,Math.round(I.trajectories.agent_dir.total_bytes/k*100)),color:"res-bar-amber"}]});async function d(){try{const I=await z.get("/api/resource-usage");i.value=I,t.value=null,s.value=!0}catch(I){t.value=I.message||"Failed to load resource usage"}finally{e.value=!1,a.value=!1}}async function u(){a.value=!0,await d()}function f(){e.value=!0,t.value=null,d()}let m=!1;function h(){m||(m=!0,d(),l||(l=setInterval(d,3e4)))}function b(){m&&(m=!1,l&&(clearInterval(l),l=null))}return st(h),rs(h),Kt(b),xt(b),{hasData:s,loading:e,error:t,refreshing:a,data:i,activeTab:n,tabs:o,collectedAt:r,storageItems:c,fmtNum:Nu,refresh:u,retry:f}}},V1=new Set(["timestamp","type","level","tool_name","action","method","path","status","success","execution_time_ms","duration_ms","metadata","audit_metadata","turn","tool_input","audit_observer","_hmac","_prev_hmac","agent_id","agent_label","parent_agent_id","root_agent_id","originating_turn_id","turn_id","iteration","call_id","result_summary","detail","message","diff","error"]),q1=new Set(["agent_id","agent_label","label","parent_agent_id","root_agent_id","originating_turn_id","turn_id","iteration","call_id","status","duration_ms","tool_input_keys","audit_observer","_hmac","_prev_hmac"]);function G1(e){const t={};for(const s of["metadata","audit_metadata"]){if(!(e!=null&&e[s])||typeof e[s]!="object")continue;const a=Object.fromEntries(Object.entries(e[s]).filter(([n])=>!q1.has(n)));Object.keys(a).length&&(t[s]=a)}return Object.keys(t).length?Xl(t):""}function W1(e){var n,i,l,o;const t=e.record;if(!t)return{body:e.text,action:"",status:"",duration:null};let s;for(const r of["result_summary","detail","message","diff","error"])if(t[r]!==void 0&&t[r]!==null&&t[r]!==""){s=t[r];break}if(s===void 0&&((n=t.metadata)!=null&&n.error)&&(s=t.metadata.error),s===void 0){const r=Object.fromEntries(Object.entries(t).filter(([c])=>!V1.has(c)));s=Object.keys(r).length?Xl(r):""}const a=t.status??((i=t.metadata)==null?void 0:i.status)??(t.error||t.success===!1?"failed":t.success===!0?"success":["tool_start","loop_tool_start"].includes(t.type)?"started":"");return{body:s,action:t.tool_name||t.action||(t.method?`${t.method} ${t.path||""}`.trim():t.type||""),status:a,duration:t.execution_time_ms??t.duration_ms??((l=t.metadata)==null?void 0:l.duration_ms)??((o=t.metadata)==null?void 0:o.elapsed_ms)??null}}const wn=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),eo=e=>typeof e=="string"||typeof e=="number"?String(e):"";function K1(e){const t=wn(e)?e:{},s=wn(t.metadata)?t.metadata:{},a=wn(t.audit_metadata)?t.audit_metadata:{},n=wn(t.turn)?t.turn:{},i=l=>eo(t[l]??s[l]??a[l]??n[l]);return{agentId:i("agent_id"),label:i("agent_label")||i("label"),parentId:i("parent_agent_id"),rootId:i("root_agent_id"),turnId:i("originating_turn_id")||i("turn_id"),iteration:i("iteration"),callId:i("call_id")}}function Mf(e){return e.record?JSON.stringify(Cg(e),null,2):e.text}function Cg(e){var t;return((t=e.events)==null?void 0:t.length)>1?e.events:e.record}function Sd(e){return e!=null&&e.tool_name?["tool_start","loop_tool_start"].includes(e.type)?"start":["tool_end","loop_tool"].includes(e.type)?"end":(!e.type||e.type==="execution")&&"result_summary"in e?"execution":"":""}function Df(e){if(!Sd(e.record))return"";const t=e.attribution,s=e.record;return!t.callId||!t.turnId&&!t.agentId?"":JSON.stringify([t.turnId,t.agentId,t.iteration,t.callId,e.tool,eo(s.channel_id),eo(s.user_id??s.actor)])}function J1(e,t,s=2e3){var i,l,o;const a=Df(t),n=a?e.findIndex(r=>Df(r)===a):-1;if(n<0)e.push(t);else{const r=e[n],c=[...r.events||[r.record]],d=JSON.stringify(t.record);if(c.some(y=>JSON.stringify(y)===d))return;c.push(t.record);const u=y=>"result_summary"in y?2:Sd(y)==="end"?1:0,f=[...c].sort((y,g)=>u(y)-u(g)),m=Object.assign({},...f);m.type=f[f.length-1].type||"execution";for(const y of["metadata","audit_metadata","turn"]){const g=f.filter(x=>wn(x[y])).map(x=>x[y]);g.length&&(m[y]=Object.assign({},...g))}const h=c.some(y=>Sd(y)!=="start"),b=c.find(y=>Cd(y,0).level==="ERROR"),I=(b==null?void 0:b.status)||((i=b==null?void 0:b.metadata)==null?void 0:i.status);m.status=b?["failed","error","cancelled","denied","outcome_unknown"].includes(I)?I:"failed":h?m.status||((l=m.metadata)==null?void 0:l.status)||"succeeded":"started",h&&m.status==="started"&&(m.status="succeeded"),b&&(m.error=b.error||((o=b.metadata)==null?void 0:o.error)||m.error);const k=Cd(m,r.id,r._time);Object.assign(k,{events:c,ts:r.ts,_time:r._time,searchText:c.map(y=>JSON.stringify(y)).join(` -`)}),e.splice(n,1,k)}e.length>s&&e.splice(0,e.length-s)}function Cd(e,t,s=new Date){var u,f;let a=e;if(wn(e)&&e.type==="log"&&"line"in e?a=e.line:wn(e)&&"payload"in e&&(a=e.payload),typeof a=="string")try{a=JSON.parse(a)}catch{}const n=wn(a)?a:null,i=n!=null&&n.timestamp?new Date(n.timestamp):s,l=Number.isNaN(i.getTime())?s:i,o=n?n.result_summary??n.detail??n.message??JSON.stringify(n):typeof a=="string"?a:JSON.stringify(a)??"",c=(n==null?void 0:n.error)||((u=n==null?void 0:n.metadata)==null?void 0:u.error)||(n==null?void 0:n.success)===!1||[n==null?void 0:n.status,(f=n==null?void 0:n.metadata)==null?void 0:f.status].some(m=>["failed","error","cancelled","denied","outcome_unknown"].includes(m))?"ERROR":eo(n==null?void 0:n.level).toUpperCase()||"INFO",d={id:t,record:n,ts:l.toLocaleTimeString(),_time:l,level:c,text:typeof o=="string"?o:JSON.stringify(o),tool:eo(n==null?void 0:n.tool_name),raw:n?null:o,attribution:K1(n)};return d.searchText=n?JSON.stringify(n):d.text,d}function Z1(e){const t=new Map;for(const s of e){const{turnId:a,agentId:n,rootId:i,label:l,parentId:o}=s.attribution,r=a?`turn:${a}`:n?`root:${i||n}`:"unattributed";t.has(r)||t.set(r,{key:r,title:a?`Turn ${a}`:n?`Agent root ${i||n} (turn unavailable)`:"Unattributed / legacy records",count:0,sections:[],_sections:new Map});const c=t.get(r),d=n?`agent:${n}`:"main";if(!c._sections.has(d)){const u={key:d,agentId:n,label:l,parentId:o,rootId:i,title:n?`${l||"Agent"} (${n})`:"Main thread / turn events",entries:[]};c._sections.set(d,u),c.sections.push(u)}c._sections.get(d).entries.push(s),c.count++}return[...t.values()].map(({_sections:s,...a})=>a)}const Y1={components:{ToolOutput:ec},props:{entry:{type:Object,required:!0}},emits:["copy"],setup(e){const t=H(()=>W1(e.entry)),s=H(()=>{var o;return Xl(((o=e.entry.record)==null?void 0:o.tool_input)??"")}),a=H(()=>{var o,r,c;return Xl(((o=e.entry.record)==null?void 0:o.error)||((c=(r=e.entry.record)==null?void 0:r.metadata)==null?void 0:c.error)||"")}),n=H(()=>G1(e.entry.record)),i=H(()=>Cg(e.entry)),l=H(()=>(e.entry.events||[e.entry.record]).filter(Boolean).map(o=>{var r;return{type:o.type||"execution",timestamp:o.timestamp||"Time not recorded",status:o.status||((r=o.metadata)==null?void 0:r.status)||""}}));return{display:t,argumentsText:s,errorText:a,metadataText:n,rawRecord:i,lifecycle:l}},template:` + `,setup(){const e=p(!0),t=p(null),s=p(!1),a=p(!1),n=p("sessions"),i=p(null);let l=null;const o=[{key:"sessions",label:"Sessions"},{key:"knowledge",label:"Knowledge"},{key:"trajectories",label:"Trajectories"},{key:"storage",label:"Storage"}],r=U(()=>{if(!i.value||!i.value.collected_at)return"";try{return new Date(i.value.collected_at).toLocaleTimeString()}catch{return""}}),c=U(()=>{if(!i.value)return[];const A=i.value,w=A.storage_total_bytes||1;return[{label:"Session Persistence",mb:A.sessions.persist_dir.total_mb,bytes:A.sessions.persist_dir.total_bytes,files:A.sessions.persist_dir.file_count,pct:Math.min(100,Math.round(A.sessions.persist_dir.total_bytes/w*100)),color:"res-bar-blue"},{label:"Knowledge Database",mb:A.knowledge.db_file.total_mb,bytes:A.knowledge.db_file.total_bytes,files:A.knowledge.db_file.file_count,pct:Math.min(100,Math.round(A.knowledge.db_file.total_bytes/w*100)),color:"res-bar-purple"},{label:"Message Trajectories",mb:A.trajectories.message_dir.total_mb,bytes:A.trajectories.message_dir.total_bytes,files:A.trajectories.message_dir.file_count,pct:Math.min(100,Math.round(A.trajectories.message_dir.total_bytes/w*100)),color:"res-bar-emerald"},{label:"Agent Trajectories",mb:A.trajectories.agent_dir.total_mb,bytes:A.trajectories.agent_dir.total_bytes,files:A.trajectories.agent_dir.file_count,pct:Math.min(100,Math.round(A.trajectories.agent_dir.total_bytes/w*100)),color:"res-bar-amber"}]});async function d(){try{const A=await z.get("/api/resource-usage");i.value=A,t.value=null,s.value=!0}catch(A){t.value=A.message||"Failed to load resource usage"}finally{e.value=!1,a.value=!1}}async function u(){a.value=!0,await d()}function f(){e.value=!0,t.value=null,d()}let m=!1;function h(){m||(m=!0,d(),l||(l=setInterval(d,3e4)))}function g(){m&&(m=!1,l&&(clearInterval(l),l=null))}return tt(h),rs(h),Kt(g),xt(g),{hasData:s,loading:e,error:t,refreshing:a,data:i,activeTab:n,tabs:o,collectedAt:r,storageItems:c,fmtNum:Bu,refresh:u,retry:f}}},eC=new Set(["timestamp","type","level","tool_name","action","method","path","status","success","execution_time_ms","duration_ms","metadata","audit_metadata","turn","tool_input","audit_observer","_hmac","_prev_hmac","agent_id","agent_label","parent_agent_id","root_agent_id","originating_turn_id","turn_id","iteration","call_id","result_summary","detail","message","diff","error"]),tC=new Set(["agent_id","agent_label","label","parent_agent_id","root_agent_id","originating_turn_id","turn_id","iteration","call_id","status","duration_ms","tool_input_keys","audit_observer","_hmac","_prev_hmac"]);function sC(e){const t={};for(const s of["metadata","audit_metadata"]){if(!(e!=null&&e[s])||typeof e[s]!="object")continue;const a=Object.fromEntries(Object.entries(e[s]).filter(([n])=>!tC.has(n)));Object.keys(a).length&&(t[s]=a)}return Object.keys(t).length?eo(t):""}function aC(e){var n,i,l,o;const t=e.record;if(!t)return{body:e.text,action:"",status:"",duration:null};let s;for(const r of["result_summary","detail","message","diff","error"])if(t[r]!==void 0&&t[r]!==null&&t[r]!==""){s=t[r];break}if(s===void 0&&((n=t.metadata)!=null&&n.error)&&(s=t.metadata.error),s===void 0){const r=Object.fromEntries(Object.entries(t).filter(([c])=>!eC.has(c)));s=Object.keys(r).length?eo(r):""}const a=t.status??((i=t.metadata)==null?void 0:i.status)??(t.error||t.success===!1?"failed":t.success===!0?"success":["tool_start","loop_tool_start"].includes(t.type)?"started":"");return{body:s,action:t.tool_name||t.action||(t.method?`${t.method} ${t.path||""}`.trim():t.type||""),status:a,duration:t.execution_time_ms??t.duration_ms??((l=t.metadata)==null?void 0:l.duration_ms)??((o=t.metadata)==null?void 0:o.elapsed_ms)??null}}const wn=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),to=e=>typeof e=="string"||typeof e=="number"?String(e):"";function nC(e){const t=wn(e)?e:{},s=wn(t.metadata)?t.metadata:{},a=wn(t.audit_metadata)?t.audit_metadata:{},n=wn(t.turn)?t.turn:{},i=l=>to(t[l]??s[l]??a[l]??n[l]);return{agentId:i("agent_id"),label:i("agent_label")||i("label"),parentId:i("parent_agent_id"),rootId:i("root_agent_id"),turnId:i("originating_turn_id")||i("turn_id"),iteration:i("iteration"),callId:i("call_id")}}function Uf(e){return e.record?JSON.stringify(Og(e),null,2):e.text}function Og(e){var t;return((t=e.events)==null?void 0:t.length)>1?e.events:e.record}function Id(e){return e!=null&&e.tool_name?["tool_start","loop_tool_start"].includes(e.type)?"start":["tool_end","loop_tool"].includes(e.type)?"end":(!e.type||e.type==="execution")&&"result_summary"in e?"execution":"":""}function zf(e){if(!Id(e.record))return"";const t=e.attribution,s=e.record;return!t.callId||!t.turnId&&!t.agentId?"":JSON.stringify([t.turnId,t.agentId,t.iteration,t.callId,e.tool,to(s.channel_id),to(s.user_id??s.actor)])}function iC(e,t,s=2e3){var i,l,o;const a=zf(t),n=a?e.findIndex(r=>zf(r)===a):-1;if(n<0)e.push(t);else{const r=e[n],c=[...r.events||[r.record]],d=JSON.stringify(t.record);if(c.some(y=>JSON.stringify(y)===d))return;c.push(t.record);const u=y=>"result_summary"in y?2:Id(y)==="end"?1:0,f=[...c].sort((y,b)=>u(y)-u(b)),m=Object.assign({},...f);m.type=f[f.length-1].type||"execution";for(const y of["metadata","audit_metadata","turn"]){const b=f.filter(x=>wn(x[y])).map(x=>x[y]);b.length&&(m[y]=Object.assign({},...b))}const h=c.some(y=>Id(y)!=="start"),g=c.find(y=>Od(y,0).level==="ERROR"),A=(g==null?void 0:g.status)||((i=g==null?void 0:g.metadata)==null?void 0:i.status);m.status=g?["failed","error","cancelled","denied","outcome_unknown"].includes(A)?A:"failed":h?m.status||((l=m.metadata)==null?void 0:l.status)||"succeeded":"started",h&&m.status==="started"&&(m.status="succeeded"),g&&(m.error=g.error||((o=g.metadata)==null?void 0:o.error)||m.error);const w=Od(m,r.id,r._time);Object.assign(w,{events:c,ts:r.ts,_time:r._time,searchText:c.map(y=>JSON.stringify(y)).join(` +`)}),e.splice(n,1,w)}e.length>s&&e.splice(0,e.length-s)}function Od(e,t,s=new Date){var u,f;let a=e;if(wn(e)&&e.type==="log"&&"line"in e?a=e.line:wn(e)&&"payload"in e&&(a=e.payload),typeof a=="string")try{a=JSON.parse(a)}catch{}const n=wn(a)?a:null,i=n!=null&&n.timestamp?new Date(n.timestamp):s,l=Number.isNaN(i.getTime())?s:i,o=n?n.result_summary??n.detail??n.message??JSON.stringify(n):typeof a=="string"?a:JSON.stringify(a)??"",c=(n==null?void 0:n.error)||((u=n==null?void 0:n.metadata)==null?void 0:u.error)||(n==null?void 0:n.success)===!1||[n==null?void 0:n.status,(f=n==null?void 0:n.metadata)==null?void 0:f.status].some(m=>["failed","error","cancelled","denied","outcome_unknown"].includes(m))?"ERROR":to(n==null?void 0:n.level).toUpperCase()||"INFO",d={id:t,record:n,ts:l.toLocaleTimeString(),_time:l,level:c,text:typeof o=="string"?o:JSON.stringify(o),tool:to(n==null?void 0:n.tool_name),raw:n?null:o,attribution:nC(n)};return d.searchText=n?JSON.stringify(n):d.text,d}function lC(e){const t=new Map;for(const s of e){const{turnId:a,agentId:n,rootId:i,label:l,parentId:o}=s.attribution,r=a?`turn:${a}`:n?`root:${i||n}`:"unattributed";t.has(r)||t.set(r,{key:r,title:a?`Turn ${a}`:n?`Agent root ${i||n} (turn unavailable)`:"Unattributed / legacy records",count:0,sections:[],_sections:new Map});const c=t.get(r),d=n?`agent:${n}`:"main";if(!c._sections.has(d)){const u={key:d,agentId:n,label:l,parentId:o,rootId:i,title:n?`${l||"Agent"} (${n})`:"Main thread / turn events",entries:[]};c._sections.set(d,u),c.sections.push(u)}c._sections.get(d).entries.push(s),c.count++}return[...t.values()].map(({_sections:s,...a})=>a)}const oC={components:{ToolOutput:ic},props:{entry:{type:Object,required:!0}},emits:["copy"],setup(e){const t=U(()=>aC(e.entry)),s=U(()=>{var o;return eo(((o=e.entry.record)==null?void 0:o.tool_input)??"")}),a=U(()=>{var o,r,c;return eo(((o=e.entry.record)==null?void 0:o.error)||((c=(r=e.entry.record)==null?void 0:r.metadata)==null?void 0:c.error)||"")}),n=U(()=>sC(e.entry.record)),i=U(()=>Og(e.entry)),l=U(()=>(e.entry.events||[e.entry.record]).filter(Boolean).map(o=>{var r;return{type:o.type||"execution",timestamp:o.timestamp||"Time not recorded",status:o.status||((r=o.metadata)==null?void 0:r.status)||""}}));return{display:t,argumentsText:s,errorText:a,metadataText:n,rawRecord:i,lifecycle:l}},template:`
@@ -3953,7 +3968,7 @@ ${u.text}`:u.text).join(` -
`},Q1=["INFO","WARNING","ERROR"],Pf="WARNING+",X1=[{id:"all",name:"All Logs",icon:"list",filters:{}},{id:"errors",name:"Errors Only",icon:"error",filters:{level:"ERROR"}},{id:"warnings",name:"Warnings+",icon:"warning",filters:{levels:["WARNING","ERROR"]}},{id:"tools",name:"Tool Activity",icon:"wrench",filters:{hasToolName:!0}},{id:"recent-errors",name:"Recent Errors",icon:"flame",filters:{level:"ERROR",timeRange:"last_1h"}}],Dc=[{value:"",label:"All Time"},{value:"last_5m",label:"Last 5 min",seconds:300},{value:"last_15m",label:"Last 15 min",seconds:900},{value:"last_1h",label:"Last 1 hour",seconds:3600},{value:"last_4h",label:"Last 4 hours",seconds:14400},{value:"last_24h",label:"Last 24 hours",seconds:86400}],eC=[50,100,200,500],tC={components:{ToolOutput:ec,LogRecord:Y1},template:` + `},rC=["INFO","WARNING","ERROR"],Hf="WARNING+",cC=[{id:"all",name:"All Logs",icon:"list",filters:{}},{id:"errors",name:"Errors Only",icon:"error",filters:{level:"ERROR"}},{id:"warnings",name:"Warnings+",icon:"warning",filters:{levels:["WARNING","ERROR"]}},{id:"tools",name:"Tool Activity",icon:"wrench",filters:{hasToolName:!0}},{id:"recent-errors",name:"Recent Errors",icon:"flame",filters:{level:"ERROR",timeRange:"last_1h"}}],Uc=[{value:"",label:"All Time"},{value:"last_5m",label:"Last 5 min",seconds:300},{value:"last_15m",label:"Last 15 min",seconds:900},{value:"last_1h",label:"Last 1 hour",seconds:3600},{value:"last_4h",label:"Last 4 hours",seconds:14400},{value:"last_24h",label:"Last 24 hours",seconds:86400}],dC=[50,100,200,500],uC={components:{ToolOutput:ic,LogRecord:oC,DiscordIdentity:En},template:`
@@ -4288,7 +4303,7 @@ ${u.text}`:u.text).join(` {{ entry.timestamp || 'N/A' }} -
`,setup(){const e=p("live"),t=p([]);let s=0;const a=p(!1),n=p(!1),i=p(!0),l=p(""),o=p(""),r=p(!1),c=p(!1),d=p(ut.state||"disconnected"),u=H(()=>{switch(d.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}}),f=p(null),m=p(!1),h=p(null),b=2e3,I=Q1,k=X1,y=Dc,g=p("all"),x=p(""),S=p(!1),_=p([]),A=p(!1),R=p(""),w=p([]);function O(){try{const le=localStorage.getItem("odin-log-presets");le&&(_.value=JSON.parse(le))}catch{}}function B(){try{localStorage.setItem("odin-log-presets",JSON.stringify(_.value))}catch{}}const T=H(()=>l.value!==""||o.value.trim()!==""||x.value!==""||S.value),$=H(()=>{const le=Dc.find(ke=>ke.value===x.value);return le?le.label:""}),Q=H(()=>{if(!r.value||!o.value)return null;try{return new RegExp(o.value,"i"),null}catch(le){return le.message}}),W=24,N=H(()=>{if(se.value.length===0)return[];const le=[],ke=new Date,qe=3600*1e3;for(let Ge=W-1;Ge>=0;Ge--){const kt=new Date(ke.getTime()-(Ge+1)*qe),St=new Date(ke.getTime()-Ge*qe);le.push({start:kt,end:St,label:ce(kt,St),shortLabel:St.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}),total:0,info:0,warnings:0,errors:0})}for(const Ge of se.value){if(!Ge._time)continue;const kt=Ge._time.getTime();for(const St of le)if(kt>=St.start.getTime()&&kt{let le=1;for(const ke of N.value)ke.total>le&&(le=ke.total);return le}),D=H(()=>{if(N.value.length===0)return"";const le=se.value.map(Ge=>Ge._time&&Ge._time.getTime()).filter(Boolean);if(le.length===0)return"";const ke=new Date(Math.min(...le));return`${se.value.length} shown, oldest ${ke.toLocaleTimeString()}`}),re=H(()=>Math.ceil(W/8));function ce(le,ke){const qe={hour:"2-digit",minute:"2-digit"};return le.toLocaleTimeString([],qe)+" - "+ke.toLocaleTimeString([],qe)}function J(le,ke){return!ke||!le?"0px":Math.max(2,le/ke*100)+"%"}function K(le){const ke=se.value.findIndex(qe=>qe._time&&qe._time.getTime()>=le.start.getTime()&&qe._time.getTime()=0&&f.value){const qe=f.value.querySelector('[data-log-id="'+se.value[ke].id+'"]');qe&&(qe.scrollIntoView({behavior:"smooth",block:"center"}),i.value=!1)}}const se=H(()=>{let le=t.value;if(S.value&&(le=le.filter(ke=>!!ke.tool)),l.value)if(l.value===Pf){const ke={INFO:0,WARNING:1,ERROR:2,CRITICAL:3};le=le.filter(qe=>ke[qe.level||"INFO"]>=ke.WARNING)}else le=le.filter(ke=>(ke.level||"INFO")===l.value);if(x.value){const ke=Dc.find(qe=>qe.value===x.value);if(ke&&ke.seconds){const qe=new Date(Date.now()-ke.seconds*1e3);le=le.filter(Ge=>Ge._time&&Ge._time>=qe)}}if(o.value&&!Q.value)if(r.value)try{const ke=new RegExp(o.value,"i");le=le.filter(qe=>{const Ge=qe.searchText,kt=qe.tool||"";return ke.test(Ge)||ke.test(kt)})}catch{}else{const ke=o.value.toLowerCase();le=le.filter(qe=>{const Ge=qe.searchText.toLowerCase(),kt=(qe.tool||"").toLowerCase();return Ge.includes(ke)||kt.includes(ke)})}return le}),j=H(()=>Z1(se.value));function ye(le){const ke=Cd(le,++s);if(n.value){w.value.push(ke);return}Oe(ke)}function Oe(le){J1(t.value,le,b),i.value&&zt(()=>ne())}function ne(le=!1){const ke=f.value;ke&&ke.scrollTo({top:ke.scrollHeight,behavior:le?"smooth":"instant"})}function ie(){i.value=!0,m.value=!1,zt(()=>ne(!0))}const ve=new Set(["PageUp","PageDown","ArrowUp","ArrowDown","Home","End"," "]);function be(){const le=f.value;if(!le)return;const ke=le.scrollHeight-le.scrollTop-le.clientHeight<40;m.value=!i.value&&!ke&&t.value.length>0,P.value&&ge()}function ge(){const le=f.value;!le||!i.value||le.scrollHeight-le.scrollTop-le.clientHeight>=40&&(i.value=!1,m.value=t.value.length>0)}function Ne(){i.value&&requestAnimationFrame(ge)}function E(le){ve.has(le.key)&&Ne()}const P=p(!1);function G(){i.value&&(P.value=!0,requestAnimationFrame(ge))}function me(){P.value&&(P.value=!1,ge())}function de(){i.value&&(m.value=!1,zt(()=>ne()))}function fe(){if(n.value=!n.value,!n.value&&w.value.length>0){for(const le of w.value)Oe(le);w.value=[]}}function _e(){t.value=[],w.value=[],m.value=!1}function q(){let le;e.value==="search"?le=rt.value.map(kt=>{const St=kt.error?"ERROR":"INFO",cn=kt.tool_name?`[${kt.tool_name}] `:"";return`${kt.timestamp||""} ${St} ${cn}${kt.result_summary||kt.message||""}`}).join(` -`):le=se.value.map(Mf).join(` +
`,setup(){const e=p("live"),t=p([]);let s=0;const a=p(!1),n=p(!1),i=p(!0),l=p(""),o=p(""),r=p(!1),c=p(!1),d=p(ut.state||"disconnected"),u=U(()=>{switch(d.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}}),f=p(null),m=p(!1),h=p(null),g=2e3,A=rC,w=cC,y=Uc,b=p("all"),x=p(""),S=p(!1),_=p([]),T=p(!1),L=p(""),E=p([]);function P(){try{const re=localStorage.getItem("odin-log-presets");re&&(_.value=JSON.parse(re))}catch{}}function R(){try{localStorage.setItem("odin-log-presets",JSON.stringify(_.value))}catch{}}const I=U(()=>l.value!==""||o.value.trim()!==""||x.value!==""||S.value),H=U(()=>{const re=Uc.find(Ce=>Ce.value===x.value);return re?re.label:""}),X=U(()=>{if(!r.value||!o.value)return null;try{return new RegExp(o.value,"i"),null}catch(re){return re.message}}),Q=24,N=U(()=>{if(se.value.length===0)return[];const re=[],Ce=new Date,Ve=3600*1e3;for(let Ge=Q-1;Ge>=0;Ge--){const kt=new Date(Ce.getTime()-(Ge+1)*Ve),St=new Date(Ce.getTime()-Ge*Ve);re.push({start:kt,end:St,label:ue(kt,St),shortLabel:St.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}),total:0,info:0,warnings:0,errors:0})}for(const Ge of se.value){if(!Ge._time)continue;const kt=Ge._time.getTime();for(const St of re)if(kt>=St.start.getTime()&&kt{let re=1;for(const Ce of N.value)Ce.total>re&&(re=Ce.total);return re}),$=U(()=>{if(N.value.length===0)return"";const re=se.value.map(Ge=>Ge._time&&Ge._time.getTime()).filter(Boolean);if(re.length===0)return"";const Ce=new Date(Math.min(...re));return`${se.value.length} shown, oldest ${Ce.toLocaleTimeString()}`}),ce=U(()=>Math.ceil(Q/8));function ue(re,Ce){const Ve={hour:"2-digit",minute:"2-digit"};return re.toLocaleTimeString([],Ve)+" - "+Ce.toLocaleTimeString([],Ve)}function Z(re,Ce){return!Ce||!re?"0px":Math.max(2,re/Ce*100)+"%"}function J(re){const Ce=se.value.findIndex(Ve=>Ve._time&&Ve._time.getTime()>=re.start.getTime()&&Ve._time.getTime()=0&&f.value){const Ve=f.value.querySelector('[data-log-id="'+se.value[Ce].id+'"]');Ve&&(Ve.scrollIntoView({behavior:"smooth",block:"center"}),i.value=!1)}}const se=U(()=>{let re=t.value;if(S.value&&(re=re.filter(Ce=>!!Ce.tool)),l.value)if(l.value===Hf){const Ce={INFO:0,WARNING:1,ERROR:2,CRITICAL:3};re=re.filter(Ve=>Ce[Ve.level||"INFO"]>=Ce.WARNING)}else re=re.filter(Ce=>(Ce.level||"INFO")===l.value);if(x.value){const Ce=Uc.find(Ve=>Ve.value===x.value);if(Ce&&Ce.seconds){const Ve=new Date(Date.now()-Ce.seconds*1e3);re=re.filter(Ge=>Ge._time&&Ge._time>=Ve)}}if(o.value&&!X.value)if(r.value)try{const Ce=new RegExp(o.value,"i");re=re.filter(Ve=>{const Ge=Ve.searchText,kt=Ve.tool||"";return Ce.test(Ge)||Ce.test(kt)})}catch{}else{const Ce=o.value.toLowerCase();re=re.filter(Ve=>{const Ge=Ve.searchText.toLowerCase(),kt=(Ve.tool||"").toLowerCase();return Ge.includes(Ce)||kt.includes(Ce)})}return re}),q=U(()=>lC(se.value));function ye(re){const Ce=Od(re,++s);if(n.value){E.value.push(Ce);return}Oe(Ce)}function Oe(re){iC(t.value,re,g),i.value&&Mt(()=>le())}function le(re=!1){const Ce=f.value;Ce&&Ce.scrollTo({top:Ce.scrollHeight,behavior:re?"smooth":"instant"})}function oe(){i.value=!0,m.value=!1,Mt(()=>le(!0))}const ve=new Set(["PageUp","PageDown","ArrowUp","ArrowDown","Home","End"," "]);function j(){const re=f.value;if(!re)return;const Ce=re.scrollHeight-re.scrollTop-re.clientHeight<40;m.value=!i.value&&!Ce&&t.value.length>0,D.value&&V()}function V(){const re=f.value;!re||!i.value||re.scrollHeight-re.scrollTop-re.clientHeight>=40&&(i.value=!1,m.value=t.value.length>0)}function xe(){i.value&&requestAnimationFrame(V)}function k(re){ve.has(re.key)&&xe()}const D=p(!1);function W(){i.value&&(D.value=!0,requestAnimationFrame(V))}function ge(){D.value&&(D.value=!1,V())}function pe(){i.value&&(m.value=!1,Mt(()=>le()))}function he(){if(n.value=!n.value,!n.value&&E.value.length>0){for(const re of E.value)Oe(re);E.value=[]}}function we(){t.value=[],E.value=[],m.value=!1}function K(){let re;e.value==="search"?re=rt.value.map(kt=>{const St=kt.error?"ERROR":"INFO",cn=kt.tool_name?`[${kt.tool_name}] `:"";return`${kt.timestamp||""} ${St} ${cn}${kt.result_summary||kt.message||""}`}).join(` +`):re=se.value.map(Uf).join(` -`);const ke=new Blob([le],{type:"text/plain"}),qe=URL.createObjectURL(ke),Ge=document.createElement("a");Ge.href=qe,Ge.download=`odin-logs-${new Date().toISOString().slice(0,19).replace(/:/g,"-")}.txt`,Ge.click(),URL.revokeObjectURL(qe)}function te(le){const ke=Mf(le);navigator.clipboard.writeText(ke).then(()=>{h.value=le.id,setTimeout(()=>{h.value=null},1500)}).catch(()=>{})}function X(le){l.value=l.value===le?"":le,g.value="all",S.value=!1}function xe(le){return le.level==="ERROR"?"log-line-error":le.level==="WARNING"?"log-line-warning":"text-gray-300"}function U(le){return le==="ERROR"?"text-red-500 font-semibold":le==="WARNING"?"text-yellow-500":"text-blue-500"}function ue(le){return le==="ERROR"?"log-chip-error":le==="WARNING"?"log-chip-warning":"log-chip-info"}function oe(le){g.value=le.id;const ke=le.filters;l.value=ke.level||"",x.value=ke.timeRange||"",o.value=ke.text||"",S.value=!!ke.hasToolName,ke.levels&&(l.value=ke.levels.includes("WARNING")&&ke.levels.includes("ERROR")?Pf:ke.levels[0]||"")}function he(le){g.value=le.id,l.value=le.filters.level||"",x.value=le.filters.timeRange||"",o.value=le.filters.text||"",S.value=!!le.filters.hasToolName}function Fe(){if(!R.value.trim())return;const le={id:"custom-"+Date.now(),name:R.value.trim(),filters:{level:l.value,timeRange:x.value,text:o.value,hasToolName:S.value||void 0}};_.value=[..._.value,le],B(),A.value=!1,R.value=""}function Ve(le){_.value=_.value.filter(ke=>ke.id!==le),B(),g.value===le&&(g.value="all",x.value="",S.value=!1,l.value="",o.value="")}const $e=p("all"),Ke=p(""),at=p(""),Xe=p(""),Z=p(""),Ee=p(""),Ae=p(100),Me=eC,ee=p(!1),Le=p(!1),He=p(""),rt=p([]),Zt=p(null),lt=p(null);function gt(){e.value="search",Zt.value||Yt()}async function Yt(){try{Zt.value=await z.get("/api/logs/stats")}catch{}}function cs(){const le=Ee.value;if(!le){Xe.value="",Z.value="";return}const qe={last_5m:300,last_15m:900,last_1h:3600,last_4h:14400,last_24h:86400,last_7d:604800}[le];if(qe){const Ge=new Date(Date.now()-qe*1e3);Xe.value=oa(Ge),Z.value=""}}function oa(le){const ke=qe=>String(qe).padStart(2,"0");return`${le.getFullYear()}-${ke(le.getMonth()+1)}-${ke(le.getDate())}T${ke(le.getHours())}:${ke(le.getMinutes())}`}function js(le){if(!le)return"";const ke=new Date(le);return isNaN(ke.getTime())?"":ke.toISOString()}let Ze=0;async function ra(){if(ee.value)return;const le=++Ze;ee.value=!0,He.value="",Le.value=!0,lt.value=null;try{const ke=new URLSearchParams;$e.value&&$e.value!=="all"&&ke.set("level",$e.value),Ke.value&&ke.set("tool",Ke.value),at.value&&ke.set("q",at.value);const qe=js(Xe.value),Ge=js(Z.value);qe&&ke.set("start",qe),Ge&&ke.set("end",Ge),ke.set("limit",String(Ae.value));const kt=await z.get(`/api/logs/search?${ke.toString()}`);if(le!==Ze)return;rt.value=kt.entries||[]}catch(ke){if(le!==Ze)return;He.value=ke.message||"Search failed",rt.value=[]}finally{le===Ze&&(ee.value=!1)}}function vs(){Ze++,$e.value="all",Ke.value="",at.value="",Xe.value="",Z.value="",Ee.value="",Ae.value=100,rt.value=[],Le.value=!1,He.value="",lt.value=null,ee.value=!1}function Ms(le){lt.value=lt.value===le?null:le}function gs(le){if(!le.timestamp)return"";try{return new Date(le.timestamp).toLocaleString()}catch{return le.timestamp}}function En(le){return le.type==="web_action"?`${le.status||""} (${le.execution_time_ms||0}ms)`:(le.result_summary||"").slice(0,200)}function Qt(le){return le.error?"log-line-error":"text-gray-300"}function _t(le){try{return JSON.stringify(le,null,2)}catch{return String(le)}}let ds=null,ba=!1;function rn(){ba||(ba=!0,ut.subscribe("logs",ye),c.value=ut.connected,d.value=ut.state||"disconnected",ds=ut.onState(le=>{d.value=le,c.value=le==="connected"}))}function ca(){ba&&(ba=!1,ut.unsubscribe("logs",ye),ds&&(ds(),ds=null))}return st(()=>{O(),window.addEventListener("pointerup",me),window.addEventListener("pointercancel",me)}),rs(rn),Kt(ca),xt(()=>{ca(),window.removeEventListener("pointerup",me),window.removeEventListener("pointercancel",me)}),{mode:e,logs:t,paused:n,autoScroll:i,levelFilter:l,textFilter:o,useRegex:r,groupByTurn:a,groupedLogs:j,subscribed:c,wsState:d,wsStateLabel:u,logContainer:f,filteredLogs:se,pauseBuffer:w,showJumpBottom:m,copiedIndex:h,regexError:Q,levels:I,logPresets:k,timeRanges:y,timeRange:x,toolOnly:S,activeLogPreset:g,customLogPresets:_,showSaveLogPreset:A,newLogPresetName:R,hasActiveLogFilters:T,timeRangeLabel:$,timelineBuckets:N,timelineMax:L,timelineSpanLabel:D,timelineLabelSkip:re,togglePause:fe,clearLogs:_e,exportLogs:q,logLineClass:xe,levelClass:U,levelChipClass:ue,toggleLevel:X,copyLine:te,jumpToBottom:ie,onScroll:be,onUserScrollIntent:Ne,onUserScrollKey:E,onAutoScrollToggle:de,onPointerDown:G,applyLogPreset:oe,applyCustomLogPreset:he,saveLogCustomPreset:Fe,removeLogCustomPreset:Ve,segmentHeight:J,jumpToTimelineBucket:K,searchLevel:$e,searchTool:Ke,searchKeyword:at,searchStart:Xe,searchEnd:Z,searchTimePreset:Ee,searchLimit:Ae,searchLimits:Me,searching:ee,searchRan:Le,searchError:He,searchResults:rt,searchStats:Zt,expandedSearch:lt,switchToSearch:gt,runSearch:ra,clearSearchFilters:vs,toggleSearchExpand:Ms,formatSearchTs:gs,searchEntryText:En,searchLogLineClass:Qt,formatJson:_t,applySearchTimePreset:cs}}};function Lo(e=[]){const t=[],s=new Set;function a(n){const i=[n.kind,n.label,n.apply_mode||"",n.code||"",n.text||""].join("\0");s.has(i)||(s.add(i),t.push({...n,key:i}))}for(const n of e)for(const i of(n==null?void 0:n.consumers)||[])a({kind:"consumer",label:i.name,apply_mode:i.apply_mode,text:i.detail});for(const n of e)n!=null&&n.apply_handler&&a({kind:"handler",label:"Apply handler",code:n.apply_handler});for(const n of e)n!=null&&n.restart_reason&&a({kind:"restart",label:"Why a restart is required",text:n.restart_reason});for(const n of e)n!=null&&n.activation_policy&&a({kind:"activation",label:"Activation policy",text:n.activation_policy});return t}const sC=Object.freeze([{key:"all",label:"All fields",short:"All",icon:"grid"},{key:"applied",label:"Applied",short:"Applied",icon:"success"},{key:"pending_restart",label:"Pending restart",short:"Restart",icon:"refresh"},{key:"dormant",label:"Saved, not active",short:"Saved only",icon:"pause"},{key:"invalid",label:"Invalid",short:"Invalid",icon:"error"},{key:"drift",label:"Drift",short:"Drift",icon:"warning"},{key:"unknown",label:"Effective state unknown",short:"Unknown",icon:"info"}]);function aC(e,t={}){var n,i;const s=t.getStyle||(l=>globalThis.getComputedStyle(l)),a=Object.hasOwn(t,"fallback")?t.fallback:(n=globalThis.document)==null?void 0:n.scrollingElement;for(let l=e;l;l=l.parentElement){const o=((i=s(l))==null?void 0:i.overflowY)||"";if(/^(auto|scroll|overlay)$/.test(o)&&l.scrollHeight>l.clientHeight)return l}return a&&a.scrollHeight>a.clientHeight?a:e||a||null}const $i=[{key:"core",label:"Core",icon:"sliders",sections:["timezone","logging","permissions","graceful_degradation"]},{key:"models",label:"Models & AI",icon:"brain",sections:["image","llm_recovery"]},{key:"runtime",label:"Runtime",icon:"activity",sections:["context","sessions","agents","turn_state"]},{key:"data",label:"Data & Storage",icon:"database",sections:["learning","search","usage","audit","attachments"]},{key:"services",label:"Services",icon:"link",sections:["webhook","observability","email","browser","slack","mcp"]},{key:"automation",label:"Automation",icon:"workflow",sections:["grafana_alerts","outbound_webhooks"]},{key:"infrastructure",label:"Infrastructure",icon:"server",sections:["tools","web"]}],nC={live_read:"Applies immediately",live_apply:"Dedicated live apply",live_for_new_work:"Applies to new work",restart:"Restart required",activation_required:"Saved only — see activation note",legacy_control:"Controlled elsewhere",dormant:"Saved for future support"},No=new Set(["llm_provider","openai_codex","ollama","openai_compatible","kimi","personality","discord","computer"]),iC=Object.freeze(["web.api_tokens","outbound_webhooks.targets"]);function $f(e){return iC.some(t=>e===t||e.startsWith(`${t}.`))}const Tg="odin_config_center_expanded_v1",Eg="odin_config_center_category_v1",lC=50,oC=650,ml=()=>z.get("/api/config/meta");function Un(e){return e===void 0?void 0:JSON.parse(JSON.stringify(e))}function Ei(e,t){return JSON.stringify(e)===JSON.stringify(t)}function bi(e){return String(e).replace(/[_-]+/g," ").replace(/\b\w/g,t=>t.toUpperCase())}function rC(e){return e===void 0?"unset":e===null?"null":typeof e=="boolean"?e?"Enabled":"Disabled":Array.isArray(e)?e.length?`${e.length} item${e.length===1?"":"s"}`:"Empty list":typeof e=="object"?Object.keys(e).length?`${Object.keys(e).length} field${Object.keys(e).length===1?"":"s"}`:"Empty object":e===""?"Empty":String(e)}function cC(e){if(e===void 0)return"unset";if(e===null)return"null";if(typeof e=="object")try{return JSON.stringify(e,null,2)}catch{return String(e)}return String(e)}function Ag(e,t){if(Ei(e,t))return;if(!(e&&t&&typeof e=="object"&&typeof t=="object"&&!Array.isArray(e)&&!Array.isArray(t)))return Un(t);const a={};for(const[n,i]of Object.entries(t)){const l=Ag(e[n],i);l!==void 0&&(a[n]=l)}return Object.keys(a).length?a:void 0}function dC(e,t){const s={};for(const[a,n]of Object.entries(t||{})){const i=Ag(e==null?void 0:e[a],n);i!==void 0&&(s[a]=i)}return s}function Rg(e,t,s,a){if(Ei(e,t))return;if(e&&t&&typeof e=="object"&&typeof t=="object"&&!Array.isArray(e)&&!Array.isArray(t)){const i=new Set([...Object.keys(e),...Object.keys(t)]);for(const l of i)Rg(e[l],t[l],s?`${s}.${l}`:l,a);return}a.push({path:s,oldVal:e,newVal:t})}function uC(){try{const e=JSON.parse(localStorage.getItem(Tg)||"{}");return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}}function pC(){try{const e=localStorage.getItem(Eg);return $i.some(t=>t.key===e)?e:$i[0].key}catch{return $i[0].key}}const fC={template:` +`);const Ce=new Blob([re],{type:"text/plain"}),Ve=URL.createObjectURL(Ce),Ge=document.createElement("a");Ge.href=Ve,Ge.download=`odin-logs-${new Date().toISOString().slice(0,19).replace(/:/g,"-")}.txt`,Ge.click(),URL.revokeObjectURL(Ve)}function ne(re){const Ce=Uf(re);navigator.clipboard.writeText(Ce).then(()=>{h.value=re.id,setTimeout(()=>{h.value=null},1500)}).catch(()=>{})}function te(re){l.value=l.value===re?"":re,b.value="all",S.value=!1}function _e(re){return re.level==="ERROR"?"log-line-error":re.level==="WARNING"?"log-line-warning":"text-gray-300"}function B(re){return re==="ERROR"?"text-red-500 font-semibold":re==="WARNING"?"text-yellow-500":"text-blue-500"}function fe(re){return re==="ERROR"?"log-chip-error":re==="WARNING"?"log-chip-warning":"log-chip-info"}function de(re){b.value=re.id;const Ce=re.filters;l.value=Ce.level||"",x.value=Ce.timeRange||"",o.value=Ce.text||"",S.value=!!Ce.hasToolName,Ce.levels&&(l.value=Ce.levels.includes("WARNING")&&Ce.levels.includes("ERROR")?Hf:Ce.levels[0]||"")}function be(re){b.value=re.id,l.value=re.filters.level||"",x.value=re.filters.timeRange||"",o.value=re.filters.text||"",S.value=!!re.filters.hasToolName}function Fe(){if(!L.value.trim())return;const re={id:"custom-"+Date.now(),name:L.value.trim(),filters:{level:l.value,timeRange:x.value,text:o.value,hasToolName:S.value||void 0}};_.value=[..._.value,re],R(),T.value=!1,L.value=""}function qe(re){_.value=_.value.filter(Ce=>Ce.id!==re),R(),b.value===re&&(b.value="all",x.value="",S.value=!1,l.value="",o.value="")}const $e=p("all"),Ke=p(""),at=p(""),Xe=p(""),Y=p(""),Ae=p(""),Re=p(100),Me=dC,ae=p(!1),Ne=p(!1),He=p(""),rt=p([]),Zt=p(null),lt=p(null);function gt(){e.value="search",Zt.value||Qt()}async function Qt(){try{Zt.value=await z.get("/api/logs/stats")}catch{}}function cs(){const re=Ae.value;if(!re){Xe.value="",Y.value="";return}const Ve={last_5m:300,last_15m:900,last_1h:3600,last_4h:14400,last_24h:86400,last_7d:604800}[re];if(Ve){const Ge=new Date(Date.now()-Ve*1e3);Xe.value=oa(Ge),Y.value=""}}function oa(re){const Ce=Ve=>String(Ve).padStart(2,"0");return`${re.getFullYear()}-${Ce(re.getMonth()+1)}-${Ce(re.getDate())}T${Ce(re.getHours())}:${Ce(re.getMinutes())}`}function js(re){if(!re)return"";const Ce=new Date(re);return isNaN(Ce.getTime())?"":Ce.toISOString()}let Ze=0;async function ra(){if(ae.value)return;const re=++Ze;ae.value=!0,He.value="",Ne.value=!0,lt.value=null;try{const Ce=new URLSearchParams;$e.value&&$e.value!=="all"&&Ce.set("level",$e.value),Ke.value&&Ce.set("tool",Ke.value),at.value&&Ce.set("q",at.value);const Ve=js(Xe.value),Ge=js(Y.value);Ve&&Ce.set("start",Ve),Ge&&Ce.set("end",Ge),Ce.set("limit",String(Re.value));const kt=await z.get(`/api/logs/search?${Ce.toString()}`);if(re!==Ze)return;rt.value=kt.entries||[]}catch(Ce){if(re!==Ze)return;He.value=Ce.message||"Search failed",rt.value=[]}finally{re===Ze&&(ae.value=!1)}}function vs(){Ze++,$e.value="all",Ke.value="",at.value="",Xe.value="",Y.value="",Ae.value="",Re.value=100,rt.value=[],Ne.value=!1,He.value="",lt.value=null,ae.value=!1}function Ms(re){lt.value=lt.value===re?null:re}function gs(re){if(!re.timestamp)return"";try{return new Date(re.timestamp).toLocaleString()}catch{return re.timestamp}}function An(re){return re.type==="web_action"?`${re.status||""} (${re.execution_time_ms||0}ms)`:(re.result_summary||"").slice(0,200)}function Yt(re){return re.error?"log-line-error":"text-gray-300"}function _t(re){try{return JSON.stringify(re,null,2)}catch{return String(re)}}let ds=null,ba=!1;function rn(){ba||(ba=!0,ut.subscribe("logs",ye),c.value=ut.connected,d.value=ut.state||"disconnected",ds=ut.onState(re=>{d.value=re,c.value=re==="connected"}))}function ca(){ba&&(ba=!1,ut.unsubscribe("logs",ye),ds&&(ds(),ds=null))}return tt(()=>{P(),window.addEventListener("pointerup",ge),window.addEventListener("pointercancel",ge)}),rs(rn),Kt(ca),xt(()=>{ca(),window.removeEventListener("pointerup",ge),window.removeEventListener("pointercancel",ge)}),{mode:e,logs:t,paused:n,autoScroll:i,levelFilter:l,textFilter:o,useRegex:r,groupByTurn:a,groupedLogs:q,subscribed:c,wsState:d,wsStateLabel:u,logContainer:f,filteredLogs:se,pauseBuffer:E,showJumpBottom:m,copiedIndex:h,regexError:X,levels:A,logPresets:w,timeRanges:y,timeRange:x,toolOnly:S,activeLogPreset:b,customLogPresets:_,showSaveLogPreset:T,newLogPresetName:L,hasActiveLogFilters:I,timeRangeLabel:H,timelineBuckets:N,timelineMax:O,timelineSpanLabel:$,timelineLabelSkip:ce,togglePause:he,clearLogs:we,exportLogs:K,logLineClass:_e,levelClass:B,levelChipClass:fe,toggleLevel:te,copyLine:ne,jumpToBottom:oe,onScroll:j,onUserScrollIntent:xe,onUserScrollKey:k,onAutoScrollToggle:pe,onPointerDown:W,applyLogPreset:de,applyCustomLogPreset:be,saveLogCustomPreset:Fe,removeLogCustomPreset:qe,segmentHeight:Z,jumpToTimelineBucket:J,searchLevel:$e,searchTool:Ke,searchKeyword:at,searchStart:Xe,searchEnd:Y,searchTimePreset:Ae,searchLimit:Re,searchLimits:Me,searching:ae,searchRan:Ne,searchError:He,searchResults:rt,searchStats:Zt,expandedSearch:lt,switchToSearch:gt,runSearch:ra,clearSearchFilters:vs,toggleSearchExpand:Ms,formatSearchTs:gs,searchEntryText:An,searchLogLineClass:Yt,formatJson:_t,applySearchTimePreset:cs}}};function $o(e=[]){const t=[],s=new Set;function a(n){const i=[n.kind,n.label,n.apply_mode||"",n.code||"",n.text||""].join("\0");s.has(i)||(s.add(i),t.push({...n,key:i}))}for(const n of e)for(const i of(n==null?void 0:n.consumers)||[])a({kind:"consumer",label:i.name,apply_mode:i.apply_mode,text:i.detail});for(const n of e)n!=null&&n.apply_handler&&a({kind:"handler",label:"Apply handler",code:n.apply_handler});for(const n of e)n!=null&&n.restart_reason&&a({kind:"restart",label:"Why a restart is required",text:n.restart_reason});for(const n of e)n!=null&&n.activation_policy&&a({kind:"activation",label:"Activation policy",text:n.activation_policy});return t}const pC=Object.freeze([{key:"all",label:"All fields",short:"All",icon:"grid"},{key:"applied",label:"Applied",short:"Applied",icon:"success"},{key:"pending_restart",label:"Pending restart",short:"Restart",icon:"refresh"},{key:"dormant",label:"Saved, not active",short:"Saved only",icon:"pause"},{key:"invalid",label:"Invalid",short:"Invalid",icon:"error"},{key:"drift",label:"Drift",short:"Drift",icon:"warning"},{key:"unknown",label:"Effective state unknown",short:"Unknown",icon:"info"}]);function fC(e,t={}){var n,i;const s=t.getStyle||(l=>globalThis.getComputedStyle(l)),a=Object.hasOwn(t,"fallback")?t.fallback:(n=globalThis.document)==null?void 0:n.scrollingElement;for(let l=e;l;l=l.parentElement){const o=((i=s(l))==null?void 0:i.overflowY)||"";if(/^(auto|scroll|overlay)$/.test(o)&&l.scrollHeight>l.clientHeight)return l}return a&&a.scrollHeight>a.clientHeight?a:e||a||null}const Fi=[{key:"core",label:"Core",icon:"sliders",sections:["timezone","logging","permissions","graceful_degradation"]},{key:"models",label:"Models & AI",icon:"brain",sections:["image","llm_recovery"]},{key:"runtime",label:"Runtime",icon:"activity",sections:["context","sessions","agents","turn_state"]},{key:"data",label:"Data & Storage",icon:"database",sections:["learning","search","usage","audit","attachments"]},{key:"services",label:"Services",icon:"link",sections:["webhook","observability","email","browser","mcp"]},{key:"automation",label:"Automation",icon:"workflow",sections:["grafana_alerts","outbound_webhooks"]},{key:"infrastructure",label:"Infrastructure",icon:"server",sections:["tools","web"]}],mC={live_read:"Applies immediately",live_apply:"Dedicated live apply",live_for_new_work:"Applies to new work",restart:"Restart required",activation_required:"Saved only — see activation note",legacy_control:"Controlled elsewhere",dormant:"Saved for future support"},Fo=new Set(["llm_provider","openai_codex","ollama","openai_compatible","kimi","personality","discord","computer"]),hC=Object.freeze(["web.api_tokens","outbound_webhooks.targets"]);function jf(e){return hC.some(t=>e===t||e.startsWith(`${t}.`))}const Lg="odin_config_center_expanded_v1",Ng="odin_config_center_category_v1",vC=50,gC=650,hl=()=>z.get("/api/config/meta");function zn(e){return e===void 0?void 0:JSON.parse(JSON.stringify(e))}function Ai(e,t){return JSON.stringify(e)===JSON.stringify(t)}function yi(e){return String(e).replace(/[_-]+/g," ").replace(/\b\w/g,t=>t.toUpperCase())}function bC(e){return e===void 0?"unset":e===null?"null":typeof e=="boolean"?e?"Enabled":"Disabled":Array.isArray(e)?e.length?`${e.length} item${e.length===1?"":"s"}`:"Empty list":typeof e=="object"?Object.keys(e).length?`${Object.keys(e).length} field${Object.keys(e).length===1?"":"s"}`:"Empty object":e===""?"Empty":String(e)}function yC(e){if(e===void 0)return"unset";if(e===null)return"null";if(typeof e=="object")try{return JSON.stringify(e,null,2)}catch{return String(e)}return String(e)}function Mg(e,t){if(Ai(e,t))return;if(!(e&&t&&typeof e=="object"&&typeof t=="object"&&!Array.isArray(e)&&!Array.isArray(t)))return zn(t);const a={};for(const[n,i]of Object.entries(t)){const l=Mg(e[n],i);l!==void 0&&(a[n]=l)}return Object.keys(a).length?a:void 0}function xC(e,t){const s={};for(const[a,n]of Object.entries(t||{})){const i=Mg(e==null?void 0:e[a],n);i!==void 0&&(s[a]=i)}return s}function Dg(e,t,s,a){if(Ai(e,t))return;if(e&&t&&typeof e=="object"&&typeof t=="object"&&!Array.isArray(e)&&!Array.isArray(t)){const i=new Set([...Object.keys(e),...Object.keys(t)]);for(const l of i)Dg(e[l],t[l],s?`${s}.${l}`:l,a);return}a.push({path:s,oldVal:e,newVal:t})}function _C(){try{const e=JSON.parse(localStorage.getItem(Lg)||"{}");return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}}function wC(){try{const e=localStorage.getItem(Ng);return Fi.some(t=>t.key===e)?e:Fi[0].key}catch{return Fi[0].key}}const kC={template:`
@@ -4812,7 +4827,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p(null),t=p(null),s=p(!0),a=p(null),n=p(!1),i=p(null),l=p(!1),o=p(""),r=p(!1),c=p(""),d=p(""),u=p("");function f(C){i.value=C||null,C&&typeof C.authorized=="boolean"&&(l.value=C.authorized)}async function m(){try{const C=await z.get("/api/setup/status");f(C.listener),u.value=""}catch(C){f(null),u.value=`Listener status could not be loaded: ${C.message||"Unknown error"}`}}async function h(){if(!(!me.value||!o.value.trim()||r.value||G.value)){r.value=!0,c.value="",d.value="";try{const C=z.setListenerExposure(o.value.trim(),l.value);o.value="";const V=await C;c.value=V.message,f(V.listener)}catch(C){d.value=C.message||"Listener consent could not be saved."}finally{o.value="",r.value=!1}}}const b=p(null),I=["image_model","outer_model"],k=p(null),y=p(null),g=p(null),x=p(!1),S=p(!1),_=p(null),A=p(""),R=p("all"),w=p(pC()),O=p(uC()),B=p({}),T=p({}),$=p(""),Q=p({}),W=p({}),N=p([]),L=p([]),D=p(!1),re=p(!1),ce=p(!1);let J=null,K=null,se={path:null,at:0},j=0;const ye=H(()=>{var C;return(((C=t.value)==null?void 0:C.fields)||[]).filter(V=>!No.has(V.path.split(".")[0])&&!$f(V.path))}),Oe=H(()=>new Map(ye.value.map(C=>[C.path,C]))),ne=H(()=>Ne.value.reduce((C,V)=>C+V.sections.length,0)),ie=H(()=>ye.value.length),ve=H(()=>sC),be=H(()=>N.value.length>0),ge=H(()=>L.value.length>0),Ne=H(()=>{if(!e.value)return[];const C=new Set($i.flatMap(Ce=>Ce.sections)),V=$i.map(Ce=>({...Ce,sections:Ce.sections.filter(Je=>Object.hasOwn(e.value,Je)&&!No.has(Je))})).filter(Ce=>Ce.sections.length),ae=Object.keys(e.value).filter(Ce=>!C.has(Ce)&&!No.has(Ce));return ae.length&&V.push({key:"other",label:"Other",icon:"folder",sections:ae}),V}),E=H(()=>e.value?{...e.value,...B.value}:null),P=H(()=>{if(!e.value)return[];const C=[];for(const[V,ae]of Object.entries(B.value))Rg(e.value[V],ae,V,C);return C.filter(V=>!Ei(V.oldVal,V.newVal)).map(V=>{const ae=at(V.path);return{...V,label:(ae==null?void 0:ae.label)||bi(V.path.split(".").at(-1)),apply_mode:(ae==null?void 0:ae.apply_mode)||Le(V.path.split(".")[0])}})}),G=H(()=>P.value.length>0),me=H(()=>!!i.value&&l.value!==i.value.authorized),de=H(()=>{var V;const C=(V=i.value)==null?void 0:V.state;return C==="active"||C==="authorized_loopback"?"active":["pending_widening","pending_narrowing","active_rebind_pending"].includes(C)?"pending":C==="restricted"?"restricted":"unknown"}),fe=H(()=>{var C;return{active:"Exposure active",authorized_loopback:"Authorized · loopback host",pending_widening:"Authorized · restart pending",pending_narrowing:"Restriction saved · restart pending",active_rebind_pending:"Exposed · restart pending",restricted:"Loopback only",unknown:"Runtime state unavailable"}[(C=i.value)==null?void 0:C.state]||"Loading listener state"}),_e=H(()=>i.value?i.value.authorized?i.value.authorization_source==="explicit"?"Beyond-loopback access is explicitly authorized":"Beyond-loopback access is retained from this installation":"Beyond-loopback access is not authorized":"Unavailable"),q=H(()=>{var C;return{explicit:"saved explicitly in config.yml",default:"schema default; no web.host key is saved",unknown:"source could not be verified"}[(C=i.value)==null?void 0:C.configured_host_source]||"source unavailable"}),te=H(()=>{const C=i.value;return!C||C.running_scope==="unavailable"?"Actual bound address unavailable":`${(C.listening_hosts||[]).map((ae,Ce)=>{var Ct;const Je=(Ct=C.listening_ports)==null?void 0:Ct[Ce];return Je?`${ae}:${Je}`:ae}).join(", ")} · ${C.running_scope==="loopback"?"loopback only":"accepting beyond loopback"}`}),X=H(()=>P.value.length),xe=H(()=>new Set(P.value.map(C=>C.path.split(".")[0])).size),U=H(()=>!!A.value||R.value!=="all"),ue=H(()=>{const C={...W.value};for(const V of P.value){const ae=at(V.path),Ce=pe(ae,V.newVal);Ce&&(C[V.path]=Ce)}return C}),oe=H(()=>Object.keys(ue.value).length>0),he=H(()=>e.value?(U.value?Ne.value:Ne.value.filter(V=>V.key===w.value)).map(V=>({...V,sections:V.sections.filter(ae=>Qt(ae))})).filter(V=>V.sections.length):[]),Fe=H(()=>{const C=["live_read","live_apply","live_for_new_work","restart","activation_required","legacy_control","dormant"],V=new Map(C.map(ae=>[ae,[]]));for(const ae of P.value){const Ce=V.has(ae.apply_mode)?ae.apply_mode:"restart";V.get(Ce).push(ae)}return C.filter(ae=>V.get(ae).length).map(ae=>({key:ae,label:bs(ae),entries:V.get(ae)}))}),Ve=H(()=>P.value.filter(C=>C.apply_mode==="restart").length),$e=H(()=>ye.value.filter(C=>C.pending_restart)),Ke=H(()=>$e.value.length);function at(C){const V=Oe.value.get(C);return V?{...V,apply_details:Lo([V])}:null}function Xe(C){const V=`${C}.`;return ye.value.filter(ae=>ae.path===C||ae.path.startsWith(V))}function Z(){return ye.value.some(C=>C.path==="tools.hosts"||C.path.startsWith("tools.hosts."))}function Ee(){var ae,Ce;const C=((Ce=(ae=e.value)==null?void 0:ae.tools)==null?void 0:Ce.hosts)||{},V=Object.keys(C).length;return`${V} host${V===1?"":"s"} configured.`}function Ae(C){return Xe(C).length}function Me(C){return bi(C)}function ee(C){const V=Xe(C);if(!V.length)return`${bi(C)} configuration.`;const ae=V.find(Ct=>Ct.sensitivity==="public"&&Ct.description)||V.find(Ct=>Ct.description),Ce=(ae==null?void 0:ae.description)||"";return Ce.match(/setting for (.+)\.$/i)?`${bi(C)} settings and runtime behaviour.`:Ce}function Le(C){const V=[...new Set(Xe(C).map(ae=>ae.apply_mode))];return V.length===1?V[0]:V.includes("restart")?"restart":V.includes("activation_required")?"activation_required":V[0]||"restart"}function He(C){const V=[...new Set(Xe(C).map(ae=>bs(ae.apply_mode)))];return V.length?V.length===1?V[0]:`Mixed apply behaviour: ${V.join(" · ")}`:""}function rt(C){return Lo(Xe(C))}function Zt(C){var V;return Object.hasOwn(B.value,C)?B.value[C]:(V=e.value)==null?void 0:V[C]}function lt(){const C=Zt("mcp")||{},V=Object.keys(C.servers||{}).length;return`${C.enabled?"Globally enabled":"Globally disabled"} · ${V} configured server${V===1?"":"s"}.`}function gt(C,V){return V.split(".").reduce((ae,Ce)=>ae==null?void 0:ae[Ce],C)}function Yt(C){const V=E.value;return Xe(C).filter(ae=>$f(ae.path)?!1:ae.path.split(".").length<=2?!0:!ae.path.includes(".*")).map(ae=>({...ae,key:ae.path.split(".").at(-1),value:gt(V,ae.path),apply_details:Lo([ae]),editor:ae.path==="agents.final_warning_iterations"?"warning-chips":null}))}function cs(C){const V=C.path.split(".");return V.length>2?V.slice(0,2).join("."):null}function oa(C){const V=new Map;for(const ae of Yt(C)){const Ce=cs(ae),Je=Ce||`${C}.__root`;V.has(Je)||V.set(Je,{key:Je,path:Ce,entries:[]}),V.get(Je).entries.push(ae)}return[...V.values()].map(ae=>{const Ce=ae.entries.find(Je=>Je.group_description);return{...ae,label:ae.path?bi(ae.path.split(".").at(-1)):null,description:(Ce==null?void 0:Ce.group_description)||null,apply_details:Lo(ae.entries),runtime_summaries:Ze(ae.entries)}})}function js(C){return{save:C.save_effect||(C.apply_mode==="dormant"?"Saving records this value in config.yml.":"Saving records this value and validates the section."),runtime:C.runtime_effect||{live_read:"Odin reads the saved value during current work.",live_apply:"Odin reloads this setting without a restart.",live_for_new_work:"New work uses the saved value; existing work keeps its snapshot.",restart:"Odin keeps using its startup value until a clean restart.",activation_required:"Odin keeps the current behavior until you enable this feature separately.",legacy_control:"Odin keeps the existing compatibility behavior until you apply this choice.",dormant:"This version of Odin does not use the saved value. Restarting will not activate it."}[C.apply_mode]||"Effective runtime state is not currently observable."}}function Ze(C){const V=new Map;for(const ae of C){const Ce=js(ae),Je=`${ae.apply_mode}|${Ce.save}|${Ce.runtime}`;V.has(Je)||V.set(Je,{key:Je,label:bs(ae.apply_mode),save:Ce.save,runtime:Ce.runtime})}return[...V.values()]}function ra(C){if(vs(C))return C.runtime_effect||C.activation_policy||"";if(C.apply_mode==="activation_required"){const V=C.activation_policy||C.runtime_effect;return V?`Not active after saving. No activation control exists in this release. ${V}`:"Not active after saving; no activation control exists in this release."}return""}function vs(C){return C.action_available===!0&&!!(C.action_label&&C.action_endpoint)}async function Ms(C){if(vs(C))try{if(ca(C.path))throw new Error("Save this setting before applying its action.");const V=String(C.action_method||"POST").toLowerCase(),ae={post:z.post.bind(z),put:z.put.bind(z),delete:z.del.bind(z)}[V];if(!ae)throw new Error("Unsupported configuration action");await ae(C.action_endpoint,C.action_body||void 0),await Xi(),Ma("success",`${C.action_label} completed.`)}catch(V){Ma("error",V.message||`${C.action_label} failed`)}}function gs(C,V){return[C.label,C.path,C.description,...C.aliases||[]].filter(Boolean).join(" ").toLowerCase().includes(V)}function En(C){const V=A.value.trim().toLowerCase();return V?Xe(C).filter(ae=>gs(ae,V)):[]}function Qt(C){const V=Xe(C);if(R.value!=="all"&&!V.some(Ce=>Ce.apply_state===R.value))return!1;const ae=A.value.trim().toLowerCase();return!ae||`${Me(C)} ${C}`.toLowerCase().includes(ae)?!0:V.some(Ce=>gs(Ce,ae))}function _t(C,V){return Xe(C).filter(ae=>ae.apply_state===V).length}function ds(C){return C==="all"?ie.value:ye.value.filter(V=>V.apply_state===C).length}function ba(C){const V=C.sections.flatMap(ae=>Xe(ae));return{fields:V.length,modified:P.value.filter(ae=>C.sections.includes(ae.path.split(".")[0])).length,pending_restart:V.filter(ae=>ae.apply_state==="pending_restart").length,invalid:V.filter(ae=>ae.apply_state==="invalid").length,dormant:V.filter(ae=>ae.apply_state==="dormant").length}}function rn(C){var V;return Object.hasOwn(B.value,C)&&!Ei((V=e.value)==null?void 0:V[C],B.value[C])}function ca(C){return P.value.some(V=>V.path===C||V.path.startsWith(`${C}.`))}function le(C){w.value=C,A.value="",R.value="all";try{localStorage.setItem(Eg,C)}catch{}}function ke(C){R.value=C}function qe(){A.value="",R.value="all"}function Ge(C){var V;return((V=Ne.value.find(ae=>ae.sections.includes(C)))==null?void 0:V.sections)||[]}function kt(C){const V=Ge(C),ae=V.find(Ce=>O.value[Ce]===!0);return ae||V.find(Ce=>O.value[Ce]!==!1)||null}function St(C){return A.value&&!ce.value&&Qt(C)?!0:ce.value?kt(C)===C:Object.hasOwn(O.value,C)?O.value[C]===!0:!0}function cn(C){const V=!St(C);if(ce.value){const ae={...O.value};for(const Ce of Ge(C))ae[Ce]===!0&&(ae[Ce]=!1);ae[C]=V,O.value=ae;return}O.value={...O.value,[C]:V}}function An(){N.value.push(Un(B.value)),N.value.length>lC&&N.value.shift(),L.value=[]}function dn(){n.value||G.value&&(An(),B.value={},W.value={},D.value=!1)}function Rn(C,V=!1){const ae=Date.now();if(V&&se.path===C&&ae-se.atJe-Ce);$.value="",jt(C,ae)}function ri(C,V){jt(C,(C.value||[]).filter(ae=>ae!==V))}function ns(C){return C.apply_mode==="live_read"?"Odin reads the saved file value on next use.":C.apply_mode==="live_for_new_work"?"New work uses the saved file value.":C.apply_mode==="live_apply"?C.apply_handler?`Apply the saved value through ${C.apply_handler}.`:"Apply it through its dedicated owner page or endpoint.":C.apply_mode==="restart"?"Restart Odin for the saved collection to take effect.":C.apply_mode==="activation_required"?"Saving does not enable it. No activation control exists in this release.":C.apply_mode==="dormant"?"This release does not use the saved collection.":"Follow the runtime details shown for this setting."}function On(C){return C.type==="array"&&Array.isArray(C.value)&&!C.structured_container&&!C.structured_container_child&&C.sensitivity==="public"&&C.value.every(V=>["string","number","boolean"].includes(typeof V))}function Re(C){const V=String(Q.value[C.path]??"").trim();if(!V)return;const ae=[...new Set([...C.value||[],V])];Q.value={...Q.value,[C.path]:""},jt(C,ae)}function M(C,V){jt(C,(C.value||[]).filter(ae=>ae!==V))}function pe(C,V){var Ce;if(!C)return null;if((Ce=C.enum)!=null&&Ce.length&&!C.enum.includes(V))return`Choose one of: ${C.enum.join(", ")}`;if(C.path==="agents.final_warning_iterations"&&(!Array.isArray(V)||!V.length))return"Add at least one warning threshold.";const ae=C.constraints||{};if((C.type==="integer"||C.type==="number")&&typeof V=="number"){if(ae.minimum!==void 0&&Vae.maximum)return`Must be at most ${ae.maximum}${C.unit?` ${C.unit}`:""}`}return null}function we(C){return ue.value[C.path]||null}function Ue(C){const V=`${C}.`;return Object.keys(ue.value).some(ae=>ae===C||ae.startsWith(V))}function We(){n.value||N.value.length&&(L.value.push(Un(B.value)),B.value=N.value.pop(),W.value={},T.value={},se={path:null,at:0})}function Qe(){n.value||L.value.length&&(N.value.push(Un(B.value)),B.value=L.value.pop(),W.value={},T.value={},se={path:null,at:0})}function Et(){!G.value||oe.value||(D.value=!0,re.value=!1)}function pt(){D.value=!1}function At(){dn()}function bs(C){return nC[C]||bi(C||"unknown")}function Nt(C){return`apply-${String(C||"unknown").replaceAll("_","-")}`}function Na(C){return`cfgc-field-${C.replace(/[^a-zA-Z0-9_-]/g,"-")}`}function ci(C){return`${Na(C)}-input`}function mo(C){const V=document.getElementById(Na(C))||document.getElementById(Na(C.split(".").slice(0,2).join(".")));V==null||V.scrollIntoView({behavior:"smooth",block:"center"})}function Ma(C,V){y.value={type:C,message:V},window.setTimeout(()=>{var ae;((ae=y.value)==null?void 0:ae.message)===V&&(y.value=null)},3500)}function ac(){x.value=!1,R.value="pending_restart",A.value="";const C=aC(a.value);C&&(C.scrollTop=0)}function ho(){x.value=!1}function di(C=1800){K&&window.clearTimeout(K),K=window.setTimeout(nc,C)}async function nc(){if(S.value){if(j+=1,j>45){S.value=!1,_.value="Odin did not return with the new startup settings within 90 seconds.";return}try{if(t.value=await ml(),Ke.value===0){S.value=!1,_.value=null,Ma("success","Odin restarted and the saved startup settings are active.");return}}catch{}di(2e3)}}async function Ln(){if(!S.value){_.value=null;try{await z.post("/api/restart",{}),S.value=!0,j=0,x.value=!1,di()}catch(C){_.value=C.message||"Odin could not schedule a restart."}}}async function ic(){if(!(!G.value||oe.value||n.value)){n.value=!0;try{const C=dC(e.value,B.value),V=await z.put("/api/config",C);e.value=V,B.value={},N.value=[],L.value=[],W.value={},D.value=!1;try{t.value=await ml(),g.value=null,x.value=Ke.value>0,Ma("success",Ke.value?`Configuration saved. ${Ke.value} setting${Ke.value===1?"":"s"} still use startup values.`:"Configuration saved. Apply status has been refreshed.")}catch(ae){g.value=ae.message||"Unknown metadata error.",Ma("error",`Configuration saved, but apply status could not be refreshed: ${g.value}`)}}catch(C){Ma("error",C.message||"Configuration could not be saved")}finally{n.value=!1}}}async function Da(){if(!n.value){n.value=!0,b.value=null;try{t.value=await ml(),g.value=null}catch(C){b.value=`Image model status could not be refreshed: ${C.message||"Unknown error"}`}finally{n.value=!1}}}async function lc(C,V){if(n.value||!["follow","pin"].includes(V)||!C.length||C.some(Ce=>{var Je,Ct;return!I.includes(Ce)||!((Ct=(Je=t.value)==null?void 0:Je.image_model_defaults)!=null&&Ct[Ce])}))return;n.value=!0,b.value=null;let ae=!1;try{const Ce=await z.post("/api/config/image-models",{operations:Object.fromEntries(C.map(Je=>[Je,V])),expected_revision:t.value.image_model_revision});ae=!0;for(const Je of C){const Ct=`image.openai.${Je}`,Vs=gt(e.value,Ct),qs=gt(Ce.config,Ct),pn=xa=>!Object.hasOwn(xa,"image")||!Ei(gt(xa,Ct),Vs)?xa:da(xa,Ct.split("."),qs);B.value=pn(B.value),N.value=N.value.map(pn),L.value=L.value.map(pn),e.value=da(e.value,Ct.split("."),qs)}t.value={...t.value,image_model_defaults:Ce.image_model_defaults,image_model_revision:Ce.image_model_revision},Ma("success","Image model defaults saved. Unrelated drafts were not saved.")}catch(Ce){b.value=`Image model operation failed: ${Ce.message||"Unknown error"}. Refresh status before retrying if the outcome is uncertain.`}try{t.value=await ml(),g.value=null}catch(Ce){const Je=`Image model status could not be refreshed: ${Ce.message||"Unknown error"}`;g.value=Je,b.value=ae?`Image model defaults were saved, but ${Je}`:`${b.value} ${Je}`}finally{n.value=!1}}async function Xi(){var C,V;if(!(G.value||n.value)){s.value=!0,k.value=null;try{const ae=await z.get("/api/config"),Ce=await ml();await m(),e.value=ae,t.value=Ce,g.value=null;const Je=Ne.value;if(Je.some(Ct=>Ct.key===w.value)||(w.value=((C=Je[0])==null?void 0:C.key)||$i[0].key),ce.value){const Vs=(((V=Je.find(qs=>qs.key===w.value))==null?void 0:V.sections)||[]).find(qs=>O.value[qs]===!0);O.value=Vs?{...O.value,[Vs]:!0}:{}}}catch(ae){k.value=ae.message||"Unknown configuration error"}finally{s.value=!1}}}function Pa(C){if(D.value||!(C.ctrlKey||C.metaKey))return;const V=C.target;V instanceof HTMLElement&&(V.matches("input, textarea, select")||V.isContentEditable)||(!C.shiftKey&&C.key.toLowerCase()==="z"?(C.preventDefault(),We()):(C.key.toLowerCase()==="y"||C.shiftKey&&C.key.toLowerCase()==="z")&&(C.preventDefault(),Qe()))}function el(C){ce.value=C.matches}Wt(O,C=>{try{localStorage.setItem(Tg,JSON.stringify(C))}catch{}},{deep:!0});let ui=!1;function tl(){ui||(ui=!0,document.addEventListener("keydown",Pa))}function sl(){ui&&(ui=!1,document.removeEventListener("keydown",Pa))}return st(()=>{var C;Xi(),tl(),J=window.matchMedia("(max-width: 760px)"),el(J),(C=J.addEventListener)==null||C.call(J,"change",el)}),rs(tl),Kt(sl),Kt(()=>{o.value=""}),xt(()=>{var C;o.value="",sl(),(C=J==null?void 0:J.removeEventListener)==null||C.call(J,"change",el),K&&window.clearTimeout(K)}),{listenerState:i,listenerConsent:l,listenerCredential:o,listenerSaving:r,listenerMessage:c,listenerError:d,listenerStatusError:u,listenerChoiceChanged:me,listenerStatusTone:de,listenerStatusLabel:fe,listenerAuthorizationCopy:_e,listenerConfiguredSourceCopy:q,listenerRunningCopy:te,saveListenerConsent:h,armKeydown:tl,disarmKeydown:sl,handleKeydown:Pa,config:e,meta:t,loading:s,saving:n,error:k,toast:y,metaRefreshError:g,restartPromptOpen:x,restartScheduled:S,restartError:_,configMain:a,imageModelError:b,imageModelLeaves:I,setImageModelDefaults:lc,refreshImageModelMetadata:Da,searchQuery:A,healthFilter:R,activeCategory:w,reviewOpen:D,mobileOverflowOpen:re,warningThresholdInput:$,arrayInputs:Q,healthFilters:ve,visibleCategories:Ne,displayGroups:he,reviewGroups:Fe,sectionCount:ne,fieldCount:ie,hasChanges:G,changeCount:X,changedSectionCount:xe,hasDraftErrors:oe,canUndo:be,canRedo:ge,globalFilterActive:U,reviewRestartCount:Ve,pendingRestartCount:Ke,pendingRestartFields:$e,healthCount:ds,categoryStats:ba,selectCategory:le,selectHealthFilter:ke,clearFilters:qe,sectionLabel:Me,sectionDescription:ee,sectionFieldCount:Ae,sectionHealthCount:_t,sectionApplySummary:He,sectionApplyDetails:rt,sectionEntries:Yt,fieldGroups:oa,sectionSearchHits:En,mcpConfigSummary:lt,fieldRuntimeCopy:js,fieldSpecificRuntimeNote:ra,hasHonestAction:vs,runFieldAction:Ms,hasHostsCollection:Z,hostsConfigSummary:Ee,sectionChanged:rn,fieldChanged:ca,isSectionExpanded:St,toggleSection:cn,discardAllDrafts:dn,setFieldValue:jt,setNumberFieldValue:li,numberInputValue:In,beginInputEdit:ua,endTextInputEdit:as,endInputEdit:un,addWarningThreshold:oi,removeWarningThreshold:ri,isScalarArray:On,addScalarArrayItem:Re,removeScalarArrayItem:M,fieldError:we,sectionHasErrors:Ue,undo:We,redo:Qe,openReview:Et,closeReview:pt,mobileCancel:At,applyModeLabel:bs,applyClass:Nt,compactValue:rC,formatValue:cC,structuredApplyCopy:ns,fieldId:Na,fieldInputId:ci,focusField:mo,fetchConfig:Xi,saveConfig:ic,restartOdin:Ln,restartLater:ho,reviewPendingRestart:ac}}},mC=/^\d{15,25}$/;function Ig(e){return String((e==null?void 0:e.display_name)||(e==null?void 0:e.username)||(e==null?void 0:e.id)||"Unknown user")}const Og={props:{members:{type:Array,default:()=>[]},excludedIds:{type:Array,default:()=>[]},placeholder:{type:String,default:"Search Discord users…"},ariaLabel:{type:String,default:"Search Discord users"},optionsId:{type:String,required:!0},autofocus:{type:Boolean,default:!1},showAddButton:{type:Boolean,default:!1}},emits:["select"],template:` + `,setup(){const e=p(null),t=p(null),s=p(!0),a=p(null),n=p(!1),i=p(null),l=p(!1),o=p(""),r=p(!1),c=p(""),d=p(""),u=p("");function f(C){i.value=C||null,C&&typeof C.authorized=="boolean"&&(l.value=C.authorized)}async function m(){try{const C=await z.get("/api/setup/status");f(C.listener),u.value=""}catch(C){f(null),u.value=`Listener status could not be loaded: ${C.message||"Unknown error"}`}}async function h(){if(!(!ge.value||!o.value.trim()||r.value||W.value)){r.value=!0,c.value="",d.value="";try{const C=z.setListenerExposure(o.value.trim(),l.value);o.value="";const G=await C;c.value=G.message,f(G.listener)}catch(C){d.value=C.message||"Listener consent could not be saved."}finally{o.value="",r.value=!1}}}const g=p(null),A=["image_model","outer_model"],w=p(null),y=p(null),b=p(null),x=p(!1),S=p(!1),_=p(null),T=p(""),L=p("all"),E=p(wC()),P=p(_C()),R=p({}),I=p({}),H=p(""),X=p({}),Q=p({}),N=p([]),O=p([]),$=p(!1),ce=p(!1),ue=p(!1);let Z=null,J=null,se={path:null,at:0},q=0;const ye=U(()=>{var C;return(((C=t.value)==null?void 0:C.fields)||[]).filter(G=>!Fo.has(G.path.split(".")[0])&&!jf(G.path))}),Oe=U(()=>new Map(ye.value.map(C=>[C.path,C]))),le=U(()=>xe.value.reduce((C,G)=>C+G.sections.length,0)),oe=U(()=>ye.value.length),ve=U(()=>pC),j=U(()=>N.value.length>0),V=U(()=>O.value.length>0),xe=U(()=>{if(!e.value)return[];const C=new Set(Fi.flatMap(Te=>Te.sections)),G=Fi.map(Te=>({...Te,sections:Te.sections.filter(Je=>Object.hasOwn(e.value,Je)&&!Fo.has(Je))})).filter(Te=>Te.sections.length),ie=Object.keys(e.value).filter(Te=>!C.has(Te)&&!Fo.has(Te));return ie.length&&G.push({key:"other",label:"Other",icon:"folder",sections:ie}),G}),k=U(()=>e.value?{...e.value,...R.value}:null),D=U(()=>{if(!e.value)return[];const C=[];for(const[G,ie]of Object.entries(R.value))Dg(e.value[G],ie,G,C);return C.filter(G=>!Ai(G.oldVal,G.newVal)).map(G=>{const ie=at(G.path);return{...G,label:(ie==null?void 0:ie.label)||yi(G.path.split(".").at(-1)),apply_mode:(ie==null?void 0:ie.apply_mode)||Ne(G.path.split(".")[0])}})}),W=U(()=>D.value.length>0),ge=U(()=>!!i.value&&l.value!==i.value.authorized),pe=U(()=>{var G;const C=(G=i.value)==null?void 0:G.state;return C==="active"||C==="authorized_loopback"?"active":["pending_widening","pending_narrowing","active_rebind_pending"].includes(C)?"pending":C==="restricted"?"restricted":"unknown"}),he=U(()=>{var C;return{active:"Exposure active",authorized_loopback:"Authorized · loopback host",pending_widening:"Authorized · restart pending",pending_narrowing:"Restriction saved · restart pending",active_rebind_pending:"Exposed · restart pending",restricted:"Loopback only",unknown:"Runtime state unavailable"}[(C=i.value)==null?void 0:C.state]||"Loading listener state"}),we=U(()=>i.value?i.value.authorized?i.value.authorization_source==="explicit"?"Beyond-loopback access is explicitly authorized":"Beyond-loopback access is retained from this installation":"Beyond-loopback access is not authorized":"Unavailable"),K=U(()=>{var C;return{explicit:"saved explicitly in config.yml",default:"schema default; no web.host key is saved",unknown:"source could not be verified"}[(C=i.value)==null?void 0:C.configured_host_source]||"source unavailable"}),ne=U(()=>{const C=i.value;return!C||C.running_scope==="unavailable"?"Actual bound address unavailable":`${(C.listening_hosts||[]).map((ie,Te)=>{var Ct;const Je=(Ct=C.listening_ports)==null?void 0:Ct[Te];return Je?`${ie}:${Je}`:ie}).join(", ")} · ${C.running_scope==="loopback"?"loopback only":"accepting beyond loopback"}`}),te=U(()=>D.value.length),_e=U(()=>new Set(D.value.map(C=>C.path.split(".")[0])).size),B=U(()=>!!T.value||L.value!=="all"),fe=U(()=>{const C={...Q.value};for(const G of D.value){const ie=at(G.path),Te=me(ie,G.newVal);Te&&(C[G.path]=Te)}return C}),de=U(()=>Object.keys(fe.value).length>0),be=U(()=>e.value?(B.value?xe.value:xe.value.filter(G=>G.key===E.value)).map(G=>({...G,sections:G.sections.filter(ie=>Yt(ie))})).filter(G=>G.sections.length):[]),Fe=U(()=>{const C=["live_read","live_apply","live_for_new_work","restart","activation_required","legacy_control","dormant"],G=new Map(C.map(ie=>[ie,[]]));for(const ie of D.value){const Te=G.has(ie.apply_mode)?ie.apply_mode:"restart";G.get(Te).push(ie)}return C.filter(ie=>G.get(ie).length).map(ie=>({key:ie,label:bs(ie),entries:G.get(ie)}))}),qe=U(()=>D.value.filter(C=>C.apply_mode==="restart").length),$e=U(()=>ye.value.filter(C=>C.pending_restart)),Ke=U(()=>$e.value.length);function at(C){const G=Oe.value.get(C);return G?{...G,apply_details:$o([G])}:null}function Xe(C){const G=`${C}.`;return ye.value.filter(ie=>ie.path===C||ie.path.startsWith(G))}function Y(){return ye.value.some(C=>C.path==="tools.hosts"||C.path.startsWith("tools.hosts."))}function Ae(){var ie,Te;const C=((Te=(ie=e.value)==null?void 0:ie.tools)==null?void 0:Te.hosts)||{},G=Object.keys(C).length;return`${G} host${G===1?"":"s"} configured.`}function Re(C){return Xe(C).length}function Me(C){return yi(C)}function ae(C){const G=Xe(C);if(!G.length)return`${yi(C)} configuration.`;const ie=G.find(Ct=>Ct.sensitivity==="public"&&Ct.description)||G.find(Ct=>Ct.description),Te=(ie==null?void 0:ie.description)||"";return Te.match(/setting for (.+)\.$/i)?`${yi(C)} settings and runtime behaviour.`:Te}function Ne(C){const G=[...new Set(Xe(C).map(ie=>ie.apply_mode))];return G.length===1?G[0]:G.includes("restart")?"restart":G.includes("activation_required")?"activation_required":G[0]||"restart"}function He(C){const G=[...new Set(Xe(C).map(ie=>bs(ie.apply_mode)))];return G.length?G.length===1?G[0]:`Mixed apply behaviour: ${G.join(" · ")}`:""}function rt(C){return $o(Xe(C))}function Zt(C){var G;return Object.hasOwn(R.value,C)?R.value[C]:(G=e.value)==null?void 0:G[C]}function lt(){const C=Zt("mcp")||{},G=Object.keys(C.servers||{}).length;return`${C.enabled?"Globally enabled":"Globally disabled"} · ${G} configured server${G===1?"":"s"}.`}function gt(C,G){return G.split(".").reduce((ie,Te)=>ie==null?void 0:ie[Te],C)}function Qt(C){const G=k.value;return Xe(C).filter(ie=>jf(ie.path)?!1:ie.path.split(".").length<=2?!0:!ie.path.includes(".*")).map(ie=>({...ie,key:ie.path.split(".").at(-1),value:gt(G,ie.path),apply_details:$o([ie]),editor:ie.path==="agents.final_warning_iterations"?"warning-chips":null}))}function cs(C){const G=C.path.split(".");return G.length>2?G.slice(0,2).join("."):null}function oa(C){const G=new Map;for(const ie of Qt(C)){const Te=cs(ie),Je=Te||`${C}.__root`;G.has(Je)||G.set(Je,{key:Je,path:Te,entries:[]}),G.get(Je).entries.push(ie)}return[...G.values()].map(ie=>{const Te=ie.entries.find(Je=>Je.group_description);return{...ie,label:ie.path?yi(ie.path.split(".").at(-1)):null,description:(Te==null?void 0:Te.group_description)||null,apply_details:$o(ie.entries),runtime_summaries:Ze(ie.entries)}})}function js(C){return{save:C.save_effect||(C.apply_mode==="dormant"?"Saving records this value in config.yml.":"Saving records this value and validates the section."),runtime:C.runtime_effect||{live_read:"Odin reads the saved value during current work.",live_apply:"Odin reloads this setting without a restart.",live_for_new_work:"New work uses the saved value; existing work keeps its snapshot.",restart:"Odin keeps using its startup value until a clean restart.",activation_required:"Odin keeps the current behavior until you enable this feature separately.",legacy_control:"Odin keeps the existing compatibility behavior until you apply this choice.",dormant:"This version of Odin does not use the saved value. Restarting will not activate it."}[C.apply_mode]||"Effective runtime state is not currently observable."}}function Ze(C){const G=new Map;for(const ie of C){const Te=js(ie),Je=`${ie.apply_mode}|${Te.save}|${Te.runtime}`;G.has(Je)||G.set(Je,{key:Je,label:bs(ie.apply_mode),save:Te.save,runtime:Te.runtime})}return[...G.values()]}function ra(C){if(vs(C))return C.runtime_effect||C.activation_policy||"";if(C.apply_mode==="activation_required"){const G=C.activation_policy||C.runtime_effect;return G?`Not active after saving. No activation control exists in this release. ${G}`:"Not active after saving; no activation control exists in this release."}return""}function vs(C){return C.action_available===!0&&!!(C.action_label&&C.action_endpoint)}async function Ms(C){if(vs(C))try{if(ca(C.path))throw new Error("Save this setting before applying its action.");const G=String(C.action_method||"POST").toLowerCase(),ie={post:z.post.bind(z),put:z.put.bind(z),delete:z.del.bind(z)}[G];if(!ie)throw new Error("Unsupported configuration action");await ie(C.action_endpoint,C.action_body||void 0),await el(),Ma("success",`${C.action_label} completed.`)}catch(G){Ma("error",G.message||`${C.action_label} failed`)}}function gs(C,G){return[C.label,C.path,C.description,...C.aliases||[]].filter(Boolean).join(" ").toLowerCase().includes(G)}function An(C){const G=T.value.trim().toLowerCase();return G?Xe(C).filter(ie=>gs(ie,G)):[]}function Yt(C){const G=Xe(C);if(L.value!=="all"&&!G.some(Te=>Te.apply_state===L.value))return!1;const ie=T.value.trim().toLowerCase();return!ie||`${Me(C)} ${C}`.toLowerCase().includes(ie)?!0:G.some(Te=>gs(Te,ie))}function _t(C,G){return Xe(C).filter(ie=>ie.apply_state===G).length}function ds(C){return C==="all"?oe.value:ye.value.filter(G=>G.apply_state===C).length}function ba(C){const G=C.sections.flatMap(ie=>Xe(ie));return{fields:G.length,modified:D.value.filter(ie=>C.sections.includes(ie.path.split(".")[0])).length,pending_restart:G.filter(ie=>ie.apply_state==="pending_restart").length,invalid:G.filter(ie=>ie.apply_state==="invalid").length,dormant:G.filter(ie=>ie.apply_state==="dormant").length}}function rn(C){var G;return Object.hasOwn(R.value,C)&&!Ai((G=e.value)==null?void 0:G[C],R.value[C])}function ca(C){return D.value.some(G=>G.path===C||G.path.startsWith(`${C}.`))}function re(C){E.value=C,T.value="",L.value="all";try{localStorage.setItem(Ng,C)}catch{}}function Ce(C){L.value=C}function Ve(){T.value="",L.value="all"}function Ge(C){var G;return((G=xe.value.find(ie=>ie.sections.includes(C)))==null?void 0:G.sections)||[]}function kt(C){const G=Ge(C),ie=G.find(Te=>P.value[Te]===!0);return ie||G.find(Te=>P.value[Te]!==!1)||null}function St(C){return T.value&&!ue.value&&Yt(C)?!0:ue.value?kt(C)===C:Object.hasOwn(P.value,C)?P.value[C]===!0:!0}function cn(C){const G=!St(C);if(ue.value){const ie={...P.value};for(const Te of Ge(C))ie[Te]===!0&&(ie[Te]=!1);ie[C]=G,P.value=ie;return}P.value={...P.value,[C]:G}}function Rn(){N.value.push(zn(R.value)),N.value.length>vC&&N.value.shift(),O.value=[]}function dn(){n.value||W.value&&(Rn(),R.value={},Q.value={},$.value=!1)}function In(C,G=!1){const ie=Date.now();if(G&&se.path===C&&ie-se.atJe-Te);H.value="",qt(C,ie)}function ci(C,G){qt(C,(C.value||[]).filter(ie=>ie!==G))}function ns(C){return C.apply_mode==="live_read"?"Odin reads the saved file value on next use.":C.apply_mode==="live_for_new_work"?"New work uses the saved file value.":C.apply_mode==="live_apply"?C.apply_handler?`Apply the saved value through ${C.apply_handler}.`:"Apply it through its dedicated owner page or endpoint.":C.apply_mode==="restart"?"Restart Odin for the saved collection to take effect.":C.apply_mode==="activation_required"?"Saving does not enable it. No activation control exists in this release.":C.apply_mode==="dormant"?"This release does not use the saved collection.":"Follow the runtime details shown for this setting."}function Ln(C){return C.type==="array"&&Array.isArray(C.value)&&!C.structured_container&&!C.structured_container_child&&C.sensitivity==="public"&&C.value.every(G=>["string","number","boolean"].includes(typeof G))}function Ie(C){const G=String(X.value[C.path]??"").trim();if(!G)return;const ie=[...new Set([...C.value||[],G])];X.value={...X.value,[C.path]:""},qt(C,ie)}function M(C,G){qt(C,(C.value||[]).filter(ie=>ie!==G))}function me(C,G){var Te;if(!C)return null;if((Te=C.enum)!=null&&Te.length&&!C.enum.includes(G))return`Choose one of: ${C.enum.join(", ")}`;if(C.path==="agents.final_warning_iterations"&&(!Array.isArray(G)||!G.length))return"Add at least one warning threshold.";const ie=C.constraints||{};if((C.type==="integer"||C.type==="number")&&typeof G=="number"){if(ie.minimum!==void 0&&Gie.maximum)return`Must be at most ${ie.maximum}${C.unit?` ${C.unit}`:""}`}return null}function Se(C){return fe.value[C.path]||null}function Ue(C){const G=`${C}.`;return Object.keys(fe.value).some(ie=>ie===C||ie.startsWith(G))}function We(){n.value||N.value.length&&(O.value.push(zn(R.value)),R.value=N.value.pop(),Q.value={},I.value={},se={path:null,at:0})}function Ye(){n.value||O.value.length&&(N.value.push(zn(R.value)),R.value=O.value.pop(),Q.value={},I.value={},se={path:null,at:0})}function Et(){!W.value||de.value||($.value=!0,ce.value=!1)}function pt(){$.value=!1}function At(){dn()}function bs(C){return mC[C]||yi(C||"unknown")}function Nt(C){return`apply-${String(C||"unknown").replaceAll("_","-")}`}function Na(C){return`cfgc-field-${C.replace(/[^a-zA-Z0-9_-]/g,"-")}`}function di(C){return`${Na(C)}-input`}function ho(C){const G=document.getElementById(Na(C))||document.getElementById(Na(C.split(".").slice(0,2).join(".")));G==null||G.scrollIntoView({behavior:"smooth",block:"center"})}function Ma(C,G){y.value={type:C,message:G},window.setTimeout(()=>{var ie;((ie=y.value)==null?void 0:ie.message)===G&&(y.value=null)},3500)}function rc(){x.value=!1,L.value="pending_restart",T.value="";const C=fC(a.value);C&&(C.scrollTop=0)}function vo(){x.value=!1}function ui(C=1800){J&&window.clearTimeout(J),J=window.setTimeout(cc,C)}async function cc(){if(S.value){if(q+=1,q>45){S.value=!1,_.value="Odin did not return with the new startup settings within 90 seconds.";return}try{if(t.value=await hl(),Ke.value===0){S.value=!1,_.value=null,Ma("success","Odin restarted and the saved startup settings are active.");return}}catch{}ui(2e3)}}async function Nn(){if(!S.value){_.value=null;try{await z.post("/api/restart",{}),S.value=!0,q=0,x.value=!1,ui()}catch(C){_.value=C.message||"Odin could not schedule a restart."}}}async function dc(){if(!(!W.value||de.value||n.value)){n.value=!0;try{const C=xC(e.value,R.value),G=await z.put("/api/config",C);e.value=G,R.value={},N.value=[],O.value=[],Q.value={},$.value=!1;try{t.value=await hl(),b.value=null,x.value=Ke.value>0,Ma("success",Ke.value?`Configuration saved. ${Ke.value} setting${Ke.value===1?"":"s"} still use startup values.`:"Configuration saved. Apply status has been refreshed.")}catch(ie){b.value=ie.message||"Unknown metadata error.",Ma("error",`Configuration saved, but apply status could not be refreshed: ${b.value}`)}}catch(C){Ma("error",C.message||"Configuration could not be saved")}finally{n.value=!1}}}async function Da(){if(!n.value){n.value=!0,g.value=null;try{t.value=await hl(),b.value=null}catch(C){g.value=`Image model status could not be refreshed: ${C.message||"Unknown error"}`}finally{n.value=!1}}}async function uc(C,G){if(n.value||!["follow","pin"].includes(G)||!C.length||C.some(Te=>{var Je,Ct;return!A.includes(Te)||!((Ct=(Je=t.value)==null?void 0:Je.image_model_defaults)!=null&&Ct[Te])}))return;n.value=!0,g.value=null;let ie=!1;try{const Te=await z.post("/api/config/image-models",{operations:Object.fromEntries(C.map(Je=>[Je,G])),expected_revision:t.value.image_model_revision});ie=!0;for(const Je of C){const Ct=`image.openai.${Je}`,qs=gt(e.value,Ct),Vs=gt(Te.config,Ct),pn=xa=>!Object.hasOwn(xa,"image")||!Ai(gt(xa,Ct),qs)?xa:da(xa,Ct.split("."),Vs);R.value=pn(R.value),N.value=N.value.map(pn),O.value=O.value.map(pn),e.value=da(e.value,Ct.split("."),Vs)}t.value={...t.value,image_model_defaults:Te.image_model_defaults,image_model_revision:Te.image_model_revision},Ma("success","Image model defaults saved. Unrelated drafts were not saved.")}catch(Te){g.value=`Image model operation failed: ${Te.message||"Unknown error"}. Refresh status before retrying if the outcome is uncertain.`}try{t.value=await hl(),b.value=null}catch(Te){const Je=`Image model status could not be refreshed: ${Te.message||"Unknown error"}`;b.value=Je,g.value=ie?`Image model defaults were saved, but ${Je}`:`${g.value} ${Je}`}finally{n.value=!1}}async function el(){var C,G;if(!(W.value||n.value)){s.value=!0,w.value=null;try{const ie=await z.get("/api/config"),Te=await hl();await m(),e.value=ie,t.value=Te,b.value=null;const Je=xe.value;if(Je.some(Ct=>Ct.key===E.value)||(E.value=((C=Je[0])==null?void 0:C.key)||Fi[0].key),ue.value){const qs=(((G=Je.find(Vs=>Vs.key===E.value))==null?void 0:G.sections)||[]).find(Vs=>P.value[Vs]===!0);P.value=qs?{...P.value,[qs]:!0}:{}}}catch(ie){w.value=ie.message||"Unknown configuration error"}finally{s.value=!1}}}function Pa(C){if($.value||!(C.ctrlKey||C.metaKey))return;const G=C.target;G instanceof HTMLElement&&(G.matches("input, textarea, select")||G.isContentEditable)||(!C.shiftKey&&C.key.toLowerCase()==="z"?(C.preventDefault(),We()):(C.key.toLowerCase()==="y"||C.shiftKey&&C.key.toLowerCase()==="z")&&(C.preventDefault(),Ye()))}function tl(C){ue.value=C.matches}jt(P,C=>{try{localStorage.setItem(Lg,JSON.stringify(C))}catch{}},{deep:!0});let pi=!1;function sl(){pi||(pi=!0,document.addEventListener("keydown",Pa))}function al(){pi&&(pi=!1,document.removeEventListener("keydown",Pa))}return tt(()=>{var C;el(),sl(),Z=window.matchMedia("(max-width: 760px)"),tl(Z),(C=Z.addEventListener)==null||C.call(Z,"change",tl)}),rs(sl),Kt(al),Kt(()=>{o.value=""}),xt(()=>{var C;o.value="",al(),(C=Z==null?void 0:Z.removeEventListener)==null||C.call(Z,"change",tl),J&&window.clearTimeout(J)}),{listenerState:i,listenerConsent:l,listenerCredential:o,listenerSaving:r,listenerMessage:c,listenerError:d,listenerStatusError:u,listenerChoiceChanged:ge,listenerStatusTone:pe,listenerStatusLabel:he,listenerAuthorizationCopy:we,listenerConfiguredSourceCopy:K,listenerRunningCopy:ne,saveListenerConsent:h,armKeydown:sl,disarmKeydown:al,handleKeydown:Pa,config:e,meta:t,loading:s,saving:n,error:w,toast:y,metaRefreshError:b,restartPromptOpen:x,restartScheduled:S,restartError:_,configMain:a,imageModelError:g,imageModelLeaves:A,setImageModelDefaults:uc,refreshImageModelMetadata:Da,searchQuery:T,healthFilter:L,activeCategory:E,reviewOpen:$,mobileOverflowOpen:ce,warningThresholdInput:H,arrayInputs:X,healthFilters:ve,visibleCategories:xe,displayGroups:be,reviewGroups:Fe,sectionCount:le,fieldCount:oe,hasChanges:W,changeCount:te,changedSectionCount:_e,hasDraftErrors:de,canUndo:j,canRedo:V,globalFilterActive:B,reviewRestartCount:qe,pendingRestartCount:Ke,pendingRestartFields:$e,healthCount:ds,categoryStats:ba,selectCategory:re,selectHealthFilter:Ce,clearFilters:Ve,sectionLabel:Me,sectionDescription:ae,sectionFieldCount:Re,sectionHealthCount:_t,sectionApplySummary:He,sectionApplyDetails:rt,sectionEntries:Qt,fieldGroups:oa,sectionSearchHits:An,mcpConfigSummary:lt,fieldRuntimeCopy:js,fieldSpecificRuntimeNote:ra,hasHonestAction:vs,runFieldAction:Ms,hasHostsCollection:Y,hostsConfigSummary:Ae,sectionChanged:rn,fieldChanged:ca,isSectionExpanded:St,toggleSection:cn,discardAllDrafts:dn,setFieldValue:qt,setNumberFieldValue:oi,numberInputValue:On,beginInputEdit:ua,endTextInputEdit:as,endInputEdit:un,addWarningThreshold:ri,removeWarningThreshold:ci,isScalarArray:Ln,addScalarArrayItem:Ie,removeScalarArrayItem:M,fieldError:Se,sectionHasErrors:Ue,undo:We,redo:Ye,openReview:Et,closeReview:pt,mobileCancel:At,applyModeLabel:bs,applyClass:Nt,compactValue:bC,formatValue:yC,structuredApplyCopy:ns,fieldId:Na,fieldInputId:di,focusField:ho,fetchConfig:el,saveConfig:dc,restartOdin:Nn,restartLater:vo,reviewPendingRestart:rc}}},SC=/^\d{15,25}$/;function Pg(e){return String((e==null?void 0:e.display_name)||(e==null?void 0:e.username)||(e==null?void 0:e.id)||"Unknown user")}const $g={props:{members:{type:Array,default:()=>[]},excludedIds:{type:Array,default:()=>[]},placeholder:{type:String,default:"Search Discord users…"},ariaLabel:{type:String,default:"Search Discord users"},optionsId:{type:String,required:!0},autofocus:{type:Boolean,default:!1},showAddButton:{type:Boolean,default:!1}},emits:["select"],template:`
- `,setup(e,{emit:t}){const s=p(""),a=p(!1),n=p(0),i=p(null),l=H(()=>new Set((e.excludedIds||[]).map(String))),o=H(()=>{const _=s.value.toLowerCase().trim();return(e.members||[]).filter(A=>l.value.has(String(A.id))?!1:_?f(A).toLowerCase().includes(_)||String(A.username||"").toLowerCase().includes(_)||String(A.id).includes(_):!0)}),r=H(()=>{const _=s.value.trim();return o.value.length===0&&mC.test(_)&&!l.value.has(_)?_:""}),c=H(()=>o.value.length+(r.value?1:0)),d=H(()=>!!s.value.trim()&&!!(o.value[n.value]||r.value&&n.value===o.value.length)),u=H(()=>{if(a.value){if(o.value[n.value])return`${e.optionsId}-${n.value}`;if(r.value&&n.value===o.value.length)return`${e.optionsId}-raw`}});function f(_){return Ig(_)}function m(){a.value=!0,n.value=0}function h(){m()}function b(){const _=Math.max(c.value-1,0);n.value=Math.min(n.value+1,_)}function I(){n.value=Math.max(n.value-1,0)}function k(){const _=o.value[n.value];_?y(_):r.value&&n.value===o.value.length&&g(r.value)}function y(_){g(String(_.id))}function g(_){t("select",_),s.value="",a.value=!1,n.value=0}function x(){a.value=!1}function S(){setTimeout(x,150)}return st(()=>{e.autofocus&&zt(()=>{var _;return(_=i.value)==null?void 0:_.focus()})}),{query:s,open:a,highlightedIndex:n,input:i,filteredMembers:o,rawId:r,activeOptionId:u,selectableValue:d,memberName:f,openOptions:m,onInput:h,highlightNext:b,highlightPrevious:I,selectHighlighted:k,selectMember:y,selectId:g,closeOptions:x,onBlur:S}}};function Ff(e,t,s){var a;return((a=e==null?void 0:e.config)==null?void 0:a[t])!=null?e.config[t]:s==null?void 0:s[t]}const hC={components:{DiscordUserCombobox:Og},template:` + `,setup(e,{emit:t}){const s=p(""),a=p(!1),n=p(0),i=p(null),l=U(()=>new Set((e.excludedIds||[]).map(String))),o=U(()=>{const _=s.value.toLowerCase().trim();return(e.members||[]).filter(T=>l.value.has(String(T.id))?!1:_?f(T).toLowerCase().includes(_)||String(T.username||"").toLowerCase().includes(_)||String(T.id).includes(_):!0)}),r=U(()=>{const _=s.value.trim();return o.value.length===0&&SC.test(_)&&!l.value.has(_)?_:""}),c=U(()=>o.value.length+(r.value?1:0)),d=U(()=>!!s.value.trim()&&!!(o.value[n.value]||r.value&&n.value===o.value.length)),u=U(()=>{if(a.value){if(o.value[n.value])return`${e.optionsId}-${n.value}`;if(r.value&&n.value===o.value.length)return`${e.optionsId}-raw`}});function f(_){return Pg(_)}function m(){a.value=!0,n.value=0}function h(){m()}function g(){const _=Math.max(c.value-1,0);n.value=Math.min(n.value+1,_)}function A(){n.value=Math.max(n.value-1,0)}function w(){const _=o.value[n.value];_?y(_):r.value&&n.value===o.value.length&&b(r.value)}function y(_){b(String(_.id))}function b(_){t("select",_),s.value="",a.value=!1,n.value=0}function x(){a.value=!1}function S(){setTimeout(x,150)}return tt(()=>{e.autofocus&&Mt(()=>{var _;return(_=i.value)==null?void 0:_.focus()})}),{query:s,open:a,highlightedIndex:n,input:i,filteredMembers:o,rawId:r,activeOptionId:u,selectableValue:d,memberName:f,openOptions:m,onInput:h,highlightNext:g,highlightPrevious:A,selectHighlighted:w,selectMember:y,selectId:b,closeOptions:x,onBlur:S}}};function qf(e,t,s){var a;return((a=e==null?void 0:e.config)==null?void 0:a[t])!=null?e.config[t]:s==null?void 0:s[t]}const CC={components:{DiscordUserCombobox:$g},template:`

Discord Channels

@@ -5058,7 +5073,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p([]),t=p({persisted:!1,credential_usable:!1,credential_preferred_storage:!1,connection:{state:"unavailable",detail:"Connection status unavailable"}}),s=p(""),a=p(!1),n=p(!1),i=p(null);let l=null;const o=p(!0),r=p(null),c=p({}),d=p(null),u=p(null),f=p(!1),m=p(null),h=p({}),b=p([]);let I=0;const k=Object.freeze([{key:"allowed_users",label:"Allowed users",description:"Absolute gate for ordinary conversational intake. Guild/channel settings cannot readmit blocked users; explicitly allowed test webhooks bypass this gate.",placeholder:"Search users",userAutocomplete:!0},{key:"channels",label:"Allowed channels",description:"Absolute gate for ordinary conversational intake. Guild/channel settings cannot readmit blocked channels.",placeholder:"Search channels"},{key:"ignore_bot_ids",label:"Ignored bot IDs",description:"Ignored unless the bot explicitly mentions Odin; the effective respond-to-bots policy still applies.",placeholder:"Bot ID",userAutocomplete:!0}]),y=H(()=>{var ge,Ne,E,P;const ie=String(((Ne=(ge=t.value)==null?void 0:ge.connection)==null?void 0:Ne.state)||"").toLowerCase();return{...{connected:{key:"connected",label:"Connected",badgeClass:"badge-success"},connecting:{key:"connecting",label:"Connecting",badgeClass:"badge-warning"},disconnected:{key:"disconnected",label:"Disconnected",badgeClass:""},detached:{key:"disconnected",label:"Disconnected",badgeClass:""},detaching:{key:"disconnected",label:"Disconnected",badgeClass:""},stopped:{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},failed:{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"}}[ie]||{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},detail:((P=(E=t.value)==null?void 0:E.connection)==null?void 0:P.detail)||"Connection status unavailable"}}),g=H(()=>JSON.stringify(d.value)!==JSON.stringify(u.value)),x=H(()=>new Map(b.value.map(ie=>[String(ie.id),ie])));function S(ie){return ie.config&&ie.config.enabled!==void 0?ie.config.enabled:!0}function _(ie){return Ff(ie,"require_mention",d.value)}function A(ie){return Ff(ie,"respond_to_bots",d.value)}function R(ie){return ie.config&&Object.keys(ie.config).length>0}function w(ie){c.value[ie]=!c.value[ie]}function O(ie){const ve=ie.discord||{};return{allowed_users:[...ve.allowed_users||[]],channels:[...ve.channels||[]],respond_to_bots:!!ve.respond_to_bots,require_mention:!!ve.require_mention,ignore_bot_ids:[...ve.ignore_bot_ids||[]]}}async function B({showLoading:ie=!0}={}){const ve=++I;ie&&(o.value=!0),r.value=null;try{const be=await z.get("/api/discord/guilds");ve===I&&(e.value=be)}catch(be){ve===I&&(r.value=be.message)}finally{ie&&ve===I&&(o.value=!1)}}async function T(){try{t.value=await z.get("/api/discord/connection"),i.value=null}catch(ie){i.value=ie.message}}async function $(ie,ve=null){if(!n.value){n.value=!0,i.value=null;try{const be={operation:ie};ve!==null&&(be.token=ve),t.value=await z.post("/api/discord/connection",be),ie==="credentials"&&(s.value="",a.value=!1)}catch(be){i.value=be.message||"Connection update failed."}finally{n.value=!1}}}function Q(){if(!(!a.value||!s.value))return $("credentials",s.value)}function W(){return $("connect")}function N(){return $("detach")}async function L(){o.value=!0,r.value=null;try{const[ie,ve,be]=await Promise.all([z.get("/api/discord/guilds"),z.get("/api/discord/members").catch(()=>[]),z.get("/api/config")]),ge=O(be),Ne=g.value;d.value=ge,Ne||(u.value=JSON.parse(JSON.stringify(ge))),b.value=ve,e.value=ie,m.value=null}catch(ie){r.value=ie.message}finally{o.value=!1}}let D=Promise.resolve();const re=p(new Set);function ce(ie,ve){const be=new Set(re.value);be.add(ie),re.value=be;const ge=D.then(ve);return D=ge.catch(()=>{}),ge.finally(()=>{const Ne=new Set(re.value);Ne.delete(ie),re.value=Ne})}function J(ie,ve,be,ge){const Ne=(ge==null?void 0:ge.target)??null;return ce(`guild:${ie}:${ve}`,async()=>{try{await z.put("/api/discord/guild/"+ie+"/config",{[ve]:be}),await B({showLoading:!1})}catch(E){r.value=E.message,Ne&&typeof be=="boolean"&&(Ne.checked=!be)}})}function K(ie,ve,be,ge,Ne){const E=(Ne==null?void 0:Ne.target)??null;return ce(`channel:${ie}:${be}`,async()=>{try{await z.put("/api/discord/channel/"+ie+"/config",{[be]:ge}),await B({showLoading:!1})}catch(P){r.value=P.message,E&&typeof ge=="boolean"&&(E.checked=!ge)}})}function se(ie,ve){return ce(`channel:${ie}:clear`,async()=>{try{await z.put("/api/discord/channel/"+ie+"/config",{clear:!0}),await B({showLoading:!1})}catch(be){r.value=be.message}})}function j(ie,ve){const be=String(ve);if(!ie.userAutocomplete)return be;const ge=x.value.get(be);return ge?Ig(ge):be}function ye(ie,ve=null){const be=String(ve??h.value[ie]??"").trim();!be||u.value[ie].includes(be)||(u.value[ie]=[...u.value[ie],be],h.value={...h.value,[ie]:""})}function Oe(ie,ve){u.value[ie]=u.value[ie].filter(be=>be!==ve)}async function ne(){if(!(!g.value||f.value)){f.value=!0,m.value=null;try{const ve=(await z.put("/api/config",{discord:u.value})).discord||u.value;d.value={allowed_users:[...ve.allowed_users||[]],channels:[...ve.channels||[]],respond_to_bots:!!ve.respond_to_bots,require_mention:!!ve.require_mention,ignore_bot_ids:[...ve.ignore_bot_ids||[]]},u.value=JSON.parse(JSON.stringify(d.value))}catch(ie){m.value=ie.message||"Global defaults could not be saved."}finally{f.value=!1}}}return st(()=>{L(),T(),l=window.setInterval(T,5e3)}),xt(()=>{l!==null&&window.clearInterval(l),l=null}),{guilds:e,loading:o,error:r,expanded:c,globalDraft:u,globalSaving:f,globalError:m,globalArrayInputs:h,globalMembers:b,globalListEditors:k,globalChanged:g,guildEnabled:S,guildMention:_,guildBots:A,hasOverride:R,toggleGuild:w,fetchAll:L,fetchGuilds:B,setGuildConfig:J,setChannelConfig:K,clearOverride:se,mutationPending:re,globalItemLabel:j,addGlobalItem:ye,removeGlobalItem:Oe,saveGlobalDefaults:ne,connection:t,connectionState:y,connectionToken:s,connectionTokenDirty:a,connectionBusy:n,connectionError:i,saveDiscordCredentials:Q,connectDiscord:W,detachDiscord:N}}},Ws=e=>e==null?e:JSON.parse(JSON.stringify(e));function vC({applyDefault:e,applyUser:t,applyDelete:s,onDefaultConfirmed:a=()=>{},onDefaultRollback:n=()=>{},onUserConfirmed:i=()=>{},onUserRollback:l=()=>{},onUserDeleted:o=()=>{},onError:r=()=>{}}){let c=Promise.resolve(),d=0,u=0;const f=new Map;let m=null;const h=new Map;function b(_){d+=1;const A=c.then(_,_);return c=A.catch(()=>{}),A}function I(_,A){m=Ws(_),h.clear();for(const[R,w]of Object.entries(A||{}))h.set(R,Ws(w))}function k(_){const A=Ws(_),R=++u;return b(async()=>{try{await e(Ws(A)),m=Ws(A),R===u&&a(Ws(A))}catch(w){R===u&&(n(Ws(m)),r(w,{kind:"default"}))}})}function y(_,A){const R=Ws(A),w=(f.get(_)||0)+1;return f.set(_,w),b(async()=>{try{await t(_,Ws(R)),h.set(_,Ws(R)),w===f.get(_)&&i(_,Ws(R))}catch(O){w===f.get(_)&&(l(_,Ws(h.get(_)??null)),r(O,{kind:"user",uid:_}))}})}function g(_){const A=(f.get(_)||0)+1;return f.set(_,A),b(async()=>{try{await s(_),h.delete(_),A===f.get(_)&&o(_)}catch(R){A===f.get(_)&&(l(_,Ws(h.get(_)??null)),r(R,{kind:"delete",uid:_}))}})}async function x(){for(;;){const _=c;if(await _,_===c)return d}}async function S(_){for(;;){const A=await x(),R=await _();if(A===d)return R}}return{seed:I,saveDefault:k,saveUser:y,deleteUser:g,whenIdle:x,readSnapshot:S,get revision(){return d}}}const gC={components:{DiscordUserCombobox:Og},template:` + `,setup(){const e=p([]),t=p({persisted:!1,credential_usable:!1,credential_preferred_storage:!1,connection:{state:"unavailable",detail:"Connection status unavailable"}}),s=p(""),a=p(!1),n=p(!1),i=p(null);let l=null;const o=p(!0),r=p(null),c=p({}),d=p(null),u=p(null),f=p(!1),m=p(null),h=p({}),g=p([]);let A=0;const w=Object.freeze([{key:"allowed_users",label:"Allowed users",description:"Absolute gate for ordinary conversational intake. Guild/channel settings cannot readmit blocked users; explicitly allowed test webhooks bypass this gate.",placeholder:"Search users",userAutocomplete:!0},{key:"channels",label:"Allowed channels",description:"Absolute gate for ordinary conversational intake. Guild/channel settings cannot readmit blocked channels.",placeholder:"Search channels"},{key:"ignore_bot_ids",label:"Ignored bot IDs",description:"Ignored unless the bot explicitly mentions Odin; the effective respond-to-bots policy still applies.",placeholder:"Bot ID",userAutocomplete:!0}]),y=U(()=>{var V,xe,k,D;const oe=String(((xe=(V=t.value)==null?void 0:V.connection)==null?void 0:xe.state)||"").toLowerCase();return{...{connected:{key:"connected",label:"Connected",badgeClass:"badge-success"},connecting:{key:"connecting",label:"Connecting",badgeClass:"badge-warning"},disconnected:{key:"disconnected",label:"Disconnected",badgeClass:""},detached:{key:"disconnected",label:"Disconnected",badgeClass:""},detaching:{key:"disconnected",label:"Disconnected",badgeClass:""},stopped:{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},failed:{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"}}[oe]||{key:"unavailable",label:"Unavailable",badgeClass:"badge-warning"},detail:((D=(k=t.value)==null?void 0:k.connection)==null?void 0:D.detail)||"Connection status unavailable"}}),b=U(()=>JSON.stringify(d.value)!==JSON.stringify(u.value)),x=U(()=>new Map(g.value.map(oe=>[String(oe.id),oe])));function S(oe){return oe.config&&oe.config.enabled!==void 0?oe.config.enabled:!0}function _(oe){return qf(oe,"require_mention",d.value)}function T(oe){return qf(oe,"respond_to_bots",d.value)}function L(oe){return oe.config&&Object.keys(oe.config).length>0}function E(oe){c.value[oe]=!c.value[oe]}function P(oe){const ve=oe.discord||{};return{allowed_users:[...ve.allowed_users||[]],channels:[...ve.channels||[]],respond_to_bots:!!ve.respond_to_bots,require_mention:!!ve.require_mention,ignore_bot_ids:[...ve.ignore_bot_ids||[]]}}async function R({showLoading:oe=!0}={}){const ve=++A;oe&&(o.value=!0),r.value=null;try{const j=await z.get("/api/discord/guilds");ve===A&&(e.value=j)}catch(j){ve===A&&(r.value=j.message)}finally{oe&&ve===A&&(o.value=!1)}}async function I(){try{t.value=await z.get("/api/discord/connection"),i.value=null}catch(oe){i.value=oe.message}}async function H(oe,ve=null){if(!n.value){n.value=!0,i.value=null;try{const j={operation:oe};ve!==null&&(j.token=ve),t.value=await z.post("/api/discord/connection",j),oe==="credentials"&&(s.value="",a.value=!1)}catch(j){i.value=j.message||"Connection update failed."}finally{n.value=!1}}}function X(){if(!(!a.value||!s.value))return H("credentials",s.value)}function Q(){return H("connect")}function N(){return H("detach")}async function O(){o.value=!0,r.value=null;try{const[oe,ve,j]=await Promise.all([z.get("/api/discord/guilds"),z.get("/api/discord/members").catch(()=>[]),z.get("/api/config")]),V=P(j),xe=b.value;d.value=V,xe||(u.value=JSON.parse(JSON.stringify(V))),g.value=ve,e.value=oe,m.value=null}catch(oe){r.value=oe.message}finally{o.value=!1}}let $=Promise.resolve();const ce=p(new Set);function ue(oe,ve){const j=new Set(ce.value);j.add(oe),ce.value=j;const V=$.then(ve);return $=V.catch(()=>{}),V.finally(()=>{const xe=new Set(ce.value);xe.delete(oe),ce.value=xe})}function Z(oe,ve,j,V){const xe=(V==null?void 0:V.target)??null;return ue(`guild:${oe}:${ve}`,async()=>{try{await z.put("/api/discord/guild/"+oe+"/config",{[ve]:j}),await R({showLoading:!1})}catch(k){r.value=k.message,xe&&typeof j=="boolean"&&(xe.checked=!j)}})}function J(oe,ve,j,V,xe){const k=(xe==null?void 0:xe.target)??null;return ue(`channel:${oe}:${j}`,async()=>{try{await z.put("/api/discord/channel/"+oe+"/config",{[j]:V}),await R({showLoading:!1})}catch(D){r.value=D.message,k&&typeof V=="boolean"&&(k.checked=!V)}})}function se(oe,ve){return ue(`channel:${oe}:clear`,async()=>{try{await z.put("/api/discord/channel/"+oe+"/config",{clear:!0}),await R({showLoading:!1})}catch(j){r.value=j.message}})}function q(oe,ve){const j=String(ve);if(!oe.userAutocomplete)return j;const V=x.value.get(j);return V?Pg(V):j}function ye(oe,ve=null){const j=String(ve??h.value[oe]??"").trim();!j||u.value[oe].includes(j)||(u.value[oe]=[...u.value[oe],j],h.value={...h.value,[oe]:""})}function Oe(oe,ve){u.value[oe]=u.value[oe].filter(j=>j!==ve)}async function le(){if(!(!b.value||f.value)){f.value=!0,m.value=null;try{const ve=(await z.put("/api/config",{discord:u.value})).discord||u.value;d.value={allowed_users:[...ve.allowed_users||[]],channels:[...ve.channels||[]],respond_to_bots:!!ve.respond_to_bots,require_mention:!!ve.require_mention,ignore_bot_ids:[...ve.ignore_bot_ids||[]]},u.value=JSON.parse(JSON.stringify(d.value))}catch(oe){m.value=oe.message||"Global defaults could not be saved."}finally{f.value=!1}}}return tt(()=>{O(),I(),l=window.setInterval(I,5e3)}),xt(()=>{l!==null&&window.clearInterval(l),l=null}),{guilds:e,loading:o,error:r,expanded:c,globalDraft:u,globalSaving:f,globalError:m,globalArrayInputs:h,globalMembers:g,globalListEditors:w,globalChanged:b,guildEnabled:S,guildMention:_,guildBots:T,hasOverride:L,toggleGuild:E,fetchAll:O,fetchGuilds:R,setGuildConfig:Z,setChannelConfig:J,clearOverride:se,mutationPending:ce,globalItemLabel:q,addGlobalItem:ye,removeGlobalItem:Oe,saveGlobalDefaults:le,connection:t,connectionState:y,connectionToken:s,connectionTokenDirty:a,connectionBusy:n,connectionError:i,saveDiscordCredentials:X,connectDiscord:Q,detachDiscord:N}}},Ws=e=>e==null?e:JSON.parse(JSON.stringify(e));function TC({applyDefault:e,applyUser:t,applyDelete:s,onDefaultConfirmed:a=()=>{},onDefaultRollback:n=()=>{},onUserConfirmed:i=()=>{},onUserRollback:l=()=>{},onUserDeleted:o=()=>{},onError:r=()=>{}}){let c=Promise.resolve(),d=0,u=0;const f=new Map;let m=null;const h=new Map;function g(_){d+=1;const T=c.then(_,_);return c=T.catch(()=>{}),T}function A(_,T){m=Ws(_),h.clear();for(const[L,E]of Object.entries(T||{}))h.set(L,Ws(E))}function w(_){const T=Ws(_),L=++u;return g(async()=>{try{await e(Ws(T)),m=Ws(T),L===u&&a(Ws(T))}catch(E){L===u&&(n(Ws(m)),r(E,{kind:"default"}))}})}function y(_,T){const L=Ws(T),E=(f.get(_)||0)+1;return f.set(_,E),g(async()=>{try{await t(_,Ws(L)),h.set(_,Ws(L)),E===f.get(_)&&i(_,Ws(L))}catch(P){E===f.get(_)&&(l(_,Ws(h.get(_)??null)),r(P,{kind:"user",uid:_}))}})}function b(_){const T=(f.get(_)||0)+1;return f.set(_,T),g(async()=>{try{await s(_),h.delete(_),T===f.get(_)&&o(_)}catch(L){T===f.get(_)&&(l(_,Ws(h.get(_)??null)),r(L,{kind:"delete",uid:_}))}})}async function x(){for(;;){const _=c;if(await _,_===c)return d}}async function S(_){for(;;){const T=await x(),L=await _();if(T===d)return L}}return{seed:A,saveDefault:w,saveUser:y,deleteUser:b,whenIdle:x,readSnapshot:S,get revision(){return d}}}const EC={components:{DiscordUserCombobox:$g,DiscordIdentity:En},template:`

Host Access Control

@@ -5084,32 +5099,16 @@ ${u.text}`:u.text).join(`

Default Policy

Applied to users without an explicit host access entry.

-
- -
-
- - -
+
{{ chip }}
+ +

Default host: {{ defaultPolicy.default_host || 'None' }}

-
+

User Overrides

+ @@ -5118,7 +5117,7 @@ ${u.text}`:u.text).join(`
- @@ -5126,49 +5125,28 @@ ${u.text}`:u.text).join(`
-
- +
+
- + - + - + - + + @@ -5178,8 +5156,32 @@ ${u.text}`:u.text).join(` + + +
+

Edit hosts

{{ editor === 'default' ? 'Default Policy' : (getMember(editor)?.display_name || editor) }}

+
+ + +
+ +

No matching hosts.

+
+ + +

{{ defaultHostError }}

+ +
+
+ +
+
- `,setup(){const e=p(!0),t=p(""),s=p(null),a=p([]),n=p({}),i=p({allowed_hosts:[],default_host:""}),l=p({}),o=p(!1),r=p([]),c=H(()=>{const T={};for(const $ of r.value)T[$.id]=$;return T});function d(T){return c.value[T]||null}function u(T,$){return T?T.allowed_hosts===null||T.allowed_hosts===void 0?{allowed_hosts:[...$],default_host:T.default_host||"",allow_all:!0}:{allowed_hosts:T.allowed_hosts,default_host:T.default_host||"",allow_all:!1}:{allowed_hosts:[...$],default_host:$[0]||"",allow_all:!0}}const f=vC({applyDefault:async T=>{const $=T.allow_all?null:T.allowed_hosts;await z.put("/api/host-access/default-policy",{allowed_hosts:$,default_host:T.default_host})},applyUser:async(T,$)=>{const Q=$.allow_all?null:$.allowed_hosts;await z.put(`/api/host-access/user/${T}`,{allowed_hosts:Q,default_host:$.default_host})},applyDelete:T=>z.del(`/api/host-access/user/${T}`),onDefaultConfirmed:()=>Se.success("Default policy updated"),onDefaultRollback:T=>{T&&(i.value=T)},onUserConfirmed:T=>{const $=d(T);Se.success(`Updated access for ${$?$.display_name:T}`)},onUserRollback:(T,$)=>{const Q={...l.value};$?Q[T]=$:delete Q[T],l.value=Q},onUserDeleted:T=>{const $={...l.value};delete $[T],l.value=$},onError:(T,$)=>{var W;const Q=$.uid?` ${((W=d($.uid))==null?void 0:W.display_name)||$.uid}`:"";Se.error(`${T.message||"Failed to save"} — reverted${Q}`)}});let m=0;async function h(){const T=++m;e.value=!0,t.value="";try{const $=await f.readSnapshot(()=>z.get("/api/host-access"));if(T!==m)return;s.value=$,a.value=$.available_hosts||[],n.value=$.host_descriptions||{},i.value=u($.default_policy,a.value);const Q=$.users||{},W={};for(const[N,L]of Object.entries(Q))W[N]=u(L,a.value);l.value=W,f.seed(i.value,W)}catch($){T===m&&(t.value=$.message||"Failed to fetch host access data")}finally{T===m&&(e.value=!1)}try{const $=await z.get("/api/discord/members")||[];T===m&&(r.value=$)}catch{T===m&&(r.value=[])}}const b=500,I=new Map;function k(T,$){const Q=I.get(T);Q&&clearTimeout(Q.timer);const W={run:$,timer:null};W.timer=setTimeout(()=>{I.delete(T),$()},b),I.set(T,W)}function y(T){const $=I.get(T);$&&(clearTimeout($.timer),I.delete(T))}function g(){for(const[T,$]of[...I])clearTimeout($.timer),I.delete(T),$.run()}function x(){k("default",()=>f.saveDefault(i.value))}function S(T,$){i.value.allow_all=!1,$?i.value.allowed_hosts.includes(T)||i.value.allowed_hosts.push(T):(i.value.allowed_hosts=i.value.allowed_hosts.filter(Q=>Q!==T),i.value.default_host===T&&(i.value.default_host=i.value.allowed_hosts[0]||"")),x()}function _(T){k(`user:${T}`,()=>{const $=l.value[T];$&&f.saveUser(T,$)})}function A(T,$,Q){const W=l.value[T];W&&(W.allow_all=!1,Q?W.allowed_hosts.includes($)||W.allowed_hosts.push($):(W.allowed_hosts=W.allowed_hosts.filter(N=>N!==$),W.default_host===$&&(W.default_host=W.allowed_hosts[0]||"")),_(T))}function R(T,$){const Q=l.value[T];Q&&(Q.default_host=$,_(T))}function w(){o.value=!0}function O(T){!/^\d{15,25}$/.test(T)||l.value[T]||(l.value[T]={allowed_hosts:[...a.value],default_host:a.value[0]||"",allow_all:!1},f.saveUser(T,l.value[T]),o.value=!1)}async function B(T){const $=d(T);await os({title:"Remove user override",message:`Remove the host access override for ${$?$.display_name:T}? They will fall back to the default policy.`,confirmLabel:"Remove",danger:!0})&&(y(`user:${T}`),await f.deleteUser(T),l.value[T]||Se.success(`Removed override for ${$?$.display_name:T}`))}return st(h),Kt(g),xt(g),{loading:e,error:t,data:s,availableHosts:a,hostDescriptions:n,defaultPolicy:i,users:l,showAddUser:o,members:r,fetchData:h,saveDefaultPolicy:x,toggleDefaultHost:S,getMember:d,toggleUserHost:A,setUserDefault:R,openAddUser:w,addUserById:O,deleteUser:B,flushPendingSaves:g}}},bC={template:` + `,setup(){const e=p(!0),t=p(""),s=p(null),a=p([]),n=p({}),i=p({allowed_hosts:[],default_host:""}),l=p({}),o=p([]),r=p(!1),c=p([]),d=p({overrides:{},config_tiers:{},invalid_overrides:{}}),u=["admin","user","guest"],f=p({}),m=p(""),h=p(null),g=p(null),A=p(!1),w=p(""),y=p(!1),b=p(""),x=p(""),S=U(()=>a.value.filter(j=>`${j} ${n.value[j]||""}`.toLowerCase().includes(b.value.toLowerCase()))),_=U(()=>a.value.filter(j=>{var V,xe;return((V=g.value)==null?void 0:V.allow_all)||((xe=g.value)==null?void 0:xe.allowed_hosts.includes(j))})),T=U(()=>g.value?g.value.default_host&&!_.value.includes(g.value.default_host)?_.value.length?"Choose a new default from the allowed hosts before saving.":"No hosts are allowed. Select None for the default before saving.":y.value&&_.value.length&&!g.value.default_host?"Choose a new default from the allowed hosts before saving.":"":""),L=U(()=>[...new Set([...Object.keys(l.value),...Object.keys(d.value.overrides||{}),...Object.keys(d.value.config_tiers||{}),...Object.keys(d.value.invalid_overrides||{})])]),E=U(()=>L.value.filter(j=>{var V,xe;return`${((V=R(j))==null?void 0:V.display_name)||""} ${((xe=R(j))==null?void 0:xe.username)||""} ${j}`.toLowerCase().includes(x.value.toLowerCase())})),P=U(()=>{const j={};for(const V of c.value)j[V.id]=V;return j});function R(j){return P.value[j]||null}function I(j){if(!j||j.allow_all)return"All hosts";const V=j.allowed_hosts||[];return V.length>3?`${V.slice(0,3).join(", ")} +${V.length-3}`:V.join(", ")||"No hosts"}function H(j){if(j.allow_all)return["All hosts"];const V=j.allowed_hosts||[];return[...V.slice(0,2),...V.length>2?[`+${V.length-2}`]:[],...V.length?[]:["None"]]}function X(j){return o.value.includes(j)?H(l.value[j]):["Inherit policy"]}async function Q(j){var xe,k;if(A.value)return;const V=j==="default"?i.value:l.value[j]||i.value;m.value=j,g.value={...V,allowed_hosts:[...V.allowed_hosts]},y.value=!!V.default_host,b.value="",w.value="",await Mt(),g.value&&!((xe=h.value)!=null&&xe.open)&&((k=h.value)==null||k.showModal())}function N(){var j;A.value||((j=h.value)==null||j.close(),g.value=null,m.value="")}function O(j){if(j.key!=="Tab")return;const V=[...h.value.querySelectorAll("button, input, select")].filter(D=>!D.matches(":disabled")),xe=V[0],k=V.at(-1);if(!xe){j.preventDefault();return}j.shiftKey&&document.activeElement===xe?(j.preventDefault(),k.focus()):!j.shiftKey&&document.activeElement===k&&(j.preventDefault(),xe.focus())}async function $(){var xe;if(!g.value||A.value||T.value)return;const j=m.value,V={...g.value,allowed_hosts:[...g.value.allowed_hosts]};A.value=!0,w.value="",await Mt(),(xe=h.value)!=null&&xe.open&&h.value.focus();try{j==="default"?(i.value=V,await se.saveDefault(V)):(l.value[j]=V,o.value.includes(j)||o.value.push(j),await se.saveUser(j,V))}finally{A.value=!1}w.value||N()}function ce(j){var V,xe;return((V=d.value.overrides)==null?void 0:V[j])||((xe=d.value.config_tiers)==null?void 0:xe[j])||"default"}async function ue(j,V){var xe;try{if(V==="default"){if(!((xe=d.value.overrides)!=null&&xe[j]))return;await z.del(`/api/permissions/user/${encodeURIComponent(j)}`)}else await z.put(`/api/permissions/user/${encodeURIComponent(j)}`,{tier:V});V==="default"?delete d.value.overrides[j]:d.value.overrides[j]=V,ke.success("Permission tier updated")}catch(k){ke.error(k.message||"Failed to update tier"),await ye()}}async function Z(j){const V=f.value[j];if(V)try{await z.post(`/api/permissions/user/${encodeURIComponent(j)}/repair`,{tier:V}),delete d.value.invalid_overrides[j],d.value.overrides[j]=V}catch(xe){ke.error(xe.message||"Failed to repair tier")}}function J(j,V){return j?j.allowed_hosts===null||j.allowed_hosts===void 0?{allowed_hosts:[...V],default_host:j.default_host||"",allow_all:!0}:{allowed_hosts:j.allowed_hosts,default_host:j.default_host||"",allow_all:!1}:{allowed_hosts:[...V],default_host:V[0]||"",allow_all:!0}}const se=TC({applyDefault:async j=>{const V=j.allow_all?null:j.allowed_hosts;await z.put("/api/host-access/default-policy",{allowed_hosts:V,default_host:j.default_host})},applyUser:async(j,V)=>{const xe=V.allow_all?null:V.allowed_hosts;await z.put(`/api/host-access/user/${j}`,{allowed_hosts:xe,default_host:V.default_host})},applyDelete:j=>z.del(`/api/host-access/user/${j}`),onDefaultConfirmed:()=>ke.success("Default policy updated"),onDefaultRollback:j=>{j&&(i.value=j)},onUserConfirmed:j=>{const V=R(j);ke.success(`Updated access for ${V?V.display_name:j}`)},onUserRollback:(j,V)=>{const xe={...l.value};V?xe[j]=V:delete xe[j],l.value=xe,V||(o.value=o.value.filter(k=>k!==j))},onUserDeleted:j=>{const V={...l.value};delete V[j],l.value=V,o.value=o.value.filter(xe=>xe!==j)},onError:(j,V)=>{var k;A.value&&(w.value=j.message||"Failed to save. Your draft is retained; try again.");const xe=V.uid?` ${((k=R(V.uid))==null?void 0:k.display_name)||V.uid}`:"";ke.error(`${j.message||"Failed to save"} — reverted${xe}`)}});let q=0;async function ye(){const j=++q;e.value=!0,t.value="";try{const V=await se.readSnapshot(()=>z.get("/api/host-access"));if(j!==q)return;s.value=V,a.value=V.available_hosts||[],n.value=V.host_descriptions||{},i.value=J(V.default_policy,a.value);const xe=V.users||{};o.value=Object.keys(xe);const k={};for(const[D,W]of Object.entries(xe))k[D]=J(W,a.value);l.value=k,se.seed(i.value,k),d.value=await z.get("/api/permissions/tiers")}catch(V){j===q&&(t.value=V.message||"Failed to fetch host access data")}finally{j===q&&(e.value=!1)}try{const V=await z.get("/api/discord/members")||[];j===q&&(c.value=V)}catch{j===q&&(c.value=[])}}function Oe(){r.value=!0}function le(j){!/^\d{15,25}$/.test(j)||l.value[j]||(l.value[j]={allowed_hosts:[...a.value],default_host:a.value[0]||"",allow_all:!1},o.value.includes(j)||o.value.push(j),se.saveUser(j,l.value[j]),r.value=!1)}async function oe(j){const V=R(j);await os({title:"Remove user override",message:`Remove the host access override for ${V?V.display_name:j}? They will fall back to the default policy.`,confirmLabel:"Remove",danger:!0})&&(await se.deleteUser(j),l.value[j]||ke.success(`Removed override for ${V?V.display_name:j}`))}tt(ye);function ve(){var j;(j=h.value)==null||j.close(),g.value=null,m.value=""}return Kt(ve),xt(ve),{loading:e,error:t,data:s,availableHosts:a,hostDescriptions:n,defaultPolicy:i,users:l,showAddUser:r,members:c,hostOverrideIds:o,fetchData:ye,getMember:R,openAddUser:Oe,addUserById:le,deleteUser:oe,permissions:d,validTiers:u,repairTiers:f,repairTier:Z,tierValue:ce,setTier:ue,editor:m,hostQuery:b,userQuery:x,filteredHosts:S,allUserIds:L,visibleUserIds:E,hostSummary:I,hostChips:H,visibleHostChips:X,hostDialog:h,draft:g,saving:A,saveError:w,requiresDefault:y,defaultHostOptions:_,defaultHostError:T,openEditor:Q,closeEditor:N,saveEditor:$,trapEditorFocus:O}}},AC={template:`

Managed Hosts

Live inventory, pinned SSH trust, and connection health.

@@ -5226,8 +5228,8 @@ ${u.text}`:u.text).join(`

Activation is live. Users with allowed_hosts: null gain this host automatically. Review grants on Host Access after saving.

Open Host Access
-
`,setup(){const e=p([]),t=p(!1),s=p(""),a=p([]),n=p(!1),i=p(!1),l=p(1),o=p(""),r=p(!1),c=p(null),d=p(""),u=p([]),f=p(!1),m=p(null),h=p(""),b=()=>({alias:"",address:"",port:22,ssh_user:"root",os:"linux",description:"",trust_mode:"pinned",enabled:!0,confirm_local:!1,confirm_tofu:!1}),I=p(b()),k=H(()=>["127.0.0.1","localhost","::1"].includes(I.value.address));async function y(){t.value=!0,s.value="";try{const W=await z.get("/api/hosts");e.value=W.hosts||[],o.value=W.default_host||"",r.value=!!W.tofu_enabled}catch(W){s.value=W.message}finally{t.value=!1}}async function g(){try{await z.post("/api/hosts/settings",{default_host:o.value,allow_host_tofu:r.value}),Se.success("Host settings saved and published live"),await y()}catch(W){Se.error(W.message)}}function x(){d.value="",u.value=[],f.value=!1,m.value=null,c.value=null,h.value="",l.value=1,n.value=!0}function S(){i.value=!1,I.value=b(),x()}function _(W){i.value=!0,I.value={...b(),...W},x()}async function A(){try{c.value=await z.get("/api/hosts/public-key")}catch(W){Se.error(W.message)}}async function R(W){try{const N=await z.post("/api/hosts/"+encodeURIComponent(W.alias)+"/import-legacy",{});i.value=!0,I.value={...b(),...W,trust_mode:"pinned"},x(),d.value=N.candidate_token,u.value=N.fingerprints||[],h.value=u.value.join(` -`),l.value=4,Se.info("Imported existing known_hosts trust. Test before activation.")}catch(N){Se.error(N.message)}}async function w(){try{const W=h.value.split(/\s+/).filter(Boolean),N={...I.value,expected_fingerprints:W,candidate_fingerprints:u.value},L=await z.post("/api/hosts/candidates",N);if(d.value=L.candidate_token,u.value=L.fingerprints||[],I.value.trust_mode==="tofu"&&N.candidate_fingerprints.length===0){I.value.confirm_tofu=!1,Se.info("Fingerprint scanned. Review it, tick confirmation, then scan again.");return}l.value=4}catch(W){Se.error(W.message)}}async function O(){var W,N;f.value=!1,m.value=null;try{const L=await z.post("/api/hosts/candidates/"+d.value+"/test",{});f.value=!!L.tested,m.value=L.last_test,f.value&&(l.value=5)}catch(L){const D=(W=L.data)==null?void 0:W.last_test;D&&typeof D=="object"&&!Array.isArray(D)&&(m.value=D);const re=(N=m.value)==null?void 0:N.detail;Se.error(typeof re=="string"&&re.trim()?re:L.message)}}async function B(){try{await z.post("/api/hosts/candidates/"+d.value+"/commit",{}),Se.success("Host saved and published live"),n.value=!1,await y()}catch(W){Se.error(W.message)}}async function T(W){try{await z.post("/api/hosts/"+encodeURIComponent(W.alias)+"/enabled",{enabled:!W.enabled}),await y()}catch(N){Se.error(N.message)}}async function $(W){var N;if(await os("Delete host "+W.alias+"? Dependencies will block deletion.")){a.value=[];try{await z.del("/api/hosts/"+encodeURIComponent(W.alias)),await y()}catch(L){a.value=Array.isArray((N=L.data)==null?void 0:N.pending_references)?L.data.pending_references:[],Se.error(L.message)}}}async function Q(W){if(await os("Force revoke "+W.alias+"? Remote outcomes may be unknown."))try{await z.post("/api/hosts/"+encodeURIComponent(W.alias)+"/force-revoke",{}),await y()}catch(N){Se.error(N.message)}}return st(y),{hosts:e,loading:t,error:s,pendingReferences:a,wizard:n,editing:i,step:l,defaultHost:o,tofuEnabled:r,form:I,isLocal:k,keyInfo:c,candidate:d,observed:u,tested:f,testResult:m,fingerprintsText:h,load:y,saveSettings:g,beginAdd:S,beginEdit:_,loadKey:A,importLegacy:R,prepare:w,testConnection:O,commit:B,toggle:T,remove:$,forceRevoke:Q}}},yC={template:` + `,setup(){const e=p([]),t=p(!1),s=p(""),a=p([]),n=p(!1),i=p(!1),l=p(1),o=p(""),r=p(!1),c=p(null),d=p(""),u=p([]),f=p(!1),m=p(null),h=p(""),g=()=>({alias:"",address:"",port:22,ssh_user:"root",os:"linux",description:"",trust_mode:"pinned",enabled:!0,confirm_local:!1,confirm_tofu:!1}),A=p(g()),w=U(()=>["127.0.0.1","localhost","::1"].includes(A.value.address));async function y(){t.value=!0,s.value="";try{const Q=await z.get("/api/hosts");e.value=Q.hosts||[],o.value=Q.default_host||"",r.value=!!Q.tofu_enabled}catch(Q){s.value=Q.message}finally{t.value=!1}}async function b(){try{await z.post("/api/hosts/settings",{default_host:o.value,allow_host_tofu:r.value}),ke.success("Host settings saved and published live"),await y()}catch(Q){ke.error(Q.message)}}function x(){d.value="",u.value=[],f.value=!1,m.value=null,c.value=null,h.value="",l.value=1,n.value=!0}function S(){i.value=!1,A.value=g(),x()}function _(Q){i.value=!0,A.value={...g(),...Q},x()}async function T(){try{c.value=await z.get("/api/hosts/public-key")}catch(Q){ke.error(Q.message)}}async function L(Q){try{const N=await z.post("/api/hosts/"+encodeURIComponent(Q.alias)+"/import-legacy",{});i.value=!0,A.value={...g(),...Q,trust_mode:"pinned"},x(),d.value=N.candidate_token,u.value=N.fingerprints||[],h.value=u.value.join(` +`),l.value=4,ke.info("Imported existing known_hosts trust. Test before activation.")}catch(N){ke.error(N.message)}}async function E(){try{const Q=h.value.split(/\s+/).filter(Boolean),N={...A.value,expected_fingerprints:Q,candidate_fingerprints:u.value},O=await z.post("/api/hosts/candidates",N);if(d.value=O.candidate_token,u.value=O.fingerprints||[],A.value.trust_mode==="tofu"&&N.candidate_fingerprints.length===0){A.value.confirm_tofu=!1,ke.info("Fingerprint scanned. Review it, tick confirmation, then scan again.");return}l.value=4}catch(Q){ke.error(Q.message)}}async function P(){var Q,N;f.value=!1,m.value=null;try{const O=await z.post("/api/hosts/candidates/"+d.value+"/test",{});f.value=!!O.tested,m.value=O.last_test,f.value&&(l.value=5)}catch(O){const $=(Q=O.data)==null?void 0:Q.last_test;$&&typeof $=="object"&&!Array.isArray($)&&(m.value=$);const ce=(N=m.value)==null?void 0:N.detail;ke.error(typeof ce=="string"&&ce.trim()?ce:O.message)}}async function R(){try{await z.post("/api/hosts/candidates/"+d.value+"/commit",{}),ke.success("Host saved and published live"),n.value=!1,await y()}catch(Q){ke.error(Q.message)}}async function I(Q){try{await z.post("/api/hosts/"+encodeURIComponent(Q.alias)+"/enabled",{enabled:!Q.enabled}),await y()}catch(N){ke.error(N.message)}}async function H(Q){var N;if(await os("Delete host "+Q.alias+"? Dependencies will block deletion.")){a.value=[];try{await z.del("/api/hosts/"+encodeURIComponent(Q.alias)),await y()}catch(O){a.value=Array.isArray((N=O.data)==null?void 0:N.pending_references)?O.data.pending_references:[],ke.error(O.message)}}}async function X(Q){if(await os("Force revoke "+Q.alias+"? Remote outcomes may be unknown."))try{await z.post("/api/hosts/"+encodeURIComponent(Q.alias)+"/force-revoke",{}),await y()}catch(N){ke.error(N.message)}}return tt(y),{hosts:e,loading:t,error:s,pendingReferences:a,wizard:n,editing:i,step:l,defaultHost:o,tofuEnabled:r,form:A,isLocal:w,keyInfo:c,candidate:d,observed:u,tested:f,testResult:m,fingerprintsText:h,load:y,saveSettings:b,beginAdd:S,beginEdit:_,loadKey:T,importLegacy:L,prepare:E,testConnection:P,commit:R,toggle:I,remove:H,forceRevoke:X}}},RC={components:{DiscordIdentity:En},template:`

API Tokens

@@ -5249,6 +5251,16 @@ ${u.text}`:u.text).join(`
+ +
@@ -5366,7 +5378,7 @@ ${u.text}`:u.text).join(`
- + + @@ -5960,6 +5973,28 @@ ${u.text}`:u.text).join(` — + ${e} `}tablecell(e){const t=this.parser.parseInline(e.tokens),s=e.header?"th":"td";return(e.align?`<${s} align="${e.align}">`:`<${s}>`)+t+` -`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${Ca(e,!0)}`}br(e){return"
"}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:s}){const a=this.parser.parseInline(s),n=lm(e);if(n===null)return a;e=n;let i='",i}image({href:e,title:t,text:s,tokens:a}){a&&(s=this.parser.parseInline(a,this.parser.textRenderer));const n=lm(e);if(n===null)return Ca(s);e=n;let i=`${s}{const r=l[o].flat(1/0);s=s.concat(this.walkTokens(r,t))}):l.tokens&&(s=s.concat(this.walkTokens(l.tokens,t)))}}return s}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(s=>{const a={...s};if(a.async=this.defaults.async||a.async||!1,s.extensions&&(s.extensions.forEach(n=>{if(!n.name)throw new Error("extension name required");if("renderer"in n){const i=t.renderers[n.name];i?t.renderers[n.name]=function(...l){let o=n.renderer.apply(this,l);return o===!1&&(o=i.apply(this,l)),o}:t.renderers[n.name]=n.renderer}if("tokenizer"in n){if(!n.level||n.level!=="block"&&n.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=t[n.level];i?i.unshift(n.tokenizer):t[n.level]=[n.tokenizer],n.start&&(n.level==="block"?t.startBlock?t.startBlock.push(n.start):t.startBlock=[n.start]:n.level==="inline"&&(t.startInline?t.startInline.push(n.start):t.startInline=[n.start]))}"childTokens"in n&&n.childTokens&&(t.childTokens[n.name]=n.childTokens)}),a.extensions=t),s.renderer){const n=this.defaults.renderer||new Tr(this.defaults);for(const i in s.renderer){if(!(i in n))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const l=i,o=s.renderer[l],r=n[l];n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d||""}}a.renderer=n}if(s.tokenizer){const n=this.defaults.tokenizer||new Cr(this.defaults);for(const i in s.tokenizer){if(!(i in n))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const l=i,o=s.tokenizer[l],r=n[l];n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d}}a.tokenizer=n}if(s.hooks){const n=this.defaults.hooks||new Jo;for(const i in s.hooks){if(!(i in n))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const l=i,o=s.hooks[l],r=n[l];Jo.passThroughHooks.has(i)?n[l]=c=>{if(this.defaults.async)return Promise.resolve(o.call(n,c)).then(u=>r.call(n,u));const d=o.call(n,c);return r.call(n,d)}:n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d}}a.hooks=n}if(s.walkTokens){const n=this.defaults.walkTokens,i=s.walkTokens;a.walkTokens=function(l){let o=[];return o.push(i.call(this,l)),n&&(o=o.concat(n.call(this,l))),o}}this.defaults={...this.defaults,...a}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return Ka.lex(e,t??this.defaults)}parser(e,t){return Ja.parse(e,t??this.defaults)}parseMarkdown(e){return(s,a)=>{const n={...a},i={...this.defaults,...n},l=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&n.async===!1)return l(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof s>"u"||s===null)return l(new Error("marked(): input parameter is undefined or null"));if(typeof s!="string")return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(s)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=e);const o=i.hooks?i.hooks.provideLexer():e?Ka.lex:Ka.lexInline,r=i.hooks?i.hooks.provideParser():e?Ja.parse:Ja.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(s):s).then(c=>o(c,i)).then(c=>i.hooks?i.hooks.processAllTokens(c):c).then(c=>i.walkTokens?Promise.all(this.walkTokens(c,i.walkTokens)).then(()=>c):c).then(c=>r(c,i)).then(c=>i.hooks?i.hooks.postprocess(c):c).catch(l);try{i.hooks&&(s=i.hooks.preprocess(s));let c=o(s,i);i.hooks&&(c=i.hooks.processAllTokens(c)),i.walkTokens&&this.walkTokens(c,i.walkTokens);let d=r(c,i);return i.hooks&&(d=i.hooks.postprocess(d)),d}catch(c){return l(c)}}}onError(e,t){return s=>{if(s.message+=` -Please report this to https://github.com/markedjs/marked.`,e){const a="

An error occurred:

"+Ca(s.message+"",!0)+"
";return t?Promise.resolve(a):a}if(t)return Promise.reject(s);throw s}}},Xn=new XT;function vt(e,t){return Xn.parse(e,t)}vt.options=vt.setOptions=function(e){return Xn.setOptions(e),vt.defaults=Xn.defaults,Pg(vt.defaults),vt};vt.getDefaults=Mu;vt.defaults=ii;vt.use=function(...e){return Xn.use(...e),vt.defaults=Xn.defaults,Pg(vt.defaults),vt};vt.walkTokens=function(e,t){return Xn.walkTokens(e,t)};vt.parseInline=Xn.parseInline;vt.Parser=Ja;vt.parser=Ja.parse;vt.Renderer=Tr;vt.TextRenderer=Hu;vt.Lexer=Ka;vt.lexer=Ka.lex;vt.Tokenizer=Cr;vt.Hooks=Jo;vt.parse=vt;vt.options;vt.setOptions;vt.use;vt.walkTokens;vt.parseInline;Ja.parse;Ka.lex;const eE={breaks:!0,gfm:!0};function cm(e){if(!e)return"";try{if(typeof vt<"u"&&vt.parse){const t=vt.parse(e,eE);return typeof am<"u"?am.sanitize(t):t}}catch{}return e.replace(/&/g,"&").replace(//g,">").replace(/\n/g,"
")}function tE(e){const t=new Date(e),s=t.getHours().toString().padStart(2,"0"),a=t.getMinutes().toString().padStart(2,"0");return`${s}:${a}`}const sE={run_command:"terminal",ssh_command:"terminal",run_script:"terminal",read_file:"file",apply_patch:"edit",list_directory:"folder",search_knowledge:"search",ingest_document:"book",generate_image:"image",analyze_image:"eye",analyze_pdf:"file",browser_screenshot:"globe",manage_process:"sliders"};function aE(e){return sE[e]||"wrench"}const nE=/https?:\/\/\S+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?\S*)?/gi;function dm(e){if(!e)return[];const t=e.match(nE);return t?[...new Set(t)]:[]}const iE={template:` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${Ca(e,!0)}`}br(e){return"
"}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:s}){const a=this.parser.parseInline(s),n=pm(e);if(n===null)return a;e=n;let i='
",i}image({href:e,title:t,text:s,tokens:a}){a&&(s=this.parser.parseInline(a,this.parser.textRenderer));const n=pm(e);if(n===null)return Ca(s);e=n;let i=`${s}{const r=l[o].flat(1/0);s=s.concat(this.walkTokens(r,t))}):l.tokens&&(s=s.concat(this.walkTokens(l.tokens,t)))}}return s}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(s=>{const a={...s};if(a.async=this.defaults.async||a.async||!1,s.extensions&&(s.extensions.forEach(n=>{if(!n.name)throw new Error("extension name required");if("renderer"in n){const i=t.renderers[n.name];i?t.renderers[n.name]=function(...l){let o=n.renderer.apply(this,l);return o===!1&&(o=i.apply(this,l)),o}:t.renderers[n.name]=n.renderer}if("tokenizer"in n){if(!n.level||n.level!=="block"&&n.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=t[n.level];i?i.unshift(n.tokenizer):t[n.level]=[n.tokenizer],n.start&&(n.level==="block"?t.startBlock?t.startBlock.push(n.start):t.startBlock=[n.start]:n.level==="inline"&&(t.startInline?t.startInline.push(n.start):t.startInline=[n.start]))}"childTokens"in n&&n.childTokens&&(t.childTokens[n.name]=n.childTokens)}),a.extensions=t),s.renderer){const n=this.defaults.renderer||new Or(this.defaults);for(const i in s.renderer){if(!(i in n))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const l=i,o=s.renderer[l],r=n[l];n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d||""}}a.renderer=n}if(s.tokenizer){const n=this.defaults.tokenizer||new Ir(this.defaults);for(const i in s.tokenizer){if(!(i in n))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const l=i,o=s.tokenizer[l],r=n[l];n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d}}a.tokenizer=n}if(s.hooks){const n=this.defaults.hooks||new er;for(const i in s.hooks){if(!(i in n))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const l=i,o=s.hooks[l],r=n[l];er.passThroughHooks.has(i)?n[l]=c=>{if(this.defaults.async)return Promise.resolve(o.call(n,c)).then(u=>r.call(n,u));const d=o.call(n,c);return r.call(n,d)}:n[l]=(...c)=>{let d=o.apply(n,c);return d===!1&&(d=r.apply(n,c)),d}}a.hooks=n}if(s.walkTokens){const n=this.defaults.walkTokens,i=s.walkTokens;a.walkTokens=function(l){let o=[];return o.push(i.call(this,l)),n&&(o=o.concat(n.call(this,l))),o}}this.defaults={...this.defaults,...a}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return Ka.lex(e,t??this.defaults)}parser(e,t){return Ja.parse(e,t??this.defaults)}parseMarkdown(e){return(s,a)=>{const n={...a},i={...this.defaults,...n},l=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&n.async===!1)return l(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof s>"u"||s===null)return l(new Error("marked(): input parameter is undefined or null"));if(typeof s!="string")return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(s)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=e);const o=i.hooks?i.hooks.provideLexer():e?Ka.lex:Ka.lexInline,r=i.hooks?i.hooks.provideParser():e?Ja.parse:Ja.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(s):s).then(c=>o(c,i)).then(c=>i.hooks?i.hooks.processAllTokens(c):c).then(c=>i.walkTokens?Promise.all(this.walkTokens(c,i.walkTokens)).then(()=>c):c).then(c=>r(c,i)).then(c=>i.hooks?i.hooks.postprocess(c):c).catch(l);try{i.hooks&&(s=i.hooks.preprocess(s));let c=o(s,i);i.hooks&&(c=i.hooks.processAllTokens(c)),i.walkTokens&&this.walkTokens(c,i.walkTokens);let d=r(c,i);return i.hooks&&(d=i.hooks.postprocess(d)),d}catch(c){return l(c)}}}onError(e,t){return s=>{if(s.message+=` +Please report this to https://github.com/markedjs/marked.`,e){const a="

An error occurred:

"+Ca(s.message+"",!0)+"
";return t?Promise.resolve(a):a}if(t)return Promise.reject(s);throw s}}},ei=new uE;function vt(e,t){return ei.parse(e,t)}vt.options=vt.setOptions=function(e){return ei.setOptions(e),vt.defaults=ei.defaults,Hg(vt.defaults),vt};vt.getDefaults=Uu;vt.defaults=li;vt.use=function(...e){return ei.use(...e),vt.defaults=ei.defaults,Hg(vt.defaults),vt};vt.walkTokens=function(e,t){return ei.walkTokens(e,t)};vt.parseInline=ei.parseInline;vt.Parser=Ja;vt.parser=Ja.parse;vt.Renderer=Or;vt.TextRenderer=Ku;vt.Lexer=Ka;vt.lexer=Ka.lex;vt.Tokenizer=Ir;vt.Hooks=er;vt.parse=vt;vt.options;vt.setOptions;vt.use;vt.walkTokens;vt.parseInline;Ja.parse;Ka.lex;const pE={breaks:!0,gfm:!0};function hm(e){if(!e)return"";try{if(typeof vt<"u"&&vt.parse){const t=vt.parse(e,pE);return typeof cm<"u"?cm.sanitize(t):t}}catch{}return e.replace(/&/g,"&").replace(//g,">").replace(/\n/g,"
")}function fE(e){const t=new Date(e),s=t.getHours().toString().padStart(2,"0"),a=t.getMinutes().toString().padStart(2,"0");return`${s}:${a}`}const mE={run_command:"terminal",ssh_command:"terminal",run_script:"terminal",read_file:"file",apply_patch:"edit",list_directory:"folder",search_knowledge:"search",ingest_document:"book",generate_image:"image",analyze_image:"eye",analyze_pdf:"file",browser_screenshot:"globe",manage_process:"sliders"};function hE(e){return mE[e]||"wrench"}const vE=/https?:\/\/\S+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?\S*)?/gi;function vm(e){if(!e)return[];const t=e.match(vE);return t?[...new Set(t)]:[]}const gE={template:`
@@ -7255,7 +7290,7 @@ Please report this to https://github.com/markedjs/marked.`,e){const a="

An err

- `,setup(){const e=p([]),t=p(""),s=p(!1),a=p(""),n=p(null),i=p(null),l=p(0),o=p("");let r=null,c=0;const d=["Check system health","List running services","Show disk usage","What can you do?"],u=H(()=>t.value.trim().length>0&&!s.value),f=p(ut.state||"disconnected");let m=null;const h=H(()=>{const N=f.value;return N==="connected"?"Connected":N==="reconnecting"?"Reconnecting…":N==="connecting"?"Connecting…":"REST fallback"}),b=["Watching across all realms...","Processing...","Consulting the bifrost...","Observing..."],I=H(()=>{const N=Math.floor(l.value/4)%b.length,L=l.value;return L>3?`${b[N]} (${L}s)`:b[0]});function k(){zt(()=>{n.value&&(n.value.scrollTop=n.value.scrollHeight)})}function y(){if(!i.value)return;const N=i.value;N.style.height="auto",N.style.height=Math.min(N.scrollHeight,120)+"px"}function g(N,L,D={}){const re={id:++c,role:N,content:L,timestamp:Date.now(),html:N==="bot"?cm(L):"",tools_used:D.tools_used||[],is_error:D.is_error||!1,images:N==="bot"?dm(L):[],files:D.files||[],_showTools:!1};return e.value.push(re),k(),N==="bot"&&zt(()=>x()),re}function x(){if(!n.value)return;n.value.querySelectorAll(".chat-markdown pre:not([data-copy])").forEach(L=>{L.setAttribute("data-copy","true"),L.style.position="relative";const D=document.createElement("button");D.className="chat-code-copy",D.textContent="Copy",D.addEventListener("click",()=>{const re=L.querySelector("code"),ce=re?re.textContent:L.textContent;navigator.clipboard.writeText(ce).then(()=>{D.textContent="Copied!",setTimeout(()=>{D.textContent="Copy"},1500)}).catch(()=>{})}),L.appendChild(D)})}function S(N){if(N===0)return!0;const L=e.value[N-1],D=e.value[N],re=new Date(L.timestamp).toDateString(),ce=new Date(D.timestamp).toDateString();return re!==ce}function _(N){const L=new Date(N),D=new Date;if(L.toDateString()===D.toDateString())return"Today";const re=new Date(D);return re.setDate(re.getDate()-1),L.toDateString()===re.toDateString()?"Yesterday":L.toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}function A(N){t.value=N,zt(()=>Q())}function R(N){window.open(N,"_blank","noopener")}function w(N){N.target.style.display="none"}function O(){l.value=0,r=setInterval(()=>{l.value++},1e3)}function B(){r&&(clearInterval(r),r=null),l.value=0}function T(N){s.value&&(s.value=!1,B(),N.type==="chat_response"?g("bot",N.content,{tools_used:N.tools_used||[],is_error:N.is_error||!1,files:N.files||[]}):N.type==="chat_error"&&g("bot",N.error||"Unknown error",{is_error:!0}),zt(()=>{var L;return(L=i.value)==null?void 0:L.focus()}))}async function $(N){try{const L=await z.post("/api/chat",{content:N,channel_id:o.value});g("bot",L.response,{tools_used:L.tools_used||[],is_error:L.is_error||!1,files:L.files||[]})}catch(L){g("bot",L.message||"Failed to send message",{is_error:!0})}}async function Q(){const N=t.value.trim();if(!N||s.value)return;g("user",N),t.value="",s.value=!0,O(),i.value&&(i.value.style.height="auto"),ut.connected&&ut.sendChat(N,{channelId:o.value})||(await $(N),s.value=!1,B()),zt(()=>{var D;return(D=i.value)==null?void 0:D.focus()})}async function W(){a.value="";try{if(!o.value){const L=await z.get("/api/auth/session");o.value=L.channel_id||L.user_id||"web-user"}const N=await z.get("/api/sessions/"+encodeURIComponent(o.value));if(N&&N.messages&&N.messages.length>0){for(const L of N.messages){const D=L.role==="user"?"user":"bot";let re=L.content||"";if(D==="user"){const J=re.match(/^\[.*?\]:\s*/);J&&(re=re.slice(J[0].length))}if(!re.trim())continue;const ce={id:++c,role:D,content:re,timestamp:L.timestamp?L.timestamp*1e3:Date.now(),html:D==="bot"?cm(re):"",tools_used:[],is_error:!1,images:D==="bot"?dm(re):[],files:[],_showTools:!1};e.value.push(ce)}zt(()=>{k(),x()})}}catch(N){N&&N.status!==404&&(a.value="Couldn't load chat history — earlier messages may be missing. Refresh to retry.",Se.error(a.value))}}return st(()=>{ut.subscribe("chat",T),f.value=ut.state||"disconnected",m=ut.onState(N=>{f.value=N}),W(),zt(()=>{var N;return(N=i.value)==null?void 0:N.focus()})}),xt(()=>{ut.unsubscribe("chat",T),m&&(m(),m=null),B()}),{messages:e,input:t,sending:s,historyError:a,messagesEl:n,inputEl:i,canSend:u,wsStatus:h,typingText:I,suggestions:d,send:Q,autoResize:y,formatTime:tE,formatDate:_,showDateSeparator:S,useSuggestion:A,openImage:R,onImageError:w,getToolIcon:aE,loadHistory:W}}},lE={setup(){const e=p("odin"),t=p(""),s=p(""),a=p(""),n=p({}),i=p([]),l=p([]),o=p(!1),r=p(!1),c=p(null),d=p(!0),u=p(""),f=p(!1),m=p(!1),h=H(()=>e.value==="custom"),b=H(()=>[...i.value,...l.value]),I=H(()=>l.value.includes(e.value)),k=H(()=>{var R;return h.value?t.value||"Odin":((R=n.value[e.value])==null?void 0:R.name)||e.value}),y=H(()=>{var R;return h.value?s.value||"(empty — will use Odin default)":((R=n.value[e.value])==null?void 0:R.identity)||""}),g=H(()=>{var R;return h.value?a.value||"(empty — will use Odin default)":((R=n.value[e.value])==null?void 0:R.voice)||""});async function x(){d.value=!0;try{const R=await z.get("/api/personality");e.value=R.preset||"odin",t.value=R.custom_name||"",s.value=R.custom_identity||"",a.value=R.custom_voice||"",n.value=R.presets||{},i.value=R.builtin_presets||[],l.value=R.user_presets||[]}catch(R){c.value=R.message}finally{d.value=!1}}async function S(){o.value=!0,c.value=null,r.value=!1;try{await z.put("/api/personality",{preset:e.value,custom_name:t.value,custom_identity:s.value,custom_voice:a.value}),r.value=!0,setTimeout(()=>r.value=!1,3e3)}catch(R){c.value=R.message}finally{o.value=!1}}async function _(){const R=u.value.trim();if(R){m.value=!0,c.value=null;try{await z.post("/api/personality/presets",{name:R,display_name:k.value,identity:y.value,voice:g.value}),f.value=!1,u.value="",await x(),e.value=R.toLowerCase().replace(/ /g,"_")}catch(w){c.value=w.message}finally{m.value=!1}}}async function A(){if(await os({title:"Delete preset",message:`Delete preset "${e.value}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){c.value=null;try{await z.del(`/api/personality/presets/${encodeURIComponent(e.value)}`),await x(),e.value="odin"}catch(w){c.value=w.message}}}return st(x),{preset:e,customName:t,customIdentity:s,customVoice:a,presets:n,presetNames:b,isCustom:h,isUserPreset:I,previewName:k,previewIdentity:y,previewVoice:g,saving:o,saved:r,error:c,loading:d,save:S,showSavePreset:f,newPresetName:u,savingPreset:m,saveAsPreset:_,deletePreset:A,builtinPresets:i,userPresets:l}},template:` + `,setup(){const e=p([]),t=p(""),s=p(!1),a=p(""),n=p(null),i=p(null),l=p(0),o=p("");let r=null,c=0;const d=["Check system health","List running services","Show disk usage","What can you do?"],u=U(()=>t.value.trim().length>0&&!s.value),f=p(ut.state||"disconnected");let m=null;const h=U(()=>{const N=f.value;return N==="connected"?"Connected":N==="reconnecting"?"Reconnecting…":N==="connecting"?"Connecting…":"REST fallback"}),g=["Watching across all realms...","Processing...","Consulting the bifrost...","Observing..."],A=U(()=>{const N=Math.floor(l.value/4)%g.length,O=l.value;return O>3?`${g[N]} (${O}s)`:g[0]});function w(){Mt(()=>{n.value&&(n.value.scrollTop=n.value.scrollHeight)})}function y(){if(!i.value)return;const N=i.value;N.style.height="auto",N.style.height=Math.min(N.scrollHeight,120)+"px"}function b(N,O,$={}){const ce={id:++c,role:N,content:O,timestamp:Date.now(),html:N==="bot"?hm(O):"",tools_used:$.tools_used||[],is_error:$.is_error||!1,images:N==="bot"?vm(O):[],files:$.files||[],_showTools:!1};return e.value.push(ce),w(),N==="bot"&&Mt(()=>x()),ce}function x(){if(!n.value)return;n.value.querySelectorAll(".chat-markdown pre:not([data-copy])").forEach(O=>{O.setAttribute("data-copy","true"),O.style.position="relative";const $=document.createElement("button");$.className="chat-code-copy",$.textContent="Copy",$.addEventListener("click",()=>{const ce=O.querySelector("code"),ue=ce?ce.textContent:O.textContent;navigator.clipboard.writeText(ue).then(()=>{$.textContent="Copied!",setTimeout(()=>{$.textContent="Copy"},1500)}).catch(()=>{})}),O.appendChild($)})}function S(N){if(N===0)return!0;const O=e.value[N-1],$=e.value[N],ce=new Date(O.timestamp).toDateString(),ue=new Date($.timestamp).toDateString();return ce!==ue}function _(N){const O=new Date(N),$=new Date;if(O.toDateString()===$.toDateString())return"Today";const ce=new Date($);return ce.setDate(ce.getDate()-1),O.toDateString()===ce.toDateString()?"Yesterday":O.toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}function T(N){t.value=N,Mt(()=>X())}function L(N){window.open(N,"_blank","noopener")}function E(N){N.target.style.display="none"}function P(){l.value=0,r=setInterval(()=>{l.value++},1e3)}function R(){r&&(clearInterval(r),r=null),l.value=0}function I(N){s.value&&(s.value=!1,R(),N.type==="chat_response"?b("bot",N.content,{tools_used:N.tools_used||[],is_error:N.is_error||!1,files:N.files||[]}):N.type==="chat_error"&&b("bot",N.error||"Unknown error",{is_error:!0}),Mt(()=>{var O;return(O=i.value)==null?void 0:O.focus()}))}async function H(N){try{const O=await z.post("/api/chat",{content:N,channel_id:o.value});b("bot",O.response,{tools_used:O.tools_used||[],is_error:O.is_error||!1,files:O.files||[]})}catch(O){b("bot",O.message||"Failed to send message",{is_error:!0})}}async function X(){const N=t.value.trim();if(!N||s.value)return;b("user",N),t.value="",s.value=!0,P(),i.value&&(i.value.style.height="auto"),ut.connected&&ut.sendChat(N,{channelId:o.value})||(await H(N),s.value=!1,R()),Mt(()=>{var $;return($=i.value)==null?void 0:$.focus()})}async function Q(){a.value="";try{if(!o.value){const O=await z.get("/api/auth/session");o.value=O.channel_id||O.user_id||"web-user"}const N=await z.get("/api/sessions/"+encodeURIComponent(o.value));if(N&&N.messages&&N.messages.length>0){for(const O of N.messages){const $=O.role==="user"?"user":"bot";let ce=O.content||"";if($==="user"){const Z=ce.match(/^\[.*?\]:\s*/);Z&&(ce=ce.slice(Z[0].length))}if(!ce.trim())continue;const ue={id:++c,role:$,content:ce,timestamp:O.timestamp?O.timestamp*1e3:Date.now(),html:$==="bot"?hm(ce):"",tools_used:[],is_error:!1,images:$==="bot"?vm(ce):[],files:[],_showTools:!1};e.value.push(ue)}Mt(()=>{w(),x()})}}catch(N){N&&N.status!==404&&(a.value="Couldn't load chat history — earlier messages may be missing. Refresh to retry.",ke.error(a.value))}}return tt(()=>{ut.subscribe("chat",I),f.value=ut.state||"disconnected",m=ut.onState(N=>{f.value=N}),Q(),Mt(()=>{var N;return(N=i.value)==null?void 0:N.focus()})}),xt(()=>{ut.unsubscribe("chat",I),m&&(m(),m=null),R()}),{messages:e,input:t,sending:s,historyError:a,messagesEl:n,inputEl:i,canSend:u,wsStatus:h,typingText:A,suggestions:d,send:X,autoResize:y,formatTime:fE,formatDate:_,showDateSeparator:S,useSuggestion:T,openImage:L,onImageError:E,getToolIcon:hE,loadHistory:Q}}},bE={setup(){const e=p("odin"),t=p(""),s=p(""),a=p(""),n=p({}),i=p([]),l=p([]),o=p(!1),r=p(!1),c=p(null),d=p(!0),u=p(""),f=p(!1),m=p(!1),h=U(()=>e.value==="custom"),g=U(()=>[...i.value,...l.value]),A=U(()=>l.value.includes(e.value)),w=U(()=>{var L;return h.value?t.value||"Odin":((L=n.value[e.value])==null?void 0:L.name)||e.value}),y=U(()=>{var L;return h.value?s.value||"(empty — will use Odin default)":((L=n.value[e.value])==null?void 0:L.identity)||""}),b=U(()=>{var L;return h.value?a.value||"(empty — will use Odin default)":((L=n.value[e.value])==null?void 0:L.voice)||""});async function x(){d.value=!0;try{const L=await z.get("/api/personality");e.value=L.preset||"odin",t.value=L.custom_name||"",s.value=L.custom_identity||"",a.value=L.custom_voice||"",n.value=L.presets||{},i.value=L.builtin_presets||[],l.value=L.user_presets||[]}catch(L){c.value=L.message}finally{d.value=!1}}async function S(){o.value=!0,c.value=null,r.value=!1;try{await z.put("/api/personality",{preset:e.value,custom_name:t.value,custom_identity:s.value,custom_voice:a.value}),r.value=!0,setTimeout(()=>r.value=!1,3e3)}catch(L){c.value=L.message}finally{o.value=!1}}async function _(){const L=u.value.trim();if(L){m.value=!0,c.value=null;try{await z.post("/api/personality/presets",{name:L,display_name:w.value,identity:y.value,voice:b.value}),f.value=!1,u.value="",await x(),e.value=L.toLowerCase().replace(/ /g,"_")}catch(E){c.value=E.message}finally{m.value=!1}}}async function T(){if(await os({title:"Delete preset",message:`Delete preset "${e.value}"? This cannot be undone.`,confirmLabel:"Delete",danger:!0})){c.value=null;try{await z.del(`/api/personality/presets/${encodeURIComponent(e.value)}`),await x(),e.value="odin"}catch(E){c.value=E.message}}}return tt(x),{preset:e,customName:t,customIdentity:s,customVoice:a,presets:n,presetNames:g,isCustom:h,isUserPreset:A,previewName:w,previewIdentity:y,previewVoice:b,saving:o,saved:r,error:c,loading:d,save:S,showSavePreset:f,newPresetName:u,savingPreset:m,saveAsPreset:_,deletePreset:T,builtinPresets:i,userPresets:l}},template:`

Personality

@@ -7355,7 +7390,7 @@ Please report this to https://github.com/markedjs/marked.`,e){const a="

An err

- `},oE={props:["onComplete"],template:` + `},yE={props:["onComplete"],template:`
-
`,setup(e){const t=p(""),s=p(""),a=p(!1),n=p(!1),i=p(""),l=p("Initialization pending."),o=p(!1),r=p(""),c=p(null),d=p({}),u=p("");let f=0,m=null;function h(){return f+=1,m==null||m.abort(),m=null,f}function b(S,_,A){return typeof z.postWithOptions=="function"?z.postWithOptions(S,_,{signal:A}):z.post(S,_)}async function I(){var A,R,w;a.value=!0,i.value="",l.value="Saving setup…";const S={},_=!!s.value.trim();t.value.trim()&&(S.web_api_token=t.value.trim()),s.value.trim()&&(S.discord_token=s.value.trim());try{const O=z.post("/api/setup/complete",S);t.value="",s.value="";const B=await O,T=((A=B.discord)==null?void 0:A.state)||B.discord_status;if(T==="failed"?(i.value=((R=B.discord)==null?void 0:R.error)||"Discord attachment failed. Setup was saved.",l.value="Saved. Discord attachment failed."):T==="connecting"?l.value="Saved. Connecting Discord…":T==="ready"?l.value="Saved. Discord ready.":_?l.value="Saved. Discord token stored; attachment status is pending.":l.value=B.message||"Setup saved. Ready to sign in.",(w=B.restart_required)!=null&&w.length){const $=B.message||`Restart Odin to apply: ${B.restart_required.join(", ")}`;l.value.includes($)||(l.value+=` ${$}`)}n.value=!0}catch(O){i.value=O.message||"Setup could not be saved.",l.value="Initialization pending."}finally{a.value=!1}}async function k(){const S=h(),_=typeof AbortController=="function"?new AbortController:null;m=_,o.value=!0,u.value="";try{const A=await b("/api/codex/device-code",void 0,_==null?void 0:_.signal);if(S!==f)return;c.value=A,r.value="pending";const R=await b("/api/codex/device-poll",{device_auth_id:A.device_auth_id,user_code:A.user_code,interval:A.interval},_==null?void 0:_.signal);if(S!==f)return;d.value=R||{},r.value="ready"}catch(A){S===f&&(A==null?void 0:A.name)!=="AbortError"&&(u.value=A.message||"Device sign-in failed.",r.value="failed")}finally{S===f&&(o.value=!1,m=null)}}function y(){h(),o.value=!1,x()}function g(){var S;(S=e.onComplete)==null||S.call(e)}function x(){r.value="",c.value=null,d.value={},u.value=""}return xt(()=>{h()}),{webApiToken:t,discordToken:s,saving:a,completed:n,error:i,statusMessage:l,save:I,onComplete:g,deviceLoading:o,deviceState:r,deviceInfo:c,deviceResult:d,deviceError:u,startDeviceLogin:k,cancelDeviceLogin:y,clearDeviceState:x}}},Mt=(e,t)=>s=>({path:e,query:{...s.query,tab:t}}),Wg=[{path:"/",redirect:"/dashboard"},{path:"/dashboard",component:zC,meta:{label:"Dashboard",icon:"dashboard",section:"Workspace",description:"System posture and recent activity"}},{path:"/chat",component:iE,meta:{label:"Chat",icon:"chat",section:"Workspace",description:"Direct operator conversation"}},{path:"/operations",component:r1,meta:{label:"Operations",icon:"operations",section:"Operate",description:"Execution, agents, loops, processes, and schedules"}},{path:"/history",component:g1,meta:{label:"History",icon:"history",section:"Observe",description:"Audit trail, sessions, traces, and usage"}},{path:"/capabilities",component:F1,meta:{label:"Capabilities",icon:"capabilities",section:"Manage",description:"Tools, skills, knowledge, and memory"}},{path:"/personality",component:lE,meta:{label:"Personality",icon:"personality",section:"Manage",description:"Behavior and response profile"}},{path:"/system",component:MC,meta:{label:"System",icon:"system",section:"Manage",description:"Health, configuration, access, and updates"}},{path:"/execution",redirect:Mt("/operations","live")},{path:"/agents",redirect:Mt("/operations","agents")},{path:"/loops",redirect:Mt("/operations","loops")},{path:"/processes",redirect:Mt("/operations","processes")},{path:"/schedules",redirect:Mt("/operations","schedules")},{path:"/audit",redirect:Mt("/history","audit")},{path:"/sessions",redirect:Mt("/history","sessions")},{path:"/traces",redirect:Mt("/history","traces")},{path:"/usage",redirect:Mt("/history","usage")},{path:"/tools",redirect:Mt("/capabilities","tools")},{path:"/skills",redirect:Mt("/capabilities","skills")},{path:"/mcp",redirect:Mt("/capabilities","mcp-servers")},{path:"/knowledge",redirect:Mt("/capabilities","knowledge")},{path:"/memory",redirect:Mt("/capabilities","memory")},{path:"/learned",redirect:Mt("/capabilities","learned")},{path:"/health",redirect:Mt("/system","health")},{path:"/resources",redirect:Mt("/system","resources")},{path:"/logs",redirect:Mt("/system","logs")},{path:"/config",redirect:Mt("/system","config")},{path:"/host-access",redirect:Mt("/system","host-access")},{path:"/hosts",redirect:Mt("/system","hosts")},{path:"/internals",redirect:Mt("/system","internals")}],Nl=DS({history:fS(),routes:Wg});Nl.afterEach(e=>{var s;const t=(s=e.meta)==null?void 0:s.label;document.title=t?`Odin — ${t}`:"Odin — Management"});const rE={template:` + `,setup(e){const t=p(""),s=p(""),a=p(!1),n=p(!1),i=p(""),l=p("Initialization pending."),o=p(!1),r=p(""),c=p(null),d=p({}),u=p("");let f=0,m=null;function h(){return f+=1,m==null||m.abort(),m=null,f}function g(S,_,T){return typeof z.postWithOptions=="function"?z.postWithOptions(S,_,{signal:T}):z.post(S,_)}async function A(){var T,L,E;a.value=!0,i.value="",l.value="Saving setup…";const S={},_=!!s.value.trim();t.value.trim()&&(S.web_api_token=t.value.trim()),s.value.trim()&&(S.discord_token=s.value.trim());try{const P=z.post("/api/setup/complete",S);t.value="",s.value="";const R=await P,I=((T=R.discord)==null?void 0:T.state)||R.discord_status;if(I==="failed"?(i.value=((L=R.discord)==null?void 0:L.error)||"Discord attachment failed. Setup was saved.",l.value="Saved. Discord attachment failed."):I==="connecting"?l.value="Saved. Connecting Discord…":I==="ready"?l.value="Saved. Discord ready.":_?l.value="Saved. Discord token stored; attachment status is pending.":l.value=R.message||"Setup saved. Ready to sign in.",(E=R.restart_required)!=null&&E.length){const H=R.message||`Restart Odin to apply: ${R.restart_required.join(", ")}`;l.value.includes(H)||(l.value+=` ${H}`)}n.value=!0}catch(P){i.value=P.message||"Setup could not be saved.",l.value="Initialization pending."}finally{a.value=!1}}async function w(){const S=h(),_=typeof AbortController=="function"?new AbortController:null;m=_,o.value=!0,u.value="";try{const T=await g("/api/codex/device-code",void 0,_==null?void 0:_.signal);if(S!==f)return;c.value=T,r.value="pending";const L=await g("/api/codex/device-poll",{device_auth_id:T.device_auth_id,user_code:T.user_code,interval:T.interval},_==null?void 0:_.signal);if(S!==f)return;d.value=L||{},r.value="ready"}catch(T){S===f&&(T==null?void 0:T.name)!=="AbortError"&&(u.value=T.message||"Device sign-in failed.",r.value="failed")}finally{S===f&&(o.value=!1,m=null)}}function y(){h(),o.value=!1,x()}function b(){var S;(S=e.onComplete)==null||S.call(e)}function x(){r.value="",c.value=null,d.value={},u.value=""}return xt(()=>{h()}),{webApiToken:t,discordToken:s,saving:a,completed:n,error:i,statusMessage:l,save:A,onComplete:b,deviceLoading:o,deviceState:r,deviceInfo:c,deviceResult:d,deviceError:u,startDeviceLogin:w,cancelDeviceLogin:y,clearDeviceState:x}}},Dt=(e,t)=>s=>({path:e,query:{...s.query,tab:t}}),Xg=[{path:"/",redirect:"/dashboard"},{path:"/dashboard",component:XC,meta:{label:"Dashboard",icon:"dashboard",section:"Workspace",description:"System posture and recent activity"}},{path:"/chat",component:gE,meta:{label:"Chat",icon:"chat",section:"Workspace",description:"Direct operator conversation"}},{path:"/operations",component:v1,meta:{label:"Operations",icon:"operations",section:"Operate",description:"Execution, agents, loops, processes, and schedules"}},{path:"/history",component:E1,meta:{label:"History",icon:"history",section:"Observe",description:"Audit trail, sessions, traces, and usage"}},{path:"/capabilities",component:K1,meta:{label:"Capabilities",icon:"capabilities",section:"Manage",description:"Tools, skills, knowledge, and memory"}},{path:"/personality",component:bE,meta:{label:"Personality",icon:"personality",section:"Manage",description:"Behavior and response profile"}},{path:"/system",component:GC,meta:{label:"System",icon:"system",section:"Manage",description:"Health, configuration, access, and updates"}},{path:"/execution",redirect:Dt("/operations","live")},{path:"/agents",redirect:Dt("/operations","agents")},{path:"/loops",redirect:Dt("/operations","loops")},{path:"/processes",redirect:Dt("/operations","processes")},{path:"/schedules",redirect:Dt("/operations","schedules")},{path:"/audit",redirect:Dt("/history","audit")},{path:"/sessions",redirect:Dt("/history","sessions")},{path:"/traces",redirect:Dt("/history","traces")},{path:"/usage",redirect:Dt("/history","usage")},{path:"/tools",redirect:Dt("/capabilities","tools")},{path:"/skills",redirect:Dt("/capabilities","skills")},{path:"/mcp",redirect:Dt("/capabilities","mcp-servers")},{path:"/knowledge",redirect:Dt("/capabilities","knowledge")},{path:"/memory",redirect:Dt("/capabilities","memory")},{path:"/learned",redirect:Dt("/capabilities","learned")},{path:"/health",redirect:Dt("/system","health")},{path:"/resources",redirect:Dt("/system","resources")},{path:"/logs",redirect:Dt("/system","logs")},{path:"/config",redirect:Dt("/system","config")},{path:"/host-access",redirect:Dt("/system","host-access")},{path:"/hosts",redirect:Dt("/system","hosts")},{path:"/internals",redirect:Dt("/system","internals")}],Ml=jS({history:_S(),routes:Xg});Ml.afterEach(e=>{var s;const t=(s=e.meta)==null?void 0:s.label;document.title=t?`Odin — ${t}`:"Odin — Management"});const xE={template:` `,props:["onLogin","sessionExpired"],setup(e){const t=p(""),s=p(null),a=p(!1),n=p(!1);async function i(){a.value=!0,s.value=null;try{z.setPersist(n.value),await z.login(t.value),e.onLogin()}catch(l){s.value=l.message||"Login failed"}finally{a.value=!1}}return{token:t,error:s,busy:a,persist:n,login:i}}},cE={template:` + `,props:["onLogin","sessionExpired"],setup(e){const t=p(""),s=p(null),a=p(!1),n=p(!1);async function i(){a.value=!0,s.value=null;try{z.setPersist(n.value),await z.login(t.value),e.onLogin()}catch(l){s.value=l.message||"Login failed"}finally{a.value=!1}}return{token:t,error:s,busy:a,persist:n,login:i}}},_E={template:`
Loading application... @@ -7540,4 +7575,4 @@ Please report this to https://github.com/markedjs/marked.`,e){const a="

An err

- `,setup(){const e=p("checking"),t=p(!1),s=p(!1),a=p(!1),n=p(null),i=p(null),l=p(!1);let o=null,r=null;const c=p(!1),d=p("disconnected"),u=p(-1),f=p(null);let m=null;const h=p("starting"),b=p(""),I=Wg.filter(J=>J.meta),k=H(()=>["Workspace","Operate","Observe","Manage"].map(J=>({name:J,routes:I.filter(K=>K.meta.section===J)})).filter(J=>J.routes.length)),y=H(()=>{var J;return((J=Nl.currentRoute.value.meta)==null?void 0:J.label)||"Odin"}),g=H(()=>{var J;return((J=Nl.currentRoute.value.meta)==null?void 0:J.section)||"Management"}),x=H(()=>{var J;return((J=Nl.currentRoute.value.meta)==null?void 0:J.description)||"Management console"});function S(){ut.disconnect(),N&&(clearInterval(N),N=null)}z.onSessionExpired=()=>{t.value=!0,S(),z.setToken(""),e.value="login"};function _(J){var K;if((J.ctrlKey||J.metaKey)&&J.key.toLowerCase()==="k"){e.value==="ready"&&(J.preventDefault(),Hf());return}if(a.value&&J.key==="Tab"){const se=[...((K=n.value)==null?void 0:K.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'))||[]];if(se.length){const j=se[0],ye=se[se.length-1];if(J.shiftKey&&(document.activeElement===j||!n.value.contains(document.activeElement))){J.preventDefault(),ye.focus();return}if(!J.shiftKey&&(document.activeElement===ye||!n.value.contains(document.activeElement))){J.preventDefault(),j.focus();return}}}if(J.key==="Escape"&&a.value){a.value=!1,J.preventDefault();return}if(J.key==="/"&&!["INPUT","TEXTAREA","SELECT"].includes(J.target.tagName)){J.preventDefault();const se=document.querySelector('.hm-main input[type="text"], .hm-main .hm-input:not(textarea):not(select)');se&&se.focus()}}function A(){l.value=!!(o!=null&&o.matches),l.value||(a.value=!1)}async function R(){try{const J=await z.get("/api/setup/status");if(J.mode==="pending"||J.needed===!0)return S(),e.value="setup",!0}catch(J){J==null||J.name}return!1}st(async()=>{if(document.addEventListener("keydown",_),o=window.matchMedia("(max-width: 900px)"),A(),o.addEventListener("change",A),await R())return;const J=await z.check();J.ok?(e.value="ready",re()):J.needsAuth?e.value="login":(e.value="ready",re())});function w(){t.value=!1,e.value="ready",re()}async function O(){if(await R())return;const J=await z.check();J.ok?(e.value="ready",re()):J.needsAuth?e.value="login":(e.value="ready",re())}async function B(){S(),e.value="login",await z.logout()}function T(){s.value=!s.value}function $(){a.value=!a.value}Wt(a,async J=>{var K,se;if(J)r=document.activeElement,await zt(),(se=(K=n.value)==null?void 0:K.querySelector(".nav-item"))==null||se.focus();else if(r!=null&&r.isConnected){const j=r;r=null,requestAnimationFrame(()=>j.focus())}});const Q=H(()=>{switch(d.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}});function W(J,K="info",se=3e3){f.value={text:J,level:K},clearTimeout(m),m=setTimeout(()=>{f.value=null},se)}let N=null,L=!1,D=[];function re(){for(const J of D)J();D=[ut.onStatus(J=>{c.value=J}),ut.onLatencyChange(J=>{u.value=J}),ut.onState((J,K)=>{d.value=J,J==="connected"?(L&&W("Connection restored","success"),L=!0):J==="reconnecting"&&K.attempt===1&&W("Connection lost — reconnecting…","warn")})],ut.connect(),ce(),N&&clearInterval(N),N=setInterval(ce,15e3)}async function ce(){try{const J=await z.get("/api/status");h.value=J.status==="online"?"online":"starting";const K=J.uptime_seconds||0,se=Math.floor(K/3600),j=Math.floor(K%3600/60);b.value=`${se}h ${j}m uptime`}catch{h.value="offline",b.value=""}}return xt(()=>{N&&clearInterval(N);for(const J of D)J();D=[],ut.disconnect(),document.removeEventListener("keydown",_),o==null||o.removeEventListener("change",A)}),{authState:e,sessionExpired:t,sidebarCollapsed:s,mobileOpen:a,wsConnected:c,wsState:d,wsLatency:u,wsLabel:Q,wsToast:f,botStatus:h,botUptime:b,navRoutes:I,navGroups:k,currentPage:y,currentSection:g,currentDescription:x,sidebarEl:n,mobileMenuButton:i,isMobileViewport:l,onLogin:w,onSetupComplete:O,logout:B,toggleSidebar:T,toggleMobileNavigation:$,openPalette:Hf}}},on=fr(cE);on.component("odin-icon",FC);on.component("login-screen",rE);on.component("setup-page",oE);on.component("toast-container",Ek);on.component("confirm-host",Ak);on.component("command-palette",$C);on.directive("modal-focus",UC);on.use(Nl);on.mount("#app"); + `,setup(){const e=p("checking"),t=p(!1),s=p(!1),a=p(!1),n=p(null),i=p(null),l=p(!1);let o=null,r=null;const c=p(!1),d=p("disconnected"),u=p(-1),f=p(null);let m=null;const h=p("starting"),g=p(""),A=Xg.filter(Z=>Z.meta),w=U(()=>["Workspace","Operate","Observe","Manage"].map(Z=>({name:Z,routes:A.filter(J=>J.meta.section===Z)})).filter(Z=>Z.routes.length)),y=U(()=>{var Z;return((Z=Ml.currentRoute.value.meta)==null?void 0:Z.label)||"Odin"}),b=U(()=>{var Z;return((Z=Ml.currentRoute.value.meta)==null?void 0:Z.section)||"Management"}),x=U(()=>{var Z;return((Z=Ml.currentRoute.value.meta)==null?void 0:Z.description)||"Management console"});function S(){ut.disconnect(),N&&(clearInterval(N),N=null)}z.onSessionExpired=()=>{t.value=!0,S(),z.setToken(""),e.value="login"};function _(Z){var J;if((Z.ctrlKey||Z.metaKey)&&Z.key.toLowerCase()==="k"){e.value==="ready"&&(Z.preventDefault(),Kf());return}if(a.value&&Z.key==="Tab"){const se=[...((J=n.value)==null?void 0:J.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'))||[]];if(se.length){const q=se[0],ye=se[se.length-1];if(Z.shiftKey&&(document.activeElement===q||!n.value.contains(document.activeElement))){Z.preventDefault(),ye.focus();return}if(!Z.shiftKey&&(document.activeElement===ye||!n.value.contains(document.activeElement))){Z.preventDefault(),q.focus();return}}}if(Z.key==="Escape"&&a.value){a.value=!1,Z.preventDefault();return}if(Z.key==="/"&&!["INPUT","TEXTAREA","SELECT"].includes(Z.target.tagName)){Z.preventDefault();const se=document.querySelector('.hm-main input[type="text"], .hm-main .hm-input:not(textarea):not(select)');se&&se.focus()}}function T(){l.value=!!(o!=null&&o.matches),l.value||(a.value=!1)}async function L(){try{const Z=await z.get("/api/setup/status");if(Z.mode==="pending"||Z.needed===!0)return S(),e.value="setup",!0}catch(Z){Z==null||Z.name}return!1}tt(async()=>{if(document.addEventListener("keydown",_),o=window.matchMedia("(max-width: 900px)"),T(),o.addEventListener("change",T),await L())return;const Z=await z.check();Z.ok?(e.value="ready",ce()):Z.needsAuth?e.value="login":(e.value="ready",ce())});function E(){t.value=!1,e.value="ready",ce()}async function P(){if(await L())return;const Z=await z.check();Z.ok?(e.value="ready",ce()):Z.needsAuth?e.value="login":(e.value="ready",ce())}async function R(){S(),e.value="login",await z.logout()}function I(){s.value=!s.value}function H(){a.value=!a.value}jt(a,async Z=>{var J,se;if(Z)r=document.activeElement,await Mt(),(se=(J=n.value)==null?void 0:J.querySelector(".nav-item"))==null||se.focus();else if(r!=null&&r.isConnected){const q=r;r=null,requestAnimationFrame(()=>q.focus())}});const X=U(()=>{switch(d.value){case"connected":return"Live";case"connecting":return"Connecting…";case"reconnecting":return"Reconnecting…";default:return"Disconnected"}});function Q(Z,J="info",se=3e3){f.value={text:Z,level:J},clearTimeout(m),m=setTimeout(()=>{f.value=null},se)}let N=null,O=!1,$=[];function ce(){for(const Z of $)Z();$=[ut.onStatus(Z=>{c.value=Z}),ut.onLatencyChange(Z=>{u.value=Z}),ut.onState((Z,J)=>{d.value=Z,Z==="connected"?(O&&Q("Connection restored","success"),O=!0):Z==="reconnecting"&&J.attempt===1&&Q("Connection lost — reconnecting…","warn")})],ut.connect(),ue(),N&&clearInterval(N),N=setInterval(ue,15e3)}async function ue(){try{const Z=await z.get("/api/status");h.value=Z.status==="online"?"online":"starting";const J=Z.uptime_seconds||0,se=Math.floor(J/3600),q=Math.floor(J%3600/60);g.value=`${se}h ${q}m uptime`}catch{h.value="offline",g.value=""}}return xt(()=>{N&&clearInterval(N);for(const Z of $)Z();$=[],ut.disconnect(),document.removeEventListener("keydown",_),o==null||o.removeEventListener("change",T)}),{authState:e,sessionExpired:t,sidebarCollapsed:s,mobileOpen:a,wsConnected:c,wsState:d,wsLatency:u,wsLabel:X,wsToast:f,botStatus:h,botUptime:g,navRoutes:A,navGroups:w,currentPage:y,currentSection:b,currentDescription:x,sidebarEl:n,mobileMenuButton:i,isMobileViewport:l,onLogin:E,onSetupComplete:P,logout:R,toggleSidebar:I,toggleMobileNavigation:H,openPalette:Kf}}},on=br(_E);on.component("odin-icon",ZC);on.component("login-screen",xE);on.component("setup-page",yE);on.component("toast-container",Dk);on.component("confirm-host",Pk);on.component("command-palette",JC);on.directive("modal-focus",YC);on.use(Ml);on.mount("#app"); diff --git a/ui/dist/assets/index-CEmQAbKm.css b/ui/dist/assets/index-CEmQAbKm.css new file mode 100644 index 000000000..0f13a168b --- /dev/null +++ b/ui/dist/assets/index-CEmQAbKm.css @@ -0,0 +1 @@ +@font-face{font-family:Fira Code;font-style:normal;font-weight:400;font-display:swap;src:url(/ui/assets/fira-code-400-CHoedHDv.woff2) format("woff2")}@font-face{font-family:Fira Code;font-style:normal;font-weight:500;font-display:swap;src:url(/ui/assets/fira-code-400-CHoedHDv.woff2) format("woff2")}@font-face{font-family:Fira Code;font-style:normal;font-weight:600;font-display:swap;src:url(/ui/assets/fira-code-400-CHoedHDv.woff2) format("woff2")}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/ui/assets/inter-400-Dx4kXJAl.woff2) format("woff2")}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/ui/assets/inter-400-Dx4kXJAl.woff2) format("woff2")}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/ui/assets/inter-400-Dx4kXJAl.woff2) format("woff2")}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(/ui/assets/inter-400-Dx4kXJAl.woff2) format("woff2")}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.m-2{margin:.5rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-2{height:.5rem}.h-28{height:7rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.max-h-24{max-height:6rem}.max-h-32{max-height:8rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.min-h-screen{min-height:100vh}.w-2{width:.5rem}.w-32{width:8rem}.w-5{width:1.25rem}.w-72{width:18rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[100px\]{min-width:100px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-full{max-width:100%}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.self-end{align-self:flex-end}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-amber-700\/50{border-color:#b4530980}.border-amber-800{--tw-border-opacity: 1;border-color:rgb(146 64 14 / var(--tw-border-opacity, 1))}.border-amber-900{--tw-border-opacity: 1;border-color:rgb(120 53 15 / var(--tw-border-opacity, 1))}.border-blue-500\/30{border-color:#3b82f64d}.border-gray-600{--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.border-gray-700\/50{border-color:#37415180}.border-gray-800{--tw-border-opacity: 1;border-color:rgb(31 41 55 / var(--tw-border-opacity, 1))}.border-gray-800\/50{border-color:#1f293780}.border-green-500\/30{border-color:#22c55e4d}.border-green-500\/40{border-color:#22c55e66}.border-green-800{--tw-border-opacity: 1;border-color:rgb(22 101 52 / var(--tw-border-opacity, 1))}.border-indigo-900\/30{border-color:#312e814d}.border-red-500\/30{border-color:#ef44444d}.border-red-500\/40{border-color:#ef444466}.border-red-700{--tw-border-opacity: 1;border-color:rgb(185 28 28 / var(--tw-border-opacity, 1))}.border-red-800{--tw-border-opacity: 1;border-color:rgb(153 27 27 / var(--tw-border-opacity, 1))}.border-red-900{--tw-border-opacity: 1;border-color:rgb(127 29 29 / var(--tw-border-opacity, 1))}.border-red-900\/50{border-color:#7f1d1d80}.border-slate-700{--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-yellow-800{--tw-border-opacity: 1;border-color:rgb(133 77 14 / var(--tw-border-opacity, 1))}.border-yellow-900{--tw-border-opacity: 1;border-color:rgb(113 63 18 / var(--tw-border-opacity, 1))}.bg-amber-700\/70{background-color:#b45309b3}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-500\/60{background-color:#3b82f699}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-blue-900{--tw-bg-opacity: 1;background-color:rgb(30 58 138 / var(--tw-bg-opacity, 1))}.bg-blue-900\/40{background-color:#1e3a8a66}.bg-blue-900\/50{background-color:#1e3a8a80}.bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-gray-800\/50{background-color:#1f293780}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-gray-900\/30{background-color:#1118274d}.bg-gray-900\/50{background-color:#11182780}.bg-gray-950{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity, 1))}.bg-green-900{--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.bg-green-900\/30{background-color:#14532d4d}.bg-green-950\/30{background-color:#052e164d}.bg-indigo-500{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.bg-indigo-900{--tw-bg-opacity: 1;background-color:rgb(49 46 129 / var(--tw-bg-opacity, 1))}.bg-indigo-950\/30{background-color:#1e1b4b4d}.bg-red-900\/20{background-color:#7f1d1d33}.bg-red-900\/30{background-color:#7f1d1d4d}.bg-red-900\/50{background-color:#7f1d1d80}.bg-red-950\/30{background-color:#450a0a4d}.bg-yellow-900\/20{background-color:#713f1233}.\!p-0{padding:0!important}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.pl-5{padding-left:1.25rem}.pr-1{padding-right:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.italic{font-style:italic}.leading-relaxed{line-height:1.625}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-300{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-cyan-400{--tw-text-opacity: 1;color:rgb(34 211 238 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-indigo-300{--tw-text-opacity: 1;color:rgb(165 180 252 / var(--tw-text-opacity, 1))}.text-indigo-400{--tw-text-opacity: 1;color:rgb(129 140 248 / var(--tw-text-opacity, 1))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-teal-400{--tw-text-opacity: 1;color:rgb(45 212 191 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-200{--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.text-yellow-300{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.underline{text-decoration-line:underline}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.last\:mb-0:last-child{margin-bottom:0}.last\:border-0:last-child{border-width:0px}.hover\:border-gray-600:hover{--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.hover\:bg-gray-700:hover{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.hover\:text-blue-300:hover{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.hover\:text-gray-300:hover{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.hover\:text-green-300:hover{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.hover\:text-indigo-300:hover{--tw-text-opacity: 1;color:rgb(165 180 252 / var(--tw-text-opacity, 1))}.hover\:text-red-300:hover{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.hover\:text-yellow-300:hover{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}@media(min-width:360px){.min-\[360px\]\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:768px){.md\:col-span-2{grid-column:span 2 / span 2}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}.output-renderer{min-width:0;max-width:100%;border:1px solid var(--hm-border);border-radius:var(--hm-radius-sm);background:var(--hm-bg-raised);color:var(--hm-text);font-size:.75rem;overflow:hidden;text-align:left}.output-summary,.output-controls{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem .65rem;padding:.35rem .5rem}.output-summary{color:var(--hm-text-muted);overflow-wrap:anywhere;white-space:normal;line-height:1.5}.output-summary strong{color:var(--hm-text)}.output-summary span+span:before{content:"·";margin-right:.65rem;color:var(--hm-text-dim)}.output-controls{border-bottom:1px solid var(--hm-border-subtle)}.output-controls .btn,.output-expand{font-size:.75rem;padding:.25rem .5rem;min-height:28px}.output-controls [aria-pressed=true]{color:var(--hm-accent-hover);background:var(--hm-accent-dim)}.output-section-label,.output-fold-note{display:block;padding:.35rem .5rem;color:var(--hm-text-muted);white-space:normal;overflow-wrap:anywhere}.output-section+.output-section{border-top:1px dashed var(--hm-border)}.output-body{margin:0;padding:.5rem;max-width:100%;overflow:auto;white-space:pre;font-family:var(--hm-font-mono);font-size:.75rem;line-height:1.5}.output-body.output-wrapped{white-space:pre-wrap;overflow-wrap:anywhere;word-break:normal}.output-expand{margin:.35rem .5rem}.log-compact-line{border-bottom:1px solid var(--hm-border-subtle)}.output-renderer.output-compact{border:0;border-radius:0;background:transparent;overflow:visible}.output-event-row{display:flex;align-items:center;gap:.5rem;min-width:0;line-height:1.5}.output-event-heading{display:flex;align-items:center;gap:.5rem;min-width:0;flex:0 0 auto;max-width:calc(100% - 4ch);white-space:nowrap}.output-event-heading>*{flex-shrink:0}.output-event-heading .log-ts{flex-shrink:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.output-event-heading .log-compact-action{overflow:hidden;text-overflow:ellipsis;flex-shrink:1;max-width:24ch;min-width:0;color:var(--hm-accent-hover)}.output-event-heading .log-compact-web-action{color:#a78bfa}.output-control-separator{color:var(--hm-text-dim);white-space:pre}.output-inline-summary{flex:1 1 4ch;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.output-inline-summary>span{margin-right:.5rem}.output-inline-summary .log-compact-agent,.output-inline-summary .log-compact-arguments{display:inline-block;vertical-align:bottom;overflow:hidden;text-overflow:ellipsis;max-width:24ch}.output-inline-summary .log-compact-arguments{max-width:32ch}.output-compact-outcome{color:var(--hm-text-muted)}.output-compact-warning{color:var(--hm-warning);white-space:nowrap;flex:0 1 auto;max-width:28ch;overflow:hidden;text-overflow:ellipsis;padding:0;border:0;background:transparent;cursor:pointer}.output-warning-short{display:none}.output-compact-warning-detail{color:var(--hm-warning);overflow-wrap:anywhere}.output-compact-envelope-detail{color:var(--hm-text-muted);overflow-wrap:anywhere}.output-compact-actions{display:flex;align-items:center;flex-shrink:0;gap:.4rem}.output-compact .output-controls{position:static;padding:0;gap:.4rem;flex-wrap:nowrap;border:0;background:transparent}.output-compact-actions button{border:0;border-radius:0;background:transparent;min-width:0;min-height:0;padding:0;margin:0;font:inherit;font-size:.6875rem;line-height:inherit;color:var(--hm-text-muted);cursor:pointer;text-decoration:none}.output-compact-actions button[aria-pressed=true]{color:var(--hm-info);background:transparent}.output-compact-actions button:hover,.output-compact-actions button:focus,.output-compact-warning:hover,.output-compact-warning:focus{text-decoration:underline}.output-compact-actions button:focus-visible,.output-compact-warning:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.output-compact .output-body{padding:0;line-height:18px}.output-compact-preview{margin:0 0 .25rem}.output-compact .output-compact-folded{max-height:72px;overflow:hidden}.output-compact-detail{padding:.25rem 0}.log-compact-attribution{display:flex;flex-wrap:wrap;gap:.25rem .75rem;overflow-wrap:anywhere}.log-compact-detail-arguments{margin-top:.25rem}.output-copy-status{color:var(--hm-text-muted);overflow-wrap:anywhere}@media(max-width:600px){.output-warning-full{display:none}.output-warning-short{display:inline}.output-event-row,.output-event-heading{gap:.3rem}.output-event-heading{font-size:.6875rem}.output-event-heading .log-ts{max-width:8ch;overflow:hidden}.output-event-heading .log-compact-action,.output-inline-summary .log-compact-arguments,.output-inline-summary .log-compact-agent{max-width:10ch}}@media(max-width:360px){.output-event-row,.output-event-heading{gap:.25rem}.output-event-heading,.output-compact-actions button{font-size:.625rem}.output-event-heading .log-ts,.output-event-heading .log-compact-action{max-width:6ch}.output-compact-actions,.output-compact .output-controls{gap:.25rem}}html{-webkit-text-size-adjust:100%}*{-webkit-tap-highlight-color:rgba(217,119,6,.15)}:root{--hm-font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--hm-font-mono: "Fira Code", "Cascadia Code", "JetBrains Mono", ui-monospace, monospace;--hm-text-xs: .6875rem;--hm-text-sm: .75rem;--hm-text-base: .8125rem;--hm-text-md: .875rem;--hm-text-lg: 1rem;--hm-text-xl: 1.25rem;--hm-text-2xl: 1.5rem;--hm-leading-tight: 1.25;--hm-leading-normal: 1.5;--hm-leading-relaxed: 1.65;--hm-space-1: .25rem;--hm-space-2: .375rem;--hm-space-3: .5rem;--hm-space-4: .75rem;--hm-space-5: 1rem;--hm-space-6: 1.5rem;--hm-space-8: 2rem;--hm-radius-full: 9999px;--hm-shadow-glow: 0 0 20px rgba(197, 139, 50, .15), 0 0 4px rgba(197, 139, 50, .1);--hm-shadow-glow-sm: 0 0 8px rgba(197, 139, 50, .1);--hm-transition-fast: .1s ease;--hm-transition-base: .15s ease;--hm-transition-slow: .25s ease;--hm-transition-spring: .3s cubic-bezier(.34, 1.56, .64, 1)}body{font-family:var(--hm-font-sans);font-size:var(--hm-text-md);line-height:var(--hm-leading-normal);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;letter-spacing:-.011em}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:var(--hm-bg)}::-webkit-scrollbar-thumb{background:#374151;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#4b5563}*{scrollbar-width:thin;scrollbar-color:#374151 var(--hm-bg)}.codex-quota-cell{min-width:190px;max-width:260px}.codex-quota-list{display:grid;gap:.45rem;min-width:175px}.codex-quota-block{display:grid;gap:.2rem}.codex-quota-heading{display:flex;align-items:baseline;justify-content:space-between;gap:.5rem;font-size:.7rem}.codex-quota-heading strong{color:#d1d5db;white-space:nowrap}.codex-quota-label{color:#9ca3af}.codex-quota-track{height:5px;overflow:hidden;background:#e5e7eb;border-radius:9999px}.codex-quota-fill{height:100%;background:#34d399;border-radius:inherit;transition:width .2s ease}.codex-quota-reset,.codex-quota-observed{color:#6b7280;font-size:.65rem;white-space:nowrap}.codex-quota-limited,.codex-quota-failure{color:#fbbf24}.codex-quota-observed{display:block;margin-top:.15rem}.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}.status-dot.online{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success)}.status-dot.offline{background:var(--hm-danger);box-shadow:0 0 6px var(--hm-danger)}.status-dot.starting{background:var(--hm-warning);box-shadow:0 0 6px var(--hm-warning)}.hm-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-5);box-shadow:var(--hm-shadow-sm);transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.hm-table{width:100%;border-collapse:collapse;font-size:var(--hm-text-md)}.hm-table th{text-align:left;padding:var(--hm-space-3) var(--hm-space-4);color:var(--hm-text-muted);font-weight:500;font-size:var(--hm-text-sm);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--hm-border);white-space:nowrap}.hm-table td{padding:var(--hm-space-3) var(--hm-space-4);border-bottom:1px solid rgba(31,41,55,.5)}.hm-table tr:hover td{background:#1f29374d}.host-access-table{table-layout:fixed;min-width:700px}.host-access-table th:nth-child(1){width:28%}.host-access-table th:nth-child(2){width:22%}.host-access-table th:nth-child(3){width:25%}.host-access-table th:nth-child(4){width:17%}.host-access-table th:nth-child(5){width:8%}.host-chip-summary{display:flex;align-items:center;flex-wrap:wrap;gap:.25rem;max-width:100%;text-align:left}.host-default-summary{overflow-wrap:anywhere}.host-access-modal{position:fixed;top:0;right:0;bottom:0;left:0;margin:auto;width:min(36rem,calc(100vw - 2rem));max-width:100%;max-height:calc(100dvh - 2rem);padding:0;border:1px solid var(--hm-border);border-radius:var(--hm-radius-xl);background:var(--hm-surface);color:var(--hm-text)}.host-access-modal::backdrop{background:#000000a6}.host-access-modal-form{display:flex;flex-direction:column;max-height:calc(100dvh - 2rem)}.host-access-modal header,.host-access-modal footer{padding:1rem 1.5rem;flex-shrink:0}.host-access-modal-body{display:flex;flex-direction:column;gap:.75rem;padding:0 1.5rem 1rem;min-height:0;overflow-y:auto}.host-access-checklist{display:flex;flex-direction:column;gap:.65rem;min-height:5rem;max-height:35dvh;overflow-y:auto;flex-shrink:0;overflow-wrap:anywhere}.host-access-checklist input{flex-shrink:0}.host-access-checklist input:disabled+span{opacity:.55}@media(max-width:640px){.host-access-modal{width:100%;height:100dvh;max-height:100dvh;border-radius:0;border:0}.host-access-modal-form{height:100%;max-height:100dvh}.host-access-modal-body{flex:1}}.host-chip{display:inline-block;max-width:9rem;padding:.12rem .4rem;border:1px solid var(--hm-border);border-radius:4px;font-size:var(--hm-text-xs);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.btn{display:inline-flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-2) var(--hm-space-4);border-radius:var(--hm-radius-md);font-size:var(--hm-text-base);font-weight:500;font-family:var(--hm-font-sans);cursor:pointer;border:none;transition:background var(--hm-transition-base),box-shadow var(--hm-transition-base),transform var(--hm-transition-fast);letter-spacing:-.006em}.btn:active:not(:disabled){transform:scale(.97)}.btn-primary{background:var(--hm-accent);color:#fff}.btn-primary:hover{background:var(--hm-accent-hover);box-shadow:var(--hm-shadow-glow-sm)}.btn-danger{background:#ef444426;color:#f87171}.btn-danger:hover{background:#ef444440}.btn-ghost{background:transparent;color:var(--hm-text-muted)}.btn-ghost:hover{background:var(--hm-surface-hover);color:var(--hm-text)}.btn:disabled{opacity:.5;cursor:not-allowed}.hm-input{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4);color:var(--hm-text);font-size:var(--hm-text-md);font-family:var(--hm-font-sans);width:100%;outline:none;transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.hm-input:focus{border-color:var(--hm-accent);box-shadow:0 0 0 3px var(--hm-accent-glow)}.credential-input:-webkit-autofill,.credential-input:-webkit-autofill:hover,.credential-input:-webkit-autofill:focus{-webkit-text-fill-color:var(--hm-text);caret-color:var(--hm-text);box-shadow:0 0 0 1000px var(--hm-bg) inset;-webkit-transition:background-color 9999s ease-out 0s;transition:background-color 9999s ease-out 0s}textarea.hm-input{font-family:var(--hm-font-mono);resize:vertical;min-height:120px}.toggle-switch{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{position:absolute;top:0;right:0;bottom:0;left:0;background:#374151;border-radius:10px;cursor:pointer;transition:background .2s}.toggle-slider:before{content:"";position:absolute;width:14px;height:14px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:transform .2s}.toggle-switch input:checked+.toggle-slider{background:var(--hm-accent);box-shadow:var(--hm-shadow-glow-sm)}.toggle-switch input:checked+.toggle-slider:before{transform:translate(16px)}.toggle-switch input:disabled+.toggle-slider{opacity:.4;cursor:not-allowed}.field-changed td:first-child{border-left:2px solid var(--hm-accent)}.config-tag{display:inline-flex;align-items:center;gap:var(--hm-space-1);padding:.125rem .5rem;background:var(--hm-gold-dim);border:1px solid rgba(217,119,6,.2);border-radius:var(--hm-radius-sm);font-size:var(--hm-text-sm);font-family:var(--hm-font-mono);color:var(--hm-text)}.config-tag button{background:none;border:none;color:var(--hm-text-muted);cursor:pointer;padding:0;font-size:var(--hm-text-md);line-height:1}.config-tag button:hover{color:var(--hm-danger)}.hm-select{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-2) var(--hm-space-3);color:var(--hm-text);font-size:var(--hm-text-base);font-family:var(--hm-font-sans);outline:none;cursor:pointer;transition:border-color var(--hm-transition-base)}.hm-select:focus{border-color:var(--hm-accent)}.badge{display:inline-block;padding:.125rem .5rem;border-radius:var(--hm-radius-full);font-size:var(--hm-text-sm);font-weight:500;letter-spacing:.01em}.badge-success{background:#22c55e26;color:#4ade80}.badge-warning{background:#eab30826;color:#facc15}.badge-danger{background:#ef444426;color:#f87171}.badge-info{background:var(--hm-accent-dim);color:var(--hm-gold)}.spinner{width:20px;height:20px;border:2px solid var(--hm-border);border-top-color:var(--hm-accent);border-radius:50%;animation:spin .6s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.modal-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#0009;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:50}.modal-content{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-xl);padding:var(--hm-space-6);max-width:600px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:var(--hm-shadow-lg)}.skeleton{background:linear-gradient(90deg,var(--hm-surface) 25%,var(--hm-surface-hover) 50%,var(--hm-surface) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:var(--hm-radius-sm)}.skeleton-text{height:.875rem;margin-bottom:.5rem}.skeleton-stat{height:2rem;width:3rem;margin:0 auto .25rem}.skeleton-row{height:2.25rem;margin-bottom:.375rem}@keyframes shimmer{0%{background-position:200% 0}to{background-position:-200% 0}}.flash-new{animation:flash-highlight 1.5s ease-out}@keyframes flash-highlight{0%{background-color:#d9770640}to{background-color:transparent}}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}.error-state{display:flex;flex-direction:column;align-items:center;gap:var(--hm-space-4);padding:var(--hm-space-6);text-align:center}.error-state .error-icon{font-size:1.5rem;opacity:.6}.chat-container{display:flex;flex-direction:column;height:calc(100vh - var(--hm-topbar-h));overflow:hidden}.page-viewport .chat-container.page-fade-in{max-width:none;margin-inline:0;--chat-work-lane: 1400px;--chat-reading-lane: 1080px}.chat-messages{flex:1;overflow-y:auto;padding:var(--hm-space-5) var(--hm-space-5) var(--hm-space-3);scroll-behavior:smooth}.chat-messages>.chat-message,.chat-messages>.chat-date-sep{max-width:var(--chat-reading-lane);margin-inline:auto}.chat-messages>.chat-message:has(pre) .chat-bubble-wrap,.chat-messages>.chat-message:has(table) .chat-bubble-wrap{max-width:100%;width:100%}.chat-empty{display:flex;align-items:center;justify-content:center;height:100%}.chat-welcome{text-align:center;max-width:28rem}.chat-welcome-icon{color:var(--hm-accent);margin-bottom:var(--hm-space-4);opacity:.8}.chat-welcome-title{font-size:1.25rem;font-weight:700;color:var(--hm-text);margin-bottom:var(--hm-space-2);letter-spacing:.01em}.chat-welcome-subtitle{font-size:var(--hm-text-sm);color:var(--hm-text-muted);margin-bottom:var(--hm-space-5)}.chat-suggestions{display:flex;flex-wrap:wrap;justify-content:center;gap:var(--hm-space-2)}.chat-suggestion{background:var(--hm-surface-elevated);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-2) var(--hm-space-4);color:var(--hm-text-muted);font-size:var(--hm-text-sm);cursor:pointer;transition:border-color var(--hm-transition-base),color var(--hm-transition-base)}.chat-suggestion:hover{border-color:var(--hm-accent);color:var(--hm-accent-hover)}.chat-date-sep{display:flex;align-items:center;gap:var(--hm-space-4);margin:var(--hm-space-4) 0}.chat-date-sep:before,.chat-date-sep:after{content:"";flex:1;height:1px;background:var(--hm-border)}.chat-date-sep span{font-size:var(--hm-text-xs);color:var(--hm-text-muted);white-space:nowrap}.chat-message{display:flex;gap:var(--hm-space-3);margin-bottom:var(--hm-space-4);align-items:flex-start}.chat-message.chat-user{flex-direction:row-reverse}.chat-message.chat-bot{flex-direction:row}.chat-avatar{width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px}.chat-avatar-bot{background:linear-gradient(135deg,#d9770633,#d977061a);border:1px solid rgba(217,119,6,.3);color:var(--hm-accent)}.chat-avatar-user{background:var(--hm-surface-hover);border:1px solid var(--hm-border);color:var(--hm-text-muted)}.chat-avatar-eye,.chat-avatar-user{display:flex;align-items:center;justify-content:center}.chat-avatar-pulse{animation:avatar-pulse 2s ease-in-out infinite}@keyframes avatar-pulse{0%,to{opacity:1}50%{opacity:.5}}.chat-bubble-wrap{max-width:75%;min-width:0}.chat-message.chat-user .chat-bubble-wrap{align-items:flex-end;display:flex;flex-direction:column}.chat-bubble{border-radius:var(--hm-radius-xl);padding:.625rem .875rem;font-size:var(--hm-text-md);line-height:var(--hm-leading-normal);word-break:break-word}.chat-bubble-user{background:linear-gradient(135deg,var(--hm-accent),#b45309);color:#fff;border-bottom-right-radius:var(--hm-radius-sm);box-shadow:var(--hm-shadow-sm)}.chat-bubble-bot{background:var(--hm-surface-elevated);border:1px solid var(--hm-border);border-bottom-left-radius:var(--hm-radius-sm)}.chat-bubble-typing{display:flex;align-items:center;gap:var(--hm-space-3)}.chat-bubble-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:var(--hm-space-1)}.chat-bubble-label{font-size:var(--hm-text-xs);color:var(--hm-accent-hover);font-weight:600;letter-spacing:.02em}.chat-error-indicator{font-size:.625rem;color:#f87171;background:#ef444426;border:1px solid rgba(239,68,68,.3);border-radius:var(--hm-radius-sm);padding:0 .375rem;line-height:1.4;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.chat-bubble-text{white-space:pre-wrap}.chat-timestamp{font-size:.625rem;color:var(--hm-text-muted);margin-top:2px;opacity:0;transition:opacity var(--hm-transition-base);padding:0 .25rem}.chat-message:hover .chat-timestamp{opacity:1}.chat-markdown{white-space:normal}.chat-markdown p{margin:0 0 .5rem}.chat-markdown p:last-child{margin-bottom:0}.chat-markdown code{background:#0000004d;padding:.125rem .375rem;border-radius:var(--hm-radius-sm);font-size:var(--hm-text-base);font-family:var(--hm-font-mono)}.chat-markdown pre{background:#0000004d;border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4);margin:var(--hm-space-2) 0;overflow-x:auto;position:relative}.chat-markdown pre code{background:none;padding:0}.chat-markdown ul,.chat-markdown ol{margin:.25rem 0;padding-left:1.25rem}.chat-markdown blockquote{border-left:3px solid var(--hm-accent);padding-left:var(--hm-space-4);margin:var(--hm-space-2) 0;color:var(--hm-text-muted)}.chat-markdown a{color:var(--hm-accent-hover);text-decoration:underline}.chat-markdown table{border-collapse:collapse;margin:var(--hm-space-2) 0;font-size:var(--hm-text-sm);display:block;width:-moz-max-content;width:max-content;max-width:100%;overflow-x:auto}.chat-markdown th,.chat-markdown td{border:1px solid var(--hm-border);padding:var(--hm-space-2) var(--hm-space-3);text-align:left;white-space:nowrap;word-break:normal}.chat-markdown th{background:#0003;font-weight:600}.chat-code-copy{position:absolute;top:var(--hm-space-2);right:var(--hm-space-2);background:var(--hm-surface-hover);border:1px solid var(--hm-border);border-radius:var(--hm-radius-sm);color:var(--hm-text-muted);font-size:.625rem;padding:2px 8px;cursor:pointer;opacity:0;transition:opacity var(--hm-transition-base),color var(--hm-transition-base);font-family:var(--hm-font-sans);text-transform:uppercase;letter-spacing:.04em}.chat-code-copy:hover{color:var(--hm-accent-hover)}.chat-tool-cards{margin-bottom:var(--hm-space-2)}.chat-tools-toggle{background:none;border:none;color:var(--hm-text-muted);font-size:var(--hm-text-sm);cursor:pointer;display:flex;align-items:center;gap:var(--hm-space-1);padding:.125rem 0}.chat-tools-toggle:hover{color:var(--hm-text)}.chat-tools-toggle-icon{font-size:.5rem}.chat-tools-toggle-count{background:#d9770626;color:var(--hm-accent-hover);border-radius:var(--hm-radius-sm);padding:0 .375rem;font-weight:600;font-size:var(--hm-text-xs)}.chat-tool-list{display:flex;flex-wrap:wrap;gap:var(--hm-space-1);margin-top:var(--hm-space-2)}.chat-tool-card{display:flex;align-items:center;gap:var(--hm-space-2);background:#0003;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-1) var(--hm-space-3);font-size:var(--hm-text-xs)}.chat-tool-icon{font-size:var(--hm-text-sm)}.chat-tool-name{font-family:var(--hm-font-mono);color:var(--hm-text-muted)}.chat-images{display:flex;flex-wrap:wrap;gap:var(--hm-space-2);margin-top:var(--hm-space-2)}.chat-image-thumb{border-radius:var(--hm-radius-md);overflow:hidden;border:1px solid var(--hm-border);max-width:240px;cursor:pointer;transition:border-color var(--hm-transition-base)}.chat-image-thumb:hover{border-color:var(--hm-accent)}.chat-image-thumb img{display:block;width:100%;height:auto;max-height:200px;-o-object-fit:cover;object-fit:cover}.chat-typing{display:flex;gap:var(--hm-space-1);padding:var(--hm-space-1) 0}.chat-typing span{width:6px;height:6px;background:var(--hm-accent);border-radius:50%;animation:typing-dot 1.4s infinite ease-in-out both}.chat-typing span:nth-child(1){animation-delay:0s}.chat-typing span:nth-child(2){animation-delay:.2s}.chat-typing span:nth-child(3){animation-delay:.4s}@keyframes typing-dot{0%,80%,to{transform:scale(.6);opacity:.4}40%{transform:scale(1);opacity:1}}.chat-typing-text{font-size:var(--hm-text-xs);color:var(--hm-text-muted);margin-left:var(--hm-space-2);font-style:italic}.chat-input-area{border-top:1px solid var(--hm-border);background:var(--hm-surface);padding:var(--hm-space-4) var(--hm-space-5);min-height:var(--hm-rail-h);display:flex;flex-direction:column;justify-content:center}.chat-input-row,.chat-input-hint{width:100%;max-width:var(--chat-work-lane);margin-inline:auto}.chat-input-row{display:flex;gap:var(--hm-space-3);align-items:flex-end}.chat-input{flex:1;background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-3) var(--hm-space-4);color:var(--hm-text);font-size:var(--hm-text-md);resize:none;outline:none;line-height:var(--hm-leading-normal);max-height:120px;font-family:inherit;transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.chat-input:focus{border-color:var(--hm-accent);box-shadow:0 0 0 3px var(--hm-accent-glow)}.chat-input:disabled{opacity:.5}.chat-send-btn{height:36px;min-width:44px;flex-shrink:0;display:flex;align-items:center;justify-content:center}.chat-send-icon{pointer-events:none}.chat-input-hint{display:flex;justify-content:space-between;align-items:center;padding-top:var(--hm-space-1)}.chat-connection-status{display:flex;align-items:center;gap:var(--hm-space-1);font-size:var(--hm-text-xs)}.chat-status-dot{width:6px;height:6px;border-radius:50%}.chat-ws-on .chat-status-dot{background:#4ade80}.chat-ws-off .chat-status-dot{background:#f87171}.chat-ws-on{color:var(--hm-text-muted)}.chat-ws-off{color:#f87171}.session-card{transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.session-card:hover{border-color:var(--hm-accent);box-shadow:var(--hm-shadow-glow-sm)}.session-selected{border-color:var(--hm-accent)!important;background:#d977060d}.session-checkbox{width:14px;height:14px;accent-color:var(--hm-accent);cursor:pointer;flex-shrink:0}.session-preview-role{font-weight:600;font-family:var(--hm-font-mono);flex-shrink:0;width:36px}.session-msg-content{font-size:var(--hm-text-base)}.sess-source-icon{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:var(--hm-text-sm)}.sess-source-discord{background:#5865f226;border:1px solid rgba(88,101,242,.3)}.sess-source-web{background:var(--hm-accent-dim);border:1px solid rgba(217,119,6,.3)}.sess-expand-icon{font-size:.5rem;color:var(--hm-text-dim);transition:transform var(--hm-transition-base);display:inline-block;margin-right:var(--hm-space-2)}.sess-summary-banner{padding:var(--hm-space-3) var(--hm-space-4);background:linear-gradient(135deg,#eab30814,#eab30808);border:1px solid rgba(234,179,8,.2);border-radius:var(--hm-radius-md)}.sess-summary-label{font-size:var(--hm-text-xs);font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--hm-warning)}.sess-view-btn{background:#ffffff0d;border:1px solid transparent;border-radius:var(--hm-radius-md);padding:var(--hm-space-1) var(--hm-space-4);font-size:var(--hm-text-xs);color:var(--hm-text-muted);cursor:pointer;transition:all var(--hm-transition-base);font-family:var(--hm-font-sans)}.sess-view-btn:hover{background:#ffffff1a}.sess-view-active{background:var(--hm-accent-dim)!important;color:var(--hm-gold);border-color:#d9770666}.sess-thread-container{max-height:32rem;overflow-y:auto;scrollbar-gutter:stable;display:flex;flex-direction:column;gap:var(--hm-space-3)}.sess-thread{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);overflow:hidden;transition:border-color var(--hm-transition-base)}.sess-thread:hover{border-color:#ffffff1a}.sess-thread-header{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);cursor:pointer;transition:background var(--hm-transition-base)}.sess-thread-header:hover{background:#ffffff08}.sess-thread-num{width:22px;height:22px;border-radius:50%;background:var(--hm-accent-dim);color:var(--hm-accent-hover);display:flex;align-items:center;justify-content:center;font-size:var(--hm-text-xs);font-weight:700;flex-shrink:0}.sess-thread-arrow{font-size:.5rem;color:var(--hm-text-dim);transition:transform var(--hm-transition-base);display:inline-block}.sess-thread-arrow-open{transform:rotate(90deg)}.sess-thread-summary{flex:1;min-width:0;font-size:var(--hm-text-sm);color:var(--hm-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sess-thread-count{flex-shrink:0}.sess-thread-messages{border-top:1px solid var(--hm-border);padding:var(--hm-space-3) var(--hm-space-4);display:flex;flex-direction:column;gap:var(--hm-space-3)}.sess-thread-msg{padding:8px 12px;border-radius:6px;margin-bottom:8px}.sess-msg-user{background:#06b6d414;border-left:3px solid rgba(6,182,212,.5)}.sess-msg-assistant{background:#6366f114;border-left:3px solid rgba(99,102,241,.5)}.sess-msg-system{background:#6b728014;border-left:3px solid rgba(107,114,128,.4)}.sess-msg-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:var(--hm-space-2)}.sess-role-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.sess-dot-user{background:#06b6d4;box-shadow:0 0 4px #06b6d480}.sess-dot-assistant{background:#6366f1;box-shadow:0 0 4px #6366f180}.sess-dot-system{background:#6b7280}.sess-role-label{font-size:var(--hm-text-xs);font-weight:600}.sess-msg-content{font-size:13px;color:var(--hm-text);line-height:1.5;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-height:200px;overflow-y:auto}.sess-filter-bar,.logs-filter-bar{border-bottom:1px solid var(--hm-border);padding-bottom:var(--hm-space-3)}.sess-preset-chip{display:inline-flex;align-items:center;gap:var(--hm-space-1);padding:var(--hm-space-1) var(--hm-space-4);border-radius:var(--hm-radius-full);font-size:var(--hm-text-xs);font-weight:500;cursor:pointer;border:1px solid transparent;transition:all var(--hm-transition-base);background:#ffffff0d;color:var(--hm-text-muted);font-family:var(--hm-font-sans)}.sess-preset-chip:hover{background:#ffffff1a;color:var(--hm-text)}.sess-preset-active{background:var(--hm-accent-dim)!important;color:var(--hm-gold)!important;border-color:#d9770666}.sess-preset-icon{font-size:var(--hm-text-sm)}.sess-preset-custom{border-style:dashed;border-color:#ffffff1a}.sess-preset-remove{margin-left:var(--hm-space-1);font-size:var(--hm-text-md);color:var(--hm-text-dim);cursor:pointer;line-height:1}.sess-preset-remove:hover{color:var(--hm-danger)}.log-line{border-left:2px solid transparent;padding-left:var(--hm-space-3)}.log-line-error{color:#f87171;border-left-color:#ef4444;background:#ef44440d}.log-line-warning{color:#fbbf24;border-left-color:#eab308;background:#eab3080a}.log-ts{transition:color var(--hm-transition-base)}.log-chip{display:inline-flex;align-items:center;padding:.125rem .5rem;border-radius:var(--hm-radius-full);font-size:var(--hm-text-xs);font-weight:600;cursor:pointer;border:1px solid transparent;transition:all var(--hm-transition-base);font-family:var(--hm-font-mono);background:#ffffff0d;color:var(--hm-text-muted)}.log-chip:hover{background:#ffffff1a}.log-chip-info.log-chip-active{background:#d9770633;color:var(--hm-gold);border-color:#d9770666}.log-chip-warning.log-chip-active{background:#eab30833;color:#facc15;border-color:#eab30866}.log-chip-error.log-chip-active{background:#ef444433;color:#f87171;border-color:#ef444466}.log-chip-clear{background:#ffffff0d;color:var(--hm-text-muted)}.log-chip-clear:hover{background:#ffffff1a;color:var(--hm-text)}.log-jump-btn{position:absolute;bottom:1rem;left:50%;transform:translate(-50%);background:var(--hm-accent);color:#fff;border:none;border-radius:var(--hm-radius-full);padding:var(--hm-space-2) var(--hm-space-5);font-size:var(--hm-text-sm);font-weight:500;cursor:pointer;z-index:10;box-shadow:0 2px 8px #0006;transition:background var(--hm-transition-base),transform var(--hm-transition-base);animation:jump-btn-in .2s ease-out}.log-jump-btn:hover{background:var(--hm-accent-hover)}@keyframes jump-btn-in{0%{opacity:0;transform:translate(-50%) translateY(.5rem)}to{opacity:1;transform:translate(-50%) translateY(0)}}.logs-tool-badge{display:inline-block;background:#d977061a;border:1px solid rgba(217,119,6,.2);border-radius:var(--hm-radius-sm);padding:0 var(--hm-space-2);font-size:var(--hm-text-xs);color:var(--hm-accent-hover);margin-right:var(--hm-space-2);font-family:var(--hm-font-mono)}.logs-timeline{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4)}.logs-timeline-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--hm-space-2)}.logs-timeline-chart{display:flex;gap:2px;align-items:flex-end;height:48px}.logs-timeline-bar-wrap{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;cursor:pointer;min-width:0}.logs-timeline-bar{width:100%;height:40px;display:flex;flex-direction:column-reverse;border-radius:2px 2px 0 0;overflow:hidden;background:#ffffff05;transition:background var(--hm-transition-base)}.logs-timeline-bar-wrap:hover .logs-timeline-bar{background:#ffffff0f}.logs-timeline-segment{width:100%;min-height:1px;transition:height var(--hm-transition-slow)}.logs-tl-error{background:#ef4444b3}.logs-tl-warning{background:#eab30899}.logs-tl-info{background:#3b82f666}.logs-timeline-label{font-size:.5rem;color:var(--hm-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.stat-card{position:relative;overflow:hidden}.stat-card:before{content:"";position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--hm-accent),transparent);opacity:0;transition:opacity var(--hm-transition-base)}.stat-card:hover:before{opacity:1}.stat-icon{font-size:var(--hm-text-md);margin-bottom:var(--hm-space-1);opacity:.7}.dash-hero{display:flex;flex-wrap:wrap;align-items:center;gap:var(--hm-space-5);position:relative}.dash-hero-left{display:flex;align-items:center;gap:var(--hm-space-4);flex:1;min-width:0}.dash-hero-ring{position:relative;width:48px;height:48px;flex-shrink:0}.dash-ring-svg{width:48px;height:48px;transform:rotate(-90deg)}.ring-online{color:var(--hm-success)}.ring-starting{color:var(--hm-warning)}.dash-ring-progress{transition:stroke-dashoffset 1s ease}.dash-hero-icon{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;font-size:1rem;color:var(--hm-text-muted)}.dash-hero-name{font-size:var(--hm-text-lg);font-weight:600;color:var(--hm-text);letter-spacing:-.02em}.dash-hero-sub{display:flex;align-items:center;gap:var(--hm-space-2);font-size:var(--hm-text-sm);color:var(--hm-text-muted)}.dash-hero-sep{opacity:.4}.dash-hero-actions{display:flex;gap:var(--hm-space-2);flex-shrink:0}.dash-hero-skeleton{display:flex;align-items:center;gap:var(--hm-space-4)}.dash-stat{display:flex;flex-direction:column;gap:var(--hm-space-1);padding:var(--hm-space-4) var(--hm-space-5)}.dash-stat-header{display:flex;align-items:center;gap:var(--hm-space-2)}.dash-stat-icon{font-size:var(--hm-text-md);opacity:.7}.dash-stat-label{font-size:var(--hm-text-xs);color:var(--hm-text-dim);text-transform:uppercase;letter-spacing:.04em;font-weight:500}.dash-stat-value{font-size:var(--hm-text-2xl);font-weight:700;letter-spacing:-.02em;line-height:1}.dash-stat-sub{font-size:var(--hm-text-xs)}.dash-stat-highlight{border-color:#d9770640;background:linear-gradient(135deg,var(--hm-surface),rgba(217,119,6,.03))}.dash-health-bar{padding:var(--hm-space-4) var(--hm-space-5)}.dash-health-items{display:flex;flex-wrap:wrap;gap:var(--hm-space-4)}.dash-health-item{display:flex;align-items:center;gap:var(--hm-space-2);font-size:var(--hm-text-sm)}.dash-health-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.dash-health-ok{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success)}.dash-health-warn{background:var(--hm-warning);box-shadow:0 0 6px var(--hm-warning)}.dash-health-error{background:var(--hm-danger);box-shadow:0 0 6px var(--hm-danger)}.dash-health-label{color:var(--hm-text);font-weight:500}.dash-health-detail{color:var(--hm-text-muted)}.dash-panel{padding:var(--hm-space-4) var(--hm-space-5);min-height:120px}.dash-panel-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--hm-space-3)}.dash-panel-title{font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-text-muted);text-transform:uppercase;letter-spacing:.04em}.dash-load-failed .dash-empty-icon{color:#d9a441}.dash-load-failed span:last-child{color:var(--hm-text-muted)}.dash-load-warning{color:#d9a441;padding:.1rem 0 .35rem}.chat-history-warning{color:#d9a441;padding:.55rem 1rem;border-bottom:1px solid var(--hm-border);background:#d9a44114}.dash-empty{display:flex;flex-direction:column;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-5) 0;color:var(--hm-text-dim);font-size:var(--hm-text-sm)}.dash-empty-icon{font-size:1.25rem;opacity:.3}.dash-agent-list{display:flex;flex-direction:column;gap:var(--hm-space-3)}.dash-agent-item{padding:var(--hm-space-3);background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.dash-agent-top{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:var(--hm-space-1)}.dash-agent-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.dash-agent-running{background:var(--hm-success);box-shadow:0 0 4px var(--hm-success);animation:loop-pulse 2s ease-in-out infinite}.dash-agent-completed{background:var(--hm-info)}.dash-agent-failed{background:var(--hm-danger)}.dash-agent-timeout{background:var(--hm-warning)}.dash-agent-killed{background:var(--hm-text-dim)}.dash-agent-label{font-weight:600;font-size:var(--hm-text-sm);color:var(--hm-text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dash-agent-iters{font-size:var(--hm-text-xs);color:var(--hm-text-dim);font-family:var(--hm-font-mono);flex-shrink:0}.dash-agent-goal{font-size:var(--hm-text-xs);color:var(--hm-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dash-agent-meta{display:flex;gap:var(--hm-space-3);font-size:var(--hm-text-xs);color:var(--hm-text-dim);margin-top:var(--hm-space-1)}.dash-agent-tools{font-family:var(--hm-font-mono)}.dash-activity-list{display:flex;flex-direction:column}.dash-activity-item{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-2) 0;border-bottom:1px solid rgba(31,41,55,.5);font-size:var(--hm-text-xs)}.dash-activity-item:last-child{border-bottom:none}.dash-activity-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}.dot-ok{background:var(--hm-success)}.dot-error{background:var(--hm-danger)}.dash-activity-tool{font-family:var(--hm-font-mono);color:var(--hm-text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dash-activity-time{color:var(--hm-text-dim);white-space:nowrap;flex-shrink:0}.dash-guild-item{display:flex;align-items:center;gap:var(--hm-space-2);font-size:var(--hm-text-sm)}.dash-guild-count{margin-left:auto;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.dash-error-list{display:flex;flex-direction:column;gap:var(--hm-space-2)}.dash-error-item{padding:var(--hm-space-2) 0;border-bottom:1px solid rgba(31,41,55,.5);font-size:var(--hm-text-xs)}.dash-error-item:last-child{border-bottom:none}.dash-error-top{display:flex;align-items:center;gap:var(--hm-space-2)}.dash-error-tool{font-family:var(--hm-font-mono);color:#f87171;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dash-error-time{color:var(--hm-text-dim);white-space:nowrap;flex-shrink:0}.dash-error-msg{color:var(--hm-text-dim);padding-left:1.25rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:90%}.tool-expand-icon{font-size:.625rem;display:inline-block;transition:transform var(--hm-transition-base)}.tool-detail-row td{padding:0!important}.tool-detail-cell{padding:var(--hm-space-4) var(--hm-space-5) var(--hm-space-4) 2rem!important;background:#11182780;border-left:2px solid var(--hm-accent)}.tl-stat-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-4) var(--hm-space-5);text-align:center;position:relative;overflow:hidden}.tl-stat-card:hover{border-color:var(--hm-accent-dim)}.tl-stat-value{font-size:var(--hm-text-2xl);font-weight:700;color:var(--hm-text);line-height:1.2}.tl-stat-label{font-size:var(--hm-text-xs);color:var(--hm-text-dim);margin-top:var(--hm-space-1)}.tl-stat-spark{display:flex;justify-content:center;margin-top:var(--hm-space-2)}.tl-view-toggle{display:inline-flex;background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);overflow:hidden}.tl-view-btn{padding:var(--hm-space-1) var(--hm-space-3);background:none;border:none;color:var(--hm-text-dim);cursor:pointer;font-size:var(--hm-text-sm);transition:all var(--hm-transition-base)}.tl-view-btn:hover{color:var(--hm-text);background:var(--hm-surface-hover)}.tl-view-active{color:var(--hm-accent)!important;background:var(--hm-accent-dim)!important}.tl-search{flex:1;min-width:200px}.tl-panel-warning{margin-top:2px;color:var(--hm-text-dim);font-size:.62rem;max-width:72ch}.tl-tool-switch{display:inline-flex;align-items:center;gap:var(--hm-space-2);cursor:pointer}.tl-tool-switch-label{color:var(--hm-text-dim);font-size:.58rem;font-weight:600;letter-spacing:.03em}.tl-tool-switch .toggle-switch[aria-busy=true]{opacity:.55;pointer-events:none}.tl-tool-card.tl-tool-off{border-color:var(--hm-border-subtle);background:var(--hm-surface);box-shadow:none}tr.tl-tool-off td{color:var(--hm-text-dim)}.tl-state-badge{display:inline-block;margin-left:6px;padding:1px 8px;border:1px solid;border-radius:999px;font-size:.55rem;font-weight:600;letter-spacing:.03em;white-space:nowrap}.tl-state-disabled{color:var(--hm-text-dim);border-color:var(--hm-border)}.tl-state-unavailable{color:#d9a441;border-color:#d9a44173}.tl-state-global_disabled{color:#d98e8e;border-color:#d98e8e66}.tl-core-advisory{margin-top:6px;color:#d9a441;font-size:.6rem}.tl-category-chips{display:flex;flex-wrap:wrap;gap:var(--hm-space-1)}.tl-category-chip{padding:var(--hm-space-1) var(--hm-space-3);border-radius:var(--hm-radius-full);border:1px solid var(--hm-border);background:var(--hm-surface);color:var(--hm-text-muted);font-size:var(--hm-text-xs);cursor:pointer;transition:all var(--hm-transition-base);white-space:nowrap}.tl-category-chip:hover{border-color:var(--hm-accent-dim);color:var(--hm-text)}.tl-category-active{background:var(--hm-accent-dim)!important;border-color:var(--hm-accent)!important;color:var(--hm-accent)!important}.tl-group-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:var(--hm-space-3);padding-bottom:var(--hm-space-2);border-bottom:1px solid var(--hm-border-subtle)}.tl-group-icon{font-size:var(--hm-text-md)}.tl-group-label{font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-text)}.tl-tool-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:var(--hm-space-3)}.tl-tool-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-4);cursor:pointer;transition:all var(--hm-transition-base)}.tl-tool-card:hover{border-color:#d977064d;box-shadow:var(--hm-shadow-glow-sm)}.tl-tool-card-active{border-left:2px solid var(--hm-accent)}.tl-tool-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--hm-space-2)}.tl-tool-name{font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-text)}.tl-tool-desc{font-size:var(--hm-text-xs);color:var(--hm-text-muted);line-height:var(--hm-leading-normal);margin-bottom:var(--hm-space-3)}.tl-tool-footer{display:flex;align-items:center;justify-content:space-between}.tl-tool-usage{display:flex;align-items:baseline;gap:var(--hm-space-1)}.tl-tool-usage-count{font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-accent)}.tl-tool-usage-zero{font-size:var(--hm-text-sm);color:var(--hm-text-dim)}.tl-tool-usage-label{font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.tl-tool-spark{display:flex;align-items:center}.tl-sparkline{display:block}.tl-tool-detail{margin-top:var(--hm-space-3);padding-top:var(--hm-space-3);border-top:1px solid var(--hm-border-subtle)}.tl-tool-detail-desc{font-size:var(--hm-text-xs);color:var(--hm-text);line-height:var(--hm-leading-relaxed);white-space:pre-wrap;margin-bottom:var(--hm-space-3)}.tl-tool-params{margin-top:var(--hm-space-2)}.tl-tool-params-title{font-size:var(--hm-text-xs);font-weight:600;color:var(--hm-text-muted);margin-bottom:var(--hm-space-2)}.tl-tool-param{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-1) 0;font-size:var(--hm-text-xs)}.tl-tool-param-name{font-family:var(--hm-font-mono);color:var(--hm-text);font-weight:500}.tl-tool-param-type{color:var(--hm-info);font-family:var(--hm-font-mono)}.tl-tool-param-req{color:var(--hm-warning);font-size:.625rem;font-weight:600;text-transform:uppercase}.sk-stat-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-4) var(--hm-space-5);text-align:center}.sk-stat-card:hover{border-color:var(--hm-accent-dim)}.sk-stat-value{font-size:var(--hm-text-2xl);font-weight:700;color:var(--hm-text);line-height:1.2}.sk-stat-label{font-size:var(--hm-text-xs);color:var(--hm-text-dim);margin-top:var(--hm-space-1)}.sk-search{width:100%;max-width:400px}.sk-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,360px),1fr));gap:var(--hm-space-4)}.sk-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);overflow:hidden;transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.sk-card:hover{border-color:#d977064d;box-shadow:var(--hm-shadow-glow-sm)}.sk-card-tested{border-left:2px solid var(--hm-success)}.sk-card-header{display:flex;align-items:center;justify-content:space-between;padding:var(--hm-space-4) var(--hm-space-5);border-bottom:1px solid var(--hm-border-subtle);flex-wrap:wrap;gap:var(--hm-space-2)}.sk-card-title-row{display:flex;align-items:center;gap:var(--hm-space-2)}.sk-card-icon{font-size:var(--hm-text-md);opacity:.7}.sk-card-name{font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-text)}.sk-card-runs{font-size:var(--hm-text-xs);color:var(--hm-text-dim);font-family:var(--hm-font-mono)}.sk-card-actions{display:flex;gap:var(--hm-space-1)}.sk-action-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:var(--hm-radius-md);border:1px solid var(--hm-border);background:var(--hm-surface);color:var(--hm-text-muted);cursor:pointer;font-size:var(--hm-text-xs);transition:all var(--hm-transition-base)}.sk-action-btn:hover{background:var(--hm-surface-hover);color:var(--hm-text)}.sk-action-test:hover{color:var(--hm-success);border-color:#22c55e4d}.sk-action-code:hover{color:var(--hm-info);border-color:#3b82f64d}.sk-action-edit:hover{color:var(--hm-accent);border-color:var(--hm-accent-dim)}.sk-action-delete:hover{color:var(--hm-danger);border-color:#ef44444d}.sk-card-body{padding:var(--hm-space-4) var(--hm-space-5)}.sk-card-desc{font-size:var(--hm-text-sm);color:var(--hm-text-muted);line-height:var(--hm-leading-normal);margin-bottom:var(--hm-space-2)}.sk-card-meta{display:flex;align-items:center;gap:var(--hm-space-4);font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.sk-card-date,.sk-card-lines{white-space:nowrap}.sk-test-result{margin:0 var(--hm-space-5) var(--hm-space-4);padding:var(--hm-space-3) var(--hm-space-4);border-radius:var(--hm-radius-md);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs)}.sk-test-pass{background:#22c55e14;border:1px solid rgba(34,197,94,.2)}.sk-test-fail{background:#ef444414;border:1px solid rgba(239,68,68,.2)}.sk-test-label{font-weight:600;font-family:var(--hm-font-sans);font-size:var(--hm-text-xs);margin-bottom:var(--hm-space-1)}.sk-test-pass .sk-test-label{color:var(--hm-success)}.sk-test-fail .sk-test-label{color:var(--hm-danger)}.sk-test-output{white-space:pre-wrap;color:var(--hm-text-muted);max-height:120px;overflow-y:auto}.sk-code-container{border-top:1px solid var(--hm-border-subtle)}.sk-code-header{display:flex;align-items:center;justify-content:space-between;padding:var(--hm-space-2) var(--hm-space-5);background:#03071280;border-bottom:1px solid var(--hm-border-subtle)}.sk-code-filename{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.sk-code-copy{background:none;border:none;color:var(--hm-text-dim);cursor:pointer;font-size:var(--hm-text-sm);padding:var(--hm-space-1);border-radius:var(--hm-radius-sm);transition:color var(--hm-transition-base)}.sk-code-copy:hover{color:var(--hm-text)}.sk-code-wrap{display:flex;max-height:400px;overflow:auto}.sk-line-numbers{padding:var(--hm-space-4) var(--hm-space-3);text-align:right;color:var(--hm-text-dim);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);line-height:var(--hm-leading-relaxed);background:#03071280;border-right:1px solid var(--hm-border-subtle);-webkit-user-select:none;-moz-user-select:none;user-select:none;min-width:2.5rem;margin:0;flex-shrink:0}.sk-code-block{flex:1;padding:var(--hm-space-4) var(--hm-space-5);margin:0;background:var(--hm-bg);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);line-height:var(--hm-leading-relaxed);color:var(--hm-text);overflow-x:auto}.sk-code-block code{background:none;padding:0;font-size:inherit}.sk-kw{color:#c084fc;font-weight:600}.sk-str{color:#86efac}.sk-cmt{color:var(--hm-text-dim);font-style:italic}.sk-dec{color:var(--hm-gold)}.sk-num{color:#67e8f9}.sk-builtin{color:#93c5fd}.sk-editor-panel{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-5) var(--hm-space-6);margin-top:var(--hm-space-4)}.sk-editor-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--hm-space-4)}.sk-editor-title{font-size:var(--hm-text-md);font-weight:600;color:var(--hm-text)}.sk-field-label{display:block;font-size:var(--hm-text-xs);color:var(--hm-text-muted);margin-bottom:var(--hm-space-1)}.sk-field-hint{font-size:var(--hm-text-xs);color:var(--hm-text-dim);margin-top:var(--hm-space-1)}.sk-editor-wrap{display:flex;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);overflow:hidden;background:var(--hm-bg)}.sk-editor-gutter{padding:var(--hm-space-4) var(--hm-space-3);text-align:right;color:var(--hm-text-dim);font-family:var(--hm-font-mono);font-size:var(--hm-text-base);line-height:var(--hm-leading-relaxed);background:#03071280;border-right:1px solid var(--hm-border-subtle);-webkit-user-select:none;-moz-user-select:none;user-select:none;min-width:2.5rem;white-space:pre;overflow:hidden;flex-shrink:0}.sk-editor-textarea{flex:1;padding:var(--hm-space-4) var(--hm-space-5);background:transparent;border:none;color:var(--hm-text);font-family:var(--hm-font-mono);font-size:var(--hm-text-base);line-height:var(--hm-leading-relaxed);resize:vertical;-moz-tab-size:4;-o-tab-size:4;tab-size:4;outline:none;min-height:300px;width:100%}.sk-editor-textarea::-moz-placeholder{color:var(--hm-text-dim)}.sk-editor-textarea::placeholder{color:var(--hm-text-dim)}.sk-editor-status{display:flex;gap:var(--hm-space-4);margin-top:var(--hm-space-2);font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.sk-editor-line-count,.sk-editor-char-count{font-family:var(--hm-font-mono)}.sk-validation-box{padding:var(--hm-space-3) var(--hm-space-4);border-radius:var(--hm-radius-md);font-size:var(--hm-text-xs);margin-bottom:var(--hm-space-3)}.sk-validation-ok{background:#22c55e14;border:1px solid rgba(34,197,94,.2);color:var(--hm-success)}.sk-validation-err{background:#eab30814;border:1px solid rgba(234,179,8,.2);color:var(--hm-warning)}.skill-code-block{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-4) var(--hm-space-5);font-size:var(--hm-text-base);font-family:var(--hm-font-mono);overflow-x:auto;line-height:var(--hm-leading-relaxed);max-height:400px;overflow-y:auto;margin:0;color:var(--hm-text)}.skill-card{transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.skill-card:hover{border-color:#d977064d;box-shadow:var(--hm-shadow-glow-sm)}.skill-editor{font-family:var(--hm-font-mono);font-size:var(--hm-text-base);line-height:var(--hm-leading-relaxed);-moz-tab-size:4;-o-tab-size:4;tab-size:4;min-height:300px}.cfg-group{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);overflow:hidden;box-shadow:var(--hm-shadow-sm)}.cfg-group-header{display:flex;align-items:center;gap:var(--hm-space-3);padding:var(--hm-space-4) var(--hm-space-5);background:linear-gradient(135deg,var(--hm-surface),var(--hm-surface-elevated));border-bottom:1px solid var(--hm-border);transition:background var(--hm-transition-base)}.cfg-group-header:hover{background:var(--hm-surface-hover)}.cfg-group-icon{font-size:var(--hm-text-lg);opacity:.8}.cfg-group-label{font-weight:600;font-size:var(--hm-text-md);color:var(--hm-text);flex:1}.cfg-group-arrow{font-size:var(--hm-text-xs);color:var(--hm-text-dim);font-family:var(--hm-font-mono)}.cfg-group-body{padding:var(--hm-space-3);display:flex;flex-direction:column;gap:var(--hm-space-3)}.cfg-section{background:var(--hm-bg);border:1px solid rgba(31,41,55,.5);border-radius:var(--hm-radius-md);overflow:hidden}.cfg-section-header{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);transition:background var(--hm-transition-base)}.cfg-section-header:hover{background:#1f29374d}.cfg-section-name{font-size:var(--hm-text-sm);font-weight:500;color:var(--hm-text)}.cfg-section-body{padding:0 var(--hm-space-3) var(--hm-space-3)}.cfg-field-error{font-size:var(--hm-text-xs);color:#f87171;margin-top:2px;font-family:var(--hm-font-sans);font-weight:400}.cfg-input-error{border-color:#ef444480!important;box-shadow:0 0 0 2px #ef444426!important}.cfg-change-count{font-size:var(--hm-text-xs);color:var(--hm-accent-hover);background:var(--hm-accent-dim);padding:2px 8px;border-radius:var(--hm-radius-full);font-weight:600}.cfg-diff-list{max-height:50vh;overflow-y:auto;display:flex;flex-direction:column;gap:var(--hm-space-3)}.cfg-diff-entry{border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);overflow:hidden}.cfg-diff-path{padding:var(--hm-space-2) var(--hm-space-3);background:var(--hm-surface-hover);border-bottom:1px solid var(--hm-border);color:var(--hm-accent-hover)}.cfg-diff-values{font-size:var(--hm-text-sm)}.cfg-diff-old{padding:var(--hm-space-2) var(--hm-space-3);background:#ef444414;border-bottom:1px solid rgba(31,41,55,.3);color:#f87171;white-space:pre-wrap;word-break:break-word}.cfg-diff-new{padding:var(--hm-space-2) var(--hm-space-3);background:#22c55e14;color:#4ade80;white-space:pre-wrap;word-break:break-word}.cfg-diff-label{display:inline-block;width:1.25rem;font-weight:700;font-family:var(--hm-font-mono)}.knowledge-highlight{background:#d977064d;color:#fde68a;padding:0 2px;border-radius:2px}.knowledge-preview{border-left:2px solid var(--hm-border);padding-left:var(--hm-space-4);max-height:4rem;overflow:hidden;font-style:italic}.kb-stats-bar{display:flex;gap:var(--hm-space-4);margin-bottom:var(--hm-space-4);flex-wrap:wrap}.kb-stat{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-3) var(--hm-space-4);text-align:center;min-width:80px}.kb-stat-value{display:block;font-size:1.25rem;font-weight:700;color:var(--hm-gold)}.kb-stat-label{display:block;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.kb-score-badge{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);color:var(--hm-text-dim);background:var(--hm-gold-dim);padding:1px 6px;border-radius:3px}.kb-search-result{border-left:3px solid var(--hm-accent-dim)}.kb-ingest-form{border-left:3px solid var(--hm-accent)}.kb-tree,.kb-tree-list{display:flex;flex-direction:column;gap:var(--hm-space-2)}.kb-tree-node{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);overflow:hidden}.kb-tree-header{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background .15s}.kb-tree-header:hover{background:var(--hm-surface-hover)}.kb-tree-arrow{font-size:10px;color:var(--hm-text-dim);transition:transform .2s;flex-shrink:0}.kb-tree-icon{font-size:1rem;flex-shrink:0}.kb-tree-name{font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600}.kb-tree-actions{margin-left:auto;display:flex;gap:var(--hm-space-1);flex-shrink:0}.kb-tree-meta{padding:0 var(--hm-space-4) var(--hm-space-2) 2.25rem;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.kb-tree-preview{padding:0 var(--hm-space-4) var(--hm-space-3) 2.25rem;font-size:var(--hm-text-sm);color:var(--hm-text-muted);font-style:italic;max-height:3rem;overflow:hidden;white-space:pre-wrap;word-break:break-word}.kb-chunk-browser{padding:0 var(--hm-space-4) var(--hm-space-4) var(--hm-space-4)}.kb-chunk-loading{display:flex;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-dim);font-size:var(--hm-text-sm);padding:var(--hm-space-2) 0}.kb-chunk-list{display:flex;flex-direction:column;gap:var(--hm-space-1)}.kb-chunk-header{display:flex;justify-content:space-between;padding:var(--hm-space-2) 0;border-bottom:1px solid var(--hm-border)}.kb-chunk-empty{padding:var(--hm-space-2) 0}.kb-chunk-error{color:#d9a441}.kb-chunk-item{padding:var(--hm-space-2) var(--hm-space-3);border-radius:var(--hm-radius-md);border:1px solid var(--hm-border-subtle);cursor:pointer;transition:all .15s}.kb-chunk-item:hover{background:var(--hm-surface-hover);border-color:var(--hm-border)}.kb-chunk-selected{background:var(--hm-gold-dim)!important;border-color:#d977064d!important}.kb-chunk-item-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:2px}.kb-chunk-index{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);font-weight:600;color:var(--hm-accent);min-width:2rem}.kb-chunk-chars{font-size:var(--hm-text-xs);color:var(--hm-text-dim);min-width:4.5rem}.kb-chunk-bar{flex:1;height:4px;background:var(--hm-border);border-radius:2px;overflow:hidden}.kb-chunk-bar-fill{height:100%;background:var(--hm-accent);border-radius:2px;transition:width .3s}.kb-chunk-preview{font-size:var(--hm-text-xs);color:var(--hm-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.kb-chunk-content{font-size:var(--hm-text-sm);color:var(--hm-text);white-space:pre-wrap;word-break:break-word;max-height:20rem;overflow-y:auto;margin-top:var(--hm-space-2);padding:var(--hm-space-2);background:var(--hm-bg);border-radius:var(--hm-radius-md);border:1px solid var(--hm-border)}.memory-scope-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:var(--hm-text-sm);font-weight:600;font-family:var(--hm-font-mono)}.memory-scope-global{background:#d9770633;color:var(--hm-gold);border:1px solid rgba(217,119,6,.3)}.memory-scope-user{background:#eab30826;color:#fde047;border:1px solid rgba(234,179,8,.25)}.memory-checkbox{width:16px;height:16px;accent-color:var(--hm-accent);cursor:pointer}.mem-stats-bar{display:flex;gap:var(--hm-space-4);margin-bottom:var(--hm-space-4);flex-wrap:wrap}.mem-stat{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-3) var(--hm-space-4);text-align:center;min-width:80px}.mem-stat-value{display:block;font-size:1.25rem;font-weight:700;color:var(--hm-gold)}.mem-stat-label{display:block;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.mem-stat-action{display:flex;align-items:center;justify-content:center}.mem-add-form{border-left:3px solid var(--hm-accent)}.mem-tree{display:flex;flex-direction:column;gap:var(--hm-space-2)}.mem-tree-node{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);overflow:hidden}.mem-tree-header{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background .15s}.mem-tree-header:hover{background:var(--hm-surface-hover)}.mem-tree-arrow{font-size:10px;color:var(--hm-text-dim);transition:transform .2s;flex-shrink:0}.mem-tree-entries{padding:0 var(--hm-space-4) var(--hm-space-3)}.mem-tree-loading{display:flex;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-dim);font-size:var(--hm-text-sm);padding:var(--hm-space-2) 0}.mem-tree-empty{padding:var(--hm-space-2) 0}.mem-tree-entry{padding:var(--hm-space-2) var(--hm-space-3);border-radius:var(--hm-radius-md);border:1px solid var(--hm-border-subtle);margin-bottom:var(--hm-space-1);transition:all .15s}.mem-tree-entry:hover{background:var(--hm-surface-hover)}.mem-tree-entry-selected{background:var(--hm-gold-dim);border-color:#d977064d}.mem-tree-entry-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:2px}.mem-tree-key{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);font-weight:500;color:var(--hm-text-muted)}.mem-tree-entry-actions{margin-left:auto;display:flex;gap:var(--hm-space-1);flex-shrink:0}.mem-tree-value{font-size:var(--hm-text-sm);color:var(--hm-text);white-space:pre-wrap;word-break:break-word;max-height:6rem;overflow:hidden;padding-left:1.5rem}.mem-tree-edit{padding-left:1.5rem;margin-top:var(--hm-space-1)}.ag-checkbox{width:14px;height:14px;accent-color:var(--hm-accent);cursor:pointer}.ag-stats-bar{display:flex;gap:var(--hm-space-4);margin-bottom:var(--hm-space-4);flex-wrap:wrap}.ag-stat{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-3) var(--hm-space-4);text-align:center;min-width:80px}.ag-stat-value{display:block;font-size:1.25rem;font-weight:700;color:var(--hm-gold)}.ag-stat-label{display:block;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.ag-stat-running{color:var(--hm-success)!important}.ag-stat-completed{color:var(--hm-info)!important}.ag-stat-failed{color:var(--hm-danger)!important}.ag-filter-bar{display:flex;gap:var(--hm-space-2);margin-bottom:var(--hm-space-4);flex-wrap:wrap}.ag-filter-btn{padding:var(--hm-space-1) var(--hm-space-3);border-radius:var(--hm-radius-md);font-size:var(--hm-text-xs);color:var(--hm-text-muted);background:var(--hm-surface);border:1px solid var(--hm-border);cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:var(--hm-space-1)}.ag-filter-btn:hover{background:var(--hm-surface-hover);color:var(--hm-text)}.ag-filter-active{background:var(--hm-accent-dim)!important;border-color:var(--hm-accent)!important;color:var(--hm-gold)!important}.ag-filter-count{font-family:var(--hm-font-mono);font-weight:600;font-size:.65rem;background:var(--hm-border);padding:0 4px;border-radius:3px}.ag-filter-active .ag-filter-count{background:#d977064d}.ag-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,340px),1fr));gap:var(--hm-space-4)}.ag-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-4);display:flex;flex-direction:column;gap:var(--hm-space-2);transition:border-color .2s,box-shadow .2s}.ag-card:hover{border-color:var(--hm-border)}.ag-card-running{border-left:3px solid var(--hm-success)}.ag-card-completed{border-left:3px solid var(--hm-info)}.ag-card-failed{border-left:3px solid var(--hm-danger)}.ag-card-timeout{border-left:3px solid var(--hm-warning)}.ag-card-killed{border-left:3px solid var(--hm-text-dim)}.ag-card-header{display:flex;align-items:center;justify-content:space-between}.ag-card-title-row{display:flex;align-items:center;gap:var(--hm-space-2);min-width:0;flex-wrap:nowrap}.ag-card-label{font-weight:600;font-size:var(--hm-text-sm);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ag-card-id{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);color:var(--hm-text-dim);flex:none}.ag-status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.ag-dot-running{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success);animation:loop-pulse 2s ease-in-out infinite}.ag-dot-completed{background:var(--hm-info)}.ag-dot-failed{background:var(--hm-danger)}.ag-dot-timeout{background:var(--hm-warning)}.ag-dot-killed{background:var(--hm-text-dim)}.ag-status-badge{font-size:var(--hm-text-xs);font-weight:600;padding:1px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:.05em}.ag-badge-running{background:#22c55e26;color:var(--hm-success)}.ag-badge-completed{background:#3b82f626;color:var(--hm-info)}.ag-badge-failed{background:#ef444426;color:var(--hm-danger)}.ag-badge-timeout{background:#eab30826;color:var(--hm-warning)}.ag-badge-killed{background:#9ca3af26;color:var(--hm-text-dim)}.ag-card-goal{font-size:var(--hm-text-sm);color:var(--hm-text-muted);line-height:1.4;height:3.5rem;overflow:hidden;position:relative}.ag-card-goal:after{content:"";position:absolute;inset:auto 0 0 0;height:1.5rem;background:linear-gradient(transparent,var(--hm-surface));pointer-events:none}.ag-card-clickable:hover .ag-card-goal:after{background:linear-gradient(transparent,var(--hm-surface-hover))}.ag-progress-bar{height:3px;background:var(--hm-border);border-radius:2px;overflow:hidden}.ag-progress-fill{height:100%;background:var(--hm-success);border-radius:2px;transition:width .5s;animation:ag-progress-glow 2s ease-in-out infinite}@keyframes ag-progress-glow{0%,to{opacity:1}50%{opacity:.6}}.ag-card-stats{display:flex;gap:var(--hm-space-3)}.ag-card-stat{flex:1;text-align:center}.ag-card-stat-label{display:block;font-size:.6rem;color:var(--hm-text-dim);text-transform:uppercase;letter-spacing:.05em}.ag-card-stat-value{display:block;font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600}.ag-card-tools{display:flex;flex-wrap:wrap;gap:4px}.ag-tool-chip{font-family:var(--hm-font-mono);font-size:.6rem;padding:1px 6px;background:var(--hm-gold-dim);color:var(--hm-text-muted);border-radius:3px;border:1px solid var(--hm-border-subtle)}.ag-card-policy{display:flex;flex-wrap:nowrap;gap:4px;min-width:0}.ag-policy-chip{font-family:var(--hm-font-mono);font-size:.62rem;padding:1px 7px;color:var(--hm-text-muted);background:var(--hm-surface-elevated);border:1px solid var(--hm-border);border-radius:3px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ag-policy-chip:not(.ag-policy-effort){flex:0 1 auto}.ag-policy-effort{flex:0 0 auto;color:var(--hm-accent);border-color:#c58b3247}.ag-card-body{display:flex;flex-direction:column;gap:var(--hm-space-2);text-align:left;border-radius:var(--hm-radius-md)}.ag-card-clickable{cursor:pointer;transition:background .12s ease,box-shadow .12s ease}.ag-card-clickable:hover{background:var(--hm-surface-hover)}.ag-card-clickable:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.modal-content.ag-detail-modal{width:min(1000px,94vw);max-width:min(1000px,94vw);max-height:88vh;display:flex;flex-direction:column;gap:var(--hm-space-3);overflow-y:auto}.ag-detail-header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-3)}.ag-detail-title-row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--hm-space-2);min-width:0}.ag-detail-title{margin:0;font-size:1rem;font-weight:650;letter-spacing:-.015em;color:#f2f3f6}.ag-detail-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--hm-space-2) var(--hm-space-3)}.ag-detail-meta-item{display:flex;flex-direction:column;gap:1px;min-width:0}.ag-detail-meta-label{font-size:.6rem;color:var(--hm-text-dim);text-transform:uppercase;letter-spacing:.05em}.ag-detail-meta-value{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);color:var(--hm-text);word-break:break-word}.ag-detail-source{margin:0;font-size:.66rem;color:var(--hm-text-dim);font-style:italic}.ag-detail-section{display:flex;flex-direction:column;gap:4px}.ag-detail-section-head{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-2)}.ag-detail-text{margin:0;padding:var(--hm-space-2);max-height:34vh;overflow:auto;font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);line-height:1.5;white-space:pre-wrap;word-break:break-word;color:var(--hm-text-muted);background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.ag-detail-pending{margin:0;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}@media(max-width:640px){.modal-content.ag-detail-modal{width:96vw;max-width:96vw;max-height:92vh}.ag-detail-text{max-height:40vh}}.ag-card-meta{display:flex;justify-content:space-between;gap:var(--hm-space-2)}.ag-card-result,.ag-card-error{padding:var(--hm-space-2);border-radius:var(--hm-radius-md);background:var(--hm-bg);border:1px solid var(--hm-border)}.ag-result-label{font-size:.6rem;color:var(--hm-text-dim);text-transform:uppercase;margin-bottom:2px}.ag-result-text{font-size:var(--hm-text-xs);white-space:pre-wrap;word-break:break-word;max-height:6rem;overflow:hidden;color:var(--hm-text-muted)}.ag-card-error{border-color:#ef444433}.ag-card-actions{display:flex;justify-content:flex-end}.loop-status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}.loop-status-running{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success);animation:loop-pulse 2s ease-in-out infinite}.loop-status-error{background:var(--hm-danger);box-shadow:0 0 6px var(--hm-danger)}.loop-status-stopped{background:var(--hm-text-dim)}@keyframes loop-pulse{0%,to{opacity:1;box-shadow:0 0 6px var(--hm-success)}50%{opacity:.5;box-shadow:0 0 2px var(--hm-success)}}.loop-history{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4);max-height:200px;overflow-y:auto}.log-filter-field{flex:1 1 0%;min-width:0}.log-filter-row{display:flex;align-items:center;gap:.375rem;min-width:0}.log-filter-input{min-width:120px}@media(max-width:640px){.log-filter-field{flex:1 0 100%}.log-filter-input{min-width:0}}.loop-history-entry{font-size:var(--hm-text-sm);font-family:var(--hm-font-mono);color:var(--hm-text-muted);padding:var(--hm-space-1) 0;border-bottom:1px solid rgba(31,41,55,.3);white-space:pre-wrap;word-break:break-word}.loop-history-entry:last-child{border-bottom:none}.loop-card{display:flex;flex-direction:column;gap:var(--hm-space-3)}.loop-card-main{min-width:0;cursor:pointer;border-radius:var(--hm-radius-md);transition:background .12s ease,box-shadow .12s ease}.loop-card-main:hover{background:var(--hm-surface-hover)}.loop-card-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;margin-bottom:var(--hm-space-2);color:var(--hm-text);font-size:var(--hm-text-sm);line-height:1.45}.loop-card-main:focus-visible{outline:2px solid var(--hm-accent);outline-offset:3px}.loop-card-actions{display:flex;justify-content:flex-end;gap:var(--hm-space-2)}.loop-card-preview{display:flex;flex-direction:column;gap:3px;margin-top:var(--hm-space-3);padding:var(--hm-space-2);border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:var(--hm-bg);font-size:var(--hm-text-xs);color:var(--hm-text-dim);max-height:4.5rem;overflow:hidden;white-space:pre-wrap;word-break:break-word}.loop-detail-modal{gap:var(--hm-space-4)}.loop-detail-goal{max-height:22vh}.loop-detail-condition{max-height:14vh}.loop-detail-history-head{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--hm-space-3);border-top:1px solid var(--hm-border);padding-top:var(--hm-space-3)}.loop-detail-history-head h3{margin:0 0 2px;font-size:var(--hm-text-sm)}.loop-detail-notice{padding:var(--hm-space-2);color:var(--hm-text-muted);font-size:var(--hm-text-xs);background:#c58b3214;border:1px solid rgba(197,139,50,.2);border-radius:var(--hm-radius-md)}.loop-detail-iterations{display:flex;flex-direction:column;gap:var(--hm-space-3)}.loop-detail-iteration{display:flex;flex-direction:column;gap:var(--hm-space-2);padding:var(--hm-space-3);border:1px solid var(--hm-border);background:var(--hm-surface-elevated);border-radius:var(--hm-radius-lg)}.loop-detail-iteration-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-2)}.loop-detail-iteration-head>div{display:flex;flex-direction:column;min-width:0}.loop-detail-turn-meta{display:flex;flex-wrap:wrap;gap:5px var(--hm-space-3);font-family:var(--hm-font-mono);font-size:.65rem;color:var(--hm-text-dim)}.loop-detail-response{max-height:30vh}.loop-detail-tools{display:flex;flex-wrap:wrap;gap:4px}.loop-context-details{padding:var(--hm-space-3);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);background:var(--hm-bg)}.loop-context-details summary{cursor:pointer;color:var(--hm-text-muted);font-size:var(--hm-text-xs);font-weight:600}.loop-context-details[open] summary{margin-bottom:var(--hm-space-2)}.loop-context-list{display:flex;flex-direction:column;gap:var(--hm-space-2);margin-top:var(--hm-space-2)}.loop-context-entry{max-height:18vh}@media(max-width:640px){.loop-card-actions{justify-content:stretch}.loop-card-actions .btn{flex:1}.loop-detail-history-head{align-items:flex-start}.loop-detail-turn-meta{display:grid;grid-template-columns:1fr 1fr}}.process-output-preview{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4);font-size:var(--hm-text-sm);font-family:var(--hm-font-mono);color:var(--hm-text-muted);max-height:120px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;margin:0}.hm-input:focus-visible,.hm-select:focus-visible,.chat-input:focus-visible,.toggle-switch input:focus-visible+.toggle-slider,.session-checkbox:focus-visible,.memory-checkbox:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.page-fade-in{animation:page-fade .2s ease-out}@keyframes page-fade{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.empty-state{display:flex;flex-direction:column;align-items:center;gap:var(--hm-space-3);padding:var(--hm-space-8) var(--hm-space-5);text-align:center}.empty-state-icon{font-size:1.75rem;opacity:.4}.empty-state-text{color:var(--hm-text-muted);font-size:var(--hm-text-md)}.empty-state-hint{color:var(--hm-text-dim);font-size:var(--hm-text-sm)}.cron-preset-btn{display:inline-block;padding:.125rem .5rem;background:var(--hm-gold-dim);border:1px solid rgba(217,119,6,.2);border-radius:var(--hm-radius-sm);font-size:var(--hm-text-xs);color:var(--hm-text-muted);cursor:pointer;transition:all var(--hm-transition-base)}.cron-preset-btn:hover{background:var(--hm-accent-dim);color:var(--hm-text);border-color:#d9770666}.hm-card-accent{border-top:2px solid var(--hm-accent)}.hm-divider{height:1px;background:linear-gradient(90deg,transparent,var(--hm-accent-dim),transparent);border:none;margin:var(--hm-space-5) 0}.hm-mono{font-family:var(--hm-font-mono)}.hm-section-title{font-size:var(--hm-text-sm);font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--hm-text-dim)}.ws-indicator{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;transition:background .3s,box-shadow .3s}.ws-connected{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success)}.ws-disconnected{background:var(--hm-danger);box-shadow:0 0 6px var(--hm-danger)}.ws-connecting,.ws-reconnecting{background:var(--hm-warning);box-shadow:0 0 6px var(--hm-warning);animation:ws-pulse 1.2s ease-in-out infinite}@keyframes ws-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.75)}}.ws-toast{position:fixed;bottom:var(--hm-space-5);left:50%;transform:translate(-50%);z-index:9999;padding:var(--hm-space-3) var(--hm-space-5);border-radius:var(--hm-radius-lg);font-size:var(--hm-text-sm);font-weight:500;box-shadow:var(--hm-shadow-lg);pointer-events:none}.ws-toast-success{background:#22c55e26;border:1px solid rgba(34,197,94,.3);color:#4ade80}.ws-toast-warn{background:#eab30826;border:1px solid rgba(234,179,8,.3);color:#fbbf24}.ws-toast-info{background:#3b82f626;border:1px solid rgba(59,130,246,.3);color:#60a5fa}.ws-toast-enter-active{animation:ws-toast-in .3s ease-out}.ws-toast-leave-active{animation:ws-toast-out .25s ease-in forwards}@keyframes ws-toast-in{0%{opacity:0;transform:translate(-50%) translateY(12px)}to{opacity:1;transform:translate(-50%) translateY(0)}}@keyframes ws-toast-out{0%{opacity:1;transform:translate(-50%) translateY(0)}to{opacity:0;transform:translate(-50%) translateY(12px)}}.toast-stack{position:fixed;bottom:var(--hm-space-5);right:var(--hm-space-5);z-index:9999;display:flex;flex-direction:column;align-items:flex-end;gap:var(--hm-space-2);pointer-events:none}.toast-item{display:flex;align-items:center;gap:var(--hm-space-2);max-width:380px;padding:var(--hm-space-3) var(--hm-space-4);border-radius:var(--hm-radius-lg);font-size:var(--hm-text-sm);font-weight:500;box-shadow:var(--hm-shadow-lg);cursor:pointer;pointer-events:auto;background:var(--hm-surface);border:1px solid var(--hm-border);color:var(--hm-text)}.toast-item .toast-icon{flex-shrink:0}.toast-item .toast-text{overflow-wrap:anywhere}.toast-success{background:#22c55e1f;border-color:#22c55e4d;color:#4ade80}.toast-error{background:#ef44441f;border-color:#ef444459;color:#f87171}.toast-info{background:#3b82f61f;border-color:#3b82f64d;color:#60a5fa}.toast-enter-active{transition:all .25s ease-out}.toast-leave-active{transition:all .2s ease-in}.toast-enter-from,.toast-leave-to{opacity:0;transform:translateY(10px)}.modal-enter-active{transition:opacity .15s ease-out}.modal-leave-active{transition:opacity .12s ease-in}.modal-enter-from,.modal-leave-to{opacity:0}.confirm-dialog{max-width:420px}.palette-overlay{align-items:flex-start;padding-top:14vh}.palette{width:90%;max-width:520px;background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-xl);box-shadow:var(--hm-shadow-lg);overflow:hidden}.palette-input{width:100%;background:transparent;border:none;border-bottom:1px solid var(--hm-border);padding:var(--hm-space-4) var(--hm-space-5);font-size:var(--hm-text-base);color:var(--hm-text);outline:none}.palette-input::-moz-placeholder{color:var(--hm-text-muted)}.palette-input::placeholder{color:var(--hm-text-muted)}.palette-results{max-height:320px;overflow-y:auto;padding:var(--hm-space-2)}.palette-item{display:flex;align-items:center;gap:var(--hm-space-2);width:100%;text-align:left;padding:var(--hm-space-2) var(--hm-space-3);border-radius:var(--hm-radius-md);font-size:var(--hm-text-sm);color:var(--hm-text);background:transparent;border:none;cursor:pointer}.palette-item.selected{background:var(--hm-surface-hover)}.palette-item .palette-icon{flex-shrink:0}.palette-item .palette-group{color:var(--hm-text-muted)}.palette-empty{padding:var(--hm-space-4);text-align:center;color:var(--hm-text-muted);font-size:var(--hm-text-sm)}.palette-footer{display:flex;gap:var(--hm-space-3);padding:var(--hm-space-2) var(--hm-space-4);border-top:1px solid var(--hm-border);font-size:var(--hm-text-xs);color:var(--hm-text-muted)}.palette-footer kbd{padding:0 .3em;background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-sm)}.item-enter{animation:item-slide-in .25s ease-out}@keyframes item-slide-in{0%{opacity:0;transform:translate(-8px)}to{opacity:1;transform:translate(0)}}.dash-stat-value{transition:color var(--hm-transition-slow)}.badge{transition:transform var(--hm-transition-fast),background var(--hm-transition-base)}.badge-pop{animation:badge-pop .3s var(--hm-transition-spring)}@keyframes badge-pop{0%{transform:scale(1)}50%{transform:scale(1.3)}to{transform:scale(1)}}.action-pending{opacity:.6;pointer-events:none}.action-success{animation:action-flash-ok .5s ease-out}.action-error{animation:action-flash-err .5s ease-out}@keyframes action-flash-ok{0%{box-shadow:0 0 #22c55e66}to{box-shadow:0 0 0 0 transparent}}@keyframes action-flash-err{0%{box-shadow:0 0 #ef444466}to{box-shadow:0 0 0 0 transparent}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip-nav{position:absolute;top:-100%;left:var(--hm-space-5);z-index:1000;padding:var(--hm-space-3) var(--hm-space-5);background:var(--hm-accent);color:#fff;border-radius:var(--hm-radius-md);font-size:var(--hm-text-md);font-weight:600;text-decoration:none;transition:top var(--hm-transition-base)}.skip-nav:focus{top:var(--hm-space-3)}.ag-filter-btn:focus-visible,.kb-tree-header:focus-visible,.kb-chunk-item:focus-visible,.cron-preset-btn:focus-visible,.dash-health-item:focus-visible,[role=tab]:focus-visible,[role=button]:focus-visible,a:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}.spinner{animation:none;border-top-color:var(--hm-accent)}.page-fade-in{animation:none}.hm-sidebar,.dash-ring-progress{transition:none}.ws-connecting,.ws-reconnecting,.ws-toast{animation:none}}@media(forced-colors:active){.status-dot,.ag-status-dot,.dash-health-dot,.loop-status-dot{forced-color-adjust:none}.btn-primary{border:1px solid ButtonText}.hm-card{border:1px solid CanvasText}.nav-item.active{border-left:3px solid Highlight}}.hm-main .p-6{max-width:1600px}@media(min-width:1200px){:root{--hm-text-md: .9375rem}}@media(max-width:768px){.hm-main .p-6{padding:var(--hm-space-5)}.hm-table{font-size:var(--hm-text-sm)}.hm-table th,.hm-table td{padding:var(--hm-space-2) var(--hm-space-3)}.mobile-hide{display:none!important}.chat-bubble{max-width:90%}.btn{min-height:36px}.hm-input,.hm-select{min-height:36px;font-size:1rem}.toggle-switch{width:44px;height:24px}.toggle-slider:before{width:18px;height:18px}.toggle-switch input:checked+.toggle-slider:before{transform:translate(20px)}.modal-content{padding:var(--hm-space-5);width:95%}.config-key-col{width:auto!important;min-width:80px}.log-chip{padding:var(--hm-space-1) .625rem;font-size:var(--hm-text-sm)}.session-checkbox,.memory-checkbox{width:18px;height:18px}.cron-preset-btn{padding:var(--hm-space-1) .625rem;font-size:var(--hm-text-sm)}}@media(max-width:640px){.dash-hero-left{flex-basis:100%}}@media(max-width:480px){.chat-bubble-wrap{max-width:90%}.chat-avatar{width:24px;height:24px}.chat-input-row{flex-direction:column;align-items:stretch}.chat-input{width:100%}.chat-send-btn{width:100%;min-width:unset}.chat-suggestions{flex-direction:column}.chat-suggestion{width:100%}.stat-grid-mobile{grid-template-columns:repeat(2,1fr)!important}h1{font-size:1.125rem!important}.hm-main .p-6{padding:var(--hm-space-4)}}.fts-result-user{background:#11182780;border-color:#1f2937}.fts-result-assistant{background:#312e814d;border-color:#312e814d}.fts-result-summary{background:#451a0333;border-color:#78350f4d}.fts-result-fts{background:#022c2233;border-color:#064e3b4d}.fts-result-channel{background:#3b076433;border-color:#581c874d}.fts-result-default{background:#1118274d;border-color:#1f293780}.fts-highlight{background:#d977064d;color:var(--hm-gold);border-radius:2px;padding:0 2px}.health-card{padding:1rem;border-left:3px solid transparent;transition:border-color .2s}.health-card-ok{border-left-color:#22c55e}.health-card-degraded{border-left-color:#eab308}.health-card-down{border-left-color:#ef4444}.health-card-unconfigured{border-left-color:var(--hm-border)}.health-card-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.375rem}.health-card-icon{font-size:.875rem;flex-shrink:0}.health-card-name{font-size:.8125rem;font-weight:600;flex:1}.health-card-detail{font-size:.75rem;color:var(--hm-text-muted);line-height:1.4}.health-card-meta{margin-top:.5rem;padding-top:.5rem;border-top:1px solid var(--hm-border-subtle)}.health-meta-row{display:flex;align-items:center;gap:.5rem;padding:1px 0}.health-host-item{display:flex;align-items:center;gap:.375rem;padding:2px 0}.health-host-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.health-host-dot.dot-connected{background:#22c55e}.health-host-dot.dot-idle{background:#6b7280}.health-host-dot.dot-unknown{background:#374151}.res-bar-bg{height:8px;background:#1e293b;border-radius:4px;overflow:hidden}.res-bar-fill{height:100%;border-radius:4px;transition:width .4s ease;min-width:2px}.res-bar-blue{background:#3b82f6}.res-bar-purple{background:#a855f7}.res-bar-emerald{background:#10b981}.res-bar-amber{background:#f59e0b}.discord-gateway-card{display:grid;grid-template-columns:minmax(220px,.85fr) minmax(340px,1.35fr);align-items:center;gap:var(--hm-space-5)}.discord-gateway-summary{min-width:0;display:grid;gap:var(--hm-space-2)}.discord-gateway-heading{display:flex;align-items:center;gap:var(--hm-space-3)}.discord-credential-status{display:flex;align-items:center;flex-wrap:wrap;gap:var(--hm-space-2)}.discord-storage-note{color:var(--hm-text-dim);font-size:.62rem}.discord-gateway-controls{min-width:0;display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-3)}.discord-token-form{min-width:0;flex:1 1 420px;display:flex;gap:var(--hm-space-2)}.discord-token-form .hm-input{min-width:0;flex:1 1 auto}.discord-gateway-actions{flex:none;display:flex;gap:var(--hm-space-2)}.discord-global-card{display:grid;gap:var(--hm-space-4)}.discord-global-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-4)}.discord-global-heading p{margin-top:.2rem;color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.discord-global-toggles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-5);padding:var(--hm-space-3) 0;border-top:1px solid var(--hm-border-subtle);border-bottom:1px solid var(--hm-border-subtle)}.discord-global-toggles label{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.discord-global-rows{display:grid}.discord-global-row{min-width:0;display:grid;grid-template-columns:minmax(120px,.45fr) minmax(180px,1fr) minmax(260px,1.2fr);align-items:center;gap:var(--hm-space-4);padding:var(--hm-space-3) 0;border-bottom:1px solid var(--hm-border-subtle)}.discord-global-row:last-child{border-bottom:0}.discord-global-row-label{display:flex;align-items:center;gap:var(--hm-space-2)}.discord-global-row-label strong{color:var(--hm-text);font-size:var(--hm-text-xs)}.discord-help{position:relative;flex:none}.discord-help summary{width:18px;height:18px;display:inline-grid;place-items:center;border:1px solid var(--hm-border);border-radius:50%;color:var(--hm-text-dim);font-size:.62rem;cursor:pointer;list-style:none}.discord-help summary::-webkit-details-marker{display:none}.discord-help p{position:absolute;z-index:50;top:calc(100% + .35rem);left:0;width:min(280px,70vw);padding:var(--hm-space-3);color:var(--hm-text-muted);background:#0d121a;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);box-shadow:var(--hm-shadow-lg);font-size:.66rem;line-height:1.45}.discord-global-row .cfgc-chip-list,.discord-global-row .cfgc-chip-add,.discord-global-row .discord-user-combobox{min-width:0}.discord-global-row .cfgc-chip-add{flex-wrap:nowrap}.discord-global-row .cfgc-chip-add .hm-input{width:100%;min-width:0}.discord-user-combobox-with-add{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--hm-space-2)}.discord-user-combobox-with-add .discord-user-combobox-options{grid-column:1 / -1}.discord-global-footer{padding-top:var(--hm-space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);border-top:1px solid var(--hm-border-subtle)}.discord-global-footer span{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}@media(max-width:760px){.discord-gateway-card{grid-template-columns:minmax(0,1fr)}.discord-gateway-controls{align-items:stretch;flex-direction:column}.discord-token-form{flex-basis:auto}.discord-gateway-actions .btn{flex:1}.discord-global-heading,.discord-global-footer{align-items:stretch;flex-direction:column}.discord-global-toggles{grid-template-columns:minmax(0,1fr);gap:var(--hm-space-3)}.discord-global-row{grid-template-columns:minmax(0,1fr);gap:var(--hm-space-2)}.discord-global-footer .btn{width:100%}}.llm-advanced{margin-top:var(--hm-space-4);overflow:hidden;background:#04070b45;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.llm-advanced>summary{min-height:50px;padding:var(--hm-space-3) var(--hm-space-4);display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);color:var(--hm-text);cursor:pointer;list-style:none}.llm-advanced>summary::-webkit-details-marker{display:none}.llm-advanced>summary:after{content:"+";flex:none;color:var(--hm-accent);font-size:1rem}.llm-advanced[open]>summary:after{content:"−"}.llm-advanced>summary span{font-size:var(--hm-text-sm);font-weight:650}.llm-advanced>summary small{color:var(--hm-text-dim);font-size:var(--hm-text-xs);text-align:right}.llm-advanced-body{padding:var(--hm-space-4);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-4);border-top:1px solid var(--hm-border-subtle)}.llm-advanced-group{padding:var(--hm-space-4);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:var(--hm-space-3);background:#111720a6;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.llm-advanced-group>header{grid-column:1 / -1;display:grid;gap:.1rem}.llm-advanced-group>header strong{color:var(--hm-text);font-size:var(--hm-text-xs)}.llm-advanced-group>header span{color:var(--hm-text-dim);font-size:.62rem}.llm-advanced-group>label{min-width:0;display:grid;align-content:start;gap:.3rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.llm-advanced-group>label small{color:var(--hm-text-dim)}.llm-advanced-group.single{grid-template-columns:minmax(0,360px)}.llm-advanced-toggle{grid-column:1 / -1}.llm-toggle-control{min-height:38px;display:flex;align-items:center}.llm-advanced-footer{grid-column:1 / -1;display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4)}.llm-advanced-footer p{max-width:680px;color:var(--hm-text-dim);font-size:var(--hm-text-xs);line-height:1.5}.llm-advanced-state{grid-column:1 / -1;margin:0;color:var(--hm-text-dim);font-size:var(--hm-text-xs);line-height:1.45}.llm-advanced-state.pending{color:var(--hm-warning)}.llm-advanced.compact .llm-advanced-body{grid-template-columns:minmax(0,1fr) auto;align-items:end}.llm-advanced.compact .llm-advanced-footer{grid-column:auto}.llm-field-note{display:block;margin-top:.3rem;color:#9a8255;line-height:1.4}@media(max-width:760px){.llm-advanced>summary{align-items:flex-start}.llm-advanced>summary small{text-align:left}.llm-advanced-body,.llm-advanced.compact .llm-advanced-body,.llm-advanced-group{grid-template-columns:minmax(0,1fr)}.llm-advanced-footer,.llm-advanced.compact .llm-advanced-footer{grid-column:1;align-items:stretch;flex-direction:column}.llm-advanced-footer .btn{width:100%}}.config-center-page{--cfgc-rail-w: 218px;--cfgc-panel-border: rgba(49, 57, 71, .86);height:calc(100vh - var(--hm-topbar-h) - var(--hm-section-tabs-h));min-height:0!important;padding-bottom:0;display:flex;flex-direction:column;overflow:hidden}.cfgc-page-header{position:relative;z-index:16;flex:none;display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-6);margin-bottom:var(--hm-space-5)}.cfgc-eyebrow{margin-bottom:.28rem;color:var(--hm-accent);font-size:.625rem;font-weight:700;letter-spacing:.115em;line-height:1.2;text-transform:uppercase}.cfgc-page-summary{margin-top:.35rem;color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.cfgc-header-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-2);flex-wrap:wrap}.cfgc-header-actions .btn{display:inline-flex;align-items:center;gap:var(--hm-space-2)}.cfgc-loading{display:grid;gap:var(--hm-space-4)}.cfgc-loading-grid{display:grid;grid-template-columns:var(--cfgc-rail-w) minmax(0,1fr);gap:var(--hm-space-5)}.cfgc-loading-grid .skeleton:last-child{grid-column:2}.cfgc-health{flex:none;margin-bottom:var(--hm-space-5);padding:var(--hm-space-5);background:radial-gradient(circle at 100% 0,rgba(197,139,50,.09),transparent 30%),linear-gradient(150deg,#131821fa,#0c1017fa);border:1px solid var(--cfgc-panel-border);border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-sm)}.cfgc-health-heading{display:flex;align-items:center;gap:var(--hm-space-4);margin-bottom:var(--hm-space-4)}.cfgc-health-heading>div:first-child{flex:1;min-width:0}.cfgc-health-heading h2,.cfgc-category-panel-heading h2,.cfgc-empty h2,.cfgc-review-header h2{color:var(--hm-text);font-size:var(--hm-text-lg);font-weight:650;letter-spacing:-.015em}.cfgc-health-ok,.cfgc-unsaved-pill{display:inline-flex;align-items:center;gap:var(--hm-space-2);padding:.3rem .55rem;border-radius:var(--hm-radius-full);font-size:var(--hm-text-xs);font-weight:650;white-space:nowrap}.cfgc-health-ok{color:#83d6a6;background:#22c55e14;border:1px solid rgba(34,197,94,.19)}.cfgc-unsaved-pill{color:#ecc16f;background:#d977061a;border:1px solid rgba(217,119,6,.24)}.cfgc-listener-heading{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-bottom:.5rem}.cfgc-listener-state{display:inline-flex;align-items:center;flex:none;border:1px solid rgba(148,163,184,.22);border-radius:999px;padding:.2rem .55rem;color:#aeb9c9;background:#94a3b814;font-size:.72rem;font-weight:600}.cfgc-listener-state.state-active{color:#78cb98;border-color:#22c55e38;background:#22c55e14}.cfgc-listener-state.state-pending{color:#dfa858;border-color:#d977063d;background:#d9770617}.cfgc-listener-state.state-restricted{color:#78b9d6;border-color:#0ea5e938;background:#0ea5e914}.cfgc-listener-facts{display:grid;gap:.45rem;margin:0 0 .9rem;padding:.75rem;border:1px solid rgba(148,163,184,.12);border-radius:.6rem;background:#0f172a38}.cfgc-listener-facts>div{display:grid;grid-template-columns:minmax(8rem,.3fr) 1fr;gap:.75rem}.cfgc-listener-facts span{color:#8793a5;font-size:.75rem}.cfgc-listener-facts strong{color:#cbd5e1;font-size:.75rem;font-weight:500;overflow-wrap:anywhere}.cfgc-health-filters{display:grid;grid-template-columns:repeat(7,minmax(112px,1fr));gap:var(--hm-space-2);overflow-x:auto;scrollbar-width:thin}.cfgc-health-filter{min-width:0;min-height:56px;padding:.55rem .6rem;display:flex;align-items:center;gap:var(--hm-space-3);color:var(--hm-text-muted);background:#06090d61;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);text-align:left;cursor:pointer;transition:color var(--hm-transition-base),border-color var(--hm-transition-base),background var(--hm-transition-base)}.cfgc-health-filter:hover{color:var(--hm-text);border-color:var(--hm-border);background:#ffffff06}.cfgc-health-filter.active{color:var(--hm-text);border-color:#c58b326b;background:#c58b3213;box-shadow:inset 0 0 0 1px #c58b320d}.cfgc-health-icon{width:28px;height:28px;display:inline-grid;place-items:center;flex:none;color:#a5afbd;background:#ffffff0a;border-radius:var(--hm-radius-md)}.cfgc-health-icon.state-applied{color:#69c78e;background:#22c55e14}.cfgc-health-icon.state-pending_restart{color:#e1aa55;background:#d977061a}.cfgc-health-icon.state-dormant{color:#a792d8;background:#8b5cf617}.cfgc-health-icon.state-invalid{color:#e17878;background:#ef444417}.cfgc-health-icon.state-drift{color:#e1bd65;background:#eab30817}.cfgc-health-icon.state-unknown{color:#9aa6b6;background:#94a3b817}.cfgc-health-copy{min-width:0;display:grid;gap:.1rem}.cfgc-health-copy>span{font-size:var(--hm-text-xs);font-weight:650;line-height:1.25;overflow-wrap:anywhere;white-space:normal}.cfgc-health-copy small{color:var(--hm-text-dim);font-size:.625rem;white-space:nowrap}.cfgc-health-alert{display:flex;align-items:flex-start;gap:var(--hm-space-3);margin-top:var(--hm-space-3);padding:var(--hm-space-3) var(--hm-space-4);border-radius:var(--hm-radius-md);font-size:var(--hm-text-xs)}.cfgc-health-alert>.odin-icon{flex:none;margin-top:.1rem}.cfgc-health-alert>div{display:grid;gap:.1rem}.cfgc-health-alert>div span{color:var(--hm-text-muted)}.cfgc-health-alert.danger{color:#eb8a8a;background:#ef444413;border:1px solid rgba(239,68,68,.19)}.cfgc-health-alert.warning{color:#e3bb69;background:#eab30811;border:1px solid rgba(234,179,8,.17)}.cfgc-workspace{min-height:0;flex:1;display:grid;grid-template-columns:var(--cfgc-rail-w) minmax(0,1fr);align-items:stretch;gap:var(--hm-space-5);overflow:hidden}.cfgc-category-rail{position:relative;top:auto;height:100%;max-height:none;padding:var(--hm-space-3);overflow:hidden auto;background:#0c1017e6;border:1px solid var(--cfgc-panel-border);border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-sm)}.cfgc-rail-label{padding:var(--hm-space-2) var(--hm-space-3) var(--hm-space-3);color:var(--hm-text-dim);font-size:.625rem;font-weight:700;letter-spacing:.105em;text-transform:uppercase}.cfgc-category-scroll{display:grid;gap:.2rem}.cfgc-category{width:100%;min-width:0;padding:.55rem var(--hm-space-3);display:grid;grid-template-columns:26px minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-muted);background:transparent;border:1px solid transparent;border-radius:var(--hm-radius-md);text-align:left;cursor:pointer}.cfgc-category:hover{color:var(--hm-text);background:#ffffff07}.cfgc-category.active{color:#edd19a;background:#c58b3218;border-color:#c58b323d}.cfgc-category-icon{width:26px;height:26px;display:inline-grid;place-items:center;color:currentColor;background:#ffffff09;border-radius:var(--hm-radius-md)}.cfgc-category-copy{min-width:0;display:grid;gap:.05rem}.cfgc-category-copy>span{overflow:hidden;font-size:var(--hm-text-sm);font-weight:600;text-overflow:ellipsis;white-space:nowrap}.cfgc-category-copy small{color:var(--hm-text-dim);font-size:.6rem}.cfgc-category-counts{display:flex;align-items:center;justify-content:flex-end;gap:.18rem;flex-wrap:wrap;max-width:50px}.cfgc-category-counts>span,.cfgc-rail-key b{display:inline-grid;min-width:18px;height:18px;place-items:center;padding:0 .2rem;border-radius:4px;font-size:.55rem;font-weight:750}.cfgc-category-counts .modified,.cfgc-rail-key .modified{color:#edc171;background:#d977061f}.cfgc-category-counts .restart,.cfgc-rail-key .restart{color:#dfaa5d;background:#b4681b21}.cfgc-category-counts .invalid,.cfgc-rail-key .invalid{color:#e38282;background:#ef44441c}.cfgc-category-counts .dormant,.cfgc-rail-key .dormant{color:#b09adf;background:#8b5cf61f}.cfgc-rail-key{margin-top:var(--hm-space-3);padding:var(--hm-space-3) var(--hm-space-2) var(--hm-space-1);display:grid;grid-template-columns:1fr 1fr;gap:.32rem;color:var(--hm-text-dim);border-top:1px solid var(--hm-border-subtle);font-size:.56rem}.cfgc-rail-key span{display:flex;align-items:center;gap:.25rem}.cfgc-rail-key b{font-style:normal}.cfgc-main{min-width:0;min-height:0;height:100%;overflow-y:auto;padding-bottom:5rem}.cfgc-toolbar{position:sticky;top:0;z-index:12;margin:0 0 var(--hm-space-4);padding:var(--hm-space-3);display:flex;align-items:center;gap:var(--hm-space-3);background:#090c11f0;border:1px solid var(--cfgc-panel-border);border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-sm);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.cfgc-search{min-width:0;height:38px;display:flex;align-items:center;flex:1;gap:var(--hm-space-3);padding:0 .45rem 0 .75rem;color:var(--hm-text-dim);background:#04070b8c;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.cfgc-search:focus-within{color:var(--hm-accent);border-color:#c58b3275;box-shadow:0 0 0 3px #c58b3214}.cfgc-search input{min-width:0;height:100%;flex:1;color:var(--hm-text);background:transparent;border:0;outline:0;font-size:var(--hm-text-sm)}.cfgc-search input::-moz-placeholder{color:#5f6977}.cfgc-search input::placeholder{color:#5f6977}.cfgc-search .icon-btn{width:28px;height:28px}.cfgc-category-panel{display:grid;gap:var(--hm-space-3)}.cfgc-category-panel+.cfgc-category-panel{margin-top:var(--hm-space-6)}.cfgc-category-panel-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--hm-space-4);padding:0 var(--hm-space-1)}.cfgc-category-panel-heading>span{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.cfgc-empty{min-height:280px;display:grid;place-items:center;align-content:center;gap:var(--hm-space-3);color:var(--hm-text-dim);text-align:center}.cfgc-empty p{max-width:520px;color:var(--hm-text-muted);font-size:var(--hm-text-sm)}.cfgc-empty code{color:#c5a569}.cfgc-section{overflow:clip;background:linear-gradient(150deg,#121720f5,#0d1118fa);border:1px solid var(--cfgc-panel-border);border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-sm);transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.cfgc-section:hover{border-color:#394252}.cfgc-section.modified{border-color:#c58b3266;box-shadow:inset 3px 0 #c58b328c,var(--hm-shadow-sm)}.cfgc-section.editing{border-color:#c58b3294;box-shadow:inset 3px 0 var(--hm-accent),var(--hm-shadow-glow-sm)}.cfgc-section-header{width:100%;min-width:0;min-height:66px;padding:var(--hm-space-4) var(--hm-space-5);display:grid;grid-template-columns:20px minmax(150px,.65fr) minmax(220px,1.35fr) auto;align-items:center;gap:var(--hm-space-3);color:inherit;background:transparent;border:0;text-align:left;cursor:pointer}.cfgc-section-header:hover{background:#ffffff05}.cfgc-section-chevron{color:var(--hm-text-dim)}.cfgc-section-title{min-width:0;display:grid;gap:.12rem}.cfgc-section-title>span{overflow:hidden;color:var(--hm-text);font-size:var(--hm-text-sm);font-weight:650;text-overflow:ellipsis;white-space:nowrap}.cfgc-section-title small{overflow:hidden;color:#67717f;font-family:var(--hm-font-mono);font-size:.6rem;text-overflow:ellipsis;white-space:nowrap}.cfgc-section-summary{min-width:0;overflow:hidden;color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1.45;text-overflow:ellipsis;white-space:nowrap}.cfgc-section-badges{min-width:0;display:flex;align-items:center;justify-content:flex-end;gap:.3rem;flex-wrap:wrap}.cfgc-section-badges .badge{padding:.15rem .4rem;font-size:.56rem}.cfgc-badge-restart{color:#dba454;background:#d9770617;border:1px solid rgba(217,119,6,.22)}.cfgc-badge-dormant{color:#ac98d7;background:#8b5cf617;border:1px solid rgba(139,92,246,.2)}.cfgc-field-count{min-width:27px;height:22px;display:inline-grid;place-items:center;padding:0 .35rem;color:var(--hm-text-dim);background:#ffffff09;border-radius:var(--hm-radius-full);font-family:var(--hm-font-mono);font-size:.6rem}.cfgc-section-body{border-top:1px solid var(--hm-border-subtle)}.cfgc-section-actions{min-height:58px;padding:var(--hm-space-3) var(--hm-space-5);display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);background:#04070b47;border-bottom:1px solid var(--hm-border-subtle)}.cfgc-section-actions>div:first-child{min-width:0;display:grid;gap:.1rem}.cfgc-section-actions strong{color:var(--hm-text);font-size:var(--hm-text-xs);font-weight:650}.cfgc-section-actions span{overflow:hidden;color:var(--hm-text-dim);font-size:.625rem;text-overflow:ellipsis;white-space:nowrap}.cfgc-section-actions .btn{display:inline-flex;align-items:center;gap:var(--hm-space-2)}.cfgc-search-hits{padding:var(--hm-space-3) var(--hm-space-5);display:flex;align-items:center;gap:var(--hm-space-2);flex-wrap:wrap;color:var(--hm-text-dim);background:#c58b320a;border-bottom:1px solid rgba(197,139,50,.12);font-size:.625rem}.cfgc-search-hits button{padding:.22rem .4rem;color:#dbbd84;background:#c58b3214;border:1px solid rgba(197,139,50,.18);border-radius:5px;cursor:pointer}.cfgc-search-hits code{margin-left:.2rem;color:#7b8491}.cfgc-owner-card{margin:var(--hm-space-5);padding:var(--hm-space-5);display:grid;grid-template-columns:36px minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-4);background:#c58b320e;border:1px solid rgba(197,139,50,.18);border-radius:var(--hm-radius-md)}.cfgc-owner-card.compact{margin-bottom:0}.cfgc-owner-icon{width:36px;height:36px;display:inline-grid;place-items:center;color:#ddba78;background:#c58b321a;border-radius:var(--hm-radius-md)}.cfgc-owner-card strong{color:var(--hm-text);font-size:var(--hm-text-sm)}.cfgc-owner-card p{margin-top:.18rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1.5}.cfgc-owner-card .btn{display:inline-flex;align-items:center;gap:var(--hm-space-2)}.cfgc-fields{padding:0 var(--hm-space-5)}.cfgc-field{min-width:0;padding:var(--hm-space-5) 0;display:grid;grid-template-columns:minmax(210px,.95fr) minmax(240px,1.05fr);gap:var(--hm-space-6);scroll-margin-top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h) + 70px);border-bottom:1px solid var(--hm-border-subtle)}.cfgc-field:last-child{border-bottom:0}.cfgc-field.changed{margin-inline:calc(var(--hm-space-3) * -1);padding-inline:var(--hm-space-3);background:#c58b3209;border-radius:var(--hm-radius-md)}.cfgc-field.invalid{background:#ef444409}.cfgc-field-copy{min-width:0;padding-inline:var(--hm-space-4)}.cfgc-field-copy>label,.cfgc-field-copy>.cfgc-field-label{display:block;color:var(--hm-text);font-size:var(--hm-text-sm);font-weight:630}.cfgc-field-copy>code{display:block;margin-top:.2rem;overflow-wrap:anywhere;color:#687382;font-family:var(--hm-font-mono);font-size:.6rem}.cfgc-field-copy>p{max-width:620px;margin-top:.42rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1.52}.cfgc-field-meta{margin-top:var(--hm-space-3);display:flex;align-items:center;gap:var(--hm-space-2);flex-wrap:wrap;color:var(--hm-text-dim);font-size:.6rem}.cfgc-apply-pill{display:inline-flex;align-items:center;min-height:20px;padding:.14rem .42rem;color:#9ea8b5;background:#ffffff09;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-full);font-size:.58rem;font-weight:650;line-height:1.2}.cfgc-apply-pill.apply-live-read,.cfgc-apply-pill.apply-live-apply{color:#78cb98;background:#22c55e12;border-color:#22c55e2b}.cfgc-apply-pill.apply-live-for-new-work{color:#78b9d6;background:#0ea5e912;border-color:#0ea5e92e}.cfgc-apply-pill.apply-restart{color:#dfa858;background:#d9770614;border-color:#d9770630}.cfgc-apply-pill.apply-activation-required,.cfgc-apply-pill.apply-dormant{color:#af99df;background:#8b5cf614;border-color:#8b5cf62e}.cfgc-apply-pill.apply-legacy-control{color:#d8bd70;background:#eab30812;border-color:#eab3082e}.cfgc-sensitive{display:inline-flex;align-items:center;gap:.22rem;color:#b6a06f}.cfgc-apply-details{margin-top:var(--hm-space-3);display:grid;gap:var(--hm-space-2)}.cfgc-section-apply-details{margin:0 var(--hm-space-5) var(--hm-space-4);display:grid;gap:var(--hm-space-2)}.cfgc-apply-detail{padding:.55rem .65rem;background:#04070b42;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.cfgc-apply-detail-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-2)}.cfgc-apply-detail-heading strong{color:#aeb8c5;font-size:.64rem;font-weight:650}.cfgc-apply-detail>p{margin-top:.3rem;color:var(--hm-text-dim);font-size:.62rem;line-height:1.45}.cfgc-apply-detail>code{display:block;margin-top:.3rem;overflow-wrap:anywhere;color:#c8ad75;font-family:var(--hm-font-mono);font-size:.6rem}.cfgc-apply-detail.detail-restart{border-color:#d9770629}.cfgc-apply-detail.detail-activation{border-color:#8b5cf62b}.cfgc-field-control{min-width:0;display:grid;align-content:start}.cfgc-field-control .hm-input,.cfgc-field-control .hm-select{width:100%}.cfgc-field-control .hm-input[type=number]{max-width:250px}.cfgc-value{display:block;min-width:0;padding:.52rem .65rem;overflow:hidden;color:#c2c9d2;background:#04070b5e;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);text-overflow:ellipsis;white-space:nowrap}.cfgc-value-block{max-height:160px;margin:0;padding:var(--hm-space-3);overflow:auto;color:#aeb7c3;background:#04070b5e;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);font-family:var(--hm-font-mono);font-size:.65rem;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.cfgc-boolean-control{min-height:38px;padding:.42rem .6rem;display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);color:var(--hm-text-muted);background:#04070b57;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);font-size:var(--hm-text-xs)}.cfgc-json-input{resize:vertical;line-height:1.5}.cfgc-expert-note{margin-top:.35rem;color:var(--hm-text-dim);font-size:.6rem}.cfgc-field-error{margin-top:.35rem;color:#e27c7c;font-size:var(--hm-text-xs)}.cfgc-write-only{padding:var(--hm-space-3);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.15rem var(--hm-space-4);background:#c58b320b;border:1px solid rgba(197,139,50,.15);border-radius:var(--hm-radius-md)}.cfgc-write-only>span{display:inline-flex;align-items:center;gap:var(--hm-space-2);color:#cdb57e;font-size:var(--hm-text-xs);font-weight:620}.cfgc-write-only>small{grid-column:1;color:var(--hm-text-dim);font-size:.6rem}.cfgc-write-only>button{grid-column:2;grid-row:1 / span 2}.cfgc-mobile-action-bar{display:none}.cfgc-review-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:80;display:flex;justify-content:flex-end;background:#000000a8;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.cfgc-review-tray{width:min(680px,94vw);height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto;color:var(--hm-text);background:#0c1017;border-left:1px solid #303846;box-shadow:-24px 0 70px #0000006b;outline:0;animation:cfgc-tray-in .18s ease-out}@keyframes cfgc-tray-in{0%{transform:translate(20px);opacity:.55}to{transform:translate(0);opacity:1}}.cfgc-review-header{padding:var(--hm-space-6);display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-4);border-bottom:1px solid var(--hm-border)}.cfgc-review-header p{margin-top:.32rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.cfgc-review-body{min-height:0;padding:var(--hm-space-5) var(--hm-space-6);overflow-y:auto}.cfgc-review-group{overflow:hidden;background:#121720bd;border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg)}.cfgc-review-group+.cfgc-review-group{margin-top:var(--hm-space-4)}.cfgc-review-group>header{padding:var(--hm-space-3) var(--hm-space-4);display:flex;align-items:center;justify-content:space-between;background:#05080c5c;border-bottom:1px solid var(--hm-border-subtle)}.cfgc-review-group>header>span:last-child{min-width:23px;height:23px;display:inline-grid;place-items:center;color:var(--hm-text-dim);background:#ffffff0a;border-radius:var(--hm-radius-full);font-family:var(--hm-font-mono);font-size:.6rem}.cfgc-review-entry{padding:var(--hm-space-4);display:grid;grid-template-columns:minmax(160px,.7fr) minmax(200px,1.3fr);align-items:center;gap:var(--hm-space-4);border-bottom:1px solid var(--hm-border-subtle)}.cfgc-review-entry:last-child{border-bottom:0}.cfgc-review-entry>div:first-child{min-width:0;display:grid;gap:.17rem}.cfgc-review-entry strong{overflow:hidden;font-size:var(--hm-text-xs);font-weight:620;text-overflow:ellipsis;white-space:nowrap}.cfgc-review-entry code{overflow-wrap:anywhere;color:var(--hm-text-dim);font-size:.58rem}.cfgc-review-values{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:var(--hm-space-2)}.cfgc-review-values span{min-width:0;padding:.38rem .48rem;overflow:hidden;color:#9da7b4;background:#04070b66;border:1px solid var(--hm-border-subtle);border-radius:5px;font-family:var(--hm-font-mono);font-size:.6rem;text-overflow:ellipsis;white-space:nowrap}.cfgc-review-values span:last-child{color:#d5bd89;border-color:#c58b322b}.cfgc-review-values .odin-icon{color:var(--hm-text-dim)}.cfgc-review-footer{padding:var(--hm-space-5) var(--hm-space-6);display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--hm-space-3);background:#080b10f5;border-top:1px solid var(--hm-border)}.cfgc-review-footer>div{min-width:0;display:grid;gap:.1rem}.cfgc-review-footer strong{color:var(--hm-text);font-size:var(--hm-text-xs);font-weight:620}.cfgc-review-footer span{color:var(--hm-text-dim);font-size:.6rem}.config-center-page{max-width:1600px;margin-inline:auto}.cfgc-health-filters{grid-template-columns:repeat(auto-fit,minmax(128px,1fr))}.cfgc-restart-banner{flex:none;margin-bottom:var(--hm-space-5);padding:var(--hm-space-4) var(--hm-space-5);display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-4);color:#e6ba6c;background:#b4681b17;border:1px solid rgba(217,119,6,.28);border-radius:var(--hm-radius-lg)}.cfgc-restart-banner>div:nth-child(2){min-width:0;display:grid;gap:.15rem}.cfgc-restart-banner strong{color:#f0cf92;font-size:var(--hm-text-sm)}.cfgc-restart-banner span{color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.cfgc-restart-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-2);flex-wrap:wrap}.cfgc-restart-dialog{width:min(620px,calc(100vw - 2rem));margin:auto;padding:var(--hm-space-6);color:var(--hm-text);background:#0f141c;border:1px solid #3a4352;border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-lg)}.cfgc-restart-dialog h2{font-size:var(--hm-text-xl);font-weight:650}.cfgc-restart-dialog>p{margin-top:var(--hm-space-3);color:var(--hm-text-muted);font-size:var(--hm-text-sm);line-height:1.55}.cfgc-restart-dialog-actions{margin-top:var(--hm-space-5);display:flex;justify-content:flex-end;gap:var(--hm-space-2);flex-wrap:wrap}.cfgc-field-groups{display:grid;gap:var(--hm-space-4);padding:0 var(--hm-space-5) var(--hm-space-5)}.cfgc-field-group{overflow:clip;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.cfgc-field-group:not(.nested){border-color:transparent}.cfgc-field-group-header{padding:var(--hm-space-3) var(--hm-space-4);display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-4);background:#04070b59;border-bottom:1px solid var(--hm-border-subtle)}.cfgc-field-group-header>div{min-width:0;display:grid;gap:.18rem}.cfgc-field-group-header strong{color:var(--hm-text);font-size:var(--hm-text-sm)}.cfgc-field-group-header code{color:var(--hm-text-dim);font-size:.58rem}.cfgc-field-group-header p{color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1.45}.cfgc-field-group-header>span{flex:none;color:var(--hm-text-dim);font-size:.6rem}.cfgc-field-group .cfgc-fields{padding:0}.cfgc-image-models .cfgc-field-group-header{flex-wrap:wrap}.cfgc-image-models .cfgc-field-group-header>div{flex:1 1 240px}.cfgc-image-models .cfgc-field-group-header>.btn{flex:none}.cfgc-image-model-control{gap:var(--hm-space-2);padding-right:var(--hm-space-4)}.cfgc-image-model-effective{display:flex;align-items:center;gap:var(--hm-space-2);flex-wrap:wrap}.cfgc-image-model-effective>code{min-width:0;overflow-wrap:anywhere;color:var(--hm-text);font-size:var(--hm-text-xs)}.cfgc-image-model-status{padding:.14rem .42rem;color:var(--hm-text-muted);background:var(--hm-surface-elevated);border:1px solid var(--hm-border);border-radius:var(--hm-radius-full);font-size:.58rem;line-height:1.2}.cfgc-image-model-choice{width:-moz-fit-content;width:fit-content;max-width:100%;padding:var(--hm-space-1);display:inline-grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-1);background:var(--hm-bg-raised);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.cfgc-image-model-choice>button{min-width:0;min-height:32px;padding:var(--hm-space-2) var(--hm-space-3);display:flex;align-items:center;justify-content:center;color:var(--hm-text-muted);background:transparent;border:0;border-radius:var(--hm-radius-sm);font-size:var(--hm-text-xs);text-align:center;cursor:pointer}.cfgc-image-model-choice>button:not(:disabled):hover{background:var(--hm-surface-hover);color:var(--hm-text)}.cfgc-image-model-choice>button[aria-pressed=true]{color:var(--hm-gold);background:var(--hm-accent-dim)}.cfgc-image-model-choice>button:focus-visible{outline:2px solid var(--hm-accent);outline-offset:1px}.cfgc-image-model-choice>button:disabled{opacity:.5;cursor:not-allowed}.cfgc-image-model-shipped,.cfgc-image-model-notice{color:var(--hm-text-muted);font-size:var(--hm-text-xs);overflow-wrap:anywhere}.cfgc-image-model-shipped code{color:var(--hm-text-muted)}.cfgc-image-model-notice{padding:var(--hm-space-3) var(--hm-space-4)}.cfgc-image-model-notice.cfgc-field-error{color:var(--hm-danger)}.cfgc-field{grid-template-columns:minmax(180px,4fr) minmax(190px,5fr);align-items:start}.cfgc-field-runtime-note{grid-column:1 / -1;min-width:0;margin-top:calc(var(--hm-space-2) * -1);padding:var(--hm-space-3);display:grid;gap:.25rem;background:#8b5cf60b;border:1px solid rgba(139,92,246,.16);border-radius:var(--hm-radius-md)}.cfgc-field-runtime-note strong{color:#b9a6df;font-size:.6rem;text-transform:uppercase;letter-spacing:.055em}.cfgc-field-runtime-note p{color:var(--hm-text-muted);font-size:.66rem;line-height:1.45}.cfgc-field-runtime-note .btn{margin-top:var(--hm-space-2);justify-self:start}.cfgc-runtime-summary-list{margin-top:var(--hm-space-3);display:grid;gap:var(--hm-space-2)}.cfgc-runtime-summary{padding:var(--hm-space-3);background:#04070b45;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.cfgc-runtime-summary strong{color:#b8c0cb;font-size:.62rem}.cfgc-runtime-summary p{margin-top:.25rem;color:var(--hm-text-muted);font-size:.66rem;line-height:1.45}.cfgc-group-apply-details{padding:0 var(--hm-space-4) var(--hm-space-4)}.cfgc-group-apply-details summary{color:var(--hm-text-dim);cursor:pointer;font-size:var(--hm-text-xs)}.cfgc-apply-detail-list{margin-top:var(--hm-space-3);display:grid;gap:var(--hm-space-2)}.discord-user-combobox{position:relative;width:100%}.discord-user-combobox>.hm-input{width:100%}.discord-user-combobox-options{position:absolute;z-index:60;top:calc(100% + .25rem);left:0;right:0;max-height:15rem;overflow-y:auto;background:#0d121a;border:1px solid #3b4555;border-radius:var(--hm-radius-md);box-shadow:var(--hm-shadow-lg)}.discord-user-combobox-option{width:100%;min-height:38px;padding:.45rem .65rem;display:flex;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-muted);background:transparent;border:0;cursor:pointer;font-size:var(--hm-text-xs);text-align:left}.discord-user-combobox-option:hover,.discord-user-combobox-option.active{color:var(--hm-text);background:#c58b3217}.discord-user-combobox-option img,.discord-user-combobox-avatar{width:22px;height:22px;flex:none;border-radius:var(--hm-radius-full)}.discord-user-combobox-avatar{display:inline-grid;place-items:center;color:var(--hm-text-dim);background:#293140;font-size:.6rem}.discord-user-combobox-name{min-width:0;overflow:hidden;color:inherit;text-overflow:ellipsis;white-space:nowrap}.discord-user-combobox-username{min-width:0;overflow:hidden;color:var(--hm-text-dim);text-overflow:ellipsis;white-space:nowrap}.discord-user-combobox-bot{margin-left:auto;padding:.08rem .25rem;color:#c4b5fd;background:#6366f133;border-radius:3px;font-size:.52rem}.discord-global-user-picker{display:block}.cfgc-chip-editor{display:grid;gap:var(--hm-space-3)}.cfgc-chip-list{min-height:32px;display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}.cfgc-chip{max-width:100%;padding:.25rem .35rem .25rem .55rem;display:inline-flex;align-items:center;gap:.35rem;overflow-wrap:anywhere;color:#d8c28e;background:#c58b3214;border:1px solid rgba(197,139,50,.2);border-radius:var(--hm-radius-full);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs)}.cfgc-chip button{width:20px;height:20px;color:#a9956e;background:transparent;border:0;border-radius:50%;cursor:pointer}.cfgc-chip button:hover{color:#fff;background:#ffffff14}.cfgc-chip-empty{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.cfgc-chip-add{display:flex;align-items:center;gap:var(--hm-space-2);flex-wrap:wrap;color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.cfgc-chip-add .hm-input{width:min(170px,100%)}.cfgc-structured-summary{padding:var(--hm-space-3);display:grid;gap:.15rem;background:#04070b52;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.cfgc-structured-summary span{color:var(--hm-text);font-size:var(--hm-text-xs)}.cfgc-structured-summary small{color:var(--hm-text-dim);font-size:.62rem;line-height:1.4}@media(max-width:1180px){.config-center-page{--cfgc-rail-w: 190px}.cfgc-health-filters{grid-template-columns:repeat(7,minmax(106px,1fr))}.cfgc-category-copy small{display:none}.cfgc-section-header{grid-template-columns:20px minmax(0,1fr) auto}.cfgc-section-summary{grid-column:2;white-space:normal}.cfgc-section-badges{grid-column:3;grid-row:1 / span 2}.cfgc-field{grid-template-columns:minmax(180px,5fr) minmax(220px,7fr)}}@media(max-width:900px){.config-center-page{--cfgc-rail-w: 100%;height:auto;min-height:100%!important;overflow:visible}.cfgc-workspace{min-height:auto;flex:none;grid-template-columns:minmax(0,1fr);align-items:start;gap:var(--hm-space-3);overflow:visible}.cfgc-main{height:auto;overflow:visible;padding-bottom:5rem}.cfgc-category-rail{position:sticky;top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h));height:auto;z-index:13;max-height:none;padding:var(--hm-space-2);overflow:visible}.cfgc-rail-label,.cfgc-rail-key{display:none}.cfgc-category-scroll{display:flex;gap:var(--hm-space-2);overflow-x:auto;scroll-snap-type:x proximity}.cfgc-category{width:auto;min-width:150px;grid-template-columns:24px minmax(0,1fr) auto;flex:0 0 auto;scroll-snap-align:start}.cfgc-category-copy small{display:block}.cfgc-toolbar{top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h) + 59px)}.cfgc-section-header{grid-template-columns:20px minmax(120px,.8fr) minmax(150px,1.2fr) auto}.cfgc-field{grid-template-columns:minmax(180px,.85fr) minmax(210px,1.15fr);gap:var(--hm-space-4)}}@media(max-width:760px){.section-tabs-wrap{position:sticky;overflow:hidden}.section-tabs-wrap:after{content:"";position:absolute;top:0;right:0;bottom:0;width:34px;pointer-events:none;background:linear-gradient(90deg,transparent,rgba(9,12,17,.98));border-right:2px solid rgba(197,139,50,.28)}.config-center-page{padding-bottom:6.75rem!important}.cfgc-page-header{align-items:stretch}.cfgc-header-actions{flex:none}.cfgc-header-actions .cfgc-desktop-history,.cfgc-header-actions .btn-primary{display:none}.cfgc-health{margin-inline:-.15rem;padding:var(--hm-space-4)}.cfgc-health-heading{align-items:flex-start;flex-wrap:wrap}.cfgc-health-filters{grid-template-columns:repeat(7,122px);margin-inline:calc(var(--hm-space-4) * -1);padding-inline:var(--hm-space-4);padding-bottom:.2rem;scroll-snap-type:x proximity}.cfgc-health-filter{scroll-snap-align:start}.cfgc-category-rail{top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h));margin-inline:-.1rem}.cfgc-category-scroll{padding-right:1.5rem}.cfgc-category-scroll:after{content:"";flex:0 0 .1rem}.cfgc-toolbar{top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h) + 58px);padding:var(--hm-space-2)}.cfgc-toolbar>.btn{display:none}.cfgc-search input{font-size:1rem}.cfgc-category-panel-heading{align-items:center}.cfgc-section-header{min-height:72px;padding:var(--hm-space-4);grid-template-columns:18px minmax(0,1fr) auto;gap:var(--hm-space-2)}.cfgc-section-summary,.cfgc-section-badges .badge{display:none}.cfgc-section-actions{padding:var(--hm-space-3) var(--hm-space-4);align-items:stretch;flex-direction:column}.cfgc-section-actions>div:last-child{justify-content:flex-end}.cfgc-search-hits{padding-inline:var(--hm-space-4)}.cfgc-owner-card{margin:var(--hm-space-4);grid-template-columns:34px minmax(0,1fr);padding:var(--hm-space-4)}.cfgc-owner-card .btn{grid-column:1 / -1;width:100%;justify-content:center}.cfgc-field-groups{padding-inline:var(--hm-space-4)}.cfgc-fields{padding:0}.cfgc-field{grid-template-columns:minmax(0,1fr);gap:var(--hm-space-3);padding-block:var(--hm-space-4)}.cfgc-field-copy>p{line-height:1.45}.cfgc-image-model-control{padding-inline:var(--hm-space-4)}.cfgc-write-only{grid-template-columns:minmax(0,1fr)}.cfgc-write-only>small,.cfgc-write-only>button{grid-column:1;grid-row:auto}.cfgc-write-only>button{width:100%;margin-top:var(--hm-space-2)}.cfgc-restart-banner{grid-template-columns:auto minmax(0,1fr);padding:var(--hm-space-4)}.cfgc-restart-actions{grid-column:1 / -1;justify-content:stretch}.cfgc-restart-actions .btn{flex:1}.cfgc-restart-dialog-actions{display:grid}.cfgc-mobile-action-bar{position:fixed;z-index:55;left:0;right:0;bottom:0;min-height:68px;padding:var(--hm-space-3) max(var(--hm-space-4),env(safe-area-inset-right)) calc(var(--hm-space-3) + env(safe-area-inset-bottom)) max(var(--hm-space-4),env(safe-area-inset-left));display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-2);background:#090c11fa;border-top:1px solid #394252;box-shadow:0 -12px 32px #0006;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}.cfgc-mobile-action-bar .btn{min-width:0;padding-inline:.55rem}.cfgc-mobile-overflow{position:relative}.cfgc-mobile-overflow-menu{position:absolute;right:0;bottom:calc(100% + var(--hm-space-2));width:170px;padding:var(--hm-space-2);display:grid;gap:.15rem;background:#111720;border:1px solid #3a4352;border-radius:var(--hm-radius-md);box-shadow:var(--hm-shadow-lg)}.cfgc-mobile-overflow-menu button{min-height:38px;padding:0 var(--hm-space-3);display:flex;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-muted);background:transparent;border:0;border-radius:5px;font-size:var(--hm-text-sm);text-align:left}.cfgc-mobile-overflow-menu button:hover:not(:disabled){color:var(--hm-text);background:#ffffff0a}.cfgc-mobile-overflow-menu button:disabled{opacity:.38}.cfgc-review-tray{width:100%;max-width:none}.cfgc-review-header{padding:var(--hm-space-5) var(--hm-space-4)}.cfgc-review-body{padding:var(--hm-space-4)}.cfgc-review-entry{grid-template-columns:minmax(0,1fr);gap:var(--hm-space-3)}.cfgc-review-footer{padding:var(--hm-space-4);grid-template-columns:1fr 1fr}.cfgc-review-footer>div{grid-column:1 / -1}}@media(max-width:420px){.cfgc-page-header{gap:var(--hm-space-3)}.cfgc-header-actions .btn{padding-inline:.65rem}.cfgc-page-summary{max-width:220px}.cfgc-mobile-action-bar{grid-template-columns:minmax(70px,1fr) minmax(90px,1fr) 36px}.cfgc-section-title>span{white-space:normal}.cfgc-review-values{grid-template-columns:minmax(0,1fr)}.cfgc-review-values .odin-icon{margin:-.1rem auto;transform:rotate(90deg)}}.llm-context-summary{min-height:58px;padding:.48rem .65rem;display:grid;grid-template-rows:auto 1fr;gap:.2rem;background:#070a0f61;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.llm-context-summary>span:first-child{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.llm-context-summary-value{min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:.2rem var(--hm-space-2)}.llm-context-summary-pair{display:inline-flex;flex-wrap:nowrap;align-items:center;gap:var(--hm-space-2)}.llm-context-summary strong{color:#e7d39d;font-size:var(--hm-text-sm);font-variant-numeric:tabular-nums;white-space:nowrap}.llm-context-summary strong small{color:var(--hm-text-dim);font-size:.58rem;font-weight:500}.llm-context-summary .llm-budget-provenance{flex:none}.llm-context-summary-value>small{color:var(--hm-text-dim);font-size:.58rem}.llm-context-budget-panel{grid-column:1 / -1;overflow:hidden;background:radial-gradient(circle at 100% 0,rgba(201,162,78,.075),transparent 32rem),#080c12c7;border:1px solid rgba(116,96,57,.58);border-radius:var(--hm-radius-md)}.llm-context-budget-heading{padding:var(--hm-space-4);display:flex;align-items:end;justify-content:space-between;gap:var(--hm-space-5);border-bottom:1px solid var(--hm-border-subtle)}.llm-context-budget-heading>div{min-width:0;display:grid;gap:.15rem}.llm-context-budget-heading strong{color:var(--hm-text);font-size:var(--hm-text-sm)}.llm-context-budget-heading>div>span{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.llm-utilization-field{min-width:176px;display:grid;gap:.35rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.llm-utilization-input{display:grid;grid-template-columns:minmax(0,1fr) 2rem;align-items:center}.llm-utilization-input .hm-input{border-radius:var(--hm-radius-md) 0 0 var(--hm-radius-md);text-align:right;font-variant-numeric:tabular-nums}.llm-utilization-input small{align-self:stretch;display:grid;place-items:center;color:var(--hm-accent);background:#c9a24e14;border:1px solid var(--hm-border);border-left:0;border-radius:0 var(--hm-radius-md) var(--hm-radius-md) 0}.llm-context-budget-copy{margin:0;padding:var(--hm-space-3) var(--hm-space-4);color:var(--hm-text-dim);background:#070a0f8c;border-bottom:1px solid var(--hm-border-subtle);font-size:var(--hm-text-xs);line-height:1.55}.llm-context-budget-loading,.llm-context-budget-error{min-height:112px;padding:var(--hm-space-5);display:flex;align-items:center;justify-content:center;gap:var(--hm-space-3);color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.llm-context-budget-loading .spinner{width:16px;height:16px}.llm-context-budget-error{color:var(--hm-danger)}.llm-context-budget-table-wrap{overflow-x:auto}.llm-context-budget-table{min-width:1120px;table-layout:fixed}.llm-context-budget-table th{padding:0 var(--hm-space-3);font-size:.58rem}.llm-context-budget-table td{padding:var(--hm-space-3);font-size:var(--hm-text-xs)}.llm-context-budget-table th:nth-child(1){width:15%}.llm-context-budget-table th:nth-child(2){width:11%}.llm-context-budget-table th:nth-child(3){width:19%}.llm-context-budget-table th:nth-child(4){width:12%}.llm-context-budget-table th:nth-child(5){width:13%}.llm-context-budget-table th:nth-child(6){width:14%}.llm-context-budget-table th:nth-child(7){width:16%}.llm-context-budget-table tbody tr.has-clamp td{background:#d3943109}.llm-context-budget-table code,.llm-clamp-card code{color:#e4e8ef;font-size:.72rem;letter-spacing:-.015em}.llm-context-budget-table td>small{display:block;margin-top:.2rem;color:var(--hm-text-dim);font-size:.58rem;line-height:1.3}.llm-budget-value{color:var(--hm-text-muted);font-variant-numeric:tabular-nums}.llm-budget-effective{color:#e7d39d;font-weight:650}.llm-budget-override{display:flex;align-items:center;gap:var(--hm-space-2)}.llm-budget-override .hm-input{min-width:0;height:34px;padding:0 var(--hm-space-2);font-size:var(--hm-text-xs);font-variant-numeric:tabular-nums}.llm-budget-reset{padding:0;color:var(--hm-text-dim);border:0;background:transparent;font-size:.62rem;cursor:pointer}.llm-budget-reset:hover{color:var(--hm-accent)}.llm-budget-warning{color:var(--hm-warning)!important}.llm-budget-pending{display:inline-flex;margin-top:.3rem;padding:.12rem .42rem;color:var(--hm-warning);background:#d977061a;border:1px solid rgba(217,119,6,.23);border-radius:var(--hm-radius-full);font-size:.56rem;font-weight:650}.llm-budget-provenance{display:inline-flex;align-items:center;min-height:22px;padding:.16rem .48rem;border:1px solid transparent;border-radius:var(--hm-radius-full);font-size:.58rem;font-weight:650;white-space:nowrap}.llm-budget-provenance.is-built-in{color:#94a0b0;background:#7e889817;border-color:#7e88982e}.llm-budget-provenance.is-override{color:#ccb474;background:#c9a24e17;border-color:#c9a24e33}.llm-budget-provenance.is-clamp{color:var(--hm-warning);background:#d977061a;border-color:#d977063b}.llm-budget-density{display:inline-flex;align-items:center;min-height:22px;margin-top:.18rem;padding:.16rem .48rem;border:1px solid rgba(122,162,201,.2);border-radius:var(--hm-radius-full);color:#8fb3d1;background:#7aa2c917;font-size:.58rem;font-weight:650;white-space:nowrap}.llm-clamp-list{padding:var(--hm-space-4);border-top:1px solid rgba(217,119,6,.2)}.llm-clamp-list-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);margin-bottom:var(--hm-space-3)}.llm-clamp-list-heading>div{display:grid;gap:.1rem}.llm-clamp-list-heading strong{color:var(--hm-warning);font-size:var(--hm-text-xs)}.llm-clamp-list-heading>div>span{color:var(--hm-text-dim);font-size:.6rem}.llm-clamp-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-3)}.llm-clamp-card{min-width:0;padding:var(--hm-space-3);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.25rem var(--hm-space-3);background:#d977060b;border:1px solid rgba(217,119,6,.16);border-radius:var(--hm-radius-md)}.llm-clamp-card>div{min-width:0;display:flex;align-items:baseline;gap:var(--hm-space-2);overflow:hidden}.llm-clamp-card>div span{color:var(--hm-warning);font-size:var(--hm-text-xs);font-variant-numeric:tabular-nums;white-space:nowrap}.llm-clamp-card p{grid-column:1;margin:0;color:var(--hm-text-dim);font-size:.58rem}.llm-clamp-card .btn{grid-column:2;grid-row:1 / span 2}@media(max-width:1180px){.llm-context-budget-table{min-width:900px}.llm-clamp-grid{grid-template-columns:minmax(0,1fr)}}@media(max-width:900px){.llm-context-budget-panel+.llm-advanced-footer{align-items:stretch;flex-direction:column}.llm-context-budget-panel+.llm-advanced-footer .btn{width:100%;justify-content:center}.llm-context-budget-heading{align-items:stretch;flex-direction:column}.llm-utilization-field{min-width:0}.llm-context-budget-table-wrap{overflow:visible}.llm-context-budget-table,.llm-context-budget-table tbody,.llm-context-budget-table tr,.llm-context-budget-table td{display:block;width:100%;min-width:0}.llm-context-budget-table thead{display:none}.llm-context-budget-table tbody{padding:var(--hm-space-3);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-3)}.llm-context-budget-table tr{margin-bottom:0;overflow:hidden;background:#111720b3;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.llm-context-budget-table td{min-height:44px;padding:var(--hm-space-2) var(--hm-space-3);display:grid;grid-template-columns:minmax(104px,.85fr) minmax(0,1.15fr);align-items:center;gap:var(--hm-space-3);text-align:right}.llm-context-budget-table td:before{content:attr(data-label);color:var(--hm-text-dim);font-size:.58rem;font-weight:650;letter-spacing:.045em;text-align:left;text-transform:uppercase}.llm-context-budget-table td:first-child{min-height:48px;background:#070a0f73}.llm-context-budget-table td>small{margin:-.1rem 0 0;grid-column:2}.llm-budget-override{justify-content:flex-end}.llm-budget-override .hm-input{width:150px}.llm-clamp-list-heading{align-items:flex-start}.llm-clamp-card{grid-template-columns:minmax(0,1fr)}.llm-clamp-card p,.llm-clamp-card .btn{grid-column:1;grid-row:auto}.llm-clamp-card .btn{width:100%;justify-content:center;margin-top:var(--hm-space-2)}}@media(max-width:600px){.llm-context-budget-table tbody{grid-template-columns:minmax(0,1fr)}.llm-budget-override{gap:.35rem}.llm-budget-override .hm-input{width:min(105px,100%)}}.mcp-page{width:min(100%,1440px);display:grid;gap:var(--hm-space-5)}.mcp-page-header,.mcp-section-heading,.mcp-server-header,.mcp-editor-header,.mcp-editor-footer{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-5)}.mcp-eyebrow{margin-bottom:.2rem;color:var(--hm-accent-hover);font-size:.63rem;font-weight:650;letter-spacing:.1em;text-transform:uppercase}.mcp-lede{max-width:72ch;margin-top:.3rem;color:var(--hm-text-dim);font-size:.75rem;line-height:1.55}.mcp-header-actions,.mcp-server-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-2);flex-wrap:wrap}.mcp-header-actions .btn,.mcp-server-actions .btn{white-space:nowrap}.mcp-loading{display:grid;gap:var(--hm-space-4)}.mcp-control-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--hm-space-5);padding:var(--hm-space-5);background:linear-gradient(135deg,rgba(197,139,50,.07),transparent 34%),var(--hm-surface);border:1px solid rgba(197,139,50,.26);border-radius:var(--hm-radius-xl);box-shadow:var(--hm-shadow-sm)}.mcp-control-main{display:flex;align-items:center;gap:var(--hm-space-4);min-width:0}.mcp-control-icon{width:42px;height:42px;display:grid;place-items:center;flex:none;color:var(--hm-accent-hover);background:var(--hm-accent-dim);border:1px solid rgba(197,139,50,.24);border-radius:11px}.mcp-control-title-row{display:flex;align-items:center;gap:var(--hm-space-3);flex-wrap:wrap}.mcp-control-title-row h2{color:var(--hm-text);font-size:.92rem;font-weight:650}.mcp-control-main p{margin-top:.2rem;color:var(--hm-text-dim);font-size:.71rem}.mcp-master-state,.mcp-state-pill,.mcp-tool-state{display:inline-flex;align-items:center;min-height:20px;padding:0 .48rem;border:1px solid transparent;border-radius:999px;font-size:.62rem;font-weight:650;line-height:1;letter-spacing:.025em}.mcp-master-state.enabled{color:#8cdbb8;background:#3db9831c;border-color:#3db9833d}.mcp-master-state.disabled{color:#9aa4b3;background:#9aa4b314;border-color:#9aa4b32e}.mcp-master-toggle{display:grid;place-items:center;min-width:44px;min-height:42px}.mcp-master-toggle .toggle-switch{transform:scale(1.16)}.mcp-publication-limits{grid-column:1 / -1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr)) auto;align-items:start;gap:var(--hm-space-3)}.mcp-publication-limits>.btn{align-self:center;white-space:nowrap}.mcp-limits-help,.mcp-limits-error{grid-column:1 / -1;font-size:.66rem;line-height:1.5}.mcp-limits-help{color:var(--hm-text-dim)}.mcp-limits-error{color:#f2b1b1}.mcp-aggregate{grid-column:1 / -1;display:grid;grid-template-columns:repeat(4,minmax(90px,1fr));overflow:hidden;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-lg);background:#0305083d}.mcp-aggregate>div{min-height:56px;padding:.65rem .8rem;display:grid;align-content:center;border-right:1px solid var(--hm-border-subtle)}.mcp-aggregate>div:last-child{border-right:0}.mcp-aggregate strong{color:var(--hm-text);font-family:var(--hm-font-mono);font-size:1rem;font-weight:600}.mcp-aggregate span{color:var(--hm-text-dim);font-size:.62rem}.mcp-inline-error{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-3);padding:.65rem .8rem;color:#f2b1b1;background:#e0646414;border:1px solid rgba(224,100,100,.22);border-radius:var(--hm-radius-md);font-size:.7rem}.mcp-inline-error button{padding:.2rem;color:inherit;background:transparent;border:0;border-radius:4px;cursor:pointer}.mcp-server-section{display:grid;gap:var(--hm-space-4)}.mcp-section-heading{align-items:end;padding:0 .1rem}.mcp-section-heading h2{color:var(--hm-text);font-size:.88rem;font-weight:650}.mcp-section-heading p{max-width:76ch;margin-top:.2rem;color:var(--hm-text-dim);font-size:.68rem}.mcp-section-heading>span{color:var(--hm-text-dim);font-family:var(--hm-font-mono);font-size:.65rem;white-space:nowrap}.mcp-empty{display:grid;place-items:center;align-content:center;gap:var(--hm-space-3);text-align:center}.mcp-empty h3{color:var(--hm-text);font-size:.84rem}.mcp-empty p{max-width:54ch;color:var(--hm-text-dim);font-size:.7rem}.mcp-server-card{position:relative;overflow:hidden;background:linear-gradient(145deg,rgba(255,255,255,.012),transparent 48%),var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-xl);box-shadow:var(--hm-shadow-sm)}.mcp-server-card:before{content:"";position:absolute;inset:0 auto 0 0;width:2px;background:#566170}.mcp-server-card.state-connected:before{background:var(--hm-success)}.mcp-server-card.state-connecting:before{background:var(--hm-info)}.mcp-server-card.state-stale:before,.mcp-server-card.state-blocked:before{background:var(--hm-warning)}.mcp-server-card.state-error:before{background:var(--hm-danger)}.mcp-server-header{align-items:center;padding:var(--hm-space-5);border-bottom:1px solid var(--hm-border-subtle)}.mcp-card-switch{display:flex;align-items:center;gap:var(--hm-space-3);margin-left:auto;padding-right:var(--hm-space-4)}.mcp-card-switch-copy{display:grid;gap:2px;text-align:right}.mcp-card-switch-copy strong{color:var(--hm-text-muted);font-size:.63rem;font-weight:600}.mcp-card-switch-copy small{color:var(--hm-text-dim);font-size:.56rem;line-height:1.3;max-width:24ch}.mcp-card-switch .toggle-switch[aria-busy=true]{opacity:.55;pointer-events:none}.mcp-server-card.mcp-card-off{background:var(--hm-surface);border-color:var(--hm-border-subtle);box-shadow:none}.mcp-server-card.mcp-card-off .mcp-server-header{background:#ffffff04}.mcp-current-endpoint{display:grid;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);background:#ffffff05;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.mcp-current-endpoint>span{color:var(--hm-text-muted);font-size:.6rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase}.mcp-current-endpoint code{font-size:.66rem;color:var(--hm-text);word-break:break-all;background:transparent;padding:0}.mcp-current-endpoint small{color:var(--hm-text-dim);font-size:.59rem;line-height:1.4}.mcp-server-identity{display:flex;align-items:center;gap:var(--hm-space-4);min-width:0}.mcp-state-indicator{width:10px;height:10px;flex:none;border:2px solid currentColor;border-radius:50%;color:#687384;background:#68738438}.mcp-state-indicator.connected{color:var(--hm-success);background:var(--hm-success);box-shadow:0 0 8px #3db98361}.mcp-state-indicator.connecting{color:var(--hm-info);background:transparent;border-top-color:transparent;animation:spin .8s linear infinite}.mcp-state-indicator.stale,.mcp-state-indicator.blocked{color:var(--hm-warning);background:var(--hm-warning)}.mcp-state-indicator.error{color:var(--hm-danger);background:var(--hm-danger)}.mcp-server-title-row{display:flex;align-items:center;gap:var(--hm-space-3);flex-wrap:wrap}.mcp-server-title-row h3{color:var(--hm-text);font-family:var(--hm-font-mono);font-size:.83rem;font-weight:600;overflow-wrap:anywhere}.mcp-state-pill.connected{color:#8cdbb8;background:#3db9831a;border-color:#3db9833b}.mcp-state-pill.connecting{color:#a8c0ef;background:#668fd71a;border-color:#668fd73b}.mcp-state-pill.stale,.mcp-state-pill.blocked{color:#edc36d;background:#d8a3421a;border-color:#d8a3423d}.mcp-state-pill.error{color:#f0a3a3;background:#e064641a;border-color:#e064643b}.mcp-state-pill.disabled{color:#a1aab7;background:#9aa4b312;border-color:#9aa4b329}.mcp-server-subtitle{display:flex;align-items:center;gap:.8rem;margin-top:.25rem;color:var(--hm-text-dim);font-size:.63rem;flex-wrap:wrap}.mcp-server-subtitle span{display:inline-flex;align-items:center;gap:.28rem}.mcp-server-actions .icon-btn.danger{color:#d98e8e}.mcp-server-actions .icon-btn.danger:hover{color:#ffd0d0;background:#e064641f}.mcp-server-body{display:grid;gap:var(--hm-space-4);padding:var(--hm-space-5)}.mcp-metrics{display:grid;grid-template-columns:repeat(4,minmax(76px,1fr));gap:var(--hm-space-3)}.mcp-metrics>div{min-height:48px;padding:.55rem .7rem;display:grid;align-content:center;background:#05080c42;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.mcp-metrics strong{color:#d9dee6;font-family:var(--hm-font-mono);font-size:.78rem;font-weight:600}.mcp-metrics span{color:var(--hm-text-dim);font-size:.6rem}.mcp-server-message{display:grid;grid-template-columns:auto minmax(0,1fr);gap:var(--hm-space-3);padding:.65rem .75rem;border-radius:var(--hm-radius-md);font-size:.67rem}.mcp-server-message.blocked{color:#e9c67f;background:#d8a34212;border:1px solid rgba(216,163,66,.18)}.mcp-server-message.error{color:#efaaaa;background:#e0646412;border:1px solid rgba(224,100,100,.18)}.mcp-server-message strong{display:block;margin-bottom:.12rem;font-size:.64rem}.mcp-server-message p{color:inherit;opacity:.82;overflow-wrap:anywhere;white-space:pre-wrap}.mcp-stderr{overflow:hidden;background:#080a0e;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.mcp-stderr summary{padding:.55rem .75rem;color:var(--hm-text-dim);font-size:.63rem;cursor:pointer}.mcp-stderr pre{max-height:180px;overflow:auto;padding:.7rem;color:#9da8b8;background:#05070a;border-top:1px solid var(--hm-border-subtle);font-family:var(--hm-font-mono);font-size:.61rem;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.mcp-tool-disclosure{overflow:hidden;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-lg)}.mcp-tools-toggle{width:100%;min-height:40px;padding:.55rem .75rem;display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);color:var(--hm-text-muted);background:#06080c2e;border:0;cursor:pointer;font-size:.68rem}.mcp-tools-toggle:hover{color:var(--hm-text);background:#ffffff06}.mcp-tools-toggle>span{display:inline-flex;align-items:center;gap:var(--hm-space-2)}.mcp-tools-toggle>span:last-child{color:var(--hm-text-dim);font-size:.61rem}.mcp-tools-panel{display:grid;gap:var(--hm-space-3);padding:var(--hm-space-4);border-top:1px solid var(--hm-border-subtle)}.mcp-tool-search{min-height:36px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-3);padding:0 .65rem;color:var(--hm-text-dim);background:#090c11;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.mcp-tool-search:focus-within{border-color:var(--hm-accent);box-shadow:0 0 0 3px var(--hm-accent-glow)}.mcp-tool-search input{width:100%;min-width:0;padding:.45rem 0;color:var(--hm-text);background:transparent;border:0;outline:0;font-size:.68rem}.mcp-tool-search>span{font-size:.6rem}.mcp-tool-list{display:grid;gap:var(--hm-space-2)}.mcp-tool-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.25rem var(--hm-space-4);padding:.68rem .75rem;background:#04070b47;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.mcp-tool-names{display:flex;align-items:center;gap:var(--hm-space-3);min-width:0;flex-wrap:wrap}.mcp-tool-names>span{display:inline-flex;align-items:center;gap:var(--hm-space-2);min-width:0}.mcp-tool-names code{color:#c7ced9;font-size:.64rem;overflow-wrap:anywhere}.mcp-not-published{color:var(--hm-text-dim);font-size:.6rem}.mcp-tool-state.published{color:#8cdbb8;background:#3db98314;border-color:#3db98330}.mcp-tool-state.excluded{color:#e9c67f;background:#d8a34214;border-color:#d8a34230}.mcp-tool-state.unpublished{color:#a7afbb;background:#9aa4b312;border-color:#9aa4b329}.mcp-tool-row>p{grid-column:1 / -1;color:var(--hm-text-dim);font-size:.63rem;line-height:1.45;overflow-wrap:anywhere}.mcp-tool-row>p.mcp-exclusion{color:#c8ab75}.mcp-tool-empty,.mcp-tool-error{padding:1rem;text-align:center;color:var(--hm-text-dim);font-size:.66rem}.mcp-tool-error{color:#efaaaa}.mcp-modal-overlay{padding:var(--hm-space-5)}.modal-content.mcp-editor{width:min(96vw,920px);max-width:920px;max-height:92vh;padding:0;overflow:hidden;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto}.mcp-editor-header{padding:var(--hm-space-5) var(--hm-space-6);border-bottom:1px solid var(--hm-border-subtle)}.mcp-editor-header h2{font-size:1rem}.mcp-editor-header p{max-width:68ch;margin-top:.25rem;color:var(--hm-text-dim);font-size:.66rem}.mcp-form-error{margin:var(--hm-space-4) var(--hm-space-6) 0;padding:.65rem .75rem;display:flex;align-items:flex-start;gap:var(--hm-space-3);color:#efaaaa;background:#e0646414;border:1px solid rgba(224,100,100,.2);border-radius:var(--hm-radius-md);font-size:.68rem}.mcp-editor-nav{display:flex;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-6);overflow-x:auto;border-bottom:1px solid var(--hm-border-subtle)}.mcp-editor-nav button{padding:.35rem .6rem;color:var(--hm-text-dim);background:transparent;border:0;border-radius:var(--hm-radius-sm);font:inherit;font-size:.63rem;cursor:pointer;white-space:nowrap}.mcp-editor-nav button:hover{color:var(--hm-text);background:#ffffff0a}.mcp-editor-nav button:focus-visible{color:var(--hm-text);outline:2px solid var(--hm-accent);outline-offset:1px}.mcp-editor-groups{overflow-y:auto;padding:0 var(--hm-space-6)}.mcp-form-group{padding:var(--hm-space-6) 0;border-bottom:1px solid var(--hm-border-subtle);scroll-margin-top:var(--hm-space-4)}.mcp-form-group:last-child{border-bottom:0}.mcp-form-group>header{display:grid;grid-template-columns:30px minmax(0,1fr);gap:var(--hm-space-3);margin-bottom:var(--hm-space-4)}.mcp-form-group>header>span{width:28px;height:28px;display:grid;place-items:center;color:var(--hm-accent-hover);background:var(--hm-accent-dim);border:1px solid rgba(197,139,50,.2);border-radius:50%;font-family:var(--hm-font-mono);font-size:.59rem}.mcp-form-group h3{color:var(--hm-text);font-size:.79rem;font-weight:650}.mcp-form-group header p{margin-top:.12rem;color:var(--hm-text-dim);font-size:.63rem}.mcp-form-grid{display:grid;gap:var(--hm-space-4)}.mcp-form-grid.two{grid-template-columns:repeat(2,minmax(0,1fr))}.mcp-field{display:grid;align-content:start;gap:var(--hm-space-2);color:var(--hm-text-muted);font-size:.66rem}.mcp-field>span{min-height:1rem;display:flex;align-items:baseline;gap:var(--hm-space-2)}.mcp-field>span small{color:var(--hm-text-dim);font-size:.58rem;font-weight:400}.mcp-field>span .mcp-configured-indicator{color:#81bea3}.mcp-field>small{color:var(--hm-text-dim);font-size:.59rem;line-height:1.4}.mcp-field textarea.hm-input{min-height:76px}.mcp-switch-field{display:grid;align-content:start;gap:var(--hm-space-2);color:var(--hm-text-muted);font-size:.66rem}.mcp-switch-line{min-height:38px;padding:0 var(--hm-space-4);display:flex;align-items:center;justify-content:space-between;color:var(--hm-text);background:#090c11;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.mcp-switch-field>small{color:var(--hm-text-dim);font-size:.59rem}.mcp-transport-choice{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-3);margin-bottom:var(--hm-space-4)}.mcp-transport-choice label{min-height:58px;padding:.65rem .75rem;display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:var(--hm-space-3);color:var(--hm-text-muted);background:#05080c3d;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);cursor:pointer}.mcp-transport-choice label.selected{color:var(--hm-text);background:var(--hm-accent-dim);border-color:#c58b325c}.mcp-transport-choice label>span{display:grid}.mcp-transport-choice strong{font-size:.68rem}.mcp-transport-choice small{color:var(--hm-text-dim);font-size:.58rem}.mcp-replace-field{min-height:56px;padding:.65rem .75rem;display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:var(--hm-space-3);text-align:left;color:var(--hm-text-muted);background:#05080c33;border:1px dashed var(--hm-border);border-radius:var(--hm-radius-md);cursor:pointer}.mcp-replace-field:hover{color:var(--hm-text);border-color:#c58b3252;background:var(--hm-accent-dim)}.mcp-replace-field span{display:grid;gap:.12rem}.mcp-replace-field strong{font-size:.65rem;font-weight:550}.mcp-replace-field small{color:var(--hm-text-dim);font-size:.58rem}.mcp-static-auth-note,.mcp-limit-note{padding:.65rem .75rem;display:grid;grid-template-columns:auto minmax(0,1fr);gap:var(--hm-space-3);color:var(--hm-text-dim);background:#668fd70e;border:1px solid rgba(102,143,215,.14);border-radius:var(--hm-radius-md);font-size:.62rem;line-height:1.5}.mcp-limit-note{margin-top:var(--hm-space-4);color:#a9956e;background:#c58b320b;border-color:#c58b3221}.mcp-secret-columns{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-4)}.mcp-secret-editor{min-width:0;padding:var(--hm-space-4);background:#05080c38;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-lg)}.mcp-secret-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-3);margin-bottom:var(--hm-space-3)}.mcp-secret-heading>div{display:grid}.mcp-secret-heading strong{color:var(--hm-text-muted);font-size:.66rem}.mcp-secret-heading small{color:var(--hm-text-dim);font-size:.56rem}.mcp-configured-secrets{display:grid;gap:var(--hm-space-2);margin-bottom:var(--hm-space-3)}.mcp-configured-secrets>div{min-height:32px;padding:.35rem .5rem;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--hm-space-3);background:#ffffff05;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-sm)}.mcp-configured-secrets code{color:#bcc5d1;font-size:.61rem;overflow-wrap:anywhere}.mcp-configured-secrets span{color:#81bea3;font-size:.56rem}.mcp-configured-secrets button{color:#d39494;background:transparent;border:0;cursor:pointer;font-size:.57rem}.mcp-configured-secrets button.undo{color:var(--hm-accent-hover)}.mcp-secret-row{display:grid;grid-template-columns:minmax(100px,.85fr) minmax(120px,1.15fr) auto;align-items:center;gap:var(--hm-space-2);margin-top:var(--hm-space-2)}.mcp-secret-row .hm-input{min-width:0;padding:.55rem .65rem;font-size:.65rem}.mcp-secret-empty{padding:.65rem 0;color:var(--hm-text-dim);font-size:.59rem}.mcp-editor-footer{align-items:center;padding:var(--hm-space-4) var(--hm-space-6);background:#07090de0;border-top:1px solid var(--hm-border)}.mcp-editor-footer>span{color:var(--hm-text-dim);font-size:.6rem}.mcp-editor-footer>div{display:flex;gap:var(--hm-space-2)}.cfgc-mcp-owner{margin:var(--hm-space-4);padding:var(--hm-space-4);display:grid;grid-template-columns:36px minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-4);background:linear-gradient(135deg,#c58b320e,#04070b38);border:1px solid rgba(197,139,50,.18);border-radius:var(--hm-radius-lg)}.cfgc-mcp-owner-icon{width:36px;height:36px;display:grid;place-items:center;color:var(--hm-accent-hover);background:var(--hm-accent-dim);border-radius:9px}.cfgc-mcp-owner strong{color:var(--hm-text);font-size:.7rem}.cfgc-mcp-owner p{margin-top:.15rem;color:var(--hm-text-dim);font-size:.62rem;line-height:1.45}@media(max-width:900px){.mcp-page-header,.mcp-server-header{align-items:stretch;flex-direction:column}.mcp-header-actions,.mcp-server-actions{justify-content:flex-start}.mcp-server-actions .btn{flex:1 1 150px;justify-content:center}.mcp-secret-columns{grid-template-columns:minmax(0,1fr)}}@media(max-width:640px){.mcp-page{gap:var(--hm-space-4)}.mcp-header-actions{display:grid;grid-template-columns:1fr 1fr}.mcp-header-actions .btn{justify-content:center}.mcp-control-card{grid-template-columns:minmax(0,1fr) auto;padding:var(--hm-space-4)}.mcp-publication-limits{grid-template-columns:minmax(0,1fr)}.mcp-control-icon{display:none}.mcp-aggregate{grid-template-columns:repeat(2,minmax(0,1fr))}.mcp-aggregate>div:nth-child(2){border-right:0}.mcp-aggregate>div:nth-child(-n+2){border-bottom:1px solid var(--hm-border-subtle)}.mcp-server-header,.mcp-server-body{padding:var(--hm-space-4)}.mcp-server-actions{display:grid;grid-template-columns:1fr 1fr auto auto}.mcp-server-actions .btn{min-width:0;padding-inline:var(--hm-space-3)}.mcp-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.mcp-tools-toggle{align-items:flex-start}.mcp-tools-toggle>span:last-child{text-align:right}.mcp-tool-row{grid-template-columns:minmax(0,1fr)}.mcp-tool-state{justify-self:start}.mcp-modal-overlay{padding:0;align-items:flex-end}.modal-content.mcp-editor{width:100%;max-width:none;max-height:96vh;border-radius:var(--hm-radius-xl) var(--hm-radius-xl) 0 0}.mcp-editor-header,.mcp-editor-footer,.mcp-editor-nav,.mcp-editor-groups{padding-inline:var(--hm-space-4)}.mcp-form-grid.two,.mcp-transport-choice{grid-template-columns:minmax(0,1fr)}.mcp-editor-footer{align-items:stretch;flex-direction:column}.mcp-editor-footer>div{display:grid;grid-template-columns:1fr 1fr}.cfgc-mcp-owner{grid-template-columns:34px minmax(0,1fr)}.cfgc-mcp-owner .btn{grid-column:1 / -1;justify-content:center}}.ts-count-row{display:flex;flex-wrap:wrap;gap:.75rem}.ts-count{display:flex;flex-direction:column;gap:.1rem;padding:.4rem .7rem;border:1px solid var(--hm-border);border-radius:6px;min-width:90px}.ts-count-value{font-size:var(--hm-text-lg);font-weight:600;font-variant-numeric:tabular-nums}.ts-count-label{font-size:var(--hm-text-xs);color:var(--hm-text-muted)}.ts-count-alert{border-color:#d9770673}.ts-count-alert .ts-count-value{color:var(--hm-warning)}.ts-turn-row{border:1px solid var(--hm-border);border-radius:6px;padding:.5rem .7rem}.ts-turn-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem}.ts-turn-age{margin-left:auto}.ts-turn-warning{color:var(--hm-warning);font-size:var(--hm-text-xs);margin-top:.35rem}.ts-op-list{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.ts-op{font-size:var(--hm-text-xs);color:var(--hm-text-muted);border:1px solid var(--hm-border);border-radius:4px;padding:.1rem .4rem;overflow-wrap:anywhere}.ts-op-alert{color:var(--hm-warning);border-color:#d9770673}.sched-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-3);align-content:start}@media(min-width:768px){.sched-detail-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.sched-detail-grid>div{display:grid;align-content:start;gap:.3rem;min-width:0}.sched-detail-label{color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1}.audit-verify-ok{border-color:#22c55e59}.audit-verify-valid{color:#4ade80}:root{--hm-topbar-h: 76px;--hm-rail-h: 107px;--hm-section-tabs-inner-h: 48px;--hm-section-tabs-border: 1px;--hm-section-tabs-h: calc(var(--hm-section-tabs-inner-h) + var(--hm-section-tabs-border));--hm-bg: #07090d;--hm-bg-raised: #0b0e14;--hm-surface: #10141c;--hm-surface-hover: #171d28;--hm-surface-elevated: #151a24;--hm-border: #252c39;--hm-border-subtle: #1a202b;--hm-accent: #c58b32;--hm-accent-hover: #dda84f;--hm-accent-dim: rgba(197, 139, 50, .13);--hm-accent-glow: rgba(197, 139, 50, .16);--hm-gold: #e0b766;--hm-gold-dim: rgba(197, 139, 50, .08);--hm-text: #edf0f5;--hm-text-muted: #9aa4b3;--hm-text-dim: #667182;--hm-success: #3db983;--hm-warning: #d8a342;--hm-danger: #e06464;--hm-info: #668fd7;--hm-radius-sm: 5px;--hm-radius-md: 7px;--hm-radius-lg: 10px;--hm-radius-xl: 14px;--hm-shadow-sm: 0 1px 2px rgba(0,0,0,.24), 0 0 0 1px rgba(255,255,255,.015);--hm-shadow-md: 0 10px 30px rgba(0,0,0,.28);--hm-shadow-lg: 0 24px 64px rgba(0,0,0,.48);--shell-sidebar: 252px;--shell-sidebar-collapsed: 72px}html{background:var(--hm-bg)}body{color:var(--hm-text);background:radial-gradient(circle at 72% -20%,rgba(197,139,50,.055),transparent 34rem),linear-gradient(180deg,#080a0f 0%,var(--hm-bg) 35%)}button,input,select,textarea{font:inherit}::-moz-selection{background:#c58b3252;color:#fff}::selection{background:#c58b3252;color:#fff}.odin-icon{display:block;flex:none}.app-loading{min-height:100vh;display:grid;place-items:center}.brand-loader{color:var(--hm-accent-hover);animation:brand-pulse 1.4s ease-in-out infinite}@keyframes brand-pulse{50%{opacity:.45;transform:scale(.94)}}.app-shell{display:flex;min-height:100vh}.hm-sidebar{position:relative;width:var(--shell-sidebar);min-width:var(--shell-sidebar);height:100vh;min-height:0;background:#0d1017f5;border-right:1px solid var(--hm-border-subtle);box-shadow:10px 0 35px #0000001f;transition:width .22s ease,min-width .22s ease,transform .22s ease;display:flex;flex-direction:column;z-index:40}.hm-sidebar:before{content:"";position:absolute;inset:0 auto 0 0;width:2px;background:linear-gradient(180deg,transparent 3%,var(--hm-accent) 24%,rgba(197,139,50,.15) 68%,transparent);opacity:.65;pointer-events:none}.hm-sidebar.collapsed{width:var(--shell-sidebar-collapsed);min-width:var(--shell-sidebar-collapsed)}.sidebar-brand{min-height:76px;padding:0 16px 0 18px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--hm-border-subtle)}.brand-mark{width:34px;height:34px;display:grid;place-items:center;color:var(--hm-accent-hover);background:linear-gradient(145deg,#c58b3229,#c58b3209);border:1px solid rgba(197,139,50,.3);border-radius:9px;flex:none}.sidebar-brand-copy{display:flex;flex-direction:column;min-width:0;line-height:1}.brand-wordmark{font-size:.83rem;letter-spacing:.23em;font-weight:700;color:#f2dfb8}.brand-caption{margin-top:7px;font-size:.61rem;letter-spacing:.1em;text-transform:uppercase;color:var(--hm-text-dim)}.sidebar-toggle-btn{margin-left:auto}.hm-sidebar.collapsed .sidebar-brand{padding-inline:18px;justify-content:center}.hm-sidebar.collapsed .sidebar-brand-copy,.hm-sidebar.collapsed .nav-label,.hm-sidebar.collapsed .nav-section-label,.hm-sidebar.collapsed .connection-copy,.hm-sidebar.collapsed .shortcut-hint span,.hm-sidebar.collapsed .shortcut-hint kbd,.hm-sidebar.collapsed .brand-mark{display:none}.hm-sidebar.collapsed .sidebar-toggle-btn{margin-left:0}.sidebar-nav{flex:1;min-height:0;padding:13px 10px;overflow:auto}.nav-group+.nav-group{margin-top:18px}.nav-section-label{padding:0 10px 6px;color:#555f6f;font-size:.625rem;line-height:1;text-transform:uppercase;letter-spacing:.14em;font-weight:700}.nav-item{position:relative;min-height:40px;display:flex;align-items:center;white-space:nowrap;overflow:hidden;margin:2px 0;padding:0 10px;gap:11px;color:#8d97a7;border-radius:7px;font-size:.79rem;font-weight:500;text-decoration:none;cursor:pointer;transition:background var(--hm-transition-base),color var(--hm-transition-base)}.nav-item:hover{background:#ffffff09;color:#d9dee7}.nav-item.active{background:linear-gradient(90deg,#c58b3224,#c58b320b);color:#ebc982;box-shadow:inset 2px 0 0 var(--hm-accent)}.nav-item.active:after{content:"";position:absolute;right:10px;width:4px;height:4px;background:var(--hm-accent-hover);border-radius:50%;box-shadow:0 0 8px var(--hm-accent)}.nav-icon{width:20px;display:grid;flex:none;place-items:center;color:#717d8f}.nav-item:hover .nav-icon{color:#aeb7c4}.nav-item.active .nav-icon{color:var(--hm-accent-hover)}.hm-sidebar.collapsed .sidebar-nav{padding-inline:12px}.hm-sidebar.collapsed .nav-item{padding:0;justify-content:center}.hm-sidebar.collapsed .nav-item.active:after{right:5px}.sidebar-footer{min-height:var(--hm-rail-h);padding:12px;border-top:1px solid var(--hm-border-subtle);display:grid;gap:8px;align-content:center}.connection-card{min-height:42px;padding:8px 10px;display:flex;align-items:center;gap:9px;border:1px solid var(--hm-border-subtle);background:#00000024;border-radius:8px}.connection-copy{min-width:0;display:flex;flex:1;justify-content:space-between;align-items:baseline;gap:8px}.connection-label{font-size:.7rem;color:var(--hm-text-muted)}.connection-latency{font-family:var(--hm-font-mono);font-size:.58rem;color:var(--hm-text-dim)}.shortcut-hint{width:100%;min-height:32px;display:flex;align-items:center;gap:7px;padding:0 9px;border:0;border-radius:6px;background:transparent;color:var(--hm-text-dim);font-size:.67rem;cursor:pointer}.shortcut-hint:hover{color:var(--hm-text-muted);background:#ffffff08}.shortcut-hint kbd{margin-left:auto}.hm-sidebar.collapsed .connection-card,.hm-sidebar.collapsed .shortcut-hint{justify-content:center;padding-inline:0}.hm-main{flex:1;min-width:0;height:100vh;max-height:none;overflow-y:auto;background:transparent}.hm-topbar{position:sticky;top:0;display:flex;align-items:center;z-index:25;height:var(--hm-topbar-h);padding:0 28px;gap:22px;background:#07090de0;border-bottom:1px solid var(--hm-border-subtle);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-context{display:flex;flex-direction:column;min-width:140px}.topbar-kicker{margin-bottom:2px;color:var(--hm-accent);font-size:.57rem;line-height:1;text-transform:uppercase;letter-spacing:.16em;font-weight:700}.topbar-title-row{display:flex;align-items:center;gap:10px}.topbar-title-row h1{margin:0;color:#f3f4f7;font-size:1rem;line-height:1.25;font-weight:650;letter-spacing:-.018em}.topbar-description{margin:0;padding-left:22px;border-left:1px solid var(--hm-border);color:var(--hm-text-dim);font-size:.72rem}.topbar-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.uptime-label{margin-right:5px;color:var(--hm-text-dim);font-family:var(--hm-font-mono);font-size:.62rem}.status-pill{display:inline-flex;align-items:center;gap:5px;color:var(--hm-text-muted);font-size:.58rem;text-transform:capitalize;font-weight:600}.status-pill .status-dot{width:6px;height:6px;box-shadow:none}.command-trigger{min-height:34px;padding:0 8px 0 10px;display:flex;align-items:center;gap:7px;color:var(--hm-text-muted);background:#ffffff06;border:1px solid var(--hm-border);border-radius:7px;font-size:.68rem;cursor:pointer}.command-trigger:hover{color:var(--hm-text);border-color:#3a4352;background:#ffffff0b}kbd{padding:2px 5px;color:#818b99;background:#090c11;border:1px solid #2b3340;border-radius:4px;font-family:var(--hm-font-sans);font-size:.57rem;box-shadow:inset 0 -1px #ffffff09}.page-viewport{min-height:calc(100vh - var(--hm-topbar-h))}.icon-btn.mobile-menu-btn,.mobile-scrim{display:none}.icon-btn{width:34px;height:34px;padding:0;display:inline-grid;place-items:center;flex:none;color:var(--hm-text-muted);background:transparent;border:1px solid transparent;border-radius:7px;cursor:pointer;transition:color .15s ease,border-color .15s ease,background .15s ease}.icon-btn:hover{color:var(--hm-text);background:#ffffff0b;border-color:var(--hm-border)}.icon-btn-danger{color:#d67a7a}.icon-btn-danger:hover{color:#f08a8a;background:#e064641a;border-color:#e064643d}.section-shell{min-height:calc(100vh - var(--hm-topbar-h))}.section-tabs-wrap{position:sticky;top:var(--hm-topbar-h);z-index:20;padding:0 24px;background:#090c11eb;border-bottom:var(--hm-section-tabs-border) solid var(--hm-border-subtle);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}.section-tabs{display:flex;align-items:stretch;gap:2px;min-height:var(--hm-section-tabs-inner-h);overflow-x:auto}.section-tab{position:relative;padding:0 13px;white-space:nowrap;color:#7f8998;background:transparent;border:0;font-size:.72rem;font-weight:550;cursor:pointer}.section-tab:hover{color:#c8ced7}.section-tab.active{color:#e9ca8e}.section-tab.active:after{content:"";position:absolute;left:13px;right:13px;bottom:0;height:2px;background:var(--hm-accent);border-radius:2px 2px 0 0;box-shadow:0 -3px 12px #c58b3233}.section-panel>*{min-height:100%}.hm-card{background:linear-gradient(150deg,#121720f5,#0e1219fa);border-color:var(--hm-border);box-shadow:var(--hm-shadow-sm)}.hm-card:hover{border-color:#303847}.hm-table th{background:#07090d4d;color:#7e8898;font-size:.64rem;letter-spacing:.075em;font-weight:650}.hm-table td{border-bottom-color:var(--hm-border-subtle)}.hm-table tr:hover td{background:#ffffff05}.btn{min-height:34px;padding:7px 12px;border:1px solid transparent;font-weight:600}.btn-primary{color:#171006;background:linear-gradient(180deg,#d4a14e,#b77b29);border-color:#dcaa57;box-shadow:inset 0 1px #ffffff21,0 5px 14px #83521324}.btn-primary:hover{background:linear-gradient(180deg,#e2b35f,#c88a31);box-shadow:0 7px 18px #83521333}.btn-ghost{border-color:var(--hm-border);background:#ffffff04}.btn-ghost:hover{border-color:#394251;background:#ffffff0a}.hm-input{min-height:38px;background:#090c11;border-color:#2a3240}.hm-input:hover{border-color:#36404f}.hm-input:focus{border-color:var(--hm-accent);box-shadow:0 0 0 3px #c58b321a}.login-shell{min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 50% 20%,rgba(197,139,50,.09),transparent 30rem)}.login-panel{width:100%;max-width:380px;padding:34px;background:linear-gradient(150deg,#131821f7,#0b0e14fc);border:1px solid var(--hm-border);border-radius:14px;box-shadow:var(--hm-shadow-lg)}.login-brand{width:48px;height:48px;margin-bottom:26px;display:grid;place-items:center;color:var(--hm-accent-hover);background:var(--hm-accent-dim);border:1px solid rgba(197,139,50,.3);border-radius:11px}.login-eyebrow{margin:0 0 7px;color:var(--hm-accent);font-size:.63rem;font-weight:700;letter-spacing:.15em;text-transform:uppercase}.login-title{margin:0;color:#f2f3f6;font-size:1.65rem;line-height:1.2;font-weight:650;letter-spacing:-.035em}.login-subtitle{margin:7px 0 25px;color:var(--hm-text-muted);font-size:.8rem}.modal-overlay{background:#030508c2;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.modal-content,.palette{background:linear-gradient(155deg,#171c25,#0f131a);border-color:#303847;box-shadow:var(--hm-shadow-lg)}.confirm-heading{display:flex;gap:13px;margin-bottom:22px}.confirm-heading h3{margin:2px 0 6px;font-size:.92rem;font-weight:650}.confirm-heading p{margin:0;color:var(--hm-text-muted);font-size:.76rem;line-height:1.55}.confirm-icon{width:38px;height:38px;display:grid;place-items:center;flex:none;color:var(--hm-info);background:#668fd71a;border:1px solid rgba(102,143,215,.22);border-radius:9px}.confirm-icon.danger{color:var(--hm-danger);background:#e064641a;border-color:#e0646438}.toast-item{min-width:260px;padding:11px 13px;background:#141923;border-color:#2b3442;border-left-width:3px;color:var(--hm-text)}.toast-success{border-left-color:var(--hm-success)}.toast-error{border-left-color:var(--hm-danger)}.toast-info{border-left-color:var(--hm-info)}.toast-success,.toast-error,.toast-info{background:#141923;color:var(--hm-text)}.toast-success .toast-icon{color:var(--hm-success)}.toast-error .toast-icon{color:var(--hm-danger)}.toast-info .toast-icon{color:var(--hm-info)}.palette-overlay{padding-top:12vh}.palette{max-width:570px}.palette-search{display:flex;align-items:center;gap:10px;padding-left:17px;color:var(--hm-text-dim);border-bottom:1px solid var(--hm-border)}.palette-input{border:0;padding:17px 17px 17px 0;font-size:.82rem}.palette-results{max-height:390px;padding:8px}.palette-item{min-height:48px;gap:11px;padding:6px 10px}.palette-item.selected{background:linear-gradient(90deg,#c58b321f,#c58b3209)}.palette-icon{width:30px;height:30px;display:grid;place-items:center;color:#8994a4;background:#ffffff09;border:1px solid var(--hm-border-subtle);border-radius:7px}.palette-item.selected .palette-icon{color:var(--hm-accent-hover);border-color:#c58b3240}.palette-copy{display:flex;flex-direction:column;gap:2px}.palette-label{color:#dfe3e9;font-size:.76rem;font-weight:550}.palette-group{color:var(--hm-text-dim);font-size:.61rem}.palette-arrow{margin-left:auto;color:#4e5867}.palette-footer{justify-content:flex-end;gap:16px;padding:10px 14px}.palette-footer span{display:flex;align-items:center;gap:5px}.error-icon,.empty-state-icon{display:grid;place-items:center;color:var(--hm-text-dim)}.empty-state{min-height:220px;border-style:dashed}.empty-state-icon{width:44px;height:44px;font-size:initial;background:#ffffff06;border:1px solid var(--hm-border-subtle);border-radius:10px}@media(max-width:900px){.hm-sidebar{position:fixed;left:0;top:0;transform:translate(-102%);width:min(280px,86vw);min-width:min(280px,86vw);box-shadow:20px 0 60px #00000080}.hm-sidebar.mobile-open{transform:translate(0)}.hm-sidebar.collapsed{width:min(280px,86vw);min-width:min(280px,86vw)}.hm-sidebar.collapsed .sidebar-brand-copy,.hm-sidebar.collapsed .nav-label,.hm-sidebar.collapsed .nav-section-label,.hm-sidebar.collapsed .connection-copy,.hm-sidebar.collapsed .shortcut-hint span,.hm-sidebar.collapsed .shortcut-hint kbd{display:flex}.hm-sidebar.collapsed .sidebar-brand{justify-content:flex-start}.hm-sidebar.collapsed .brand-mark{display:grid}.hm-sidebar.collapsed .nav-item{padding:0 10px;justify-content:flex-start}.mobile-scrim{display:block;position:fixed;top:0;right:0;bottom:0;left:0;z-index:35;background:#0000009e;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.icon-btn.mobile-menu-btn{display:inline-grid}.hm-sidebar .sidebar-toggle-btn{display:none}.hm-topbar{padding:0 16px;gap:12px}.topbar-description,.uptime-label,.command-trigger span{display:none}.command-trigger{width:34px;padding:0;justify-content:center}.command-trigger kbd{display:none}}@media(max-width:640px){:root{--hm-topbar-h: 64px}.section-tabs-wrap{padding:0 12px}.topbar-kicker{display:none}.topbar-title-row h1{font-size:.9rem}.status-pill{display:none}.section-tab{padding-inline:10px}.section-panel .p-6{padding:1rem}.login-panel{padding:26px}.toast-stack{left:12px;right:12px;bottom:12px}.toast-item{width:100%;max-width:none}}@media(prefers-reduced-motion:reduce){.brand-loader{animation:none}}.page-viewport .page-fade-in{width:100%;max-width:1600px;margin-inline:0}.page-viewport .p-6{padding:clamp(1rem,2vw,1.75rem)}.page-viewport h1.text-xl{color:#f2f3f6;font-size:1.12rem;line-height:1.3;letter-spacing:-.025em;font-weight:650}.page-viewport h2,.page-viewport h3{letter-spacing:-.012em}.page-lede{margin-top:4px;max-width:64ch;color:var(--hm-text-dim);font-size:.72rem;line-height:1.55}.page-header{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:16px}.page-header-copy{min-width:0}.page-header-actions,.action-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.section-card-header{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:14px}.section-eyebrow{color:var(--hm-text-dim);font-size:.62rem;font-weight:650;letter-spacing:.09em;text-transform:uppercase}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.detail-grid>div{min-width:0;padding:10px 12px;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:#00000021}.detail-grid dt,.field-label{color:var(--hm-text-dim);font-size:.65rem;font-weight:600;letter-spacing:.035em}.detail-grid dd{margin-top:3px;color:#d7dce4;font-size:.77rem}.detail-stack{display:grid;gap:7px}.text-break{overflow-wrap:anywhere}.btn-touch{min-height:44px}.form-panel{position:relative;overflow:hidden;border-color:#c58b3238;background:linear-gradient(145deg,#c58b320b,#0d1118fa 35%)}.form-panel:before{content:"";position:absolute;inset:0 auto 0 0;width:2px;background:linear-gradient(180deg,var(--hm-accent),transparent 75%)}.provider-choice-list{display:grid;gap:8px}.provider-choice{padding:11px 12px;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:#0000001f}.provider-choice:has(input:checked){border-color:#c58b324d;background:var(--hm-accent-dim)}.provider-choice-label{display:flex;align-items:center;gap:9px;cursor:pointer}.provider-control{accent-color:var(--hm-accent)}.computer-page>.page-header{position:sticky;top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h));z-index:10;padding-block:12px;background:var(--hm-bg-raised);border-bottom:1px solid var(--hm-border-subtle)}.computer-status-card{display:flex;align-items:center;justify-content:space-between;gap:12px;border-left:2px solid var(--hm-accent)}.computer-state{margin-top:2px;color:#f0d49f;font-size:1rem;font-weight:650;text-transform:capitalize}.computer-provisioning{min-width:0}.computer-provisioning-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px;min-width:0;padding:0;border:0}.computer-provisioning-field{display:flex;flex-direction:column;align-items:flex-start;gap:8px;min-width:0;overflow-wrap:anywhere}.computer-provisioning-field .hm-input,.computer-provisioning-field .hm-select{width:100%;min-width:0}.computer-provisioning-field textarea{resize:vertical}.computer-provisioning-review dd{overflow-wrap:anywhere;white-space:pre-wrap}.profile-list{display:grid;gap:8px}.profile-list-item{padding:10px 12px;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:#00000021;color:#cdd3dc;font-size:.78rem}.evidence-figure{padding:12px;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:#080a0e}.evidence-frame{display:block;max-width:100%;max-height:65vh;margin-inline:auto;-o-object-fit:contain;object-fit:contain}.export-controls{display:flex;align-items:stretch;flex-wrap:wrap;gap:8px}.export-name{width:min(100%,30rem);min-height:44px}.artifact-row{display:flex;align-items:center;flex-wrap:wrap;gap:10px}@media(max-width:640px){.page-header-actions,.section-card-header .action-row,.export-controls{width:100%}.page-header-actions .btn,.section-card-header .action-row .btn,.export-controls .btn,.export-name{flex:1 1 100%;justify-content:center}.detail-grid{grid-template-columns:minmax(0,1fr)}}.hm-card{padding:1rem;border-radius:var(--hm-radius-lg)}.hm-card>h2:first-child,.hm-card>h3:first-child{color:#e5e8ee}.table-responsive{border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-lg);background:#06080c3d}.hm-table{width:100%;border-collapse:collapse}.hm-table th{height:38px;padding:0 12px;white-space:nowrap}.hm-table td{padding:10px 12px;vertical-align:middle}.hm-table tbody tr:last-child td{border-bottom:0}.hm-select,select.hm-input{min-height:38px;background-color:#090c11;border-color:#2a3240}textarea.hm-input{min-height:90px;line-height:1.55}label{accent-color:var(--hm-accent)}input[type=checkbox]{width:15px;height:15px;accent-color:var(--hm-accent);border-radius:4px;cursor:pointer}input[type=checkbox]:focus-visible{outline:2px solid var(--hm-accent);outline-offset:3px}input[type=checkbox]:disabled{cursor:not-allowed;opacity:.45}.btn-danger{color:#f6b2b2;background:#e0646414;border-color:#e0646452}.btn-danger:hover{color:#ffd0d0;background:#e0646429;border-color:#e0646480}.btn:focus-visible,.icon-btn:focus-visible,.nav-item:focus-visible,.section-tab:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.modal-content{width:min(92vw,520px);padding:22px;border-radius:var(--hm-radius-xl)}.modal-content h2,.modal-content h3{color:#f0f2f5}.modal-content .btn{min-width:76px}.error-state{min-height:180px;border-color:#e0646442!important;background:linear-gradient(150deg,#34141852,#0e1219fa)}.error-icon{width:42px;height:42px;border-radius:10px;background:#e0646417;border:1px solid rgba(224,100,100,.2)}.sess-preset-icon,.tl-group-icon,.cfg-group-icon,.chat-tool-icon{display:inline-grid;place-items:center;flex:none}.sk-action-btn{display:inline-grid;place-items:center}.sk-card-icon{display:inline-grid;color:var(--hm-accent-hover)}.health-card-icon{display:inline-grid}.provider-status{display:inline-flex;align-items:center;gap:6px}.provider-status .status-dot{width:7px;height:7px;box-shadow:none}.row-expander{width:28px;height:28px;display:inline-grid;place-items:center;border:0;border-radius:6px;color:var(--hm-text-dim);background:transparent;cursor:pointer}.row-expander:hover{color:var(--hm-text);background:#ffffff0b}.row-expander:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.tool-expand-icon .odin-icon,.chat-tools-toggle-icon .odin-icon,.cfg-group-arrow .odin-icon,.sess-expand-icon .odin-icon,.mem-tree-arrow .odin-icon,.kb-tree-arrow .odin-icon{transition:transform var(--hm-transition-base)}.chat-markdown .chat-code-copy{opacity:0}.chat-markdown pre:hover .chat-code-copy,.chat-markdown .chat-code-copy:focus-visible{opacity:1}.fts-highlight,.knowledge-highlight{background:#e0b7663d;color:#fff0c8;border-radius:2px;padding-inline:1px}@media(max-width:640px){.page-viewport .p-6,.hm-card{padding:.85rem}.hm-table th,.hm-table td{padding-inline:9px}.modal-content{padding:18px}} diff --git a/ui/dist/assets/index-Culw9Uo0.css b/ui/dist/assets/index-Culw9Uo0.css deleted file mode 100644 index c51801235..000000000 --- a/ui/dist/assets/index-Culw9Uo0.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:Fira Code;font-style:normal;font-weight:400;font-display:swap;src:url(/ui/assets/fira-code-400-CHoedHDv.woff2) format("woff2")}@font-face{font-family:Fira Code;font-style:normal;font-weight:500;font-display:swap;src:url(/ui/assets/fira-code-400-CHoedHDv.woff2) format("woff2")}@font-face{font-family:Fira Code;font-style:normal;font-weight:600;font-display:swap;src:url(/ui/assets/fira-code-400-CHoedHDv.woff2) format("woff2")}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(/ui/assets/inter-400-Dx4kXJAl.woff2) format("woff2")}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(/ui/assets/inter-400-Dx4kXJAl.woff2) format("woff2")}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(/ui/assets/inter-400-Dx4kXJAl.woff2) format("woff2")}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(/ui/assets/inter-400-Dx4kXJAl.woff2) format("woff2")}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.m-2{margin:.5rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-2{height:.5rem}.h-28{height:7rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.max-h-24{max-height:6rem}.max-h-32{max-height:8rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.min-h-screen{min-height:100vh}.w-2{width:.5rem}.w-32{width:8rem}.w-5{width:1.25rem}.w-72{width:18rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[100px\]{min-width:100px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-full{max-width:100%}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.self-end{align-self:flex-end}.self-center{align-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-amber-700\/50{border-color:#b4530980}.border-amber-800{--tw-border-opacity: 1;border-color:rgb(146 64 14 / var(--tw-border-opacity, 1))}.border-amber-900{--tw-border-opacity: 1;border-color:rgb(120 53 15 / var(--tw-border-opacity, 1))}.border-blue-500\/30{border-color:#3b82f64d}.border-gray-600{--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.border-gray-700\/50{border-color:#37415180}.border-gray-800{--tw-border-opacity: 1;border-color:rgb(31 41 55 / var(--tw-border-opacity, 1))}.border-gray-800\/50{border-color:#1f293780}.border-green-500\/30{border-color:#22c55e4d}.border-green-500\/40{border-color:#22c55e66}.border-green-800{--tw-border-opacity: 1;border-color:rgb(22 101 52 / var(--tw-border-opacity, 1))}.border-indigo-900\/30{border-color:#312e814d}.border-red-500\/30{border-color:#ef44444d}.border-red-500\/40{border-color:#ef444466}.border-red-700{--tw-border-opacity: 1;border-color:rgb(185 28 28 / var(--tw-border-opacity, 1))}.border-red-800{--tw-border-opacity: 1;border-color:rgb(153 27 27 / var(--tw-border-opacity, 1))}.border-red-900{--tw-border-opacity: 1;border-color:rgb(127 29 29 / var(--tw-border-opacity, 1))}.border-red-900\/50{border-color:#7f1d1d80}.border-slate-700{--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-yellow-800{--tw-border-opacity: 1;border-color:rgb(133 77 14 / var(--tw-border-opacity, 1))}.border-yellow-900{--tw-border-opacity: 1;border-color:rgb(113 63 18 / var(--tw-border-opacity, 1))}.bg-amber-700\/70{background-color:#b45309b3}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-500\/60{background-color:#3b82f699}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-blue-900{--tw-bg-opacity: 1;background-color:rgb(30 58 138 / var(--tw-bg-opacity, 1))}.bg-blue-900\/40{background-color:#1e3a8a66}.bg-blue-900\/50{background-color:#1e3a8a80}.bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-gray-800\/50{background-color:#1f293780}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-gray-900\/30{background-color:#1118274d}.bg-gray-900\/50{background-color:#11182780}.bg-gray-950{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity, 1))}.bg-green-900{--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.bg-green-900\/30{background-color:#14532d4d}.bg-green-950\/30{background-color:#052e164d}.bg-indigo-500{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.bg-indigo-900{--tw-bg-opacity: 1;background-color:rgb(49 46 129 / var(--tw-bg-opacity, 1))}.bg-indigo-950\/30{background-color:#1e1b4b4d}.bg-red-900\/20{background-color:#7f1d1d33}.bg-red-900\/30{background-color:#7f1d1d4d}.bg-red-900\/50{background-color:#7f1d1d80}.bg-red-950\/30{background-color:#450a0a4d}.bg-yellow-900\/20{background-color:#713f1233}.\!p-0{padding:0!important}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.pl-5{padding-left:1.25rem}.pr-1{padding-right:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.italic{font-style:italic}.leading-relaxed{line-height:1.625}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-300{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-cyan-400{--tw-text-opacity: 1;color:rgb(34 211 238 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-indigo-300{--tw-text-opacity: 1;color:rgb(165 180 252 / var(--tw-text-opacity, 1))}.text-indigo-400{--tw-text-opacity: 1;color:rgb(129 140 248 / var(--tw-text-opacity, 1))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-teal-400{--tw-text-opacity: 1;color:rgb(45 212 191 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-200{--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.text-yellow-300{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.underline{text-decoration-line:underline}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.last\:mb-0:last-child{margin-bottom:0}.last\:border-0:last-child{border-width:0px}.hover\:border-gray-600:hover{--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.hover\:bg-gray-700:hover{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.hover\:text-blue-300:hover{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.hover\:text-gray-300:hover{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.hover\:text-green-300:hover{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.hover\:text-indigo-300:hover{--tw-text-opacity: 1;color:rgb(165 180 252 / var(--tw-text-opacity, 1))}.hover\:text-red-300:hover{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.hover\:text-yellow-300:hover{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}@media(min-width:360px){.min-\[360px\]\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:768px){.md\:col-span-2{grid-column:span 2 / span 2}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}.output-renderer{min-width:0;max-width:100%;border:1px solid var(--hm-border);border-radius:var(--hm-radius-sm);background:var(--hm-bg-raised);color:var(--hm-text);font-size:.75rem;overflow:hidden;text-align:left}.output-summary,.output-controls{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem .65rem;padding:.35rem .5rem}.output-summary{color:var(--hm-text-muted);overflow-wrap:anywhere;white-space:normal;line-height:1.5}.output-summary strong{color:var(--hm-text)}.output-summary span+span:before{content:"·";margin-right:.65rem;color:var(--hm-text-dim)}.output-controls{border-bottom:1px solid var(--hm-border-subtle)}.output-controls .btn,.output-expand{font-size:.75rem;padding:.25rem .5rem;min-height:28px}.output-controls [aria-pressed=true]{color:var(--hm-accent-hover);background:var(--hm-accent-dim)}.output-section-label,.output-fold-note{display:block;padding:.35rem .5rem;color:var(--hm-text-muted);white-space:normal;overflow-wrap:anywhere}.output-section+.output-section{border-top:1px dashed var(--hm-border)}.output-body{margin:0;padding:.5rem;max-width:100%;overflow:auto;white-space:pre;font-family:var(--hm-font-mono);font-size:.75rem;line-height:1.5}.output-body.output-wrapped{white-space:pre-wrap;overflow-wrap:anywhere;word-break:normal}.output-expand{margin:.35rem .5rem}.log-compact-line{border-bottom:1px solid var(--hm-border-subtle)}.output-renderer.output-compact{border:0;border-radius:0;background:transparent;overflow:visible}.output-event-row{display:flex;align-items:center;gap:.5rem;min-width:0;line-height:1.5}.output-event-heading{display:flex;align-items:center;gap:.5rem;min-width:0;flex:0 0 auto;max-width:calc(100% - 4ch);white-space:nowrap}.output-event-heading>*{flex-shrink:0}.output-event-heading .log-ts{flex-shrink:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.output-event-heading .log-compact-action{overflow:hidden;text-overflow:ellipsis;flex-shrink:1;max-width:24ch;min-width:0;color:var(--hm-accent-hover)}.output-event-heading .log-compact-web-action{color:#a78bfa}.output-control-separator{color:var(--hm-text-dim);white-space:pre}.output-inline-summary{flex:1 1 4ch;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.output-inline-summary>span{margin-right:.5rem}.output-inline-summary .log-compact-agent,.output-inline-summary .log-compact-arguments{display:inline-block;vertical-align:bottom;overflow:hidden;text-overflow:ellipsis;max-width:24ch}.output-inline-summary .log-compact-arguments{max-width:32ch}.output-compact-outcome{color:var(--hm-text-muted)}.output-compact-warning{color:var(--hm-warning);white-space:nowrap;flex:0 1 auto;max-width:28ch;overflow:hidden;text-overflow:ellipsis;padding:0;border:0;background:transparent;cursor:pointer}.output-warning-short{display:none}.output-compact-warning-detail{color:var(--hm-warning);overflow-wrap:anywhere}.output-compact-envelope-detail{color:var(--hm-text-muted);overflow-wrap:anywhere}.output-compact-actions{display:flex;align-items:center;flex-shrink:0;gap:.4rem}.output-compact .output-controls{position:static;padding:0;gap:.4rem;flex-wrap:nowrap;border:0;background:transparent}.output-compact-actions button{border:0;border-radius:0;background:transparent;min-width:0;min-height:0;padding:0;margin:0;font:inherit;font-size:.6875rem;line-height:inherit;color:var(--hm-text-muted);cursor:pointer;text-decoration:none}.output-compact-actions button[aria-pressed=true]{color:var(--hm-info);background:transparent}.output-compact-actions button:hover,.output-compact-actions button:focus,.output-compact-warning:hover,.output-compact-warning:focus{text-decoration:underline}.output-compact-actions button:focus-visible,.output-compact-warning:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.output-compact .output-body{padding:0;line-height:18px}.output-compact-preview{margin:0 0 .25rem}.output-compact .output-compact-folded{max-height:72px;overflow:hidden}.output-compact-detail{padding:.25rem 0}.log-compact-attribution{display:flex;flex-wrap:wrap;gap:.25rem .75rem;overflow-wrap:anywhere}.log-compact-detail-arguments{margin-top:.25rem}.output-copy-status{color:var(--hm-text-muted);overflow-wrap:anywhere}@media(max-width:600px){.output-warning-full{display:none}.output-warning-short{display:inline}.output-event-row,.output-event-heading{gap:.3rem}.output-event-heading{font-size:.6875rem}.output-event-heading .log-ts{max-width:8ch;overflow:hidden}.output-event-heading .log-compact-action,.output-inline-summary .log-compact-arguments,.output-inline-summary .log-compact-agent{max-width:10ch}}@media(max-width:360px){.output-event-row,.output-event-heading{gap:.25rem}.output-event-heading,.output-compact-actions button{font-size:.625rem}.output-event-heading .log-ts,.output-event-heading .log-compact-action{max-width:6ch}.output-compact-actions,.output-compact .output-controls{gap:.25rem}}html{-webkit-text-size-adjust:100%}*{-webkit-tap-highlight-color:rgba(217,119,6,.15)}:root{--hm-font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--hm-font-mono: "Fira Code", "Cascadia Code", "JetBrains Mono", ui-monospace, monospace;--hm-text-xs: .6875rem;--hm-text-sm: .75rem;--hm-text-base: .8125rem;--hm-text-md: .875rem;--hm-text-lg: 1rem;--hm-text-xl: 1.25rem;--hm-text-2xl: 1.5rem;--hm-leading-tight: 1.25;--hm-leading-normal: 1.5;--hm-leading-relaxed: 1.65;--hm-space-1: .25rem;--hm-space-2: .375rem;--hm-space-3: .5rem;--hm-space-4: .75rem;--hm-space-5: 1rem;--hm-space-6: 1.5rem;--hm-space-8: 2rem;--hm-radius-full: 9999px;--hm-shadow-glow: 0 0 20px rgba(197, 139, 50, .15), 0 0 4px rgba(197, 139, 50, .1);--hm-shadow-glow-sm: 0 0 8px rgba(197, 139, 50, .1);--hm-transition-fast: .1s ease;--hm-transition-base: .15s ease;--hm-transition-slow: .25s ease;--hm-transition-spring: .3s cubic-bezier(.34, 1.56, .64, 1)}body{font-family:var(--hm-font-sans);font-size:var(--hm-text-md);line-height:var(--hm-leading-normal);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;letter-spacing:-.011em}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:var(--hm-bg)}::-webkit-scrollbar-thumb{background:#374151;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#4b5563}*{scrollbar-width:thin;scrollbar-color:#374151 var(--hm-bg)}.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}.status-dot.online{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success)}.status-dot.offline{background:var(--hm-danger);box-shadow:0 0 6px var(--hm-danger)}.status-dot.starting{background:var(--hm-warning);box-shadow:0 0 6px var(--hm-warning)}.hm-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-5);box-shadow:var(--hm-shadow-sm);transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.hm-table{width:100%;border-collapse:collapse;font-size:var(--hm-text-md)}.hm-table th{text-align:left;padding:var(--hm-space-3) var(--hm-space-4);color:var(--hm-text-muted);font-weight:500;font-size:var(--hm-text-sm);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--hm-border);white-space:nowrap}.hm-table td{padding:var(--hm-space-3) var(--hm-space-4);border-bottom:1px solid rgba(31,41,55,.5)}.hm-table tr:hover td{background:#1f29374d}.btn{display:inline-flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-2) var(--hm-space-4);border-radius:var(--hm-radius-md);font-size:var(--hm-text-base);font-weight:500;font-family:var(--hm-font-sans);cursor:pointer;border:none;transition:background var(--hm-transition-base),box-shadow var(--hm-transition-base),transform var(--hm-transition-fast);letter-spacing:-.006em}.btn:active:not(:disabled){transform:scale(.97)}.btn-primary{background:var(--hm-accent);color:#fff}.btn-primary:hover{background:var(--hm-accent-hover);box-shadow:var(--hm-shadow-glow-sm)}.btn-danger{background:#ef444426;color:#f87171}.btn-danger:hover{background:#ef444440}.btn-ghost{background:transparent;color:var(--hm-text-muted)}.btn-ghost:hover{background:var(--hm-surface-hover);color:var(--hm-text)}.btn:disabled{opacity:.5;cursor:not-allowed}.hm-input{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4);color:var(--hm-text);font-size:var(--hm-text-md);font-family:var(--hm-font-sans);width:100%;outline:none;transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.hm-input:focus{border-color:var(--hm-accent);box-shadow:0 0 0 3px var(--hm-accent-glow)}.credential-input:-webkit-autofill,.credential-input:-webkit-autofill:hover,.credential-input:-webkit-autofill:focus{-webkit-text-fill-color:var(--hm-text);caret-color:var(--hm-text);box-shadow:0 0 0 1000px var(--hm-bg) inset;-webkit-transition:background-color 9999s ease-out 0s;transition:background-color 9999s ease-out 0s}textarea.hm-input{font-family:var(--hm-font-mono);resize:vertical;min-height:120px}.toggle-switch{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{position:absolute;top:0;right:0;bottom:0;left:0;background:#374151;border-radius:10px;cursor:pointer;transition:background .2s}.toggle-slider:before{content:"";position:absolute;width:14px;height:14px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:transform .2s}.toggle-switch input:checked+.toggle-slider{background:var(--hm-accent);box-shadow:var(--hm-shadow-glow-sm)}.toggle-switch input:checked+.toggle-slider:before{transform:translate(16px)}.toggle-switch input:disabled+.toggle-slider{opacity:.4;cursor:not-allowed}.field-changed td:first-child{border-left:2px solid var(--hm-accent)}.config-tag{display:inline-flex;align-items:center;gap:var(--hm-space-1);padding:.125rem .5rem;background:var(--hm-gold-dim);border:1px solid rgba(217,119,6,.2);border-radius:var(--hm-radius-sm);font-size:var(--hm-text-sm);font-family:var(--hm-font-mono);color:var(--hm-text)}.config-tag button{background:none;border:none;color:var(--hm-text-muted);cursor:pointer;padding:0;font-size:var(--hm-text-md);line-height:1}.config-tag button:hover{color:var(--hm-danger)}.hm-select{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-2) var(--hm-space-3);color:var(--hm-text);font-size:var(--hm-text-base);font-family:var(--hm-font-sans);outline:none;cursor:pointer;transition:border-color var(--hm-transition-base)}.hm-select:focus{border-color:var(--hm-accent)}.badge{display:inline-block;padding:.125rem .5rem;border-radius:var(--hm-radius-full);font-size:var(--hm-text-sm);font-weight:500;letter-spacing:.01em}.badge-success{background:#22c55e26;color:#4ade80}.badge-warning{background:#eab30826;color:#facc15}.badge-danger{background:#ef444426;color:#f87171}.badge-info{background:var(--hm-accent-dim);color:var(--hm-gold)}.spinner{width:20px;height:20px;border:2px solid var(--hm-border);border-top-color:var(--hm-accent);border-radius:50%;animation:spin .6s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.modal-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#0009;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:50}.modal-content{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-xl);padding:var(--hm-space-6);max-width:600px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:var(--hm-shadow-lg)}.skeleton{background:linear-gradient(90deg,var(--hm-surface) 25%,var(--hm-surface-hover) 50%,var(--hm-surface) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:var(--hm-radius-sm)}.skeleton-text{height:.875rem;margin-bottom:.5rem}.skeleton-stat{height:2rem;width:3rem;margin:0 auto .25rem}.skeleton-row{height:2.25rem;margin-bottom:.375rem}@keyframes shimmer{0%{background-position:200% 0}to{background-position:-200% 0}}.flash-new{animation:flash-highlight 1.5s ease-out}@keyframes flash-highlight{0%{background-color:#d9770640}to{background-color:transparent}}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}.error-state{display:flex;flex-direction:column;align-items:center;gap:var(--hm-space-4);padding:var(--hm-space-6);text-align:center}.error-state .error-icon{font-size:1.5rem;opacity:.6}.chat-container{display:flex;flex-direction:column;height:calc(100vh - var(--hm-topbar-h));overflow:hidden}.page-viewport .chat-container.page-fade-in{max-width:none;margin-inline:0;--chat-work-lane: 1400px;--chat-reading-lane: 1080px}.chat-messages{flex:1;overflow-y:auto;padding:var(--hm-space-5) var(--hm-space-5) var(--hm-space-3);scroll-behavior:smooth}.chat-messages>.chat-message,.chat-messages>.chat-date-sep{max-width:var(--chat-reading-lane);margin-inline:auto}.chat-messages>.chat-message:has(pre) .chat-bubble-wrap,.chat-messages>.chat-message:has(table) .chat-bubble-wrap{max-width:100%;width:100%}.chat-empty{display:flex;align-items:center;justify-content:center;height:100%}.chat-welcome{text-align:center;max-width:28rem}.chat-welcome-icon{color:var(--hm-accent);margin-bottom:var(--hm-space-4);opacity:.8}.chat-welcome-title{font-size:1.25rem;font-weight:700;color:var(--hm-text);margin-bottom:var(--hm-space-2);letter-spacing:.01em}.chat-welcome-subtitle{font-size:var(--hm-text-sm);color:var(--hm-text-muted);margin-bottom:var(--hm-space-5)}.chat-suggestions{display:flex;flex-wrap:wrap;justify-content:center;gap:var(--hm-space-2)}.chat-suggestion{background:var(--hm-surface-elevated);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-2) var(--hm-space-4);color:var(--hm-text-muted);font-size:var(--hm-text-sm);cursor:pointer;transition:border-color var(--hm-transition-base),color var(--hm-transition-base)}.chat-suggestion:hover{border-color:var(--hm-accent);color:var(--hm-accent-hover)}.chat-date-sep{display:flex;align-items:center;gap:var(--hm-space-4);margin:var(--hm-space-4) 0}.chat-date-sep:before,.chat-date-sep:after{content:"";flex:1;height:1px;background:var(--hm-border)}.chat-date-sep span{font-size:var(--hm-text-xs);color:var(--hm-text-muted);white-space:nowrap}.chat-message{display:flex;gap:var(--hm-space-3);margin-bottom:var(--hm-space-4);align-items:flex-start}.chat-message.chat-user{flex-direction:row-reverse}.chat-message.chat-bot{flex-direction:row}.chat-avatar{width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px}.chat-avatar-bot{background:linear-gradient(135deg,#d9770633,#d977061a);border:1px solid rgba(217,119,6,.3);color:var(--hm-accent)}.chat-avatar-user{background:var(--hm-surface-hover);border:1px solid var(--hm-border);color:var(--hm-text-muted)}.chat-avatar-eye,.chat-avatar-user{display:flex;align-items:center;justify-content:center}.chat-avatar-pulse{animation:avatar-pulse 2s ease-in-out infinite}@keyframes avatar-pulse{0%,to{opacity:1}50%{opacity:.5}}.chat-bubble-wrap{max-width:75%;min-width:0}.chat-message.chat-user .chat-bubble-wrap{align-items:flex-end;display:flex;flex-direction:column}.chat-bubble{border-radius:var(--hm-radius-xl);padding:.625rem .875rem;font-size:var(--hm-text-md);line-height:var(--hm-leading-normal);word-break:break-word}.chat-bubble-user{background:linear-gradient(135deg,var(--hm-accent),#b45309);color:#fff;border-bottom-right-radius:var(--hm-radius-sm);box-shadow:var(--hm-shadow-sm)}.chat-bubble-bot{background:var(--hm-surface-elevated);border:1px solid var(--hm-border);border-bottom-left-radius:var(--hm-radius-sm)}.chat-bubble-typing{display:flex;align-items:center;gap:var(--hm-space-3)}.chat-bubble-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:var(--hm-space-1)}.chat-bubble-label{font-size:var(--hm-text-xs);color:var(--hm-accent-hover);font-weight:600;letter-spacing:.02em}.chat-error-indicator{font-size:.625rem;color:#f87171;background:#ef444426;border:1px solid rgba(239,68,68,.3);border-radius:var(--hm-radius-sm);padding:0 .375rem;line-height:1.4;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.chat-bubble-text{white-space:pre-wrap}.chat-timestamp{font-size:.625rem;color:var(--hm-text-muted);margin-top:2px;opacity:0;transition:opacity var(--hm-transition-base);padding:0 .25rem}.chat-message:hover .chat-timestamp{opacity:1}.chat-markdown{white-space:normal}.chat-markdown p{margin:0 0 .5rem}.chat-markdown p:last-child{margin-bottom:0}.chat-markdown code{background:#0000004d;padding:.125rem .375rem;border-radius:var(--hm-radius-sm);font-size:var(--hm-text-base);font-family:var(--hm-font-mono)}.chat-markdown pre{background:#0000004d;border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4);margin:var(--hm-space-2) 0;overflow-x:auto;position:relative}.chat-markdown pre code{background:none;padding:0}.chat-markdown ul,.chat-markdown ol{margin:.25rem 0;padding-left:1.25rem}.chat-markdown blockquote{border-left:3px solid var(--hm-accent);padding-left:var(--hm-space-4);margin:var(--hm-space-2) 0;color:var(--hm-text-muted)}.chat-markdown a{color:var(--hm-accent-hover);text-decoration:underline}.chat-markdown table{border-collapse:collapse;margin:var(--hm-space-2) 0;font-size:var(--hm-text-sm);display:block;width:-moz-max-content;width:max-content;max-width:100%;overflow-x:auto}.chat-markdown th,.chat-markdown td{border:1px solid var(--hm-border);padding:var(--hm-space-2) var(--hm-space-3);text-align:left;white-space:nowrap;word-break:normal}.chat-markdown th{background:#0003;font-weight:600}.chat-code-copy{position:absolute;top:var(--hm-space-2);right:var(--hm-space-2);background:var(--hm-surface-hover);border:1px solid var(--hm-border);border-radius:var(--hm-radius-sm);color:var(--hm-text-muted);font-size:.625rem;padding:2px 8px;cursor:pointer;opacity:0;transition:opacity var(--hm-transition-base),color var(--hm-transition-base);font-family:var(--hm-font-sans);text-transform:uppercase;letter-spacing:.04em}.chat-code-copy:hover{color:var(--hm-accent-hover)}.chat-tool-cards{margin-bottom:var(--hm-space-2)}.chat-tools-toggle{background:none;border:none;color:var(--hm-text-muted);font-size:var(--hm-text-sm);cursor:pointer;display:flex;align-items:center;gap:var(--hm-space-1);padding:.125rem 0}.chat-tools-toggle:hover{color:var(--hm-text)}.chat-tools-toggle-icon{font-size:.5rem}.chat-tools-toggle-count{background:#d9770626;color:var(--hm-accent-hover);border-radius:var(--hm-radius-sm);padding:0 .375rem;font-weight:600;font-size:var(--hm-text-xs)}.chat-tool-list{display:flex;flex-wrap:wrap;gap:var(--hm-space-1);margin-top:var(--hm-space-2)}.chat-tool-card{display:flex;align-items:center;gap:var(--hm-space-2);background:#0003;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-1) var(--hm-space-3);font-size:var(--hm-text-xs)}.chat-tool-icon{font-size:var(--hm-text-sm)}.chat-tool-name{font-family:var(--hm-font-mono);color:var(--hm-text-muted)}.chat-images{display:flex;flex-wrap:wrap;gap:var(--hm-space-2);margin-top:var(--hm-space-2)}.chat-image-thumb{border-radius:var(--hm-radius-md);overflow:hidden;border:1px solid var(--hm-border);max-width:240px;cursor:pointer;transition:border-color var(--hm-transition-base)}.chat-image-thumb:hover{border-color:var(--hm-accent)}.chat-image-thumb img{display:block;width:100%;height:auto;max-height:200px;-o-object-fit:cover;object-fit:cover}.chat-typing{display:flex;gap:var(--hm-space-1);padding:var(--hm-space-1) 0}.chat-typing span{width:6px;height:6px;background:var(--hm-accent);border-radius:50%;animation:typing-dot 1.4s infinite ease-in-out both}.chat-typing span:nth-child(1){animation-delay:0s}.chat-typing span:nth-child(2){animation-delay:.2s}.chat-typing span:nth-child(3){animation-delay:.4s}@keyframes typing-dot{0%,80%,to{transform:scale(.6);opacity:.4}40%{transform:scale(1);opacity:1}}.chat-typing-text{font-size:var(--hm-text-xs);color:var(--hm-text-muted);margin-left:var(--hm-space-2);font-style:italic}.chat-input-area{border-top:1px solid var(--hm-border);background:var(--hm-surface);padding:var(--hm-space-4) var(--hm-space-5);min-height:var(--hm-rail-h);display:flex;flex-direction:column;justify-content:center}.chat-input-row,.chat-input-hint{width:100%;max-width:var(--chat-work-lane);margin-inline:auto}.chat-input-row{display:flex;gap:var(--hm-space-3);align-items:flex-end}.chat-input{flex:1;background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-3) var(--hm-space-4);color:var(--hm-text);font-size:var(--hm-text-md);resize:none;outline:none;line-height:var(--hm-leading-normal);max-height:120px;font-family:inherit;transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.chat-input:focus{border-color:var(--hm-accent);box-shadow:0 0 0 3px var(--hm-accent-glow)}.chat-input:disabled{opacity:.5}.chat-send-btn{height:36px;min-width:44px;flex-shrink:0;display:flex;align-items:center;justify-content:center}.chat-send-icon{pointer-events:none}.chat-input-hint{display:flex;justify-content:space-between;align-items:center;padding-top:var(--hm-space-1)}.chat-connection-status{display:flex;align-items:center;gap:var(--hm-space-1);font-size:var(--hm-text-xs)}.chat-status-dot{width:6px;height:6px;border-radius:50%}.chat-ws-on .chat-status-dot{background:#4ade80}.chat-ws-off .chat-status-dot{background:#f87171}.chat-ws-on{color:var(--hm-text-muted)}.chat-ws-off{color:#f87171}.session-card{transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.session-card:hover{border-color:var(--hm-accent);box-shadow:var(--hm-shadow-glow-sm)}.session-selected{border-color:var(--hm-accent)!important;background:#d977060d}.session-checkbox{width:14px;height:14px;accent-color:var(--hm-accent);cursor:pointer;flex-shrink:0}.session-preview-role{font-weight:600;font-family:var(--hm-font-mono);flex-shrink:0;width:36px}.session-msg-content{font-size:var(--hm-text-base)}.sess-source-icon{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:var(--hm-text-sm)}.sess-source-discord{background:#5865f226;border:1px solid rgba(88,101,242,.3)}.sess-source-web{background:var(--hm-accent-dim);border:1px solid rgba(217,119,6,.3)}.sess-expand-icon{font-size:.5rem;color:var(--hm-text-dim);transition:transform var(--hm-transition-base);display:inline-block;margin-right:var(--hm-space-2)}.sess-summary-banner{padding:var(--hm-space-3) var(--hm-space-4);background:linear-gradient(135deg,#eab30814,#eab30808);border:1px solid rgba(234,179,8,.2);border-radius:var(--hm-radius-md)}.sess-summary-label{font-size:var(--hm-text-xs);font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--hm-warning)}.sess-view-btn{background:#ffffff0d;border:1px solid transparent;border-radius:var(--hm-radius-md);padding:var(--hm-space-1) var(--hm-space-4);font-size:var(--hm-text-xs);color:var(--hm-text-muted);cursor:pointer;transition:all var(--hm-transition-base);font-family:var(--hm-font-sans)}.sess-view-btn:hover{background:#ffffff1a}.sess-view-active{background:var(--hm-accent-dim)!important;color:var(--hm-gold);border-color:#d9770666}.sess-thread-container{max-height:32rem;overflow-y:auto;scrollbar-gutter:stable;display:flex;flex-direction:column;gap:var(--hm-space-3)}.sess-thread{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);overflow:hidden;transition:border-color var(--hm-transition-base)}.sess-thread:hover{border-color:#ffffff1a}.sess-thread-header{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);cursor:pointer;transition:background var(--hm-transition-base)}.sess-thread-header:hover{background:#ffffff08}.sess-thread-num{width:22px;height:22px;border-radius:50%;background:var(--hm-accent-dim);color:var(--hm-accent-hover);display:flex;align-items:center;justify-content:center;font-size:var(--hm-text-xs);font-weight:700;flex-shrink:0}.sess-thread-arrow{font-size:.5rem;color:var(--hm-text-dim);transition:transform var(--hm-transition-base);display:inline-block}.sess-thread-arrow-open{transform:rotate(90deg)}.sess-thread-summary{flex:1;min-width:0;font-size:var(--hm-text-sm);color:var(--hm-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sess-thread-count{flex-shrink:0}.sess-thread-messages{border-top:1px solid var(--hm-border);padding:var(--hm-space-3) var(--hm-space-4);display:flex;flex-direction:column;gap:var(--hm-space-3)}.sess-thread-msg{padding:8px 12px;border-radius:6px;margin-bottom:8px}.sess-msg-user{background:#06b6d414;border-left:3px solid rgba(6,182,212,.5)}.sess-msg-assistant{background:#6366f114;border-left:3px solid rgba(99,102,241,.5)}.sess-msg-system{background:#6b728014;border-left:3px solid rgba(107,114,128,.4)}.sess-msg-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:var(--hm-space-2)}.sess-role-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.sess-dot-user{background:#06b6d4;box-shadow:0 0 4px #06b6d480}.sess-dot-assistant{background:#6366f1;box-shadow:0 0 4px #6366f180}.sess-dot-system{background:#6b7280}.sess-role-label{font-size:var(--hm-text-xs);font-weight:600}.sess-msg-content{font-size:13px;color:var(--hm-text);line-height:1.5;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-height:200px;overflow-y:auto}.sess-filter-bar,.logs-filter-bar{border-bottom:1px solid var(--hm-border);padding-bottom:var(--hm-space-3)}.sess-preset-chip{display:inline-flex;align-items:center;gap:var(--hm-space-1);padding:var(--hm-space-1) var(--hm-space-4);border-radius:var(--hm-radius-full);font-size:var(--hm-text-xs);font-weight:500;cursor:pointer;border:1px solid transparent;transition:all var(--hm-transition-base);background:#ffffff0d;color:var(--hm-text-muted);font-family:var(--hm-font-sans)}.sess-preset-chip:hover{background:#ffffff1a;color:var(--hm-text)}.sess-preset-active{background:var(--hm-accent-dim)!important;color:var(--hm-gold)!important;border-color:#d9770666}.sess-preset-icon{font-size:var(--hm-text-sm)}.sess-preset-custom{border-style:dashed;border-color:#ffffff1a}.sess-preset-remove{margin-left:var(--hm-space-1);font-size:var(--hm-text-md);color:var(--hm-text-dim);cursor:pointer;line-height:1}.sess-preset-remove:hover{color:var(--hm-danger)}.log-line{border-left:2px solid transparent;padding-left:var(--hm-space-3)}.log-line-error{color:#f87171;border-left-color:#ef4444;background:#ef44440d}.log-line-warning{color:#fbbf24;border-left-color:#eab308;background:#eab3080a}.log-ts{transition:color var(--hm-transition-base)}.log-chip{display:inline-flex;align-items:center;padding:.125rem .5rem;border-radius:var(--hm-radius-full);font-size:var(--hm-text-xs);font-weight:600;cursor:pointer;border:1px solid transparent;transition:all var(--hm-transition-base);font-family:var(--hm-font-mono);background:#ffffff0d;color:var(--hm-text-muted)}.log-chip:hover{background:#ffffff1a}.log-chip-info.log-chip-active{background:#d9770633;color:var(--hm-gold);border-color:#d9770666}.log-chip-warning.log-chip-active{background:#eab30833;color:#facc15;border-color:#eab30866}.log-chip-error.log-chip-active{background:#ef444433;color:#f87171;border-color:#ef444466}.log-chip-clear{background:#ffffff0d;color:var(--hm-text-muted)}.log-chip-clear:hover{background:#ffffff1a;color:var(--hm-text)}.log-jump-btn{position:absolute;bottom:1rem;left:50%;transform:translate(-50%);background:var(--hm-accent);color:#fff;border:none;border-radius:var(--hm-radius-full);padding:var(--hm-space-2) var(--hm-space-5);font-size:var(--hm-text-sm);font-weight:500;cursor:pointer;z-index:10;box-shadow:0 2px 8px #0006;transition:background var(--hm-transition-base),transform var(--hm-transition-base);animation:jump-btn-in .2s ease-out}.log-jump-btn:hover{background:var(--hm-accent-hover)}@keyframes jump-btn-in{0%{opacity:0;transform:translate(-50%) translateY(.5rem)}to{opacity:1;transform:translate(-50%) translateY(0)}}.logs-tool-badge{display:inline-block;background:#d977061a;border:1px solid rgba(217,119,6,.2);border-radius:var(--hm-radius-sm);padding:0 var(--hm-space-2);font-size:var(--hm-text-xs);color:var(--hm-accent-hover);margin-right:var(--hm-space-2);font-family:var(--hm-font-mono)}.logs-timeline{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4)}.logs-timeline-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--hm-space-2)}.logs-timeline-chart{display:flex;gap:2px;align-items:flex-end;height:48px}.logs-timeline-bar-wrap{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;cursor:pointer;min-width:0}.logs-timeline-bar{width:100%;height:40px;display:flex;flex-direction:column-reverse;border-radius:2px 2px 0 0;overflow:hidden;background:#ffffff05;transition:background var(--hm-transition-base)}.logs-timeline-bar-wrap:hover .logs-timeline-bar{background:#ffffff0f}.logs-timeline-segment{width:100%;min-height:1px;transition:height var(--hm-transition-slow)}.logs-tl-error{background:#ef4444b3}.logs-tl-warning{background:#eab30899}.logs-tl-info{background:#3b82f666}.logs-timeline-label{font-size:.5rem;color:var(--hm-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.stat-card{position:relative;overflow:hidden}.stat-card:before{content:"";position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--hm-accent),transparent);opacity:0;transition:opacity var(--hm-transition-base)}.stat-card:hover:before{opacity:1}.stat-icon{font-size:var(--hm-text-md);margin-bottom:var(--hm-space-1);opacity:.7}.dash-hero{display:flex;flex-wrap:wrap;align-items:center;gap:var(--hm-space-5);position:relative}.dash-hero-left{display:flex;align-items:center;gap:var(--hm-space-4);flex:1;min-width:0}.dash-hero-ring{position:relative;width:48px;height:48px;flex-shrink:0}.dash-ring-svg{width:48px;height:48px;transform:rotate(-90deg)}.ring-online{color:var(--hm-success)}.ring-starting{color:var(--hm-warning)}.dash-ring-progress{transition:stroke-dashoffset 1s ease}.dash-hero-icon{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;font-size:1rem;color:var(--hm-text-muted)}.dash-hero-name{font-size:var(--hm-text-lg);font-weight:600;color:var(--hm-text);letter-spacing:-.02em}.dash-hero-sub{display:flex;align-items:center;gap:var(--hm-space-2);font-size:var(--hm-text-sm);color:var(--hm-text-muted)}.dash-hero-sep{opacity:.4}.dash-hero-actions{display:flex;gap:var(--hm-space-2);flex-shrink:0}.dash-hero-skeleton{display:flex;align-items:center;gap:var(--hm-space-4)}.dash-stat{display:flex;flex-direction:column;gap:var(--hm-space-1);padding:var(--hm-space-4) var(--hm-space-5)}.dash-stat-header{display:flex;align-items:center;gap:var(--hm-space-2)}.dash-stat-icon{font-size:var(--hm-text-md);opacity:.7}.dash-stat-label{font-size:var(--hm-text-xs);color:var(--hm-text-dim);text-transform:uppercase;letter-spacing:.04em;font-weight:500}.dash-stat-value{font-size:var(--hm-text-2xl);font-weight:700;letter-spacing:-.02em;line-height:1}.dash-stat-sub{font-size:var(--hm-text-xs)}.dash-stat-highlight{border-color:#d9770640;background:linear-gradient(135deg,var(--hm-surface),rgba(217,119,6,.03))}.dash-health-bar{padding:var(--hm-space-4) var(--hm-space-5)}.dash-health-items{display:flex;flex-wrap:wrap;gap:var(--hm-space-4)}.dash-health-item{display:flex;align-items:center;gap:var(--hm-space-2);font-size:var(--hm-text-sm)}.dash-health-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.dash-health-ok{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success)}.dash-health-warn{background:var(--hm-warning);box-shadow:0 0 6px var(--hm-warning)}.dash-health-error{background:var(--hm-danger);box-shadow:0 0 6px var(--hm-danger)}.dash-health-label{color:var(--hm-text);font-weight:500}.dash-health-detail{color:var(--hm-text-muted)}.dash-panel{padding:var(--hm-space-4) var(--hm-space-5);min-height:120px}.dash-panel-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--hm-space-3)}.dash-panel-title{font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-text-muted);text-transform:uppercase;letter-spacing:.04em}.dash-load-failed .dash-empty-icon{color:#d9a441}.dash-load-failed span:last-child{color:var(--hm-text-muted)}.dash-load-warning{color:#d9a441;padding:.1rem 0 .35rem}.chat-history-warning{color:#d9a441;padding:.55rem 1rem;border-bottom:1px solid var(--hm-border);background:#d9a44114}.dash-empty{display:flex;flex-direction:column;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-5) 0;color:var(--hm-text-dim);font-size:var(--hm-text-sm)}.dash-empty-icon{font-size:1.25rem;opacity:.3}.dash-agent-list{display:flex;flex-direction:column;gap:var(--hm-space-3)}.dash-agent-item{padding:var(--hm-space-3);background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.dash-agent-top{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:var(--hm-space-1)}.dash-agent-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.dash-agent-running{background:var(--hm-success);box-shadow:0 0 4px var(--hm-success);animation:loop-pulse 2s ease-in-out infinite}.dash-agent-completed{background:var(--hm-info)}.dash-agent-failed{background:var(--hm-danger)}.dash-agent-timeout{background:var(--hm-warning)}.dash-agent-killed{background:var(--hm-text-dim)}.dash-agent-label{font-weight:600;font-size:var(--hm-text-sm);color:var(--hm-text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dash-agent-iters{font-size:var(--hm-text-xs);color:var(--hm-text-dim);font-family:var(--hm-font-mono);flex-shrink:0}.dash-agent-goal{font-size:var(--hm-text-xs);color:var(--hm-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dash-agent-meta{display:flex;gap:var(--hm-space-3);font-size:var(--hm-text-xs);color:var(--hm-text-dim);margin-top:var(--hm-space-1)}.dash-agent-tools{font-family:var(--hm-font-mono)}.dash-activity-list{display:flex;flex-direction:column}.dash-activity-item{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-2) 0;border-bottom:1px solid rgba(31,41,55,.5);font-size:var(--hm-text-xs)}.dash-activity-item:last-child{border-bottom:none}.dash-activity-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}.dot-ok{background:var(--hm-success)}.dot-error{background:var(--hm-danger)}.dash-activity-tool{font-family:var(--hm-font-mono);color:var(--hm-text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dash-activity-time{color:var(--hm-text-dim);white-space:nowrap;flex-shrink:0}.dash-guild-item{display:flex;align-items:center;gap:var(--hm-space-2);font-size:var(--hm-text-sm)}.dash-guild-count{margin-left:auto;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.dash-error-list{display:flex;flex-direction:column;gap:var(--hm-space-2)}.dash-error-item{padding:var(--hm-space-2) 0;border-bottom:1px solid rgba(31,41,55,.5);font-size:var(--hm-text-xs)}.dash-error-item:last-child{border-bottom:none}.dash-error-top{display:flex;align-items:center;gap:var(--hm-space-2)}.dash-error-tool{font-family:var(--hm-font-mono);color:#f87171;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dash-error-time{color:var(--hm-text-dim);white-space:nowrap;flex-shrink:0}.dash-error-msg{color:var(--hm-text-dim);padding-left:1.25rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:90%}.tool-expand-icon{font-size:.625rem;display:inline-block;transition:transform var(--hm-transition-base)}.tool-detail-row td{padding:0!important}.tool-detail-cell{padding:var(--hm-space-4) var(--hm-space-5) var(--hm-space-4) 2rem!important;background:#11182780;border-left:2px solid var(--hm-accent)}.tl-stat-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-4) var(--hm-space-5);text-align:center;position:relative;overflow:hidden}.tl-stat-card:hover{border-color:var(--hm-accent-dim)}.tl-stat-value{font-size:var(--hm-text-2xl);font-weight:700;color:var(--hm-text);line-height:1.2}.tl-stat-label{font-size:var(--hm-text-xs);color:var(--hm-text-dim);margin-top:var(--hm-space-1)}.tl-stat-spark{display:flex;justify-content:center;margin-top:var(--hm-space-2)}.tl-view-toggle{display:inline-flex;background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);overflow:hidden}.tl-view-btn{padding:var(--hm-space-1) var(--hm-space-3);background:none;border:none;color:var(--hm-text-dim);cursor:pointer;font-size:var(--hm-text-sm);transition:all var(--hm-transition-base)}.tl-view-btn:hover{color:var(--hm-text);background:var(--hm-surface-hover)}.tl-view-active{color:var(--hm-accent)!important;background:var(--hm-accent-dim)!important}.tl-search{flex:1;min-width:200px}.tl-panel-warning{margin-top:2px;color:var(--hm-text-dim);font-size:.62rem;max-width:72ch}.tl-tool-switch{display:inline-flex;align-items:center;gap:var(--hm-space-2);cursor:pointer}.tl-tool-switch-label{color:var(--hm-text-dim);font-size:.58rem;font-weight:600;letter-spacing:.03em}.tl-tool-switch .toggle-switch[aria-busy=true]{opacity:.55;pointer-events:none}.tl-tool-card.tl-tool-off{border-color:var(--hm-border-subtle);background:var(--hm-surface);box-shadow:none}tr.tl-tool-off td{color:var(--hm-text-dim)}.tl-state-badge{display:inline-block;margin-left:6px;padding:1px 8px;border:1px solid;border-radius:999px;font-size:.55rem;font-weight:600;letter-spacing:.03em;white-space:nowrap}.tl-state-disabled{color:var(--hm-text-dim);border-color:var(--hm-border)}.tl-state-unavailable{color:#d9a441;border-color:#d9a44173}.tl-state-global_disabled{color:#d98e8e;border-color:#d98e8e66}.tl-core-advisory{margin-top:6px;color:#d9a441;font-size:.6rem}.tl-category-chips{display:flex;flex-wrap:wrap;gap:var(--hm-space-1)}.tl-category-chip{padding:var(--hm-space-1) var(--hm-space-3);border-radius:var(--hm-radius-full);border:1px solid var(--hm-border);background:var(--hm-surface);color:var(--hm-text-muted);font-size:var(--hm-text-xs);cursor:pointer;transition:all var(--hm-transition-base);white-space:nowrap}.tl-category-chip:hover{border-color:var(--hm-accent-dim);color:var(--hm-text)}.tl-category-active{background:var(--hm-accent-dim)!important;border-color:var(--hm-accent)!important;color:var(--hm-accent)!important}.tl-group-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:var(--hm-space-3);padding-bottom:var(--hm-space-2);border-bottom:1px solid var(--hm-border-subtle)}.tl-group-icon{font-size:var(--hm-text-md)}.tl-group-label{font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-text)}.tl-tool-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:var(--hm-space-3)}.tl-tool-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-4);cursor:pointer;transition:all var(--hm-transition-base)}.tl-tool-card:hover{border-color:#d977064d;box-shadow:var(--hm-shadow-glow-sm)}.tl-tool-card-active{border-left:2px solid var(--hm-accent)}.tl-tool-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--hm-space-2)}.tl-tool-name{font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-text)}.tl-tool-desc{font-size:var(--hm-text-xs);color:var(--hm-text-muted);line-height:var(--hm-leading-normal);margin-bottom:var(--hm-space-3)}.tl-tool-footer{display:flex;align-items:center;justify-content:space-between}.tl-tool-usage{display:flex;align-items:baseline;gap:var(--hm-space-1)}.tl-tool-usage-count{font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-accent)}.tl-tool-usage-zero{font-size:var(--hm-text-sm);color:var(--hm-text-dim)}.tl-tool-usage-label{font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.tl-tool-spark{display:flex;align-items:center}.tl-sparkline{display:block}.tl-tool-detail{margin-top:var(--hm-space-3);padding-top:var(--hm-space-3);border-top:1px solid var(--hm-border-subtle)}.tl-tool-detail-desc{font-size:var(--hm-text-xs);color:var(--hm-text);line-height:var(--hm-leading-relaxed);white-space:pre-wrap;margin-bottom:var(--hm-space-3)}.tl-tool-params{margin-top:var(--hm-space-2)}.tl-tool-params-title{font-size:var(--hm-text-xs);font-weight:600;color:var(--hm-text-muted);margin-bottom:var(--hm-space-2)}.tl-tool-param{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-1) 0;font-size:var(--hm-text-xs)}.tl-tool-param-name{font-family:var(--hm-font-mono);color:var(--hm-text);font-weight:500}.tl-tool-param-type{color:var(--hm-info);font-family:var(--hm-font-mono)}.tl-tool-param-req{color:var(--hm-warning);font-size:.625rem;font-weight:600;text-transform:uppercase}.sk-stat-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-4) var(--hm-space-5);text-align:center}.sk-stat-card:hover{border-color:var(--hm-accent-dim)}.sk-stat-value{font-size:var(--hm-text-2xl);font-weight:700;color:var(--hm-text);line-height:1.2}.sk-stat-label{font-size:var(--hm-text-xs);color:var(--hm-text-dim);margin-top:var(--hm-space-1)}.sk-search{width:100%;max-width:400px}.sk-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,360px),1fr));gap:var(--hm-space-4)}.sk-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);overflow:hidden;transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.sk-card:hover{border-color:#d977064d;box-shadow:var(--hm-shadow-glow-sm)}.sk-card-tested{border-left:2px solid var(--hm-success)}.sk-card-header{display:flex;align-items:center;justify-content:space-between;padding:var(--hm-space-4) var(--hm-space-5);border-bottom:1px solid var(--hm-border-subtle);flex-wrap:wrap;gap:var(--hm-space-2)}.sk-card-title-row{display:flex;align-items:center;gap:var(--hm-space-2)}.sk-card-icon{font-size:var(--hm-text-md);opacity:.7}.sk-card-name{font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600;color:var(--hm-text)}.sk-card-runs{font-size:var(--hm-text-xs);color:var(--hm-text-dim);font-family:var(--hm-font-mono)}.sk-card-actions{display:flex;gap:var(--hm-space-1)}.sk-action-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:var(--hm-radius-md);border:1px solid var(--hm-border);background:var(--hm-surface);color:var(--hm-text-muted);cursor:pointer;font-size:var(--hm-text-xs);transition:all var(--hm-transition-base)}.sk-action-btn:hover{background:var(--hm-surface-hover);color:var(--hm-text)}.sk-action-test:hover{color:var(--hm-success);border-color:#22c55e4d}.sk-action-code:hover{color:var(--hm-info);border-color:#3b82f64d}.sk-action-edit:hover{color:var(--hm-accent);border-color:var(--hm-accent-dim)}.sk-action-delete:hover{color:var(--hm-danger);border-color:#ef44444d}.sk-card-body{padding:var(--hm-space-4) var(--hm-space-5)}.sk-card-desc{font-size:var(--hm-text-sm);color:var(--hm-text-muted);line-height:var(--hm-leading-normal);margin-bottom:var(--hm-space-2)}.sk-card-meta{display:flex;align-items:center;gap:var(--hm-space-4);font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.sk-card-date,.sk-card-lines{white-space:nowrap}.sk-test-result{margin:0 var(--hm-space-5) var(--hm-space-4);padding:var(--hm-space-3) var(--hm-space-4);border-radius:var(--hm-radius-md);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs)}.sk-test-pass{background:#22c55e14;border:1px solid rgba(34,197,94,.2)}.sk-test-fail{background:#ef444414;border:1px solid rgba(239,68,68,.2)}.sk-test-label{font-weight:600;font-family:var(--hm-font-sans);font-size:var(--hm-text-xs);margin-bottom:var(--hm-space-1)}.sk-test-pass .sk-test-label{color:var(--hm-success)}.sk-test-fail .sk-test-label{color:var(--hm-danger)}.sk-test-output{white-space:pre-wrap;color:var(--hm-text-muted);max-height:120px;overflow-y:auto}.sk-code-container{border-top:1px solid var(--hm-border-subtle)}.sk-code-header{display:flex;align-items:center;justify-content:space-between;padding:var(--hm-space-2) var(--hm-space-5);background:#03071280;border-bottom:1px solid var(--hm-border-subtle)}.sk-code-filename{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.sk-code-copy{background:none;border:none;color:var(--hm-text-dim);cursor:pointer;font-size:var(--hm-text-sm);padding:var(--hm-space-1);border-radius:var(--hm-radius-sm);transition:color var(--hm-transition-base)}.sk-code-copy:hover{color:var(--hm-text)}.sk-code-wrap{display:flex;max-height:400px;overflow:auto}.sk-line-numbers{padding:var(--hm-space-4) var(--hm-space-3);text-align:right;color:var(--hm-text-dim);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);line-height:var(--hm-leading-relaxed);background:#03071280;border-right:1px solid var(--hm-border-subtle);-webkit-user-select:none;-moz-user-select:none;user-select:none;min-width:2.5rem;margin:0;flex-shrink:0}.sk-code-block{flex:1;padding:var(--hm-space-4) var(--hm-space-5);margin:0;background:var(--hm-bg);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);line-height:var(--hm-leading-relaxed);color:var(--hm-text);overflow-x:auto}.sk-code-block code{background:none;padding:0;font-size:inherit}.sk-kw{color:#c084fc;font-weight:600}.sk-str{color:#86efac}.sk-cmt{color:var(--hm-text-dim);font-style:italic}.sk-dec{color:var(--hm-gold)}.sk-num{color:#67e8f9}.sk-builtin{color:#93c5fd}.sk-editor-panel{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-5) var(--hm-space-6);margin-top:var(--hm-space-4)}.sk-editor-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--hm-space-4)}.sk-editor-title{font-size:var(--hm-text-md);font-weight:600;color:var(--hm-text)}.sk-field-label{display:block;font-size:var(--hm-text-xs);color:var(--hm-text-muted);margin-bottom:var(--hm-space-1)}.sk-field-hint{font-size:var(--hm-text-xs);color:var(--hm-text-dim);margin-top:var(--hm-space-1)}.sk-editor-wrap{display:flex;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);overflow:hidden;background:var(--hm-bg)}.sk-editor-gutter{padding:var(--hm-space-4) var(--hm-space-3);text-align:right;color:var(--hm-text-dim);font-family:var(--hm-font-mono);font-size:var(--hm-text-base);line-height:var(--hm-leading-relaxed);background:#03071280;border-right:1px solid var(--hm-border-subtle);-webkit-user-select:none;-moz-user-select:none;user-select:none;min-width:2.5rem;white-space:pre;overflow:hidden;flex-shrink:0}.sk-editor-textarea{flex:1;padding:var(--hm-space-4) var(--hm-space-5);background:transparent;border:none;color:var(--hm-text);font-family:var(--hm-font-mono);font-size:var(--hm-text-base);line-height:var(--hm-leading-relaxed);resize:vertical;-moz-tab-size:4;-o-tab-size:4;tab-size:4;outline:none;min-height:300px;width:100%}.sk-editor-textarea::-moz-placeholder{color:var(--hm-text-dim)}.sk-editor-textarea::placeholder{color:var(--hm-text-dim)}.sk-editor-status{display:flex;gap:var(--hm-space-4);margin-top:var(--hm-space-2);font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.sk-editor-line-count,.sk-editor-char-count{font-family:var(--hm-font-mono)}.sk-validation-box{padding:var(--hm-space-3) var(--hm-space-4);border-radius:var(--hm-radius-md);font-size:var(--hm-text-xs);margin-bottom:var(--hm-space-3)}.sk-validation-ok{background:#22c55e14;border:1px solid rgba(34,197,94,.2);color:var(--hm-success)}.sk-validation-err{background:#eab30814;border:1px solid rgba(234,179,8,.2);color:var(--hm-warning)}.skill-code-block{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-4) var(--hm-space-5);font-size:var(--hm-text-base);font-family:var(--hm-font-mono);overflow-x:auto;line-height:var(--hm-leading-relaxed);max-height:400px;overflow-y:auto;margin:0;color:var(--hm-text)}.skill-card{transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.skill-card:hover{border-color:#d977064d;box-shadow:var(--hm-shadow-glow-sm)}.skill-editor{font-family:var(--hm-font-mono);font-size:var(--hm-text-base);line-height:var(--hm-leading-relaxed);-moz-tab-size:4;-o-tab-size:4;tab-size:4;min-height:300px}.cfg-group{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);overflow:hidden;box-shadow:var(--hm-shadow-sm)}.cfg-group-header{display:flex;align-items:center;gap:var(--hm-space-3);padding:var(--hm-space-4) var(--hm-space-5);background:linear-gradient(135deg,var(--hm-surface),var(--hm-surface-elevated));border-bottom:1px solid var(--hm-border);transition:background var(--hm-transition-base)}.cfg-group-header:hover{background:var(--hm-surface-hover)}.cfg-group-icon{font-size:var(--hm-text-lg);opacity:.8}.cfg-group-label{font-weight:600;font-size:var(--hm-text-md);color:var(--hm-text);flex:1}.cfg-group-arrow{font-size:var(--hm-text-xs);color:var(--hm-text-dim);font-family:var(--hm-font-mono)}.cfg-group-body{padding:var(--hm-space-3);display:flex;flex-direction:column;gap:var(--hm-space-3)}.cfg-section{background:var(--hm-bg);border:1px solid rgba(31,41,55,.5);border-radius:var(--hm-radius-md);overflow:hidden}.cfg-section-header{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);transition:background var(--hm-transition-base)}.cfg-section-header:hover{background:#1f29374d}.cfg-section-name{font-size:var(--hm-text-sm);font-weight:500;color:var(--hm-text)}.cfg-section-body{padding:0 var(--hm-space-3) var(--hm-space-3)}.cfg-field-error{font-size:var(--hm-text-xs);color:#f87171;margin-top:2px;font-family:var(--hm-font-sans);font-weight:400}.cfg-input-error{border-color:#ef444480!important;box-shadow:0 0 0 2px #ef444426!important}.cfg-change-count{font-size:var(--hm-text-xs);color:var(--hm-accent-hover);background:var(--hm-accent-dim);padding:2px 8px;border-radius:var(--hm-radius-full);font-weight:600}.cfg-diff-list{max-height:50vh;overflow-y:auto;display:flex;flex-direction:column;gap:var(--hm-space-3)}.cfg-diff-entry{border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);overflow:hidden}.cfg-diff-path{padding:var(--hm-space-2) var(--hm-space-3);background:var(--hm-surface-hover);border-bottom:1px solid var(--hm-border);color:var(--hm-accent-hover)}.cfg-diff-values{font-size:var(--hm-text-sm)}.cfg-diff-old{padding:var(--hm-space-2) var(--hm-space-3);background:#ef444414;border-bottom:1px solid rgba(31,41,55,.3);color:#f87171;white-space:pre-wrap;word-break:break-word}.cfg-diff-new{padding:var(--hm-space-2) var(--hm-space-3);background:#22c55e14;color:#4ade80;white-space:pre-wrap;word-break:break-word}.cfg-diff-label{display:inline-block;width:1.25rem;font-weight:700;font-family:var(--hm-font-mono)}.knowledge-highlight{background:#d977064d;color:#fde68a;padding:0 2px;border-radius:2px}.knowledge-preview{border-left:2px solid var(--hm-border);padding-left:var(--hm-space-4);max-height:4rem;overflow:hidden;font-style:italic}.kb-stats-bar{display:flex;gap:var(--hm-space-4);margin-bottom:var(--hm-space-4);flex-wrap:wrap}.kb-stat{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-3) var(--hm-space-4);text-align:center;min-width:80px}.kb-stat-value{display:block;font-size:1.25rem;font-weight:700;color:var(--hm-gold)}.kb-stat-label{display:block;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.kb-score-badge{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);color:var(--hm-text-dim);background:var(--hm-gold-dim);padding:1px 6px;border-radius:3px}.kb-search-result{border-left:3px solid var(--hm-accent-dim)}.kb-ingest-form{border-left:3px solid var(--hm-accent)}.kb-tree,.kb-tree-list{display:flex;flex-direction:column;gap:var(--hm-space-2)}.kb-tree-node{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);overflow:hidden}.kb-tree-header{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background .15s}.kb-tree-header:hover{background:var(--hm-surface-hover)}.kb-tree-arrow{font-size:10px;color:var(--hm-text-dim);transition:transform .2s;flex-shrink:0}.kb-tree-icon{font-size:1rem;flex-shrink:0}.kb-tree-name{font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600}.kb-tree-actions{margin-left:auto;display:flex;gap:var(--hm-space-1);flex-shrink:0}.kb-tree-meta{padding:0 var(--hm-space-4) var(--hm-space-2) 2.25rem;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.kb-tree-preview{padding:0 var(--hm-space-4) var(--hm-space-3) 2.25rem;font-size:var(--hm-text-sm);color:var(--hm-text-muted);font-style:italic;max-height:3rem;overflow:hidden;white-space:pre-wrap;word-break:break-word}.kb-chunk-browser{padding:0 var(--hm-space-4) var(--hm-space-4) var(--hm-space-4)}.kb-chunk-loading{display:flex;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-dim);font-size:var(--hm-text-sm);padding:var(--hm-space-2) 0}.kb-chunk-list{display:flex;flex-direction:column;gap:var(--hm-space-1)}.kb-chunk-header{display:flex;justify-content:space-between;padding:var(--hm-space-2) 0;border-bottom:1px solid var(--hm-border)}.kb-chunk-empty{padding:var(--hm-space-2) 0}.kb-chunk-error{color:#d9a441}.kb-chunk-item{padding:var(--hm-space-2) var(--hm-space-3);border-radius:var(--hm-radius-md);border:1px solid var(--hm-border-subtle);cursor:pointer;transition:all .15s}.kb-chunk-item:hover{background:var(--hm-surface-hover);border-color:var(--hm-border)}.kb-chunk-selected{background:var(--hm-gold-dim)!important;border-color:#d977064d!important}.kb-chunk-item-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:2px}.kb-chunk-index{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);font-weight:600;color:var(--hm-accent);min-width:2rem}.kb-chunk-chars{font-size:var(--hm-text-xs);color:var(--hm-text-dim);min-width:4.5rem}.kb-chunk-bar{flex:1;height:4px;background:var(--hm-border);border-radius:2px;overflow:hidden}.kb-chunk-bar-fill{height:100%;background:var(--hm-accent);border-radius:2px;transition:width .3s}.kb-chunk-preview{font-size:var(--hm-text-xs);color:var(--hm-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.kb-chunk-content{font-size:var(--hm-text-sm);color:var(--hm-text);white-space:pre-wrap;word-break:break-word;max-height:20rem;overflow-y:auto;margin-top:var(--hm-space-2);padding:var(--hm-space-2);background:var(--hm-bg);border-radius:var(--hm-radius-md);border:1px solid var(--hm-border)}.memory-scope-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:var(--hm-text-sm);font-weight:600;font-family:var(--hm-font-mono)}.memory-scope-global{background:#d9770633;color:var(--hm-gold);border:1px solid rgba(217,119,6,.3)}.memory-scope-user{background:#eab30826;color:#fde047;border:1px solid rgba(234,179,8,.25)}.memory-checkbox{width:16px;height:16px;accent-color:var(--hm-accent);cursor:pointer}.mem-stats-bar{display:flex;gap:var(--hm-space-4);margin-bottom:var(--hm-space-4);flex-wrap:wrap}.mem-stat{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-3) var(--hm-space-4);text-align:center;min-width:80px}.mem-stat-value{display:block;font-size:1.25rem;font-weight:700;color:var(--hm-gold)}.mem-stat-label{display:block;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.mem-stat-action{display:flex;align-items:center;justify-content:center}.mem-add-form{border-left:3px solid var(--hm-accent)}.mem-tree{display:flex;flex-direction:column;gap:var(--hm-space-2)}.mem-tree-node{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);overflow:hidden}.mem-tree-header{display:flex;align-items:center;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background .15s}.mem-tree-header:hover{background:var(--hm-surface-hover)}.mem-tree-arrow{font-size:10px;color:var(--hm-text-dim);transition:transform .2s;flex-shrink:0}.mem-tree-entries{padding:0 var(--hm-space-4) var(--hm-space-3)}.mem-tree-loading{display:flex;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-dim);font-size:var(--hm-text-sm);padding:var(--hm-space-2) 0}.mem-tree-empty{padding:var(--hm-space-2) 0}.mem-tree-entry{padding:var(--hm-space-2) var(--hm-space-3);border-radius:var(--hm-radius-md);border:1px solid var(--hm-border-subtle);margin-bottom:var(--hm-space-1);transition:all .15s}.mem-tree-entry:hover{background:var(--hm-surface-hover)}.mem-tree-entry-selected{background:var(--hm-gold-dim);border-color:#d977064d}.mem-tree-entry-header{display:flex;align-items:center;gap:var(--hm-space-2);margin-bottom:2px}.mem-tree-key{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);font-weight:500;color:var(--hm-text-muted)}.mem-tree-entry-actions{margin-left:auto;display:flex;gap:var(--hm-space-1);flex-shrink:0}.mem-tree-value{font-size:var(--hm-text-sm);color:var(--hm-text);white-space:pre-wrap;word-break:break-word;max-height:6rem;overflow:hidden;padding-left:1.5rem}.mem-tree-edit{padding-left:1.5rem;margin-top:var(--hm-space-1)}.ag-checkbox{width:14px;height:14px;accent-color:var(--hm-accent);cursor:pointer}.ag-stats-bar{display:flex;gap:var(--hm-space-4);margin-bottom:var(--hm-space-4);flex-wrap:wrap}.ag-stat{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-3) var(--hm-space-4);text-align:center;min-width:80px}.ag-stat-value{display:block;font-size:1.25rem;font-weight:700;color:var(--hm-gold)}.ag-stat-label{display:block;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}.ag-stat-running{color:var(--hm-success)!important}.ag-stat-completed{color:var(--hm-info)!important}.ag-stat-failed{color:var(--hm-danger)!important}.ag-filter-bar{display:flex;gap:var(--hm-space-2);margin-bottom:var(--hm-space-4);flex-wrap:wrap}.ag-filter-btn{padding:var(--hm-space-1) var(--hm-space-3);border-radius:var(--hm-radius-md);font-size:var(--hm-text-xs);color:var(--hm-text-muted);background:var(--hm-surface);border:1px solid var(--hm-border);cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:var(--hm-space-1)}.ag-filter-btn:hover{background:var(--hm-surface-hover);color:var(--hm-text)}.ag-filter-active{background:var(--hm-accent-dim)!important;border-color:var(--hm-accent)!important;color:var(--hm-gold)!important}.ag-filter-count{font-family:var(--hm-font-mono);font-weight:600;font-size:.65rem;background:var(--hm-border);padding:0 4px;border-radius:3px}.ag-filter-active .ag-filter-count{background:#d977064d}.ag-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,340px),1fr));gap:var(--hm-space-4)}.ag-card{background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);padding:var(--hm-space-4);display:flex;flex-direction:column;gap:var(--hm-space-2);transition:border-color .2s,box-shadow .2s}.ag-card:hover{border-color:var(--hm-border)}.ag-card-running{border-left:3px solid var(--hm-success)}.ag-card-completed{border-left:3px solid var(--hm-info)}.ag-card-failed{border-left:3px solid var(--hm-danger)}.ag-card-timeout{border-left:3px solid var(--hm-warning)}.ag-card-killed{border-left:3px solid var(--hm-text-dim)}.ag-card-header{display:flex;align-items:center;justify-content:space-between}.ag-card-title-row{display:flex;align-items:center;gap:var(--hm-space-2);min-width:0;flex-wrap:nowrap}.ag-card-label{font-weight:600;font-size:var(--hm-text-sm);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ag-card-id{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);color:var(--hm-text-dim);flex:none}.ag-status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.ag-dot-running{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success);animation:loop-pulse 2s ease-in-out infinite}.ag-dot-completed{background:var(--hm-info)}.ag-dot-failed{background:var(--hm-danger)}.ag-dot-timeout{background:var(--hm-warning)}.ag-dot-killed{background:var(--hm-text-dim)}.ag-status-badge{font-size:var(--hm-text-xs);font-weight:600;padding:1px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:.05em}.ag-badge-running{background:#22c55e26;color:var(--hm-success)}.ag-badge-completed{background:#3b82f626;color:var(--hm-info)}.ag-badge-failed{background:#ef444426;color:var(--hm-danger)}.ag-badge-timeout{background:#eab30826;color:var(--hm-warning)}.ag-badge-killed{background:#9ca3af26;color:var(--hm-text-dim)}.ag-card-goal{font-size:var(--hm-text-sm);color:var(--hm-text-muted);line-height:1.4;height:3.5rem;overflow:hidden;position:relative}.ag-card-goal:after{content:"";position:absolute;inset:auto 0 0 0;height:1.5rem;background:linear-gradient(transparent,var(--hm-surface));pointer-events:none}.ag-card-clickable:hover .ag-card-goal:after{background:linear-gradient(transparent,var(--hm-surface-hover))}.ag-progress-bar{height:3px;background:var(--hm-border);border-radius:2px;overflow:hidden}.ag-progress-fill{height:100%;background:var(--hm-success);border-radius:2px;transition:width .5s;animation:ag-progress-glow 2s ease-in-out infinite}@keyframes ag-progress-glow{0%,to{opacity:1}50%{opacity:.6}}.ag-card-stats{display:flex;gap:var(--hm-space-3)}.ag-card-stat{flex:1;text-align:center}.ag-card-stat-label{display:block;font-size:.6rem;color:var(--hm-text-dim);text-transform:uppercase;letter-spacing:.05em}.ag-card-stat-value{display:block;font-family:var(--hm-font-mono);font-size:var(--hm-text-sm);font-weight:600}.ag-card-tools{display:flex;flex-wrap:wrap;gap:4px}.ag-tool-chip{font-family:var(--hm-font-mono);font-size:.6rem;padding:1px 6px;background:var(--hm-gold-dim);color:var(--hm-text-muted);border-radius:3px;border:1px solid var(--hm-border-subtle)}.ag-card-policy{display:flex;flex-wrap:nowrap;gap:4px;min-width:0}.ag-policy-chip{font-family:var(--hm-font-mono);font-size:.62rem;padding:1px 7px;color:var(--hm-text-muted);background:var(--hm-surface-elevated);border:1px solid var(--hm-border);border-radius:3px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ag-policy-chip:not(.ag-policy-effort){flex:0 1 auto}.ag-policy-effort{flex:0 0 auto;color:var(--hm-accent);border-color:#c58b3247}.ag-card-body{display:flex;flex-direction:column;gap:var(--hm-space-2);text-align:left;border-radius:var(--hm-radius-md)}.ag-card-clickable{cursor:pointer;transition:background .12s ease,box-shadow .12s ease}.ag-card-clickable:hover{background:var(--hm-surface-hover)}.ag-card-clickable:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.modal-content.ag-detail-modal{width:min(1000px,94vw);max-width:min(1000px,94vw);max-height:88vh;display:flex;flex-direction:column;gap:var(--hm-space-3);overflow-y:auto}.ag-detail-header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-3)}.ag-detail-title-row{display:flex;align-items:center;flex-wrap:wrap;gap:var(--hm-space-2);min-width:0}.ag-detail-title{margin:0;font-size:1rem;font-weight:650;letter-spacing:-.015em;color:#f2f3f6}.ag-detail-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--hm-space-2) var(--hm-space-3)}.ag-detail-meta-item{display:flex;flex-direction:column;gap:1px;min-width:0}.ag-detail-meta-label{font-size:.6rem;color:var(--hm-text-dim);text-transform:uppercase;letter-spacing:.05em}.ag-detail-meta-value{font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);color:var(--hm-text);word-break:break-word}.ag-detail-source{margin:0;font-size:.66rem;color:var(--hm-text-dim);font-style:italic}.ag-detail-section{display:flex;flex-direction:column;gap:4px}.ag-detail-section-head{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-2)}.ag-detail-text{margin:0;padding:var(--hm-space-2);max-height:34vh;overflow:auto;font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);line-height:1.5;white-space:pre-wrap;word-break:break-word;color:var(--hm-text-muted);background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.ag-detail-pending{margin:0;font-size:var(--hm-text-xs);color:var(--hm-text-dim)}@media(max-width:640px){.modal-content.ag-detail-modal{width:96vw;max-width:96vw;max-height:92vh}.ag-detail-text{max-height:40vh}}.ag-card-meta{display:flex;justify-content:space-between;gap:var(--hm-space-2)}.ag-card-result,.ag-card-error{padding:var(--hm-space-2);border-radius:var(--hm-radius-md);background:var(--hm-bg);border:1px solid var(--hm-border)}.ag-result-label{font-size:.6rem;color:var(--hm-text-dim);text-transform:uppercase;margin-bottom:2px}.ag-result-text{font-size:var(--hm-text-xs);white-space:pre-wrap;word-break:break-word;max-height:6rem;overflow:hidden;color:var(--hm-text-muted)}.ag-card-error{border-color:#ef444433}.ag-card-actions{display:flex;justify-content:flex-end}.loop-status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}.loop-status-running{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success);animation:loop-pulse 2s ease-in-out infinite}.loop-status-error{background:var(--hm-danger);box-shadow:0 0 6px var(--hm-danger)}.loop-status-stopped{background:var(--hm-text-dim)}@keyframes loop-pulse{0%,to{opacity:1;box-shadow:0 0 6px var(--hm-success)}50%{opacity:.5;box-shadow:0 0 2px var(--hm-success)}}.loop-history{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4);max-height:200px;overflow-y:auto}.log-filter-field{flex:1 1 0%;min-width:0}.log-filter-row{display:flex;align-items:center;gap:.375rem;min-width:0}.log-filter-input{min-width:120px}@media(max-width:640px){.log-filter-field{flex:1 0 100%}.log-filter-input{min-width:0}}.loop-history-entry{font-size:var(--hm-text-sm);font-family:var(--hm-font-mono);color:var(--hm-text-muted);padding:var(--hm-space-1) 0;border-bottom:1px solid rgba(31,41,55,.3);white-space:pre-wrap;word-break:break-word}.loop-history-entry:last-child{border-bottom:none}.loop-card{display:flex;flex-direction:column;gap:var(--hm-space-3)}.loop-card-main{min-width:0;cursor:pointer;border-radius:var(--hm-radius-md);transition:background .12s ease,box-shadow .12s ease}.loop-card-main:hover{background:var(--hm-surface-hover)}.loop-card-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;margin-bottom:var(--hm-space-2);color:var(--hm-text);font-size:var(--hm-text-sm);line-height:1.45}.loop-card-main:focus-visible{outline:2px solid var(--hm-accent);outline-offset:3px}.loop-card-actions{display:flex;justify-content:flex-end;gap:var(--hm-space-2)}.loop-card-preview{display:flex;flex-direction:column;gap:3px;margin-top:var(--hm-space-3);padding:var(--hm-space-2);border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:var(--hm-bg);font-size:var(--hm-text-xs);color:var(--hm-text-dim);max-height:4.5rem;overflow:hidden;white-space:pre-wrap;word-break:break-word}.loop-detail-modal{gap:var(--hm-space-4)}.loop-detail-goal{max-height:22vh}.loop-detail-condition{max-height:14vh}.loop-detail-history-head{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--hm-space-3);border-top:1px solid var(--hm-border);padding-top:var(--hm-space-3)}.loop-detail-history-head h3{margin:0 0 2px;font-size:var(--hm-text-sm)}.loop-detail-notice{padding:var(--hm-space-2);color:var(--hm-text-muted);font-size:var(--hm-text-xs);background:#c58b3214;border:1px solid rgba(197,139,50,.2);border-radius:var(--hm-radius-md)}.loop-detail-iterations{display:flex;flex-direction:column;gap:var(--hm-space-3)}.loop-detail-iteration{display:flex;flex-direction:column;gap:var(--hm-space-2);padding:var(--hm-space-3);border:1px solid var(--hm-border);background:var(--hm-surface-elevated);border-radius:var(--hm-radius-lg)}.loop-detail-iteration-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-2)}.loop-detail-iteration-head>div{display:flex;flex-direction:column;min-width:0}.loop-detail-turn-meta{display:flex;flex-wrap:wrap;gap:5px var(--hm-space-3);font-family:var(--hm-font-mono);font-size:.65rem;color:var(--hm-text-dim)}.loop-detail-response{max-height:30vh}.loop-detail-tools{display:flex;flex-wrap:wrap;gap:4px}.loop-context-details{padding:var(--hm-space-3);border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg);background:var(--hm-bg)}.loop-context-details summary{cursor:pointer;color:var(--hm-text-muted);font-size:var(--hm-text-xs);font-weight:600}.loop-context-details[open] summary{margin-bottom:var(--hm-space-2)}.loop-context-list{display:flex;flex-direction:column;gap:var(--hm-space-2);margin-top:var(--hm-space-2)}.loop-context-entry{max-height:18vh}@media(max-width:640px){.loop-card-actions{justify-content:stretch}.loop-card-actions .btn{flex:1}.loop-detail-history-head{align-items:flex-start}.loop-detail-turn-meta{display:grid;grid-template-columns:1fr 1fr}}.process-output-preview{background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);padding:var(--hm-space-3) var(--hm-space-4);font-size:var(--hm-text-sm);font-family:var(--hm-font-mono);color:var(--hm-text-muted);max-height:120px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;margin:0}.hm-input:focus-visible,.hm-select:focus-visible,.chat-input:focus-visible,.toggle-switch input:focus-visible+.toggle-slider,.session-checkbox:focus-visible,.memory-checkbox:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.page-fade-in{animation:page-fade .2s ease-out}@keyframes page-fade{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.empty-state{display:flex;flex-direction:column;align-items:center;gap:var(--hm-space-3);padding:var(--hm-space-8) var(--hm-space-5);text-align:center}.empty-state-icon{font-size:1.75rem;opacity:.4}.empty-state-text{color:var(--hm-text-muted);font-size:var(--hm-text-md)}.empty-state-hint{color:var(--hm-text-dim);font-size:var(--hm-text-sm)}.cron-preset-btn{display:inline-block;padding:.125rem .5rem;background:var(--hm-gold-dim);border:1px solid rgba(217,119,6,.2);border-radius:var(--hm-radius-sm);font-size:var(--hm-text-xs);color:var(--hm-text-muted);cursor:pointer;transition:all var(--hm-transition-base)}.cron-preset-btn:hover{background:var(--hm-accent-dim);color:var(--hm-text);border-color:#d9770666}.hm-card-accent{border-top:2px solid var(--hm-accent)}.hm-divider{height:1px;background:linear-gradient(90deg,transparent,var(--hm-accent-dim),transparent);border:none;margin:var(--hm-space-5) 0}.hm-mono{font-family:var(--hm-font-mono)}.hm-section-title{font-size:var(--hm-text-sm);font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--hm-text-dim)}.ws-indicator{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;transition:background .3s,box-shadow .3s}.ws-connected{background:var(--hm-success);box-shadow:0 0 6px var(--hm-success)}.ws-disconnected{background:var(--hm-danger);box-shadow:0 0 6px var(--hm-danger)}.ws-connecting,.ws-reconnecting{background:var(--hm-warning);box-shadow:0 0 6px var(--hm-warning);animation:ws-pulse 1.2s ease-in-out infinite}@keyframes ws-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.75)}}.ws-toast{position:fixed;bottom:var(--hm-space-5);left:50%;transform:translate(-50%);z-index:9999;padding:var(--hm-space-3) var(--hm-space-5);border-radius:var(--hm-radius-lg);font-size:var(--hm-text-sm);font-weight:500;box-shadow:var(--hm-shadow-lg);pointer-events:none}.ws-toast-success{background:#22c55e26;border:1px solid rgba(34,197,94,.3);color:#4ade80}.ws-toast-warn{background:#eab30826;border:1px solid rgba(234,179,8,.3);color:#fbbf24}.ws-toast-info{background:#3b82f626;border:1px solid rgba(59,130,246,.3);color:#60a5fa}.ws-toast-enter-active{animation:ws-toast-in .3s ease-out}.ws-toast-leave-active{animation:ws-toast-out .25s ease-in forwards}@keyframes ws-toast-in{0%{opacity:0;transform:translate(-50%) translateY(12px)}to{opacity:1;transform:translate(-50%) translateY(0)}}@keyframes ws-toast-out{0%{opacity:1;transform:translate(-50%) translateY(0)}to{opacity:0;transform:translate(-50%) translateY(12px)}}.toast-stack{position:fixed;bottom:var(--hm-space-5);right:var(--hm-space-5);z-index:9999;display:flex;flex-direction:column;align-items:flex-end;gap:var(--hm-space-2);pointer-events:none}.toast-item{display:flex;align-items:center;gap:var(--hm-space-2);max-width:380px;padding:var(--hm-space-3) var(--hm-space-4);border-radius:var(--hm-radius-lg);font-size:var(--hm-text-sm);font-weight:500;box-shadow:var(--hm-shadow-lg);cursor:pointer;pointer-events:auto;background:var(--hm-surface);border:1px solid var(--hm-border);color:var(--hm-text)}.toast-item .toast-icon{flex-shrink:0}.toast-item .toast-text{overflow-wrap:anywhere}.toast-success{background:#22c55e1f;border-color:#22c55e4d;color:#4ade80}.toast-error{background:#ef44441f;border-color:#ef444459;color:#f87171}.toast-info{background:#3b82f61f;border-color:#3b82f64d;color:#60a5fa}.toast-enter-active{transition:all .25s ease-out}.toast-leave-active{transition:all .2s ease-in}.toast-enter-from,.toast-leave-to{opacity:0;transform:translateY(10px)}.modal-enter-active{transition:opacity .15s ease-out}.modal-leave-active{transition:opacity .12s ease-in}.modal-enter-from,.modal-leave-to{opacity:0}.confirm-dialog{max-width:420px}.palette-overlay{align-items:flex-start;padding-top:14vh}.palette{width:90%;max-width:520px;background:var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-xl);box-shadow:var(--hm-shadow-lg);overflow:hidden}.palette-input{width:100%;background:transparent;border:none;border-bottom:1px solid var(--hm-border);padding:var(--hm-space-4) var(--hm-space-5);font-size:var(--hm-text-base);color:var(--hm-text);outline:none}.palette-input::-moz-placeholder{color:var(--hm-text-muted)}.palette-input::placeholder{color:var(--hm-text-muted)}.palette-results{max-height:320px;overflow-y:auto;padding:var(--hm-space-2)}.palette-item{display:flex;align-items:center;gap:var(--hm-space-2);width:100%;text-align:left;padding:var(--hm-space-2) var(--hm-space-3);border-radius:var(--hm-radius-md);font-size:var(--hm-text-sm);color:var(--hm-text);background:transparent;border:none;cursor:pointer}.palette-item.selected{background:var(--hm-surface-hover)}.palette-item .palette-icon{flex-shrink:0}.palette-item .palette-group{color:var(--hm-text-muted)}.palette-empty{padding:var(--hm-space-4);text-align:center;color:var(--hm-text-muted);font-size:var(--hm-text-sm)}.palette-footer{display:flex;gap:var(--hm-space-3);padding:var(--hm-space-2) var(--hm-space-4);border-top:1px solid var(--hm-border);font-size:var(--hm-text-xs);color:var(--hm-text-muted)}.palette-footer kbd{padding:0 .3em;background:var(--hm-bg);border:1px solid var(--hm-border);border-radius:var(--hm-radius-sm)}.item-enter{animation:item-slide-in .25s ease-out}@keyframes item-slide-in{0%{opacity:0;transform:translate(-8px)}to{opacity:1;transform:translate(0)}}.dash-stat-value{transition:color var(--hm-transition-slow)}.badge{transition:transform var(--hm-transition-fast),background var(--hm-transition-base)}.badge-pop{animation:badge-pop .3s var(--hm-transition-spring)}@keyframes badge-pop{0%{transform:scale(1)}50%{transform:scale(1.3)}to{transform:scale(1)}}.action-pending{opacity:.6;pointer-events:none}.action-success{animation:action-flash-ok .5s ease-out}.action-error{animation:action-flash-err .5s ease-out}@keyframes action-flash-ok{0%{box-shadow:0 0 #22c55e66}to{box-shadow:0 0 0 0 transparent}}@keyframes action-flash-err{0%{box-shadow:0 0 #ef444466}to{box-shadow:0 0 0 0 transparent}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip-nav{position:absolute;top:-100%;left:var(--hm-space-5);z-index:1000;padding:var(--hm-space-3) var(--hm-space-5);background:var(--hm-accent);color:#fff;border-radius:var(--hm-radius-md);font-size:var(--hm-text-md);font-weight:600;text-decoration:none;transition:top var(--hm-transition-base)}.skip-nav:focus{top:var(--hm-space-3)}.ag-filter-btn:focus-visible,.kb-tree-header:focus-visible,.kb-chunk-item:focus-visible,.cron-preset-btn:focus-visible,.dash-health-item:focus-visible,[role=tab]:focus-visible,[role=button]:focus-visible,a:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}.spinner{animation:none;border-top-color:var(--hm-accent)}.page-fade-in{animation:none}.hm-sidebar,.dash-ring-progress{transition:none}.ws-connecting,.ws-reconnecting,.ws-toast{animation:none}}@media(forced-colors:active){.status-dot,.ag-status-dot,.dash-health-dot,.loop-status-dot{forced-color-adjust:none}.btn-primary{border:1px solid ButtonText}.hm-card{border:1px solid CanvasText}.nav-item.active{border-left:3px solid Highlight}}.hm-main .p-6{max-width:1600px}@media(min-width:1200px){:root{--hm-text-md: .9375rem}}@media(max-width:768px){.hm-main .p-6{padding:var(--hm-space-5)}.hm-table{font-size:var(--hm-text-sm)}.hm-table th,.hm-table td{padding:var(--hm-space-2) var(--hm-space-3)}.mobile-hide{display:none!important}.chat-bubble{max-width:90%}.btn{min-height:36px}.hm-input,.hm-select{min-height:36px;font-size:1rem}.toggle-switch{width:44px;height:24px}.toggle-slider:before{width:18px;height:18px}.toggle-switch input:checked+.toggle-slider:before{transform:translate(20px)}.modal-content{padding:var(--hm-space-5);width:95%}.config-key-col{width:auto!important;min-width:80px}.log-chip{padding:var(--hm-space-1) .625rem;font-size:var(--hm-text-sm)}.session-checkbox,.memory-checkbox{width:18px;height:18px}.cron-preset-btn{padding:var(--hm-space-1) .625rem;font-size:var(--hm-text-sm)}}@media(max-width:640px){.dash-hero-left{flex-basis:100%}}@media(max-width:480px){.chat-bubble-wrap{max-width:90%}.chat-avatar{width:24px;height:24px}.chat-input-row{flex-direction:column;align-items:stretch}.chat-input{width:100%}.chat-send-btn{width:100%;min-width:unset}.chat-suggestions{flex-direction:column}.chat-suggestion{width:100%}.stat-grid-mobile{grid-template-columns:repeat(2,1fr)!important}h1{font-size:1.125rem!important}.hm-main .p-6{padding:var(--hm-space-4)}}.fts-result-user{background:#11182780;border-color:#1f2937}.fts-result-assistant{background:#312e814d;border-color:#312e814d}.fts-result-summary{background:#451a0333;border-color:#78350f4d}.fts-result-fts{background:#022c2233;border-color:#064e3b4d}.fts-result-channel{background:#3b076433;border-color:#581c874d}.fts-result-default{background:#1118274d;border-color:#1f293780}.fts-highlight{background:#d977064d;color:var(--hm-gold);border-radius:2px;padding:0 2px}.health-card{padding:1rem;border-left:3px solid transparent;transition:border-color .2s}.health-card-ok{border-left-color:#22c55e}.health-card-degraded{border-left-color:#eab308}.health-card-down{border-left-color:#ef4444}.health-card-unconfigured{border-left-color:var(--hm-border)}.health-card-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.375rem}.health-card-icon{font-size:.875rem;flex-shrink:0}.health-card-name{font-size:.8125rem;font-weight:600;flex:1}.health-card-detail{font-size:.75rem;color:var(--hm-text-muted);line-height:1.4}.health-card-meta{margin-top:.5rem;padding-top:.5rem;border-top:1px solid var(--hm-border-subtle)}.health-meta-row{display:flex;align-items:center;gap:.5rem;padding:1px 0}.health-host-item{display:flex;align-items:center;gap:.375rem;padding:2px 0}.health-host-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.health-host-dot.dot-connected{background:#22c55e}.health-host-dot.dot-idle{background:#6b7280}.health-host-dot.dot-unknown{background:#374151}.res-bar-bg{height:8px;background:#1e293b;border-radius:4px;overflow:hidden}.res-bar-fill{height:100%;border-radius:4px;transition:width .4s ease;min-width:2px}.res-bar-blue{background:#3b82f6}.res-bar-purple{background:#a855f7}.res-bar-emerald{background:#10b981}.res-bar-amber{background:#f59e0b}.discord-gateway-card{display:grid;grid-template-columns:minmax(220px,.85fr) minmax(340px,1.35fr);align-items:center;gap:var(--hm-space-5)}.discord-gateway-summary{min-width:0;display:grid;gap:var(--hm-space-2)}.discord-gateway-heading{display:flex;align-items:center;gap:var(--hm-space-3)}.discord-credential-status{display:flex;align-items:center;flex-wrap:wrap;gap:var(--hm-space-2)}.discord-storage-note{color:var(--hm-text-dim);font-size:.62rem}.discord-gateway-controls{min-width:0;display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-3)}.discord-token-form{min-width:0;flex:1 1 420px;display:flex;gap:var(--hm-space-2)}.discord-token-form .hm-input{min-width:0;flex:1 1 auto}.discord-gateway-actions{flex:none;display:flex;gap:var(--hm-space-2)}.discord-global-card{display:grid;gap:var(--hm-space-4)}.discord-global-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-4)}.discord-global-heading p{margin-top:.2rem;color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.discord-global-toggles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-5);padding:var(--hm-space-3) 0;border-top:1px solid var(--hm-border-subtle);border-bottom:1px solid var(--hm-border-subtle)}.discord-global-toggles label{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.discord-global-rows{display:grid}.discord-global-row{min-width:0;display:grid;grid-template-columns:minmax(120px,.45fr) minmax(180px,1fr) minmax(260px,1.2fr);align-items:center;gap:var(--hm-space-4);padding:var(--hm-space-3) 0;border-bottom:1px solid var(--hm-border-subtle)}.discord-global-row:last-child{border-bottom:0}.discord-global-row-label{display:flex;align-items:center;gap:var(--hm-space-2)}.discord-global-row-label strong{color:var(--hm-text);font-size:var(--hm-text-xs)}.discord-help{position:relative;flex:none}.discord-help summary{width:18px;height:18px;display:inline-grid;place-items:center;border:1px solid var(--hm-border);border-radius:50%;color:var(--hm-text-dim);font-size:.62rem;cursor:pointer;list-style:none}.discord-help summary::-webkit-details-marker{display:none}.discord-help p{position:absolute;z-index:50;top:calc(100% + .35rem);left:0;width:min(280px,70vw);padding:var(--hm-space-3);color:var(--hm-text-muted);background:#0d121a;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);box-shadow:var(--hm-shadow-lg);font-size:.66rem;line-height:1.45}.discord-global-row .cfgc-chip-list,.discord-global-row .cfgc-chip-add,.discord-global-row .discord-user-combobox{min-width:0}.discord-global-row .cfgc-chip-add{flex-wrap:nowrap}.discord-global-row .cfgc-chip-add .hm-input{width:100%;min-width:0}.discord-user-combobox-with-add{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--hm-space-2)}.discord-user-combobox-with-add .discord-user-combobox-options{grid-column:1 / -1}.discord-global-footer{padding-top:var(--hm-space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);border-top:1px solid var(--hm-border-subtle)}.discord-global-footer span{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}@media(max-width:760px){.discord-gateway-card{grid-template-columns:minmax(0,1fr)}.discord-gateway-controls{align-items:stretch;flex-direction:column}.discord-token-form{flex-basis:auto}.discord-gateway-actions .btn{flex:1}.discord-global-heading,.discord-global-footer{align-items:stretch;flex-direction:column}.discord-global-toggles{grid-template-columns:minmax(0,1fr);gap:var(--hm-space-3)}.discord-global-row{grid-template-columns:minmax(0,1fr);gap:var(--hm-space-2)}.discord-global-footer .btn{width:100%}}.llm-advanced{margin-top:var(--hm-space-4);overflow:hidden;background:#04070b45;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.llm-advanced>summary{min-height:50px;padding:var(--hm-space-3) var(--hm-space-4);display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);color:var(--hm-text);cursor:pointer;list-style:none}.llm-advanced>summary::-webkit-details-marker{display:none}.llm-advanced>summary:after{content:"+";flex:none;color:var(--hm-accent);font-size:1rem}.llm-advanced[open]>summary:after{content:"−"}.llm-advanced>summary span{font-size:var(--hm-text-sm);font-weight:650}.llm-advanced>summary small{color:var(--hm-text-dim);font-size:var(--hm-text-xs);text-align:right}.llm-advanced-body{padding:var(--hm-space-4);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-4);border-top:1px solid var(--hm-border-subtle)}.llm-advanced-group{padding:var(--hm-space-4);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:var(--hm-space-3);background:#111720a6;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.llm-advanced-group>header{grid-column:1 / -1;display:grid;gap:.1rem}.llm-advanced-group>header strong{color:var(--hm-text);font-size:var(--hm-text-xs)}.llm-advanced-group>header span{color:var(--hm-text-dim);font-size:.62rem}.llm-advanced-group>label{min-width:0;display:grid;align-content:start;gap:.3rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.llm-advanced-group>label small{color:var(--hm-text-dim)}.llm-advanced-group.single{grid-template-columns:minmax(0,360px)}.llm-advanced-toggle{grid-column:1 / -1}.llm-toggle-control{min-height:38px;display:flex;align-items:center}.llm-advanced-footer{grid-column:1 / -1;display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4)}.llm-advanced-footer p{max-width:680px;color:var(--hm-text-dim);font-size:var(--hm-text-xs);line-height:1.5}.llm-advanced-state{grid-column:1 / -1;margin:0;color:var(--hm-text-dim);font-size:var(--hm-text-xs);line-height:1.45}.llm-advanced-state.pending{color:var(--hm-warning)}.llm-advanced.compact .llm-advanced-body{grid-template-columns:minmax(0,1fr) auto;align-items:end}.llm-advanced.compact .llm-advanced-footer{grid-column:auto}.llm-field-note{display:block;margin-top:.3rem;color:#9a8255;line-height:1.4}@media(max-width:760px){.llm-advanced>summary{align-items:flex-start}.llm-advanced>summary small{text-align:left}.llm-advanced-body,.llm-advanced.compact .llm-advanced-body,.llm-advanced-group{grid-template-columns:minmax(0,1fr)}.llm-advanced-footer,.llm-advanced.compact .llm-advanced-footer{grid-column:1;align-items:stretch;flex-direction:column}.llm-advanced-footer .btn{width:100%}}.config-center-page{--cfgc-rail-w: 218px;--cfgc-panel-border: rgba(49, 57, 71, .86);height:calc(100vh - var(--hm-topbar-h) - var(--hm-section-tabs-h));min-height:0!important;padding-bottom:0;display:flex;flex-direction:column;overflow:hidden}.cfgc-page-header{position:relative;z-index:16;flex:none;display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-6);margin-bottom:var(--hm-space-5)}.cfgc-eyebrow{margin-bottom:.28rem;color:var(--hm-accent);font-size:.625rem;font-weight:700;letter-spacing:.115em;line-height:1.2;text-transform:uppercase}.cfgc-page-summary{margin-top:.35rem;color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.cfgc-header-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-2);flex-wrap:wrap}.cfgc-header-actions .btn{display:inline-flex;align-items:center;gap:var(--hm-space-2)}.cfgc-loading{display:grid;gap:var(--hm-space-4)}.cfgc-loading-grid{display:grid;grid-template-columns:var(--cfgc-rail-w) minmax(0,1fr);gap:var(--hm-space-5)}.cfgc-loading-grid .skeleton:last-child{grid-column:2}.cfgc-health{flex:none;margin-bottom:var(--hm-space-5);padding:var(--hm-space-5);background:radial-gradient(circle at 100% 0,rgba(197,139,50,.09),transparent 30%),linear-gradient(150deg,#131821fa,#0c1017fa);border:1px solid var(--cfgc-panel-border);border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-sm)}.cfgc-health-heading{display:flex;align-items:center;gap:var(--hm-space-4);margin-bottom:var(--hm-space-4)}.cfgc-health-heading>div:first-child{flex:1;min-width:0}.cfgc-health-heading h2,.cfgc-category-panel-heading h2,.cfgc-empty h2,.cfgc-review-header h2{color:var(--hm-text);font-size:var(--hm-text-lg);font-weight:650;letter-spacing:-.015em}.cfgc-health-ok,.cfgc-unsaved-pill{display:inline-flex;align-items:center;gap:var(--hm-space-2);padding:.3rem .55rem;border-radius:var(--hm-radius-full);font-size:var(--hm-text-xs);font-weight:650;white-space:nowrap}.cfgc-health-ok{color:#83d6a6;background:#22c55e14;border:1px solid rgba(34,197,94,.19)}.cfgc-unsaved-pill{color:#ecc16f;background:#d977061a;border:1px solid rgba(217,119,6,.24)}.cfgc-listener-heading{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-bottom:.5rem}.cfgc-listener-state{display:inline-flex;align-items:center;flex:none;border:1px solid rgba(148,163,184,.22);border-radius:999px;padding:.2rem .55rem;color:#aeb9c9;background:#94a3b814;font-size:.72rem;font-weight:600}.cfgc-listener-state.state-active{color:#78cb98;border-color:#22c55e38;background:#22c55e14}.cfgc-listener-state.state-pending{color:#dfa858;border-color:#d977063d;background:#d9770617}.cfgc-listener-state.state-restricted{color:#78b9d6;border-color:#0ea5e938;background:#0ea5e914}.cfgc-listener-facts{display:grid;gap:.45rem;margin:0 0 .9rem;padding:.75rem;border:1px solid rgba(148,163,184,.12);border-radius:.6rem;background:#0f172a38}.cfgc-listener-facts>div{display:grid;grid-template-columns:minmax(8rem,.3fr) 1fr;gap:.75rem}.cfgc-listener-facts span{color:#8793a5;font-size:.75rem}.cfgc-listener-facts strong{color:#cbd5e1;font-size:.75rem;font-weight:500;overflow-wrap:anywhere}.cfgc-health-filters{display:grid;grid-template-columns:repeat(7,minmax(112px,1fr));gap:var(--hm-space-2);overflow-x:auto;scrollbar-width:thin}.cfgc-health-filter{min-width:0;min-height:56px;padding:.55rem .6rem;display:flex;align-items:center;gap:var(--hm-space-3);color:var(--hm-text-muted);background:#06090d61;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);text-align:left;cursor:pointer;transition:color var(--hm-transition-base),border-color var(--hm-transition-base),background var(--hm-transition-base)}.cfgc-health-filter:hover{color:var(--hm-text);border-color:var(--hm-border);background:#ffffff06}.cfgc-health-filter.active{color:var(--hm-text);border-color:#c58b326b;background:#c58b3213;box-shadow:inset 0 0 0 1px #c58b320d}.cfgc-health-icon{width:28px;height:28px;display:inline-grid;place-items:center;flex:none;color:#a5afbd;background:#ffffff0a;border-radius:var(--hm-radius-md)}.cfgc-health-icon.state-applied{color:#69c78e;background:#22c55e14}.cfgc-health-icon.state-pending_restart{color:#e1aa55;background:#d977061a}.cfgc-health-icon.state-dormant{color:#a792d8;background:#8b5cf617}.cfgc-health-icon.state-invalid{color:#e17878;background:#ef444417}.cfgc-health-icon.state-drift{color:#e1bd65;background:#eab30817}.cfgc-health-icon.state-unknown{color:#9aa6b6;background:#94a3b817}.cfgc-health-copy{min-width:0;display:grid;gap:.1rem}.cfgc-health-copy>span{font-size:var(--hm-text-xs);font-weight:650;line-height:1.25;overflow-wrap:anywhere;white-space:normal}.cfgc-health-copy small{color:var(--hm-text-dim);font-size:.625rem;white-space:nowrap}.cfgc-health-alert{display:flex;align-items:flex-start;gap:var(--hm-space-3);margin-top:var(--hm-space-3);padding:var(--hm-space-3) var(--hm-space-4);border-radius:var(--hm-radius-md);font-size:var(--hm-text-xs)}.cfgc-health-alert>.odin-icon{flex:none;margin-top:.1rem}.cfgc-health-alert>div{display:grid;gap:.1rem}.cfgc-health-alert>div span{color:var(--hm-text-muted)}.cfgc-health-alert.danger{color:#eb8a8a;background:#ef444413;border:1px solid rgba(239,68,68,.19)}.cfgc-health-alert.warning{color:#e3bb69;background:#eab30811;border:1px solid rgba(234,179,8,.17)}.cfgc-workspace{min-height:0;flex:1;display:grid;grid-template-columns:var(--cfgc-rail-w) minmax(0,1fr);align-items:stretch;gap:var(--hm-space-5);overflow:hidden}.cfgc-category-rail{position:relative;top:auto;height:100%;max-height:none;padding:var(--hm-space-3);overflow:hidden auto;background:#0c1017e6;border:1px solid var(--cfgc-panel-border);border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-sm)}.cfgc-rail-label{padding:var(--hm-space-2) var(--hm-space-3) var(--hm-space-3);color:var(--hm-text-dim);font-size:.625rem;font-weight:700;letter-spacing:.105em;text-transform:uppercase}.cfgc-category-scroll{display:grid;gap:.2rem}.cfgc-category{width:100%;min-width:0;padding:.55rem var(--hm-space-3);display:grid;grid-template-columns:26px minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-muted);background:transparent;border:1px solid transparent;border-radius:var(--hm-radius-md);text-align:left;cursor:pointer}.cfgc-category:hover{color:var(--hm-text);background:#ffffff07}.cfgc-category.active{color:#edd19a;background:#c58b3218;border-color:#c58b323d}.cfgc-category-icon{width:26px;height:26px;display:inline-grid;place-items:center;color:currentColor;background:#ffffff09;border-radius:var(--hm-radius-md)}.cfgc-category-copy{min-width:0;display:grid;gap:.05rem}.cfgc-category-copy>span{overflow:hidden;font-size:var(--hm-text-sm);font-weight:600;text-overflow:ellipsis;white-space:nowrap}.cfgc-category-copy small{color:var(--hm-text-dim);font-size:.6rem}.cfgc-category-counts{display:flex;align-items:center;justify-content:flex-end;gap:.18rem;flex-wrap:wrap;max-width:50px}.cfgc-category-counts>span,.cfgc-rail-key b{display:inline-grid;min-width:18px;height:18px;place-items:center;padding:0 .2rem;border-radius:4px;font-size:.55rem;font-weight:750}.cfgc-category-counts .modified,.cfgc-rail-key .modified{color:#edc171;background:#d977061f}.cfgc-category-counts .restart,.cfgc-rail-key .restart{color:#dfaa5d;background:#b4681b21}.cfgc-category-counts .invalid,.cfgc-rail-key .invalid{color:#e38282;background:#ef44441c}.cfgc-category-counts .dormant,.cfgc-rail-key .dormant{color:#b09adf;background:#8b5cf61f}.cfgc-rail-key{margin-top:var(--hm-space-3);padding:var(--hm-space-3) var(--hm-space-2) var(--hm-space-1);display:grid;grid-template-columns:1fr 1fr;gap:.32rem;color:var(--hm-text-dim);border-top:1px solid var(--hm-border-subtle);font-size:.56rem}.cfgc-rail-key span{display:flex;align-items:center;gap:.25rem}.cfgc-rail-key b{font-style:normal}.cfgc-main{min-width:0;min-height:0;height:100%;overflow-y:auto;padding-bottom:5rem}.cfgc-toolbar{position:sticky;top:0;z-index:12;margin:0 0 var(--hm-space-4);padding:var(--hm-space-3);display:flex;align-items:center;gap:var(--hm-space-3);background:#090c11f0;border:1px solid var(--cfgc-panel-border);border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-sm);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.cfgc-search{min-width:0;height:38px;display:flex;align-items:center;flex:1;gap:var(--hm-space-3);padding:0 .45rem 0 .75rem;color:var(--hm-text-dim);background:#04070b8c;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.cfgc-search:focus-within{color:var(--hm-accent);border-color:#c58b3275;box-shadow:0 0 0 3px #c58b3214}.cfgc-search input{min-width:0;height:100%;flex:1;color:var(--hm-text);background:transparent;border:0;outline:0;font-size:var(--hm-text-sm)}.cfgc-search input::-moz-placeholder{color:#5f6977}.cfgc-search input::placeholder{color:#5f6977}.cfgc-search .icon-btn{width:28px;height:28px}.cfgc-category-panel{display:grid;gap:var(--hm-space-3)}.cfgc-category-panel+.cfgc-category-panel{margin-top:var(--hm-space-6)}.cfgc-category-panel-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--hm-space-4);padding:0 var(--hm-space-1)}.cfgc-category-panel-heading>span{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.cfgc-empty{min-height:280px;display:grid;place-items:center;align-content:center;gap:var(--hm-space-3);color:var(--hm-text-dim);text-align:center}.cfgc-empty p{max-width:520px;color:var(--hm-text-muted);font-size:var(--hm-text-sm)}.cfgc-empty code{color:#c5a569}.cfgc-section{overflow:clip;background:linear-gradient(150deg,#121720f5,#0d1118fa);border:1px solid var(--cfgc-panel-border);border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-sm);transition:border-color var(--hm-transition-base),box-shadow var(--hm-transition-base)}.cfgc-section:hover{border-color:#394252}.cfgc-section.modified{border-color:#c58b3266;box-shadow:inset 3px 0 #c58b328c,var(--hm-shadow-sm)}.cfgc-section.editing{border-color:#c58b3294;box-shadow:inset 3px 0 var(--hm-accent),var(--hm-shadow-glow-sm)}.cfgc-section-header{width:100%;min-width:0;min-height:66px;padding:var(--hm-space-4) var(--hm-space-5);display:grid;grid-template-columns:20px minmax(150px,.65fr) minmax(220px,1.35fr) auto;align-items:center;gap:var(--hm-space-3);color:inherit;background:transparent;border:0;text-align:left;cursor:pointer}.cfgc-section-header:hover{background:#ffffff05}.cfgc-section-chevron{color:var(--hm-text-dim)}.cfgc-section-title{min-width:0;display:grid;gap:.12rem}.cfgc-section-title>span{overflow:hidden;color:var(--hm-text);font-size:var(--hm-text-sm);font-weight:650;text-overflow:ellipsis;white-space:nowrap}.cfgc-section-title small{overflow:hidden;color:#67717f;font-family:var(--hm-font-mono);font-size:.6rem;text-overflow:ellipsis;white-space:nowrap}.cfgc-section-summary{min-width:0;overflow:hidden;color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1.45;text-overflow:ellipsis;white-space:nowrap}.cfgc-section-badges{min-width:0;display:flex;align-items:center;justify-content:flex-end;gap:.3rem;flex-wrap:wrap}.cfgc-section-badges .badge{padding:.15rem .4rem;font-size:.56rem}.cfgc-badge-restart{color:#dba454;background:#d9770617;border:1px solid rgba(217,119,6,.22)}.cfgc-badge-dormant{color:#ac98d7;background:#8b5cf617;border:1px solid rgba(139,92,246,.2)}.cfgc-field-count{min-width:27px;height:22px;display:inline-grid;place-items:center;padding:0 .35rem;color:var(--hm-text-dim);background:#ffffff09;border-radius:var(--hm-radius-full);font-family:var(--hm-font-mono);font-size:.6rem}.cfgc-section-body{border-top:1px solid var(--hm-border-subtle)}.cfgc-section-actions{min-height:58px;padding:var(--hm-space-3) var(--hm-space-5);display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);background:#04070b47;border-bottom:1px solid var(--hm-border-subtle)}.cfgc-section-actions>div:first-child{min-width:0;display:grid;gap:.1rem}.cfgc-section-actions strong{color:var(--hm-text);font-size:var(--hm-text-xs);font-weight:650}.cfgc-section-actions span{overflow:hidden;color:var(--hm-text-dim);font-size:.625rem;text-overflow:ellipsis;white-space:nowrap}.cfgc-section-actions .btn{display:inline-flex;align-items:center;gap:var(--hm-space-2)}.cfgc-search-hits{padding:var(--hm-space-3) var(--hm-space-5);display:flex;align-items:center;gap:var(--hm-space-2);flex-wrap:wrap;color:var(--hm-text-dim);background:#c58b320a;border-bottom:1px solid rgba(197,139,50,.12);font-size:.625rem}.cfgc-search-hits button{padding:.22rem .4rem;color:#dbbd84;background:#c58b3214;border:1px solid rgba(197,139,50,.18);border-radius:5px;cursor:pointer}.cfgc-search-hits code{margin-left:.2rem;color:#7b8491}.cfgc-owner-card{margin:var(--hm-space-5);padding:var(--hm-space-5);display:grid;grid-template-columns:36px minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-4);background:#c58b320e;border:1px solid rgba(197,139,50,.18);border-radius:var(--hm-radius-md)}.cfgc-owner-card.compact{margin-bottom:0}.cfgc-owner-icon{width:36px;height:36px;display:inline-grid;place-items:center;color:#ddba78;background:#c58b321a;border-radius:var(--hm-radius-md)}.cfgc-owner-card strong{color:var(--hm-text);font-size:var(--hm-text-sm)}.cfgc-owner-card p{margin-top:.18rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1.5}.cfgc-owner-card .btn{display:inline-flex;align-items:center;gap:var(--hm-space-2)}.cfgc-fields{padding:0 var(--hm-space-5)}.cfgc-field{min-width:0;padding:var(--hm-space-5) 0;display:grid;grid-template-columns:minmax(210px,.95fr) minmax(240px,1.05fr);gap:var(--hm-space-6);scroll-margin-top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h) + 70px);border-bottom:1px solid var(--hm-border-subtle)}.cfgc-field:last-child{border-bottom:0}.cfgc-field.changed{margin-inline:calc(var(--hm-space-3) * -1);padding-inline:var(--hm-space-3);background:#c58b3209;border-radius:var(--hm-radius-md)}.cfgc-field.invalid{background:#ef444409}.cfgc-field-copy{min-width:0;padding-inline:var(--hm-space-4)}.cfgc-field-copy>label,.cfgc-field-copy>.cfgc-field-label{display:block;color:var(--hm-text);font-size:var(--hm-text-sm);font-weight:630}.cfgc-field-copy>code{display:block;margin-top:.2rem;overflow-wrap:anywhere;color:#687382;font-family:var(--hm-font-mono);font-size:.6rem}.cfgc-field-copy>p{max-width:620px;margin-top:.42rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1.52}.cfgc-field-meta{margin-top:var(--hm-space-3);display:flex;align-items:center;gap:var(--hm-space-2);flex-wrap:wrap;color:var(--hm-text-dim);font-size:.6rem}.cfgc-apply-pill{display:inline-flex;align-items:center;min-height:20px;padding:.14rem .42rem;color:#9ea8b5;background:#ffffff09;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-full);font-size:.58rem;font-weight:650;line-height:1.2}.cfgc-apply-pill.apply-live-read,.cfgc-apply-pill.apply-live-apply{color:#78cb98;background:#22c55e12;border-color:#22c55e2b}.cfgc-apply-pill.apply-live-for-new-work{color:#78b9d6;background:#0ea5e912;border-color:#0ea5e92e}.cfgc-apply-pill.apply-restart{color:#dfa858;background:#d9770614;border-color:#d9770630}.cfgc-apply-pill.apply-activation-required,.cfgc-apply-pill.apply-dormant{color:#af99df;background:#8b5cf614;border-color:#8b5cf62e}.cfgc-apply-pill.apply-legacy-control{color:#d8bd70;background:#eab30812;border-color:#eab3082e}.cfgc-sensitive{display:inline-flex;align-items:center;gap:.22rem;color:#b6a06f}.cfgc-apply-details{margin-top:var(--hm-space-3);display:grid;gap:var(--hm-space-2)}.cfgc-section-apply-details{margin:0 var(--hm-space-5) var(--hm-space-4);display:grid;gap:var(--hm-space-2)}.cfgc-apply-detail{padding:.55rem .65rem;background:#04070b42;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.cfgc-apply-detail-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-2)}.cfgc-apply-detail-heading strong{color:#aeb8c5;font-size:.64rem;font-weight:650}.cfgc-apply-detail>p{margin-top:.3rem;color:var(--hm-text-dim);font-size:.62rem;line-height:1.45}.cfgc-apply-detail>code{display:block;margin-top:.3rem;overflow-wrap:anywhere;color:#c8ad75;font-family:var(--hm-font-mono);font-size:.6rem}.cfgc-apply-detail.detail-restart{border-color:#d9770629}.cfgc-apply-detail.detail-activation{border-color:#8b5cf62b}.cfgc-field-control{min-width:0;display:grid;align-content:start}.cfgc-field-control .hm-input,.cfgc-field-control .hm-select{width:100%}.cfgc-field-control .hm-input[type=number]{max-width:250px}.cfgc-value{display:block;min-width:0;padding:.52rem .65rem;overflow:hidden;color:#c2c9d2;background:#04070b5e;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs);text-overflow:ellipsis;white-space:nowrap}.cfgc-value-block{max-height:160px;margin:0;padding:var(--hm-space-3);overflow:auto;color:#aeb7c3;background:#04070b5e;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);font-family:var(--hm-font-mono);font-size:.65rem;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.cfgc-boolean-control{min-height:38px;padding:.42rem .6rem;display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);color:var(--hm-text-muted);background:#04070b57;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);font-size:var(--hm-text-xs)}.cfgc-json-input{resize:vertical;line-height:1.5}.cfgc-expert-note{margin-top:.35rem;color:var(--hm-text-dim);font-size:.6rem}.cfgc-field-error{margin-top:.35rem;color:#e27c7c;font-size:var(--hm-text-xs)}.cfgc-write-only{padding:var(--hm-space-3);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.15rem var(--hm-space-4);background:#c58b320b;border:1px solid rgba(197,139,50,.15);border-radius:var(--hm-radius-md)}.cfgc-write-only>span{display:inline-flex;align-items:center;gap:var(--hm-space-2);color:#cdb57e;font-size:var(--hm-text-xs);font-weight:620}.cfgc-write-only>small{grid-column:1;color:var(--hm-text-dim);font-size:.6rem}.cfgc-write-only>button{grid-column:2;grid-row:1 / span 2}.cfgc-mobile-action-bar{display:none}.cfgc-review-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:80;display:flex;justify-content:flex-end;background:#000000a8;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.cfgc-review-tray{width:min(680px,94vw);height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto;color:var(--hm-text);background:#0c1017;border-left:1px solid #303846;box-shadow:-24px 0 70px #0000006b;outline:0;animation:cfgc-tray-in .18s ease-out}@keyframes cfgc-tray-in{0%{transform:translate(20px);opacity:.55}to{transform:translate(0);opacity:1}}.cfgc-review-header{padding:var(--hm-space-6);display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-4);border-bottom:1px solid var(--hm-border)}.cfgc-review-header p{margin-top:.32rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.cfgc-review-body{min-height:0;padding:var(--hm-space-5) var(--hm-space-6);overflow-y:auto}.cfgc-review-group{overflow:hidden;background:#121720bd;border:1px solid var(--hm-border);border-radius:var(--hm-radius-lg)}.cfgc-review-group+.cfgc-review-group{margin-top:var(--hm-space-4)}.cfgc-review-group>header{padding:var(--hm-space-3) var(--hm-space-4);display:flex;align-items:center;justify-content:space-between;background:#05080c5c;border-bottom:1px solid var(--hm-border-subtle)}.cfgc-review-group>header>span:last-child{min-width:23px;height:23px;display:inline-grid;place-items:center;color:var(--hm-text-dim);background:#ffffff0a;border-radius:var(--hm-radius-full);font-family:var(--hm-font-mono);font-size:.6rem}.cfgc-review-entry{padding:var(--hm-space-4);display:grid;grid-template-columns:minmax(160px,.7fr) minmax(200px,1.3fr);align-items:center;gap:var(--hm-space-4);border-bottom:1px solid var(--hm-border-subtle)}.cfgc-review-entry:last-child{border-bottom:0}.cfgc-review-entry>div:first-child{min-width:0;display:grid;gap:.17rem}.cfgc-review-entry strong{overflow:hidden;font-size:var(--hm-text-xs);font-weight:620;text-overflow:ellipsis;white-space:nowrap}.cfgc-review-entry code{overflow-wrap:anywhere;color:var(--hm-text-dim);font-size:.58rem}.cfgc-review-values{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:var(--hm-space-2)}.cfgc-review-values span{min-width:0;padding:.38rem .48rem;overflow:hidden;color:#9da7b4;background:#04070b66;border:1px solid var(--hm-border-subtle);border-radius:5px;font-family:var(--hm-font-mono);font-size:.6rem;text-overflow:ellipsis;white-space:nowrap}.cfgc-review-values span:last-child{color:#d5bd89;border-color:#c58b322b}.cfgc-review-values .odin-icon{color:var(--hm-text-dim)}.cfgc-review-footer{padding:var(--hm-space-5) var(--hm-space-6);display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--hm-space-3);background:#080b10f5;border-top:1px solid var(--hm-border)}.cfgc-review-footer>div{min-width:0;display:grid;gap:.1rem}.cfgc-review-footer strong{color:var(--hm-text);font-size:var(--hm-text-xs);font-weight:620}.cfgc-review-footer span{color:var(--hm-text-dim);font-size:.6rem}.config-center-page{max-width:1600px;margin-inline:auto}.cfgc-health-filters{grid-template-columns:repeat(auto-fit,minmax(128px,1fr))}.cfgc-restart-banner{flex:none;margin-bottom:var(--hm-space-5);padding:var(--hm-space-4) var(--hm-space-5);display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-4);color:#e6ba6c;background:#b4681b17;border:1px solid rgba(217,119,6,.28);border-radius:var(--hm-radius-lg)}.cfgc-restart-banner>div:nth-child(2){min-width:0;display:grid;gap:.15rem}.cfgc-restart-banner strong{color:#f0cf92;font-size:var(--hm-text-sm)}.cfgc-restart-banner span{color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.cfgc-restart-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-2);flex-wrap:wrap}.cfgc-restart-dialog{width:min(620px,calc(100vw - 2rem));margin:auto;padding:var(--hm-space-6);color:var(--hm-text);background:#0f141c;border:1px solid #3a4352;border-radius:var(--hm-radius-lg);box-shadow:var(--hm-shadow-lg)}.cfgc-restart-dialog h2{font-size:var(--hm-text-xl);font-weight:650}.cfgc-restart-dialog>p{margin-top:var(--hm-space-3);color:var(--hm-text-muted);font-size:var(--hm-text-sm);line-height:1.55}.cfgc-restart-dialog-actions{margin-top:var(--hm-space-5);display:flex;justify-content:flex-end;gap:var(--hm-space-2);flex-wrap:wrap}.cfgc-field-groups{display:grid;gap:var(--hm-space-4);padding:0 var(--hm-space-5) var(--hm-space-5)}.cfgc-field-group{overflow:clip;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.cfgc-field-group:not(.nested){border-color:transparent}.cfgc-field-group-header{padding:var(--hm-space-3) var(--hm-space-4);display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-4);background:#04070b59;border-bottom:1px solid var(--hm-border-subtle)}.cfgc-field-group-header>div{min-width:0;display:grid;gap:.18rem}.cfgc-field-group-header strong{color:var(--hm-text);font-size:var(--hm-text-sm)}.cfgc-field-group-header code{color:var(--hm-text-dim);font-size:.58rem}.cfgc-field-group-header p{color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1.45}.cfgc-field-group-header>span{flex:none;color:var(--hm-text-dim);font-size:.6rem}.cfgc-field-group .cfgc-fields{padding:0}.cfgc-image-models .cfgc-field-group-header{flex-wrap:wrap}.cfgc-image-models .cfgc-field-group-header>div{flex:1 1 240px}.cfgc-image-models .cfgc-field-group-header>.btn{flex:none}.cfgc-image-model-control{gap:var(--hm-space-2);padding-right:var(--hm-space-4)}.cfgc-image-model-effective{display:flex;align-items:center;gap:var(--hm-space-2);flex-wrap:wrap}.cfgc-image-model-effective>code{min-width:0;overflow-wrap:anywhere;color:var(--hm-text);font-size:var(--hm-text-xs)}.cfgc-image-model-status{padding:.14rem .42rem;color:var(--hm-text-muted);background:var(--hm-surface-elevated);border:1px solid var(--hm-border);border-radius:var(--hm-radius-full);font-size:.58rem;line-height:1.2}.cfgc-image-model-choice{width:-moz-fit-content;width:fit-content;max-width:100%;padding:var(--hm-space-1);display:inline-grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-1);background:var(--hm-bg-raised);border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.cfgc-image-model-choice>button{min-width:0;min-height:32px;padding:var(--hm-space-2) var(--hm-space-3);display:flex;align-items:center;justify-content:center;color:var(--hm-text-muted);background:transparent;border:0;border-radius:var(--hm-radius-sm);font-size:var(--hm-text-xs);text-align:center;cursor:pointer}.cfgc-image-model-choice>button:not(:disabled):hover{background:var(--hm-surface-hover);color:var(--hm-text)}.cfgc-image-model-choice>button[aria-pressed=true]{color:var(--hm-gold);background:var(--hm-accent-dim)}.cfgc-image-model-choice>button:focus-visible{outline:2px solid var(--hm-accent);outline-offset:1px}.cfgc-image-model-choice>button:disabled{opacity:.5;cursor:not-allowed}.cfgc-image-model-shipped,.cfgc-image-model-notice{color:var(--hm-text-muted);font-size:var(--hm-text-xs);overflow-wrap:anywhere}.cfgc-image-model-shipped code{color:var(--hm-text-muted)}.cfgc-image-model-notice{padding:var(--hm-space-3) var(--hm-space-4)}.cfgc-image-model-notice.cfgc-field-error{color:var(--hm-danger)}.cfgc-field{grid-template-columns:minmax(180px,4fr) minmax(190px,5fr);align-items:start}.cfgc-field-runtime-note{grid-column:1 / -1;min-width:0;margin-top:calc(var(--hm-space-2) * -1);padding:var(--hm-space-3);display:grid;gap:.25rem;background:#8b5cf60b;border:1px solid rgba(139,92,246,.16);border-radius:var(--hm-radius-md)}.cfgc-field-runtime-note strong{color:#b9a6df;font-size:.6rem;text-transform:uppercase;letter-spacing:.055em}.cfgc-field-runtime-note p{color:var(--hm-text-muted);font-size:.66rem;line-height:1.45}.cfgc-field-runtime-note .btn{margin-top:var(--hm-space-2);justify-self:start}.cfgc-runtime-summary-list{margin-top:var(--hm-space-3);display:grid;gap:var(--hm-space-2)}.cfgc-runtime-summary{padding:var(--hm-space-3);background:#04070b45;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.cfgc-runtime-summary strong{color:#b8c0cb;font-size:.62rem}.cfgc-runtime-summary p{margin-top:.25rem;color:var(--hm-text-muted);font-size:.66rem;line-height:1.45}.cfgc-group-apply-details{padding:0 var(--hm-space-4) var(--hm-space-4)}.cfgc-group-apply-details summary{color:var(--hm-text-dim);cursor:pointer;font-size:var(--hm-text-xs)}.cfgc-apply-detail-list{margin-top:var(--hm-space-3);display:grid;gap:var(--hm-space-2)}.discord-user-combobox{position:relative;width:100%}.discord-user-combobox>.hm-input{width:100%}.discord-user-combobox-options{position:absolute;z-index:60;top:calc(100% + .25rem);left:0;right:0;max-height:15rem;overflow-y:auto;background:#0d121a;border:1px solid #3b4555;border-radius:var(--hm-radius-md);box-shadow:var(--hm-shadow-lg)}.discord-user-combobox-option{width:100%;min-height:38px;padding:.45rem .65rem;display:flex;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-muted);background:transparent;border:0;cursor:pointer;font-size:var(--hm-text-xs);text-align:left}.discord-user-combobox-option:hover,.discord-user-combobox-option.active{color:var(--hm-text);background:#c58b3217}.discord-user-combobox-option img,.discord-user-combobox-avatar{width:22px;height:22px;flex:none;border-radius:var(--hm-radius-full)}.discord-user-combobox-avatar{display:inline-grid;place-items:center;color:var(--hm-text-dim);background:#293140;font-size:.6rem}.discord-user-combobox-name{min-width:0;overflow:hidden;color:inherit;text-overflow:ellipsis;white-space:nowrap}.discord-user-combobox-username{min-width:0;overflow:hidden;color:var(--hm-text-dim);text-overflow:ellipsis;white-space:nowrap}.discord-user-combobox-bot{margin-left:auto;padding:.08rem .25rem;color:#c4b5fd;background:#6366f133;border-radius:3px;font-size:.52rem}.discord-global-user-picker{display:block}.cfgc-chip-editor{display:grid;gap:var(--hm-space-3)}.cfgc-chip-list{min-height:32px;display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}.cfgc-chip{max-width:100%;padding:.25rem .35rem .25rem .55rem;display:inline-flex;align-items:center;gap:.35rem;overflow-wrap:anywhere;color:#d8c28e;background:#c58b3214;border:1px solid rgba(197,139,50,.2);border-radius:var(--hm-radius-full);font-family:var(--hm-font-mono);font-size:var(--hm-text-xs)}.cfgc-chip button{width:20px;height:20px;color:#a9956e;background:transparent;border:0;border-radius:50%;cursor:pointer}.cfgc-chip button:hover{color:#fff;background:#ffffff14}.cfgc-chip-empty{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.cfgc-chip-add{display:flex;align-items:center;gap:var(--hm-space-2);flex-wrap:wrap;color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.cfgc-chip-add .hm-input{width:min(170px,100%)}.cfgc-structured-summary{padding:var(--hm-space-3);display:grid;gap:.15rem;background:#04070b52;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.cfgc-structured-summary span{color:var(--hm-text);font-size:var(--hm-text-xs)}.cfgc-structured-summary small{color:var(--hm-text-dim);font-size:.62rem;line-height:1.4}@media(max-width:1180px){.config-center-page{--cfgc-rail-w: 190px}.cfgc-health-filters{grid-template-columns:repeat(7,minmax(106px,1fr))}.cfgc-category-copy small{display:none}.cfgc-section-header{grid-template-columns:20px minmax(0,1fr) auto}.cfgc-section-summary{grid-column:2;white-space:normal}.cfgc-section-badges{grid-column:3;grid-row:1 / span 2}.cfgc-field{grid-template-columns:minmax(180px,5fr) minmax(220px,7fr)}}@media(max-width:900px){.config-center-page{--cfgc-rail-w: 100%;height:auto;min-height:100%!important;overflow:visible}.cfgc-workspace{min-height:auto;flex:none;grid-template-columns:minmax(0,1fr);align-items:start;gap:var(--hm-space-3);overflow:visible}.cfgc-main{height:auto;overflow:visible;padding-bottom:5rem}.cfgc-category-rail{position:sticky;top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h));height:auto;z-index:13;max-height:none;padding:var(--hm-space-2);overflow:visible}.cfgc-rail-label,.cfgc-rail-key{display:none}.cfgc-category-scroll{display:flex;gap:var(--hm-space-2);overflow-x:auto;scroll-snap-type:x proximity}.cfgc-category{width:auto;min-width:150px;grid-template-columns:24px minmax(0,1fr) auto;flex:0 0 auto;scroll-snap-align:start}.cfgc-category-copy small{display:block}.cfgc-toolbar{top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h) + 59px)}.cfgc-section-header{grid-template-columns:20px minmax(120px,.8fr) minmax(150px,1.2fr) auto}.cfgc-field{grid-template-columns:minmax(180px,.85fr) minmax(210px,1.15fr);gap:var(--hm-space-4)}}@media(max-width:760px){.section-tabs-wrap{position:sticky;overflow:hidden}.section-tabs-wrap:after{content:"";position:absolute;top:0;right:0;bottom:0;width:34px;pointer-events:none;background:linear-gradient(90deg,transparent,rgba(9,12,17,.98));border-right:2px solid rgba(197,139,50,.28)}.config-center-page{padding-bottom:6.75rem!important}.cfgc-page-header{align-items:stretch}.cfgc-header-actions{flex:none}.cfgc-header-actions .cfgc-desktop-history,.cfgc-header-actions .btn-primary{display:none}.cfgc-health{margin-inline:-.15rem;padding:var(--hm-space-4)}.cfgc-health-heading{align-items:flex-start;flex-wrap:wrap}.cfgc-health-filters{grid-template-columns:repeat(7,122px);margin-inline:calc(var(--hm-space-4) * -1);padding-inline:var(--hm-space-4);padding-bottom:.2rem;scroll-snap-type:x proximity}.cfgc-health-filter{scroll-snap-align:start}.cfgc-category-rail{top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h));margin-inline:-.1rem}.cfgc-category-scroll{padding-right:1.5rem}.cfgc-category-scroll:after{content:"";flex:0 0 .1rem}.cfgc-toolbar{top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h) + 58px);padding:var(--hm-space-2)}.cfgc-toolbar>.btn{display:none}.cfgc-search input{font-size:1rem}.cfgc-category-panel-heading{align-items:center}.cfgc-section-header{min-height:72px;padding:var(--hm-space-4);grid-template-columns:18px minmax(0,1fr) auto;gap:var(--hm-space-2)}.cfgc-section-summary,.cfgc-section-badges .badge{display:none}.cfgc-section-actions{padding:var(--hm-space-3) var(--hm-space-4);align-items:stretch;flex-direction:column}.cfgc-section-actions>div:last-child{justify-content:flex-end}.cfgc-search-hits{padding-inline:var(--hm-space-4)}.cfgc-owner-card{margin:var(--hm-space-4);grid-template-columns:34px minmax(0,1fr);padding:var(--hm-space-4)}.cfgc-owner-card .btn{grid-column:1 / -1;width:100%;justify-content:center}.cfgc-field-groups{padding-inline:var(--hm-space-4)}.cfgc-fields{padding:0}.cfgc-field{grid-template-columns:minmax(0,1fr);gap:var(--hm-space-3);padding-block:var(--hm-space-4)}.cfgc-field-copy>p{line-height:1.45}.cfgc-image-model-control{padding-inline:var(--hm-space-4)}.cfgc-write-only{grid-template-columns:minmax(0,1fr)}.cfgc-write-only>small,.cfgc-write-only>button{grid-column:1;grid-row:auto}.cfgc-write-only>button{width:100%;margin-top:var(--hm-space-2)}.cfgc-restart-banner{grid-template-columns:auto minmax(0,1fr);padding:var(--hm-space-4)}.cfgc-restart-actions{grid-column:1 / -1;justify-content:stretch}.cfgc-restart-actions .btn{flex:1}.cfgc-restart-dialog-actions{display:grid}.cfgc-mobile-action-bar{position:fixed;z-index:55;left:0;right:0;bottom:0;min-height:68px;padding:var(--hm-space-3) max(var(--hm-space-4),env(safe-area-inset-right)) calc(var(--hm-space-3) + env(safe-area-inset-bottom)) max(var(--hm-space-4),env(safe-area-inset-left));display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-2);background:#090c11fa;border-top:1px solid #394252;box-shadow:0 -12px 32px #0006;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}.cfgc-mobile-action-bar .btn{min-width:0;padding-inline:.55rem}.cfgc-mobile-overflow{position:relative}.cfgc-mobile-overflow-menu{position:absolute;right:0;bottom:calc(100% + var(--hm-space-2));width:170px;padding:var(--hm-space-2);display:grid;gap:.15rem;background:#111720;border:1px solid #3a4352;border-radius:var(--hm-radius-md);box-shadow:var(--hm-shadow-lg)}.cfgc-mobile-overflow-menu button{min-height:38px;padding:0 var(--hm-space-3);display:flex;align-items:center;gap:var(--hm-space-2);color:var(--hm-text-muted);background:transparent;border:0;border-radius:5px;font-size:var(--hm-text-sm);text-align:left}.cfgc-mobile-overflow-menu button:hover:not(:disabled){color:var(--hm-text);background:#ffffff0a}.cfgc-mobile-overflow-menu button:disabled{opacity:.38}.cfgc-review-tray{width:100%;max-width:none}.cfgc-review-header{padding:var(--hm-space-5) var(--hm-space-4)}.cfgc-review-body{padding:var(--hm-space-4)}.cfgc-review-entry{grid-template-columns:minmax(0,1fr);gap:var(--hm-space-3)}.cfgc-review-footer{padding:var(--hm-space-4);grid-template-columns:1fr 1fr}.cfgc-review-footer>div{grid-column:1 / -1}}@media(max-width:420px){.cfgc-page-header{gap:var(--hm-space-3)}.cfgc-header-actions .btn{padding-inline:.65rem}.cfgc-page-summary{max-width:220px}.cfgc-mobile-action-bar{grid-template-columns:minmax(70px,1fr) minmax(90px,1fr) 36px}.cfgc-section-title>span{white-space:normal}.cfgc-review-values{grid-template-columns:minmax(0,1fr)}.cfgc-review-values .odin-icon{margin:-.1rem auto;transform:rotate(90deg)}}.llm-context-summary{min-height:58px;padding:.48rem .65rem;display:grid;grid-template-rows:auto 1fr;gap:.2rem;background:#070a0f61;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.llm-context-summary>span:first-child{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.llm-context-summary-value{min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:.2rem var(--hm-space-2)}.llm-context-summary-pair{display:inline-flex;flex-wrap:nowrap;align-items:center;gap:var(--hm-space-2)}.llm-context-summary strong{color:#e7d39d;font-size:var(--hm-text-sm);font-variant-numeric:tabular-nums;white-space:nowrap}.llm-context-summary strong small{color:var(--hm-text-dim);font-size:.58rem;font-weight:500}.llm-context-summary .llm-budget-provenance{flex:none}.llm-context-summary-value>small{color:var(--hm-text-dim);font-size:.58rem}.llm-context-budget-panel{grid-column:1 / -1;overflow:hidden;background:radial-gradient(circle at 100% 0,rgba(201,162,78,.075),transparent 32rem),#080c12c7;border:1px solid rgba(116,96,57,.58);border-radius:var(--hm-radius-md)}.llm-context-budget-heading{padding:var(--hm-space-4);display:flex;align-items:end;justify-content:space-between;gap:var(--hm-space-5);border-bottom:1px solid var(--hm-border-subtle)}.llm-context-budget-heading>div{min-width:0;display:grid;gap:.15rem}.llm-context-budget-heading strong{color:var(--hm-text);font-size:var(--hm-text-sm)}.llm-context-budget-heading>div>span{color:var(--hm-text-dim);font-size:var(--hm-text-xs)}.llm-utilization-field{min-width:176px;display:grid;gap:.35rem;color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.llm-utilization-input{display:grid;grid-template-columns:minmax(0,1fr) 2rem;align-items:center}.llm-utilization-input .hm-input{border-radius:var(--hm-radius-md) 0 0 var(--hm-radius-md);text-align:right;font-variant-numeric:tabular-nums}.llm-utilization-input small{align-self:stretch;display:grid;place-items:center;color:var(--hm-accent);background:#c9a24e14;border:1px solid var(--hm-border);border-left:0;border-radius:0 var(--hm-radius-md) var(--hm-radius-md) 0}.llm-context-budget-copy{margin:0;padding:var(--hm-space-3) var(--hm-space-4);color:var(--hm-text-dim);background:#070a0f8c;border-bottom:1px solid var(--hm-border-subtle);font-size:var(--hm-text-xs);line-height:1.55}.llm-context-budget-loading,.llm-context-budget-error{min-height:112px;padding:var(--hm-space-5);display:flex;align-items:center;justify-content:center;gap:var(--hm-space-3);color:var(--hm-text-muted);font-size:var(--hm-text-xs)}.llm-context-budget-loading .spinner{width:16px;height:16px}.llm-context-budget-error{color:var(--hm-danger)}.llm-context-budget-table-wrap{overflow-x:auto}.llm-context-budget-table{min-width:1120px;table-layout:fixed}.llm-context-budget-table th{padding:0 var(--hm-space-3);font-size:.58rem}.llm-context-budget-table td{padding:var(--hm-space-3);font-size:var(--hm-text-xs)}.llm-context-budget-table th:nth-child(1){width:15%}.llm-context-budget-table th:nth-child(2){width:11%}.llm-context-budget-table th:nth-child(3){width:19%}.llm-context-budget-table th:nth-child(4){width:12%}.llm-context-budget-table th:nth-child(5){width:13%}.llm-context-budget-table th:nth-child(6){width:14%}.llm-context-budget-table th:nth-child(7){width:16%}.llm-context-budget-table tbody tr.has-clamp td{background:#d3943109}.llm-context-budget-table code,.llm-clamp-card code{color:#e4e8ef;font-size:.72rem;letter-spacing:-.015em}.llm-context-budget-table td>small{display:block;margin-top:.2rem;color:var(--hm-text-dim);font-size:.58rem;line-height:1.3}.llm-budget-value{color:var(--hm-text-muted);font-variant-numeric:tabular-nums}.llm-budget-effective{color:#e7d39d;font-weight:650}.llm-budget-override{display:flex;align-items:center;gap:var(--hm-space-2)}.llm-budget-override .hm-input{min-width:0;height:34px;padding:0 var(--hm-space-2);font-size:var(--hm-text-xs);font-variant-numeric:tabular-nums}.llm-budget-reset{padding:0;color:var(--hm-text-dim);border:0;background:transparent;font-size:.62rem;cursor:pointer}.llm-budget-reset:hover{color:var(--hm-accent)}.llm-budget-warning{color:var(--hm-warning)!important}.llm-budget-pending{display:inline-flex;margin-top:.3rem;padding:.12rem .42rem;color:var(--hm-warning);background:#d977061a;border:1px solid rgba(217,119,6,.23);border-radius:var(--hm-radius-full);font-size:.56rem;font-weight:650}.llm-budget-provenance{display:inline-flex;align-items:center;min-height:22px;padding:.16rem .48rem;border:1px solid transparent;border-radius:var(--hm-radius-full);font-size:.58rem;font-weight:650;white-space:nowrap}.llm-budget-provenance.is-built-in{color:#94a0b0;background:#7e889817;border-color:#7e88982e}.llm-budget-provenance.is-override{color:#ccb474;background:#c9a24e17;border-color:#c9a24e33}.llm-budget-provenance.is-clamp{color:var(--hm-warning);background:#d977061a;border-color:#d977063b}.llm-budget-density{display:inline-flex;align-items:center;min-height:22px;margin-top:.18rem;padding:.16rem .48rem;border:1px solid rgba(122,162,201,.2);border-radius:var(--hm-radius-full);color:#8fb3d1;background:#7aa2c917;font-size:.58rem;font-weight:650;white-space:nowrap}.llm-clamp-list{padding:var(--hm-space-4);border-top:1px solid rgba(217,119,6,.2)}.llm-clamp-list-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);margin-bottom:var(--hm-space-3)}.llm-clamp-list-heading>div{display:grid;gap:.1rem}.llm-clamp-list-heading strong{color:var(--hm-warning);font-size:var(--hm-text-xs)}.llm-clamp-list-heading>div>span{color:var(--hm-text-dim);font-size:.6rem}.llm-clamp-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-3)}.llm-clamp-card{min-width:0;padding:var(--hm-space-3);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.25rem var(--hm-space-3);background:#d977060b;border:1px solid rgba(217,119,6,.16);border-radius:var(--hm-radius-md)}.llm-clamp-card>div{min-width:0;display:flex;align-items:baseline;gap:var(--hm-space-2);overflow:hidden}.llm-clamp-card>div span{color:var(--hm-warning);font-size:var(--hm-text-xs);font-variant-numeric:tabular-nums;white-space:nowrap}.llm-clamp-card p{grid-column:1;margin:0;color:var(--hm-text-dim);font-size:.58rem}.llm-clamp-card .btn{grid-column:2;grid-row:1 / span 2}@media(max-width:1180px){.llm-context-budget-table{min-width:900px}.llm-clamp-grid{grid-template-columns:minmax(0,1fr)}}@media(max-width:900px){.llm-context-budget-panel+.llm-advanced-footer{align-items:stretch;flex-direction:column}.llm-context-budget-panel+.llm-advanced-footer .btn{width:100%;justify-content:center}.llm-context-budget-heading{align-items:stretch;flex-direction:column}.llm-utilization-field{min-width:0}.llm-context-budget-table-wrap{overflow:visible}.llm-context-budget-table,.llm-context-budget-table tbody,.llm-context-budget-table tr,.llm-context-budget-table td{display:block;width:100%;min-width:0}.llm-context-budget-table thead{display:none}.llm-context-budget-table tbody{padding:var(--hm-space-3);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-3)}.llm-context-budget-table tr{margin-bottom:0;overflow:hidden;background:#111720b3;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.llm-context-budget-table td{min-height:44px;padding:var(--hm-space-2) var(--hm-space-3);display:grid;grid-template-columns:minmax(104px,.85fr) minmax(0,1.15fr);align-items:center;gap:var(--hm-space-3);text-align:right}.llm-context-budget-table td:before{content:attr(data-label);color:var(--hm-text-dim);font-size:.58rem;font-weight:650;letter-spacing:.045em;text-align:left;text-transform:uppercase}.llm-context-budget-table td:first-child{min-height:48px;background:#070a0f73}.llm-context-budget-table td>small{margin:-.1rem 0 0;grid-column:2}.llm-budget-override{justify-content:flex-end}.llm-budget-override .hm-input{width:150px}.llm-clamp-list-heading{align-items:flex-start}.llm-clamp-card{grid-template-columns:minmax(0,1fr)}.llm-clamp-card p,.llm-clamp-card .btn{grid-column:1;grid-row:auto}.llm-clamp-card .btn{width:100%;justify-content:center;margin-top:var(--hm-space-2)}}@media(max-width:600px){.llm-context-budget-table tbody{grid-template-columns:minmax(0,1fr)}.llm-budget-override{gap:.35rem}.llm-budget-override .hm-input{width:min(105px,100%)}}.mcp-page{width:min(100%,1440px);display:grid;gap:var(--hm-space-5)}.mcp-page-header,.mcp-section-heading,.mcp-server-header,.mcp-editor-header,.mcp-editor-footer{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--hm-space-5)}.mcp-eyebrow{margin-bottom:.2rem;color:var(--hm-accent-hover);font-size:.63rem;font-weight:650;letter-spacing:.1em;text-transform:uppercase}.mcp-lede{max-width:72ch;margin-top:.3rem;color:var(--hm-text-dim);font-size:.75rem;line-height:1.55}.mcp-header-actions,.mcp-server-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-2);flex-wrap:wrap}.mcp-header-actions .btn,.mcp-server-actions .btn{white-space:nowrap}.mcp-loading{display:grid;gap:var(--hm-space-4)}.mcp-control-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--hm-space-5);padding:var(--hm-space-5);background:linear-gradient(135deg,rgba(197,139,50,.07),transparent 34%),var(--hm-surface);border:1px solid rgba(197,139,50,.26);border-radius:var(--hm-radius-xl);box-shadow:var(--hm-shadow-sm)}.mcp-control-main{display:flex;align-items:center;gap:var(--hm-space-4);min-width:0}.mcp-control-icon{width:42px;height:42px;display:grid;place-items:center;flex:none;color:var(--hm-accent-hover);background:var(--hm-accent-dim);border:1px solid rgba(197,139,50,.24);border-radius:11px}.mcp-control-title-row{display:flex;align-items:center;gap:var(--hm-space-3);flex-wrap:wrap}.mcp-control-title-row h2{color:var(--hm-text);font-size:.92rem;font-weight:650}.mcp-control-main p{margin-top:.2rem;color:var(--hm-text-dim);font-size:.71rem}.mcp-master-state,.mcp-state-pill,.mcp-tool-state{display:inline-flex;align-items:center;min-height:20px;padding:0 .48rem;border:1px solid transparent;border-radius:999px;font-size:.62rem;font-weight:650;line-height:1;letter-spacing:.025em}.mcp-master-state.enabled{color:#8cdbb8;background:#3db9831c;border-color:#3db9833d}.mcp-master-state.disabled{color:#9aa4b3;background:#9aa4b314;border-color:#9aa4b32e}.mcp-master-toggle{display:grid;place-items:center;min-width:44px;min-height:42px}.mcp-master-toggle .toggle-switch{transform:scale(1.16)}.mcp-publication-limits{grid-column:1 / -1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr)) auto;align-items:start;gap:var(--hm-space-3)}.mcp-publication-limits>.btn{align-self:center;white-space:nowrap}.mcp-limits-help,.mcp-limits-error{grid-column:1 / -1;font-size:.66rem;line-height:1.5}.mcp-limits-help{color:var(--hm-text-dim)}.mcp-limits-error{color:#f2b1b1}.mcp-aggregate{grid-column:1 / -1;display:grid;grid-template-columns:repeat(4,minmax(90px,1fr));overflow:hidden;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-lg);background:#0305083d}.mcp-aggregate>div{min-height:56px;padding:.65rem .8rem;display:grid;align-content:center;border-right:1px solid var(--hm-border-subtle)}.mcp-aggregate>div:last-child{border-right:0}.mcp-aggregate strong{color:var(--hm-text);font-family:var(--hm-font-mono);font-size:1rem;font-weight:600}.mcp-aggregate span{color:var(--hm-text-dim);font-size:.62rem}.mcp-inline-error{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-3);padding:.65rem .8rem;color:#f2b1b1;background:#e0646414;border:1px solid rgba(224,100,100,.22);border-radius:var(--hm-radius-md);font-size:.7rem}.mcp-inline-error button{padding:.2rem;color:inherit;background:transparent;border:0;border-radius:4px;cursor:pointer}.mcp-server-section{display:grid;gap:var(--hm-space-4)}.mcp-section-heading{align-items:end;padding:0 .1rem}.mcp-section-heading h2{color:var(--hm-text);font-size:.88rem;font-weight:650}.mcp-section-heading p{max-width:76ch;margin-top:.2rem;color:var(--hm-text-dim);font-size:.68rem}.mcp-section-heading>span{color:var(--hm-text-dim);font-family:var(--hm-font-mono);font-size:.65rem;white-space:nowrap}.mcp-empty{display:grid;place-items:center;align-content:center;gap:var(--hm-space-3);text-align:center}.mcp-empty h3{color:var(--hm-text);font-size:.84rem}.mcp-empty p{max-width:54ch;color:var(--hm-text-dim);font-size:.7rem}.mcp-server-card{position:relative;overflow:hidden;background:linear-gradient(145deg,rgba(255,255,255,.012),transparent 48%),var(--hm-surface);border:1px solid var(--hm-border);border-radius:var(--hm-radius-xl);box-shadow:var(--hm-shadow-sm)}.mcp-server-card:before{content:"";position:absolute;inset:0 auto 0 0;width:2px;background:#566170}.mcp-server-card.state-connected:before{background:var(--hm-success)}.mcp-server-card.state-connecting:before{background:var(--hm-info)}.mcp-server-card.state-stale:before,.mcp-server-card.state-blocked:before{background:var(--hm-warning)}.mcp-server-card.state-error:before{background:var(--hm-danger)}.mcp-server-header{align-items:center;padding:var(--hm-space-5);border-bottom:1px solid var(--hm-border-subtle)}.mcp-card-switch{display:flex;align-items:center;gap:var(--hm-space-3);margin-left:auto;padding-right:var(--hm-space-4)}.mcp-card-switch-copy{display:grid;gap:2px;text-align:right}.mcp-card-switch-copy strong{color:var(--hm-text-muted);font-size:.63rem;font-weight:600}.mcp-card-switch-copy small{color:var(--hm-text-dim);font-size:.56rem;line-height:1.3;max-width:24ch}.mcp-card-switch .toggle-switch[aria-busy=true]{opacity:.55;pointer-events:none}.mcp-server-card.mcp-card-off{background:var(--hm-surface);border-color:var(--hm-border-subtle);box-shadow:none}.mcp-server-card.mcp-card-off .mcp-server-header{background:#ffffff04}.mcp-current-endpoint{display:grid;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-4);background:#ffffff05;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.mcp-current-endpoint>span{color:var(--hm-text-muted);font-size:.6rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase}.mcp-current-endpoint code{font-size:.66rem;color:var(--hm-text);word-break:break-all;background:transparent;padding:0}.mcp-current-endpoint small{color:var(--hm-text-dim);font-size:.59rem;line-height:1.4}.mcp-server-identity{display:flex;align-items:center;gap:var(--hm-space-4);min-width:0}.mcp-state-indicator{width:10px;height:10px;flex:none;border:2px solid currentColor;border-radius:50%;color:#687384;background:#68738438}.mcp-state-indicator.connected{color:var(--hm-success);background:var(--hm-success);box-shadow:0 0 8px #3db98361}.mcp-state-indicator.connecting{color:var(--hm-info);background:transparent;border-top-color:transparent;animation:spin .8s linear infinite}.mcp-state-indicator.stale,.mcp-state-indicator.blocked{color:var(--hm-warning);background:var(--hm-warning)}.mcp-state-indicator.error{color:var(--hm-danger);background:var(--hm-danger)}.mcp-server-title-row{display:flex;align-items:center;gap:var(--hm-space-3);flex-wrap:wrap}.mcp-server-title-row h3{color:var(--hm-text);font-family:var(--hm-font-mono);font-size:.83rem;font-weight:600;overflow-wrap:anywhere}.mcp-state-pill.connected{color:#8cdbb8;background:#3db9831a;border-color:#3db9833b}.mcp-state-pill.connecting{color:#a8c0ef;background:#668fd71a;border-color:#668fd73b}.mcp-state-pill.stale,.mcp-state-pill.blocked{color:#edc36d;background:#d8a3421a;border-color:#d8a3423d}.mcp-state-pill.error{color:#f0a3a3;background:#e064641a;border-color:#e064643b}.mcp-state-pill.disabled{color:#a1aab7;background:#9aa4b312;border-color:#9aa4b329}.mcp-server-subtitle{display:flex;align-items:center;gap:.8rem;margin-top:.25rem;color:var(--hm-text-dim);font-size:.63rem;flex-wrap:wrap}.mcp-server-subtitle span{display:inline-flex;align-items:center;gap:.28rem}.mcp-server-actions .icon-btn.danger{color:#d98e8e}.mcp-server-actions .icon-btn.danger:hover{color:#ffd0d0;background:#e064641f}.mcp-server-body{display:grid;gap:var(--hm-space-4);padding:var(--hm-space-5)}.mcp-metrics{display:grid;grid-template-columns:repeat(4,minmax(76px,1fr));gap:var(--hm-space-3)}.mcp-metrics>div{min-height:48px;padding:.55rem .7rem;display:grid;align-content:center;background:#05080c42;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.mcp-metrics strong{color:#d9dee6;font-family:var(--hm-font-mono);font-size:.78rem;font-weight:600}.mcp-metrics span{color:var(--hm-text-dim);font-size:.6rem}.mcp-server-message{display:grid;grid-template-columns:auto minmax(0,1fr);gap:var(--hm-space-3);padding:.65rem .75rem;border-radius:var(--hm-radius-md);font-size:.67rem}.mcp-server-message.blocked{color:#e9c67f;background:#d8a34212;border:1px solid rgba(216,163,66,.18)}.mcp-server-message.error{color:#efaaaa;background:#e0646412;border:1px solid rgba(224,100,100,.18)}.mcp-server-message strong{display:block;margin-bottom:.12rem;font-size:.64rem}.mcp-server-message p{color:inherit;opacity:.82;overflow-wrap:anywhere;white-space:pre-wrap}.mcp-stderr{overflow:hidden;background:#080a0e;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.mcp-stderr summary{padding:.55rem .75rem;color:var(--hm-text-dim);font-size:.63rem;cursor:pointer}.mcp-stderr pre{max-height:180px;overflow:auto;padding:.7rem;color:#9da8b8;background:#05070a;border-top:1px solid var(--hm-border-subtle);font-family:var(--hm-font-mono);font-size:.61rem;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.mcp-tool-disclosure{overflow:hidden;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-lg)}.mcp-tools-toggle{width:100%;min-height:40px;padding:.55rem .75rem;display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-4);color:var(--hm-text-muted);background:#06080c2e;border:0;cursor:pointer;font-size:.68rem}.mcp-tools-toggle:hover{color:var(--hm-text);background:#ffffff06}.mcp-tools-toggle>span{display:inline-flex;align-items:center;gap:var(--hm-space-2)}.mcp-tools-toggle>span:last-child{color:var(--hm-text-dim);font-size:.61rem}.mcp-tools-panel{display:grid;gap:var(--hm-space-3);padding:var(--hm-space-4);border-top:1px solid var(--hm-border-subtle)}.mcp-tool-search{min-height:36px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-3);padding:0 .65rem;color:var(--hm-text-dim);background:#090c11;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.mcp-tool-search:focus-within{border-color:var(--hm-accent);box-shadow:0 0 0 3px var(--hm-accent-glow)}.mcp-tool-search input{width:100%;min-width:0;padding:.45rem 0;color:var(--hm-text);background:transparent;border:0;outline:0;font-size:.68rem}.mcp-tool-search>span{font-size:.6rem}.mcp-tool-list{display:grid;gap:var(--hm-space-2)}.mcp-tool-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.25rem var(--hm-space-4);padding:.68rem .75rem;background:#04070b47;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md)}.mcp-tool-names{display:flex;align-items:center;gap:var(--hm-space-3);min-width:0;flex-wrap:wrap}.mcp-tool-names>span{display:inline-flex;align-items:center;gap:var(--hm-space-2);min-width:0}.mcp-tool-names code{color:#c7ced9;font-size:.64rem;overflow-wrap:anywhere}.mcp-not-published{color:var(--hm-text-dim);font-size:.6rem}.mcp-tool-state.published{color:#8cdbb8;background:#3db98314;border-color:#3db98330}.mcp-tool-state.excluded{color:#e9c67f;background:#d8a34214;border-color:#d8a34230}.mcp-tool-state.unpublished{color:#a7afbb;background:#9aa4b312;border-color:#9aa4b329}.mcp-tool-row>p{grid-column:1 / -1;color:var(--hm-text-dim);font-size:.63rem;line-height:1.45;overflow-wrap:anywhere}.mcp-tool-row>p.mcp-exclusion{color:#c8ab75}.mcp-tool-empty,.mcp-tool-error{padding:1rem;text-align:center;color:var(--hm-text-dim);font-size:.66rem}.mcp-tool-error{color:#efaaaa}.mcp-modal-overlay{padding:var(--hm-space-5)}.modal-content.mcp-editor{width:min(96vw,920px);max-width:920px;max-height:92vh;padding:0;overflow:hidden;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto}.mcp-editor-header{padding:var(--hm-space-5) var(--hm-space-6);border-bottom:1px solid var(--hm-border-subtle)}.mcp-editor-header h2{font-size:1rem}.mcp-editor-header p{max-width:68ch;margin-top:.25rem;color:var(--hm-text-dim);font-size:.66rem}.mcp-form-error{margin:var(--hm-space-4) var(--hm-space-6) 0;padding:.65rem .75rem;display:flex;align-items:flex-start;gap:var(--hm-space-3);color:#efaaaa;background:#e0646414;border:1px solid rgba(224,100,100,.2);border-radius:var(--hm-radius-md);font-size:.68rem}.mcp-editor-nav{display:flex;gap:var(--hm-space-2);padding:var(--hm-space-3) var(--hm-space-6);overflow-x:auto;border-bottom:1px solid var(--hm-border-subtle)}.mcp-editor-nav button{padding:.35rem .6rem;color:var(--hm-text-dim);background:transparent;border:0;border-radius:var(--hm-radius-sm);font:inherit;font-size:.63rem;cursor:pointer;white-space:nowrap}.mcp-editor-nav button:hover{color:var(--hm-text);background:#ffffff0a}.mcp-editor-nav button:focus-visible{color:var(--hm-text);outline:2px solid var(--hm-accent);outline-offset:1px}.mcp-editor-groups{overflow-y:auto;padding:0 var(--hm-space-6)}.mcp-form-group{padding:var(--hm-space-6) 0;border-bottom:1px solid var(--hm-border-subtle);scroll-margin-top:var(--hm-space-4)}.mcp-form-group:last-child{border-bottom:0}.mcp-form-group>header{display:grid;grid-template-columns:30px minmax(0,1fr);gap:var(--hm-space-3);margin-bottom:var(--hm-space-4)}.mcp-form-group>header>span{width:28px;height:28px;display:grid;place-items:center;color:var(--hm-accent-hover);background:var(--hm-accent-dim);border:1px solid rgba(197,139,50,.2);border-radius:50%;font-family:var(--hm-font-mono);font-size:.59rem}.mcp-form-group h3{color:var(--hm-text);font-size:.79rem;font-weight:650}.mcp-form-group header p{margin-top:.12rem;color:var(--hm-text-dim);font-size:.63rem}.mcp-form-grid{display:grid;gap:var(--hm-space-4)}.mcp-form-grid.two{grid-template-columns:repeat(2,minmax(0,1fr))}.mcp-field{display:grid;align-content:start;gap:var(--hm-space-2);color:var(--hm-text-muted);font-size:.66rem}.mcp-field>span{min-height:1rem;display:flex;align-items:baseline;gap:var(--hm-space-2)}.mcp-field>span small{color:var(--hm-text-dim);font-size:.58rem;font-weight:400}.mcp-field>span .mcp-configured-indicator{color:#81bea3}.mcp-field>small{color:var(--hm-text-dim);font-size:.59rem;line-height:1.4}.mcp-field textarea.hm-input{min-height:76px}.mcp-switch-field{display:grid;align-content:start;gap:var(--hm-space-2);color:var(--hm-text-muted);font-size:.66rem}.mcp-switch-line{min-height:38px;padding:0 var(--hm-space-4);display:flex;align-items:center;justify-content:space-between;color:var(--hm-text);background:#090c11;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md)}.mcp-switch-field>small{color:var(--hm-text-dim);font-size:.59rem}.mcp-transport-choice{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-3);margin-bottom:var(--hm-space-4)}.mcp-transport-choice label{min-height:58px;padding:.65rem .75rem;display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:var(--hm-space-3);color:var(--hm-text-muted);background:#05080c3d;border:1px solid var(--hm-border);border-radius:var(--hm-radius-md);cursor:pointer}.mcp-transport-choice label.selected{color:var(--hm-text);background:var(--hm-accent-dim);border-color:#c58b325c}.mcp-transport-choice label>span{display:grid}.mcp-transport-choice strong{font-size:.68rem}.mcp-transport-choice small{color:var(--hm-text-dim);font-size:.58rem}.mcp-replace-field{min-height:56px;padding:.65rem .75rem;display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:var(--hm-space-3);text-align:left;color:var(--hm-text-muted);background:#05080c33;border:1px dashed var(--hm-border);border-radius:var(--hm-radius-md);cursor:pointer}.mcp-replace-field:hover{color:var(--hm-text);border-color:#c58b3252;background:var(--hm-accent-dim)}.mcp-replace-field span{display:grid;gap:.12rem}.mcp-replace-field strong{font-size:.65rem;font-weight:550}.mcp-replace-field small{color:var(--hm-text-dim);font-size:.58rem}.mcp-static-auth-note,.mcp-limit-note{padding:.65rem .75rem;display:grid;grid-template-columns:auto minmax(0,1fr);gap:var(--hm-space-3);color:var(--hm-text-dim);background:#668fd70e;border:1px solid rgba(102,143,215,.14);border-radius:var(--hm-radius-md);font-size:.62rem;line-height:1.5}.mcp-limit-note{margin-top:var(--hm-space-4);color:#a9956e;background:#c58b320b;border-color:#c58b3221}.mcp-secret-columns{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-4)}.mcp-secret-editor{min-width:0;padding:var(--hm-space-4);background:#05080c38;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-lg)}.mcp-secret-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-3);margin-bottom:var(--hm-space-3)}.mcp-secret-heading>div{display:grid}.mcp-secret-heading strong{color:var(--hm-text-muted);font-size:.66rem}.mcp-secret-heading small{color:var(--hm-text-dim);font-size:.56rem}.mcp-configured-secrets{display:grid;gap:var(--hm-space-2);margin-bottom:var(--hm-space-3)}.mcp-configured-secrets>div{min-height:32px;padding:.35rem .5rem;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--hm-space-3);background:#ffffff05;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-sm)}.mcp-configured-secrets code{color:#bcc5d1;font-size:.61rem;overflow-wrap:anywhere}.mcp-configured-secrets span{color:#81bea3;font-size:.56rem}.mcp-configured-secrets button{color:#d39494;background:transparent;border:0;cursor:pointer;font-size:.57rem}.mcp-configured-secrets button.undo{color:var(--hm-accent-hover)}.mcp-secret-row{display:grid;grid-template-columns:minmax(100px,.85fr) minmax(120px,1.15fr) auto;align-items:center;gap:var(--hm-space-2);margin-top:var(--hm-space-2)}.mcp-secret-row .hm-input{min-width:0;padding:.55rem .65rem;font-size:.65rem}.mcp-secret-empty{padding:.65rem 0;color:var(--hm-text-dim);font-size:.59rem}.mcp-editor-footer{align-items:center;padding:var(--hm-space-4) var(--hm-space-6);background:#07090de0;border-top:1px solid var(--hm-border)}.mcp-editor-footer>span{color:var(--hm-text-dim);font-size:.6rem}.mcp-editor-footer>div{display:flex;gap:var(--hm-space-2)}.cfgc-mcp-owner{margin:var(--hm-space-4);padding:var(--hm-space-4);display:grid;grid-template-columns:36px minmax(0,1fr) auto;align-items:center;gap:var(--hm-space-4);background:linear-gradient(135deg,#c58b320e,#04070b38);border:1px solid rgba(197,139,50,.18);border-radius:var(--hm-radius-lg)}.cfgc-mcp-owner-icon{width:36px;height:36px;display:grid;place-items:center;color:var(--hm-accent-hover);background:var(--hm-accent-dim);border-radius:9px}.cfgc-mcp-owner strong{color:var(--hm-text);font-size:.7rem}.cfgc-mcp-owner p{margin-top:.15rem;color:var(--hm-text-dim);font-size:.62rem;line-height:1.45}@media(max-width:900px){.mcp-page-header,.mcp-server-header{align-items:stretch;flex-direction:column}.mcp-header-actions,.mcp-server-actions{justify-content:flex-start}.mcp-server-actions .btn{flex:1 1 150px;justify-content:center}.mcp-secret-columns{grid-template-columns:minmax(0,1fr)}}@media(max-width:640px){.mcp-page{gap:var(--hm-space-4)}.mcp-header-actions{display:grid;grid-template-columns:1fr 1fr}.mcp-header-actions .btn{justify-content:center}.mcp-control-card{grid-template-columns:minmax(0,1fr) auto;padding:var(--hm-space-4)}.mcp-publication-limits{grid-template-columns:minmax(0,1fr)}.mcp-control-icon{display:none}.mcp-aggregate{grid-template-columns:repeat(2,minmax(0,1fr))}.mcp-aggregate>div:nth-child(2){border-right:0}.mcp-aggregate>div:nth-child(-n+2){border-bottom:1px solid var(--hm-border-subtle)}.mcp-server-header,.mcp-server-body{padding:var(--hm-space-4)}.mcp-server-actions{display:grid;grid-template-columns:1fr 1fr auto auto}.mcp-server-actions .btn{min-width:0;padding-inline:var(--hm-space-3)}.mcp-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.mcp-tools-toggle{align-items:flex-start}.mcp-tools-toggle>span:last-child{text-align:right}.mcp-tool-row{grid-template-columns:minmax(0,1fr)}.mcp-tool-state{justify-self:start}.mcp-modal-overlay{padding:0;align-items:flex-end}.modal-content.mcp-editor{width:100%;max-width:none;max-height:96vh;border-radius:var(--hm-radius-xl) var(--hm-radius-xl) 0 0}.mcp-editor-header,.mcp-editor-footer,.mcp-editor-nav,.mcp-editor-groups{padding-inline:var(--hm-space-4)}.mcp-form-grid.two,.mcp-transport-choice{grid-template-columns:minmax(0,1fr)}.mcp-editor-footer{align-items:stretch;flex-direction:column}.mcp-editor-footer>div{display:grid;grid-template-columns:1fr 1fr}.cfgc-mcp-owner{grid-template-columns:34px minmax(0,1fr)}.cfgc-mcp-owner .btn{grid-column:1 / -1;justify-content:center}}.ts-count-row{display:flex;flex-wrap:wrap;gap:.75rem}.ts-count{display:flex;flex-direction:column;gap:.1rem;padding:.4rem .7rem;border:1px solid var(--hm-border);border-radius:6px;min-width:90px}.ts-count-value{font-size:var(--hm-text-lg);font-weight:600;font-variant-numeric:tabular-nums}.ts-count-label{font-size:var(--hm-text-xs);color:var(--hm-text-muted)}.ts-count-alert{border-color:#d9770673}.ts-count-alert .ts-count-value{color:var(--hm-warning)}.ts-turn-row{border:1px solid var(--hm-border);border-radius:6px;padding:.5rem .7rem}.ts-turn-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem}.ts-turn-age{margin-left:auto}.ts-turn-warning{color:var(--hm-warning);font-size:var(--hm-text-xs);margin-top:.35rem}.ts-op-list{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.ts-op{font-size:var(--hm-text-xs);color:var(--hm-text-muted);border:1px solid var(--hm-border);border-radius:4px;padding:.1rem .4rem;overflow-wrap:anywhere}.ts-op-alert{color:var(--hm-warning);border-color:#d9770673}.sched-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--hm-space-3);align-content:start}@media(min-width:768px){.sched-detail-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.sched-detail-grid>div{display:grid;align-content:start;gap:.3rem;min-width:0}.sched-detail-label{color:var(--hm-text-muted);font-size:var(--hm-text-xs);line-height:1}.audit-verify-ok{border-color:#22c55e59}.audit-verify-valid{color:#4ade80}:root{--hm-topbar-h: 76px;--hm-rail-h: 107px;--hm-section-tabs-inner-h: 48px;--hm-section-tabs-border: 1px;--hm-section-tabs-h: calc(var(--hm-section-tabs-inner-h) + var(--hm-section-tabs-border));--hm-bg: #07090d;--hm-bg-raised: #0b0e14;--hm-surface: #10141c;--hm-surface-hover: #171d28;--hm-surface-elevated: #151a24;--hm-border: #252c39;--hm-border-subtle: #1a202b;--hm-accent: #c58b32;--hm-accent-hover: #dda84f;--hm-accent-dim: rgba(197, 139, 50, .13);--hm-accent-glow: rgba(197, 139, 50, .16);--hm-gold: #e0b766;--hm-gold-dim: rgba(197, 139, 50, .08);--hm-text: #edf0f5;--hm-text-muted: #9aa4b3;--hm-text-dim: #667182;--hm-success: #3db983;--hm-warning: #d8a342;--hm-danger: #e06464;--hm-info: #668fd7;--hm-radius-sm: 5px;--hm-radius-md: 7px;--hm-radius-lg: 10px;--hm-radius-xl: 14px;--hm-shadow-sm: 0 1px 2px rgba(0,0,0,.24), 0 0 0 1px rgba(255,255,255,.015);--hm-shadow-md: 0 10px 30px rgba(0,0,0,.28);--hm-shadow-lg: 0 24px 64px rgba(0,0,0,.48);--shell-sidebar: 252px;--shell-sidebar-collapsed: 72px}html{background:var(--hm-bg)}body{color:var(--hm-text);background:radial-gradient(circle at 72% -20%,rgba(197,139,50,.055),transparent 34rem),linear-gradient(180deg,#080a0f 0%,var(--hm-bg) 35%)}button,input,select,textarea{font:inherit}::-moz-selection{background:#c58b3252;color:#fff}::selection{background:#c58b3252;color:#fff}.odin-icon{display:block;flex:none}.app-loading{min-height:100vh;display:grid;place-items:center}.brand-loader{color:var(--hm-accent-hover);animation:brand-pulse 1.4s ease-in-out infinite}@keyframes brand-pulse{50%{opacity:.45;transform:scale(.94)}}.app-shell{display:flex;min-height:100vh}.hm-sidebar{position:relative;width:var(--shell-sidebar);min-width:var(--shell-sidebar);height:100vh;min-height:0;background:#0d1017f5;border-right:1px solid var(--hm-border-subtle);box-shadow:10px 0 35px #0000001f;transition:width .22s ease,min-width .22s ease,transform .22s ease;display:flex;flex-direction:column;z-index:40}.hm-sidebar:before{content:"";position:absolute;inset:0 auto 0 0;width:2px;background:linear-gradient(180deg,transparent 3%,var(--hm-accent) 24%,rgba(197,139,50,.15) 68%,transparent);opacity:.65;pointer-events:none}.hm-sidebar.collapsed{width:var(--shell-sidebar-collapsed);min-width:var(--shell-sidebar-collapsed)}.sidebar-brand{min-height:76px;padding:0 16px 0 18px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--hm-border-subtle)}.brand-mark{width:34px;height:34px;display:grid;place-items:center;color:var(--hm-accent-hover);background:linear-gradient(145deg,#c58b3229,#c58b3209);border:1px solid rgba(197,139,50,.3);border-radius:9px;flex:none}.sidebar-brand-copy{display:flex;flex-direction:column;min-width:0;line-height:1}.brand-wordmark{font-size:.83rem;letter-spacing:.23em;font-weight:700;color:#f2dfb8}.brand-caption{margin-top:7px;font-size:.61rem;letter-spacing:.1em;text-transform:uppercase;color:var(--hm-text-dim)}.sidebar-toggle-btn{margin-left:auto}.hm-sidebar.collapsed .sidebar-brand{padding-inline:18px;justify-content:center}.hm-sidebar.collapsed .sidebar-brand-copy,.hm-sidebar.collapsed .nav-label,.hm-sidebar.collapsed .nav-section-label,.hm-sidebar.collapsed .connection-copy,.hm-sidebar.collapsed .shortcut-hint span,.hm-sidebar.collapsed .shortcut-hint kbd,.hm-sidebar.collapsed .brand-mark{display:none}.hm-sidebar.collapsed .sidebar-toggle-btn{margin-left:0}.sidebar-nav{flex:1;min-height:0;padding:13px 10px;overflow:auto}.nav-group+.nav-group{margin-top:18px}.nav-section-label{padding:0 10px 6px;color:#555f6f;font-size:.625rem;line-height:1;text-transform:uppercase;letter-spacing:.14em;font-weight:700}.nav-item{position:relative;min-height:40px;display:flex;align-items:center;white-space:nowrap;overflow:hidden;margin:2px 0;padding:0 10px;gap:11px;color:#8d97a7;border-radius:7px;font-size:.79rem;font-weight:500;text-decoration:none;cursor:pointer;transition:background var(--hm-transition-base),color var(--hm-transition-base)}.nav-item:hover{background:#ffffff09;color:#d9dee7}.nav-item.active{background:linear-gradient(90deg,#c58b3224,#c58b320b);color:#ebc982;box-shadow:inset 2px 0 0 var(--hm-accent)}.nav-item.active:after{content:"";position:absolute;right:10px;width:4px;height:4px;background:var(--hm-accent-hover);border-radius:50%;box-shadow:0 0 8px var(--hm-accent)}.nav-icon{width:20px;display:grid;flex:none;place-items:center;color:#717d8f}.nav-item:hover .nav-icon{color:#aeb7c4}.nav-item.active .nav-icon{color:var(--hm-accent-hover)}.hm-sidebar.collapsed .sidebar-nav{padding-inline:12px}.hm-sidebar.collapsed .nav-item{padding:0;justify-content:center}.hm-sidebar.collapsed .nav-item.active:after{right:5px}.sidebar-footer{min-height:var(--hm-rail-h);padding:12px;border-top:1px solid var(--hm-border-subtle);display:grid;gap:8px;align-content:center}.connection-card{min-height:42px;padding:8px 10px;display:flex;align-items:center;gap:9px;border:1px solid var(--hm-border-subtle);background:#00000024;border-radius:8px}.connection-copy{min-width:0;display:flex;flex:1;justify-content:space-between;align-items:baseline;gap:8px}.connection-label{font-size:.7rem;color:var(--hm-text-muted)}.connection-latency{font-family:var(--hm-font-mono);font-size:.58rem;color:var(--hm-text-dim)}.shortcut-hint{width:100%;min-height:32px;display:flex;align-items:center;gap:7px;padding:0 9px;border:0;border-radius:6px;background:transparent;color:var(--hm-text-dim);font-size:.67rem;cursor:pointer}.shortcut-hint:hover{color:var(--hm-text-muted);background:#ffffff08}.shortcut-hint kbd{margin-left:auto}.hm-sidebar.collapsed .connection-card,.hm-sidebar.collapsed .shortcut-hint{justify-content:center;padding-inline:0}.hm-main{flex:1;min-width:0;height:100vh;max-height:none;overflow-y:auto;background:transparent}.hm-topbar{position:sticky;top:0;display:flex;align-items:center;z-index:25;height:var(--hm-topbar-h);padding:0 28px;gap:22px;background:#07090de0;border-bottom:1px solid var(--hm-border-subtle);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-context{display:flex;flex-direction:column;min-width:140px}.topbar-kicker{margin-bottom:2px;color:var(--hm-accent);font-size:.57rem;line-height:1;text-transform:uppercase;letter-spacing:.16em;font-weight:700}.topbar-title-row{display:flex;align-items:center;gap:10px}.topbar-title-row h1{margin:0;color:#f3f4f7;font-size:1rem;line-height:1.25;font-weight:650;letter-spacing:-.018em}.topbar-description{margin:0;padding-left:22px;border-left:1px solid var(--hm-border);color:var(--hm-text-dim);font-size:.72rem}.topbar-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.uptime-label{margin-right:5px;color:var(--hm-text-dim);font-family:var(--hm-font-mono);font-size:.62rem}.status-pill{display:inline-flex;align-items:center;gap:5px;color:var(--hm-text-muted);font-size:.58rem;text-transform:capitalize;font-weight:600}.status-pill .status-dot{width:6px;height:6px;box-shadow:none}.command-trigger{min-height:34px;padding:0 8px 0 10px;display:flex;align-items:center;gap:7px;color:var(--hm-text-muted);background:#ffffff06;border:1px solid var(--hm-border);border-radius:7px;font-size:.68rem;cursor:pointer}.command-trigger:hover{color:var(--hm-text);border-color:#3a4352;background:#ffffff0b}kbd{padding:2px 5px;color:#818b99;background:#090c11;border:1px solid #2b3340;border-radius:4px;font-family:var(--hm-font-sans);font-size:.57rem;box-shadow:inset 0 -1px #ffffff09}.page-viewport{min-height:calc(100vh - var(--hm-topbar-h))}.icon-btn.mobile-menu-btn,.mobile-scrim{display:none}.icon-btn{width:34px;height:34px;padding:0;display:inline-grid;place-items:center;flex:none;color:var(--hm-text-muted);background:transparent;border:1px solid transparent;border-radius:7px;cursor:pointer;transition:color .15s ease,border-color .15s ease,background .15s ease}.icon-btn:hover{color:var(--hm-text);background:#ffffff0b;border-color:var(--hm-border)}.icon-btn-danger{color:#d67a7a}.icon-btn-danger:hover{color:#f08a8a;background:#e064641a;border-color:#e064643d}.section-shell{min-height:calc(100vh - var(--hm-topbar-h))}.section-tabs-wrap{position:sticky;top:var(--hm-topbar-h);z-index:20;padding:0 24px;background:#090c11eb;border-bottom:var(--hm-section-tabs-border) solid var(--hm-border-subtle);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px)}.section-tabs{display:flex;align-items:stretch;gap:2px;min-height:var(--hm-section-tabs-inner-h);overflow-x:auto}.section-tab{position:relative;padding:0 13px;white-space:nowrap;color:#7f8998;background:transparent;border:0;font-size:.72rem;font-weight:550;cursor:pointer}.section-tab:hover{color:#c8ced7}.section-tab.active{color:#e9ca8e}.section-tab.active:after{content:"";position:absolute;left:13px;right:13px;bottom:0;height:2px;background:var(--hm-accent);border-radius:2px 2px 0 0;box-shadow:0 -3px 12px #c58b3233}.section-panel>*{min-height:100%}.hm-card{background:linear-gradient(150deg,#121720f5,#0e1219fa);border-color:var(--hm-border);box-shadow:var(--hm-shadow-sm)}.hm-card:hover{border-color:#303847}.hm-table th{background:#07090d4d;color:#7e8898;font-size:.64rem;letter-spacing:.075em;font-weight:650}.hm-table td{border-bottom-color:var(--hm-border-subtle)}.hm-table tr:hover td{background:#ffffff05}.btn{min-height:34px;padding:7px 12px;border:1px solid transparent;font-weight:600}.btn-primary{color:#171006;background:linear-gradient(180deg,#d4a14e,#b77b29);border-color:#dcaa57;box-shadow:inset 0 1px #ffffff21,0 5px 14px #83521324}.btn-primary:hover{background:linear-gradient(180deg,#e2b35f,#c88a31);box-shadow:0 7px 18px #83521333}.btn-ghost{border-color:var(--hm-border);background:#ffffff04}.btn-ghost:hover{border-color:#394251;background:#ffffff0a}.hm-input{min-height:38px;background:#090c11;border-color:#2a3240}.hm-input:hover{border-color:#36404f}.hm-input:focus{border-color:var(--hm-accent);box-shadow:0 0 0 3px #c58b321a}.login-shell{min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 50% 20%,rgba(197,139,50,.09),transparent 30rem)}.login-panel{width:100%;max-width:380px;padding:34px;background:linear-gradient(150deg,#131821f7,#0b0e14fc);border:1px solid var(--hm-border);border-radius:14px;box-shadow:var(--hm-shadow-lg)}.login-brand{width:48px;height:48px;margin-bottom:26px;display:grid;place-items:center;color:var(--hm-accent-hover);background:var(--hm-accent-dim);border:1px solid rgba(197,139,50,.3);border-radius:11px}.login-eyebrow{margin:0 0 7px;color:var(--hm-accent);font-size:.63rem;font-weight:700;letter-spacing:.15em;text-transform:uppercase}.login-title{margin:0;color:#f2f3f6;font-size:1.65rem;line-height:1.2;font-weight:650;letter-spacing:-.035em}.login-subtitle{margin:7px 0 25px;color:var(--hm-text-muted);font-size:.8rem}.modal-overlay{background:#030508c2;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.modal-content,.palette{background:linear-gradient(155deg,#171c25,#0f131a);border-color:#303847;box-shadow:var(--hm-shadow-lg)}.confirm-heading{display:flex;gap:13px;margin-bottom:22px}.confirm-heading h3{margin:2px 0 6px;font-size:.92rem;font-weight:650}.confirm-heading p{margin:0;color:var(--hm-text-muted);font-size:.76rem;line-height:1.55}.confirm-icon{width:38px;height:38px;display:grid;place-items:center;flex:none;color:var(--hm-info);background:#668fd71a;border:1px solid rgba(102,143,215,.22);border-radius:9px}.confirm-icon.danger{color:var(--hm-danger);background:#e064641a;border-color:#e0646438}.toast-item{min-width:260px;padding:11px 13px;background:#141923;border-color:#2b3442;border-left-width:3px;color:var(--hm-text)}.toast-success{border-left-color:var(--hm-success)}.toast-error{border-left-color:var(--hm-danger)}.toast-info{border-left-color:var(--hm-info)}.toast-success,.toast-error,.toast-info{background:#141923;color:var(--hm-text)}.toast-success .toast-icon{color:var(--hm-success)}.toast-error .toast-icon{color:var(--hm-danger)}.toast-info .toast-icon{color:var(--hm-info)}.palette-overlay{padding-top:12vh}.palette{max-width:570px}.palette-search{display:flex;align-items:center;gap:10px;padding-left:17px;color:var(--hm-text-dim);border-bottom:1px solid var(--hm-border)}.palette-input{border:0;padding:17px 17px 17px 0;font-size:.82rem}.palette-results{max-height:390px;padding:8px}.palette-item{min-height:48px;gap:11px;padding:6px 10px}.palette-item.selected{background:linear-gradient(90deg,#c58b321f,#c58b3209)}.palette-icon{width:30px;height:30px;display:grid;place-items:center;color:#8994a4;background:#ffffff09;border:1px solid var(--hm-border-subtle);border-radius:7px}.palette-item.selected .palette-icon{color:var(--hm-accent-hover);border-color:#c58b3240}.palette-copy{display:flex;flex-direction:column;gap:2px}.palette-label{color:#dfe3e9;font-size:.76rem;font-weight:550}.palette-group{color:var(--hm-text-dim);font-size:.61rem}.palette-arrow{margin-left:auto;color:#4e5867}.palette-footer{justify-content:flex-end;gap:16px;padding:10px 14px}.palette-footer span{display:flex;align-items:center;gap:5px}.error-icon,.empty-state-icon{display:grid;place-items:center;color:var(--hm-text-dim)}.empty-state{min-height:220px;border-style:dashed}.empty-state-icon{width:44px;height:44px;font-size:initial;background:#ffffff06;border:1px solid var(--hm-border-subtle);border-radius:10px}@media(max-width:900px){.hm-sidebar{position:fixed;left:0;top:0;transform:translate(-102%);width:min(280px,86vw);min-width:min(280px,86vw);box-shadow:20px 0 60px #00000080}.hm-sidebar.mobile-open{transform:translate(0)}.hm-sidebar.collapsed{width:min(280px,86vw);min-width:min(280px,86vw)}.hm-sidebar.collapsed .sidebar-brand-copy,.hm-sidebar.collapsed .nav-label,.hm-sidebar.collapsed .nav-section-label,.hm-sidebar.collapsed .connection-copy,.hm-sidebar.collapsed .shortcut-hint span,.hm-sidebar.collapsed .shortcut-hint kbd{display:flex}.hm-sidebar.collapsed .sidebar-brand{justify-content:flex-start}.hm-sidebar.collapsed .brand-mark{display:grid}.hm-sidebar.collapsed .nav-item{padding:0 10px;justify-content:flex-start}.mobile-scrim{display:block;position:fixed;top:0;right:0;bottom:0;left:0;z-index:35;background:#0000009e;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.icon-btn.mobile-menu-btn{display:inline-grid}.hm-sidebar .sidebar-toggle-btn{display:none}.hm-topbar{padding:0 16px;gap:12px}.topbar-description,.uptime-label,.command-trigger span{display:none}.command-trigger{width:34px;padding:0;justify-content:center}.command-trigger kbd{display:none}}@media(max-width:640px){:root{--hm-topbar-h: 64px}.section-tabs-wrap{padding:0 12px}.topbar-kicker{display:none}.topbar-title-row h1{font-size:.9rem}.status-pill{display:none}.section-tab{padding-inline:10px}.section-panel .p-6{padding:1rem}.login-panel{padding:26px}.toast-stack{left:12px;right:12px;bottom:12px}.toast-item{width:100%;max-width:none}}@media(prefers-reduced-motion:reduce){.brand-loader{animation:none}}.page-viewport .page-fade-in{width:100%;max-width:1600px;margin-inline:0}.page-viewport .p-6{padding:clamp(1rem,2vw,1.75rem)}.page-viewport h1.text-xl{color:#f2f3f6;font-size:1.12rem;line-height:1.3;letter-spacing:-.025em;font-weight:650}.page-viewport h2,.page-viewport h3{letter-spacing:-.012em}.page-lede{margin-top:4px;max-width:64ch;color:var(--hm-text-dim);font-size:.72rem;line-height:1.55}.page-header{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:16px}.page-header-copy{min-width:0}.page-header-actions,.action-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.section-card-header{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:14px}.section-eyebrow{color:var(--hm-text-dim);font-size:.62rem;font-weight:650;letter-spacing:.09em;text-transform:uppercase}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.detail-grid>div{min-width:0;padding:10px 12px;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:#00000021}.detail-grid dt,.field-label{color:var(--hm-text-dim);font-size:.65rem;font-weight:600;letter-spacing:.035em}.detail-grid dd{margin-top:3px;color:#d7dce4;font-size:.77rem}.detail-stack{display:grid;gap:7px}.text-break{overflow-wrap:anywhere}.btn-touch{min-height:44px}.form-panel{position:relative;overflow:hidden;border-color:#c58b3238;background:linear-gradient(145deg,#c58b320b,#0d1118fa 35%)}.form-panel:before{content:"";position:absolute;inset:0 auto 0 0;width:2px;background:linear-gradient(180deg,var(--hm-accent),transparent 75%)}.provider-choice-list{display:grid;gap:8px}.provider-choice{padding:11px 12px;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:#0000001f}.provider-choice:has(input:checked){border-color:#c58b324d;background:var(--hm-accent-dim)}.provider-choice-label{display:flex;align-items:center;gap:9px;cursor:pointer}.provider-control{accent-color:var(--hm-accent)}.computer-page>.page-header{position:sticky;top:calc(var(--hm-topbar-h) + var(--hm-section-tabs-h));z-index:10;padding-block:12px;background:var(--hm-bg-raised);border-bottom:1px solid var(--hm-border-subtle)}.computer-status-card{display:flex;align-items:center;justify-content:space-between;gap:12px;border-left:2px solid var(--hm-accent)}.computer-state{margin-top:2px;color:#f0d49f;font-size:1rem;font-weight:650;text-transform:capitalize}.computer-provisioning{min-width:0}.computer-provisioning-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px;min-width:0;padding:0;border:0}.computer-provisioning-field{display:flex;flex-direction:column;align-items:flex-start;gap:8px;min-width:0;overflow-wrap:anywhere}.computer-provisioning-field .hm-input,.computer-provisioning-field .hm-select{width:100%;min-width:0}.computer-provisioning-field textarea{resize:vertical}.computer-provisioning-review dd{overflow-wrap:anywhere;white-space:pre-wrap}.profile-list{display:grid;gap:8px}.profile-list-item{padding:10px 12px;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:#00000021;color:#cdd3dc;font-size:.78rem}.evidence-figure{padding:12px;border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-md);background:#080a0e}.evidence-frame{display:block;max-width:100%;max-height:65vh;margin-inline:auto;-o-object-fit:contain;object-fit:contain}.export-controls{display:flex;align-items:stretch;flex-wrap:wrap;gap:8px}.export-name{width:min(100%,30rem);min-height:44px}.artifact-row{display:flex;align-items:center;flex-wrap:wrap;gap:10px}@media(max-width:640px){.page-header-actions,.section-card-header .action-row,.export-controls{width:100%}.page-header-actions .btn,.section-card-header .action-row .btn,.export-controls .btn,.export-name{flex:1 1 100%;justify-content:center}.detail-grid{grid-template-columns:minmax(0,1fr)}}.hm-card{padding:1rem;border-radius:var(--hm-radius-lg)}.hm-card>h2:first-child,.hm-card>h3:first-child{color:#e5e8ee}.table-responsive{border:1px solid var(--hm-border-subtle);border-radius:var(--hm-radius-lg);background:#06080c3d}.hm-table{width:100%;border-collapse:collapse}.hm-table th{height:38px;padding:0 12px;white-space:nowrap}.hm-table td{padding:10px 12px;vertical-align:middle}.hm-table tbody tr:last-child td{border-bottom:0}.hm-select,select.hm-input{min-height:38px;background-color:#090c11;border-color:#2a3240}textarea.hm-input{min-height:90px;line-height:1.55}label{accent-color:var(--hm-accent)}input[type=checkbox]{width:15px;height:15px;accent-color:var(--hm-accent);border-radius:4px;cursor:pointer}input[type=checkbox]:focus-visible{outline:2px solid var(--hm-accent);outline-offset:3px}input[type=checkbox]:disabled{cursor:not-allowed;opacity:.45}.btn-danger{color:#f6b2b2;background:#e0646414;border-color:#e0646452}.btn-danger:hover{color:#ffd0d0;background:#e0646429;border-color:#e0646480}.btn:focus-visible,.icon-btn:focus-visible,.nav-item:focus-visible,.section-tab:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.modal-content{width:min(92vw,520px);padding:22px;border-radius:var(--hm-radius-xl)}.modal-content h2,.modal-content h3{color:#f0f2f5}.modal-content .btn{min-width:76px}.error-state{min-height:180px;border-color:#e0646442!important;background:linear-gradient(150deg,#34141852,#0e1219fa)}.error-icon{width:42px;height:42px;border-radius:10px;background:#e0646417;border:1px solid rgba(224,100,100,.2)}.sess-preset-icon,.tl-group-icon,.cfg-group-icon,.chat-tool-icon{display:inline-grid;place-items:center;flex:none}.sk-action-btn{display:inline-grid;place-items:center}.sk-card-icon{display:inline-grid;color:var(--hm-accent-hover)}.health-card-icon{display:inline-grid}.provider-status{display:inline-flex;align-items:center;gap:6px}.provider-status .status-dot{width:7px;height:7px;box-shadow:none}.row-expander{width:28px;height:28px;display:inline-grid;place-items:center;border:0;border-radius:6px;color:var(--hm-text-dim);background:transparent;cursor:pointer}.row-expander:hover{color:var(--hm-text);background:#ffffff0b}.row-expander:focus-visible{outline:2px solid var(--hm-accent);outline-offset:2px}.tool-expand-icon .odin-icon,.chat-tools-toggle-icon .odin-icon,.cfg-group-arrow .odin-icon,.sess-expand-icon .odin-icon,.mem-tree-arrow .odin-icon,.kb-tree-arrow .odin-icon{transition:transform var(--hm-transition-base)}.chat-markdown .chat-code-copy{opacity:0}.chat-markdown pre:hover .chat-code-copy,.chat-markdown .chat-code-copy:focus-visible{opacity:1}.fts-highlight,.knowledge-highlight{background:#e0b7663d;color:#fff0c8;border-radius:2px;padding-inline:1px}@media(max-width:640px){.page-viewport .p-6,.hm-card{padding:.85rem}.hm-table th,.hm-table td{padding-inline:9px}.modal-content{padding:18px}} diff --git a/ui/dist/index.html b/ui/dist/index.html index fcc2e0fda..d97903d6d 100644 --- a/ui/dist/index.html +++ b/ui/dist/index.html @@ -9,8 +9,8 @@ Odin — Management - - + +
Skip to main content diff --git a/ui/js/codex-quota.js b/ui/js/codex-quota.js new file mode 100644 index 000000000..39f96cb14 --- /dev/null +++ b/ui/js/codex-quota.js @@ -0,0 +1,33 @@ +// Pure view-model math for Codex quota windows. Kept separate so tests exercise +// the same calculations consumed by the rendered account table. +export function quotaBlocks(account, formatDate = () => 'unknown') { + const quota = account?.quota; + if (!quota || typeof quota !== 'object') return []; + return ['primary', 'secondary'].flatMap(key => { + const window = quota[key]; + const used = Number(window?.used_percent); + const minutes = Number(window?.window_minutes); + if (!window || !Number.isFinite(used) || used < 0 + || !Number.isFinite(minutes) || minutes <= 0) return []; + let label; + if (minutes === 300) label = '5-hour usage limit'; + else if (minutes === 10080) label = 'Weekly usage limit'; + else if (Number.isFinite(minutes) && minutes > 0) { + label = minutes < 60 ? `${minutes}-minute usage limit` + : minutes < 1440 ? `${Math.round(minutes / 60)}-hour usage limit` + : `${Math.round(minutes / 1440)}-day usage limit`; + } else label = key === 'primary' ? 'Primary usage limit' : 'Secondary usage limit'; + return [{ + key, + label, + remaining: Math.round(Math.max(0, Math.min(100, 100 - used))), + limitReached: used >= 100, + statusLabel: used >= 100 ? 'Limit reached' : '', + resetLabel: window.resets_at == null ? 'unknown' : formatDate(window.resets_at), + }]; + }); +} + +export function quotaFailureVisible(account) { + return Boolean(account?.quota_check_failed); +} diff --git a/ui/js/config-meta-fixture.js b/ui/js/config-meta-fixture.js index c9dadcfbe..ef8df686c 100644 --- a/ui/js/config-meta-fixture.js +++ b/ui/js/config-meta-fixture.js @@ -34,7 +34,6 @@ const SECTION_DEFAULTS = { attachments: { apply_mode: 'live_read', description: 'Attachment limits, paths, and cleanup policy.' }, personality: { apply_mode: 'live_read', owner: 'personality', description: 'Response identity, style, and personality presets.' }, mcp: { apply_mode: 'activation_required', owner: 'mcp', description: 'Model Context Protocol servers and tool publication.' }, - slack: { apply_mode: 'restart', description: 'Slack destinations and internal alert forwarding.' }, audit: { apply_mode: 'restart', description: 'Audit signing, verification, and retention.' }, agents: { apply_mode: 'live_for_new_work', description: 'Spawned-agent budgets, inheritance, and tree limits.' }, grafana_alerts: { apply_mode: 'restart', owner: 'grafana_alerts', description: 'Grafana alert routing and remediation policy.', restart_reason: 'The GrafanaAlertHandler is constructed from these values at startup; saving does not rebuild it.' }, @@ -160,11 +159,6 @@ const FIELD_OVERRIDES = { description: 'Directory containing the persistent Usage & Activity rollup.', restart_reason: 'The SQLite store and background indexer bind this path at startup.', }, - 'slack.forward_alerts': { - apply_mode: 'activation_required', - description: 'Forward normalized internal alerts to tested Slack destinations.', - activation_policy: 'Requires an effective notifier, tested destination, and activation receipt.', - }, }; const PARTIAL_LIVE_PREFIXES = [ diff --git a/ui/js/discord-identity.js b/ui/js/discord-identity.js new file mode 100644 index 000000000..dc66c2e26 --- /dev/null +++ b/ui/js/discord-identity.js @@ -0,0 +1,85 @@ +import { computed, onMounted, ref, watch } from 'vue'; +import { api } from './api.js'; + +const DISCORD_SNOWFLAKE = /^\d{15,25}$/; +const identityCache = new Map(); +const pending = new Map(); +let guildMembers = null; +let guildMembersRequest = null; + +async function getGuildMembers() { + if (guildMembers) return guildMembers; + if (!guildMembersRequest) { + guildMembersRequest = api.get('/api/discord/members') + .then(members => { + guildMembers = Array.isArray(members) ? members : []; + return guildMembers; + }) + .catch(() => []) + .finally(() => { guildMembersRequest = null; }); + } + return guildMembersRequest; +} + +async function lookupDiscordIdentity(id) { + if (!DISCORD_SNOWFLAKE.test(String(id))) return null; + const member = (await getGuildMembers()).find(item => String(item.id) === String(id)); + if (member) { + identityCache.set(String(id), member); + return member; + } + if (identityCache.has(String(id))) return identityCache.get(String(id)); + if (!pending.has(String(id))) { + pending.set(String(id), api.get(`/api/discord/users/${encodeURIComponent(id)}`) + .then(data => data?.user || null).catch(() => null) + .then(user => { + if (user) identityCache.set(String(id), user); + pending.delete(String(id)); + return user; + })); + } + return pending.get(String(id)); +} + +export const DiscordIdentity = { + props: { + userId: { type: [String, Number], required: true }, + members: { type: Array, default: () => [] }, + showId: { type: Boolean, default: true }, + fallbackLabel: { type: String, default: '' }, + }, + template: ` + + + {{ displayFallback }} + {{ userId }} + + `, + setup(props) { + const fetched = ref(null); + const member = computed(() => (props.members || []).find(item => String(item.id) === String(props.userId)) || null); + const identity = computed(() => member.value || fetched.value); + const displayFallback = computed(() => DISCORD_SNOWFLAKE.test(String(props.userId)) + ? props.userId : (props.fallbackLabel || props.userId)); + async function resolve() { + fetched.value = null; + if (DISCORD_SNOWFLAKE.test(String(props.userId)) && !member.value) + fetched.value = await lookupDiscordIdentity(props.userId); + } + watch(() => [props.userId, props.members], resolve, { deep: true }); + onMounted(resolve); + return { identity, displayFallback }; + }, +}; + +export { lookupDiscordIdentity }; +export default DiscordIdentity; diff --git a/ui/js/pages/api-tokens.js b/ui/js/pages/api-tokens.js index 5922ffa6a..765466555 100644 --- a/ui/js/pages/api-tokens.js +++ b/ui/js/pages/api-tokens.js @@ -2,9 +2,11 @@ import { api } from '../api.js'; import { toast } from '../toast.js'; import { confirmDialog } from '../confirm.js'; import { computed, nextTick, onMounted, ref } from 'vue'; +import { DiscordIdentity } from '../discord-identity.js'; export default { + components: { DiscordIdentity }, template: `
@@ -27,6 +29,16 @@ export default {
+ +
@@ -144,7 +156,7 @@ export default {
- + - +
User -
{{ host }}
{{ hostDescriptions[host] }}
-
TierHosts Default HostActionsRemove
-
- -
- {{ (getMember(uid)?.display_name || '?').charAt(0) }} -
- {{ getMember(uid)?.display_name || uid }} - {{ getMember(uid).username }} - BOT -
-
- +
Invalid tier: {{ permissions.invalid_overrides[uid] }}
{{ chip }}
- + {{ (users[uid] || defaultPolicy).default_host || 'None' }} - +
{{ t.user_id }} {{ t.label || '—' }} {{ t.tier }} @@ -5401,7 +5413,7 @@ ${u.text}`:u.text).join(` Label Email PlanQuota Status Active Actions +
+
+
+ {{ block.label }} + {{ block.remaining }}% remaining +
+
+
+
+ + {{ block.statusLabel ? block.statusLabel + ' · ' : '' }}Resets {{ block.resetLabel }} + +
+
+ Quota check failed + — + + checked {{ quotaAge(a.quota.observed_at) }} ago + +
Error Expired @@ -6183,7 +6218,7 @@ ${u.text}`:u.text).join(` - `,setup(){const e=p(!0),t=p(null),s=15e3;let a=null,n=!1,i=null,l=null;const o={codexBasic:null,codexAdvanced:null,ollamaBasic:null,ollamaAdvanced:null,compatibleBasic:null,compatibleAdvanced:null,auxiliary:null,mainModel:null,agents:null},r=v=>JSON.stringify(v),c=(v,F)=>{o[v]=r(F)},d=(v,F)=>o[v]!==null&&o[v]!==r(F),u=p(null),f=p(!1),m=p({main:"",main_capability:"medium",agent_capability:"adaptive"}),h=p(""),b=["none","low","medium","high","xhigh","max"],I=["none","low","medium","high","xhigh","max"],k=p({enabled:!1,model:"gpt-6-sol",reasoning_effort:"xhigh",agent_reasoning_effort:"auto",request_timeout_seconds:3600,stream_stall_timeout_seconds:180,retry:{max_retries:3,base_delay:1,max_delay:30},connection_pool:{max_connections:10,keepalive_timeout:30},context_compression:{enabled:!0,max_context_chars:null,keep_recent_iterations:30},context_budget_overrides:{},context_utilization:60}),y=["gpt-6-astra","gpt-6-sol","gpt-6-luna","gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"],g=H(()=>{const v=u.value||{},F=v.model_catalogue||v.model_catalog||{},Y=Be=>{var Xt,De,Ft;return((Xt=he.value.model_profiles)==null?void 0:Xt[Be])||((Ft=(De=he.value.openrouter)==null?void 0:De.catalogue_profiles)==null?void 0:Ft[Be])||null},Te=(Be,Xt,De)=>Xt.map(Ft=>{var Yu,Qu;const Bt=typeof Ft=="string"?Ft:Ft.name,Gs=Be==="compat"?Y(Bt):null;return{ref:Be==="codex"?Ft:`${Be}:${Bt}`,name:Bt,provider:Be,available:!!(De!=null&&De.enabled&&(Be==="codex"?De.configured:(Yu=De.health)!=null&&Yu.healthy)),unavailable_reason:De!=null&&De.enabled?De!=null&&De.configured?!((Qu=De==null?void 0:De.health)!=null&&Qu.healthy)&&Be!=="codex"?"unreachable":"":"not configured":"disabled",capability:Be==="codex"||Gs!=null&&Gs.supports_reasoning?"reasoning":Gs!=null&&Gs.supports_thinking_mode?"thinking":"none",efforts:Gs==null?void 0:Gs.supported_efforts,profile:Gs}}),Pe=[...F.codex||Te("codex",y,v.codex),...F.compat||F.openai_compatible||[],...F.ollama||Te("ollama",ee.value,v.ollama)].map(Be=>typeof Be=="string"?{ref:Be,name:Be,provider:"codex",available:!0,capability:"reasoning"}:Be),ct=Be=>{const Xt=Pe.findIndex(De=>De.ref===Be.ref);Xt===-1?Pe.push(Be):Pe[Xt]={...Pe[Xt],...Be}};for(const Be of Te("compat",gt.value,v.openai_compatible))ct(Be);if(ns.value&&Ge.value.length)for(const Be of Ge.value){const Xt=`compat:${Be.id}`;ct({ref:Xt,name:Be.name||Be.id,provider:"compat",available:!0,unavailable_reason:"",capability:Be.supports_reasoning?"reasoning":"none",efforts:b.filter(De=>{var Ft;return(Ft=Be.supported_efforts)==null?void 0:Ft.includes(De)}),agent_available:Be.agent_eligible&&!!Be.profile,agent_unavailable_reason:Be.agent_unavailable_reason||""})}const Ts=new Set(Pe.map(Be=>Be.ref));for(const Be of[m.value.main,Ze.value.model,...Qt.value])Be&&Be!=="auto"&&!Ts.has(Be)&&Pe.unshift({ref:Be,name:Be.replace(/^(compat|ollama):/,""),provider:Be.split(":")[0]||"codex",available:!1,unavailable_reason:"unavailable",capability:Be.startsWith("ollama:")||Be.includes(":")?"none":"reasoning"});return Pe.filter(Be=>!ns.value||Be.provider!=="compat"||Be.ref.slice(7).includes("/")).map(Be=>Be.efforts?{...Be,efforts:b.filter(Xt=>Be.efforts.includes(Xt)&&!j(Be.ref,Xt))}:Be)}),x=H(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].map(([v,F])=>({id:v,label:F,models:g.value.filter(Y=>{if(Y.provider!==v)return!1;const Te=h.value.trim().toLowerCase();return!Te||`${Y.name} ${Y.ref}`.toLowerCase().includes(Te)})})).filter(v=>v.models.length)),S=v=>v.available&&v.agent_available!==!1,_=v=>v.available?`${v.name}${v.agent_available===!1?` (${v.agent_unavailable_reason||"not agent-eligible"})`:""}`:ce(v),A=H(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].map(([v,F])=>({id:v,label:F,models:g.value.filter(Y=>Y.provider===v&&!(v==="compat"&&ns.value)&&`${Y.ref} ${Y.name}`.toLowerCase().includes(kt.value.trim().toLowerCase()))})).filter(v=>v.models.length)),R=H(()=>g.value.find(v=>v.ref===m.value.main)),w=H(()=>g.value.find(v=>v.ref===Ze.value.model)),O=v=>g.value.find(F=>F.ref===v),B=v=>{const F=O(v);return(F==null?void 0:F.provider)==="codex"?b.filter(Y=>!j(v,Y)):(F==null?void 0:F.efforts)||[]},T=v=>gs.value.find(F=>Ms(F)===v),$=(v,F)=>{const Y=T(v);return typeof Y=="string"?"":(Y==null?void 0:Y[F])||""},Q=H(()=>Qt.value.map(v=>O(v)).filter(v=>v&&S(v))),W=H(()=>[...new Set(Q.value.map(v=>v.capability!=="reasoning"?v.capability||"none":v.provider==="codex"?"codex_reasoning":"compatible_reasoning"))]),N=H(()=>W.value.length>1),L=H(()=>{var F;if(Ze.value.model!=="auto")return((F=w.value)==null?void 0:F.capability)||"none";if(N.value)return"mixed";const v=W.value[0]||"none";return v.endsWith("_reasoning")?"reasoning":v}),D=H(()=>{var v;return Ze.value.model==="auto"?b:((v=w.value)==null?void 0:v.efforts)||b}),re=H(()=>L.value==="thinking"?Ze.value.thinking_mode??k.value.agent_reasoning_effort??"":k.value.agent_reasoning_effort??""),ce=v=>`${v.name}${v.available?"":` (${v.unavailable_reason||"unavailable"})`}`,J=H(()=>{const v=k.value.model;return v&&!y.includes(v)?[v,...y]:y}),K=H(()=>{const v=Ze.value.model;return v&&v!=="auto"&&!y.includes(v)?[v,...y]:y}),se={"gpt-5.4":["max"],"gpt-5.4-mini":["max"],"gpt-6-astra":["none"]},j=(v,F)=>!!v&&!!F&&(se[v]||[]).includes(F),ye=H(()=>{const v=Ze.value.model;return v&&!v.includes(":")?v:null}),Oe=v=>!j(k.value.model,v)&&!(k.value.agent_reasoning_effort===""&&j(ye.value,v)),ne=v=>{const F=Ze.value.model;return F==="auto"?!0:!j(F||k.value.model,v)},ie=H(()=>{const v=k.value.agent_reasoning_effort;return v==="auto"?null:v||k.value.reasoning_effort}),ve=v=>j(v,k.value.reasoning_effort)||Ze.value.model===""&&j(v,ie.value),be=v=>j(v,ie.value),ge=p({enabled:!1,model:"gpt-6-luna"}),Ne=p({unavailable_reason:null}),E=H(()=>{const v=ge.value.model;return v&&!y.includes(v)?[v,...y]:y});function P(v){const F=v.target.value;ge.value.enabled=F!=="",F!==""&&(ge.value.model=F),F.startsWith("compat:")&&ns.value?Pa(Ce(F),Je(F)).then(()=>Mn()).catch(Y=>ze(Y.message||"Failed to prepare OpenRouter model","error")):Mn()}const G=p(!1),me=p({codex:!1,ollama:!1,compatible:!1}),de=p(null),fe=p(!1),_e=p(""),q=p(null),te=p(!1);let X=0;const xe=H(()=>{var v;return Object.entries(((v=de.value)==null?void 0:v.models)||{}).map(([F,Y])=>{var Te,Pe,ct;return{model:F,floor:Y.floor,override:Y.override,effectiveBudget:(Te=Y.effective)==null?void 0:Te.effective_budget,configuredPrimaryChars:(Pe=Y.configured)==null?void 0:Pe.primary_chars,primaryChars:(ct=Y.effective)==null?void 0:ct.primary_chars,provenance:Y.provenance,clampExpiresAt:Y.clamp_expires_at,densityPriorMilli:Y.density_prior_milli,densityScope:Y.density_scope,workloadCalibration:Y.workload_calibration}})}),U=H(()=>{var v;return((v=de.value)==null?void 0:v.clamps)||[]}),ue=H(()=>{var v,F;return((F=(v=de.value)==null?void 0:v.models)==null?void 0:F[k.value.model])||null}),oe=p({enabled:!1,base_url:"",model:"",api_key:"",max_tokens:4096,num_ctx:32768,timeout:300}),he=p({enabled:!1,base_url:"https://api.deepseek.com/v1",api_key:"",model:"deepseek-v4-flash",request_timeout_seconds:3600,stream_stall_timeout_seconds:180,preset:"deepseek",reasoning_effort:"medium",model_profiles:{},context_utilization:75,openrouter:{order:[],allow_fallbacks:!0,quantizations:[],sort:null,data_collection:null,reasoning_effort:"medium",model_pins:{},catalogue_profiles:{}}}),Fe=p(!1),Ve=p(!1),$e=p(!1),Ke=p(!1),at=p(!1),Xe=p(!1);function Z(){var F,Y,Te;return!!(((F=t.value)==null?void 0:F.contains(document.activeElement))&&((Te=(Y=document.activeElement)==null?void 0:Y.matches)==null?void 0:Te.call(Y,'input, textarea, select, [contenteditable="true"]'))||ra.value||vo.value!==null||te.value||Fe.value||Ve.value||vs.value||$e.value||Ke.value||at.value||G.value||fi.pending()||mi.pending()||Dn.pending()||Mn.pending()||d("codexBasic",Do(k.value))||d("codexAdvanced",Po(k.value))||d("ollamaBasic",$o(oe.value))||d("ollamaAdvanced",$c(oe.value))||d("compatibleBasic",Mo(he.value))||d("compatibleAdvanced",Pc(he.value))||d("auxiliary",ge.value)||d("mainModel",m.value.main)||d("agents",Ze.value))}function Ee(){o.codexBasic=r(Do(k.value)),o.codexAdvanced=r(Po(k.value)),o.ollamaBasic=r($o(oe.value)),o.ollamaAdvanced=r($c(oe.value)),o.compatibleBasic=r(Mo(he.value)),o.compatibleAdvanced=r(Pc(he.value)),o.auxiliary=r(ge.value),o.mainModel=r(m.value.main)}const Ae=p({configured:null}),Me=p(!1),ee=p([]),Le=p(""),He=p(!1),rt=p(!1),Zt=p({configured:null}),lt=p(!1),gt=p([]),Yt=H(()=>ns.value?Ge.value.map(v=>v.id):gt.value),cs=p(""),oa=p(!1),js=p(!1),Ze=p({model:"auto",thinking_mode:null,auto_model_allowlist:[]}),ra=p(!1),vs=p(!1),Ms=v=>typeof v=="string"?v:v==null?void 0:v.model,gs=H(()=>(Ze.value.auto_model_allowlist||[]).map(v=>typeof v=="string"?v:{...v}).filter(v=>Ms(v))),En=H(()=>!k.value.enabled&&he.value.enabled?he.value.model?[`compat:${he.value.model}`]:[]:!k.value.enabled&&!he.value.enabled&&oe.value.enabled?oe.value.model?[`ollama:${oe.value.model}`]:[]:y),Qt=H(()=>gs.value.length?gs.value.map(Ms):En.value),_t=H(()=>{var v;return(v=Ze.value.auto_model_allowlist)!=null&&v.length?`Allowlist: ${Qt.value.length} models`:`Default: ${Qt.value.join(", ")||"no available agent models"}`}),ds=new Map;function ba(){ra.value=!1,V()}const rn=v=>{if(ns.value&&v.startsWith("compat:")&&!v.slice(7).includes("/"))return"OpenRouter requires a namespaced vendor/model ID; this is a direct-endpoint profile.";const F=g.value.find(Y=>Y.ref===v);return F?S(F)?"":F.agent_unavailable_reason||F.unavailable_reason||"Not agent-eligible":"Model is absent from the current endpoint catalogue."},ca=v=>{if(Et.value.has(v))return bs(v);const F=g.value.find(Te=>Te.ref===v),Y=(F==null?void 0:F.hint_metadata)||{};return[Y.hint||Y.hint_derived,Y.as_of&&`as of ${Y.as_of}`,Y.scope_note,Y.evidence&&`Evidence: ${Y.evidence}`,F&&qs(F)].filter(Boolean).join(" · ")||"No catalogue hint. Add an operator hint below."},le=p(null),ke=p(!1),qe=p(""),Ge=H(()=>{var v;return(((v=le.value)==null?void 0:v.models)||[]).map(F=>{var Te,Pe,ct;const Y=((Te=he.value.model_profiles)==null?void 0:Te[F.id])||F.profile||((ct=(Pe=he.value.openrouter)==null?void 0:Pe.catalogue_profiles)==null?void 0:ct[F.id])||(F.context_length>0&&F.max_completion_tokens>0?{total_window_tokens:F.context_length,max_output_tokens:F.max_completion_tokens}:null);return{...F,profile:Y}})}),kt=p(""),St=p(""),cn=p(!0),An=p(!0),dn=p(!0),Rn=p(!1),da=p(null),ya=p(""),jt=p(null),ua=p(""),as=p([]),un=p(!1),In=p("throughput"),li=(v,F,Y=null)=>{const Te=v[F],Pe=Y&&Te&&typeof Te=="object"?Te[Y]:Te,ct=Number(Pe);return Number.isFinite(ct)?ct:null},oi=H(()=>{const v=[...as.value],F=In.value;return v.sort((Y,Te)=>{var Bt,Gs;if(F==="quantization")return String(Y.quantization).localeCompare(String(Te.quantization));const[Pe,ct,Ts]=F==="throughput"?["throughput_last_30m","p50",!0]:F==="latency_p99"?["latency_last_30m","p99",!1]:F==="cache_price"?["cache_read_per_token",null,!1]:["prompt_per_token",null,!1],Be=(Bt=Y.pricing)==null?void 0:Bt[Pe],Xt=(Gs=Te.pricing)==null?void 0:Gs[Pe],De=ct?li(Y,Pe,ct):Be==null?null:Number(Be),Ft=ct?li(Te,Pe,ct):Xt==null?null:Number(Xt);return Number.isFinite(De)?Number.isFinite(Ft)?Ts?Ft-De:De-Ft:-1:1})}),ri=H(()=>{try{return new URL(he.value.base_url).hostname}catch{return""}}),ns=H(()=>/(^|\.)openrouter\.ai$/i.test(ri.value)),On=H(()=>{var v,F;return ns.value?ke.value?"OpenRouter recognized · fetching catalogue…":qe.value?`OpenRouter recognized · catalogue failed: ${qe.value}`:`OpenRouter recognized · ${((F=(v=le.value)==null?void 0:v.models)==null?void 0:F.length)||0} catalogue models loaded`:gt.value.length?`${gt.value.length} endpoint models loaded`:"Catalogue not loaded"}),Re=H(()=>qe.value?"text-red-400":"text-gray-500"),M=H(()=>{var v;return[...new Set((((v=le.value)==null?void 0:v.models)||[]).map(F=>F.vendor))].sort()}),pe=H(()=>{var v;return[...new Set((((v=le.value)==null?void 0:v.models)||[]).flatMap(F=>(F.endpoints||[]).map(Y=>Y.quantization)).filter(Boolean))].sort()}),we=v=>{var F;return(((F=le.value)==null?void 0:F.measured_cache)||[]).some(Y=>Y.model===v.id&&Y.samples>0&&Y.cached_percent>0)},Ue=H(()=>{const v=kt.value.trim().toLowerCase();return Ge.value.filter(F=>{var Y;return!(v&&!`${F.id} ${F.name} ${F.vendor}`.toLowerCase().includes(v)||St.value&&F.vendor!==St.value||cn.value&&!F.supports_tools||An.value&&!F.agent_eligible||dn.value&&F.variant!=="standard"||Rn.value&&!we(F)||da.value!=null&&Number((Y=F.pricing)==null?void 0:Y.prompt_per_token)*1e6>Number(da.value)||ya.value&&!(F.endpoints||[]).some(Te=>Te.quantization===ya.value))})}),We=H(()=>Ue.value.slice(0,100)),Qe=H(()=>Ue.value.length),Et=H(()=>new Map(Ge.value.map(v=>[`compat:${v.id}`,v]))),pt=v=>v==null?"n/a":`$${(Number(v)*1e6).toFixed(3)}/M`,At=v=>{var F,Y,Te,Pe;return[v.vendor,v.context_length?`${Number(v.context_length).toLocaleString()} ctx`:"context unknown",`${pt((F=v.pricing)==null?void 0:F.prompt_per_token)} in`,`${pt((Y=v.pricing)==null?void 0:Y.completion_per_token)} out`,`${pt((Te=v.pricing)==null?void 0:Te.cache_read_per_token)} cache read`,`${pt((Pe=v.pricing)==null?void 0:Pe.cache_write_per_token)} cache write`,v.supports_tools?"tools":"no tools",v.supports_reasoning?"reasoning":"no reasoning",v.variant!=="standard"?v.variant:null].filter(Boolean).join(" · ")},bs=v=>{var Pe;const F=Et.value.get(v);if(!F)return"Catalogue facts unavailable";const Y=(((Pe=le.value)==null?void 0:Pe.measured_cache)||[]).filter(ct=>ct.model===F.id),Te=Y.length?Y.map(ct=>`${ct.upstream_provider}: ${ct.cached_percent}% cached`).join(" · "):"No measured cache evidence yet";return`${At(F)} · ${Te}${F.profile_conflict?" · operator profile conflicts with catalogue":""}`},Nt=H(()=>gt.value.map(v=>typeof v=="string"?v:v.name).filter(Boolean)),Na=async()=>{var F,Y,Te;const v=(Te=(Y=(F=u.value)==null?void 0:F.openai_compatible)==null?void 0:Y.preset_catalogue)==null?void 0:Te[he.value.preset];v&&(he.value.base_url=v.base_url),Dn.cancel(),await ol()},ci=(v,F)=>{he.value.openrouter[v]=F.split(",").map(Y=>Y.trim()).filter(Boolean)},mo=H(()=>ee.value||[]),Ma=H(()=>{const v=[...y,...Nt.value.map(F=>`compat:${F}`),...mo.value.map(F=>`ollama:${F.name}`)];for(const F of[Ze.value.model,...Qt.value])F&&F!=="auto"&&!v.includes(F)&&v.unshift(F);return v}),ac=v=>v==="codex-auto-review"?"codex-auto-review (Codex alias → gpt-5.6-luna)":v;async function ho({poll:v=!1}={}){try{const F=await z.get("/api/agents/model");if(v&&Z())return;Ze.value={...Ze.value,...F},o.agents=r(Ze.value)}catch{}}async function di(){if(!ns.value){le.value=null,qe.value="";return}ke.value=!0;try{le.value=await z.get("/api/openrouter/catalogue"),qe.value=""}catch(v){qe.value=v.message||"Failed to load OpenRouter catalogue"}finally{ke.value=!1}}async function nc(){var v;try{(v=Ze.value.model)!=null&&v.startsWith("compat:")&&ns.value&&await Pa(Ce(Ze.value.model),Je(Ze.value.model));const F=await z.put("/api/agents/model",{model:Ze.value.model||null});Ze.value={...Ze.value,...F},o.agents=r(Ze.value),ze("Agent model policy saved")}catch(F){ze(F.message||"Failed to save agent model policy","error")}}const Ln=()=>[...gs.value.length?gs.value:Qt.value];async function ic(v,F){const Y=Ln(),Te=Y.findIndex(Ts=>Ms(Ts)===v);let Pe=null;if(F.target.checked&&Te<0&&Y.push(ds.get(v)||v),!F.target.checked&&Te>=0&&([Pe]=Y.splice(Te,1)),!Y.length){F.target.checked=!0,ze("Keep one model selected. An empty list restores the provider default.","error");return}const ct=await Da(Y,"Agent Auto allowlist saved");ct&&F.target.checked&&ds.delete(v),ct&&!F.target.checked&&typeof Pe=="object"&&ds.set(v,Pe),ct||(F.target.checked=Qt.value.includes(v))}async function Da(v,F){if(vs.value)return!1;vs.value=!0;try{const Y=await z.put("/api/agents/model",{auto_model_allowlist:v});return Ze.value={...Ze.value,...Y},o.agents=r(Ze.value),ze(F),!0}catch(Y){return ze(Y.message||"Failed to save agent allowlist","error"),!1}finally{vs.value=!1}}const lc=()=>Da([],"Provider default restored");async function Xi(v,F,Y){const Te=Qt.value.map(Pe=>{const ct=T(Pe);if(Pe!==v)return ct||Pe;const Ts=typeof ct=="string"?{model:Pe}:{...ct||{model:Pe}};return delete Ts.reasoning_effort,delete Ts.thinking_mode,Y&&(Ts[F]=Y),Object.keys(Ts).length===1?Ts.model:Ts});await Da(Te,"Model default saved")}async function Pa(v,F=""){const[Y,...Te]=v.split("/");if(!Y||!Te.length)throw new Error("OpenRouter model id is not namespaced");return z.post(`/api/openrouter/models/${encodeURIComponent(Y)}/${encodeURIComponent(Te.join("/"))}/select`,{provider_tag:F})}const el=(v,F)=>{var Te;const Y=(((Te=le.value)==null?void 0:Te.measured_cache)||[]).find(Pe=>Pe.model===v&&Pe.upstream_provider===F);return Y?`${Y.cached_percent}% cached over ${Y.samples} calls`:"no measured cache evidence"},ui=v=>v==null?"n/a":`$${(Number(v)*1e6).toFixed(4)}/M`,tl=(v,F)=>{if(v==null)return"n/a";if(typeof v=="number")return Number(v).toLocaleString();const Y=v[F];return Y==null?"n/a":Number(Y).toLocaleString()},sl=v=>{var Pe;const F=[];v.quantization==="fp4"&&F.push("fp4 quantization may change quality");const Y=typeof v.latency_last_30m=="object"?Number((Pe=v.latency_last_30m)==null?void 0:Pe.p99):null,Te=Number(Ze.value.iteration_timeout_seconds||0)*1e3;return Y&&Te&&Y>Te&&F.push("p99 exceeds the agent iteration budget"),F.join("; ")};async function C(v){var F;jt.value=v,ua.value=((F=he.value.openrouter.model_pins)==null?void 0:F[v.id])||"",un.value=!0;try{const[Y,...Te]=v.id.split("/"),Pe=await z.get(`/api/openrouter/models/${encodeURIComponent(Y)}/${encodeURIComponent(Te.join("/"))}/endpoints`);as.value=Pe.endpoints||[]}catch(Y){as.value=[],ze(Y.message||"Failed to load OpenRouter provider routes","error")}finally{un.value=!1}}function V(){jt.value=null,ua.value="",as.value=[]}async function ae(v,F=""){try{await Pa(v.id,F);const Y=Ln(),Te=`compat:${v.id}`;if(Y.some(ct=>Ms(ct)===Te)||Y.push(Te),!await Da(Y,F?"OpenRouter model added and provider pinned.":"OpenRouter model added unpinned."))return;V(),await _a()}catch(Y){ze(Y.message||"Failed to add OpenRouter model","error")}}const Ce=v=>v.startsWith("compat:")?v.slice(7):v,Je=v=>{var F;return((F=he.value.openrouter.model_pins)==null?void 0:F[Ce(v)])||""},Ct=v=>Qt.value.length>1&&Da(Ln().filter(F=>Ms(F)!==v),"Model removed from allowlist");async function Vs(){var v,F;try{const Y=Ln();for(const Te of((v=le.value)==null?void 0:v.quick_add)||[])(F=Et.value.get(Te))!=null&&F.agent_eligible&&(await Pa(Ce(Te),""),Y.some(Pe=>Ms(Pe)===Te)||Y.push(Te));await Da(Y,"Curated OpenRouter models added"),await _a()}catch(Y){ze(Y.message||"Failed to add curated OpenRouter models","error")}}function qs(v){const F=v.hint_metadata||{},Y=[];return F.context_tokens&&Y.push(`context ${Number(F.context_tokens).toLocaleString()}`),F.max_output_tokens&&Y.push(`max output ${Number(F.max_output_tokens).toLocaleString()}`),F.structural_source&&Y.push(`source ${F.structural_source}`),Y.join("; ")}async function pn(v,F){const Y={...Ze.value.model_selection_hints||{}},Te=F.trim();Te?Y[v]=Te:delete Y[v];try{const Pe=await z.put("/api/agents/model",{model_selection_hints:Y});Ze.value={...Ze.value,...Pe},o.agents=r(Ze.value),ze("Model hint saved")}catch(Pe){ze(Pe.message||"Failed to save model hint","error")}}function xa(v,F){const Y=Qt.value.indexOf(v);return!vs.value&&Y>=0&&Y+F>=0&&Y+FMs(Pe)===v);Te<0||!xa(v,F)||([Y[Te],Y[Te+F]]=[Y[Te+F],Y[Te]],await Da(Y,"Agent Auto allowlist order saved"))}const oc=p(!0),rc=p(""),ju=p({configured:null,accounts:[]}),cc=p(null),vo=p(null),dc=p(""),al=p(null),uc=p(!1),pc=p(null),Vu=p(null),qu=p("");let pi=null;function ze(v,F="success"){Se(v,F==="error"?"error":"success")}function Jg(v){if(!v)return"?";const F=v/(1024*1024*1024);return F>=1?F.toFixed(1)+" GB":(v/(1024*1024)).toFixed(0)+" MB"}function Zg(v){return Number.isFinite(Number(v))?Number(v).toLocaleString():"—"}function Yg(v){return v==null?"automatic (model-derived)":Number(v).toLocaleString()+" characters"}function Qg(v){const F=new Date(v);return Number.isNaN(F.getTime())?"unknown":F.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function Xg(v){return typeof v=="string"&&v.length>12?v.slice(0,8)+"…"+v.slice(-4):v}function eb(v){return typeof v!="number"||!Number.isFinite(v)?"—":(v/1e3).toFixed(2)}function tb(v){return v==="temporary learned clamp"?"is-clamp":v==="override"?"is-override":"is-built-in"}function sb(v){const F=k.value.context_budget_overrides[v.model];return v.floor!=null&&Number.isFinite(Number(F))&&Number(F)>v.floor}function ab(v,F){const Y={...k.value.context_budget_overrides};F.target.value===""?delete Y[v]:Y[v]=Number(F.target.value),k.value.context_budget_overrides=Y,te.value=!0}function nb(v){k.value.context_utilization=v.target.value===""?"":Number(v.target.value),te.value=!0}function ib(v){const F={...k.value.context_budget_overrides};delete F[v],k.value.context_budget_overrides=F,te.value=!0}async function _a({quiet:v=!1,poll:F=!1}={}){if(!(F&&Z())){if(l&&await l,i)return i;v||(e.value=!0),i=(async()=>{await Promise.all([Ds({poll:F}),nl(),il(),ho({poll:F}),pa(),Nn({poll:F})]),await di(),Z()||Ee()})();try{await i}finally{i=null,v||(e.value=!1)}}}async function lb(){if(!(Z()||i)){if(l)return l;l=Promise.all([Ds({poll:!0}),nl({refreshModels:!1}),il({refreshModels:!1}),ho({poll:!0}),pa(),Nn({poll:!0})]).then(()=>{Z()||Ee()});try{await l}finally{l=null}}}async function Ds({preserveBasic:v=!1,preserveAdvanced:F=!1,poll:Y=!1}={}){var Te,Pe,ct,Ts,Be,Xt;try{const De=await z.get("/api/llm/status"),Ft=Y&&Z();u.value=De,f.value=!1,Ft||(m.value.main=De.main_model||De.active_model||(De.active_provider==="compat"?`compat:${((Te=De.openai_compatible)==null?void 0:Te.model)||""}`:De.active_provider==="ollama"?`ollama:${((Pe=De.ollama)==null?void 0:Pe.model)||""}`:((ct=De.codex)==null?void 0:ct.model)||"gpt-6-sol")),!Ft&&De.codex&&!fi.pending()&&(v||(k.value.enabled=De.codex.enabled,k.value.model=De.codex.model||"gpt-6-sol",k.value.reasoning_effort=De.codex.reasoning_effort||"medium",k.value.agent_reasoning_effort=De.codex.agent_reasoning_effort||""),F||(k.value.request_timeout_seconds=De.codex.request_timeout_seconds??k.value.request_timeout_seconds,k.value.stream_stall_timeout_seconds=De.codex.stream_stall_timeout_seconds??k.value.stream_stall_timeout_seconds,k.value.retry={...k.value.retry,...De.codex.retry||{}},k.value.connection_pool={...k.value.connection_pool,...De.codex.connection_pool||{}},k.value.context_compression={...k.value.context_compression,...De.codex.context_compression||{}},!te.value&&!$e.value&&(k.value.context_budget_overrides={...De.codex.context_budget_overrides||{}},k.value.context_utilization=De.codex.context_utilization??k.value.context_utilization))),!Ft&&De.ollama&&!mi.pending()&&(v||(oe.value.enabled=De.ollama.enabled,oe.value.base_url=De.ollama.base_url||"",oe.value.model=De.ollama.model||"",oe.value.max_tokens=De.ollama.max_tokens||4096,oe.value.num_ctx=De.ollama.num_ctx||32768),F||(oe.value.timeout=De.ollama.timeout??oe.value.timeout));const Bt=De.openai_compatible;!Ft&&Bt&&!Dn.pending()&&(v||(he.value.enabled=Bt.enabled,he.value.base_url=Bt.base_url||he.value.base_url,he.value.model=Bt.model||he.value.model,he.value.preset=Bt.preset||he.value.preset,he.value.reasoning_effort=Bt.reasoning_effort||"medium"),F||(he.value.request_timeout_seconds=Bt.request_timeout_seconds??he.value.request_timeout_seconds,he.value.stream_stall_timeout_seconds=Bt.stream_stall_timeout_seconds??he.value.stream_stall_timeout_seconds,he.value.model_profiles=Bt.model_profiles||he.value.model_profiles,he.value.context_utilization=Bt.context_utilization??he.value.context_utilization,he.value.openrouter={...he.value.openrouter,...Bt.openrouter||{}})),Ft||((Ts=m.value.main)!=null&&Ts.startsWith("compat:")?m.value.main_capability=(Bt==null?void 0:Bt.reasoning_effort)||"medium":(Be=m.value.main)!=null&&Be.startsWith("ollama:")||(m.value.main_capability=((Xt=De.codex)==null?void 0:Xt.reasoning_effort)||"medium")),!Ft&&De.auxiliary&&(Ne.value=De.auxiliary,Mn.pending()||(ge.value.enabled=De.auxiliary.enabled,ge.value.model=De.auxiliary.model||"gpt-6-luna"))}catch{u.value||(u.value={active_provider:"",codex:{configured:null},ollama:{configured:null},openai_compatible:{configured:null}}),f.value=!0}}async function Nn({poll:v=!1}={}){const F=++X;fe.value=!0,_e.value="";try{const Y=await z.get("/api/context/windows");if(F!==X)return;de.value=Y,!(v&&Z())&&!$e.value&&!te.value&&(k.value.context_budget_overrides=Object.fromEntries(Object.entries(Y.models||{}).filter(([,Te])=>Te.override!=null).map(([Te,Pe])=>[Te,Pe.override])),k.value.context_utilization=Y.utilization??k.value.context_utilization)}catch(Y){F===X&&(_e.value=Y.message||"Failed to load context budgets")}finally{F===X&&(fe.value=!1)}}async function nl({refreshModels:v=!0}={}){try{if(Ae.value=await z.get("/api/ollama/status"),Me.value=!1,Ae.value.model&&(Le.value=Ae.value.model),v&&Ae.value.configured)try{const F=await z.get("/api/ollama/models");ee.value=F.models||[]}catch{ee.value=[]}else if(v&&oe.value.base_url)try{const F=await z.post("/api/ollama/probe-models",{base_url:oe.value.base_url});ee.value=F.models||[]}catch{ee.value=[]}}catch{Me.value=!0}}async function pa(){oc.value=!0,rc.value="";try{ju.value=await z.get("/api/codex/status")}catch(v){rc.value=v.message||"Failed to fetch Codex status"}finally{oc.value=!1}}async function ob(){try{m.value.main.startsWith("compat:")&&ns.value&&await Pa(Ce(m.value.main),Je(m.value.main));try{await z.put("/api/llm/main-model",{model:m.value.main})}catch(v){if(!/404|not found/i.test(v.message||""))throw v;await z.post("/api/llm/switch",{model:m.value.main})}c("mainModel",m.value.main),ze("Main model saved"),await _a()}catch(v){ze(v.message||"Failed to save main model","error"),await Ds()}}async function rb(v){m.value.main_capability=v;const F=R.value;F&&(F.provider==="compat"&&F.capability!=="none"?(he.value.reasoning_effort=v,await ol()):F.capability==="reasoning"?(k.value.reasoning_effort=v,await ll()):F.capability==="thinking"&&(await z.put("/api/openai-compatible/config",{thinking_mode:v}),ze("Thinking mode saved")))}async function cb(v){m.value.agent_capability=v;const F=w.value,Y=(F==null?void 0:F.capability)||L.value;if(Y!=="none"){if(v===""||v==="auto"||Y==="reasoning"||Y==="mixed"){if(k.value.agent_reasoning_effort=v,Y==="thinking"){const Te=await z.put("/api/agents/model",{thinking_mode:null});Ze.value={...Ze.value,...Te}}await ll()}else if(Y==="thinking"){const Te=await z.put("/api/agents/model",{thinking_mode:v});Ze.value={...Ze.value,...Te},ze("Agent thinking mode saved")}}}async function db(){He.value=!0;try{const v=await z.post("/api/ollama/reload");ze(v.configured?"Ollama reloaded":v.reason||"Ollama not configured",v.configured?"success":"error"),await _a()}catch(v){ze(v.message||"Reload failed","error")}finally{He.value=!1}}async function ub(){rt.value=!0;try{await z.post("/api/ollama/model",{model:Le.value}),ze("Model set to "+Le.value),await _a()}catch(v){ze(v.message||"Failed","error")}finally{rt.value=!1}}async function pb(){const v=oe.value.base_url;if(!v){ze("Enter a base URL first","error");return}Xe.value=!0;try{const F=await z.post("/api/ollama/probe-models",{base_url:v});ee.value=F.models||[],ee.value.length?(ze(ee.value.length+" model(s) found"),!oe.value.model&&ee.value.length&&(oe.value.model=ee.value[0].name)):ze("No models found at "+v,"error")}catch(F){ze(F.message||"Could not reach Ollama","error")}finally{Xe.value=!1}}async function il({refreshModels:v=!0}={}){try{if(Zt.value=await z.get("/api/openai-compatible/status"),lt.value=!1,Zt.value.model&&(cs.value=Zt.value.model),v&&Zt.value.configured)try{const F=await z.get("/api/openai-compatible/models");gt.value=F.models||[]}catch{gt.value=[]}}catch{lt.value=!0}}async function fb(){oa.value=!0;try{const v=await z.post("/api/openai-compatible/reload");ze(v.configured?"OpenAI-compatible reloaded":v.reason||"OpenAI-compatible not configured",v.configured?"success":"error"),await _a()}catch(v){ze(v.message||"Reload failed","error")}finally{oa.value=!1}}async function mb(){js.value=!0;try{await z.post("/api/openai-compatible/model",{model:cs.value}),ze("Model set to "+cs.value),await _a()}catch(v){ze(v.message||"Failed","error")}finally{js.value=!1}}async function ll(){if($e.value){fi();return}$e.value=!0;const v=Do(k.value);try{await z.put("/api/llm/codex/config",v),c("codexBasic",v),ze("Codex config saved"),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),pa()])}catch(F){ze(F.message||"Failed","error");const Y=JSON.stringify(Do(k.value))!==JSON.stringify(v);await Promise.all([Ds({preserveBasic:Y,preserveAdvanced:!0}),pa()])}finally{$e.value=!1}}async function Gu(){if($e.value)return;$e.value=!0;const v=Po(k.value);try{await z.put("/api/llm/codex/config",v),c("codexAdvanced",v),JSON.stringify({context_budget_overrides:k.value.context_budget_overrides,context_utilization:k.value.context_utilization})===JSON.stringify({context_budget_overrides:v.context_budget_overrides,context_utilization:v.context_utilization})&&(te.value=!1),ze("Codex advanced settings saved"),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),pa(),Nn()])}catch(F){ze(F.message||"Failed","error");const Y=JSON.stringify(Po(k.value))!==JSON.stringify(v);await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:Y}),pa(),Nn()])}finally{$e.value=!1}}async function fc(){if(Ke.value){mi();return}Ke.value=!0;try{const v=Fe.value?oe.value.api_key:null,F=$o(oe.value,{includeApiKey:v!==null});await z.put("/api/llm/ollama/config",F),ze("Ollama config saved"),v!==null&&oe.value.api_key===v&&(oe.value.api_key="",Fe.value=!1),c("ollamaBasic",$o(oe.value)),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),nl()])}catch(v){ze(v.message||"Failed","error")}finally{Ke.value=!1}}async function Wu(){if(!Ke.value){Ke.value=!0;try{const v=$c(oe.value);await z.put("/api/llm/ollama/config",v),c("ollamaAdvanced",v),ze("Ollama timeout saved"),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),nl()])}catch(v){ze(v.message||"Failed","error")}finally{Ke.value=!1}}}async function ol(){if(at.value){Dn();return}at.value=!0;try{const v=Ve.value?he.value.api_key:null,F=Mo(he.value,{includeApiKey:v!==null});await z.put("/api/openai-compatible/config",F),ze("OpenAI-compatible config saved"),v!==null&&he.value.api_key===v&&(he.value.api_key="",Ve.value=!1),c("compatibleBasic",Mo(he.value)),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),il()]),await di()}catch(v){ze(v.message||"Failed","error")}finally{at.value=!1}}async function Ku(){if(!at.value){at.value=!0;try{const v=Pc(he.value);await z.put("/api/openai-compatible/config",v),c("compatibleAdvanced",v),ze("OpenAI-compatible endpoint settings saved"),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),il()]),await di()}catch(v){ze(v.message||"Failed","error")}finally{at.value=!1}}}async function hb(){if(G.value){Mn();return}G.value=!0;try{const v={...ge.value};await z.put("/api/llm/auxiliary/config",v),c("auxiliary",v),ze("Auxiliary config saved"),await Ds()}catch(v){ze(v.message||"Failed","error"),await Ds()}finally{G.value=!1}}const fi=Fo(ll),Mn=Fo(hb),mi=Fo(fc),Dn=Fo(ol),vb=()=>(fi.cancel(),ll()),gb=()=>(mi.cancel(),fc()),bb=()=>(Dn.cancel(),ol()),yb=()=>Gu(),xb=()=>Wu(),_b=()=>Ku();async function wb(v){const F=v.account_key+":"+v.model;q.value=F;try{const Y=await z.post("/api/context/windows/clear",{account_key:v.account_key,model:v.model});ze(Y.cleared?"Temporary clamp cleared":"Clamp was already inactive"),await Nn()}catch(Y){ze(Y.message||"Failed to clear clamp","error"),await Nn()}finally{q.value=null}}async function kb(v){try{await z.post("/api/codex/account/"+v+"/activate"),ze("Active account switched"),await pa()}catch(F){ze(F.message||"Failed","error")}}async function Sb(v){cc.value=v;try{await z.post("/api/codex/account/"+v+"/refresh"),ze("Token refreshed"),await pa()}catch(F){ze(F.message||"Refresh failed","error")}finally{cc.value=null}}function Cb(v,F){vo.value=v,dc.value=F||""}async function Tb(v){try{await z.put("/api/codex/account/"+v+"/label",{label:dc.value}),ze("Label updated"),vo.value=null,await pa()}catch(F){ze(F.message||"Failed","error")}}async function Eb(v,F){if(await os({title:"Delete Codex account",message:`Delete ${F||"account #"+(v+1)}? The pool will reload without it.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/codex/account/"+v),ze("Deleted. Pool reloaded."),await pa()}catch(Te){ze(Te.message||"Failed","error")}}async function Ab(){uc.value=!0;try{const v=await z.post("/api/codex/device-code");pc.value=v,al.value="pending",Rb(v)}catch(v){ze(v.message||"Failed","error")}finally{uc.value=!1}}async function Rb(v){pi={cancelled:!1};const F=pi;try{const Y=await z.post("/api/codex/device-poll",{device_auth_id:v.device_auth_id,user_code:v.user_code,interval:v.interval});if(F.cancelled)return;Vu.value=Y,al.value="success",await _a()}catch(Y){if(F.cancelled)return;qu.value=Y.message||"Device login failed",al.value="error"}}function Ib(){pi&&(pi.cancelled=!0),al.value=null,pc.value=null}function Ju(){n||(n=!0,_a(),a=window.setInterval(lb,s))}function Zu(){n&&(n=!1,a!==null&&window.clearInterval(a),a=null)}return st(Ju),rs(Ju),Kt(Zu),xt(()=>{Zu(),pi&&(pi.cancelled=!0),fi.cancel(),Mn.cancel(),mi.cancel(),Dn.cancel()}),{pageRoot:t,allowlistModalOpen:ra,closeAllowlistModal:ba,allowlistSaving:vs,effectiveAllowlist:Qt,allowlistSummary:_t,resetAgentAllowlist:lc,selectedUnavailableReason:rn,selectedModelFacts:ca,loading:e,llmStatus:u,llmStatusLoadFailed:f,modelSelection:m,modelSelectorSearch:h,reasoningEfforts:b,neutralReasoningLevels:I,modelCatalog:g,modelGroups:x,selectedMainModel:R,selectedAgentModel:w,selectedAgentCapabilityValue:re,agentCapabilityKind:L,agentCapabilityEfforts:D,autoAllowlistModels:Q,allowlistModel:O,allowlistModelEfforts:B,allowlistEntryCapabilityValue:$,modelOptionLabel:ce,agentModelAvailable:S,agentModelOptionLabel:_,advancedOpen:me,codexForm:k,codexModelOptions:J,codexAgentModelOptions:K,mainEffortAllowed:Oe,agentEffortAllowed:ne,mainModelOptionDisabled:ve,agentModelOptionDisabled:be,auxForm:ge,auxData:Ne,auxModelOptions:E,onAuxModelChange:P,savingAux:G,saveAuxConfigDebounced:Mn,ollamaForm:oe,compatibleForm:he,savingCodex:$e,savingOllama:Ke,savingCompatible:at,probingOllama:Xe,ollamaKeyDirty:Fe,compatibleKeyDirty:Ve,fetchCodexStatus:pa,ollamaStatus:Ae,ollamaStatusLoadFailed:Me,ollamaModels:ee,ollamaSelectedModel:Le,reloading:He,settingModel:rt,compatibleStatus:Zt,compatibleStatusLoadFailed:lt,compatibleModels:gt,visibleCompatibleModels:Yt,compatibleSelectedModel:cs,reloadingCompatible:oa,settingCompatibleModel:js,applyCompatiblePreset:Na,setOpenRouterList:ci,agentsConfig:Ze,compatibleAgentModels:Nt,ollamaAgentModels:mo,knownAgentModelRefs:Ma,agentModelLabel:ac,saveAgentsModel:nc,toggleAgentAutoAllowlist:ic,saveAllowlistEntryCapability:Xi,autoAllowlistGroups:A,structuralFacts:qs,saveModelHint:pn,canMoveAllowlist:xa,moveAgentAutoAllowlist:Kg,openRouterCatalogue:le,openRouterCatalogueLoading:ke,openRouterCatalogueError:qe,openRouterRecognized:ns,compatibleCatalogueStatus:On,compatibleCatalogueStatusClass:Re,openRouterSearch:kt,openRouterVendor:St,openRouterVendors:M,openRouterToolsOnly:cn,openRouterEligibleOnly:An,openRouterStandardOnly:dn,openRouterMeasuredCacheOnly:Rn,openRouterMaxPromptPrice:da,openRouterQuantization:ya,openRouterQuantizations:pe,openRouterResults:We,openRouterMatchCount:Qe,openRouterInlineFacts:At,openRouterSelectedFacts:bs,prepareOpenRouterModel:C,addOpenRouterModel:ae,removeOpenRouterModel:Ct,quickAddOpenRouter:Vs,openRouterModelMap:Et,openRouterPin:Je,openRouterPendingModel:jt,openRouterPendingTag:ua,openRouterPendingEndpoints:as,openRouterPendingLoading:un,openRouterEndpointSort:In,openRouterSortedPendingEndpoints:oi,openRouterEndpointCacheFact:el,openRouterRate:ui,openRouterMetric:tl,openRouterRouteWarning:sl,cancelOpenRouterPending:V,codexLoading:oc,codexError:rc,codexData:ju,refreshing:cc,editingLabel:vo,labelValue:dc,contextWindows:de,contextWindowsLoading:fe,contextWindowsError:_e,contextBudgetRows:xe,activeClampRows:U,activeContextBudget:ue,clearingClamp:q,contextPolicyDirty:te,deviceState:al,deviceLoading:uc,deviceInfo:pc,deviceResult:Vu,deviceError:qu,fetchAll:_a,fetchLLMStatus:Ds,fetchOllamaStatus:nl,fetchCompatibleStatus:il,saveMainModel:ob,saveMainCapability:rb,saveAgentCapability:cb,reloadOllama:db,setOllamaModel:ub,reloadCompatible:fb,setCompatibleModel:mb,probeOllamaModels:pb,saveCodexConfig:ll,saveOllamaConfig:fc,saveCompatibleConfig:ol,saveCodexAdvancedConfig:Gu,saveOllamaAdvancedConfig:Wu,saveCompatibleAdvancedConfig:Ku,saveCodexConfigDebounced:fi,saveOllamaConfigDebounced:mi,saveCompatibleConfigDebounced:Dn,saveCodexConfigNow:vb,saveOllamaConfigNow:gb,saveCompatibleConfigNow:bb,saveCodexAdvancedConfigNow:yb,saveOllamaAdvancedConfigNow:xb,saveCompatibleAdvancedConfigNow:_b,activateAccount:kb,refreshAccount:Sb,startEditLabel:Cb,saveLabel:Tb,deleteAccount:Eb,startDeviceLogin:Ab,cancelDeviceLogin:Ib,formatSize:Jg,fetchContextWindows:Nn,clearContextClamp:wb,setContextOverride:ab,setContextUtilization:nb,resetContextOverride:ib,overrideAboveFloor:sb,formatCount:Zg,formatContextCeiling:Yg,formatExpiry:Qg,shortAccountKey:Xg,provenanceClass:tb,formatDensity:eb}}},Bf={ok:"text-green-400",pass:"text-green-400",degraded:"text-yellow-400",warn:"text-yellow-400",down:"text-red-400",fail:"text-red-400",unconfigured:"text-gray-500",skipped:"text-gray-500"};function CC(e){return Bf[e]||Bf[(e||"").toLowerCase()]||"text-gray-400"}const TC={template:` + `,setup(){const e=p(!0),t=p(null),s=15e3;let a=null,n=!1,i=null,l=null;const o={codexBasic:null,codexAdvanced:null,ollamaBasic:null,ollamaAdvanced:null,compatibleBasic:null,compatibleAdvanced:null,auxiliary:null,mainModel:null,agents:null},r=v=>JSON.stringify(v),c=(v,F)=>{o[v]=r(F)},d=(v,F)=>o[v]!==null&&o[v]!==r(F),u=p(null),f=p(!1),m=p({main:"",main_capability:"medium",agent_capability:"adaptive"}),h=p(""),g=["none","low","medium","high","xhigh","max"],A=["none","low","medium","high","xhigh","max"],w=p({enabled:!1,model:"gpt-6-sol",reasoning_effort:"xhigh",agent_reasoning_effort:"auto",request_timeout_seconds:3600,stream_stall_timeout_seconds:180,retry:{max_retries:3,base_delay:1,max_delay:30},connection_pool:{max_connections:10,keepalive_timeout:30},context_compression:{enabled:!0,max_context_chars:null,keep_recent_iterations:30},context_budget_overrides:{},context_utilization:60}),y=["gpt-6-astra","gpt-6-sol","gpt-6-luna","gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"],b=U(()=>{const v=u.value||{},F=v.model_catalogue||v.model_catalog||{},ee=Be=>{var Xt,De,Bt;return((Xt=be.value.model_profiles)==null?void 0:Xt[Be])||((Bt=(De=be.value.openrouter)==null?void 0:De.catalogue_profiles)==null?void 0:Bt[Be])||null},Ee=(Be,Xt,De)=>Xt.map(Bt=>{var ap,np;const Ut=typeof Bt=="string"?Bt:Bt.name,Gs=Be==="compat"?ee(Ut):null;return{ref:Be==="codex"?Bt:`${Be}:${Ut}`,name:Ut,provider:Be,available:!!(De!=null&&De.enabled&&(Be==="codex"?De.configured:(ap=De.health)!=null&&ap.healthy)),unavailable_reason:De!=null&&De.enabled?De!=null&&De.configured?!((np=De==null?void 0:De.health)!=null&&np.healthy)&&Be!=="codex"?"unreachable":"":"not configured":"disabled",capability:Be==="codex"||Gs!=null&&Gs.supports_reasoning?"reasoning":Gs!=null&&Gs.supports_thinking_mode?"thinking":"none",efforts:Gs==null?void 0:Gs.supported_efforts,profile:Gs}}),Pe=[...F.codex||Ee("codex",y,v.codex),...F.compat||F.openai_compatible||[],...F.ollama||Ee("ollama",ae.value,v.ollama)].map(Be=>typeof Be=="string"?{ref:Be,name:Be,provider:"codex",available:!0,capability:"reasoning"}:Be),ct=Be=>{const Xt=Pe.findIndex(De=>De.ref===Be.ref);Xt===-1?Pe.push(Be):Pe[Xt]={...Pe[Xt],...Be}};for(const Be of Ee("compat",gt.value,v.openai_compatible))ct(Be);if(ns.value&&Ge.value.length)for(const Be of Ge.value){const Xt=`compat:${Be.id}`;ct({ref:Xt,name:Be.name||Be.id,provider:"compat",available:!0,unavailable_reason:"",capability:Be.supports_reasoning?"reasoning":"none",efforts:g.filter(De=>{var Bt;return(Bt=Be.supported_efforts)==null?void 0:Bt.includes(De)}),agent_available:Be.agent_eligible&&!!Be.profile,agent_unavailable_reason:Be.agent_unavailable_reason||""})}const Ts=new Set(Pe.map(Be=>Be.ref));for(const Be of[m.value.main,Ze.value.model,...Yt.value])Be&&Be!=="auto"&&!Ts.has(Be)&&Pe.unshift({ref:Be,name:Be.replace(/^(compat|ollama):/,""),provider:Be.split(":")[0]||"codex",available:!1,unavailable_reason:"unavailable",capability:Be.startsWith("ollama:")||Be.includes(":")?"none":"reasoning"});return Pe.filter(Be=>!ns.value||Be.provider!=="compat"||Be.ref.slice(7).includes("/")).map(Be=>Be.efforts?{...Be,efforts:g.filter(Xt=>Be.efforts.includes(Xt)&&!q(Be.ref,Xt))}:Be)}),x=U(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].map(([v,F])=>({id:v,label:F,models:b.value.filter(ee=>{if(ee.provider!==v)return!1;const Ee=h.value.trim().toLowerCase();return!Ee||`${ee.name} ${ee.ref}`.toLowerCase().includes(Ee)})})).filter(v=>v.models.length)),S=v=>v.available&&v.agent_available!==!1,_=v=>v.available?`${v.name}${v.agent_available===!1?` (${v.agent_unavailable_reason||"not agent-eligible"})`:""}`:ue(v),T=U(()=>[["codex","Codex"],["compat","OpenAI-compatible"],["ollama","Ollama"]].map(([v,F])=>({id:v,label:F,models:b.value.filter(ee=>ee.provider===v&&!(v==="compat"&&ns.value)&&`${ee.ref} ${ee.name}`.toLowerCase().includes(kt.value.trim().toLowerCase()))})).filter(v=>v.models.length)),L=U(()=>b.value.find(v=>v.ref===m.value.main)),E=U(()=>b.value.find(v=>v.ref===Ze.value.model)),P=v=>b.value.find(F=>F.ref===v),R=v=>{const F=P(v);return(F==null?void 0:F.provider)==="codex"?g.filter(ee=>!q(v,ee)):(F==null?void 0:F.efforts)||[]},I=v=>gs.value.find(F=>Ms(F)===v),H=(v,F)=>{const ee=I(v);return typeof ee=="string"?"":(ee==null?void 0:ee[F])||""},X=U(()=>Yt.value.map(v=>P(v)).filter(v=>v&&S(v))),Q=U(()=>[...new Set(X.value.map(v=>v.capability!=="reasoning"?v.capability||"none":v.provider==="codex"?"codex_reasoning":"compatible_reasoning"))]),N=U(()=>Q.value.length>1),O=U(()=>{var F;if(Ze.value.model!=="auto")return((F=E.value)==null?void 0:F.capability)||"none";if(N.value)return"mixed";const v=Q.value[0]||"none";return v.endsWith("_reasoning")?"reasoning":v}),$=U(()=>{var v;return Ze.value.model==="auto"?g:((v=E.value)==null?void 0:v.efforts)||g}),ce=U(()=>O.value==="thinking"?Ze.value.thinking_mode??w.value.agent_reasoning_effort??"":w.value.agent_reasoning_effort??""),ue=v=>`${v.name}${v.available?"":` (${v.unavailable_reason||"unavailable"})`}`,Z=U(()=>{const v=w.value.model;return v&&!y.includes(v)?[v,...y]:y}),J=U(()=>{const v=Ze.value.model;return v&&v!=="auto"&&!y.includes(v)?[v,...y]:y}),se={"gpt-5.4":["max"],"gpt-5.4-mini":["max"],"gpt-6-astra":["none"]},q=(v,F)=>!!v&&!!F&&(se[v]||[]).includes(F),ye=U(()=>{const v=Ze.value.model;return v&&!v.includes(":")?v:null}),Oe=v=>!q(w.value.model,v)&&!(w.value.agent_reasoning_effort===""&&q(ye.value,v)),le=v=>{const F=Ze.value.model;return F==="auto"?!0:!q(F||w.value.model,v)},oe=U(()=>{const v=w.value.agent_reasoning_effort;return v==="auto"?null:v||w.value.reasoning_effort}),ve=v=>q(v,w.value.reasoning_effort)||Ze.value.model===""&&q(v,oe.value),j=v=>q(v,oe.value),V=p({enabled:!1,model:"gpt-6-luna"}),xe=p({unavailable_reason:null}),k=U(()=>{const v=V.value.model;return v&&!y.includes(v)?[v,...y]:y});function D(v){const F=v.target.value;V.value.enabled=F!=="",F!==""&&(V.value.model=F),F.startsWith("compat:")&&ns.value?Pa(Te(F),Je(F)).then(()=>Dn()).catch(ee=>ze(ee.message||"Failed to prepare OpenRouter model","error")):Dn()}const W=p(!1),ge=p({codex:!1,ollama:!1,compatible:!1}),pe=p(null),he=p(!1),we=p(""),K=p(null),ne=p(!1);let te=0;const _e=U(()=>{var v;return Object.entries(((v=pe.value)==null?void 0:v.models)||{}).map(([F,ee])=>{var Ee,Pe,ct;return{model:F,floor:ee.floor,override:ee.override,effectiveBudget:(Ee=ee.effective)==null?void 0:Ee.effective_budget,configuredPrimaryChars:(Pe=ee.configured)==null?void 0:Pe.primary_chars,primaryChars:(ct=ee.effective)==null?void 0:ct.primary_chars,provenance:ee.provenance,clampExpiresAt:ee.clamp_expires_at,densityPriorMilli:ee.density_prior_milli,densityScope:ee.density_scope,workloadCalibration:ee.workload_calibration}})}),B=U(()=>{var v;return((v=pe.value)==null?void 0:v.clamps)||[]}),fe=U(()=>{var v,F;return((F=(v=pe.value)==null?void 0:v.models)==null?void 0:F[w.value.model])||null}),de=p({enabled:!1,base_url:"",model:"",api_key:"",max_tokens:4096,num_ctx:32768,timeout:300}),be=p({enabled:!1,base_url:"https://api.deepseek.com/v1",api_key:"",model:"deepseek-v4-flash",request_timeout_seconds:3600,stream_stall_timeout_seconds:180,preset:"deepseek",reasoning_effort:"medium",model_profiles:{},context_utilization:75,openrouter:{order:[],allow_fallbacks:!0,quantizations:[],sort:null,data_collection:null,reasoning_effort:"medium",model_pins:{},catalogue_profiles:{}}}),Fe=p(!1),qe=p(!1),$e=p(!1),Ke=p(!1),at=p(!1),Xe=p(!1);function Y(){var F,ee,Ee;return!!(((F=t.value)==null?void 0:F.contains(document.activeElement))&&((Ee=(ee=document.activeElement)==null?void 0:ee.matches)==null?void 0:Ee.call(ee,'input, textarea, select, [contenteditable="true"]'))||ra.value||go.value!==null||ne.value||Fe.value||qe.value||vs.value||$e.value||Ke.value||at.value||W.value||mi.pending()||hi.pending()||Pn.pending()||Dn.pending()||d("codexBasic",Uo(w.value))||d("codexAdvanced",zo(w.value))||d("ollamaBasic",Ho(de.value))||d("ollamaAdvanced",Hc(de.value))||d("compatibleBasic",Bo(be.value))||d("compatibleAdvanced",zc(be.value))||d("auxiliary",V.value)||d("mainModel",m.value.main)||d("agents",Ze.value))}function Ae(){o.codexBasic=r(Uo(w.value)),o.codexAdvanced=r(zo(w.value)),o.ollamaBasic=r(Ho(de.value)),o.ollamaAdvanced=r(Hc(de.value)),o.compatibleBasic=r(Bo(be.value)),o.compatibleAdvanced=r(zc(be.value)),o.auxiliary=r(V.value),o.mainModel=r(m.value.main)}const Re=p({configured:null}),Me=p(!1),ae=p([]),Ne=p(""),He=p(!1),rt=p(!1),Zt=p({configured:null}),lt=p(!1),gt=p([]),Qt=U(()=>ns.value?Ge.value.map(v=>v.id):gt.value),cs=p(""),oa=p(!1),js=p(!1),Ze=p({model:"auto",thinking_mode:null,auto_model_allowlist:[]}),ra=p(!1),vs=p(!1),Ms=v=>typeof v=="string"?v:v==null?void 0:v.model,gs=U(()=>(Ze.value.auto_model_allowlist||[]).map(v=>typeof v=="string"?v:{...v}).filter(v=>Ms(v))),An=U(()=>!w.value.enabled&&be.value.enabled?be.value.model?[`compat:${be.value.model}`]:[]:!w.value.enabled&&!be.value.enabled&&de.value.enabled?de.value.model?[`ollama:${de.value.model}`]:[]:y),Yt=U(()=>gs.value.length?gs.value.map(Ms):An.value),_t=U(()=>{var v;return(v=Ze.value.auto_model_allowlist)!=null&&v.length?`Allowlist: ${Yt.value.length} models`:`Default: ${Yt.value.join(", ")||"no available agent models"}`}),ds=new Map;function ba(){ra.value=!1,G()}const rn=v=>{if(ns.value&&v.startsWith("compat:")&&!v.slice(7).includes("/"))return"OpenRouter requires a namespaced vendor/model ID; this is a direct-endpoint profile.";const F=b.value.find(ee=>ee.ref===v);return F?S(F)?"":F.agent_unavailable_reason||F.unavailable_reason||"Not agent-eligible":"Model is absent from the current endpoint catalogue."},ca=v=>{if(Et.value.has(v))return bs(v);const F=b.value.find(Ee=>Ee.ref===v),ee=(F==null?void 0:F.hint_metadata)||{};return[ee.hint||ee.hint_derived,ee.as_of&&`as of ${ee.as_of}`,ee.scope_note,ee.evidence&&`Evidence: ${ee.evidence}`,F&&Vs(F)].filter(Boolean).join(" · ")||"No catalogue hint. Add an operator hint below."},re=p(null),Ce=p(!1),Ve=p(""),Ge=U(()=>{var v;return(((v=re.value)==null?void 0:v.models)||[]).map(F=>{var Ee,Pe,ct;const ee=((Ee=be.value.model_profiles)==null?void 0:Ee[F.id])||F.profile||((ct=(Pe=be.value.openrouter)==null?void 0:Pe.catalogue_profiles)==null?void 0:ct[F.id])||(F.context_length>0&&F.max_completion_tokens>0?{total_window_tokens:F.context_length,max_output_tokens:F.max_completion_tokens}:null);return{...F,profile:ee}})}),kt=p(""),St=p(""),cn=p(!0),Rn=p(!0),dn=p(!0),In=p(!1),da=p(null),ya=p(""),qt=p(null),ua=p(""),as=p([]),un=p(!1),On=p("throughput"),oi=(v,F,ee=null)=>{const Ee=v[F],Pe=ee&&Ee&&typeof Ee=="object"?Ee[ee]:Ee,ct=Number(Pe);return Number.isFinite(ct)?ct:null},ri=U(()=>{const v=[...as.value],F=On.value;return v.sort((ee,Ee)=>{var Ut,Gs;if(F==="quantization")return String(ee.quantization).localeCompare(String(Ee.quantization));const[Pe,ct,Ts]=F==="throughput"?["throughput_last_30m","p50",!0]:F==="latency_p99"?["latency_last_30m","p99",!1]:F==="cache_price"?["cache_read_per_token",null,!1]:["prompt_per_token",null,!1],Be=(Ut=ee.pricing)==null?void 0:Ut[Pe],Xt=(Gs=Ee.pricing)==null?void 0:Gs[Pe],De=ct?oi(ee,Pe,ct):Be==null?null:Number(Be),Bt=ct?oi(Ee,Pe,ct):Xt==null?null:Number(Xt);return Number.isFinite(De)?Number.isFinite(Bt)?Ts?Bt-De:De-Bt:-1:1})}),ci=U(()=>{try{return new URL(be.value.base_url).hostname}catch{return""}}),ns=U(()=>/(^|\.)openrouter\.ai$/i.test(ci.value)),Ln=U(()=>{var v,F;return ns.value?Ce.value?"OpenRouter recognized · fetching catalogue…":Ve.value?`OpenRouter recognized · catalogue failed: ${Ve.value}`:`OpenRouter recognized · ${((F=(v=re.value)==null?void 0:v.models)==null?void 0:F.length)||0} catalogue models loaded`:gt.value.length?`${gt.value.length} endpoint models loaded`:"Catalogue not loaded"}),Ie=U(()=>Ve.value?"text-red-400":"text-gray-500"),M=U(()=>{var v;return[...new Set((((v=re.value)==null?void 0:v.models)||[]).map(F=>F.vendor))].sort()}),me=U(()=>{var v;return[...new Set((((v=re.value)==null?void 0:v.models)||[]).flatMap(F=>(F.endpoints||[]).map(ee=>ee.quantization)).filter(Boolean))].sort()}),Se=v=>{var F;return(((F=re.value)==null?void 0:F.measured_cache)||[]).some(ee=>ee.model===v.id&&ee.samples>0&&ee.cached_percent>0)},Ue=U(()=>{const v=kt.value.trim().toLowerCase();return Ge.value.filter(F=>{var ee;return!(v&&!`${F.id} ${F.name} ${F.vendor}`.toLowerCase().includes(v)||St.value&&F.vendor!==St.value||cn.value&&!F.supports_tools||Rn.value&&!F.agent_eligible||dn.value&&F.variant!=="standard"||In.value&&!Se(F)||da.value!=null&&Number((ee=F.pricing)==null?void 0:ee.prompt_per_token)*1e6>Number(da.value)||ya.value&&!(F.endpoints||[]).some(Ee=>Ee.quantization===ya.value))})}),We=U(()=>Ue.value.slice(0,100)),Ye=U(()=>Ue.value.length),Et=U(()=>new Map(Ge.value.map(v=>[`compat:${v.id}`,v]))),pt=v=>v==null?"n/a":`$${(Number(v)*1e6).toFixed(3)}/M`,At=v=>{var F,ee,Ee,Pe;return[v.vendor,v.context_length?`${Number(v.context_length).toLocaleString()} ctx`:"context unknown",`${pt((F=v.pricing)==null?void 0:F.prompt_per_token)} in`,`${pt((ee=v.pricing)==null?void 0:ee.completion_per_token)} out`,`${pt((Ee=v.pricing)==null?void 0:Ee.cache_read_per_token)} cache read`,`${pt((Pe=v.pricing)==null?void 0:Pe.cache_write_per_token)} cache write`,v.supports_tools?"tools":"no tools",v.supports_reasoning?"reasoning":"no reasoning",v.variant!=="standard"?v.variant:null].filter(Boolean).join(" · ")},bs=v=>{var Pe;const F=Et.value.get(v);if(!F)return"Catalogue facts unavailable";const ee=(((Pe=re.value)==null?void 0:Pe.measured_cache)||[]).filter(ct=>ct.model===F.id),Ee=ee.length?ee.map(ct=>`${ct.upstream_provider}: ${ct.cached_percent}% cached`).join(" · "):"No measured cache evidence yet";return`${At(F)} · ${Ee}${F.profile_conflict?" · operator profile conflicts with catalogue":""}`},Nt=U(()=>gt.value.map(v=>typeof v=="string"?v:v.name).filter(Boolean)),Na=async()=>{var F,ee,Ee;const v=(Ee=(ee=(F=u.value)==null?void 0:F.openai_compatible)==null?void 0:ee.preset_catalogue)==null?void 0:Ee[be.value.preset];v&&(be.value.base_url=v.base_url),Pn.cancel(),await rl()},di=(v,F)=>{be.value.openrouter[v]=F.split(",").map(ee=>ee.trim()).filter(Boolean)},ho=U(()=>ae.value||[]),Ma=U(()=>{const v=[...y,...Nt.value.map(F=>`compat:${F}`),...ho.value.map(F=>`ollama:${F.name}`)];for(const F of[Ze.value.model,...Yt.value])F&&F!=="auto"&&!v.includes(F)&&v.unshift(F);return v}),rc=v=>v==="codex-auto-review"?"codex-auto-review (Codex alias → gpt-5.6-luna)":v;async function vo({poll:v=!1}={}){try{const F=await z.get("/api/agents/model");if(v&&Y())return;Ze.value={...Ze.value,...F},o.agents=r(Ze.value)}catch{}}async function ui(){if(!ns.value){re.value=null,Ve.value="";return}Ce.value=!0;try{re.value=await z.get("/api/openrouter/catalogue"),Ve.value=""}catch(v){Ve.value=v.message||"Failed to load OpenRouter catalogue"}finally{Ce.value=!1}}async function cc(){var v;try{(v=Ze.value.model)!=null&&v.startsWith("compat:")&&ns.value&&await Pa(Te(Ze.value.model),Je(Ze.value.model));const F=await z.put("/api/agents/model",{model:Ze.value.model||null});Ze.value={...Ze.value,...F},o.agents=r(Ze.value),ze("Agent model policy saved")}catch(F){ze(F.message||"Failed to save agent model policy","error")}}const Nn=()=>[...gs.value.length?gs.value:Yt.value];async function dc(v,F){const ee=Nn(),Ee=ee.findIndex(Ts=>Ms(Ts)===v);let Pe=null;if(F.target.checked&&Ee<0&&ee.push(ds.get(v)||v),!F.target.checked&&Ee>=0&&([Pe]=ee.splice(Ee,1)),!ee.length){F.target.checked=!0,ze("Keep one model selected. An empty list restores the provider default.","error");return}const ct=await Da(ee,"Agent Auto allowlist saved");ct&&F.target.checked&&ds.delete(v),ct&&!F.target.checked&&typeof Pe=="object"&&ds.set(v,Pe),ct||(F.target.checked=Yt.value.includes(v))}async function Da(v,F){if(vs.value)return!1;vs.value=!0;try{const ee=await z.put("/api/agents/model",{auto_model_allowlist:v});return Ze.value={...Ze.value,...ee},o.agents=r(Ze.value),ze(F),!0}catch(ee){return ze(ee.message||"Failed to save agent allowlist","error"),!1}finally{vs.value=!1}}const uc=()=>Da([],"Provider default restored");async function el(v,F,ee){const Ee=Yt.value.map(Pe=>{const ct=I(Pe);if(Pe!==v)return ct||Pe;const Ts=typeof ct=="string"?{model:Pe}:{...ct||{model:Pe}};return delete Ts.reasoning_effort,delete Ts.thinking_mode,ee&&(Ts[F]=ee),Object.keys(Ts).length===1?Ts.model:Ts});await Da(Ee,"Model default saved")}async function Pa(v,F=""){const[ee,...Ee]=v.split("/");if(!ee||!Ee.length)throw new Error("OpenRouter model id is not namespaced");return z.post(`/api/openrouter/models/${encodeURIComponent(ee)}/${encodeURIComponent(Ee.join("/"))}/select`,{provider_tag:F})}const tl=(v,F)=>{var Ee;const ee=(((Ee=re.value)==null?void 0:Ee.measured_cache)||[]).find(Pe=>Pe.model===v&&Pe.upstream_provider===F);return ee?`${ee.cached_percent}% cached over ${ee.samples} calls`:"no measured cache evidence"},pi=v=>v==null?"n/a":`$${(Number(v)*1e6).toFixed(4)}/M`,sl=(v,F)=>{if(v==null)return"n/a";if(typeof v=="number")return Number(v).toLocaleString();const ee=v[F];return ee==null?"n/a":Number(ee).toLocaleString()},al=v=>{var Pe;const F=[];v.quantization==="fp4"&&F.push("fp4 quantization may change quality");const ee=typeof v.latency_last_30m=="object"?Number((Pe=v.latency_last_30m)==null?void 0:Pe.p99):null,Ee=Number(Ze.value.iteration_timeout_seconds||0)*1e3;return ee&&Ee&&ee>Ee&&F.push("p99 exceeds the agent iteration budget"),F.join("; ")};async function C(v){var F;qt.value=v,ua.value=((F=be.value.openrouter.model_pins)==null?void 0:F[v.id])||"",un.value=!0;try{const[ee,...Ee]=v.id.split("/"),Pe=await z.get(`/api/openrouter/models/${encodeURIComponent(ee)}/${encodeURIComponent(Ee.join("/"))}/endpoints`);as.value=Pe.endpoints||[]}catch(ee){as.value=[],ze(ee.message||"Failed to load OpenRouter provider routes","error")}finally{un.value=!1}}function G(){qt.value=null,ua.value="",as.value=[]}async function ie(v,F=""){try{await Pa(v.id,F);const ee=Nn(),Ee=`compat:${v.id}`;if(ee.some(ct=>Ms(ct)===Ee)||ee.push(Ee),!await Da(ee,F?"OpenRouter model added and provider pinned.":"OpenRouter model added unpinned."))return;G(),await _a()}catch(ee){ze(ee.message||"Failed to add OpenRouter model","error")}}const Te=v=>v.startsWith("compat:")?v.slice(7):v,Je=v=>{var F;return((F=be.value.openrouter.model_pins)==null?void 0:F[Te(v)])||""},Ct=v=>Yt.value.length>1&&Da(Nn().filter(F=>Ms(F)!==v),"Model removed from allowlist");async function qs(){var v,F;try{const ee=Nn();for(const Ee of((v=re.value)==null?void 0:v.quick_add)||[])(F=Et.value.get(Ee))!=null&&F.agent_eligible&&(await Pa(Te(Ee),""),ee.some(Pe=>Ms(Pe)===Ee)||ee.push(Ee));await Da(ee,"Curated OpenRouter models added"),await _a()}catch(ee){ze(ee.message||"Failed to add curated OpenRouter models","error")}}function Vs(v){const F=v.hint_metadata||{},ee=[];return F.context_tokens&&ee.push(`context ${Number(F.context_tokens).toLocaleString()}`),F.max_output_tokens&&ee.push(`max output ${Number(F.max_output_tokens).toLocaleString()}`),F.structural_source&&ee.push(`source ${F.structural_source}`),ee.join("; ")}async function pn(v,F){const ee={...Ze.value.model_selection_hints||{}},Ee=F.trim();Ee?ee[v]=Ee:delete ee[v];try{const Pe=await z.put("/api/agents/model",{model_selection_hints:ee});Ze.value={...Ze.value,...Pe},o.agents=r(Ze.value),ze("Model hint saved")}catch(Pe){ze(Pe.message||"Failed to save model hint","error")}}function xa(v,F){const ee=Yt.value.indexOf(v);return!vs.value&&ee>=0&&ee+F>=0&&ee+FMs(Pe)===v);Ee<0||!xa(v,F)||([ee[Ee],ee[Ee+F]]=[ee[Ee+F],ee[Ee]],await Da(ee,"Agent Auto allowlist order saved"))}const pc=p(!0),fc=p(""),Ju=p({configured:null,accounts:[]}),mc=p(null),go=p(null),hc=p(""),nl=p(null),vc=p(!1),gc=p(null),Zu=p(null),Qu=p("");let fi=null;function ze(v,F="success"){ke(v,F==="error"?"error":"success")}function tb(v){if(!v)return"?";const F=v/(1024*1024*1024);return F>=1?F.toFixed(1)+" GB":(v/(1024*1024)).toFixed(0)+" MB"}function sb(v){return Number.isFinite(Number(v))?Number(v).toLocaleString():"—"}function ab(v){return v==null?"automatic (model-derived)":Number(v).toLocaleString()+" characters"}function nb(v){const F=new Date(v);return Number.isNaN(F.getTime())?"unknown":F.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function ib(v){const F=new Date(Number(v)*1e3);return Number.isNaN(F.getTime())?"unknown":F.toLocaleString([],{dateStyle:"medium",timeStyle:"short"})}function lb(v){const F=Number(v);if(!Number.isFinite(F)||F<=0)return"unknown";const ee=Math.max(0,Math.floor((Date.now()/1e3-F)/60));return ee<1?"<1 min":`${ee} min`}function ob(v){return typeof v=="string"&&v.length>12?v.slice(0,8)+"…"+v.slice(-4):v}function rb(v){return typeof v!="number"||!Number.isFinite(v)?"—":(v/1e3).toFixed(2)}function cb(v){return v==="temporary learned clamp"?"is-clamp":v==="override"?"is-override":"is-built-in"}function db(v){const F=w.value.context_budget_overrides[v.model];return v.floor!=null&&Number.isFinite(Number(F))&&Number(F)>v.floor}function ub(v,F){const ee={...w.value.context_budget_overrides};F.target.value===""?delete ee[v]:ee[v]=Number(F.target.value),w.value.context_budget_overrides=ee,ne.value=!0}function pb(v){w.value.context_utilization=v.target.value===""?"":Number(v.target.value),ne.value=!0}function fb(v){const F={...w.value.context_budget_overrides};delete F[v],w.value.context_budget_overrides=F,ne.value=!0}async function _a({quiet:v=!1,poll:F=!1}={}){if(!(F&&Y())){if(l&&await l,i)return i;v||(e.value=!0),i=(async()=>{await Promise.all([Ds({poll:F}),il(),ll(),vo({poll:F}),pa(),Mn({poll:F})]),await ui(),Y()||Ae()})();try{await i}finally{i=null,v||(e.value=!1)}}}async function mb(){if(!(Y()||i)){if(l)return l;l=Promise.all([Ds({poll:!0}),il({refreshModels:!1}),ll({refreshModels:!1}),vo({poll:!0}),pa(),Mn({poll:!0})]).then(()=>{Y()||Ae()});try{await l}finally{l=null}}}async function Ds({preserveBasic:v=!1,preserveAdvanced:F=!1,poll:ee=!1}={}){var Ee,Pe,ct,Ts,Be,Xt;try{const De=await z.get("/api/llm/status"),Bt=ee&&Y();u.value=De,f.value=!1,Bt||(m.value.main=De.main_model||De.active_model||(De.active_provider==="compat"?`compat:${((Ee=De.openai_compatible)==null?void 0:Ee.model)||""}`:De.active_provider==="ollama"?`ollama:${((Pe=De.ollama)==null?void 0:Pe.model)||""}`:((ct=De.codex)==null?void 0:ct.model)||"gpt-6-sol")),!Bt&&De.codex&&!mi.pending()&&(v||(w.value.enabled=De.codex.enabled,w.value.model=De.codex.model||"gpt-6-sol",w.value.reasoning_effort=De.codex.reasoning_effort||"medium",w.value.agent_reasoning_effort=De.codex.agent_reasoning_effort||""),F||(w.value.request_timeout_seconds=De.codex.request_timeout_seconds??w.value.request_timeout_seconds,w.value.stream_stall_timeout_seconds=De.codex.stream_stall_timeout_seconds??w.value.stream_stall_timeout_seconds,w.value.retry={...w.value.retry,...De.codex.retry||{}},w.value.connection_pool={...w.value.connection_pool,...De.codex.connection_pool||{}},w.value.context_compression={...w.value.context_compression,...De.codex.context_compression||{}},!ne.value&&!$e.value&&(w.value.context_budget_overrides={...De.codex.context_budget_overrides||{}},w.value.context_utilization=De.codex.context_utilization??w.value.context_utilization))),!Bt&&De.ollama&&!hi.pending()&&(v||(de.value.enabled=De.ollama.enabled,de.value.base_url=De.ollama.base_url||"",de.value.model=De.ollama.model||"",de.value.max_tokens=De.ollama.max_tokens||4096,de.value.num_ctx=De.ollama.num_ctx||32768),F||(de.value.timeout=De.ollama.timeout??de.value.timeout));const Ut=De.openai_compatible;!Bt&&Ut&&!Pn.pending()&&(v||(be.value.enabled=Ut.enabled,be.value.base_url=Ut.base_url||be.value.base_url,be.value.model=Ut.model||be.value.model,be.value.preset=Ut.preset||be.value.preset,be.value.reasoning_effort=Ut.reasoning_effort||"medium"),F||(be.value.request_timeout_seconds=Ut.request_timeout_seconds??be.value.request_timeout_seconds,be.value.stream_stall_timeout_seconds=Ut.stream_stall_timeout_seconds??be.value.stream_stall_timeout_seconds,be.value.model_profiles=Ut.model_profiles||be.value.model_profiles,be.value.context_utilization=Ut.context_utilization??be.value.context_utilization,be.value.openrouter={...be.value.openrouter,...Ut.openrouter||{}})),Bt||((Ts=m.value.main)!=null&&Ts.startsWith("compat:")?m.value.main_capability=(Ut==null?void 0:Ut.reasoning_effort)||"medium":(Be=m.value.main)!=null&&Be.startsWith("ollama:")||(m.value.main_capability=((Xt=De.codex)==null?void 0:Xt.reasoning_effort)||"medium")),!Bt&&De.auxiliary&&(xe.value=De.auxiliary,Dn.pending()||(V.value.enabled=De.auxiliary.enabled,V.value.model=De.auxiliary.model||"gpt-6-luna"))}catch{u.value||(u.value={active_provider:"",codex:{configured:null},ollama:{configured:null},openai_compatible:{configured:null}}),f.value=!0}}async function Mn({poll:v=!1}={}){const F=++te;he.value=!0,we.value="";try{const ee=await z.get("/api/context/windows");if(F!==te)return;pe.value=ee,!(v&&Y())&&!$e.value&&!ne.value&&(w.value.context_budget_overrides=Object.fromEntries(Object.entries(ee.models||{}).filter(([,Ee])=>Ee.override!=null).map(([Ee,Pe])=>[Ee,Pe.override])),w.value.context_utilization=ee.utilization??w.value.context_utilization)}catch(ee){F===te&&(we.value=ee.message||"Failed to load context budgets")}finally{F===te&&(he.value=!1)}}async function il({refreshModels:v=!0}={}){try{if(Re.value=await z.get("/api/ollama/status"),Me.value=!1,Re.value.model&&(Ne.value=Re.value.model),v&&Re.value.configured)try{const F=await z.get("/api/ollama/models");ae.value=F.models||[]}catch{ae.value=[]}else if(v&&de.value.base_url)try{const F=await z.post("/api/ollama/probe-models",{base_url:de.value.base_url});ae.value=F.models||[]}catch{ae.value=[]}}catch{Me.value=!0}}async function pa(){pc.value=!0,fc.value="";try{Ju.value=await z.get("/api/codex/status")}catch(v){fc.value=v.message||"Failed to fetch Codex status"}finally{pc.value=!1}}async function hb(){try{m.value.main.startsWith("compat:")&&ns.value&&await Pa(Te(m.value.main),Je(m.value.main));try{await z.put("/api/llm/main-model",{model:m.value.main})}catch(v){if(!/404|not found/i.test(v.message||""))throw v;await z.post("/api/llm/switch",{model:m.value.main})}c("mainModel",m.value.main),ze("Main model saved"),await _a()}catch(v){ze(v.message||"Failed to save main model","error"),await Ds()}}async function vb(v){m.value.main_capability=v;const F=L.value;F&&(F.provider==="compat"&&F.capability!=="none"?(be.value.reasoning_effort=v,await rl()):F.capability==="reasoning"?(w.value.reasoning_effort=v,await ol()):F.capability==="thinking"&&(await z.put("/api/openai-compatible/config",{thinking_mode:v}),ze("Thinking mode saved")))}async function gb(v){m.value.agent_capability=v;const F=E.value,ee=(F==null?void 0:F.capability)||O.value;if(ee!=="none"){if(v===""||v==="auto"||ee==="reasoning"||ee==="mixed"){if(w.value.agent_reasoning_effort=v,ee==="thinking"){const Ee=await z.put("/api/agents/model",{thinking_mode:null});Ze.value={...Ze.value,...Ee}}await ol()}else if(ee==="thinking"){const Ee=await z.put("/api/agents/model",{thinking_mode:v});Ze.value={...Ze.value,...Ee},ze("Agent thinking mode saved")}}}async function bb(){He.value=!0;try{const v=await z.post("/api/ollama/reload");ze(v.configured?"Ollama reloaded":v.reason||"Ollama not configured",v.configured?"success":"error"),await _a()}catch(v){ze(v.message||"Reload failed","error")}finally{He.value=!1}}async function yb(){rt.value=!0;try{await z.post("/api/ollama/model",{model:Ne.value}),ze("Model set to "+Ne.value),await _a()}catch(v){ze(v.message||"Failed","error")}finally{rt.value=!1}}async function xb(){const v=de.value.base_url;if(!v){ze("Enter a base URL first","error");return}Xe.value=!0;try{const F=await z.post("/api/ollama/probe-models",{base_url:v});ae.value=F.models||[],ae.value.length?(ze(ae.value.length+" model(s) found"),!de.value.model&&ae.value.length&&(de.value.model=ae.value[0].name)):ze("No models found at "+v,"error")}catch(F){ze(F.message||"Could not reach Ollama","error")}finally{Xe.value=!1}}async function ll({refreshModels:v=!0}={}){try{if(Zt.value=await z.get("/api/openai-compatible/status"),lt.value=!1,Zt.value.model&&(cs.value=Zt.value.model),v&&Zt.value.configured)try{const F=await z.get("/api/openai-compatible/models");gt.value=F.models||[]}catch{gt.value=[]}}catch{lt.value=!0}}async function _b(){oa.value=!0;try{const v=await z.post("/api/openai-compatible/reload");ze(v.configured?"OpenAI-compatible reloaded":v.reason||"OpenAI-compatible not configured",v.configured?"success":"error"),await _a()}catch(v){ze(v.message||"Reload failed","error")}finally{oa.value=!1}}async function wb(){js.value=!0;try{await z.post("/api/openai-compatible/model",{model:cs.value}),ze("Model set to "+cs.value),await _a()}catch(v){ze(v.message||"Failed","error")}finally{js.value=!1}}async function ol(){if($e.value){mi();return}$e.value=!0;const v=Uo(w.value);try{await z.put("/api/llm/codex/config",v),c("codexBasic",v),ze("Codex config saved"),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),pa()])}catch(F){ze(F.message||"Failed","error");const ee=JSON.stringify(Uo(w.value))!==JSON.stringify(v);await Promise.all([Ds({preserveBasic:ee,preserveAdvanced:!0}),pa()])}finally{$e.value=!1}}async function Yu(){if($e.value)return;$e.value=!0;const v=zo(w.value);try{await z.put("/api/llm/codex/config",v),c("codexAdvanced",v),JSON.stringify({context_budget_overrides:w.value.context_budget_overrides,context_utilization:w.value.context_utilization})===JSON.stringify({context_budget_overrides:v.context_budget_overrides,context_utilization:v.context_utilization})&&(ne.value=!1),ze("Codex advanced settings saved"),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),pa(),Mn()])}catch(F){ze(F.message||"Failed","error");const ee=JSON.stringify(zo(w.value))!==JSON.stringify(v);await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:ee}),pa(),Mn()])}finally{$e.value=!1}}async function bc(){if(Ke.value){hi();return}Ke.value=!0;try{const v=Fe.value?de.value.api_key:null,F=Ho(de.value,{includeApiKey:v!==null});await z.put("/api/llm/ollama/config",F),ze("Ollama config saved"),v!==null&&de.value.api_key===v&&(de.value.api_key="",Fe.value=!1),c("ollamaBasic",Ho(de.value)),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),il()])}catch(v){ze(v.message||"Failed","error")}finally{Ke.value=!1}}async function Xu(){if(!Ke.value){Ke.value=!0;try{const v=Hc(de.value);await z.put("/api/llm/ollama/config",v),c("ollamaAdvanced",v),ze("Ollama timeout saved"),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),il()])}catch(v){ze(v.message||"Failed","error")}finally{Ke.value=!1}}}async function rl(){if(at.value){Pn();return}at.value=!0;try{const v=qe.value?be.value.api_key:null,F=Bo(be.value,{includeApiKey:v!==null});await z.put("/api/openai-compatible/config",F),ze("OpenAI-compatible config saved"),v!==null&&be.value.api_key===v&&(be.value.api_key="",qe.value=!1),c("compatibleBasic",Bo(be.value)),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),ll()]),await ui()}catch(v){ze(v.message||"Failed","error")}finally{at.value=!1}}async function ep(){if(!at.value){at.value=!0;try{const v=zc(be.value);await z.put("/api/openai-compatible/config",v),c("compatibleAdvanced",v),ze("OpenAI-compatible endpoint settings saved"),await Promise.all([Ds({preserveBasic:!0,preserveAdvanced:!0}),ll()]),await ui()}catch(v){ze(v.message||"Failed","error")}finally{at.value=!1}}}async function kb(){if(W.value){Dn();return}W.value=!0;try{const v={...V.value};await z.put("/api/llm/auxiliary/config",v),c("auxiliary",v),ze("Auxiliary config saved"),await Ds()}catch(v){ze(v.message||"Failed","error"),await Ds()}finally{W.value=!1}}const mi=jo(ol),Dn=jo(kb),hi=jo(bc),Pn=jo(rl),Sb=()=>(mi.cancel(),ol()),Cb=()=>(hi.cancel(),bc()),Tb=()=>(Pn.cancel(),rl()),Eb=()=>Yu(),Ab=()=>Xu(),Rb=()=>ep();async function Ib(v){const F=v.account_key+":"+v.model;K.value=F;try{const ee=await z.post("/api/context/windows/clear",{account_key:v.account_key,model:v.model});ze(ee.cleared?"Temporary clamp cleared":"Clamp was already inactive"),await Mn()}catch(ee){ze(ee.message||"Failed to clear clamp","error"),await Mn()}finally{K.value=null}}async function Ob(v){try{await z.post("/api/codex/account/"+v+"/activate"),ze("Active account switched"),await pa()}catch(F){ze(F.message||"Failed","error")}}async function Lb(v){mc.value=v;try{await z.post("/api/codex/account/"+v+"/refresh"),ze("Token refreshed"),await pa()}catch(F){ze(F.message||"Refresh failed","error")}finally{mc.value=null}}function Nb(v,F){go.value=v,hc.value=F||""}async function Mb(v){try{await z.put("/api/codex/account/"+v+"/label",{label:hc.value}),ze("Label updated"),go.value=null,await pa()}catch(F){ze(F.message||"Failed","error")}}async function Db(v,F){if(await os({title:"Delete Codex account",message:`Delete ${F||"account #"+(v+1)}? The pool will reload without it.`,confirmLabel:"Delete",danger:!0}))try{await z.del("/api/codex/account/"+v),ze("Deleted. Pool reloaded."),await pa()}catch(Ee){ze(Ee.message||"Failed","error")}}async function Pb(){vc.value=!0;try{const v=await z.post("/api/codex/device-code");gc.value=v,nl.value="pending",$b(v)}catch(v){ze(v.message||"Failed","error")}finally{vc.value=!1}}async function $b(v){fi={cancelled:!1};const F=fi;try{const ee=await z.post("/api/codex/device-poll",{device_auth_id:v.device_auth_id,user_code:v.user_code,interval:v.interval});if(F.cancelled)return;Zu.value=ee,nl.value="success",await _a()}catch(ee){if(F.cancelled)return;Qu.value=ee.message||"Device login failed",nl.value="error"}}function Fb(){fi&&(fi.cancelled=!0),nl.value=null,gc.value=null}function tp(){n||(n=!0,_a(),a=window.setInterval(mb,s))}function sp(){n&&(n=!1,a!==null&&window.clearInterval(a),a=null)}return tt(tp),rs(tp),Kt(sp),xt(()=>{sp(),fi&&(fi.cancelled=!0),mi.cancel(),Dn.cancel(),hi.cancel(),Pn.cancel()}),{pageRoot:t,allowlistModalOpen:ra,closeAllowlistModal:ba,allowlistSaving:vs,effectiveAllowlist:Yt,allowlistSummary:_t,resetAgentAllowlist:uc,selectedUnavailableReason:rn,selectedModelFacts:ca,loading:e,llmStatus:u,llmStatusLoadFailed:f,modelSelection:m,modelSelectorSearch:h,reasoningEfforts:g,neutralReasoningLevels:A,modelCatalog:b,modelGroups:x,selectedMainModel:L,selectedAgentModel:E,selectedAgentCapabilityValue:ce,agentCapabilityKind:O,agentCapabilityEfforts:$,autoAllowlistModels:X,allowlistModel:P,allowlistModelEfforts:R,allowlistEntryCapabilityValue:H,modelOptionLabel:ue,agentModelAvailable:S,agentModelOptionLabel:_,advancedOpen:ge,codexForm:w,codexModelOptions:Z,codexAgentModelOptions:J,mainEffortAllowed:Oe,agentEffortAllowed:le,mainModelOptionDisabled:ve,agentModelOptionDisabled:j,auxForm:V,auxData:xe,auxModelOptions:k,onAuxModelChange:D,savingAux:W,saveAuxConfigDebounced:Dn,ollamaForm:de,compatibleForm:be,savingCodex:$e,savingOllama:Ke,savingCompatible:at,probingOllama:Xe,ollamaKeyDirty:Fe,compatibleKeyDirty:qe,fetchCodexStatus:pa,ollamaStatus:Re,ollamaStatusLoadFailed:Me,ollamaModels:ae,ollamaSelectedModel:Ne,reloading:He,settingModel:rt,compatibleStatus:Zt,compatibleStatusLoadFailed:lt,compatibleModels:gt,visibleCompatibleModels:Qt,compatibleSelectedModel:cs,reloadingCompatible:oa,settingCompatibleModel:js,applyCompatiblePreset:Na,setOpenRouterList:di,agentsConfig:Ze,compatibleAgentModels:Nt,ollamaAgentModels:ho,knownAgentModelRefs:Ma,agentModelLabel:rc,saveAgentsModel:cc,toggleAgentAutoAllowlist:dc,saveAllowlistEntryCapability:el,autoAllowlistGroups:T,structuralFacts:Vs,saveModelHint:pn,canMoveAllowlist:xa,moveAgentAutoAllowlist:eb,openRouterCatalogue:re,openRouterCatalogueLoading:Ce,openRouterCatalogueError:Ve,openRouterRecognized:ns,compatibleCatalogueStatus:Ln,compatibleCatalogueStatusClass:Ie,openRouterSearch:kt,openRouterVendor:St,openRouterVendors:M,openRouterToolsOnly:cn,openRouterEligibleOnly:Rn,openRouterStandardOnly:dn,openRouterMeasuredCacheOnly:In,openRouterMaxPromptPrice:da,openRouterQuantization:ya,openRouterQuantizations:me,openRouterResults:We,openRouterMatchCount:Ye,openRouterInlineFacts:At,openRouterSelectedFacts:bs,prepareOpenRouterModel:C,addOpenRouterModel:ie,removeOpenRouterModel:Ct,quickAddOpenRouter:qs,openRouterModelMap:Et,openRouterPin:Je,openRouterPendingModel:qt,openRouterPendingTag:ua,openRouterPendingEndpoints:as,openRouterPendingLoading:un,openRouterEndpointSort:On,openRouterSortedPendingEndpoints:ri,openRouterEndpointCacheFact:tl,openRouterRate:pi,openRouterMetric:sl,openRouterRouteWarning:al,cancelOpenRouterPending:G,codexLoading:pc,codexError:fc,codexData:Ju,refreshing:mc,editingLabel:go,labelValue:hc,contextWindows:pe,contextWindowsLoading:he,contextWindowsError:we,contextBudgetRows:_e,activeClampRows:B,activeContextBudget:fe,clearingClamp:K,contextPolicyDirty:ne,deviceState:nl,deviceLoading:vc,deviceInfo:gc,deviceResult:Zu,deviceError:Qu,fetchAll:_a,fetchLLMStatus:Ds,fetchOllamaStatus:il,fetchCompatibleStatus:ll,saveMainModel:hb,saveMainCapability:vb,saveAgentCapability:gb,reloadOllama:bb,setOllamaModel:yb,reloadCompatible:_b,setCompatibleModel:wb,probeOllamaModels:xb,saveCodexConfig:ol,saveOllamaConfig:bc,saveCompatibleConfig:rl,saveCodexAdvancedConfig:Yu,saveOllamaAdvancedConfig:Xu,saveCompatibleAdvancedConfig:ep,saveCodexConfigDebounced:mi,saveOllamaConfigDebounced:hi,saveCompatibleConfigDebounced:Pn,saveCodexConfigNow:Sb,saveOllamaConfigNow:Cb,saveCompatibleConfigNow:Tb,saveCodexAdvancedConfigNow:Eb,saveOllamaAdvancedConfigNow:Ab,saveCompatibleAdvancedConfigNow:Rb,activateAccount:Ob,refreshAccount:Lb,startEditLabel:Nb,saveLabel:Mb,deleteAccount:Db,startDeviceLogin:Pb,cancelDeviceLogin:Fb,formatSize:tb,fetchContextWindows:Mn,clearContextClamp:Ib,setContextOverride:ub,setContextUtilization:pb,resetContextOverride:fb,overrideAboveFloor:db,formatCount:sb,formatContextCeiling:ab,formatExpiry:nb,shortAccountKey:ob,provenanceClass:cb,formatDensity:rb,quotaBlocks:IC,quotaFailureVisible:OC,quotaAge:lb,formatQuotaDate:ib}}},Vf={ok:"text-green-400",pass:"text-green-400",degraded:"text-yellow-400",warn:"text-yellow-400",down:"text-red-400",fail:"text-red-400",unconfigured:"text-gray-500",skipped:"text-gray-500"};function $C(e){return Vf[e]||Vf[(e||"").toLowerCase()]||"text-gray-400"}const FC={template:`
@@ -6351,7 +6386,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p(!0),t=p({}),s=p([]),a=p({}),n=p({}),i=p(null),l=p(null),o=p(null),r=p(null),c=p(null),d=H(()=>{var _;return Object.values(((_=i.value)==null?void 0:_.totals)||{}).reduce((A,R)=>A+Number(R||0),0)}),u=p(""),f=p(0),m=p([]),h=H(()=>m.value.map(_=>`${_.label} (${_.path}${_.reason?`: ${_.reason}`:""})`).join("; ")),b=Object.freeze([{key:"startup",label:"Startup diagnostics",path:"/api/startup/diagnostics"},{key:"subsystems",label:"Subsystem status",path:"/api/subsystems/status"},{key:"sshPool",label:"SSH pool",path:"/api/pools/ssh"},{key:"httpPool",label:"HTTP pool",path:"/api/pools/http"},{key:"riskStats",label:"Risk stats",path:"/api/risk/stats"},{key:"recoveryStats",label:"Recovery stats",path:"/api/recovery/stats"},{key:"compressionStats",label:"Compression stats",path:"/api/compression/stats"},{key:"freshnessStats",label:"Freshness stats",path:"/api/freshness/stats"},{key:"governorStats",label:"Governor stats",path:"/api/governor/stats"}]);let I=null;async function k(){var O;const _=await Promise.allSettled(b.map(B=>z.get(B.path))),A=B=>_[B].status==="fulfilled"?_[B].value:null;t.value=A(0)||{};const R=A(1);s.value=Array.isArray(R)?R:R&&R.subsystems||[],a.value=A(2)||{},n.value=A(3)||{},i.value=A(4),l.value=A(5),o.value=A(6),r.value=A(7),c.value=A(8);const w=_.filter(B=>B.status==="rejected");if(m.value=_.flatMap((B,T)=>{var $;return B.status==="rejected"?[{...b[T],reason:(($=B.reason)==null?void 0:$.message)||"request failed"}]:[]}),f.value=m.value.length,w.length===_.length){const B=(O=w[0])==null?void 0:O.reason;u.value=(B==null?void 0:B.message)||"Failed to load internals"}else u.value="";e.value=!1}function y(){e.value=!0,u.value="",k()}let g=!1;function x(){g||(g=!0,k(),I||(I=setInterval(k,3e4)))}function S(){g&&(g=!1,I&&(clearInterval(I),I=null))}return st(x),rs(x),Kt(S),xt(S),{loading:e,error:u,failedCount:f,failedEndpoints:m,failedEndpointSummary:h,endpoints:b,retry:y,startup:t,subsystems:s,sshPool:a,httpPool:n,riskStats:i,riskTotal:d,recoveryStats:l,compressionStats:o,freshnessStats:r,governorStats:c,statusColor:CC,formatAgeSeconds:KS}}},EC=1e4,Uf=3e4;function hl(e,t){return Math.max(0,e-t)}function Fc(e,t){return new Set((e.operations||[]).map(a=>a.state)).has("MANUAL_RESOLUTION_REQUIRED")?0:e.expired_lease||e.status==="ACTIVE"&&(!e.lease_expires_at||e.lease_expires_at{var _;return Object.values(((_=i.value)==null?void 0:_.totals)||{}).reduce((T,L)=>T+Number(L||0),0)}),u=p(""),f=p(0),m=p([]),h=U(()=>m.value.map(_=>`${_.label} (${_.path}${_.reason?`: ${_.reason}`:""})`).join("; ")),g=Object.freeze([{key:"startup",label:"Startup diagnostics",path:"/api/startup/diagnostics"},{key:"subsystems",label:"Subsystem status",path:"/api/subsystems/status"},{key:"sshPool",label:"SSH pool",path:"/api/pools/ssh"},{key:"httpPool",label:"HTTP pool",path:"/api/pools/http"},{key:"riskStats",label:"Risk stats",path:"/api/risk/stats"},{key:"recoveryStats",label:"Recovery stats",path:"/api/recovery/stats"},{key:"compressionStats",label:"Compression stats",path:"/api/compression/stats"},{key:"freshnessStats",label:"Freshness stats",path:"/api/freshness/stats"},{key:"governorStats",label:"Governor stats",path:"/api/governor/stats"}]);let A=null;async function w(){var P;const _=await Promise.allSettled(g.map(R=>z.get(R.path))),T=R=>_[R].status==="fulfilled"?_[R].value:null;t.value=T(0)||{};const L=T(1);s.value=Array.isArray(L)?L:L&&L.subsystems||[],a.value=T(2)||{},n.value=T(3)||{},i.value=T(4),l.value=T(5),o.value=T(6),r.value=T(7),c.value=T(8);const E=_.filter(R=>R.status==="rejected");if(m.value=_.flatMap((R,I)=>{var H;return R.status==="rejected"?[{...g[I],reason:((H=R.reason)==null?void 0:H.message)||"request failed"}]:[]}),f.value=m.value.length,E.length===_.length){const R=(P=E[0])==null?void 0:P.reason;u.value=(R==null?void 0:R.message)||"Failed to load internals"}else u.value="";e.value=!1}function y(){e.value=!0,u.value="",w()}let b=!1;function x(){b||(b=!0,w(),A||(A=setInterval(w,3e4)))}function S(){b&&(b=!1,A&&(clearInterval(A),A=null))}return tt(x),rs(x),Kt(S),xt(S),{loading:e,error:u,failedCount:f,failedEndpoints:m,failedEndpointSummary:h,endpoints:g,retry:y,startup:t,subsystems:s,sshPool:a,httpPool:n,riskStats:i,riskTotal:d,recoveryStats:l,compressionStats:o,freshnessStats:r,governorStats:c,statusColor:$C,formatAgeSeconds:s1}}},BC=1e4,Gf=3e4;function vl(e,t){return Math.max(0,e-t)}function jc(e,t){return new Set((e.operations||[]).map(a=>a.state)).has("MANUAL_RESOLUTION_REQUIRED")?0:e.expired_lease||e.status==="ACTIVE"&&(!e.lease_expires_at||e.lease_expires_at

Turn State

@@ -6512,7 +6547,7 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p(null),t=p(""),s=p(null),a=p(!1),n=p(0),i=p(null),l=p(""),o=p(null),r=p(!1),c=p(0),d=p(Date.now());let u=null,f=0,m=0;async function h(){const L=++f;a.value=!0;try{const D=await z.get("/api/turn-state/turns?limit=100");if(L!==f)return;t.value=D.availability,e.value=D.availability==="available"?D.data:null,s.value=null,n.value=Date.now()}catch(D){if(L!==f)return;s.value=D.message||"Turn-state read failed",D.status===503&&(t.value="unavailable")}L===f&&(a.value=!1)}async function b(){const L=++m;r.value=!0;try{const D=await z.get("/api/turn-state/capacity-breakers");if(L!==m)return;l.value=D.availability,i.value=D.availability==="available"?D.data:null,o.value=null,c.value=Date.now()}catch(D){if(L!==m)return;o.value=D.message||"Breaker read failed",D.status===503&&(l.value="unavailable")}L===m&&(r.value=!1)}function I(){h(),b()}const k=H(()=>e.value!==null&&hl(d.value,n.value)>Uf),y=H(()=>i.value!==null&&hl(d.value,c.value)>Uf),g=H(()=>k.value||y.value),x=H(()=>Math.round(hl(d.value,n.value)/1e3)),S=H(()=>Math.round(hl(d.value,c.value)/1e3));function _(L){return Fc(L,d.value/1e3)}function A(L){return AC[_(L)]}const R=H(()=>{var re;const L=[...((re=e.value)==null?void 0:re.turns)||[]],D=d.value/1e3;return L.sort((ce,J)=>Fc(ce,D)-Fc(J,D)||(J.last_progress_at||0)-(ce.last_progress_at||0))});function w(L){return L.state==="closed"?"badge-success":L.state==="probing"?"badge-warning":"badge-danger"}function O(L){if(L.state==="closed")return"—";const D=hl(d.value,c.value)/1e3,re=Math.max(0,(L.cooldown_remaining_seconds||0)-D);return re>0?`${Math.ceil(re)}s`:L.state==="probing"?"probe in flight":"probe eligible"}function B(L){if(!L)return"";const D=Math.max(0,Math.round(d.value/1e3-L));if(D<90)return`${D}s ago`;const re=Math.round(D/60);return re<90?`${re}m ago`:`${Math.round(re/60)}h ago`}let T=null,$=null,Q=!1;function W(){Q||(Q=!0,I(),T=setInterval(I,EC),u=setInterval(()=>{d.value=Date.now()},1e3),$=ut.onReconnected(I))}function N(){Q&&(Q=!1,T&&(clearInterval(T),T=null),u&&(clearInterval(u),u=null),$&&($(),$=null))}return st(W),rs(W),Kt(N),xt(N),{turnsData:e,turnsAvailability:t,turnsError:s,turnsLoading:a,breakersData:i,breakersAvailability:l,breakersError:o,breakersLoading:r,turnsStale:k,breakersStale:y,anyStale:g,turnsAgeSeconds:x,breakersAgeSeconds:S,sortedTurns:R,priorityOf:_,priorityBadge:A,breakerBadge:w,cooldownLabel:O,ageLabel:B,fetchTurns:h,fetchBreakers:b,refreshAll:I,arm:W,disarm:N}}},IC={setup(){const e=p(""),t=p(""),s=p(!1),a=p(""),n=p(!1),i=p(!1),l=p(!1),o=p(null),r=p(!1);async function c(){n.value=!0,o.value=null,r.value=!1;try{const u=await z.get("/api/update/check");e.value=u.current||"",t.value=u.latest||"",s.value=u.update_available||!1,a.value=u.changelog||"",u.error&&(o.value=u.error),r.value=!0}catch(u){o.value=u.message}finally{n.value=!1}}async function d(){if(await os({title:"Update & restart",message:"Update Odin and restart? Active tasks will be interrupted.",confirmLabel:"Update & Restart",danger:!0})){i.value=!0,o.value=null;try{await z.post("/api/update/apply",{version:"latest"}),l.value=!0,setTimeout(()=>location.reload(),8e3)}catch(f){o.value=f.message}finally{i.value=!1}}}return st(c),{current:e,latest:t,updateAvailable:s,changelog:a,checking:n,applying:i,applied:l,error:o,checkDone:r,checkUpdate:c,applyUpdate:d}},template:` + `,setup(){const e=p(null),t=p(""),s=p(null),a=p(!1),n=p(0),i=p(null),l=p(""),o=p(null),r=p(!1),c=p(0),d=p(Date.now());let u=null,f=0,m=0;async function h(){const O=++f;a.value=!0;try{const $=await z.get("/api/turn-state/turns?limit=100");if(O!==f)return;t.value=$.availability,e.value=$.availability==="available"?$.data:null,s.value=null,n.value=Date.now()}catch($){if(O!==f)return;s.value=$.message||"Turn-state read failed",$.status===503&&(t.value="unavailable")}O===f&&(a.value=!1)}async function g(){const O=++m;r.value=!0;try{const $=await z.get("/api/turn-state/capacity-breakers");if(O!==m)return;l.value=$.availability,i.value=$.availability==="available"?$.data:null,o.value=null,c.value=Date.now()}catch($){if(O!==m)return;o.value=$.message||"Breaker read failed",$.status===503&&(l.value="unavailable")}O===m&&(r.value=!1)}function A(){h(),g()}const w=U(()=>e.value!==null&&vl(d.value,n.value)>Gf),y=U(()=>i.value!==null&&vl(d.value,c.value)>Gf),b=U(()=>w.value||y.value),x=U(()=>Math.round(vl(d.value,n.value)/1e3)),S=U(()=>Math.round(vl(d.value,c.value)/1e3));function _(O){return jc(O,d.value/1e3)}function T(O){return UC[_(O)]}const L=U(()=>{var ce;const O=[...((ce=e.value)==null?void 0:ce.turns)||[]],$=d.value/1e3;return O.sort((ue,Z)=>jc(ue,$)-jc(Z,$)||(Z.last_progress_at||0)-(ue.last_progress_at||0))});function E(O){return O.state==="closed"?"badge-success":O.state==="probing"?"badge-warning":"badge-danger"}function P(O){if(O.state==="closed")return"—";const $=vl(d.value,c.value)/1e3,ce=Math.max(0,(O.cooldown_remaining_seconds||0)-$);return ce>0?`${Math.ceil(ce)}s`:O.state==="probing"?"probe in flight":"probe eligible"}function R(O){if(!O)return"";const $=Math.max(0,Math.round(d.value/1e3-O));if($<90)return`${$}s ago`;const ce=Math.round($/60);return ce<90?`${ce}m ago`:`${Math.round(ce/60)}h ago`}let I=null,H=null,X=!1;function Q(){X||(X=!0,A(),I=setInterval(A,BC),u=setInterval(()=>{d.value=Date.now()},1e3),H=ut.onReconnected(A))}function N(){X&&(X=!1,I&&(clearInterval(I),I=null),u&&(clearInterval(u),u=null),H&&(H(),H=null))}return tt(Q),rs(Q),Kt(N),xt(N),{turnsData:e,turnsAvailability:t,turnsError:s,turnsLoading:a,breakersData:i,breakersAvailability:l,breakersError:o,breakersLoading:r,turnsStale:w,breakersStale:y,anyStale:b,turnsAgeSeconds:x,breakersAgeSeconds:S,sortedTurns:L,priorityOf:_,priorityBadge:T,breakerBadge:E,cooldownLabel:P,ageLabel:R,fetchTurns:h,fetchBreakers:g,refreshAll:A,arm:Q,disarm:N}}},HC={setup(){const e=p(""),t=p(""),s=p(!1),a=p(""),n=p(!1),i=p(!1),l=p(!1),o=p(null),r=p(!1);async function c(){n.value=!0,o.value=null,r.value=!1;try{const u=await z.get("/api/update/check");e.value=u.current||"",t.value=u.latest||"",s.value=u.update_available||!1,a.value=u.changelog||"",u.error&&(o.value=u.error),r.value=!0}catch(u){o.value=u.message}finally{n.value=!1}}async function d(){if(await os({title:"Update & restart",message:"Update Odin and restart? Active tasks will be interrupted.",confirmLabel:"Update & Restart",danger:!0})){i.value=!0,o.value=null;try{await z.post("/api/update/apply",{version:"latest"}),l.value=!0,setTimeout(()=>location.reload(),8e3)}catch(f){o.value=f.message}finally{i.value=!1}}}return tt(c),{current:e,latest:t,updateAvailable:s,changelog:a,checking:n,applying:i,applied:l,error:o,checkDone:r,checkUpdate:c,applyUpdate:d}},template:`

Updates

@@ -6567,7 +6602,7 @@ ${u.text}`:u.text).join(`

{{ error }}

- `},zf=e=>JSON.parse(JSON.stringify(e)),OC=(e,t)=>JSON.stringify(e)===JSON.stringify(t),LC={emits:["saved"],template:` + `},Wf=e=>JSON.parse(JSON.stringify(e)),jC=(e,t)=>JSON.stringify(e)===JSON.stringify(t),qC={emits:["saved"],template:`
@@ -6620,8 +6655,8 @@ ${u.text}`:u.text).join(`

Drafts are local to this view and discarded when leaving it. Backend validation remains authoritative.

- `,setup(e,{emit:t}){const s=p([]),a=p({}),n=p({}),i=p(!1),l=p(!1),o=p(!1),r=p(!1),c=p(!1),d=p(""),u=p("");let f=!1,m=0,h=null;const b=(N,L)=>f&&m===N&&z.token===L,I=N=>"computer-provisioning-"+N.key,k=N=>N===null?"Unset":N===""?"Empty":JSON.stringify(N),y=N=>{const L=n.value[N.key];return N.type==="array"?String(L||"").split(/\r?\n/).map(D=>D.trim()).filter(Boolean):["integer","number"].includes(N.type)?L===""||L==null?null:Number(L):L},g=H(()=>s.value.map(N=>({...N,value:y(N)})).filter(N=>!OC(N.value,a.value[N.key]))),x=H(()=>s.value.filter(N=>N.pending_restart).map(N=>N.label)),S=H(()=>s.value.some(N=>N.apply_state==="unknown")),_=H(()=>{const N={};for(const L of s.value){const D=y(L),re=L.constraints||{};["integer","number"].includes(L.type)&&(D===null&&!L.nullable?N[L.key]="A number is required.":D!==null&&(!Number.isFinite(D)||L.type==="integer"&&!Number.isInteger(D)||re.minimum!=null&&Dre.maximum)&&(N[L.key]="Enter a number within the allowed range.")),L.key==="monitor_names"&&(D.length>16||new Set(D).size!==D.length||D.some(ce=>!/^[A-Za-z0-9_.-]{1,64}$/.test(ce)))&&(N[L.key]="Use up to 16 unique monitor names, one per line (letters, digits, dot, underscore or hyphen).")}return N}),A=H(()=>Object.keys(_.value).length>0);function R(N,L){n.value[N.key]=L,u.value=""}function w(){n.value=Object.fromEntries(s.value.map(N=>[N.key,N.type==="array"?a.value[N.key].join(` -`):a.value[N.key]])),r.value=!1}async function O(N,L){const[D,re]=await Promise.all([z.get("/api/config"),z.get("/api/config/meta")]);if(!b(N,L))return!1;const ce=(re.fields||[]).filter(J=>/^computer\.[^.]+$/.test(J.path)&&J.path!=="computer.enabled"&&J.sensitivity==="public"&&J.apply_mode==="restart");if(!D.computer||!ce.length)throw new Error("Provisioning metadata is unavailable. Reload before editing.");return s.value=ce.map(J=>({...J,key:J.path.split(".")[1]})),a.value=Object.fromEntries(s.value.map(J=>[J.key,zf(D.computer[J.key])])),w(),h=L,i.value=!0,c.value=!1,!0}async function B(){if(!f||l.value||o.value)return;const N=++m,L=z.token;l.value=!0,i.value=!1,d.value="",u.value="",r.value=!1;try{await O(N,L)}catch(D){b(N,L)&&(c.value=!0,d.value=D.message||"Could not load provisioning. No changes were sent.")}finally{b(N,L)&&(l.value=!1)}}function T(){i.value&&!l.value&&!o.value&&!c.value&&g.value.length&&!A.value&&(r.value=!0)}async function $(){if(!f||!i.value||!r.value||o.value||l.value||c.value||A.value||!g.value.length)return;if(h!==z.token){W(),Q();return}const N={computer:Object.fromEntries(g.value.map(ce=>[ce.key,zf(ce.value)]))},L=m,D=z.token;o.value=!0,d.value="",u.value="";let re=!1;try{if(await z.put("/api/config",N),re=!0,!b(L,D))return;await O(L,D)&&(u.value="Provisioning saved and configuration reloaded. Startup settings remain pinned until Odin restarts; no lifecycle action was requested.",t("saved"))}catch(ce){b(L,D)&&(c.value=!0,r.value=!1,d.value=re?"Provisioning was saved, but configuration refresh failed. Reload saved values before editing again.":`Save failed or its outcome is unknown${ce.status===400?": "+ce.message:"."} Reload saved values before editing or retrying. No request was replayed.`)}finally{b(L,D)&&(o.value=!1)}}function Q(){f||(f=!0,B())}function W(){f=!1,m++,i.value=!1,l.value=!1,o.value=!1,r.value=!1,h=null,s.value=[],a.value={},n.value={},d.value="",u.value=""}return st(Q),rs(Q),Kt(W),xt(W),{fields:s,original:a,draft:n,ready:i,loading:l,saving:o,reviewing:r,uncertain:c,error:d,message:u,pending:x,effectiveUnknown:S,changes:g,validation:_,invalid:A,fieldId:I,format:k,edit:R,discard:w,load:B,openReview:T,save:$}}},NC={components:{ComputerProvisioning:LC},template:` + `,setup(e,{emit:t}){const s=p([]),a=p({}),n=p({}),i=p(!1),l=p(!1),o=p(!1),r=p(!1),c=p(!1),d=p(""),u=p("");let f=!1,m=0,h=null;const g=(N,O)=>f&&m===N&&z.token===O,A=N=>"computer-provisioning-"+N.key,w=N=>N===null?"Unset":N===""?"Empty":JSON.stringify(N),y=N=>{const O=n.value[N.key];return N.type==="array"?String(O||"").split(/\r?\n/).map($=>$.trim()).filter(Boolean):["integer","number"].includes(N.type)?O===""||O==null?null:Number(O):O},b=U(()=>s.value.map(N=>({...N,value:y(N)})).filter(N=>!jC(N.value,a.value[N.key]))),x=U(()=>s.value.filter(N=>N.pending_restart).map(N=>N.label)),S=U(()=>s.value.some(N=>N.apply_state==="unknown")),_=U(()=>{const N={};for(const O of s.value){const $=y(O),ce=O.constraints||{};["integer","number"].includes(O.type)&&($===null&&!O.nullable?N[O.key]="A number is required.":$!==null&&(!Number.isFinite($)||O.type==="integer"&&!Number.isInteger($)||ce.minimum!=null&&$ce.maximum)&&(N[O.key]="Enter a number within the allowed range.")),O.key==="monitor_names"&&($.length>16||new Set($).size!==$.length||$.some(ue=>!/^[A-Za-z0-9_.-]{1,64}$/.test(ue)))&&(N[O.key]="Use up to 16 unique monitor names, one per line (letters, digits, dot, underscore or hyphen).")}return N}),T=U(()=>Object.keys(_.value).length>0);function L(N,O){n.value[N.key]=O,u.value=""}function E(){n.value=Object.fromEntries(s.value.map(N=>[N.key,N.type==="array"?a.value[N.key].join(` +`):a.value[N.key]])),r.value=!1}async function P(N,O){const[$,ce]=await Promise.all([z.get("/api/config"),z.get("/api/config/meta")]);if(!g(N,O))return!1;const ue=(ce.fields||[]).filter(Z=>/^computer\.[^.]+$/.test(Z.path)&&Z.path!=="computer.enabled"&&Z.sensitivity==="public"&&Z.apply_mode==="restart");if(!$.computer||!ue.length)throw new Error("Provisioning metadata is unavailable. Reload before editing.");return s.value=ue.map(Z=>({...Z,key:Z.path.split(".")[1]})),a.value=Object.fromEntries(s.value.map(Z=>[Z.key,Wf($.computer[Z.key])])),E(),h=O,i.value=!0,c.value=!1,!0}async function R(){if(!f||l.value||o.value)return;const N=++m,O=z.token;l.value=!0,i.value=!1,d.value="",u.value="",r.value=!1;try{await P(N,O)}catch($){g(N,O)&&(c.value=!0,d.value=$.message||"Could not load provisioning. No changes were sent.")}finally{g(N,O)&&(l.value=!1)}}function I(){i.value&&!l.value&&!o.value&&!c.value&&b.value.length&&!T.value&&(r.value=!0)}async function H(){if(!f||!i.value||!r.value||o.value||l.value||c.value||T.value||!b.value.length)return;if(h!==z.token){Q(),X();return}const N={computer:Object.fromEntries(b.value.map(ue=>[ue.key,Wf(ue.value)]))},O=m,$=z.token;o.value=!0,d.value="",u.value="";let ce=!1;try{if(await z.put("/api/config",N),ce=!0,!g(O,$))return;await P(O,$)&&(u.value="Provisioning saved and configuration reloaded. Startup settings remain pinned until Odin restarts; no lifecycle action was requested.",t("saved"))}catch(ue){g(O,$)&&(c.value=!0,r.value=!1,d.value=ce?"Provisioning was saved, but configuration refresh failed. Reload saved values before editing again.":`Save failed or its outcome is unknown${ue.status===400?": "+ue.message:"."} Reload saved values before editing or retrying. No request was replayed.`)}finally{g(O,$)&&(o.value=!1)}}function X(){f||(f=!0,R())}function Q(){f=!1,m++,i.value=!1,l.value=!1,o.value=!1,r.value=!1,h=null,s.value=[],a.value={},n.value={},d.value="",u.value=""}return tt(X),rs(X),Kt(Q),xt(Q),{fields:s,original:a,draft:n,ready:i,loading:l,saving:o,reviewing:r,uncertain:c,error:d,message:u,pending:x,effectiveUnknown:S,changes:b,validation:_,invalid:T,fieldId:A,format:w,edit:L,discard:E,load:R,openReview:I,save:H}}},VC={components:{ComputerProvisioning:qC,DiscordIdentity:En},template:`
- `,setup(){const e=p({state:"unknown",available:!1}),t=p(!1),s=p(!1),a=p(!1),n=p(!1),i=p(!1),l=p(!1),o=p(""),r=p(!1),c=p(!1),d=p(!1),u=p(0),f=p(""),m=p(""),h=p(null),b=p(""),I=p(!1),k=p(Date.now()),y=p(""),g=p(null);let x=0,S=null,_=!1,A=z.token,R=0,w=null,O=null,B=!1;const T=q=>q===!0?"Enabled":q===!1?"Disabled":"Unknown",$=H(()=>{var q;return((q=e.value.backend)==null?void 0:q.environment)==="existing_session"}),Q=H(()=>{var te;const q=Date.parse(((te=e.value.accessibility)==null?void 0:te.checked_at)||"");return c.value&&Number.isFinite(q)&&k.value-q<15e3&&k.value>=q-5e3}),W=H(()=>{var q;return Q.value?T((q=e.value.accessibility)==null?void 0:q.enabled):"Unknown / not current"}),N=H(()=>{var q;return Q.value?((q=e.value.accessibility)==null?void 0:q.reason)==="property_read"?"Last checked: "+e.value.accessibility.checked_at:"Property unavailable for this target. Isolated sessions, an unbound operator identity, an inactive accessibility service or an inaccessible session bus may prevent this read. Unknown does not mean disabled.":"No current property read; unknown does not mean disabled."}),L=H(()=>Object.entries(e.value.input_limits||{}).filter(([,q])=>typeof q=="number"&&Number.isFinite(q)).map(([q,te])=>`${q}: ${te}`).join(", ")),D=H(()=>{var te;const q=(te=e.value.application_provenance)==null?void 0:te.script_identity;return typeof q=="string"?q:!q||typeof q!="object"?"Not observed":`${q.interpreter_basename||"Unknown interpreter"}; argv digest ${q.argv_digest||"not recorded"}; ${q.verified===!0?"verified":"not verified"}`}),re=H(()=>Array.isArray(e.value.application_profiles)?e.value.application_profiles.filter(q=>q&&typeof q.id=="string"&&typeof q.label=="string"&&["supported","capture_only"].includes(q.input)).slice(0,16):[]),ce=H(()=>{const q=e.value.restart_required;return Array.isArray(q)?q.length?q.join(", "):"None reported":q===!0?"Pending; restart required":q===!1?"None reported":"Unknown"}),J=H(()=>{var te,X;const q=Date.parse(((te=h.value)==null?void 0:te.captured_at)||"");return Number.isFinite(q)&&k.value2097152||!Number.isFinite(Date.parse(xe.frame.expires_at))||Date.parse(xe.frame.expires_at)<=Date.now())throw new Error("Invalid evidence");h.value=xe.frame,b.value=URL.createObjectURL(U),o.value=""}catch(xe){j(q,te)&&Oe(xe)}finally{q===x&&(s.value=!1)}}async function me(){if(!ye()||i.value||!e.value.available)return;g.value=null;const q=x,te=z.token;i.value=!0;try{const X=await z.post("/api/computer/export",{name:y.value});j(q,te)&&(g.value=X,o.value="")}catch(X){j(q,te)&&Oe(X)}finally{q===x&&(i.value=!1)}}async function de(){if(!ye()||l.value||!g.value)return;const q=x,te=z.token,X=g.value;l.value=!0;try{if(!/^[A-Za-z0-9_-]{8,128}$/.test((X==null?void 0:X.artifact_id)||""))throw new Error("Invalid export");const xe=await z.getBlob("/api/computer/download/"+X.artifact_id);if(!j(q,te))return;const U=URL.createObjectURL(xe),ue=document.createElement("a");ue.href=U,ue.download=X.name,ue.click(),setTimeout(()=>URL.revokeObjectURL(U),1e3)}catch(xe){j(q,te)&&Oe(xe)}finally{q===x&&(l.value=!1)}}function fe(){_||(A!==z.token&&(A=z.token,se(),u.value=0,O=null,e.value={state:"unknown",available:!1},o.value="",f.value=""),_=!0,ne(),S=setInterval(()=>{k.value=Date.now(),A!==z.token&&(A=z.token,se(),u.value=0,O=null,o.value="",f.value="",e.value={state:"unknown",available:!1}),c.value&&k.value-u.value>=15e3&&se(),h.value&&Date.parse(h.value.expires_at)<=k.value&&(K(),I.value=!0),g.value&&Date.parse(g.value.expires_at)<=k.value&&(g.value=null),!B&&k.value-R>=5e3&&ne()},500))}function _e(){_=!1,clearInterval(S),S=null,se(),c.value=!1}return st(fe),rs(fe),Kt(_e),xt(_e),{status:e,checkedAt:u,remedy:f,loading:t,observing:s,stopping:a,pausing:n,exporting:i,downloading:l,error:o,frame:h,frameUrl:b,frameExpired:I,freshness:J,name:y,artifact:g,refresh:ne,control:be,observe:G,clearFrame:K,exportFile:me,download:de,toggling:r,adminReady:c,enabledLabel:T,restartSettings:ce,setEnabled:ve,recovering:d,recover:Ne,reconcile:E,releaseOwnedInput:ge,reconciliationAck:m,applicationProfiles:re,attached:$,scriptIdentity:D,inputLimits:L,accessibilityLabel:W,accessibilityDetail:N}}},Lg=[{id:"health",label:"Health",component:H1},{id:"resources",label:"Resources",component:j1},{id:"logs",label:"Logs",component:tC},{id:"config",label:"Config",component:fC},{id:"discord",label:"Discord",component:hC},{id:"hosts",label:"Hosts",component:bC},{id:"host-access",label:"Host Access",component:gC},{id:"api-tokens",label:"API Tokens",component:yC},{id:"llm",label:"LLM Config",component:SC},{id:"internals",label:"Internals",component:TC},{id:"turn-state",label:"Turn State",component:RC},{id:"computer",label:"Computer",component:NC},{id:"update",label:"Update",component:IC}],MC={components:{TabbedPage:Xr},setup(){return{tabs:Lg}},template:''},Bo=(e,t,s,a)=>a.map(({id:n,label:i})=>({group:e,label:i,icon:t,to:{path:s,query:{tab:n}}})),DC=[{group:"Workspace",label:"Dashboard",icon:"dashboard",to:{path:"/dashboard"}},{group:"Workspace",label:"Chat",icon:"chat",to:{path:"/chat"}},...Bo("Operations","operations","/operations",wg),...Bo("History","history","/history",kg),...Bo("Capabilities","capabilities","/capabilities",Sg),{group:"Manage",label:"Personality",icon:"personality",to:{path:"/personality"}},...Bo("System","system","/system",Lg)],Fs=Tn({open:!1,query:"",selected:0});function Hf(){Fs.query="",Fs.selected=0,Fs.open=!0}function Bc(){Fs.open=!1}function PC(e,t){const s=e.label.toLowerCase(),a=`${e.group} ${e.label}`.toLowerCase();return t?s.startsWith(t)?100:a.startsWith(t)?80:s.includes(t)?60:a.includes(t)?40:0:1}const $C={setup(){const e=mg(),t=p(null),s=H(()=>{const i=Fs.query.trim().toLowerCase();return DC.map(l=>({...l,_score:PC(l,i)})).filter(l=>l._score>0).sort((l,o)=>o._score-l._score)});Wt(()=>Fs.open,async i=>{var l;i&&(await zt(),(l=t.value)==null||l.focus())}),Wt(()=>Fs.query,()=>{Fs.selected=0});function a(i){Bc(),e.push(i.to)}function n(i){if(i.key==="Escape"){i.preventDefault(),Bc();return}if(i.key==="ArrowDown")i.preventDefault(),Fs.selected=Math.min(Fs.selected+1,s.value.length-1);else if(i.key==="ArrowUp")i.preventDefault(),Fs.selected=Math.max(Fs.selected-1,0);else if(i.key==="Enter"){i.preventDefault();const l=s.value[Fs.selected];l&&a(l)}}return{state:Fs,results:s,inputEl:t,go:a,onKeydown:n,closePalette:Bc}},template:` + `,setup(){const e=p({state:"unknown",available:!1}),t=p(!1),s=p(!1),a=p(!1),n=p(!1),i=p(!1),l=p(!1),o=p(""),r=p(!1),c=p(!1),d=p(!1),u=p(0),f=p(""),m=p(""),h=p(null),g=p(""),A=p(!1),w=p(Date.now()),y=p(""),b=p(null);let x=0,S=null,_=!1,T=z.token,L=0,E=null,P=null,R=!1;const I=K=>K===!0?"Enabled":K===!1?"Disabled":"Unknown",H=U(()=>{var K;return((K=e.value.backend)==null?void 0:K.environment)==="existing_session"}),X=U(()=>{var ne;const K=Date.parse(((ne=e.value.accessibility)==null?void 0:ne.checked_at)||"");return c.value&&Number.isFinite(K)&&w.value-K<15e3&&w.value>=K-5e3}),Q=U(()=>{var K;return X.value?I((K=e.value.accessibility)==null?void 0:K.enabled):"Unknown / not current"}),N=U(()=>{var K;return X.value?((K=e.value.accessibility)==null?void 0:K.reason)==="property_read"?"Last checked: "+e.value.accessibility.checked_at:"Property unavailable for this target. Isolated sessions, an unbound operator identity, an inactive accessibility service or an inaccessible session bus may prevent this read. Unknown does not mean disabled.":"No current property read; unknown does not mean disabled."}),O=U(()=>Object.entries(e.value.input_limits||{}).filter(([,K])=>typeof K=="number"&&Number.isFinite(K)).map(([K,ne])=>`${K}: ${ne}`).join(", ")),$=U(()=>{var ne;const K=(ne=e.value.application_provenance)==null?void 0:ne.script_identity;return typeof K=="string"?K:!K||typeof K!="object"?"Not observed":`${K.interpreter_basename||"Unknown interpreter"}; argv digest ${K.argv_digest||"not recorded"}; ${K.verified===!0?"verified":"not verified"}`}),ce=U(()=>Array.isArray(e.value.application_profiles)?e.value.application_profiles.filter(K=>K&&typeof K.id=="string"&&typeof K.label=="string"&&["supported","capture_only"].includes(K.input)).slice(0,16):[]),ue=U(()=>{const K=e.value.restart_required;return Array.isArray(K)?K.length?K.join(", "):"None reported":K===!0?"Pending; restart required":K===!1?"None reported":"Unknown"}),Z=U(()=>{var ne,te;const K=Date.parse(((ne=h.value)==null?void 0:ne.captured_at)||"");return Number.isFinite(K)&&w.value2097152||!Number.isFinite(Date.parse(_e.frame.expires_at))||Date.parse(_e.frame.expires_at)<=Date.now())throw new Error("Invalid evidence");h.value=_e.frame,g.value=URL.createObjectURL(B),o.value=""}catch(_e){q(K,ne)&&Oe(_e)}finally{K===x&&(s.value=!1)}}async function ge(){if(!ye()||i.value||!e.value.available)return;b.value=null;const K=x,ne=z.token;i.value=!0;try{const te=await z.post("/api/computer/export",{name:y.value});q(K,ne)&&(b.value=te,o.value="")}catch(te){q(K,ne)&&Oe(te)}finally{K===x&&(i.value=!1)}}async function pe(){if(!ye()||l.value||!b.value)return;const K=x,ne=z.token,te=b.value;l.value=!0;try{if(!/^[A-Za-z0-9_-]{8,128}$/.test((te==null?void 0:te.artifact_id)||""))throw new Error("Invalid export");const _e=await z.getBlob("/api/computer/download/"+te.artifact_id);if(!q(K,ne))return;const B=URL.createObjectURL(_e),fe=document.createElement("a");fe.href=B,fe.download=te.name,fe.click(),setTimeout(()=>URL.revokeObjectURL(B),1e3)}catch(_e){q(K,ne)&&Oe(_e)}finally{K===x&&(l.value=!1)}}function he(){_||(T!==z.token&&(T=z.token,se(),u.value=0,P=null,e.value={state:"unknown",available:!1},o.value="",f.value=""),_=!0,le(),S=setInterval(()=>{w.value=Date.now(),T!==z.token&&(T=z.token,se(),u.value=0,P=null,o.value="",f.value="",e.value={state:"unknown",available:!1}),c.value&&w.value-u.value>=15e3&&se(),h.value&&Date.parse(h.value.expires_at)<=w.value&&(J(),A.value=!0),b.value&&Date.parse(b.value.expires_at)<=w.value&&(b.value=null),!R&&w.value-L>=5e3&&le()},500))}function we(){_=!1,clearInterval(S),S=null,se(),c.value=!1}return tt(he),rs(he),Kt(we),xt(we),{status:e,checkedAt:u,remedy:f,loading:t,observing:s,stopping:a,pausing:n,exporting:i,downloading:l,error:o,frame:h,frameUrl:g,frameExpired:A,freshness:Z,name:y,artifact:b,refresh:le,control:j,observe:W,clearFrame:J,exportFile:ge,download:pe,toggling:r,adminReady:c,enabledLabel:I,restartSettings:ue,setEnabled:ve,recovering:d,recover:xe,reconcile:k,releaseOwnedInput:V,reconciliationAck:m,applicationProfiles:ce,attached:H,scriptIdentity:$,inputLimits:O,accessibilityLabel:Q,accessibilityDetail:N}}},Fg=[{id:"health",label:"Health",component:Y1},{id:"resources",label:"Resources",component:X1},{id:"logs",label:"Logs",component:uC},{id:"config",label:"Config",component:kC},{id:"discord",label:"Discord",component:CC},{id:"hosts",label:"Hosts",component:AC},{id:"host-access",label:"Host Access",component:EC},{id:"api-tokens",label:"API Tokens",component:RC},{id:"llm",label:"LLM Config",component:PC},{id:"internals",label:"Internals",component:FC},{id:"turn-state",label:"Turn State",component:zC},{id:"computer",label:"Computer",component:VC},{id:"update",label:"Update",component:HC}],GC={components:{TabbedPage:nc},setup(){return{tabs:Fg}},template:''},qo=(e,t,s,a)=>a.map(({id:n,label:i})=>({group:e,label:i,icon:t,to:{path:s,query:{tab:n}}})),WC=[{group:"Workspace",label:"Dashboard",icon:"dashboard",to:{path:"/dashboard"}},{group:"Workspace",label:"Chat",icon:"chat",to:{path:"/chat"}},...qo("Operations","operations","/operations",Ag),...qo("History","history","/history",Rg),...qo("Capabilities","capabilities","/capabilities",Ig),{group:"Manage",label:"Personality",icon:"personality",to:{path:"/personality"}},...qo("System","system","/system",Fg)],Fs=Tn({open:!1,query:"",selected:0});function Kf(){Fs.query="",Fs.selected=0,Fs.open=!0}function qc(){Fs.open=!1}function KC(e,t){const s=e.label.toLowerCase(),a=`${e.group} ${e.label}`.toLowerCase();return t?s.startsWith(t)?100:a.startsWith(t)?80:s.includes(t)?60:a.includes(t)?40:0:1}const JC={setup(){const e=xg(),t=p(null),s=U(()=>{const i=Fs.query.trim().toLowerCase();return WC.map(l=>({...l,_score:KC(l,i)})).filter(l=>l._score>0).sort((l,o)=>o._score-l._score)});jt(()=>Fs.open,async i=>{var l;i&&(await Mt(),(l=t.value)==null||l.focus())}),jt(()=>Fs.query,()=>{Fs.selected=0});function a(i){qc(),e.push(i.to)}function n(i){if(i.key==="Escape"){i.preventDefault(),qc();return}if(i.key==="ArrowDown")i.preventDefault(),Fs.selected=Math.min(Fs.selected+1,s.value.length-1);else if(i.key==="ArrowUp")i.preventDefault(),Fs.selected=Math.max(Fs.selected-1,0);else if(i.key==="Enter"){i.preventDefault();const l=s.value[Fs.selected];l&&a(l)}}return{state:Fs,results:s,inputEl:t,go:a,onKeydown:n,closePalette:qc}},template:` - `},Td={brand:"M12 3 4.5 8v8L12 21l7.5-5V8L12 3Zm0 4.2 4.6 3.1L12 16.8l-4.6-6.5L12 7.2Zm0 3.3v3.7",dashboard:"M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-16v4h6V4h-6Z",chat:"M20 15a3 3 0 0 1-3 3H9l-5 3v-6a3 3 0 0 1-1-2.2V7a3 3 0 0 1 3-3h11a3 3 0 0 1 3 3v8Z",operations:"M5 12h3l2-6 4 12 2-6h3M4 4v16h16",history:"M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5M12 7v5l3 2",home:"M3 11.5 12 4l9 7.5M5.5 10v10h13V10M9 20v-6h6v6",users:"M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2m7-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.9m-2-11.8a4 4 0 0 1 0 7.7",capabilities:"M14.7 6.3a4 4 0 0 0-5.6 5.6L4 17v3h3v-2h2v-2h2l1.1-1.1a4 4 0 0 0 5.6-5.6l-3 3-3-3 3-3Z",personality:"M12 3a8 8 0 0 0-8 8c0 4 3 7 7 7v3h3v-3c3 0 6-3 6-7a8 8 0 0 0-8-8ZM8.5 10h.01M15.5 10h.01M9 14c1.7 1.2 4.3 1.2 6 0",system:"M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4",menu:"M4 7h16M4 12h16M4 17h16",panelLeft:"M9 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4V4Zm0 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H9M6 8h.01M6 12h.01",chevronLeft:"m15 18-6-6 6-6",chevronRight:"m9 18 6-6-6-6",chevronDown:"m6 9 6 6 6-6",chevronUp:"m18 15-6-6-6 6",search:"m21 21-4.3-4.3M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z",logout:"M10 17l5-5-5-5m5 5H3m10-8h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5",success:"m5 12 4 4L19 6",warning:"M12 3 2.8 20h18.4L12 3Zm0 6v4m0 3h.01",info:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-8v4m0-8h.01",error:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm-3-12 6 6m0-6-6 6",edit:"M4 20h4l11-11-4-4L4 16v4Zm9-13 4 4",trash:"M4 7h16m-10 4v5m4-5v5M9 4h6l1 3H8l1-3Zm-3 3 1 13h10l1-13",brain:"M9 5a3 3 0 0 0-5 2.2A3.5 3.5 0 0 0 4 14a3 3 0 0 0 5 2.2V5Zm6 0a3 3 0 0 1 5 2.2 3.5 3.5 0 0 1 0 6.8 3 3 0 0 1-5 2.2V5ZM9 9H7m2 4H6m9-4h2m-2 4h3M12 4v16",refresh:"M20 6v5h-5M4 18v-5h5M18.5 10A7 7 0 0 0 6 7.5L4 11m16 2-2 3.5A7 7 0 0 1 5.5 14",close:"M6 6l12 12M18 6 6 18",command:"M7 8a3 3 0 1 1-3-3h3v14a3 3 0 1 1-3-3h13a3 3 0 1 1-3 3V5a3 3 0 1 1 3 3H7Z",external:"M14 4h6v6m0-6-9 9M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6",activity:"M4 12h4l2-5 4 10 2-5h4",shield:"M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z",database:"M20 6c0 1.7-3.6 3-8 3S4 7.7 4 6s3.6-3 8-3 8 1.3 8 3Zm0 0v6c0 1.7-3.6 3-8 3s-8-1.3-8-3V6m16 6v6c0 1.7-3.6 3-8 3s-8-1.3-8-3v-6",server:"M4 4h16v6H4V4Zm0 10h16v6H4v-6Zm3-7h.01M7 17h.01",terminal:"M5 7l4 4-4 4m6 1h8M3 4h18v16H3V4Z",wrench:"M14.7 6.3a4 4 0 0 0-5.6 5.6L4 17v3h3v-2h2v-2h2l1.1-1.1a4 4 0 0 0 5.6-5.6l-3 3-3-3 3-3Z",bot:"M8 4h8m-4-2v2M5 8h14a2 2 0 0 1 2 2v8H3v-8a2 2 0 0 1 2-2Zm3 4h.01M16 12h.01M8 16h8M3 13H1m22 0h-2",workflow:"M5 5h5v5H5V5Zm9 9h5v5h-5v-5ZM10 7.5h4a3 3 0 0 1 3 3V14M7.5 10v4a3 3 0 0 0 3 3H14",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-18c2.2 2.5 3.3 5.5 3.3 9S14.2 18.5 12 21m0-18C9.8 5.5 8.7 8.5 8.7 12s1.1 6.5 3.3 9M3 12h18",book:"M4 5a3 3 0 0 1 3-2h5v17H7a3 3 0 0 0-3 1V5Zm16 0a3 3 0 0 0-3-2h-5v17h5a3 3 0 0 1 3 1V5Z",message:"M4 4h16v13H8l-4 4V4Zm4 5h8m-8 4h5",puzzle:"M9 4h3a2 2 0 1 1 4 0h4v5a2 2 0 1 0 0 4v7h-7a2 2 0 1 1-4 0H4v-7a2 2 0 1 0 0-4V4h5",sparkles:"m12 3 1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2L12 3Zm6 10 .8 2.2L21 16l-2.2.8L18 19l-.8-2.2L15 16l2.2-.8L18 13ZM5 14l1 2.8L9 18l-3 1.2L5 22l-1-2.8L1 18l3-1.2L5 14Z",link:"M9.5 14.5 14.5 9m-7 8H6a4 4 0 0 1 0-8h3m6 0h3a4 4 0 0 1 0 8h-3",file:"M6 3h8l4 4v14H6V3Zm8 0v5h5M9 13h6m-6 4h6",folder:"M3 6h7l2 2h9v11H3V6Z",image:"M4 4h16v16H4V4Zm3 12 4-4 3 3 2-2 4 4M9 9h.01",attachment:"m8 12 5-5a3 3 0 1 1 4 4l-7 7a5 5 0 0 1-7-7l7-7",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3 2",calendar:"M5 5h14v15H5V5Zm3-2v4m8-4v4M5 10h14",chart:"M4 20V10m5 10V4m5 16v-7m5 7V7M2 20h20",sliders:"M4 7h10m4 0h2M4 17h2m4 0h10M16 4v6M8 14v6",code:"m9 6-6 6 6 6m6-12 6 6-6 6",copy:"M8 8h11v12H8V8Zm-3 8H4V4h11v1",play:"m8 5 11 7-11 7V5Z",grid:"M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h6v6h-6v-6Z",list:"M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",target:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-5a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0-4h.01",rotate:"M20 6v5h-5M4 18v-5h5M18.5 10A7 7 0 0 0 6 7.5L4 11m16 2-2 3.5A7 7 0 0 1 5.5 14",archive:"M4 8h16v12H4V8Zm-1-4h18v4H3V4Zm6 8h6",flame:"M12 22c4 0 7-3 7-7 0-5-4-7-4-11-3 2-5 5-5 8-1-1-2-3-1-5-3 2-5 5-5 8 0 4 3 7 8 7Z",eye:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z",upload:"M12 16V4m-5 5 5-5 5 5M5 20h14",download:"M12 4v12m-5-5 5 5 5-5M5 20h14",undo:"M9 7 4 12l5 5m-5-5h10a6 6 0 0 1 6 6",redo:"m15 7 5 5-5 5m5-5H10a6 6 0 0 0-6 6",minus:"M5 12h14",plus:"M12 5v14M5 12h14",network:"M12 3v4m0 10v4M3 12h4m10 0h4M7.8 7.8l2.1 2.1m4.2 4.2 2.1 2.1m0-8.4-2.1 2.1m-4.2 4.2-2.1 2.1M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",more:"M6 12h.01M12 12h.01M18 12h.01",pause:"M9 5v14m6-14v14",sort:"M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"};Object.freeze(Object.keys(Td));const FC={name:"OdinIcon",props:{name:{type:String,required:!0},size:{type:[Number,String],default:18},strokeWidth:{type:[Number,String],default:1.8}},setup(e,{attrs:t}){return()=>Ui("svg",{...t,class:["odin-icon",t.class],width:e.size,height:e.size,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":e.strokeWidth,"stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":t["aria-label"]?void 0:"true",focusable:"false"},[Ui("path",{d:Td[e.name]||Td.info})])}},BC=["a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])'].join(",");function jf(e){return[...e.querySelectorAll(BC)].filter(t=>!t.hasAttribute("hidden")&&t.getAttribute("aria-hidden")!=="true")}const UC={mounted(e){const t=document.activeElement,s=a=>{if(a.key!=="Tab")return;const n=jf(e);if(!n.length){a.preventDefault(),e.focus();return}const i=n[0],l=n[n.length-1];a.shiftKey&&document.activeElement===i?(a.preventDefault(),l.focus()):!a.shiftKey&&document.activeElement===l&&(a.preventDefault(),i.focus())};e.__odinModalFocus={previous:t,onKeydown:s},e.addEventListener("keydown",s),requestAnimationFrame(()=>{(e.querySelector("[autofocus]")||jf(e)[0]||e).focus()})},unmounted(e){var s;const t=e.__odinModalFocus;t&&(e.removeEventListener("keydown",t.onKeydown),(s=t.previous)!=null&&s.isConnected&&typeof t.previous.focus=="function"&&requestAnimationFrame(()=>t.previous.focus()),delete e.__odinModalFocus)}},zC={template:` + `},Ld={brand:"M12 3 4.5 8v8L12 21l7.5-5V8L12 3Zm0 4.2 4.6 3.1L12 16.8l-4.6-6.5L12 7.2Zm0 3.3v3.7",dashboard:"M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-16v4h6V4h-6Z",chat:"M20 15a3 3 0 0 1-3 3H9l-5 3v-6a3 3 0 0 1-1-2.2V7a3 3 0 0 1 3-3h11a3 3 0 0 1 3 3v8Z",operations:"M5 12h3l2-6 4 12 2-6h3M4 4v16h16",history:"M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5M12 7v5l3 2",home:"M3 11.5 12 4l9 7.5M5.5 10v10h13V10M9 20v-6h6v6",users:"M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2m7-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.9m-2-11.8a4 4 0 0 1 0 7.7",capabilities:"M14.7 6.3a4 4 0 0 0-5.6 5.6L4 17v3h3v-2h2v-2h2l1.1-1.1a4 4 0 0 0 5.6-5.6l-3 3-3-3 3-3Z",personality:"M12 3a8 8 0 0 0-8 8c0 4 3 7 7 7v3h3v-3c3 0 6-3 6-7a8 8 0 0 0-8-8ZM8.5 10h.01M15.5 10h.01M9 14c1.7 1.2 4.3 1.2 6 0",system:"M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4",menu:"M4 7h16M4 12h16M4 17h16",panelLeft:"M9 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4V4Zm0 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H9M6 8h.01M6 12h.01",chevronLeft:"m15 18-6-6 6-6",chevronRight:"m9 18 6-6-6-6",chevronDown:"m6 9 6 6 6-6",chevronUp:"m18 15-6-6-6 6",search:"m21 21-4.3-4.3M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z",logout:"M10 17l5-5-5-5m5 5H3m10-8h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5",success:"m5 12 4 4L19 6",warning:"M12 3 2.8 20h18.4L12 3Zm0 6v4m0 3h.01",info:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-8v4m0-8h.01",error:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm-3-12 6 6m0-6-6 6",edit:"M4 20h4l11-11-4-4L4 16v4Zm9-13 4 4",trash:"M4 7h16m-10 4v5m4-5v5M9 4h6l1 3H8l1-3Zm-3 3 1 13h10l1-13",brain:"M9 5a3 3 0 0 0-5 2.2A3.5 3.5 0 0 0 4 14a3 3 0 0 0 5 2.2V5Zm6 0a3 3 0 0 1 5 2.2 3.5 3.5 0 0 1 0 6.8 3 3 0 0 1-5 2.2V5ZM9 9H7m2 4H6m9-4h2m-2 4h3M12 4v16",refresh:"M20 6v5h-5M4 18v-5h5M18.5 10A7 7 0 0 0 6 7.5L4 11m16 2-2 3.5A7 7 0 0 1 5.5 14",close:"M6 6l12 12M18 6 6 18",command:"M7 8a3 3 0 1 1-3-3h3v14a3 3 0 1 1-3-3h13a3 3 0 1 1-3 3V5a3 3 0 1 1 3 3H7Z",external:"M14 4h6v6m0-6-9 9M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6",activity:"M4 12h4l2-5 4 10 2-5h4",shield:"M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z",database:"M20 6c0 1.7-3.6 3-8 3S4 7.7 4 6s3.6-3 8-3 8 1.3 8 3Zm0 0v6c0 1.7-3.6 3-8 3s-8-1.3-8-3V6m16 6v6c0 1.7-3.6 3-8 3s-8-1.3-8-3v-6",server:"M4 4h16v6H4V4Zm0 10h16v6H4v-6Zm3-7h.01M7 17h.01",terminal:"M5 7l4 4-4 4m6 1h8M3 4h18v16H3V4Z",wrench:"M14.7 6.3a4 4 0 0 0-5.6 5.6L4 17v3h3v-2h2v-2h2l1.1-1.1a4 4 0 0 0 5.6-5.6l-3 3-3-3 3-3Z",bot:"M8 4h8m-4-2v2M5 8h14a2 2 0 0 1 2 2v8H3v-8a2 2 0 0 1 2-2Zm3 4h.01M16 12h.01M8 16h8M3 13H1m22 0h-2",workflow:"M5 5h5v5H5V5Zm9 9h5v5h-5v-5ZM10 7.5h4a3 3 0 0 1 3 3V14M7.5 10v4a3 3 0 0 0 3 3H14",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-18c2.2 2.5 3.3 5.5 3.3 9S14.2 18.5 12 21m0-18C9.8 5.5 8.7 8.5 8.7 12s1.1 6.5 3.3 9M3 12h18",book:"M4 5a3 3 0 0 1 3-2h5v17H7a3 3 0 0 0-3 1V5Zm16 0a3 3 0 0 0-3-2h-5v17h5a3 3 0 0 1 3 1V5Z",message:"M4 4h16v13H8l-4 4V4Zm4 5h8m-8 4h5",puzzle:"M9 4h3a2 2 0 1 1 4 0h4v5a2 2 0 1 0 0 4v7h-7a2 2 0 1 1-4 0H4v-7a2 2 0 1 0 0-4V4h5",sparkles:"m12 3 1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2L12 3Zm6 10 .8 2.2L21 16l-2.2.8L18 19l-.8-2.2L15 16l2.2-.8L18 13ZM5 14l1 2.8L9 18l-3 1.2L5 22l-1-2.8L1 18l3-1.2L5 14Z",link:"M9.5 14.5 14.5 9m-7 8H6a4 4 0 0 1 0-8h3m6 0h3a4 4 0 0 1 0 8h-3",file:"M6 3h8l4 4v14H6V3Zm8 0v5h5M9 13h6m-6 4h6",folder:"M3 6h7l2 2h9v11H3V6Z",image:"M4 4h16v16H4V4Zm3 12 4-4 3 3 2-2 4 4M9 9h.01",attachment:"m8 12 5-5a3 3 0 1 1 4 4l-7 7a5 5 0 0 1-7-7l7-7",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3 2",calendar:"M5 5h14v15H5V5Zm3-2v4m8-4v4M5 10h14",chart:"M4 20V10m5 10V4m5 16v-7m5 7V7M2 20h20",sliders:"M4 7h10m4 0h2M4 17h2m4 0h10M16 4v6M8 14v6",code:"m9 6-6 6 6 6m6-12 6 6-6 6",copy:"M8 8h11v12H8V8Zm-3 8H4V4h11v1",play:"m8 5 11 7-11 7V5Z",grid:"M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h6v6h-6v-6Z",list:"M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",target:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-5a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0-4h.01",rotate:"M20 6v5h-5M4 18v-5h5M18.5 10A7 7 0 0 0 6 7.5L4 11m16 2-2 3.5A7 7 0 0 1 5.5 14",archive:"M4 8h16v12H4V8Zm-1-4h18v4H3V4Zm6 8h6",flame:"M12 22c4 0 7-3 7-7 0-5-4-7-4-11-3 2-5 5-5 8-1-1-2-3-1-5-3 2-5 5-5 8 0 4 3 7 8 7Z",eye:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z",upload:"M12 16V4m-5 5 5-5 5 5M5 20h14",download:"M12 4v12m-5-5 5 5 5-5M5 20h14",undo:"M9 7 4 12l5 5m-5-5h10a6 6 0 0 1 6 6",redo:"m15 7 5 5-5 5m5-5H10a6 6 0 0 0-6 6",minus:"M5 12h14",plus:"M12 5v14M5 12h14",network:"M12 3v4m0 10v4M3 12h4m10 0h4M7.8 7.8l2.1 2.1m4.2 4.2 2.1 2.1m0-8.4-2.1 2.1m-4.2 4.2-2.1 2.1M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",more:"M6 12h.01M12 12h.01M18 12h.01",pause:"M9 5v14m6-14v14",sort:"M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"};Object.freeze(Object.keys(Ld));const ZC={name:"OdinIcon",props:{name:{type:String,required:!0},size:{type:[Number,String],default:18},strokeWidth:{type:[Number,String],default:1.8}},setup(e,{attrs:t}){return()=>zi("svg",{...t,class:["odin-icon",t.class],width:e.size,height:e.size,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":e.strokeWidth,"stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":t["aria-label"]?void 0:"true",focusable:"false"},[zi("path",{d:Ld[e.name]||Ld.info})])}},QC=["a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])'].join(",");function Jf(e){return[...e.querySelectorAll(QC)].filter(t=>!t.hasAttribute("hidden")&&t.getAttribute("aria-hidden")!=="true")}const YC={mounted(e){const t=document.activeElement,s=a=>{if(a.key!=="Tab")return;const n=Jf(e);if(!n.length){a.preventDefault(),e.focus();return}const i=n[0],l=n[n.length-1];a.shiftKey&&document.activeElement===i?(a.preventDefault(),l.focus()):!a.shiftKey&&document.activeElement===l&&(a.preventDefault(),i.focus())};e.__odinModalFocus={previous:t,onKeydown:s},e.addEventListener("keydown",s),requestAnimationFrame(()=>{(e.querySelector("[autofocus]")||Jf(e)[0]||e).focus()})},unmounted(e){var s;const t=e.__odinModalFocus;t&&(e.removeEventListener("keydown",t.onKeydown),(s=t.previous)!=null&&s.isConnected&&typeof t.previous.focus=="function"&&requestAnimationFrame(()=>t.previous.focus()),delete e.__odinModalFocus)}},XC={template:`
@@ -7034,14 +7069,14 @@ ${u.text}`:u.text).join(`
- `,setup(){const e=p({}),t=p(!0),s=p(null),a=p([]),n=p(!1),i=p([]),l=p(!1),o=p(!1),r=p([]),c=p(0),d=p(null),u=p({reload:!1,clearSessions:!1,stopLoops:!1});let f=0;const m=H(()=>{const ce=e.value.uptime_seconds||0,J=Math.floor(ce/86400),K=Math.floor(ce%86400/3600),se=Math.floor(ce%3600/60),j=[];return J>0&&j.push(`${J}d`),K>0&&j.push(`${K}h`),(j.length===0||J===0&&K===0)&&j.push(`${se}m`),j.join(" ")}),h=H(()=>{const ce=e.value.uptime_seconds||0;return 125.66*(1-Math.min(ce/86400,1))}),b=H(()=>{const ce=e.value;return[{label:"Guilds",value:ce.guild_count??0,icon:"home",iconColor:"text-blue-400"},{label:"Sessions",value:ce.session_count??0,icon:"message",iconColor:"text-yellow-400"},{label:"Tools",value:ce.tool_count??0,icon:"wrench",iconColor:"text-purple-400",sub:`${ce.skill_count??0} skills`,subColor:"text-gray-500"},{label:"Loops",value:ce.loop_count??0,icon:"rotate",iconColor:"text-green-400",color:ce.loop_count>0?"text-green-400":"",highlight:ce.loop_count>0},{label:"Agents",value:ce.agent_running??0,icon:"bot",iconColor:"text-cyan-400",sub:ce.agent_count>0?`${ce.agent_count} total`:"",subColor:"text-gray-500",highlight:(ce.agent_running??0)>0},{label:"Processes",value:ce.process_running??0,icon:"sliders",iconColor:"text-orange-400",sub:ce.process_count>0?`${ce.process_count} total`:"",subColor:"text-gray-500",highlight:(ce.process_running??0)>0},{label:"Schedules",value:ce.schedule_count??0,icon:"clock",iconColor:"text-amber-400",sub:(ce.schedule_failing>0?`${ce.schedule_failing} failing`:"")+(ce.schedule_failing>0&&ce.schedule_paused>0?", ":"")+(ce.schedule_paused>0?`${ce.schedule_paused} paused`:"")||void 0,subColor:ce.schedule_failing>0?"text-red-400":"text-yellow-400",color:ce.schedule_failing>0?"text-red-400":"",highlight:ce.schedule_failing>0},{label:"Users",value:ce.user_count??0,icon:"users",iconColor:"text-indigo-400"},...d.value!==null?[{label:"Knowledge",value:d.value,icon:"book",iconColor:"text-teal-400",sub:"chunks",subColor:"text-gray-500"}]:[]]}),I=H(()=>{const ce=e.value,J=[];return J.push({label:"Bot",status:ce.status==="online"?"ok":"warn",detail:ce.status==="online"?"Online":"Starting"}),(ce.schedule_failing||0)>0?J.push({label:"Schedules",status:"error",detail:`${ce.schedule_failing} failing`}):(ce.schedule_count||0)>0&&J.push({label:"Schedules",status:"ok",detail:`${ce.schedule_count} configured`}),(ce.loop_count||0)>0&&J.push({label:"Loops",status:"ok",detail:`${ce.loop_count} active`}),(ce.agent_running||0)>0&&J.push({label:"Agents",status:"ok",detail:`${ce.agent_running} running`}),(ce.process_running||0)>0&&J.push({label:"Processes",status:"ok",detail:`${ce.process_running} running`}),J});async function k(){try{e.value=await z.get("/api/status"),s.value=null}catch(ce){s.value=ce.message}finally{t.value=!1}}let y=0,g=0,x=0,S=0;function _(ce,J){const K=new Set;return[...J,...ce].filter(se=>{const j=se._hmac||JSON.stringify([se.timestamp,se.tool_name,se.user_id,se.result_summary,se.error]);return K.has(j)?!1:(K.add(j),!0)})}async function A(){const ce=++y,J=x;n.value=!0;try{const K=await z.get("/api/audit?limit=10");if(ce!==y)return;const se=J===x?[]:a.value.filter(j=>(j._liveEpoch||0)>J);a.value=_(K,se).slice(0,10),c.value=se.length}catch{}ce===y&&(n.value=!1)}async function R(){const ce=++g,J=S;l.value=!0;try{const K=await z.get("/api/audit?error_only=1&limit=5");if(ce!==g)return;const se=J===S?[]:i.value.filter(j=>(j._liveErrorEpoch||0)>J);i.value=_(K,se).slice(0,5),o.value=!1}catch{if(ce!==g)return;o.value=J===S||i.value.length===0}ce===g&&(l.value=!1)}async function w(){try{const ce=await z.get("/api/knowledge");d.value=(Array.isArray(ce)?ce:[]).reduce((J,K)=>J+(K.chunks||0),0)}catch{d.value=null}}async function O(){try{const ce=await z.get("/api/agents");r.value=ce.filter(J=>J.status==="running")}catch{}}async function B(){u.value={...u.value,reload:!0};try{await z.post("/api/reload"),Se.success("Config reloaded")}catch(ce){Se.error(ce.message)}u.value={...u.value,reload:!1}}async function T(){if(!await os({title:"Clear all sessions",message:"Clear all conversation sessions? This cannot be undone.",confirmLabel:"Clear All",danger:!0}))return;u.value={...u.value,clearSessions:!0};const J=e.value.session_count;e.value={...e.value,session_count:0};try{const K=await z.post("/api/sessions/clear-all");Se.success(`Cleared ${K.count} session${K.count!==1?"s":""}`),await k()}catch(K){e.value={...e.value,session_count:J},Se.error(K.message)}u.value={...u.value,clearSessions:!1}}async function $(){if(!await os({title:"Stop all loops",message:"Stop all running loops?",confirmLabel:"Stop Loops",danger:!0}))return;u.value={...u.value,stopLoops:!0};const J=e.value.loop_count;e.value={...e.value,loop_count:0};try{const K=await z.post("/api/loops/stop-all");Se.success(K.result),await k()}catch(K){e.value={...e.value,loop_count:J},Se.error(K.message)}u.value={...u.value,stopLoops:!1}}function Q(){t.value=!0,s.value=null,k(),A(),R(),O()}let W=null,N=null,L=null;function D(ce){if(ce.payload&&ce.payload.tool_name){x+=1;const J={...ce.payload,_isNew:!0,_key:++f,_liveEpoch:x};a.value.unshift(J),a.value.length>10&&a.value.pop(),c.value++,J.error&&(S+=1,J._liveErrorEpoch=S,o.value=!1,i.value.unshift(J),i.value.length>5&&i.value.pop()),setTimeout(()=>{J._isNew=!1},1500),clearTimeout(L),L=setTimeout(()=>{c.value=0},1e4)}}let re=null;return st(async()=>{await Promise.all([k(),A(),R(),O(),w()]),W=setInterval(k,15e3),N=setInterval(O,1e4),ut.subscribe("events",D),re=ut.onReconnected(()=>{A(),R()})}),xt(()=>{W&&clearInterval(W),N&&clearInterval(N),clearTimeout(L),ut.unsubscribe("events",D),re&&(re(),re=null)}),{status:e,loading:t,error:s,uptime:m,uptimeRingOffset:h,stats:b,healthIndicators:I,activity:a,activityLoading:n,newEventCount:c,errors:i,errorsLoading:l,errorsError:o,agents:r,actionLoading:u,fetchActivity:A,fetchErrors:R,fetchStatus:k,onEvent:D,formatTime:WS,formatDuration:Ji,retry:Q,reloadConfig:B,clearSessions:T,stopAllLoops:$}}};/*! @license DOMPurify 3.4.9 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.9/LICENSE */function Vf(e,t){(t==null||t>e.length)&&(t=e.length);for(var s=0,a=Array(t);s2?a-2:0),i=2;i1?s-1:0),n=1;n"u"?null:Jt(BigInt.prototype.toString),Zf=typeof Symbol>"u"?null:Jt(Symbol.prototype.toString),Dt=Jt(Object.prototype.hasOwnProperty),vl=Jt(Object.prototype.toString),us=Jt(RegExp.prototype.test),Fn=tT(TypeError);function Jt(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);for(var s=arguments.length,a=new Array(s>1?s-1:0),n=1;n2&&arguments[2]!==void 0?arguments[2]:kl;if(qf&&qf(e,null),!As(t))return e;let a=t.length;for(;a--;){let n=t[a];if(typeof n=="string"){const i=s(n);i!==n&&(WC(t)||(t[a]=i),n=i)}e[n]=!0}return e}function sT(e){for(let t=0;t/g),cT=la(/\${[\w\W]*/g),dT=la(/^data-[\-\w.\u00B7-\uFFFF]+$/),uT=la(/^aria-[\-\w]+$/),tm=la(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),pT=la(/^(?:\w+script|data):/i),fT=la(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),mT=la(/^html$/i),hT=la(/^[a-z][.\w]*(-[.\w]+)+$/i),wa={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},vT=function(){return typeof window>"u"?null:window},gT=function(t,s){if(typeof t!="object"||typeof t.createPolicy!="function")return null;let a=null;const n="data-tt-policy-suffix";s&&s.hasAttribute(n)&&(a=s.getAttribute(n));const i="dompurify"+(a?"#"+a:"");try{return t.createPolicy(i,{createHTML(l){return l},createScriptURL(l){return l}})}catch{return console.warn("TrustedTypes policy "+i+" could not be created."),null}},sm=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function Dg(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:vT();const t=Re=>Dg(Re);if(t.version="3.4.9",t.removed=[],!e||!e.document||e.document.nodeType!==wa.document||!e.Element)return t.isSupported=!1,t;let s=e.document;const a=s,n=a.currentScript;e.DocumentFragment;const i=e.HTMLTemplateElement,l=e.Node,o=e.Element,r=e.NodeFilter,c=e.NamedNodeMap;c===void 0&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;const d=e.DOMParser,u=e.trustedTypes,f=o.prototype,m=Sa(f,"cloneNode"),h=Sa(f,"remove"),b=Sa(f,"nextSibling"),I=Sa(f,"childNodes"),k=Sa(f,"parentNode"),y=Sa(f,"shadowRoot"),g=Sa(f,"attributes"),x=l&&l.prototype?Sa(l.prototype,"nodeType"):null,S=l&&l.prototype?Sa(l.prototype,"nodeName"):null;if(typeof i=="function"){const Re=s.createElement("template");Re.content&&Re.content.ownerDocument&&(s=Re.content.ownerDocument)}let _,A="",R,w=!1,O=0;const B=function(){if(O>0)throw Fn('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},T=function(M){B(),O++;try{return _.createHTML(M)}finally{O--}},$=function(M){B(),O++;try{return _.createScriptURL(M)}finally{O--}},Q=function(){return w||(R=gT(u,n),w=!0),R},W=s,N=W.implementation,L=W.createNodeIterator,D=W.createDocumentFragment,re=W.getElementsByTagName,ce=a.importNode;let J=sm();t.isSupported=typeof Ng=="function"&&typeof k=="function"&&N&&N.createHTMLDocument!==void 0;const K=oT,se=rT,j=cT,ye=dT,Oe=uT,ne=pT,ie=fT,ve=hT;let be=tm,ge=null;const Ne=tt({},[...Yf,...zc,...Hc,...jc,...Qf]);let E=null;const P=tt({},[...Xf,...Vc,...em,...Uo]);let G=Object.seal(Si(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),me=null,de=null;const fe=Object.seal(Si(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let _e=!0,q=!0,te=!1,X=!0,xe=!1,U=!0,ue=!1,oe=!1,he=!1,Fe=!1,Ve=!1,$e=!1,Ke=!0,at=!1;const Xe="user-content-";let Z=!0,Ee=!1,Ae={},Me=null;const ee=tt({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let Le=null;const He=tt({},["audio","video","img","source","image","track"]);let rt=null;const Zt=tt({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),lt="http://www.w3.org/1998/Math/MathML",gt="http://www.w3.org/2000/svg",Yt="http://www.w3.org/1999/xhtml";let cs=Yt,oa=!1,js=null;const Ze=tt({},[lt,gt,Yt],Uc);let ra=tt({},["mi","mo","mn","ms","mtext"]),vs=tt({},["annotation-xml"]);const Ms=tt({},["title","style","font","a","script"]);let gs=null;const En=["application/xhtml+xml","text/html"],Qt="text/html";let _t=null,ds=null;const ba=s.createElement("form"),rn=function(M){return M instanceof RegExp||M instanceof Function},ca=function(){let M=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(ds&&ds===M)return;(!M||typeof M!="object")&&(M={}),M=xs(M),gs=En.indexOf(M.PARSER_MEDIA_TYPE)===-1?Qt:M.PARSER_MEDIA_TYPE,_t=gs==="application/xhtml+xml"?Uc:kl,ge=Dt(M,"ALLOWED_TAGS")&&As(M.ALLOWED_TAGS)?tt({},M.ALLOWED_TAGS,_t):Ne,E=Dt(M,"ALLOWED_ATTR")&&As(M.ALLOWED_ATTR)?tt({},M.ALLOWED_ATTR,_t):P,js=Dt(M,"ALLOWED_NAMESPACES")&&As(M.ALLOWED_NAMESPACES)?tt({},M.ALLOWED_NAMESPACES,Uc):Ze,rt=Dt(M,"ADD_URI_SAFE_ATTR")&&As(M.ADD_URI_SAFE_ATTR)?tt(xs(Zt),M.ADD_URI_SAFE_ATTR,_t):Zt,Le=Dt(M,"ADD_DATA_URI_TAGS")&&As(M.ADD_DATA_URI_TAGS)?tt(xs(He),M.ADD_DATA_URI_TAGS,_t):He,Me=Dt(M,"FORBID_CONTENTS")&&As(M.FORBID_CONTENTS)?tt({},M.FORBID_CONTENTS,_t):ee,me=Dt(M,"FORBID_TAGS")&&As(M.FORBID_TAGS)?tt({},M.FORBID_TAGS,_t):xs({}),de=Dt(M,"FORBID_ATTR")&&As(M.FORBID_ATTR)?tt({},M.FORBID_ATTR,_t):xs({}),Ae=Dt(M,"USE_PROFILES")?M.USE_PROFILES&&typeof M.USE_PROFILES=="object"?xs(M.USE_PROFILES):M.USE_PROFILES:!1,_e=M.ALLOW_ARIA_ATTR!==!1,q=M.ALLOW_DATA_ATTR!==!1,te=M.ALLOW_UNKNOWN_PROTOCOLS||!1,X=M.ALLOW_SELF_CLOSE_IN_ATTR!==!1,xe=M.SAFE_FOR_TEMPLATES||!1,U=M.SAFE_FOR_XML!==!1,ue=M.WHOLE_DOCUMENT||!1,Fe=M.RETURN_DOM||!1,Ve=M.RETURN_DOM_FRAGMENT||!1,$e=M.RETURN_TRUSTED_TYPE||!1,he=M.FORCE_BODY||!1,Ke=M.SANITIZE_DOM!==!1,at=M.SANITIZE_NAMED_PROPS||!1,Z=M.KEEP_CONTENT!==!1,Ee=M.IN_PLACE||!1,be=nT(M.ALLOWED_URI_REGEXP)?M.ALLOWED_URI_REGEXP:tm,cs=typeof M.NAMESPACE=="string"?M.NAMESPACE:Yt,ra=Dt(M,"MATHML_TEXT_INTEGRATION_POINTS")&&M.MATHML_TEXT_INTEGRATION_POINTS&&typeof M.MATHML_TEXT_INTEGRATION_POINTS=="object"?xs(M.MATHML_TEXT_INTEGRATION_POINTS):tt({},["mi","mo","mn","ms","mtext"]),vs=Dt(M,"HTML_INTEGRATION_POINTS")&&M.HTML_INTEGRATION_POINTS&&typeof M.HTML_INTEGRATION_POINTS=="object"?xs(M.HTML_INTEGRATION_POINTS):tt({},["annotation-xml"]);const pe=Dt(M,"CUSTOM_ELEMENT_HANDLING")&&M.CUSTOM_ELEMENT_HANDLING&&typeof M.CUSTOM_ELEMENT_HANDLING=="object"?xs(M.CUSTOM_ELEMENT_HANDLING):Si(null);if(G=Si(null),Dt(pe,"tagNameCheck")&&rn(pe.tagNameCheck)&&(G.tagNameCheck=pe.tagNameCheck),Dt(pe,"attributeNameCheck")&&rn(pe.attributeNameCheck)&&(G.attributeNameCheck=pe.attributeNameCheck),Dt(pe,"allowCustomizedBuiltInElements")&&typeof pe.allowCustomizedBuiltInElements=="boolean"&&(G.allowCustomizedBuiltInElements=pe.allowCustomizedBuiltInElements),xe&&(q=!1),Ve&&(Fe=!0),Ae&&(ge=tt({},Qf),E=Si(null),Ae.html===!0&&(tt(ge,Yf),tt(E,Xf)),Ae.svg===!0&&(tt(ge,zc),tt(E,Vc),tt(E,Uo)),Ae.svgFilters===!0&&(tt(ge,Hc),tt(E,Vc),tt(E,Uo)),Ae.mathMl===!0&&(tt(ge,jc),tt(E,em),tt(E,Uo))),fe.tagCheck=null,fe.attributeCheck=null,Dt(M,"ADD_TAGS")&&(typeof M.ADD_TAGS=="function"?fe.tagCheck=M.ADD_TAGS:As(M.ADD_TAGS)&&(ge===Ne&&(ge=xs(ge)),tt(ge,M.ADD_TAGS,_t))),Dt(M,"ADD_ATTR")&&(typeof M.ADD_ATTR=="function"?fe.attributeCheck=M.ADD_ATTR:As(M.ADD_ATTR)&&(E===P&&(E=xs(E)),tt(E,M.ADD_ATTR,_t))),Dt(M,"ADD_URI_SAFE_ATTR")&&As(M.ADD_URI_SAFE_ATTR)&&tt(rt,M.ADD_URI_SAFE_ATTR,_t),Dt(M,"FORBID_CONTENTS")&&As(M.FORBID_CONTENTS)&&(Me===ee&&(Me=xs(Me)),tt(Me,M.FORBID_CONTENTS,_t)),Dt(M,"ADD_FORBID_CONTENTS")&&As(M.ADD_FORBID_CONTENTS)&&(Me===ee&&(Me=xs(Me)),tt(Me,M.ADD_FORBID_CONTENTS,_t)),Z&&(ge["#text"]=!0),ue&&tt(ge,["html","head","body"]),ge.table&&(tt(ge,["tbody"]),delete me.tbody),M.TRUSTED_TYPES_POLICY){if(typeof M.TRUSTED_TYPES_POLICY.createHTML!="function")throw Fn('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof M.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw Fn('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const we=_;_=M.TRUSTED_TYPES_POLICY;try{A=T("")}catch(Ue){throw _=we,Ue}}else M.TRUSTED_TYPES_POLICY===null?(_=void 0,A=""):(_===void 0&&(_=Q()),_&&typeof A=="string"&&(A=T("")));(J.uponSanitizeElement.length>0||J.uponSanitizeAttribute.length>0)&&ge===Ne&&(ge=xs(ge)),J.uponSanitizeAttribute.length>0&&E===P&&(E=xs(E)),Ns&&Ns(M),ds=M},le=tt({},[...zc,...Hc,...iT]),ke=tt({},[...jc,...lT]),qe=function(M){let pe=k(M);(!pe||!pe.tagName)&&(pe={namespaceURI:cs,tagName:"template"});const we=kl(M.tagName),Ue=kl(pe.tagName);return js[M.namespaceURI]?M.namespaceURI===gt?pe.namespaceURI===Yt?we==="svg":pe.namespaceURI===lt?we==="svg"&&(Ue==="annotation-xml"||ra[Ue]):!!le[we]:M.namespaceURI===lt?pe.namespaceURI===Yt?we==="math":pe.namespaceURI===gt?we==="math"&&vs[Ue]:!!ke[we]:M.namespaceURI===Yt?pe.namespaceURI===gt&&!vs[Ue]||pe.namespaceURI===lt&&!ra[Ue]?!1:!ke[we]&&(Ms[we]||!le[we]):!!(gs==="application/xhtml+xml"&&js[M.namespaceURI]):!1},Ge=function(M){yi(t.removed,{element:M});try{k(M).removeChild(M)}catch{if(h(M),!k(M))throw Fn("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},kt=function(M){const pe=I?I(M):M.childNodes;if(pe){const Ue=[];Ua(pe,We=>{yi(Ue,We)}),Ua(Ue,We=>{try{h(We)}catch{}})}const we=g?g(M):null;if(we)for(let Ue=we.length-1;Ue>=0;--Ue){const We=we[Ue],Qe=We&&We.name;if(typeof Qe=="string")try{M.removeAttribute(Qe)}catch{}}},St=function(M,pe){try{yi(t.removed,{attribute:pe.getAttributeNode(M),from:pe})}catch{yi(t.removed,{attribute:null,from:pe})}if(pe.removeAttribute(M),M==="is")if(Fe||Ve)try{Ge(pe)}catch{}else try{pe.setAttribute(M,"")}catch{}},cn=function(M){const pe=g?g(M):M.attributes;if(pe)for(let we=pe.length-1;we>=0;--we){const Ue=pe[we],We=Ue&&Ue.name;if(!(typeof We!="string"||E[_t(We)]))try{M.removeAttribute(We)}catch{}}},An=function(M){const pe=[M];for(;pe.length>0;){const we=pe.pop();(x?x(we):we.nodeType)===wa.element&&cn(we);const We=I?I(we):we.childNodes;if(We)for(let Qe=We.length-1;Qe>=0;--Qe)pe.push(We[Qe])}},dn=function(M){let pe=null,we=null;if(he)M=""+M;else{const Qe=Wf(M,/^[\r\n\t ]+/);we=Qe&&Qe[0]}gs==="application/xhtml+xml"&&cs===Yt&&(M=''+M+"");const Ue=_?T(M):M;if(cs===Yt)try{pe=new d().parseFromString(Ue,gs)}catch{}if(!pe||!pe.documentElement){pe=N.createDocument(cs,"template",null);try{pe.documentElement.innerHTML=oa?A:Ue}catch{}}const We=pe.body||pe.documentElement;return M&&we&&We.insertBefore(s.createTextNode(we),We.childNodes[0]||null),cs===Yt?re.call(pe,ue?"html":"body")[0]:ue?pe.documentElement:We},Rn=function(M){return L.call(M.ownerDocument||M,M,r.SHOW_ELEMENT|r.SHOW_COMMENT|r.SHOW_TEXT|r.SHOW_PROCESSING_INSTRUCTION|r.SHOW_CDATA_SECTION,null)},da=function(M){var pe,we;M.normalize();const Ue=L.call(M.ownerDocument||M,M,r.SHOW_TEXT|r.SHOW_COMMENT|r.SHOW_CDATA_SECTION|r.SHOW_PROCESSING_INSTRUCTION,null);let We=Ue.nextNode();for(;We;){let Et=We.data;Ua([K,se,j],pt=>{Et=xi(Et,pt," ")}),We.data=Et,We=Ue.nextNode()}const Qe=(pe=(we=M.querySelectorAll)===null||we===void 0?void 0:we.call(M,"template"))!==null&&pe!==void 0?pe:[];Ua(Array.from(Qe),Et=>{jt(Et.content)&&da(Et.content)})},ya=function(M){const pe=S?S(M):null;return typeof pe!="string"||_t(pe)!=="form"?!1:typeof M.nodeName!="string"||typeof M.textContent!="string"||typeof M.removeChild!="function"||M.attributes!==g(M)||typeof M.removeAttribute!="function"||typeof M.setAttribute!="function"||typeof M.namespaceURI!="string"||typeof M.insertBefore!="function"||typeof M.hasChildNodes!="function"||M.nodeType!==x(M)||M.childNodes!==I(M)},jt=function(M){if(!x||typeof M!="object"||M===null)return!1;try{return x(M)===wa.documentFragment}catch{return!1}},ua=function(M){if(!x||typeof M!="object"||M===null)return!1;try{return typeof x(M)=="number"}catch{return!1}};function as(Re,M,pe){Ua(Re,we=>{we.call(t,M,pe,ds)})}const un=function(M){let pe=null;if(as(J.beforeSanitizeElements,M,null),ya(M))return Ge(M),!0;const we=_t(S?S(M):M.nodeName);if(as(J.uponSanitizeElement,M,{tagName:we,allowedTags:ge}),U&&M.hasChildNodes()&&!ua(M.firstElementChild)&&us(/<[/\w!]/g,M.innerHTML)&&us(/<[/\w!]/g,M.textContent)||U&&M.namespaceURI===Yt&&we==="style"&&ua(M.firstElementChild)||M.nodeType===wa.progressingInstruction||U&&M.nodeType===wa.comment&&us(/<[/\w]/g,M.data))return Ge(M),!0;if(me[we]||!(fe.tagCheck instanceof Function&&fe.tagCheck(we))&&!ge[we]){if(!me[we]&&oi(we)&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,we)||G.tagNameCheck instanceof Function&&G.tagNameCheck(we)))return!1;if(Z&&!Me[we]){const We=k(M),Qe=I(M);if(Qe&&We){const Et=Qe.length;for(let pt=Et-1;pt>=0;--pt){const At=Ee?Qe[pt]:m(Qe[pt],!0);We.insertBefore(At,b(M))}}}return Ge(M),!0}return(x?x(M):M.nodeType)===wa.element&&!qe(M)||(we==="noscript"||we==="noembed"||we==="noframes")&&us(/<\/no(script|embed|frames)/i,M.innerHTML)?(Ge(M),!0):(xe&&M.nodeType===wa.text&&(pe=M.textContent,Ua([K,se,j],We=>{pe=xi(pe,We," ")}),M.textContent!==pe&&(yi(t.removed,{element:M.cloneNode()}),M.textContent=pe)),as(J.afterSanitizeElements,M,null),!1)},In=function(M,pe,we){if(de[pe]||Ke&&(pe==="id"||pe==="name")&&(we in s||we in ba))return!1;const Ue=E[pe]||fe.attributeCheck instanceof Function&&fe.attributeCheck(pe,M);if(!(q&&!de[pe]&&us(ye,pe))){if(!(_e&&us(Oe,pe))){if(!Ue||de[pe]){if(!(oi(M)&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,M)||G.tagNameCheck instanceof Function&&G.tagNameCheck(M))&&(G.attributeNameCheck instanceof RegExp&&us(G.attributeNameCheck,pe)||G.attributeNameCheck instanceof Function&&G.attributeNameCheck(pe,M))||pe==="is"&&G.allowCustomizedBuiltInElements&&(G.tagNameCheck instanceof RegExp&&us(G.tagNameCheck,we)||G.tagNameCheck instanceof Function&&G.tagNameCheck(we))))return!1}else if(!rt[pe]){if(!us(be,xi(we,ie,""))){if(!((pe==="src"||pe==="xlink:href"||pe==="href")&&M!=="script"&&Kf(we,"data:")===0&&Le[M])){if(!(te&&!us(ne,xi(we,ie,"")))){if(we)return!1}}}}}}return!0},li=tt({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),oi=function(M){return!li[kl(M)]&&us(ve,M)},ri=function(M){as(J.beforeSanitizeAttributes,M,null);const pe=M.attributes;if(!pe||ya(M))return;const we={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:E,forceKeepAttr:void 0};let Ue=pe.length;for(;Ue--;){const We=pe[Ue],Qe=We.name,Et=We.namespaceURI,pt=We.value,At=_t(Qe),bs=pt;let Nt=Qe==="value"?bs:QC(bs);if(we.attrName=At,we.attrValue=Nt,we.keepAttr=!0,we.forceKeepAttr=void 0,as(J.uponSanitizeAttribute,M,we),Nt=we.attrValue,at&&(At==="id"||At==="name")&&Kf(Nt,Xe)!==0&&(St(Qe,M),Nt=Xe+Nt),U&&us(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,Nt)){St(Qe,M);continue}if(At==="attributename"&&Wf(Nt,"href")){St(Qe,M);continue}if(we.forceKeepAttr)continue;if(!we.keepAttr){St(Qe,M);continue}if(!X&&us(/\/>/i,Nt)){St(Qe,M);continue}xe&&Ua([K,se,j],ci=>{Nt=xi(Nt,ci," ")});const Na=_t(M.nodeName);if(!In(Na,At,Nt)){St(Qe,M);continue}if(_&&typeof u=="object"&&typeof u.getAttributeType=="function"&&!Et)switch(u.getAttributeType(Na,At)){case"TrustedHTML":{Nt=T(Nt);break}case"TrustedScriptURL":{Nt=$(Nt);break}}if(Nt!==bs)try{Et?M.setAttributeNS(Et,Qe,Nt):M.setAttribute(Qe,Nt),ya(M)?Ge(M):Gf(t.removed)}catch{St(Qe,M)}}as(J.afterSanitizeAttributes,M,null)},ns=function(M){let pe=null;const we=Rn(M);for(as(J.beforeSanitizeShadowDOM,M,null);pe=we.nextNode();)if(as(J.uponSanitizeShadowNode,pe,null),un(pe),ri(pe),jt(pe.content)&&ns(pe.content),(x?x(pe):pe.nodeType)===wa.element){const We=y?y(pe):pe.shadowRoot;jt(We)&&(On(We),ns(We))}as(J.afterSanitizeShadowDOM,M,null)},On=function(M){const pe=[{node:M,shadow:null}];for(;pe.length>0;){const we=pe.pop();if(we.shadow){ns(we.shadow);continue}const Ue=we.node,Qe=(x?x(Ue):Ue.nodeType)===wa.element,Et=I?I(Ue):Ue.childNodes;if(Et)for(let pt=Et.length-1;pt>=0;--pt)pe.push({node:Et[pt],shadow:null});if(Qe){const pt=S?S(Ue):null;if(typeof pt=="string"&&_t(pt)==="template"){const At=Ue.content;jt(At)&&pe.push({node:At,shadow:null})}}if(Qe){const pt=y?y(Ue):Ue.shadowRoot;jt(pt)&&pe.push({node:null,shadow:pt},{node:pt,shadow:null})}}};return t.sanitize=function(Re){let M=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},pe=null,we=null,Ue=null,We=null;if(oa=!Re,oa&&(Re=""),typeof Re!="string"&&!ua(Re)&&(Re=aT(Re),typeof Re!="string"))throw Fn("dirty is not a string, aborting");if(!t.isSupported)return Re;oe||ca(M),t.removed=[];const Qe=Ee&&typeof Re!="string"&&ua(Re);if(Qe){const At=S?S(Re):Re.nodeName;if(typeof At=="string"){const bs=_t(At);if(!ge[bs]||me[bs])throw Fn("root node is forbidden and cannot be sanitized in-place")}if(ya(Re))throw Fn("root node is clobbered and cannot be sanitized in-place");try{On(Re)}catch(bs){throw kt(Re),bs}}else if(ua(Re))pe=dn(""),we=pe.ownerDocument.importNode(Re,!0),we.nodeType===wa.element&&we.nodeName==="BODY"||we.nodeName==="HTML"?pe=we:pe.appendChild(we),On(we);else{if(!Fe&&!xe&&!ue&&Re.indexOf("<")===-1)return _&&$e?T(Re):Re;if(pe=dn(Re),!pe)return Fe?null:$e?A:""}pe&&he&&Ge(pe.firstChild);const Et=Rn(Qe?Re:pe);try{for(;Ue=Et.nextNode();)un(Ue),ri(Ue),jt(Ue.content)&&ns(Ue.content)}catch(At){throw Qe&&kt(Re),At}if(Qe)return Ua(t.removed,At=>{At.element&&An(At.element)}),xe&&da(Re),Re;if(Fe){if(xe&&da(pe),Ve)for(We=D.call(pe.ownerDocument);pe.firstChild;)We.appendChild(pe.firstChild);else We=pe;return(E.shadowroot||E.shadowrootmode)&&(We=ce.call(a,We,!0)),We}let pt=ue?pe.outerHTML:pe.innerHTML;return ue&&ge["!doctype"]&&pe.ownerDocument&&pe.ownerDocument.doctype&&pe.ownerDocument.doctype.name&&us(mT,pe.ownerDocument.doctype.name)&&(pt=" -`+pt),xe&&Ua([K,se,j],At=>{pt=xi(pt,At," ")}),_&&$e?T(pt):pt},t.setConfig=function(){let Re=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};ca(Re),oe=!0},t.clearConfig=function(){ds=null,oe=!1,_=R,A=""},t.isValidAttribute=function(Re,M,pe){ds||ca({});const we=_t(Re),Ue=_t(M);return In(we,Ue,pe)},t.addHook=function(Re,M){typeof M=="function"&&yi(J[Re],M)},t.removeHook=function(Re,M){if(M!==void 0){const pe=ZC(J[Re],M);return pe===-1?void 0:YC(J[Re],pe,1)[0]}return Gf(J[Re])},t.removeHooks=function(Re){J[Re]=[]},t.removeAllHooks=function(){J=sm()},t}var am=Dg();function Mu(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var ii=Mu();function Pg(e){ii=e}var Ll={exec:()=>null};function yt(e,t=""){let s=typeof e=="string"?e:e.source;const a={replace:(n,i)=>{let l=typeof i=="string"?i:i.source;return l=l.replace(Os.caret,"$1"),s=s.replace(n,l),a},getRegex:()=>new RegExp(s,t)};return a}var Os={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},bT=/^(?:[ \t]*(?:\n|$))+/,yT=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,xT=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,fo=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,_T=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Du=/(?:[*+-]|\d{1,9}[.)])/,$g=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,Fg=yt($g).replace(/bull/g,Du).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),wT=yt($g).replace(/bull/g,Du).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Pu=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,kT=/^[^\n]+/,$u=/(?!\s*\])(?:\\.|[^\[\]\\])+/,ST=yt(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",$u).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),CT=yt(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Du).getRegex(),tc="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",Fu=/|$))/,TT=yt("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",Fu).replace("tag",tc).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Bg=yt(Pu).replace("hr",fo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",tc).getRegex(),ET=yt(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Bg).getRegex(),Bu={blockquote:ET,code:yT,def:ST,fences:xT,heading:_T,hr:fo,html:TT,lheading:Fg,list:CT,newline:bT,paragraph:Bg,table:Ll,text:kT},nm=yt("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",fo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",tc).getRegex(),AT={...Bu,lheading:wT,table:nm,paragraph:yt(Pu).replace("hr",fo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",nm).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",tc).getRegex()},RT={...Bu,html:yt(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",Fu).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Ll,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:yt(Pu).replace("hr",fo).replace("heading",` *#{1,6} *[^ -]`).replace("lheading",Fg).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},IT=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,OT=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Ug=/^( {2,}|\\)\n(?!\s*$)/,LT=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,jg=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,$T=yt(jg,"u").replace(/punct/g,sc).getRegex(),FT=yt(jg,"u").replace(/punct/g,Hg).getRegex(),Vg="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",BT=yt(Vg,"gu").replace(/notPunctSpace/g,zg).replace(/punctSpace/g,Uu).replace(/punct/g,sc).getRegex(),UT=yt(Vg,"gu").replace(/notPunctSpace/g,DT).replace(/punctSpace/g,MT).replace(/punct/g,Hg).getRegex(),zT=yt("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,zg).replace(/punctSpace/g,Uu).replace(/punct/g,sc).getRegex(),HT=yt(/\\(punct)/,"gu").replace(/punct/g,sc).getRegex(),jT=yt(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),VT=yt(Fu).replace("(?:-->|$)","-->").getRegex(),qT=yt("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",VT).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Sr=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,GT=yt(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",Sr).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),qg=yt(/^!?\[(label)\]\[(ref)\]/).replace("label",Sr).replace("ref",$u).getRegex(),Gg=yt(/^!?\[(ref)\](?:\[\])?/).replace("ref",$u).getRegex(),WT=yt("reflink|nolink(?!\\()","g").replace("reflink",qg).replace("nolink",Gg).getRegex(),zu={_backpedal:Ll,anyPunctuation:HT,autolink:jT,blockSkip:PT,br:Ug,code:OT,del:Ll,emStrongLDelim:$T,emStrongRDelimAst:BT,emStrongRDelimUnd:zT,escape:IT,link:GT,nolink:Gg,punctuation:NT,reflink:qg,reflinkSearch:WT,tag:qT,text:LT,url:Ll},KT={...zu,link:yt(/^!?\[(label)\]\((.*?)\)/).replace("label",Sr).getRegex(),reflink:yt(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Sr).getRegex()},Rd={...zu,emStrongRDelimAst:UT,emStrongLDelim:FT,url:yt(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},im=e=>ZT[e];function Ca(e,t){if(t){if(Os.escapeTest.test(e))return e.replace(Os.escapeReplace,im)}else if(Os.escapeTestNoEncode.test(e))return e.replace(Os.escapeReplaceNoEncode,im);return e}function lm(e){try{e=encodeURI(e).replace(Os.percentDecode,"%")}catch{return null}return e}function om(e,t){var i;const s=e.replace(Os.findPipe,(l,o,r)=>{let c=!1,d=o;for(;--d>=0&&r[d]==="\\";)c=!c;return c?"|":" |"}),a=s.split(Os.splitPipe);let n=0;if(a[0].trim()||a.shift(),a.length>0&&!((i=a.at(-1))!=null&&i.trim())&&a.pop(),t)if(a.length>t)a.splice(t);else for(;a.length0?-2:-1}function rm(e,t,s,a,n){const i=t.href,l=t.title||null,o=e[1].replace(n.other.outputLinkReplace,"$1");a.state.inLink=!0;const r={type:e[0].charAt(0)==="!"?"image":"link",raw:s,href:i,title:l,text:o,tokens:a.inlineTokens(o)};return a.state.inLink=!1,r}function QT(e,t,s){const a=e.match(s.other.indentCodeCompensation);if(a===null)return t;const n=a[1];return t.split(` + `,setup(){const e=p({}),t=p(!0),s=p(null),a=p([]),n=p(!1),i=p([]),l=p(!1),o=p(!1),r=p([]),c=p(0),d=p(null),u=p({reload:!1,clearSessions:!1,stopLoops:!1});let f=0;const m=U(()=>{const ue=e.value.uptime_seconds||0,Z=Math.floor(ue/86400),J=Math.floor(ue%86400/3600),se=Math.floor(ue%3600/60),q=[];return Z>0&&q.push(`${Z}d`),J>0&&q.push(`${J}h`),(q.length===0||Z===0&&J===0)&&q.push(`${se}m`),q.join(" ")}),h=U(()=>{const ue=e.value.uptime_seconds||0;return 125.66*(1-Math.min(ue/86400,1))}),g=U(()=>{const ue=e.value;return[{label:"Guilds",value:ue.guild_count??0,icon:"home",iconColor:"text-blue-400"},{label:"Sessions",value:ue.session_count??0,icon:"message",iconColor:"text-yellow-400"},{label:"Tools",value:ue.tool_count??0,icon:"wrench",iconColor:"text-purple-400",sub:`${ue.skill_count??0} skills`,subColor:"text-gray-500"},{label:"Loops",value:ue.loop_count??0,icon:"rotate",iconColor:"text-green-400",color:ue.loop_count>0?"text-green-400":"",highlight:ue.loop_count>0},{label:"Agents",value:ue.agent_running??0,icon:"bot",iconColor:"text-cyan-400",sub:ue.agent_count>0?`${ue.agent_count} total`:"",subColor:"text-gray-500",highlight:(ue.agent_running??0)>0},{label:"Processes",value:ue.process_running??0,icon:"sliders",iconColor:"text-orange-400",sub:ue.process_count>0?`${ue.process_count} total`:"",subColor:"text-gray-500",highlight:(ue.process_running??0)>0},{label:"Schedules",value:ue.schedule_count??0,icon:"clock",iconColor:"text-amber-400",sub:(ue.schedule_failing>0?`${ue.schedule_failing} failing`:"")+(ue.schedule_failing>0&&ue.schedule_paused>0?", ":"")+(ue.schedule_paused>0?`${ue.schedule_paused} paused`:"")||void 0,subColor:ue.schedule_failing>0?"text-red-400":"text-yellow-400",color:ue.schedule_failing>0?"text-red-400":"",highlight:ue.schedule_failing>0},{label:"Users",value:ue.user_count??0,icon:"users",iconColor:"text-indigo-400"},...d.value!==null?[{label:"Knowledge",value:d.value,icon:"book",iconColor:"text-teal-400",sub:"chunks",subColor:"text-gray-500"}]:[]]}),A=U(()=>{const ue=e.value,Z=[];return Z.push({label:"Bot",status:ue.status==="online"?"ok":"warn",detail:ue.status==="online"?"Online":"Starting"}),(ue.schedule_failing||0)>0?Z.push({label:"Schedules",status:"error",detail:`${ue.schedule_failing} failing`}):(ue.schedule_count||0)>0&&Z.push({label:"Schedules",status:"ok",detail:`${ue.schedule_count} configured`}),(ue.loop_count||0)>0&&Z.push({label:"Loops",status:"ok",detail:`${ue.loop_count} active`}),(ue.agent_running||0)>0&&Z.push({label:"Agents",status:"ok",detail:`${ue.agent_running} running`}),(ue.process_running||0)>0&&Z.push({label:"Processes",status:"ok",detail:`${ue.process_running} running`}),Z});async function w(){try{e.value=await z.get("/api/status"),s.value=null}catch(ue){s.value=ue.message}finally{t.value=!1}}let y=0,b=0,x=0,S=0;function _(ue,Z){const J=new Set;return[...Z,...ue].filter(se=>{const q=se._hmac||JSON.stringify([se.timestamp,se.tool_name,se.user_id,se.result_summary,se.error]);return J.has(q)?!1:(J.add(q),!0)})}async function T(){const ue=++y,Z=x;n.value=!0;try{const J=await z.get("/api/audit?limit=10");if(ue!==y)return;const se=Z===x?[]:a.value.filter(q=>(q._liveEpoch||0)>Z);a.value=_(J,se).slice(0,10),c.value=se.length}catch{}ue===y&&(n.value=!1)}async function L(){const ue=++b,Z=S;l.value=!0;try{const J=await z.get("/api/audit?error_only=1&limit=5");if(ue!==b)return;const se=Z===S?[]:i.value.filter(q=>(q._liveErrorEpoch||0)>Z);i.value=_(J,se).slice(0,5),o.value=!1}catch{if(ue!==b)return;o.value=Z===S||i.value.length===0}ue===b&&(l.value=!1)}async function E(){try{const ue=await z.get("/api/knowledge");d.value=(Array.isArray(ue)?ue:[]).reduce((Z,J)=>Z+(J.chunks||0),0)}catch{d.value=null}}async function P(){try{const ue=await z.get("/api/agents");r.value=ue.filter(Z=>Z.status==="running")}catch{}}async function R(){u.value={...u.value,reload:!0};try{await z.post("/api/reload"),ke.success("Config reloaded")}catch(ue){ke.error(ue.message)}u.value={...u.value,reload:!1}}async function I(){if(!await os({title:"Clear all sessions",message:"Clear all conversation sessions? This cannot be undone.",confirmLabel:"Clear All",danger:!0}))return;u.value={...u.value,clearSessions:!0};const Z=e.value.session_count;e.value={...e.value,session_count:0};try{const J=await z.post("/api/sessions/clear-all");ke.success(`Cleared ${J.count} session${J.count!==1?"s":""}`),await w()}catch(J){e.value={...e.value,session_count:Z},ke.error(J.message)}u.value={...u.value,clearSessions:!1}}async function H(){if(!await os({title:"Stop all loops",message:"Stop all running loops?",confirmLabel:"Stop Loops",danger:!0}))return;u.value={...u.value,stopLoops:!0};const Z=e.value.loop_count;e.value={...e.value,loop_count:0};try{const J=await z.post("/api/loops/stop-all");ke.success(J.result),await w()}catch(J){e.value={...e.value,loop_count:Z},ke.error(J.message)}u.value={...u.value,stopLoops:!1}}function X(){t.value=!0,s.value=null,w(),T(),L(),P()}let Q=null,N=null,O=null;function $(ue){if(ue.payload&&ue.payload.tool_name){x+=1;const Z={...ue.payload,_isNew:!0,_key:++f,_liveEpoch:x};a.value.unshift(Z),a.value.length>10&&a.value.pop(),c.value++,Z.error&&(S+=1,Z._liveErrorEpoch=S,o.value=!1,i.value.unshift(Z),i.value.length>5&&i.value.pop()),setTimeout(()=>{Z._isNew=!1},1500),clearTimeout(O),O=setTimeout(()=>{c.value=0},1e4)}}let ce=null;return tt(async()=>{await Promise.all([w(),T(),L(),P(),E()]),Q=setInterval(w,15e3),N=setInterval(P,1e4),ut.subscribe("events",$),ce=ut.onReconnected(()=>{T(),L()})}),xt(()=>{Q&&clearInterval(Q),N&&clearInterval(N),clearTimeout(O),ut.unsubscribe("events",$),ce&&(ce(),ce=null)}),{status:e,loading:t,error:s,uptime:m,uptimeRingOffset:h,stats:g,healthIndicators:A,activity:a,activityLoading:n,newEventCount:c,errors:i,errorsLoading:l,errorsError:o,agents:r,actionLoading:u,fetchActivity:T,fetchErrors:L,fetchStatus:w,onEvent:$,formatTime:t1,formatDuration:Zi,retry:X,reloadConfig:R,clearSessions:I,stopAllLoops:H}}};/*! @license DOMPurify 3.4.9 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.9/LICENSE */function Zf(e,t){(t==null||t>e.length)&&(t=e.length);for(var s=0,a=Array(t);s2?a-2:0),i=2;i1?s-1:0),n=1;n"u"?null:Jt(BigInt.prototype.toString),sm=typeof Symbol>"u"?null:Jt(Symbol.prototype.toString),Pt=Jt(Object.prototype.hasOwnProperty),gl=Jt(Object.prototype.toString),us=Jt(RegExp.prototype.test),Bn=fT(TypeError);function Jt(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);for(var s=arguments.length,a=new Array(s>1?s-1:0),n=1;n2&&arguments[2]!==void 0?arguments[2]:Sl;if(Qf&&Qf(e,null),!As(t))return e;let a=t.length;for(;a--;){let n=t[a];if(typeof n=="string"){const i=s(n);i!==n&&(iT(t)||(t[a]=i),n=i)}e[n]=!0}return e}function mT(e){for(let t=0;t/g),_T=la(/\${[\w\W]*/g),wT=la(/^data-[\-\w.\u00B7-\uFFFF]+$/),kT=la(/^aria-[\-\w]+$/),om=la(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),ST=la(/^(?:\w+script|data):/i),CT=la(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),TT=la(/^html$/i),ET=la(/^[a-z][.\w]*(-[.\w]+)+$/i),wa={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},AT=function(){return typeof window>"u"?null:window},RT=function(t,s){if(typeof t!="object"||typeof t.createPolicy!="function")return null;let a=null;const n="data-tt-policy-suffix";s&&s.hasAttribute(n)&&(a=s.getAttribute(n));const i="dompurify"+(a?"#"+a:"");try{return t.createPolicy(i,{createHTML(l){return l},createScriptURL(l){return l}})}catch{return console.warn("TrustedTypes policy "+i+" could not be created."),null}},rm=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function zg(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:AT();const t=Ie=>zg(Ie);if(t.version="3.4.9",t.removed=[],!e||!e.document||e.document.nodeType!==wa.document||!e.Element)return t.isSupported=!1,t;let s=e.document;const a=s,n=a.currentScript;e.DocumentFragment;const i=e.HTMLTemplateElement,l=e.Node,o=e.Element,r=e.NodeFilter,c=e.NamedNodeMap;c===void 0&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;const d=e.DOMParser,u=e.trustedTypes,f=o.prototype,m=Sa(f,"cloneNode"),h=Sa(f,"remove"),g=Sa(f,"nextSibling"),A=Sa(f,"childNodes"),w=Sa(f,"parentNode"),y=Sa(f,"shadowRoot"),b=Sa(f,"attributes"),x=l&&l.prototype?Sa(l.prototype,"nodeType"):null,S=l&&l.prototype?Sa(l.prototype,"nodeName"):null;if(typeof i=="function"){const Ie=s.createElement("template");Ie.content&&Ie.content.ownerDocument&&(s=Ie.content.ownerDocument)}let _,T="",L,E=!1,P=0;const R=function(){if(P>0)throw Bn('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},I=function(M){R(),P++;try{return _.createHTML(M)}finally{P--}},H=function(M){R(),P++;try{return _.createScriptURL(M)}finally{P--}},X=function(){return E||(L=RT(u,n),E=!0),L},Q=s,N=Q.implementation,O=Q.createNodeIterator,$=Q.createDocumentFragment,ce=Q.getElementsByTagName,ue=a.importNode;let Z=rm();t.isSupported=typeof Bg=="function"&&typeof w=="function"&&N&&N.createHTMLDocument!==void 0;const J=yT,se=xT,q=_T,ye=wT,Oe=kT,le=ST,oe=CT,ve=ET;let j=om,V=null;const xe=st({},[...am,...Gc,...Wc,...Kc,...nm]);let k=null;const D=st({},[...im,...Jc,...lm,...Vo]);let W=Object.seal(Ci(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ge=null,pe=null;const he=Object.seal(Ci(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let we=!0,K=!0,ne=!1,te=!0,_e=!1,B=!0,fe=!1,de=!1,be=!1,Fe=!1,qe=!1,$e=!1,Ke=!0,at=!1;const Xe="user-content-";let Y=!0,Ae=!1,Re={},Me=null;const ae=st({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let Ne=null;const He=st({},["audio","video","img","source","image","track"]);let rt=null;const Zt=st({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),lt="http://www.w3.org/1998/Math/MathML",gt="http://www.w3.org/2000/svg",Qt="http://www.w3.org/1999/xhtml";let cs=Qt,oa=!1,js=null;const Ze=st({},[lt,gt,Qt],Vc);let ra=st({},["mi","mo","mn","ms","mtext"]),vs=st({},["annotation-xml"]);const Ms=st({},["title","style","font","a","script"]);let gs=null;const An=["application/xhtml+xml","text/html"],Yt="text/html";let _t=null,ds=null;const ba=s.createElement("form"),rn=function(M){return M instanceof RegExp||M instanceof Function},ca=function(){let M=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(ds&&ds===M)return;(!M||typeof M!="object")&&(M={}),M=xs(M),gs=An.indexOf(M.PARSER_MEDIA_TYPE)===-1?Yt:M.PARSER_MEDIA_TYPE,_t=gs==="application/xhtml+xml"?Vc:Sl,V=Pt(M,"ALLOWED_TAGS")&&As(M.ALLOWED_TAGS)?st({},M.ALLOWED_TAGS,_t):xe,k=Pt(M,"ALLOWED_ATTR")&&As(M.ALLOWED_ATTR)?st({},M.ALLOWED_ATTR,_t):D,js=Pt(M,"ALLOWED_NAMESPACES")&&As(M.ALLOWED_NAMESPACES)?st({},M.ALLOWED_NAMESPACES,Vc):Ze,rt=Pt(M,"ADD_URI_SAFE_ATTR")&&As(M.ADD_URI_SAFE_ATTR)?st(xs(Zt),M.ADD_URI_SAFE_ATTR,_t):Zt,Ne=Pt(M,"ADD_DATA_URI_TAGS")&&As(M.ADD_DATA_URI_TAGS)?st(xs(He),M.ADD_DATA_URI_TAGS,_t):He,Me=Pt(M,"FORBID_CONTENTS")&&As(M.FORBID_CONTENTS)?st({},M.FORBID_CONTENTS,_t):ae,ge=Pt(M,"FORBID_TAGS")&&As(M.FORBID_TAGS)?st({},M.FORBID_TAGS,_t):xs({}),pe=Pt(M,"FORBID_ATTR")&&As(M.FORBID_ATTR)?st({},M.FORBID_ATTR,_t):xs({}),Re=Pt(M,"USE_PROFILES")?M.USE_PROFILES&&typeof M.USE_PROFILES=="object"?xs(M.USE_PROFILES):M.USE_PROFILES:!1,we=M.ALLOW_ARIA_ATTR!==!1,K=M.ALLOW_DATA_ATTR!==!1,ne=M.ALLOW_UNKNOWN_PROTOCOLS||!1,te=M.ALLOW_SELF_CLOSE_IN_ATTR!==!1,_e=M.SAFE_FOR_TEMPLATES||!1,B=M.SAFE_FOR_XML!==!1,fe=M.WHOLE_DOCUMENT||!1,Fe=M.RETURN_DOM||!1,qe=M.RETURN_DOM_FRAGMENT||!1,$e=M.RETURN_TRUSTED_TYPE||!1,be=M.FORCE_BODY||!1,Ke=M.SANITIZE_DOM!==!1,at=M.SANITIZE_NAMED_PROPS||!1,Y=M.KEEP_CONTENT!==!1,Ae=M.IN_PLACE||!1,j=vT(M.ALLOWED_URI_REGEXP)?M.ALLOWED_URI_REGEXP:om,cs=typeof M.NAMESPACE=="string"?M.NAMESPACE:Qt,ra=Pt(M,"MATHML_TEXT_INTEGRATION_POINTS")&&M.MATHML_TEXT_INTEGRATION_POINTS&&typeof M.MATHML_TEXT_INTEGRATION_POINTS=="object"?xs(M.MATHML_TEXT_INTEGRATION_POINTS):st({},["mi","mo","mn","ms","mtext"]),vs=Pt(M,"HTML_INTEGRATION_POINTS")&&M.HTML_INTEGRATION_POINTS&&typeof M.HTML_INTEGRATION_POINTS=="object"?xs(M.HTML_INTEGRATION_POINTS):st({},["annotation-xml"]);const me=Pt(M,"CUSTOM_ELEMENT_HANDLING")&&M.CUSTOM_ELEMENT_HANDLING&&typeof M.CUSTOM_ELEMENT_HANDLING=="object"?xs(M.CUSTOM_ELEMENT_HANDLING):Ci(null);if(W=Ci(null),Pt(me,"tagNameCheck")&&rn(me.tagNameCheck)&&(W.tagNameCheck=me.tagNameCheck),Pt(me,"attributeNameCheck")&&rn(me.attributeNameCheck)&&(W.attributeNameCheck=me.attributeNameCheck),Pt(me,"allowCustomizedBuiltInElements")&&typeof me.allowCustomizedBuiltInElements=="boolean"&&(W.allowCustomizedBuiltInElements=me.allowCustomizedBuiltInElements),_e&&(K=!1),qe&&(Fe=!0),Re&&(V=st({},nm),k=Ci(null),Re.html===!0&&(st(V,am),st(k,im)),Re.svg===!0&&(st(V,Gc),st(k,Jc),st(k,Vo)),Re.svgFilters===!0&&(st(V,Wc),st(k,Jc),st(k,Vo)),Re.mathMl===!0&&(st(V,Kc),st(k,lm),st(k,Vo))),he.tagCheck=null,he.attributeCheck=null,Pt(M,"ADD_TAGS")&&(typeof M.ADD_TAGS=="function"?he.tagCheck=M.ADD_TAGS:As(M.ADD_TAGS)&&(V===xe&&(V=xs(V)),st(V,M.ADD_TAGS,_t))),Pt(M,"ADD_ATTR")&&(typeof M.ADD_ATTR=="function"?he.attributeCheck=M.ADD_ATTR:As(M.ADD_ATTR)&&(k===D&&(k=xs(k)),st(k,M.ADD_ATTR,_t))),Pt(M,"ADD_URI_SAFE_ATTR")&&As(M.ADD_URI_SAFE_ATTR)&&st(rt,M.ADD_URI_SAFE_ATTR,_t),Pt(M,"FORBID_CONTENTS")&&As(M.FORBID_CONTENTS)&&(Me===ae&&(Me=xs(Me)),st(Me,M.FORBID_CONTENTS,_t)),Pt(M,"ADD_FORBID_CONTENTS")&&As(M.ADD_FORBID_CONTENTS)&&(Me===ae&&(Me=xs(Me)),st(Me,M.ADD_FORBID_CONTENTS,_t)),Y&&(V["#text"]=!0),fe&&st(V,["html","head","body"]),V.table&&(st(V,["tbody"]),delete ge.tbody),M.TRUSTED_TYPES_POLICY){if(typeof M.TRUSTED_TYPES_POLICY.createHTML!="function")throw Bn('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof M.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw Bn('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const Se=_;_=M.TRUSTED_TYPES_POLICY;try{T=I("")}catch(Ue){throw _=Se,Ue}}else M.TRUSTED_TYPES_POLICY===null?(_=void 0,T=""):(_===void 0&&(_=X()),_&&typeof T=="string"&&(T=I("")));(Z.uponSanitizeElement.length>0||Z.uponSanitizeAttribute.length>0)&&V===xe&&(V=xs(V)),Z.uponSanitizeAttribute.length>0&&k===D&&(k=xs(k)),Ns&&Ns(M),ds=M},re=st({},[...Gc,...Wc,...gT]),Ce=st({},[...Kc,...bT]),Ve=function(M){let me=w(M);(!me||!me.tagName)&&(me={namespaceURI:cs,tagName:"template"});const Se=Sl(M.tagName),Ue=Sl(me.tagName);return js[M.namespaceURI]?M.namespaceURI===gt?me.namespaceURI===Qt?Se==="svg":me.namespaceURI===lt?Se==="svg"&&(Ue==="annotation-xml"||ra[Ue]):!!re[Se]:M.namespaceURI===lt?me.namespaceURI===Qt?Se==="math":me.namespaceURI===gt?Se==="math"&&vs[Ue]:!!Ce[Se]:M.namespaceURI===Qt?me.namespaceURI===gt&&!vs[Ue]||me.namespaceURI===lt&&!ra[Ue]?!1:!Ce[Se]&&(Ms[Se]||!re[Se]):!!(gs==="application/xhtml+xml"&&js[M.namespaceURI]):!1},Ge=function(M){xi(t.removed,{element:M});try{w(M).removeChild(M)}catch{if(h(M),!w(M))throw Bn("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},kt=function(M){const me=A?A(M):M.childNodes;if(me){const Ue=[];Ua(me,We=>{xi(Ue,We)}),Ua(Ue,We=>{try{h(We)}catch{}})}const Se=b?b(M):null;if(Se)for(let Ue=Se.length-1;Ue>=0;--Ue){const We=Se[Ue],Ye=We&&We.name;if(typeof Ye=="string")try{M.removeAttribute(Ye)}catch{}}},St=function(M,me){try{xi(t.removed,{attribute:me.getAttributeNode(M),from:me})}catch{xi(t.removed,{attribute:null,from:me})}if(me.removeAttribute(M),M==="is")if(Fe||qe)try{Ge(me)}catch{}else try{me.setAttribute(M,"")}catch{}},cn=function(M){const me=b?b(M):M.attributes;if(me)for(let Se=me.length-1;Se>=0;--Se){const Ue=me[Se],We=Ue&&Ue.name;if(!(typeof We!="string"||k[_t(We)]))try{M.removeAttribute(We)}catch{}}},Rn=function(M){const me=[M];for(;me.length>0;){const Se=me.pop();(x?x(Se):Se.nodeType)===wa.element&&cn(Se);const We=A?A(Se):Se.childNodes;if(We)for(let Ye=We.length-1;Ye>=0;--Ye)me.push(We[Ye])}},dn=function(M){let me=null,Se=null;if(be)M=""+M;else{const Ye=Xf(M,/^[\r\n\t ]+/);Se=Ye&&Ye[0]}gs==="application/xhtml+xml"&&cs===Qt&&(M=''+M+"");const Ue=_?I(M):M;if(cs===Qt)try{me=new d().parseFromString(Ue,gs)}catch{}if(!me||!me.documentElement){me=N.createDocument(cs,"template",null);try{me.documentElement.innerHTML=oa?T:Ue}catch{}}const We=me.body||me.documentElement;return M&&Se&&We.insertBefore(s.createTextNode(Se),We.childNodes[0]||null),cs===Qt?ce.call(me,fe?"html":"body")[0]:fe?me.documentElement:We},In=function(M){return O.call(M.ownerDocument||M,M,r.SHOW_ELEMENT|r.SHOW_COMMENT|r.SHOW_TEXT|r.SHOW_PROCESSING_INSTRUCTION|r.SHOW_CDATA_SECTION,null)},da=function(M){var me,Se;M.normalize();const Ue=O.call(M.ownerDocument||M,M,r.SHOW_TEXT|r.SHOW_COMMENT|r.SHOW_CDATA_SECTION|r.SHOW_PROCESSING_INSTRUCTION,null);let We=Ue.nextNode();for(;We;){let Et=We.data;Ua([J,se,q],pt=>{Et=_i(Et,pt," ")}),We.data=Et,We=Ue.nextNode()}const Ye=(me=(Se=M.querySelectorAll)===null||Se===void 0?void 0:Se.call(M,"template"))!==null&&me!==void 0?me:[];Ua(Array.from(Ye),Et=>{qt(Et.content)&&da(Et.content)})},ya=function(M){const me=S?S(M):null;return typeof me!="string"||_t(me)!=="form"?!1:typeof M.nodeName!="string"||typeof M.textContent!="string"||typeof M.removeChild!="function"||M.attributes!==b(M)||typeof M.removeAttribute!="function"||typeof M.setAttribute!="function"||typeof M.namespaceURI!="string"||typeof M.insertBefore!="function"||typeof M.hasChildNodes!="function"||M.nodeType!==x(M)||M.childNodes!==A(M)},qt=function(M){if(!x||typeof M!="object"||M===null)return!1;try{return x(M)===wa.documentFragment}catch{return!1}},ua=function(M){if(!x||typeof M!="object"||M===null)return!1;try{return typeof x(M)=="number"}catch{return!1}};function as(Ie,M,me){Ua(Ie,Se=>{Se.call(t,M,me,ds)})}const un=function(M){let me=null;if(as(Z.beforeSanitizeElements,M,null),ya(M))return Ge(M),!0;const Se=_t(S?S(M):M.nodeName);if(as(Z.uponSanitizeElement,M,{tagName:Se,allowedTags:V}),B&&M.hasChildNodes()&&!ua(M.firstElementChild)&&us(/<[/\w!]/g,M.innerHTML)&&us(/<[/\w!]/g,M.textContent)||B&&M.namespaceURI===Qt&&Se==="style"&&ua(M.firstElementChild)||M.nodeType===wa.progressingInstruction||B&&M.nodeType===wa.comment&&us(/<[/\w]/g,M.data))return Ge(M),!0;if(ge[Se]||!(he.tagCheck instanceof Function&&he.tagCheck(Se))&&!V[Se]){if(!ge[Se]&&ri(Se)&&(W.tagNameCheck instanceof RegExp&&us(W.tagNameCheck,Se)||W.tagNameCheck instanceof Function&&W.tagNameCheck(Se)))return!1;if(Y&&!Me[Se]){const We=w(M),Ye=A(M);if(Ye&&We){const Et=Ye.length;for(let pt=Et-1;pt>=0;--pt){const At=Ae?Ye[pt]:m(Ye[pt],!0);We.insertBefore(At,g(M))}}}return Ge(M),!0}return(x?x(M):M.nodeType)===wa.element&&!Ve(M)||(Se==="noscript"||Se==="noembed"||Se==="noframes")&&us(/<\/no(script|embed|frames)/i,M.innerHTML)?(Ge(M),!0):(_e&&M.nodeType===wa.text&&(me=M.textContent,Ua([J,se,q],We=>{me=_i(me,We," ")}),M.textContent!==me&&(xi(t.removed,{element:M.cloneNode()}),M.textContent=me)),as(Z.afterSanitizeElements,M,null),!1)},On=function(M,me,Se){if(pe[me]||Ke&&(me==="id"||me==="name")&&(Se in s||Se in ba))return!1;const Ue=k[me]||he.attributeCheck instanceof Function&&he.attributeCheck(me,M);if(!(K&&!pe[me]&&us(ye,me))){if(!(we&&us(Oe,me))){if(!Ue||pe[me]){if(!(ri(M)&&(W.tagNameCheck instanceof RegExp&&us(W.tagNameCheck,M)||W.tagNameCheck instanceof Function&&W.tagNameCheck(M))&&(W.attributeNameCheck instanceof RegExp&&us(W.attributeNameCheck,me)||W.attributeNameCheck instanceof Function&&W.attributeNameCheck(me,M))||me==="is"&&W.allowCustomizedBuiltInElements&&(W.tagNameCheck instanceof RegExp&&us(W.tagNameCheck,Se)||W.tagNameCheck instanceof Function&&W.tagNameCheck(Se))))return!1}else if(!rt[me]){if(!us(j,_i(Se,oe,""))){if(!((me==="src"||me==="xlink:href"||me==="href")&&M!=="script"&&em(Se,"data:")===0&&Ne[M])){if(!(ne&&!us(le,_i(Se,oe,"")))){if(Se)return!1}}}}}}return!0},oi=st({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),ri=function(M){return!oi[Sl(M)]&&us(ve,M)},ci=function(M){as(Z.beforeSanitizeAttributes,M,null);const me=M.attributes;if(!me||ya(M))return;const Se={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:k,forceKeepAttr:void 0};let Ue=me.length;for(;Ue--;){const We=me[Ue],Ye=We.name,Et=We.namespaceURI,pt=We.value,At=_t(Ye),bs=pt;let Nt=Ye==="value"?bs:dT(bs);if(Se.attrName=At,Se.attrValue=Nt,Se.keepAttr=!0,Se.forceKeepAttr=void 0,as(Z.uponSanitizeAttribute,M,Se),Nt=Se.attrValue,at&&(At==="id"||At==="name")&&em(Nt,Xe)!==0&&(St(Ye,M),Nt=Xe+Nt),B&&us(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,Nt)){St(Ye,M);continue}if(At==="attributename"&&Xf(Nt,"href")){St(Ye,M);continue}if(Se.forceKeepAttr)continue;if(!Se.keepAttr){St(Ye,M);continue}if(!te&&us(/\/>/i,Nt)){St(Ye,M);continue}_e&&Ua([J,se,q],di=>{Nt=_i(Nt,di," ")});const Na=_t(M.nodeName);if(!On(Na,At,Nt)){St(Ye,M);continue}if(_&&typeof u=="object"&&typeof u.getAttributeType=="function"&&!Et)switch(u.getAttributeType(Na,At)){case"TrustedHTML":{Nt=I(Nt);break}case"TrustedScriptURL":{Nt=H(Nt);break}}if(Nt!==bs)try{Et?M.setAttributeNS(Et,Ye,Nt):M.setAttribute(Ye,Nt),ya(M)?Ge(M):Yf(t.removed)}catch{St(Ye,M)}}as(Z.afterSanitizeAttributes,M,null)},ns=function(M){let me=null;const Se=In(M);for(as(Z.beforeSanitizeShadowDOM,M,null);me=Se.nextNode();)if(as(Z.uponSanitizeShadowNode,me,null),un(me),ci(me),qt(me.content)&&ns(me.content),(x?x(me):me.nodeType)===wa.element){const We=y?y(me):me.shadowRoot;qt(We)&&(Ln(We),ns(We))}as(Z.afterSanitizeShadowDOM,M,null)},Ln=function(M){const me=[{node:M,shadow:null}];for(;me.length>0;){const Se=me.pop();if(Se.shadow){ns(Se.shadow);continue}const Ue=Se.node,Ye=(x?x(Ue):Ue.nodeType)===wa.element,Et=A?A(Ue):Ue.childNodes;if(Et)for(let pt=Et.length-1;pt>=0;--pt)me.push({node:Et[pt],shadow:null});if(Ye){const pt=S?S(Ue):null;if(typeof pt=="string"&&_t(pt)==="template"){const At=Ue.content;qt(At)&&me.push({node:At,shadow:null})}}if(Ye){const pt=y?y(Ue):Ue.shadowRoot;qt(pt)&&me.push({node:null,shadow:pt},{node:pt,shadow:null})}}};return t.sanitize=function(Ie){let M=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},me=null,Se=null,Ue=null,We=null;if(oa=!Ie,oa&&(Ie=""),typeof Ie!="string"&&!ua(Ie)&&(Ie=hT(Ie),typeof Ie!="string"))throw Bn("dirty is not a string, aborting");if(!t.isSupported)return Ie;de||ca(M),t.removed=[];const Ye=Ae&&typeof Ie!="string"&&ua(Ie);if(Ye){const At=S?S(Ie):Ie.nodeName;if(typeof At=="string"){const bs=_t(At);if(!V[bs]||ge[bs])throw Bn("root node is forbidden and cannot be sanitized in-place")}if(ya(Ie))throw Bn("root node is clobbered and cannot be sanitized in-place");try{Ln(Ie)}catch(bs){throw kt(Ie),bs}}else if(ua(Ie))me=dn(""),Se=me.ownerDocument.importNode(Ie,!0),Se.nodeType===wa.element&&Se.nodeName==="BODY"||Se.nodeName==="HTML"?me=Se:me.appendChild(Se),Ln(Se);else{if(!Fe&&!_e&&!fe&&Ie.indexOf("<")===-1)return _&&$e?I(Ie):Ie;if(me=dn(Ie),!me)return Fe?null:$e?T:""}me&&be&&Ge(me.firstChild);const Et=In(Ye?Ie:me);try{for(;Ue=Et.nextNode();)un(Ue),ci(Ue),qt(Ue.content)&&ns(Ue.content)}catch(At){throw Ye&&kt(Ie),At}if(Ye)return Ua(t.removed,At=>{At.element&&Rn(At.element)}),_e&&da(Ie),Ie;if(Fe){if(_e&&da(me),qe)for(We=$.call(me.ownerDocument);me.firstChild;)We.appendChild(me.firstChild);else We=me;return(k.shadowroot||k.shadowrootmode)&&(We=ue.call(a,We,!0)),We}let pt=fe?me.outerHTML:me.innerHTML;return fe&&V["!doctype"]&&me.ownerDocument&&me.ownerDocument.doctype&&me.ownerDocument.doctype.name&&us(TT,me.ownerDocument.doctype.name)&&(pt=" +`+pt),_e&&Ua([J,se,q],At=>{pt=_i(pt,At," ")}),_&&$e?I(pt):pt},t.setConfig=function(){let Ie=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};ca(Ie),de=!0},t.clearConfig=function(){ds=null,de=!1,_=L,T=""},t.isValidAttribute=function(Ie,M,me){ds||ca({});const Se=_t(Ie),Ue=_t(M);return On(Se,Ue,me)},t.addHook=function(Ie,M){typeof M=="function"&&xi(Z[Ie],M)},t.removeHook=function(Ie,M){if(M!==void 0){const me=rT(Z[Ie],M);return me===-1?void 0:cT(Z[Ie],me,1)[0]}return Yf(Z[Ie])},t.removeHooks=function(Ie){Z[Ie]=[]},t.removeAllHooks=function(){Z=rm()},t}var cm=zg();function Uu(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var li=Uu();function Hg(e){li=e}var Nl={exec:()=>null};function yt(e,t=""){let s=typeof e=="string"?e:e.source;const a={replace:(n,i)=>{let l=typeof i=="string"?i:i.source;return l=l.replace(Os.caret,"$1"),s=s.replace(n,l),a},getRegex:()=>new RegExp(s,t)};return a}var Os={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},IT=/^(?:[ \t]*(?:\n|$))+/,OT=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,LT=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,mo=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,NT=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,zu=/(?:[*+-]|\d{1,9}[.)])/,jg=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,qg=yt(jg).replace(/bull/g,zu).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),MT=yt(jg).replace(/bull/g,zu).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Hu=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,DT=/^[^\n]+/,ju=/(?!\s*\])(?:\\.|[^\[\]\\])+/,PT=yt(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",ju).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),$T=yt(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,zu).getRegex(),lc="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",qu=/|$))/,FT=yt("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",qu).replace("tag",lc).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Vg=yt(Hu).replace("hr",mo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",lc).getRegex(),BT=yt(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Vg).getRegex(),Vu={blockquote:BT,code:OT,def:PT,fences:LT,heading:NT,hr:mo,html:FT,lheading:qg,list:$T,newline:IT,paragraph:Vg,table:Nl,text:DT},dm=yt("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",mo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",lc).getRegex(),UT={...Vu,lheading:MT,table:dm,paragraph:yt(Hu).replace("hr",mo).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",dm).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",lc).getRegex()},zT={...Vu,html:yt(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",qu).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Nl,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:yt(Hu).replace("hr",mo).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",qg).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},HT=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,jT=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Gg=/^( {2,}|\\)\n(?!\s*$)/,qT=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,Jg=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,JT=yt(Jg,"u").replace(/punct/g,oc).getRegex(),ZT=yt(Jg,"u").replace(/punct/g,Kg).getRegex(),Zg="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",QT=yt(Zg,"gu").replace(/notPunctSpace/g,Wg).replace(/punctSpace/g,Gu).replace(/punct/g,oc).getRegex(),YT=yt(Zg,"gu").replace(/notPunctSpace/g,WT).replace(/punctSpace/g,GT).replace(/punct/g,Kg).getRegex(),XT=yt("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,Wg).replace(/punctSpace/g,Gu).replace(/punct/g,oc).getRegex(),eE=yt(/\\(punct)/,"gu").replace(/punct/g,oc).getRegex(),tE=yt(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),sE=yt(qu).replace("(?:-->|$)","-->").getRegex(),aE=yt("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",sE).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Rr=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,nE=yt(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",Rr).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Qg=yt(/^!?\[(label)\]\[(ref)\]/).replace("label",Rr).replace("ref",ju).getRegex(),Yg=yt(/^!?\[(ref)\](?:\[\])?/).replace("ref",ju).getRegex(),iE=yt("reflink|nolink(?!\\()","g").replace("reflink",Qg).replace("nolink",Yg).getRegex(),Wu={_backpedal:Nl,anyPunctuation:eE,autolink:tE,blockSkip:KT,br:Gg,code:jT,del:Nl,emStrongLDelim:JT,emStrongRDelimAst:QT,emStrongRDelimUnd:XT,escape:HT,link:nE,nolink:Yg,punctuation:VT,reflink:Qg,reflinkSearch:iE,tag:aE,text:qT,url:Nl},lE={...Wu,link:yt(/^!?\[(label)\]\((.*?)\)/).replace("label",Rr).getRegex(),reflink:yt(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Rr).getRegex()},Dd={...Wu,emStrongRDelimAst:YT,emStrongLDelim:ZT,url:yt(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},um=e=>rE[e];function Ca(e,t){if(t){if(Os.escapeTest.test(e))return e.replace(Os.escapeReplace,um)}else if(Os.escapeTestNoEncode.test(e))return e.replace(Os.escapeReplaceNoEncode,um);return e}function pm(e){try{e=encodeURI(e).replace(Os.percentDecode,"%")}catch{return null}return e}function fm(e,t){var i;const s=e.replace(Os.findPipe,(l,o,r)=>{let c=!1,d=o;for(;--d>=0&&r[d]==="\\";)c=!c;return c?"|":" |"}),a=s.split(Os.splitPipe);let n=0;if(a[0].trim()||a.shift(),a.length>0&&!((i=a.at(-1))!=null&&i.trim())&&a.pop(),t)if(a.length>t)a.splice(t);else for(;a.length0?-2:-1}function mm(e,t,s,a,n){const i=t.href,l=t.title||null,o=e[1].replace(n.other.outputLinkReplace,"$1");a.state.inLink=!0;const r={type:e[0].charAt(0)==="!"?"image":"link",raw:s,href:i,title:l,text:o,tokens:a.inlineTokens(o)};return a.state.inLink=!1,r}function dE(e,t,s){const a=e.match(s.other.indentCodeCompensation);if(a===null)return t;const n=a[1];return t.split(` `).map(i=>{const l=i.match(s.other.beginningSpace);if(l===null)return i;const[o]=l;return o.length>=n.length?i.slice(n.length):i}).join(` -`)}var Cr=class{constructor(e){Tt(this,"options");Tt(this,"rules");Tt(this,"lexer");this.options=e||ii}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const s=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?s:bl(s,` -`)}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const s=t[0],a=QT(s,t[3]||"",this.rules);return{type:"code",raw:s,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:a}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let s=t[2].trim();if(this.rules.other.endingHash.test(s)){const a=bl(s,"#");(this.options.pedantic||!a||this.rules.other.endingSpaceChar.test(a))&&(s=a.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:s,tokens:this.lexer.inline(s)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:bl(t[0],` -`)}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let s=bl(t[0],` +`)}var Ir=class{constructor(e){Tt(this,"options");Tt(this,"rules");Tt(this,"lexer");this.options=e||li}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const s=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?s:yl(s,` +`)}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const s=t[0],a=dE(s,t[3]||"",this.rules);return{type:"code",raw:s,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:a}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let s=t[2].trim();if(this.rules.other.endingHash.test(s)){const a=yl(s,"#");(this.options.pedantic||!a||this.rules.other.endingSpaceChar.test(a))&&(s=a.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:s,tokens:this.lexer.inline(s)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:yl(t[0],` +`)}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let s=yl(t[0],` `).split(` `),a="",n="";const i=[];for(;s.length>0;){let l=!1;const o=[];let r;for(r=0;r1,n={type:"list",raw:"",ordered:a,start:a?+s.slice(0,-1):"",loose:!1,items:[]};s=a?`\\d{1,9}\\${s.slice(-1)}`:`\\${s}`,this.options.pedantic&&(s=a?s:"[*+-]");const i=this.rules.other.listItemRegex(s);let l=!1;for(;e;){let r=!1,c="",d="";if(!(t=i.exec(e))||this.rules.block.hr.test(e))break;c=t[0],e=e.substring(c.length);let u=t[2].split(` -`,1)[0].replace(this.rules.other.listReplaceTabs,k=>" ".repeat(3*k.length)),f=e.split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,w=>" ".repeat(3*w.length)),f=e.split(` `,1)[0],m=!u.trim(),h=0;if(this.options.pedantic?(h=2,d=u.trimStart()):m?h=t[1].length+1:(h=t[2].search(this.rules.other.nonSpaceChar),h=h>4?1:h,d=u.slice(h),h+=t[1].length),m&&this.rules.other.blankLine.test(f)&&(c+=f+` -`,e=e.substring(f.length+1),r=!0),!r){const k=this.rules.other.nextBulletRegex(h),y=this.rules.other.hrRegex(h),g=this.rules.other.fencesBeginRegex(h),x=this.rules.other.headingBeginRegex(h),S=this.rules.other.htmlBeginRegex(h);for(;e;){const _=e.split(` -`,1)[0];let A;if(f=_,this.options.pedantic?(f=f.replace(this.rules.other.listReplaceNesting," "),A=f):A=f.replace(this.rules.other.tabCharGlobal," "),g.test(f)||x.test(f)||S.test(f)||k.test(f)||y.test(f))break;if(A.search(this.rules.other.nonSpaceChar)>=h||!f.trim())d+=` -`+A.slice(h);else{if(m||u.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||g.test(u)||x.test(u)||y.test(u))break;d+=` +`,e=e.substring(f.length+1),r=!0),!r){const w=this.rules.other.nextBulletRegex(h),y=this.rules.other.hrRegex(h),b=this.rules.other.fencesBeginRegex(h),x=this.rules.other.headingBeginRegex(h),S=this.rules.other.htmlBeginRegex(h);for(;e;){const _=e.split(` +`,1)[0];let T;if(f=_,this.options.pedantic?(f=f.replace(this.rules.other.listReplaceNesting," "),T=f):T=f.replace(this.rules.other.tabCharGlobal," "),b.test(f)||x.test(f)||S.test(f)||w.test(f)||y.test(f))break;if(T.search(this.rules.other.nonSpaceChar)>=h||!f.trim())d+=` +`+T.slice(h);else{if(m||u.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||b.test(u)||x.test(u)||y.test(u))break;d+=` `+f}!m&&!f.trim()&&(m=!0),c+=_+` -`,e=e.substring(_.length+1),u=A.slice(h)}}n.loose||(l?n.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(l=!0));let b=null,I;this.options.gfm&&(b=this.rules.other.listIsTask.exec(d),b&&(I=b[0]!=="[ ] ",d=d.replace(this.rules.other.listReplaceTask,""))),n.items.push({type:"list_item",raw:c,task:!!b,checked:I,loose:!1,text:d,tokens:[]}),n.raw+=c}const o=n.items.at(-1);if(o)o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd();else return;n.raw=n.raw.trimEnd();for(let r=0;ru.type==="space"),d=c.length>0&&c.some(u=>this.rules.other.anyLine.test(u.raw));n.loose=d}if(n.loose)for(let r=0;r({text:r,tokens:this.lexer.inline(r),header:!1,align:i.align[c]})));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const s=t[1].charAt(t[1].length-1)===` -`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:s,tokens:this.lexer.inline(s)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const s=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(s)){if(!this.rules.other.endAngleBracket.test(s))return;const i=bl(s.slice(0,-1),"\\");if((s.length-i.length)%2===0)return}else{const i=YT(t[2],"()");if(i===-2)return;if(i>-1){const o=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,o).trim(),t[3]=""}}let a=t[2],n="";if(this.options.pedantic){const i=this.rules.other.pedanticHrefTitle.exec(a);i&&(a=i[1],n=i[3])}else n=t[3]?t[3].slice(1,-1):"";return a=a.trim(),this.rules.other.startAngleBracket.test(a)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(s)?a=a.slice(1):a=a.slice(1,-1)),rm(t,{href:a&&a.replace(this.rules.inline.anyPunctuation,"$1"),title:n&&n.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let s;if((s=this.rules.inline.reflink.exec(e))||(s=this.rules.inline.nolink.exec(e))){const a=(s[2]||s[1]).replace(this.rules.other.multipleSpaceGlobal," "),n=t[a.toLowerCase()];if(!n){const i=s[0].charAt(0);return{type:"text",raw:i,text:i}}return rm(s,n,s[0],this.lexer,this.rules)}}emStrong(e,t,s=""){let a=this.rules.inline.emStrongLDelim.exec(e);if(!a||a[3]&&s.match(this.rules.other.unicodeAlphaNumeric))return;if(!(a[1]||a[2]||"")||!s||this.rules.inline.punctuation.exec(s)){const i=[...a[0]].length-1;let l,o,r=i,c=0;const d=a[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(d.lastIndex=0,t=t.slice(-1*e.length+i);(a=d.exec(t))!=null;){if(l=a[1]||a[2]||a[3]||a[4]||a[5]||a[6],!l)continue;if(o=[...l].length,a[3]||a[4]){r+=o;continue}else if((a[5]||a[6])&&i%3&&!((i+o)%3)){c+=o;continue}if(r-=o,r>0)continue;o=Math.min(o,o+r+c);const u=[...a[0]][0].length,f=e.slice(0,i+a.index+u+o);if(Math.min(i,o)%2){const h=f.slice(1,-1);return{type:"em",raw:f,text:h,tokens:this.lexer.inlineTokens(h)}}const m=f.slice(2,-2);return{type:"strong",raw:f,text:m,tokens:this.lexer.inlineTokens(m)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let s=t[2].replace(this.rules.other.newLineCharGlobal," ");const a=this.rules.other.nonSpaceChar.test(s),n=this.rules.other.startingSpaceChar.test(s)&&this.rules.other.endingSpaceChar.test(s);return a&&n&&(s=s.substring(1,s.length-1)),{type:"codespan",raw:t[0],text:s}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let s,a;return t[2]==="@"?(s=t[1],a="mailto:"+s):(s=t[1],a=s),{type:"link",raw:t[0],text:s,href:a,tokens:[{type:"text",raw:s,text:s}]}}}url(e){var s;let t;if(t=this.rules.inline.url.exec(e)){let a,n;if(t[2]==="@")a=t[0],n="mailto:"+a;else{let i;do i=t[0],t[0]=((s=this.rules.inline._backpedal.exec(t[0]))==null?void 0:s[0])??"";while(i!==t[0]);a=t[0],t[1]==="www."?n="http://"+t[0]:n=t[0]}return{type:"link",raw:t[0],text:a,href:n,tokens:[{type:"text",raw:a,text:a}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const s=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:s}}}},Ka=class Id{constructor(t){Tt(this,"tokens");Tt(this,"options");Tt(this,"state");Tt(this,"tokenizer");Tt(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=t||ii,this.options.tokenizer=this.options.tokenizer||new Cr,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const s={other:Os,block:zo.normal,inline:gl.normal};this.options.pedantic?(s.block=zo.pedantic,s.inline=gl.pedantic):this.options.gfm&&(s.block=zo.gfm,this.options.breaks?s.inline=gl.breaks:s.inline=gl.gfm),this.tokenizer.rules=s}static get rules(){return{block:zo,inline:gl}}static lex(t,s){return new Id(s).lex(t)}static lexInline(t,s){return new Id(s).inlineTokens(t)}lex(t){t=t.replace(Os.carriageReturn,` +`,e=e.substring(_.length+1),u=T.slice(h)}}n.loose||(l?n.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(l=!0));let g=null,A;this.options.gfm&&(g=this.rules.other.listIsTask.exec(d),g&&(A=g[0]!=="[ ] ",d=d.replace(this.rules.other.listReplaceTask,""))),n.items.push({type:"list_item",raw:c,task:!!g,checked:A,loose:!1,text:d,tokens:[]}),n.raw+=c}const o=n.items.at(-1);if(o)o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd();else return;n.raw=n.raw.trimEnd();for(let r=0;ru.type==="space"),d=c.length>0&&c.some(u=>this.rules.other.anyLine.test(u.raw));n.loose=d}if(n.loose)for(let r=0;r({text:r,tokens:this.lexer.inline(r),header:!1,align:i.align[c]})));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const s=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:s,tokens:this.lexer.inline(s)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const s=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(s)){if(!this.rules.other.endAngleBracket.test(s))return;const i=yl(s.slice(0,-1),"\\");if((s.length-i.length)%2===0)return}else{const i=cE(t[2],"()");if(i===-2)return;if(i>-1){const o=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,o).trim(),t[3]=""}}let a=t[2],n="";if(this.options.pedantic){const i=this.rules.other.pedanticHrefTitle.exec(a);i&&(a=i[1],n=i[3])}else n=t[3]?t[3].slice(1,-1):"";return a=a.trim(),this.rules.other.startAngleBracket.test(a)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(s)?a=a.slice(1):a=a.slice(1,-1)),mm(t,{href:a&&a.replace(this.rules.inline.anyPunctuation,"$1"),title:n&&n.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let s;if((s=this.rules.inline.reflink.exec(e))||(s=this.rules.inline.nolink.exec(e))){const a=(s[2]||s[1]).replace(this.rules.other.multipleSpaceGlobal," "),n=t[a.toLowerCase()];if(!n){const i=s[0].charAt(0);return{type:"text",raw:i,text:i}}return mm(s,n,s[0],this.lexer,this.rules)}}emStrong(e,t,s=""){let a=this.rules.inline.emStrongLDelim.exec(e);if(!a||a[3]&&s.match(this.rules.other.unicodeAlphaNumeric))return;if(!(a[1]||a[2]||"")||!s||this.rules.inline.punctuation.exec(s)){const i=[...a[0]].length-1;let l,o,r=i,c=0;const d=a[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(d.lastIndex=0,t=t.slice(-1*e.length+i);(a=d.exec(t))!=null;){if(l=a[1]||a[2]||a[3]||a[4]||a[5]||a[6],!l)continue;if(o=[...l].length,a[3]||a[4]){r+=o;continue}else if((a[5]||a[6])&&i%3&&!((i+o)%3)){c+=o;continue}if(r-=o,r>0)continue;o=Math.min(o,o+r+c);const u=[...a[0]][0].length,f=e.slice(0,i+a.index+u+o);if(Math.min(i,o)%2){const h=f.slice(1,-1);return{type:"em",raw:f,text:h,tokens:this.lexer.inlineTokens(h)}}const m=f.slice(2,-2);return{type:"strong",raw:f,text:m,tokens:this.lexer.inlineTokens(m)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let s=t[2].replace(this.rules.other.newLineCharGlobal," ");const a=this.rules.other.nonSpaceChar.test(s),n=this.rules.other.startingSpaceChar.test(s)&&this.rules.other.endingSpaceChar.test(s);return a&&n&&(s=s.substring(1,s.length-1)),{type:"codespan",raw:t[0],text:s}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let s,a;return t[2]==="@"?(s=t[1],a="mailto:"+s):(s=t[1],a=s),{type:"link",raw:t[0],text:s,href:a,tokens:[{type:"text",raw:s,text:s}]}}}url(e){var s;let t;if(t=this.rules.inline.url.exec(e)){let a,n;if(t[2]==="@")a=t[0],n="mailto:"+a;else{let i;do i=t[0],t[0]=((s=this.rules.inline._backpedal.exec(t[0]))==null?void 0:s[0])??"";while(i!==t[0]);a=t[0],t[1]==="www."?n="http://"+t[0]:n=t[0]}return{type:"link",raw:t[0],text:a,href:n,tokens:[{type:"text",raw:a,text:a}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const s=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:s}}}},Ka=class Pd{constructor(t){Tt(this,"tokens");Tt(this,"options");Tt(this,"state");Tt(this,"tokenizer");Tt(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=t||li,this.options.tokenizer=this.options.tokenizer||new Ir,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const s={other:Os,block:Go.normal,inline:bl.normal};this.options.pedantic?(s.block=Go.pedantic,s.inline=bl.pedantic):this.options.gfm&&(s.block=Go.gfm,this.options.breaks?s.inline=bl.breaks:s.inline=bl.gfm),this.tokenizer.rules=s}static get rules(){return{block:Go,inline:bl}}static lex(t,s){return new Pd(s).lex(t)}static lexInline(t,s){return new Pd(s).inlineTokens(t)}lex(t){t=t.replace(Os.carriageReturn,` `),this.blockTokens(t,this.tokens);for(let s=0;s(o=c.call({lexer:this},t,s))?(t=t.substring(o.raw.length),s.push(o),!0):!1))continue;if(o=this.tokenizer.space(t)){t=t.substring(o.raw.length);const c=s.at(-1);o.raw.length===1&&c!==void 0?c.raw+=` `:s.push(o);continue}if(o=this.tokenizer.code(t)){t=t.substring(o.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="paragraph"||(c==null?void 0:c.type)==="text"?(c.raw+=` `+o.raw,c.text+=` @@ -7071,7 +7106,7 @@ ${d}`:d;const u=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.block `+o.raw,c.text+=` `+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(o),a=r.length!==t.length,t=t.substring(o.raw.length);continue}if(o=this.tokenizer.text(t)){t=t.substring(o.raw.length);const c=s.at(-1);(c==null?void 0:c.type)==="text"?(c.raw+=` `+o.raw,c.text+=` -`+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(o);continue}if(t){const c="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return this.state.top=!0,s}inline(t,s=[]){return this.inlineQueue.push({src:t,tokens:s}),s}inlineTokens(t,s=[]){var o,r,c;let a=t,n=null;if(this.tokens.links){const d=Object.keys(this.tokens.links);if(d.length>0)for(;(n=this.tokenizer.rules.inline.reflinkSearch.exec(a))!=null;)d.includes(n[0].slice(n[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,n.index)+"["+"a".repeat(n[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(n=this.tokenizer.rules.inline.anyPunctuation.exec(a))!=null;)a=a.slice(0,n.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(n=this.tokenizer.rules.inline.blockSkip.exec(a))!=null;)a=a.slice(0,n.index)+"["+"a".repeat(n[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,l="";for(;t;){i||(l=""),i=!1;let d;if((r=(o=this.options.extensions)==null?void 0:o.inline)!=null&&r.some(f=>(d=f.call({lexer:this},t,s))?(t=t.substring(d.raw.length),s.push(d),!0):!1))continue;if(d=this.tokenizer.escape(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.tag(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.link(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(d.raw.length);const f=s.at(-1);d.type==="text"&&(f==null?void 0:f.type)==="text"?(f.raw+=d.raw,f.text+=d.text):s.push(d);continue}if(d=this.tokenizer.emStrong(t,a,l)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.codespan(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.br(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.del(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.autolink(t)){t=t.substring(d.raw.length),s.push(d);continue}if(!this.state.inLink&&(d=this.tokenizer.url(t))){t=t.substring(d.raw.length),s.push(d);continue}let u=t;if((c=this.options.extensions)!=null&&c.startInline){let f=1/0;const m=t.slice(1);let h;this.options.extensions.startInline.forEach(b=>{h=b.call({lexer:this},m),typeof h=="number"&&h>=0&&(f=Math.min(f,h))}),f<1/0&&f>=0&&(u=t.substring(0,f+1))}if(d=this.tokenizer.inlineText(u)){t=t.substring(d.raw.length),d.raw.slice(-1)!=="_"&&(l=d.raw.slice(-1)),i=!0;const f=s.at(-1);(f==null?void 0:f.type)==="text"?(f.raw+=d.raw,f.text+=d.text):s.push(d);continue}if(t){const f="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(f);break}else throw new Error(f)}}return s}},Tr=class{constructor(e){Tt(this,"options");Tt(this,"parser");this.options=e||ii}space(e){return""}code({text:e,lang:t,escaped:s}){var i;const a=(i=(t||"").match(Os.notSpaceStart))==null?void 0:i[0],n=e.replace(Os.endingNewline,"")+` +`+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=c.text):s.push(o);continue}if(t){const c="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return this.state.top=!0,s}inline(t,s=[]){return this.inlineQueue.push({src:t,tokens:s}),s}inlineTokens(t,s=[]){var o,r,c;let a=t,n=null;if(this.tokens.links){const d=Object.keys(this.tokens.links);if(d.length>0)for(;(n=this.tokenizer.rules.inline.reflinkSearch.exec(a))!=null;)d.includes(n[0].slice(n[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,n.index)+"["+"a".repeat(n[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(n=this.tokenizer.rules.inline.anyPunctuation.exec(a))!=null;)a=a.slice(0,n.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(n=this.tokenizer.rules.inline.blockSkip.exec(a))!=null;)a=a.slice(0,n.index)+"["+"a".repeat(n[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,l="";for(;t;){i||(l=""),i=!1;let d;if((r=(o=this.options.extensions)==null?void 0:o.inline)!=null&&r.some(f=>(d=f.call({lexer:this},t,s))?(t=t.substring(d.raw.length),s.push(d),!0):!1))continue;if(d=this.tokenizer.escape(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.tag(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.link(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(d.raw.length);const f=s.at(-1);d.type==="text"&&(f==null?void 0:f.type)==="text"?(f.raw+=d.raw,f.text+=d.text):s.push(d);continue}if(d=this.tokenizer.emStrong(t,a,l)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.codespan(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.br(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.del(t)){t=t.substring(d.raw.length),s.push(d);continue}if(d=this.tokenizer.autolink(t)){t=t.substring(d.raw.length),s.push(d);continue}if(!this.state.inLink&&(d=this.tokenizer.url(t))){t=t.substring(d.raw.length),s.push(d);continue}let u=t;if((c=this.options.extensions)!=null&&c.startInline){let f=1/0;const m=t.slice(1);let h;this.options.extensions.startInline.forEach(g=>{h=g.call({lexer:this},m),typeof h=="number"&&h>=0&&(f=Math.min(f,h))}),f<1/0&&f>=0&&(u=t.substring(0,f+1))}if(d=this.tokenizer.inlineText(u)){t=t.substring(d.raw.length),d.raw.slice(-1)!=="_"&&(l=d.raw.slice(-1)),i=!0;const f=s.at(-1);(f==null?void 0:f.type)==="text"?(f.raw+=d.raw,f.text+=d.text):s.push(d);continue}if(t){const f="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(f);break}else throw new Error(f)}}return s}},Or=class{constructor(e){Tt(this,"options");Tt(this,"parser");this.options=e||li}space(e){return""}code({text:e,lang:t,escaped:s}){var i;const a=(i=(t||"").match(Os.notSpaceStart))==null?void 0:i[0],n=e.replace(Os.endingNewline,"")+` `;return a?'
'+(s?n:Ca(n,!0))+`
`:"
"+(s?n:Ca(n,!0))+`
`}blockquote({tokens:e}){return`
@@ -7089,9 +7124,9 @@ ${this.parser.parse(e)}
`}tablerow({text:e}){return`
{{ t.user_id }} {{ t.label || '—' }} {{ t.tier }} @@ -179,7 +191,7 @@ export default { {{ formatTs(e.timestamp) }} {{ e.tool || e.tool_name || '—' }}{{ e.user || e.user_id || '—' }}{{ !e.user_id ? (e.user || '—') : '' }} {{ e.host || '—' }} {{ e.duration ? (e.duration < 1 ? (e.duration * 1000).toFixed(0) + 'ms' : e.duration.toFixed(1) + 's') diff --git a/ui/js/pages/computer.js b/ui/js/pages/computer.js index 44339c7b5..e21947968 100644 --- a/ui/js/pages/computer.js +++ b/ui/js/pages/computer.js @@ -2,9 +2,10 @@ import { computed, onMounted, onUnmounted, onActivated, onDeactivated, ref } from 'vue'; import { api } from '../api.js'; import ComputerProvisioning from '../components/computer-provisioning.js'; +import DiscordIdentity from '../discord-identity.js'; export default { - components: { ComputerProvisioning }, + components: { ComputerProvisioning, DiscordIdentity }, template: `
-
Owner
{{ status.owner_id || '—' }}
+
Owner
—
Session
{{ status.session_id || '—' }}
Application
{{ status.app || '—' }}
Last action / verification
{{ status.last_action || '—' }} / {{ status.last_verification || 'unavailable' }}
diff --git a/ui/js/pages/config.js b/ui/js/pages/config.js index b9a0c0f5e..df233c086 100644 --- a/ui/js/pages/config.js +++ b/ui/js/pages/config.js @@ -16,7 +16,7 @@ const CATEGORY_GROUPS = [ { key: 'models', label: 'Models & AI', icon: 'brain', sections: ['image', 'llm_recovery'] }, { key: 'runtime', label: 'Runtime', icon: 'activity', sections: ['context', 'sessions', 'agents', 'turn_state'] }, { key: 'data', label: 'Data & Storage', icon: 'database', sections: ['learning', 'search', 'usage', 'audit', 'attachments'] }, - { key: 'services', label: 'Services', icon: 'link', sections: ['webhook', 'observability', 'email', 'browser', 'slack', 'mcp'] }, + { key: 'services', label: 'Services', icon: 'link', sections: ['webhook', 'observability', 'email', 'browser', 'mcp'] }, { key: 'automation', label: 'Automation', icon: 'workflow', sections: ['grafana_alerts', 'outbound_webhooks'] }, { key: 'infrastructure', label: 'Infrastructure', icon: 'server', sections: ['tools', 'web'] }, ]; diff --git a/ui/js/pages/host-access.js b/ui/js/pages/host-access.js index d238b5d1c..a6c283b05 100644 --- a/ui/js/pages/host-access.js +++ b/ui/js/pages/host-access.js @@ -1,13 +1,14 @@ import { api } from '../api.js'; import { toast } from '../toast.js'; import { confirmDialog } from '../confirm.js'; -import { computed, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; +import { computed, nextTick, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; import { createHostAccessMutationCoordinator } from '../host-access-state.js'; import { DiscordUserCombobox } from '../discord-user-combobox.js'; +import DiscordIdentity from '../discord-identity.js'; export default { - components: { DiscordUserCombobox }, + components: { DiscordUserCombobox, DiscordIdentity }, template: `
@@ -34,32 +35,16 @@ export default {

Default Policy

Applied to users without an explicit host access entry.

-
- -
-
- - -
+
{{ chip }}
+ +

Default host: {{ defaultPolicy.default_host || 'None' }}

-
+

User Overrides

+ @@ -68,7 +53,7 @@ export default {
- @@ -76,49 +61,28 @@ export default {
-
- +
+
- + - + - + - + + @@ -128,6 +92,30 @@ export default { + + +
+

Edit hosts

{{ editor === 'default' ? 'Default Policy' : (getMember(editor)?.display_name || editor) }}

+
+ + +
+ +

No matching hosts.

+
+ + +

{{ defaultHostError }}

+ +
+
+ +
+
`, @@ -139,8 +127,30 @@ export default { const hostDescriptions = ref({}); const defaultPolicy = ref({ allowed_hosts: [], default_host: '' }); const users = ref({}); + const hostOverrideIds = ref([]); const showAddUser = ref(false); const members = ref([]); + const permissions = ref({ overrides: {}, config_tiers: {}, invalid_overrides: {} }); + const validTiers = ['admin', 'user', 'guest']; + const repairTiers = ref({}); + const editor = ref(''); + const hostDialog = ref(null); + const draft = ref(null); + const saving = ref(false); + const saveError = ref(''); + const requiresDefault = ref(false); + const hostQuery = ref(''); + const userQuery = ref(''); + const filteredHosts = computed(() => availableHosts.value.filter(h => `${h} ${hostDescriptions.value[h] || ''}`.toLowerCase().includes(hostQuery.value.toLowerCase()))); + const defaultHostOptions = computed(() => availableHosts.value.filter(h => draft.value?.allow_all || draft.value?.allowed_hosts.includes(h))); + const defaultHostError = computed(() => { + if (!draft.value) return ''; + if (draft.value.default_host && !defaultHostOptions.value.includes(draft.value.default_host)) return defaultHostOptions.value.length ? 'Choose a new default from the allowed hosts before saving.' : 'No hosts are allowed. Select None for the default before saving.'; + if (requiresDefault.value && defaultHostOptions.value.length && !draft.value.default_host) return 'Choose a new default from the allowed hosts before saving.'; + return ''; + }); + const allUserIds = computed(() => [...new Set([...Object.keys(users.value), ...Object.keys(permissions.value.overrides || {}), ...Object.keys(permissions.value.config_tiers || {}), ...Object.keys(permissions.value.invalid_overrides || {})])]); + const visibleUserIds = computed(() => allUserIds.value.filter(uid => `${getMember(uid)?.display_name || ''} ${getMember(uid)?.username || ''} ${uid}`.toLowerCase().includes(userQuery.value.toLowerCase()))); const membersById = computed(() => { const map = {}; @@ -152,6 +162,91 @@ export default { return membersById.value[uid] || null; } + function hostSummary(entry) { + if (!entry || entry.allow_all) return 'All hosts'; + const hosts = entry.allowed_hosts || []; + return hosts.length > 3 ? `${hosts.slice(0, 3).join(', ')} +${hosts.length - 3}` : hosts.join(', ') || 'No hosts'; + } + function hostChips(entry) { + if (entry.allow_all) return ['All hosts']; + const hosts = entry.allowed_hosts || []; + return [...hosts.slice(0, 2), ...(hosts.length > 2 ? [`+${hosts.length - 2}`] : []), ...(hosts.length ? [] : ['None'])]; + } + function visibleHostChips(uid) { + if (!hostOverrideIds.value.includes(uid)) return ['Inherit policy']; + return hostChips(users.value[uid]); + } + async function openEditor(key) { + if (saving.value) return; + const entry = key === 'default' ? defaultPolicy.value : (users.value[key] || defaultPolicy.value); + editor.value = key; + draft.value = { ...entry, allowed_hosts: [...entry.allowed_hosts] }; + requiresDefault.value = !!entry.default_host; + hostQuery.value = ''; + saveError.value = ''; + await nextTick(); + // Native top-layer modal supplies background inertness, viewport placement + // and return-to-opener focus. Tab wrapping also excludes browser chrome. + if (draft.value && !hostDialog.value?.open) hostDialog.value?.showModal(); + } + function closeEditor() { + if (saving.value) return; + hostDialog.value?.close(); + draft.value = null; + editor.value = ''; + } + function trapEditorFocus(event) { + if (event.key !== 'Tab') return; + const controls = [...hostDialog.value.querySelectorAll('button, input, select')].filter(el => !el.matches(':disabled')); + const first = controls[0], last = controls.at(-1); + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + } + async function saveEditor() { + if (!draft.value || saving.value || defaultHostError.value) return; + const key = editor.value; + const attempted = { ...draft.value, allowed_hosts: [...draft.value.allowed_hosts] }; + saving.value = true; + saveError.value = ''; + await nextTick(); + if (hostDialog.value?.open) hostDialog.value.focus(); + try { + if (key === 'default') { + defaultPolicy.value = attempted; + await coordinator.saveDefault(attempted); + } else { + users.value[key] = attempted; + if (!hostOverrideIds.value.includes(key)) hostOverrideIds.value.push(key); + await coordinator.saveUser(key, attempted); + } + } finally { + saving.value = false; + } + if (!saveError.value) closeEditor(); + } + function tierValue(uid) { return permissions.value.overrides?.[uid] || permissions.value.config_tiers?.[uid] || 'default'; } + async function setTier(uid, tier) { + try { + if (tier === 'default') { + if (!permissions.value.overrides?.[uid]) return; + await api.del(`/api/permissions/user/${encodeURIComponent(uid)}`); + } + else await api.put(`/api/permissions/user/${encodeURIComponent(uid)}`, { tier }); + if (tier === 'default') delete permissions.value.overrides[uid]; + else permissions.value.overrides[uid] = tier; + toast.success('Permission tier updated'); + } catch (e) { toast.error(e.message || 'Failed to update tier'); await fetchData(); } + } + async function repairTier(uid) { + const tier = repairTiers.value[uid]; if (!tier) return; + try { + await api.post(`/api/permissions/user/${encodeURIComponent(uid)}/repair`, { tier }); + delete permissions.value.invalid_overrides[uid]; + permissions.value.overrides[uid] = tier; + } catch (e) { toast.error(e.message || 'Failed to repair tier'); } + } + function normalizeEntry(entry, hosts) { if (!entry) return { allowed_hosts: [...hosts], default_host: hosts[0] || '', allow_all: true }; @@ -194,13 +289,16 @@ export default { if (confirmed) next[uid] = confirmed; else delete next[uid]; users.value = next; + if (!confirmed) hostOverrideIds.value = hostOverrideIds.value.filter(id => id !== uid); }, onUserDeleted: (uid) => { const next = { ...users.value }; delete next[uid]; users.value = next; + hostOverrideIds.value = hostOverrideIds.value.filter(id => id !== uid); }, onError: (e, context) => { + if (saving.value) saveError.value = e.message || 'Failed to save. Your draft is retained; try again.'; const suffix = context.uid ? ` ${getMember(context.uid)?.display_name || context.uid}` : ''; toast.error(`${e.message || 'Failed to save'} — reverted${suffix}`); }, @@ -221,12 +319,14 @@ export default { hostDescriptions.value = hostResp.host_descriptions || {}; defaultPolicy.value = normalizeEntry(hostResp.default_policy, availableHosts.value); const rawUsers = hostResp.users || {}; + hostOverrideIds.value = Object.keys(rawUsers); const normalized = {}; for (const [uid, entry] of Object.entries(rawUsers)) { normalized[uid] = normalizeEntry(entry, availableHosts.value); } users.value = normalized; coordinator.seed(defaultPolicy.value, normalized); + permissions.value = await api.get('/api/permissions/tiers'); } catch (e) { if (generation === fetchGeneration) { error.value = e.message || 'Failed to fetch host access data'; @@ -242,94 +342,6 @@ export default { } } - // Select-driven saves used to fire one PUT (and one toast) per arrow - // keypress (audit 6.3). Saves are debounced per key and read the LATEST - // draft at fire time, so scrubbing through options commits one final - // state. The coordinator below still owns serialization and rollback. - const SAVE_DEBOUNCE_MS = 500; - const pendingSaves = new Map(); // key -> { timer, run } - - function scheduleSave(key, run) { - const prior = pendingSaves.get(key); - if (prior) clearTimeout(prior.timer); - const entry = { run, timer: null }; - entry.timer = setTimeout(() => { - pendingSaves.delete(key); - run(); - }, SAVE_DEBOUNCE_MS); - pendingSaves.set(key, entry); - } - - function cancelPendingSave(key) { - const prior = pendingSaves.get(key); - if (prior) { - clearTimeout(prior.timer); - pendingSaves.delete(key); - } - } - - // Flush, never cancel: a user's last edit must not silently vanish when - // they navigate away before the quiet window elapses. - function flushPendingSaves() { - for (const [key, entry] of [...pendingSaves]) { - clearTimeout(entry.timer); - pendingSaves.delete(key); - entry.run(); - } - } - - function saveDefaultPolicy() { - // No parameters: this is bound directly as a @change handler. Passing a - // snapshot here receives the DOM Event because v-model has already run. - scheduleSave('default', () => coordinator.saveDefault(defaultPolicy.value)); - } - - function toggleDefaultHost(host, checked) { - defaultPolicy.value.allow_all = false; - if (checked) { - if (!defaultPolicy.value.allowed_hosts.includes(host)) - defaultPolicy.value.allowed_hosts.push(host); - } else { - defaultPolicy.value.allowed_hosts = defaultPolicy.value.allowed_hosts.filter(h => h !== host); - if (defaultPolicy.value.default_host === host) - defaultPolicy.value.default_host = defaultPolicy.value.allowed_hosts[0] || ''; - } - saveDefaultPolicy(); - } - - function saveUser(uid) { - // The entry is re-read at fire time: coalesced edits send ONE snapshot - // carrying every change, and a user deleted during the quiet window - // resolves to nothing rather than a resurrecting PUT. - scheduleSave(`user:${uid}`, () => { - const entry = users.value[uid]; - if (!entry) return; - coordinator.saveUser(uid, entry); - }); - } - - function toggleUserHost(uid, host, checked) { - const entry = users.value[uid]; - if (!entry) return; - entry.allow_all = false; - if (checked) { - if (!entry.allowed_hosts.includes(host)) - entry.allowed_hosts.push(host); - } else { - entry.allowed_hosts = entry.allowed_hosts.filter(h => h !== host); - if (entry.default_host === host) - entry.default_host = entry.allowed_hosts[0] || ''; - } - saveUser(uid); - } - - function setUserDefault(uid, host) { - const entry = users.value[uid]; - if (!entry) return; - entry.default_host = host; - saveUser(uid); - } - function openAddUser() { showAddUser.value = true; } @@ -341,8 +353,8 @@ export default { default_host: availableHosts.value[0] || '', allow_all: false, }; - // A deliberate button action commits immediately — only per-keypress - // control changes ride the debounce. + if (!hostOverrideIds.value.includes(uid)) hostOverrideIds.value.push(uid); + // Deliberate button actions commit through the same serialized queue. coordinator.saveUser(uid, users.value[uid]); showAddUser.value = false; } @@ -356,9 +368,6 @@ export default { danger: true, }); if (!ok) return; - // A save still sitting in its quiet window must not fire after the - // delete and resurrect the entry. - cancelPendingSave(`user:${uid}`); await coordinator.deleteUser(uid); // The coordinator publishes only after the server confirms deletion and // participates in the same ordering protocol as in-flight PUTs. @@ -368,17 +377,20 @@ export default { } onMounted(fetchData); - // The tab host keeps this component alive; pending edits are committed, - // not dropped, when the operator switches away. - onDeactivated(flushPendingSaves); - onUnmounted(flushPendingSaves); + // Unsaved modal drafts never become policy on navigation. Close even during + // an already submitted save so a kept-alive tab cannot strand an inert page. + function dismissEditor() { hostDialog.value?.close(); draft.value = null; editor.value = ''; } + onDeactivated(dismissEditor); + onUnmounted(dismissEditor); return { loading, error, data, availableHosts, hostDescriptions, defaultPolicy, users, showAddUser, members, - fetchData, saveDefaultPolicy, toggleDefaultHost, getMember, - toggleUserHost, setUserDefault, openAddUser, addUserById, deleteUser, - flushPendingSaves, + hostOverrideIds, + fetchData, getMember, openAddUser, addUserById, deleteUser, + permissions, validTiers, repairTiers, repairTier, tierValue, setTier, + editor, hostQuery, userQuery, filteredHosts, allUserIds, visibleUserIds, hostSummary, hostChips, visibleHostChips, + hostDialog, draft, saving, saveError, requiresDefault, defaultHostOptions, defaultHostError, openEditor, closeEditor, saveEditor, trapEditorFocus, }; }, }; diff --git a/ui/js/pages/learned.js b/ui/js/pages/learned.js index 4566bdc81..4294ee0bf 100644 --- a/ui/js/pages/learned.js +++ b/ui/js/pages/learned.js @@ -6,9 +6,11 @@ import { toast } from '../toast.js'; import { confirmDialog } from '../confirm.js'; import { formatTs } from '../utils.js'; import { computed, onMounted, ref } from 'vue'; +import { DiscordIdentity } from '../discord-identity.js'; export default { + components: { DiscordIdentity }, template: `
@@ -74,7 +76,7 @@ export default { {{ entry.key }} {{ entry.category }} - user: {{ entry.user_id }} + user:
diff --git a/ui/js/pages/llm-config.js b/ui/js/pages/llm-config.js index 03df4a28d..1987386a3 100644 --- a/ui/js/pages/llm-config.js +++ b/ui/js/pages/llm-config.js @@ -1,6 +1,7 @@ import { api } from '../api.js'; import { toast } from '../toast.js'; import { confirmDialog } from '../confirm.js'; +import { quotaBlocks, quotaFailureVisible } from '../codex-quota.js'; import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; import { codexAdvancedPayload, codexBasicPayload, @@ -485,6 +486,7 @@ export default {
+ @@ -514,6 +516,28 @@ export default { — +
User -
{{ host }}
{{ hostDescriptions[host] }}
-
TierHosts Default HostActionsRemove
-
- -
- {{ (getMember(uid)?.display_name || '?').charAt(0) }} -
- {{ getMember(uid)?.display_name || uid }} - {{ getMember(uid).username }} - BOT -
-
- +
Invalid tier: {{ permissions.invalid_overrides[uid] }}
{{ chip }}
- + {{ (users[uid] || defaultPolicy).default_host || 'None' }} - +
Label Email PlanQuota Status Active Actions +
+
+
+ {{ block.label }} + {{ block.remaining }}% remaining +
+
+
+
+ + {{ block.statusLabel ? block.statusLabel + ' · ' : '' }}Resets {{ block.resetLabel }} + +
+
+ Quota check failed + — + + checked {{ quotaAge(a.quota.observed_at) }} ago + +
Error Expired @@ -1492,6 +1516,18 @@ export default { return Number.isNaN(date.getTime()) ? 'unknown' : date.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); } + function formatQuotaDate(value) { + const date = new Date(Number(value) * 1000); + return Number.isNaN(date.getTime()) ? 'unknown' : date.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); + } + + function quotaAge(value) { + const observed = Number(value); + if (!Number.isFinite(observed) || observed <= 0) return 'unknown'; + const minutes = Math.max(0, Math.floor((Date.now() / 1000 - observed) / 60)); + return minutes < 1 ? '<1 min' : `${minutes} min`; + } + function shortAccountKey(value) { return typeof value === 'string' && value.length > 12 ? value.slice(0, 8) + '…' + value.slice(-4) : value; } @@ -2149,6 +2185,7 @@ export default { startDeviceLogin, cancelDeviceLogin, formatSize, fetchContextWindows, clearContextClamp, setContextOverride, setContextUtilization, resetContextOverride, overrideAboveFloor, formatCount, formatContextCeiling, formatExpiry, shortAccountKey, provenanceClass, formatDensity, + quotaBlocks, quotaFailureVisible, quotaAge, formatQuotaDate, }; }, }; diff --git a/ui/js/pages/logs.js b/ui/js/pages/logs.js index 4535b9d86..56fcde4c2 100644 --- a/ui/js/pages/logs.js +++ b/ui/js/pages/logs.js @@ -8,6 +8,7 @@ import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, import ToolOutput from '../tool-output.js'; import LogRecord from '../log-record.js'; import { appendLogEntry, groupLogEntries, parseLogEntry, serializeLogRecord } from '../log-records.js'; +import { DiscordIdentity } from '../discord-identity.js'; const LOG_LEVELS = ['INFO', 'WARNING', 'ERROR']; @@ -33,7 +34,7 @@ const TIME_RANGES = [ const SEARCH_LIMITS = [50, 100, 200, 500]; export default { - components: { ToolOutput, LogRecord }, + components: { ToolOutput, LogRecord, DiscordIdentity }, template: `
@@ -369,7 +370,7 @@ export default { {{ entry.timestamp || 'N/A' }}